Repository navigation
fix(switch): decide branch tracking for --create, whatever the git config says - #3950
Conversation
…nfig says `wt switch --create <name> --base origin/<branch>` must not leave the new branch tracking a differently-named base: under `push.default = upstream` a bare `git push` would then push the new work onto the base (#713). It got that from `-c branch.autoSetupMerge=simple`, injected only when the user had not set the key — so `true` and `always` still reached the footgun, and `false` and `inherit` denied a same-named branch the tracking that is the point of it. Force the `-c` instead. It outranks every config file, so `--create` produces the same tracking on every machine. Explicit `--track` / `--no-track` chosen from a name comparison was the other candidate, and it is wrong twice over. `strip_remote_prefix` splits `<remote>/<branch>` at the first slash, but git maps a remote-tracking ref back to its branch through the fetch refspec: with a remote named `team/fork`, `--create fork/release --base team/fork/release` got `merge = refs/heads/release`, reintroducing #713, while `--create release` from the same base got no upstream at all. And `--track` is a hard demand where `simple` is best-effort — in a single-branch clone holding a hand-fetched ref it failed the whole command with "starting point 'origin/release' is not a branch", after the branch name was taken. Both are now regression tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NBxMUZo7awheuY85PNXCjE
worktrunk-bot
left a comment
There was a problem hiding this comment.
The -c approach holds up, and the refspec argument against --track/--no-track is right — strip_remote_prefix genuinely can't reproduce git's refspec mapping, and the two regression tests pin that so a later "simplification" fails the suite. Two observations, neither blocking.
The forced -c isn't scoped to remote bases, but the documentation is. if *create_branch fires on every --create, local base included, so branch.autoSetupMerge = always and inherit change behaviour there too: wt switch --create feature-2 --base some-local-branch previously tracked the local base under always, or copied its upstream under inherit, and now gets neither. The inherit case looks like a genuine extra win — inheriting origin/feature onto feature-2 is the same #713 shape, a bare push under push.default = upstream landing on feature — but the help text opens with "--create from a remote base is the exception" and never mentions a local base, so a user who set always deliberately has no way to learn from --help why their new branch stopped tracking. A clause covering it would make after_long_help describe what the code now does.
--create and the DWIM path now disagree under false. The comment justifies excluding DWIM because "the names match and simple and true agree" — true of true, but not of false or inherit, which are exactly the values this PR otherwise stopped trusting. wt switch feature where only origin/feature exists runs git worktree add -b feature -- <path> origin/feature (the single-remote branch a few lines below), so under branch.autoSetupMerge = false it produces a branch with no upstream, while wt switch --create feature --base origin/feature now always sets one. Those are the same logical operation, and the sentence immediately above the changed paragraph — "Switching to a remote branch (e.g., wt switch feature when only origin/feature exists) creates a local tracking branch" — states it unconditionally. Forcing the same -c on that path, or saying why it's left to config, would close it.
One inline nit on the test setup comment.
Review feedback on #3950: the forced `-c` was scoped to `--create` while the help text described one rule, and the DWIM path was left on the user's `branch.autoSetupMerge`. Under `false` or `inherit`, `wt switch feature` from `origin/feature` then produced an untracked branch, though the docs promise a tracking one — and the same values `--create` had just stopped trusting. Drop the condition. One rule now decides every upstream `wt switch` sets: a new branch tracks the remote branch it starts from only when the two share a name. DWIM always shares it; `--create` from a differently-named base does not. The help text says that once instead of naming `--create` an exception. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NBxMUZo7awheuY85PNXCjE
worktrunk-bot
left a comment
There was a problem hiding this comment.
The -c forcing is the right shape, and I checked the git semantics it rests on against git 2.55 in scratch clones rather than taking the PR body's word for it: simple tracks a same-named base and declines a differently-named one identically whatever branch.autoSetupMerge says, and it declines a base the fetch refspec doesn't map instead of failing the command — so the --track alternative really is worse, and the two regression tests are pinning something real. The earlier observations are both addressed.
Four non-blocking suggestions, all comment/test polish. The substantive one: the two new comments both assume git worktree add can't resolve a bare remote-only branch name to its remote-tracking ref. It can — that resolution is worktree add's own rather than rev-parse's, which is why git rev-parse --verify release^{commit} fails on a remote-only release (making resolve_base_ref's ref_exists branch fire) while git -c branch.autoSetupMerge=simple worktree add -b release -- <path> release prints branch 'release' set up to track 'origin/release'. It bites on the base_pr_upstream comment, which describes the one path that reaches worktree add with a bare name: wt switch --create <PR's own head name> --base pr:N does get tracking from simple, and the explicit set_config then rewrites the same two values. The outcome is unchanged either way — the explicit config is still load-bearing for the differing-name case, which is what --base pr:N exists for — but the mechanism as written isn't what happens.
Verification transcript (git 2.55)
$ # remote-only `release`, no local branch
$ git for-each-ref --format='%(refname)' refs/remotes
refs/remotes/origin/main
refs/remotes/origin/release
$ git rev-parse --verify --end-of-options 'release^{commit}'
fatal: Needed a single revision # -> ref_exists() == false, so resolve_base_ref qualifies
$ git -c branch.autoSetupMerge=simple worktree add -b release -- /tmp/w release
Preparing worktree (new branch 'release')
branch 'release' set up to track 'origin/release'. # bare name, still read as a remote branch
$ git -c branch.autoSetupMerge=simple worktree add -b mywork -- /tmp/w1 release
$ git config branch.mywork.remote # differing name: nothing, as intendedAnd the refspec case the new regression test covers, confirming simple declines rather than fails:
$ git config remote.origin.fetch '+refs/heads/main:refs/remotes/origin/main'
$ git fetch origin refs/heads/release:refs/remotes/origin/release
$ git -c branch.autoSetupMerge=simple worktree add -b release -- /tmp/w origin/release
Preparing worktree (new branch 'release')
HEAD is now at 134b220 init # no "set up to track" line
$ git rev-parse --abbrev-ref 'release@{upstream}'
fatal: no upstream configured for branch 'release'…t simple declines Review polish on #3950. Both docstrings asserted that a bare remote-only branch name is invisible to `git worktree add`. It isn't: `worktree add` DWIMs the bare form even where `rev-parse` won't resolve it — with `-b` given, git's DWIM discards it and creates the same-named branch instead. So the remote qualification `resolve_base_ref` applies is load-bearing for `resolve_switch_target`'s validation, not for what the creation can see, and `base_pr_upstream` exists because the PR's source branch may carry any name, not because the name is unreadable. Also assert what `test_switch_create_base_outside_fetch_refspec` claims — that `simple` declines the tracking rather than guessing — and scope the DWIM matrix's docstring to the values it enumerates. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NBxMUZo7awheuY85PNXCjE
worktrunk-bot
left a comment
There was a problem hiding this comment.
One correction on the docstring rewrite — non-blocking, and partly my own to make.
resolve_base_ref's new conclusion inverts the one the commit message reached. The docstring now ends "so the qualification is load-bearing for the validation, not for the creation." It is load-bearing for both, and more sharply for the creation: git worktree add -b <name> -- <path> <bare-remote-only-branch> doesn't merely resolve the bare form, it discards -b and creates the branch under the remote branch's own name. The commit message has this right ("with -b given, git's DWIM discards it and creates the same-named branch instead"); the docstring keeps only the half that reads as "the qualification is redundant for creation", which is the sentence that would license removing it.
test_switch_create_with_remote_only_base is what that removal would break — and not by failing to create a branch, but by creating one under the wrong name: wt switch --create new-wt --base releases/4.x.x would leave you on releases/4.x.x, not new-wt. Suggestion inline.
My earlier transcript was wrong, which is how the premise got here. The approval on b46f12c claimed worktree add -b mywork -- <path> release gave "differing name: nothing, as intended". That run had created a local release one command earlier in the same clone, so the bare name resolved to that local branch and the DWIM never fired. In a clean clone it comes out the other way. Flagging it because that transcript is what this commit's premise was checked against.
The base_pr_upstream paragraph reads correctly once #3951's <remote>/<branch> fallback lands, so nothing to add there.
Verification transcript (git 2.55, clean clone each time)
The distinguishing detail is that the local branch must not already exist — that is what my earlier transcript got wrong.
$ git branch --list # remote-only `releases/4.x.x`, nothing local but main
* main
$ git -c branch.autoSetupMerge=simple worktree add -b new-wt -- /tmp/o releases/4.x.x
Preparing worktree (new branch 'releases/4.x.x')
branch 'releases/4.x.x' set up to track 'origin/releases/4.x.x'.
HEAD is now at be0590c i
$ git branch --list # `-b new-wt` silently discarded
* main
+ releases/4.x.xThe qualified spelling keeps -b:
$ git -c branch.autoSetupMerge=simple worktree add -b mywork2 -- /tmp/o2 origin/release
Preparing worktree (new branch 'mywork2')
$ git rev-parse --abbrev-ref 'mywork2@{upstream}'
fatal: no upstream configured for branch 'mywork2'And wt on this head does the right thing, because resolve_base_ref qualifies first:
$ wt switch --create my-feature --base release --no-cd
✓ Created branch my-feature from origin/release and worktree @ …/work.my-feature
$ git branch --list
* main
+ my-featureThe previous commit inverted it. `git worktree add -b <name> -- <path> <bare-remote-only-branch>` doesn't merely resolve the bare name — it drops the `-b` and creates the remote branch's own name instead. So qualifying in `resolve_base_ref` is what keeps `--create new-wt --base remote-only-branch` on `new-wt`, which matters more for the creation than for the validation it also serves. `test_switch_create_with_remote_only_base` pins it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NBxMUZo7awheuY85PNXCjE
|
Right on both counts, and thanks for re-running the check — fixed in 62aa46d. The docstring now says what the commit message did: |
|
Confirmed on |
worktrunk 0.76.0 Created-by: HarmonybrewBot Commit-by: HarmonybrewBot Merged-by: HarmonybrewBot Description: Created by `brew bump` --- Created with `brew bump-formula-pr`.<details> <summary>release notes</summary> <pre>## Release Notes ### Improved - **`wt switch --execute` takes a program, not a shell string**: `-x` names one program, with everything after `--` passed as literal argv. Worktrunk spawns it as a child rather than running it in your interactive shell, so shell functions and its `cd` no longer reach you. `-x sh -- -c '…'` recovers shell syntax, not functions. (Breaking: existing `-x` strings, plus `WORKTRUNK_DIRECTIVE_EXEC_FILE` and `WORKTRUNK_SHELL`.) ([#3977](max-sixty/worktrunk#3977), closes [#2860](max-sixty/worktrunk#2860), fixes [#3944](max-sixty/worktrunk#3944), thanks @omgreenfield for testing the migration path) - **Worktrunk decides tracking for the branches it creates**: a new branch gets an upstream only when its name matches the remote branch it starts from, whatever `branch.autoSetupMerge` says: `--create release --base origin/release` tracks, `--create feature --base origin/release` does not. Under `autoSetupMerge = false` this previously exited 128 after creating the worktree. ([#3913](max-sixty/worktrunk#3913), [#3950](max-sixty/worktrunk#3950), fixes [#3937](max-sixty/worktrunk#3937), thanks @mjakl for reporting and diagnosing) - **Retired config keys are no longer read**: `commit.generation.template-file` and `squash-template-file` are gone — put the file's contents in `template` or `squash-template`. There is no migration: the key stays, warns as unknown on every load, and the prompt reverts to the built-in default. Setting both `template` and `template-file` used to fail the load; it now loads `template`. `switch.picker.timeout-ms` likewise warns rather than being stripped. (Breaking.) ([#3949](max-sixty/worktrunk#3949)) - **Project aliases and hooks can use `wt switch --execute`**: their bodies previously refused it. Worktrunk now starts the program itself instead of handing shell text to your shell, and the command-approval gate remains the control on project-defined commands. ([#3977](max-sixty/worktrunk#3977)) - **Established automation and LLM customization interfaces are now stable**: `wt step eval`, `wt step for-each`, `wt step prune`, LLM branch summaries, `wt config state vars`, and `commit.generation.template-append` are no longer marked experimental. ([#3949](max-sixty/worktrunk#3949)) - **`wt step prune` stages removals concurrently again**: only the `git worktree remove` teardowns serialize, so dirty checks, fsmonitor shutdown, and the trash rename overlap. The `prune_e2e/live` benchmark median went from 377 ms to 222 ms. ([#3954](max-sixty/worktrunk#3954)) - **`wt config approvals add` reads as a review rather than a warning**: it opens with a cyan `Review 1 command for repo:` instead of a yellow `▲ … needs approval to execute`. The execution-time gate is unchanged. ([#3953](max-sixty/worktrunk#3953)) ### Fixed - **The `wt switch` picker survives a preview longer than 65,535 lines**: skim keeps the pane's line count in a `u16`, so a larger diff aborted `wt` with exit 101 seconds after it painted — on screen the picker looked like it closed by itself. Panes now cap at 60,000 lines and say so. ([#3959](max-sixty/worktrunk#3959), fixes [#3958](max-sixty/worktrunk#3958), thanks @sandertammesoo for reporting and diagnosing) - **The nushell wrapper propagates exit codes without a POSIX shell**: it reported a failing `wt` by spawning `sh`, so on Windows every failing command printed a ``Command `sh` not found`` trace and exit 1 whatever the real code was. Nushell's wrapper is a static file, so rerun `wt config shell install` to pick this up. ([#3945](max-sixty/worktrunk#3945)) - **The picker no longer shows one detached worktree's diff for another**: previews were keyed by branch name, so every detached row shared the key `(detached)`. Rows now key by canonical identity, which also lets `wt remove feature ~/repo.feature` plan that worktree once instead of failing on the second. ([#3926](max-sixty/worktrunk#3926)) - **`wt switch pr:<n>` gives `pre-switch` hooks the real branch**: `branch` and `target` held the literal `pr:3933`, and `target_worktree_path` was unset even when that branch had a worktree. `pr_number` and `pr_url` are now set for same-repo PRs, not only forks. ([#3941](max-sixty/worktrunk#3941), fixes [#3934](max-sixty/worktrunk#3934), thanks @robsonpeixoto for reporting) - **`wt config shell install` no longer replaces an rc file created while it was checking**: the missing-file path refuses to clobber and fails with a rerun hint. A dangling rc symlink is preserved and rejected rather than overwritten. ([#3929](max-sixty/worktrunk#3929)) - **`wt switch --create X --base pr:<n>` works when the PR branch has no local branch**: the fetch writes only the remote-tracking ref, and git won't expand a bare name to one, so the command failed with `No branch, tag, or commit named …`. The base is now named under its remote. ([#3951](max-sixty/worktrunk#3951)) - **`wt switch --create` streams `git worktree add` instead of stalling**: where a sandbox denied the timed wait's pipe or `sigaction`, the command waited out git's entire run and then reported `Failed to wait for command` for work that had already succeeded. ([#3881](max-sixty/worktrunk#3881)) - **Claude Code activity markers stay on the session's launch worktree**: the plugin's marker hooks pass `-C "$CLAUDE_PROJECT_DIR"`, so a `cd` mid-session no longer marks another repository. A session launched outside a repository gets no marker. ([#3956](max-sixty/worktrunk#3956)) ### Documentation - **`--execute` documents how to get a variable into a shell body**: each position is substituted verbatim, so a variable spliced into `sh -c` text is re-parsed by that shell and splits on spaces. The help now shows passing it as a separate argument and referencing it positionally. ([#3977](max-sixty/worktrunk#3977)) - **The clone-local default-branch override is documented**: `wt config state default-branch set` writes `worktrunk.default-branch` to the clone's local git config — machine-local, never committed, and shared by every linked worktree. `set` warns when the branch isn't checked out locally, and `wt list` skips its comparisons until it is. ([#3947](max-sixty/worktrunk#3947), [#3948](max-sixty/worktrunk#3948), fixes [#3946](max-sixty/worktrunk#3946), thanks @danielo515 for the request) - **The docs site renders code with the CLI's own colors**: terminal examples reproduce each snapshot's exact ANSI roles, while command references and the homepage command comparison get clap and shell syntax color from paired light/dark themes. File excerpts gain a path tab, and code wraps instead of scrolling on mobile. ([#3936](max-sixty/worktrunk#3936), [#3938](max-sixty/worktrunk#3938), [#3939](max-sixty/worktrunk#3939), [#3942](max-sixty/worktrunk#3942)) - **The `pre-start` upstream example uses `--base`**: it showed `wt switch --create feature origin/feature`, which `wt switch` rejects. ([#3955](max-sixty/worktrunk#3955)) ### Internal - **Library API rework** (Breaking library API): `cargo-semver-checks` fails ten lints — `RemoteRefProvider` and its provider structs give way to `ForgeKind` dispatch, the `--execute` rework retires the fish and PowerShell `ShellEscapeMode` escapes and `WORKTRUNK_SHELL_ENV_VAR`, `BranchRef` swaps `full_ref`/`worktree_path` for `id`, and accessors including `Branch::unset_upstream` and `ProjectConfig::ci_platform` are gone. ([#3913](max-sixty/worktrunk#3913), [#3926](max-sixty/worktrunk#3926), [#3949](max-sixty/worktrunk#3949), [#3963](max-sixty/worktrunk#3963), [#3977](max-sixty/worktrunk#3977)) - **Hook pipelines and `wt list` collection lost two indirection layers**: the background pipeline carries `PreparedStep` directly, and 15 marker task types collapse into one exhaustive dispatch. The synthesized-`--force` submodule path keeps its dirty re-check and destructive command under one lock, so prune's narrower locking doesn't widen that window. ([#3964](max-sixty/worktrunk#3964), [#3954](max-sixty/worktrunk#3954)) ## Install worktrunk 0.76.0 ### Install prebuilt binaries via shell script ```sh curl --proto '=https' --tlsv1.2 -LsSf https://github.com/max-sixty/worktrunk/releases/download/v0.76.0/worktrunk-installer.sh | sh && wt config shell install ``` ### Install prebuilt binaries via powershell script ```sh powershell -ExecutionPolicy Bypass -c "irm https://github.com/max-sixty/worktrunk/releases/download/v0.76.0/worktrunk-installer.ps1 | iex"; git-wt config shell install ``` ### Install prebuilt binaries via Homebrew ```sh brew install worktrunk && wt config shell install ``` ## Download worktrunk 0.76.0 | File | Platform | Checksum | |--------|----------|----------| | [worktrunk-aarch64-apple-darwin.tar.xz](https://github.com/max-sixty/worktrunk/releases/download/v0.76.0/worktrunk-aarch64-apple-darwin.tar.xz) | Apple Silicon macOS | [checksum](https://github.com/max-sixty/worktrunk/releases/download/v0.76.0/worktrunk-aarch64-apple-darwin.tar.xz.sha256) | | [worktrunk-x86_64-ap See merge request: Harmonybrew/homebrew-core!18482
Problem
wt switch --create <name> --base origin/<branch>must not leave the new branch tracking a differently-named base: underpush.default = upstreama baregit pushthen pushes the new work onto the base branch (#713).Since #3913 that came from
-c branch.autoSetupMerge=simple, injected only when the user had not set the key. So the outcome still depended on the user's git config, and two of git's five values reach a wrong answer:trueandalwaystrack a differently-named base (the #713 footgun is still live), whilefalseandinheritdeny a same-named branch the tracking that is the point of it — including the DWIMwt switch featurefromorigin/feature, which the docs promise is a tracking branch.Solution
Force the
-cinstead of defaulting it, on everygit worktree addwt switchruns.-coutranks every config file, so one rule now decides the upstream whatever the user has configured: a new branch tracks the remote branch it starts from only when the two share a name. DWIM always shares it, so it always tracks;--createfrom a differently-named base gets no upstream.Tests:
test_switch_create_from_remote_base_upstreambecomes a matrix over all sixbranch.autoSetupMergevalues (unset, simple, false, inherit, true, always) × three base spellings (origin/release, the barereleasethat resolves to it, andrefs/remotes/origin/staging);test_switch_dwim_from_remote_trackspins the DWIM half over the values that used to decline.Why not
--track/--no-trackThe obvious alternative is for
wtto pick git's explicit flags from a name comparison of its own. I built that first; it passed the full suite, and it is wrong twice over.Three reproduced defects in the explicit-flags version
The predicate is wrong.
strip_remote_prefixsplits<remote>/<branch>at the first slash, but git maps a remote-tracking ref back to its branch through the fetch refspec. With a remote namedteam/fork, the two disagree and the verdict inverts both ways:A bare
git pushunderpush.default = upstreamthen lands onrelease— #713, reintroduced. The same base with--create releasegot no upstream, the opposite error. A refspec renaming into a sub-namespace (+refs/heads/*:refs/remotes/origin/mirror/*) does the same with no unusual remote name.--trackis a hard demand wheresimpleis best-effort. In a single-branch clone holding a hand-fetched ref, it fails the whole command — after the branch name has already been taken:That is the matching-name case, i.e. exactly what the feature exists for, and it is the same "fails outright" class #3913 had just removed.
Qualified spellings lose tracking.
--base refs/remotes/origin/releaseand--base remotes/origin/releasename the same ref asorigin/releasebut don't matchshort_name, so they got--no-track.All three come from
wtcomputing the name match itself, which it cannot do correctly — only git knows the refspec mapping. Sowtdecides the rule and git applies it. The first two are now regression tests (test_switch_create_base_on_remote_with_slash,test_switch_create_base_outside_fetch_refspec), so a future simplification to--trackfails the suite rather than shipping.Verification
Measured end-to-end on scratch clones across all six
branch.autoSetupMergevalues: a differing name gets no upstream and a matching name tracks, identically in every one.