From d73c9492a65ea7e20632c3fcdf942594fa5e7656 Mon Sep 17 00:00:00 2001 From: Maximilian Roos Date: Fri, 28 Aug 2026 16:04:16 -0700 Subject: [PATCH 1/4] fix(switch): decide branch tracking for --create, whatever the git config says MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `wt switch --create --base origin/` 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 `/` 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) Claude-Session: https://claude.ai/code/session_01NBxMUZo7awheuY85PNXCjE --- docs/src/content/docs/switch.md | 2 +- .../skills/worktrunk/reference/switch.md | 2 +- skills/worktrunk/reference/switch.md | 2 +- src/cli/mod.rs | 2 +- src/commands/worktree/switch.rs | 42 +++-- src/commands/worktree/types.rs | 10 +- tests/integration_tests/switch.rs | 176 ++++++++++++++---- ...gration_tests__help__help_switch_long.snap | 4 +- 8 files changed, 183 insertions(+), 57 deletions(-) diff --git a/docs/src/content/docs/switch.md b/docs/src/content/docs/switch.md index 0fed3f429..101608de0 100644 --- a/docs/src/content/docs/switch.md +++ b/docs/src/content/docs/switch.md @@ -32,7 +32,7 @@ $ wt switch https://github.com/owner/repo/pull/123 # ...or paste the PR's URL The `--create` flag creates a new branch from `--base` — the default branch unless specified. Without `--create`, the branch must already exist. Switching to a remote branch (e.g., `wt switch feature` when only `origin/feature` exists) creates a local tracking branch. -`--create` from a remote base is the exception. For the branch it creates, `wt` defaults git's `branch.autoSetupMerge` to `simple` instead of git's own `true`, so the new branch tracks its base only when the two share a name: `--create release --base origin/release` tracks `origin/release`, while `--create feature --base origin/release` — and the bare `--base release` that resolves to it — gets no upstream. Git's default would have `feature` track `origin/release`, so under `push.default = upstream` a bare `git push` would push the new work to `release`. A `branch.autoSetupMerge` set in git config takes precedence over that default. Publishing such a branch takes `git push --set-upstream origin `, or git's `push.autoSetupRemote = true` set once, after which a bare `git push` from the new worktree publishes it and configures its tracking. +`--create` from a remote base is the exception. The branch it creates tracks its base only when the two share a name: `--create release --base origin/release` tracks `origin/release`, while `--create feature --base origin/release` — and the bare `--base release` that resolves to it — gets no upstream. Git's default would have `feature` track `origin/release`, so under `push.default = upstream` a bare `git push` would push the new work to `release`. `wt` runs the creation under git's own `branch.autoSetupMerge = simple` rule whatever the config file says, so the outcome is the same everywhere. Publishing such a branch takes `git push --set-upstream origin `, or git's `push.autoSetupRemote = true` set once, after which a bare `git push` from the new worktree publishes it and configures its tracking. ## Creating worktrees diff --git a/plugins/worktrunk/skills/worktrunk/reference/switch.md b/plugins/worktrunk/skills/worktrunk/reference/switch.md index b6a83744c..980fe0600 100644 --- a/plugins/worktrunk/skills/worktrunk/reference/switch.md +++ b/plugins/worktrunk/skills/worktrunk/reference/switch.md @@ -19,7 +19,7 @@ $ wt switch https://github.com/owner/repo/pull/123 # ...or paste the PR's URL The `--create` flag creates a new branch from `--base` — the default branch unless specified. Without `--create`, the branch must already exist. Switching to a remote branch (e.g., `wt switch feature` when only `origin/feature` exists) creates a local tracking branch. -`--create` from a remote base is the exception. For the branch it creates, `wt` defaults git's `branch.autoSetupMerge` to `simple` instead of git's own `true`, so the new branch tracks its base only when the two share a name: `--create release --base origin/release` tracks `origin/release`, while `--create feature --base origin/release` — and the bare `--base release` that resolves to it — gets no upstream. Git's default would have `feature` track `origin/release`, so under `push.default = upstream` a bare `git push` would push the new work to `release`. A `branch.autoSetupMerge` set in git config takes precedence over that default. Publishing such a branch takes `git push --set-upstream origin `, or git's `push.autoSetupRemote = true` set once, after which a bare `git push` from the new worktree publishes it and configures its tracking. +`--create` from a remote base is the exception. The branch it creates tracks its base only when the two share a name: `--create release --base origin/release` tracks `origin/release`, while `--create feature --base origin/release` — and the bare `--base release` that resolves to it — gets no upstream. Git's default would have `feature` track `origin/release`, so under `push.default = upstream` a bare `git push` would push the new work to `release`. `wt` runs the creation under git's own `branch.autoSetupMerge = simple` rule whatever the config file says, so the outcome is the same everywhere. Publishing such a branch takes `git push --set-upstream origin `, or git's `push.autoSetupRemote = true` set once, after which a bare `git push` from the new worktree publishes it and configures its tracking. ## Creating worktrees diff --git a/skills/worktrunk/reference/switch.md b/skills/worktrunk/reference/switch.md index b6a83744c..980fe0600 100644 --- a/skills/worktrunk/reference/switch.md +++ b/skills/worktrunk/reference/switch.md @@ -19,7 +19,7 @@ $ wt switch https://github.com/owner/repo/pull/123 # ...or paste the PR's URL The `--create` flag creates a new branch from `--base` — the default branch unless specified. Without `--create`, the branch must already exist. Switching to a remote branch (e.g., `wt switch feature` when only `origin/feature` exists) creates a local tracking branch. -`--create` from a remote base is the exception. For the branch it creates, `wt` defaults git's `branch.autoSetupMerge` to `simple` instead of git's own `true`, so the new branch tracks its base only when the two share a name: `--create release --base origin/release` tracks `origin/release`, while `--create feature --base origin/release` — and the bare `--base release` that resolves to it — gets no upstream. Git's default would have `feature` track `origin/release`, so under `push.default = upstream` a bare `git push` would push the new work to `release`. A `branch.autoSetupMerge` set in git config takes precedence over that default. Publishing such a branch takes `git push --set-upstream origin `, or git's `push.autoSetupRemote = true` set once, after which a bare `git push` from the new worktree publishes it and configures its tracking. +`--create` from a remote base is the exception. The branch it creates tracks its base only when the two share a name: `--create release --base origin/release` tracks `origin/release`, while `--create feature --base origin/release` — and the bare `--base release` that resolves to it — gets no upstream. Git's default would have `feature` track `origin/release`, so under `push.default = upstream` a bare `git push` would push the new work to `release`. `wt` runs the creation under git's own `branch.autoSetupMerge = simple` rule whatever the config file says, so the outcome is the same everywhere. Publishing such a branch takes `git push --set-upstream origin `, or git's `push.autoSetupRemote = true` set once, after which a bare `git push` from the new worktree publishes it and configures its tracking. ## Creating worktrees diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 511ce034b..9a82b8cc5 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -627,7 +627,7 @@ $ wt switch https://github.com/owner/repo/pull/123 # ...or paste the PR's URL The `--create` flag creates a new branch from `--base` — the default branch unless specified. Without `--create`, the branch must already exist. Switching to a remote branch (e.g., `wt switch feature` when only `origin/feature` exists) creates a local tracking branch. -`--create` from a remote base is the exception. For the branch it creates, `wt` defaults git's `branch.autoSetupMerge` to `simple` instead of git's own `true`, so the new branch tracks its base only when the two share a name: `--create release --base origin/release` tracks `origin/release`, while `--create feature --base origin/release` — and the bare `--base release` that resolves to it — gets no upstream. Git's default would have `feature` track `origin/release`, so under `push.default = upstream` a bare `git push` would push the new work to `release`. A `branch.autoSetupMerge` set in git config takes precedence over that default. Publishing such a branch takes `git push --set-upstream origin `, or git's `push.autoSetupRemote = true` set once, after which a bare `git push` from the new worktree publishes it and configures its tracking. +`--create` from a remote base is the exception. The branch it creates tracks its base only when the two share a name: `--create release --base origin/release` tracks `origin/release`, while `--create feature --base origin/release` — and the bare `--base release` that resolves to it — gets no upstream. Git's default would have `feature` track `origin/release`, so under `push.default = upstream` a bare `git push` would push the new work to `release`. `wt` runs the creation under git's own `branch.autoSetupMerge = simple` rule whatever the config file says, so the outcome is the same everywhere. Publishing such a branch takes `git push --set-upstream origin `, or git's `push.autoSetupRemote = true` set once, after which a bare `git push` from the new worktree publishes it and configures its tracking. ## Creating worktrees diff --git a/src/commands/worktree/switch.rs b/src/commands/worktree/switch.rs index 9d8f11ed7..c0e47c8d8 100644 --- a/src/commands/worktree/switch.rs +++ b/src/commands/worktree/switch.rs @@ -468,8 +468,9 @@ fn parse_ref_shortcut(input: &str) -> Option<(RefType, u32)> { /// returns the remote-qualified form so the validation in /// [`resolve_switch_target`] doesn't reject `wt switch -c new --base /// remote-only-branch`. Git's rev-parse doesn't auto-expand `foo` to -/// `refs/remotes/origin/foo`. The new branch's upstream is unset downstream -/// to keep `git push` from targeting the base. +/// `refs/remotes/origin/foo`. The remote-qualified form is also what lets the +/// creation site's `branch.autoSetupMerge = simple` see a remote branch at all, +/// so `--base release` and `--base origin/release` set up the same tracking. fn resolve_base_ref( repo: &Repository, base: &str, @@ -1044,22 +1045,35 @@ fn execute_switch( let worktree_path_str = worktree_path.to_string_lossy(); let mut args: Vec<&str> = Vec::new(); - // Safety: for an explicitly requested branch, default - // `branch.autoSetupMerge` to `simple` rather than git's - // `true`. Under `true`, `git worktree add -b feature - // origin/main` sets `feature` to track `origin/main`, so a - // bare `git push` under `push.default = upstream` pushes the - // new work to `main` (#713). `simple` is git's own narrower - // mode: it sets tracking only when the new branch's name - // matches the remote branch's, which is exactly the case - // where inherited tracking is correct. An explicit setting - // wins — `wt` picks a different default, it does not override - // the user's configuration. + // Safety: for an explicitly requested branch, `wt` decides + // tracking rather than the user's `branch.autoSetupMerge`. + // `-c` outranks every config file, so the outcome is the same + // on every machine. Git's `simple` is the rule `wt` wants: an + // upstream only when the new branch's name matches the remote + // branch it starts from, which is exactly when inherited + // tracking is right. Git's default `true`, and `always`, would + // have `--create feature --base origin/release` track + // `origin/release`, so a bare `git push` under + // `push.default = upstream` pushes the new work to `release` + // (#713); `false` and `inherit` would deny `--create release + // --base origin/release` the tracking that is the point of a + // same-named branch. + // + // `wt` sets git's rule rather than picking `--track` / + // `--no-track` from a name comparison of its own, because only + // git maps the base back to a remote branch through the fetch + // refspec. Splitting `/` reads + // `team/fork/release` as branch `fork/release`, so the verdict + // inverts both ways, and a refspec that renames into a + // sub-namespace does the same. `--track` is also a hard demand + // where `simple` is best-effort: it fails the whole command + // when the base isn't refspec-mapped, as in a single-branch + // clone holding a hand-fetched ref. // // Only the `--create` paths need it. The DWIM paths below // create `feature` from `origin/feature`, where the names // match and `simple` and `true` agree. - if *create_branch && repo.config_value("branch.autoSetupMerge")?.is_none() { + if *create_branch { args.extend(["-c", "branch.autoSetupMerge=simple"]); } diff --git a/src/commands/worktree/types.rs b/src/commands/worktree/types.rs index eed29f9d6..9a4ada959 100644 --- a/src/commands/worktree/types.rs +++ b/src/commands/worktree/types.rs @@ -66,11 +66,11 @@ pub enum CreationMethod { /// When `--base pr:N` / `--base mr:N` (same-repo) is paired with `--create`, /// the user's intent is "create a new branch tracking the PR/MR's source /// branch on the remote", so `git push` from the new worktree pushes back - /// to that PR/MR. `git worktree add -b new ` doesn't set up - /// tracking on its own (only `/` triggers DWIM, and even - /// then we'd unset it via the issue-#713 safety check), so we capture the - /// (remote, branch) pair here and configure tracking explicitly after - /// `git worktree add` succeeds. `None` for any other base resolution. + /// to that PR/MR. The base here is the PR/MR's source branch under its + /// bare name, which `branch.autoSetupMerge = simple` never reads as a + /// remote branch, so it sets no tracking — we capture the (remote, + /// branch) pair and configure tracking explicitly after `git worktree + /// add` succeeds. `None` for any other base resolution. base_pr_upstream: Option<(String, String)>, }, /// Fork PR/MR: fetch from refs/pull/N/head or refs/merge-requests/N/head, diff --git a/tests/integration_tests/switch.rs b/tests/integration_tests/switch.rs index 4f3d6f8a9..771194e16 100644 --- a/tests/integration_tests/switch.rs +++ b/tests/integration_tests/switch.rs @@ -373,29 +373,43 @@ fn test_switch_create_with_remote_only_base(#[from(repo_with_remote)] repo: Test ); } -/// `--create` from a remote tracking branch defaults `branch.autoSetupMerge` to -/// git's `simple` rather than git's own `true`, so the new branch inherits the -/// base's upstream only when the two share a name. Under `true`, a branch -/// created from `origin/release` tracks `origin/release`, and a bare `git push` -/// under `push.default = upstream` pushes the new work to `release` — GitHub -/// issue #713. +/// `--create` from a remote tracking branch sets the new branch's upstream only +/// when the two share a name. Otherwise the branch created from `origin/release` +/// tracks `origin/release`, and a bare `git push` under `push.default = upstream` +/// pushes the new work to `release` — GitHub issue #713. /// -/// An explicit `branch.autoSetupMerge` wins: `wt` picks a different default, it -/// does not override the setting. The previous implementation — a post-hoc -/// `git branch --unset-upstream` — overrode every setting, and failed the whole -/// command with exit 128 whenever the user's config meant git had set no -/// upstream for it to unset. -#[rstest] -fn test_switch_create_from_remote_base_upstream(#[from(repo_with_remote)] repo: TestRepo) { - // `release` on origin only, so it can serve as a remote base whose name a - // new branch either shares or doesn't. +/// `wt` forces `branch.autoSetupMerge = simple` for the creation, so the outcome +/// is the same under every value the user configures: git's `true` and `always` +/// would otherwise track a differently-named base, and its `false` and `inherit` +/// would deny a same-named one the tracking that is the point of it. +#[rstest] +fn test_switch_create_from_remote_base_upstream( + #[from(repo_with_remote)] repo: TestRepo, + #[values( + None, + Some("simple"), + Some("false"), + Some("inherit"), + Some("true"), + Some("always") + )] + auto_setup_merge: Option<&str>, +) { + if let Some(value) = auto_setup_merge { + repo.run_git(&["config", "branch.autoSetupMerge", value]); + } + + // `release` and `hotfix` on origin only, so each can serve as a remote base + // whose name a new branch either shares or doesn't. repo.run_git(&["push", "origin", "main:release"]); + repo.run_git(&["push", "origin", "main:hotfix"]); + repo.run_git(&["push", "origin", "main:staging"]); repo.run_git(&["fetch", "origin"]); - let create = |branch: &str| { + let create = |branch: &str, base: &str| { let output = repo .wt_command() - .args(["switch", "--create", branch, "--base=origin/release"]) + .args(["switch", "--create", branch, &format!("--base={base}")]) .output() .unwrap(); assert!( @@ -423,26 +437,124 @@ fn test_switch_create_from_remote_base_upstream(#[from(repo_with_remote)] repo: }; // Different name: no upstream, so a bare `git push` cannot reach `release`. - create("my-feature"); - assert_eq!(upstream("my-feature"), None); + create("my-feature", "origin/release"); + assert_eq!(upstream("my-feature"), None, "{auto_setup_merge:?}"); - // Same name: the tracking git would set points at the branch's own remote - // counterpart, which is what tracking is for — it stays. - create("release"); - assert_eq!(upstream("release").as_deref(), Some("origin/release")); + // Same name: the upstream is the new branch's own remote counterpart, + // which is what tracking is for. + create("release", "origin/release"); + assert_eq!( + upstream("release").as_deref(), + Some("origin/release"), + "{auto_setup_merge:?}" + ); - // `false` means git sets no upstream at all; nothing to undo, nothing to fail. - repo.run_git(&["config", "branch.autoSetupMerge", "false"]); - create("no-auto-setup"); - assert_eq!(upstream("no-auto-setup"), None); + // A bare base name resolves to the remote-only branch, and is judged by the + // same rule once resolved. + create("hotfix", "hotfix"); + assert_eq!( + upstream("hotfix").as_deref(), + Some("origin/hotfix"), + "{auto_setup_merge:?}" + ); - // `always` is the user asking for git's inheriting behaviour explicitly. - repo.run_git(&["config", "branch.autoSetupMerge", "always"]); - create("explicit-always"); + // A fully qualified base names the same remote branch, so it tracks too. + create("staging", "refs/remotes/origin/staging"); assert_eq!( - upstream("explicit-always").as_deref(), - Some("origin/release") + upstream("staging").as_deref(), + Some("origin/staging"), + "{auto_setup_merge:?}" + ); +} + +/// The name match is git's to make, because only git maps a remote-tracking ref +/// back to its branch through the fetch refspec. Splitting `/` +/// at the first slash gets a remote whose own name contains one wrong in both +/// directions: `team/fork/release` would read as branch `fork/release`, so +/// `--create fork/release` would be handed the upstream that pushes it to +/// `release`, and `--create release` would be denied its own counterpart. +#[rstest] +fn test_switch_create_base_on_remote_with_slash(#[from(repo_with_remote)] repo: TestRepo) { + let origin_url = repo.git_output(&["remote", "get-url", "origin"]); + repo.run_git(&["push", "origin", "main:release"]); + repo.run_git(&["remote", "add", "team/fork", origin_url.trim()]); + repo.run_git(&["fetch", "team/fork"]); + + let create = |branch: &str| { + let output = repo + .wt_command() + .args(["switch", "--create", branch, "--base=team/fork/release"]) + .output() + .unwrap(); + assert!( + output.status.success(), + "switch --create {branch} should succeed; stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + }; + + let upstream = |branch: &str| -> Option { + let output = repo + .git_command() + .args([ + "rev-parse", + "--abbrev-ref", + &format!("{branch}@{{upstream}}"), + ]) + .run() + .unwrap(); + output + .status + .success() + .then(|| String::from_utf8_lossy(&output.stdout).trim().to_string()) + }; + + create("fork/release"); + assert_eq!( + upstream("fork/release"), + None, + "fork/release must not inherit the base's upstream" + ); + + create("release"); + assert_eq!( + upstream("release").as_deref(), + Some("team/fork/release"), + "release is the base's own counterpart" + ); +} + +/// A remote-tracking ref the fetch refspec doesn't map — hand-fetched into a +/// single-branch clone — still creates its worktree. Git's `simple` declines the +/// tracking it can't work out; `git worktree add --track` would fail the whole +/// command with "starting point 'origin/release' is not a branch", after the +/// branch name has already been taken. +#[rstest] +fn test_switch_create_base_outside_fetch_refspec(#[from(repo_with_remote)] repo: TestRepo) { + repo.run_git(&["push", "origin", "main:release"]); + repo.run_git(&[ + "config", + "remote.origin.fetch", + "+refs/heads/main:refs/remotes/origin/main", + ]); + repo.run_git(&[ + "fetch", + "origin", + "refs/heads/release:refs/remotes/origin/release", + ]); + + let output = repo + .wt_command() + .args(["switch", "--create", "release", "--base=origin/release"]) + .output() + .unwrap(); + assert!( + output.status.success(), + "switch --create release should succeed; stderr: {}", + String::from_utf8_lossy(&output.stderr) ); + let branches = repo.git_output(&["branch", "--list", "release"]); + assert!(branches.contains("release"), "release should be created"); } /// When local branch already exists and tracks a remote, should report diff --git a/tests/snapshots/integration__integration_tests__help__help_switch_long.snap b/tests/snapshots/integration__integration_tests__help__help_switch_long.snap index 6141422dc..f194e82be 100644 --- a/tests/snapshots/integration__integration_tests__help__help_switch_long.snap +++ b/tests/snapshots/integration__integration_tests__help__help_switch_long.snap @@ -148,8 +148,8 @@ Worktrees are addressed by branch name; paths are computed from a configurable t The --create flag creates a new branch from --base — the default branch unless specified. Without --create, the branch must already exist. Switching to a remote branch (e.g., wt switch feature when only origin/feature exists) creates a local tracking branch. ---create from a remote base is the exception. For the branch it creates, wt defaults git's branch.autoSetupMerge to simple instead of git's own true, so the new branch tracks its base only when the two share a name: --create release --base origin/release tracks origin/release, while --create feature --base origin/release — and the bare --base release that resolves to it — gets no upstream. Git's default would have feature track origin/release, so under push.default = upstream a bare git push -would push the new work to release. A branch.autoSetupMerge set in git config takes precedence over that default. Publishing such a branch takes git push --set-upstream origin , or git's push.autoSetupRemote = true set once, after which a bare git push from the new worktree publishes it and configures its tracking. +--create from a remote base is the exception. The branch it creates tracks its base only when the two share a name: --create release --base origin/release tracks origin/release, while --create feature --base origin/release — and the bare --base release that resolves to it — gets no upstream. Git's default would have feature track origin/release, so under push.default = upstream a bare git push would push the new work to release. wt runs the creation under git's own branch.autoSetupMerge = simple + rule whatever the config file says, so the outcome is the same everywhere. Publishing such a branch takes git push --set-upstream origin , or git's push.autoSetupRemote = true set once, after which a bare git push from the new worktree publishes it and configures its tracking. Creating worktrees From b46f12ca69cff9cf4da3bd964b02fb485a64a36a Mon Sep 17 00:00:00 2001 From: Maximilian Roos Date: Fri, 28 Aug 2026 16:29:36 -0700 Subject: [PATCH 2/4] fix(switch): apply the tracking rule to every branch wt creates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01NBxMUZo7awheuY85PNXCjE --- docs/src/content/docs/switch.md | 2 +- .../skills/worktrunk/reference/switch.md | 2 +- skills/worktrunk/reference/switch.md | 2 +- src/cli/mod.rs | 2 +- src/commands/worktree/switch.rs | 25 +++++----- tests/integration_tests/switch.rs | 47 ++++++++++++++++++- ...gration_tests__help__help_switch_long.snap | 4 +- ...switch__switch_dwim_ambiguous_remotes.snap | 19 ++++++-- 8 files changed, 78 insertions(+), 25 deletions(-) diff --git a/docs/src/content/docs/switch.md b/docs/src/content/docs/switch.md index 101608de0..cff324c8a 100644 --- a/docs/src/content/docs/switch.md +++ b/docs/src/content/docs/switch.md @@ -32,7 +32,7 @@ $ wt switch https://github.com/owner/repo/pull/123 # ...or paste the PR's URL The `--create` flag creates a new branch from `--base` — the default branch unless specified. Without `--create`, the branch must already exist. Switching to a remote branch (e.g., `wt switch feature` when only `origin/feature` exists) creates a local tracking branch. -`--create` from a remote base is the exception. The branch it creates tracks its base only when the two share a name: `--create release --base origin/release` tracks `origin/release`, while `--create feature --base origin/release` — and the bare `--base release` that resolves to it — gets no upstream. Git's default would have `feature` track `origin/release`, so under `push.default = upstream` a bare `git push` would push the new work to `release`. `wt` runs the creation under git's own `branch.autoSetupMerge = simple` rule whatever the config file says, so the outcome is the same everywhere. Publishing such a branch takes `git push --set-upstream origin `, or git's `push.autoSetupRemote = true` set once, after which a bare `git push` from the new worktree publishes it and configures its tracking. +One rule decides the upstream, whatever `branch.autoSetupMerge` is set to: a new branch tracks the remote branch it starts from only when the two share a name. Switching to `origin/feature` shares it, so that branch tracks. `--create` need not: `--create release --base origin/release` tracks `origin/release`, while `--create feature --base origin/release` — and the bare `--base release` that resolves to it — gets no upstream. Git's default would have `feature` track `origin/release`, so under `push.default = upstream` a bare `git push` would push the new work to `release`. Publishing such a branch takes `git push --set-upstream origin `, or git's `push.autoSetupRemote = true` set once, after which a bare `git push` from the new worktree publishes it and configures its tracking. ## Creating worktrees diff --git a/plugins/worktrunk/skills/worktrunk/reference/switch.md b/plugins/worktrunk/skills/worktrunk/reference/switch.md index 980fe0600..89ecaa813 100644 --- a/plugins/worktrunk/skills/worktrunk/reference/switch.md +++ b/plugins/worktrunk/skills/worktrunk/reference/switch.md @@ -19,7 +19,7 @@ $ wt switch https://github.com/owner/repo/pull/123 # ...or paste the PR's URL The `--create` flag creates a new branch from `--base` — the default branch unless specified. Without `--create`, the branch must already exist. Switching to a remote branch (e.g., `wt switch feature` when only `origin/feature` exists) creates a local tracking branch. -`--create` from a remote base is the exception. The branch it creates tracks its base only when the two share a name: `--create release --base origin/release` tracks `origin/release`, while `--create feature --base origin/release` — and the bare `--base release` that resolves to it — gets no upstream. Git's default would have `feature` track `origin/release`, so under `push.default = upstream` a bare `git push` would push the new work to `release`. `wt` runs the creation under git's own `branch.autoSetupMerge = simple` rule whatever the config file says, so the outcome is the same everywhere. Publishing such a branch takes `git push --set-upstream origin `, or git's `push.autoSetupRemote = true` set once, after which a bare `git push` from the new worktree publishes it and configures its tracking. +One rule decides the upstream, whatever `branch.autoSetupMerge` is set to: a new branch tracks the remote branch it starts from only when the two share a name. Switching to `origin/feature` shares it, so that branch tracks. `--create` need not: `--create release --base origin/release` tracks `origin/release`, while `--create feature --base origin/release` — and the bare `--base release` that resolves to it — gets no upstream. Git's default would have `feature` track `origin/release`, so under `push.default = upstream` a bare `git push` would push the new work to `release`. Publishing such a branch takes `git push --set-upstream origin `, or git's `push.autoSetupRemote = true` set once, after which a bare `git push` from the new worktree publishes it and configures its tracking. ## Creating worktrees diff --git a/skills/worktrunk/reference/switch.md b/skills/worktrunk/reference/switch.md index 980fe0600..89ecaa813 100644 --- a/skills/worktrunk/reference/switch.md +++ b/skills/worktrunk/reference/switch.md @@ -19,7 +19,7 @@ $ wt switch https://github.com/owner/repo/pull/123 # ...or paste the PR's URL The `--create` flag creates a new branch from `--base` — the default branch unless specified. Without `--create`, the branch must already exist. Switching to a remote branch (e.g., `wt switch feature` when only `origin/feature` exists) creates a local tracking branch. -`--create` from a remote base is the exception. The branch it creates tracks its base only when the two share a name: `--create release --base origin/release` tracks `origin/release`, while `--create feature --base origin/release` — and the bare `--base release` that resolves to it — gets no upstream. Git's default would have `feature` track `origin/release`, so under `push.default = upstream` a bare `git push` would push the new work to `release`. `wt` runs the creation under git's own `branch.autoSetupMerge = simple` rule whatever the config file says, so the outcome is the same everywhere. Publishing such a branch takes `git push --set-upstream origin `, or git's `push.autoSetupRemote = true` set once, after which a bare `git push` from the new worktree publishes it and configures its tracking. +One rule decides the upstream, whatever `branch.autoSetupMerge` is set to: a new branch tracks the remote branch it starts from only when the two share a name. Switching to `origin/feature` shares it, so that branch tracks. `--create` need not: `--create release --base origin/release` tracks `origin/release`, while `--create feature --base origin/release` — and the bare `--base release` that resolves to it — gets no upstream. Git's default would have `feature` track `origin/release`, so under `push.default = upstream` a bare `git push` would push the new work to `release`. Publishing such a branch takes `git push --set-upstream origin `, or git's `push.autoSetupRemote = true` set once, after which a bare `git push` from the new worktree publishes it and configures its tracking. ## Creating worktrees diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 9a82b8cc5..ec3279121 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -627,7 +627,7 @@ $ wt switch https://github.com/owner/repo/pull/123 # ...or paste the PR's URL The `--create` flag creates a new branch from `--base` — the default branch unless specified. Without `--create`, the branch must already exist. Switching to a remote branch (e.g., `wt switch feature` when only `origin/feature` exists) creates a local tracking branch. -`--create` from a remote base is the exception. The branch it creates tracks its base only when the two share a name: `--create release --base origin/release` tracks `origin/release`, while `--create feature --base origin/release` — and the bare `--base release` that resolves to it — gets no upstream. Git's default would have `feature` track `origin/release`, so under `push.default = upstream` a bare `git push` would push the new work to `release`. `wt` runs the creation under git's own `branch.autoSetupMerge = simple` rule whatever the config file says, so the outcome is the same everywhere. Publishing such a branch takes `git push --set-upstream origin `, or git's `push.autoSetupRemote = true` set once, after which a bare `git push` from the new worktree publishes it and configures its tracking. +One rule decides the upstream, whatever `branch.autoSetupMerge` is set to: a new branch tracks the remote branch it starts from only when the two share a name. Switching to `origin/feature` shares it, so that branch tracks. `--create` need not: `--create release --base origin/release` tracks `origin/release`, while `--create feature --base origin/release` — and the bare `--base release` that resolves to it — gets no upstream. Git's default would have `feature` track `origin/release`, so under `push.default = upstream` a bare `git push` would push the new work to `release`. Publishing such a branch takes `git push --set-upstream origin `, or git's `push.autoSetupRemote = true` set once, after which a bare `git push` from the new worktree publishes it and configures its tracking. ## Creating worktrees diff --git a/src/commands/worktree/switch.rs b/src/commands/worktree/switch.rs index c0e47c8d8..2842e2de5 100644 --- a/src/commands/worktree/switch.rs +++ b/src/commands/worktree/switch.rs @@ -1045,19 +1045,22 @@ fn execute_switch( let worktree_path_str = worktree_path.to_string_lossy(); let mut args: Vec<&str> = Vec::new(); - // Safety: for an explicitly requested branch, `wt` decides - // tracking rather than the user's `branch.autoSetupMerge`. - // `-c` outranks every config file, so the outcome is the same - // on every machine. Git's `simple` is the rule `wt` wants: an + // Safety: `wt` decides tracking for a branch it creates, + // rather than the user's `branch.autoSetupMerge`. `-c` + // outranks every config file, so the outcome is the same on + // every machine. Git's `simple` is the rule `wt` wants: an // upstream only when the new branch's name matches the remote // branch it starts from, which is exactly when inherited // tracking is right. Git's default `true`, and `always`, would // have `--create feature --base origin/release` track // `origin/release`, so a bare `git push` under // `push.default = upstream` pushes the new work to `release` - // (#713); `false` and `inherit` would deny `--create release - // --base origin/release` the tracking that is the point of a - // same-named branch. + // (#713); `false` and `inherit` would deny the tracking that is + // the point of a same-named branch — `--create release --base + // origin/release`, and every DWIM `wt switch feature` from + // `origin/feature`, which is the tracking branch the docs + // promise. Both paths run under the one rule, so `wt switch` + // has one answer to state. // // `wt` sets git's rule rather than picking `--track` / // `--no-track` from a name comparison of its own, because only @@ -1069,13 +1072,7 @@ fn execute_switch( // where `simple` is best-effort: it fails the whole command // when the base isn't refspec-mapped, as in a single-branch // clone holding a hand-fetched ref. - // - // Only the `--create` paths need it. The DWIM paths below - // create `feature` from `origin/feature`, where the names - // match and `simple` and `true` agree. - if *create_branch { - args.extend(["-c", "branch.autoSetupMerge=simple"]); - } + args.extend(["-c", "branch.autoSetupMerge=simple"]); args.extend(["worktree", "add"]); diff --git a/tests/integration_tests/switch.rs b/tests/integration_tests/switch.rs index 771194e16..682e12de9 100644 --- a/tests/integration_tests/switch.rs +++ b/tests/integration_tests/switch.rs @@ -399,8 +399,8 @@ fn test_switch_create_from_remote_base_upstream( repo.run_git(&["config", "branch.autoSetupMerge", value]); } - // `release` and `hotfix` on origin only, so each can serve as a remote base - // whose name a new branch either shares or doesn't. + // `release`, `hotfix`, and `staging` on origin only, so each can serve as a + // remote base whose name a new branch either shares or doesn't. repo.run_git(&["push", "origin", "main:release"]); repo.run_git(&["push", "origin", "main:hotfix"]); repo.run_git(&["push", "origin", "main:staging"]); @@ -524,6 +524,49 @@ fn test_switch_create_base_on_remote_with_slash(#[from(repo_with_remote)] repo: ); } +/// Switching to a remote-only branch creates the tracking branch the docs +/// promise, under every `branch.autoSetupMerge`. The names match by +/// construction here, so the same rule `--create` runs under settles it — git's +/// `false` and `inherit` would otherwise leave the branch with no upstream. +#[rstest] +fn test_switch_dwim_from_remote_tracks( + #[from(repo_with_remote)] repo: TestRepo, + #[values(None, Some("false"), Some("inherit"), Some("always"))] auto_setup_merge: Option<&str>, +) { + if let Some(value) = auto_setup_merge { + repo.run_git(&["config", "branch.autoSetupMerge", value]); + } + repo.run_git(&["branch", "dwim-feature"]); + repo.run_git(&["push", "origin", "dwim-feature"]); + repo.run_git(&["branch", "-D", "dwim-feature"]); + + let output = repo + .wt_command() + .args(["switch", "dwim-feature"]) + .output() + .unwrap(); + assert!( + output.status.success(), + "switch dwim-feature should succeed; stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let upstream = repo + .git_command() + .args(["rev-parse", "--abbrev-ref", "dwim-feature@{upstream}"]) + .run() + .unwrap(); + assert!( + upstream.status.success(), + "dwim-feature should track its remote counterpart ({auto_setup_merge:?})" + ); + assert_eq!( + String::from_utf8_lossy(&upstream.stdout).trim(), + "origin/dwim-feature", + "{auto_setup_merge:?}" + ); +} + /// A remote-tracking ref the fetch refspec doesn't map — hand-fetched into a /// single-branch clone — still creates its worktree. Git's `simple` declines the /// tracking it can't work out; `git worktree add --track` would fail the whole diff --git a/tests/snapshots/integration__integration_tests__help__help_switch_long.snap b/tests/snapshots/integration__integration_tests__help__help_switch_long.snap index f194e82be..f2ba35bc9 100644 --- a/tests/snapshots/integration__integration_tests__help__help_switch_long.snap +++ b/tests/snapshots/integration__integration_tests__help__help_switch_long.snap @@ -148,8 +148,8 @@ Worktrees are addressed by branch name; paths are computed from a configurable t The --create flag creates a new branch from --base — the default branch unless specified. Without --create, the branch must already exist. Switching to a remote branch (e.g., wt switch feature when only origin/feature exists) creates a local tracking branch. ---create from a remote base is the exception. The branch it creates tracks its base only when the two share a name: --create release --base origin/release tracks origin/release, while --create feature --base origin/release — and the bare --base release that resolves to it — gets no upstream. Git's default would have feature track origin/release, so under push.default = upstream a bare git push would push the new work to release. wt runs the creation under git's own branch.autoSetupMerge = simple - rule whatever the config file says, so the outcome is the same everywhere. Publishing such a branch takes git push --set-upstream origin , or git's push.autoSetupRemote = true set once, after which a bare git push from the new worktree publishes it and configures its tracking. +One rule decides the upstream, whatever branch.autoSetupMerge is set to: a new branch tracks the remote branch it starts from only when the two share a name. Switching to origin/feature shares it, so that branch tracks. --create need not: --create release --base origin/release tracks origin/release, while --create feature --base origin/release — and the bare --base release that resolves to it — gets no upstream. Git's default would have feature track origin/release, so under push.default = +upstream a bare git push would push the new work to release. Publishing such a branch takes git push --set-upstream origin , or git's push.autoSetupRemote = true set once, after which a bare git push from the new worktree publishes it and configures its tracking. Creating worktrees diff --git a/tests/snapshots/integration__integration_tests__switch__switch_dwim_ambiguous_remotes.snap b/tests/snapshots/integration__integration_tests__switch__switch_dwim_ambiguous_remotes.snap index c0c400a5f..5e7ef15a4 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_dwim_ambiguous_remotes.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_dwim_ambiguous_remotes.snap @@ -7,12 +7,23 @@ info: - shared-feature env: APPDATA: "[TEST_CONFIG_HOME]" + CLAUDE_CONFIG_DIR: "[TEST_CLAUDE_CONFIG]" CLICOLOR_FORCE: "1" COLUMNS: "500" + GIT_ALLOW_PROTOCOL: file GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" + GIT_AUTHOR_EMAIL: test@example.com + GIT_AUTHOR_NAME: Test User GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" - GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]" - GIT_CONFIG_SYSTEM: /dev/null + GIT_COMMITTER_EMAIL: test@example.com + GIT_COMMITTER_NAME: Test User + GIT_CONFIG_COUNT: "2" + GIT_CONFIG_GLOBAL: /nonexistent/wt/gitconfig + GIT_CONFIG_KEY_0: user.useConfigOnly + GIT_CONFIG_KEY_1: rerere.enabled + GIT_CONFIG_SYSTEM: /nonexistent/wt/gitconfig + GIT_CONFIG_VALUE_0: "true" + GIT_CONFIG_VALUE_1: "false" GIT_TERMINAL_PROMPT: "0" HOME: "[TEST_HOME]" LANG: C @@ -35,8 +46,10 @@ info: WORKTRUNK_TEST_MOCK_CONFIG_DIR: "[TEST_MOCK_CONFIG]" WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_OPENCODE_INSTALLED: "0" + WORKTRUNK_TEST_PARENT_SHELL: "" WORKTRUNK_TEST_POWERSHELL_ENV: "0" WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" + WORKTRUNK_TEST_PROBE_TIMEOUT_MS: "60000" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" WORKTRUNK_TEST_ZSH_INSTALLED: "0" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" @@ -49,4 +62,4 @@ exit_code: 1 ✗ Failed to create worktree for shared-feature   fatal: invalid reference: shared-feature ↳ Failed command, exit code 128: -  git worktree add -- _REPO_.shared-feature shared-feature +  git -c branch.autoSetupMerge=simple worktree add -- _REPO_.shared-feature shared-feature From e29b43e23d5b027b7d888fc38fcec34505528ffc Mon Sep 17 00:00:00 2001 From: Maximilian Roos Date: Fri, 28 Aug 2026 16:51:31 -0700 Subject: [PATCH 3/4] docs(switch): correct two claims about what git resolves, and pin what simple declines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01NBxMUZo7awheuY85PNXCjE --- src/commands/worktree/switch.rs | 6 +++--- src/commands/worktree/types.rs | 10 +++++----- tests/integration_tests/switch.rs | 20 +++++++++++++++++--- 3 files changed, 25 insertions(+), 11 deletions(-) diff --git a/src/commands/worktree/switch.rs b/src/commands/worktree/switch.rs index 2842e2de5..d9c4e452b 100644 --- a/src/commands/worktree/switch.rs +++ b/src/commands/worktree/switch.rs @@ -468,9 +468,9 @@ fn parse_ref_shortcut(input: &str) -> Option<(RefType, u32)> { /// returns the remote-qualified form so the validation in /// [`resolve_switch_target`] doesn't reject `wt switch -c new --base /// remote-only-branch`. Git's rev-parse doesn't auto-expand `foo` to -/// `refs/remotes/origin/foo`. The remote-qualified form is also what lets the -/// creation site's `branch.autoSetupMerge = simple` see a remote branch at all, -/// so `--base release` and `--base origin/release` set up the same tracking. +/// `refs/remotes/origin/foo`, though `git worktree add` would DWIM the bare +/// form on its own — so the qualification is load-bearing for the validation, +/// not for the creation. fn resolve_base_ref( repo: &Repository, base: &str, diff --git a/src/commands/worktree/types.rs b/src/commands/worktree/types.rs index 9a4ada959..3e24ca0f6 100644 --- a/src/commands/worktree/types.rs +++ b/src/commands/worktree/types.rs @@ -66,11 +66,11 @@ pub enum CreationMethod { /// When `--base pr:N` / `--base mr:N` (same-repo) is paired with `--create`, /// the user's intent is "create a new branch tracking the PR/MR's source /// branch on the remote", so `git push` from the new worktree pushes back - /// to that PR/MR. The base here is the PR/MR's source branch under its - /// bare name, which `branch.autoSetupMerge = simple` never reads as a - /// remote branch, so it sets no tracking — we capture the (remote, - /// branch) pair and configure tracking explicitly after `git worktree - /// add` succeeds. `None` for any other base resolution. + /// to that PR/MR. That branch may carry any name, and + /// `branch.autoSetupMerge = simple` sets an upstream only where the new + /// branch shares it, so we capture the (remote, branch) pair here and + /// configure tracking explicitly after `git worktree add` succeeds. + /// `None` for any other base resolution. base_pr_upstream: Option<(String, String)>, }, /// Fork PR/MR: fetch from refs/pull/N/head or refs/merge-requests/N/head, diff --git a/tests/integration_tests/switch.rs b/tests/integration_tests/switch.rs index 682e12de9..225fcaa52 100644 --- a/tests/integration_tests/switch.rs +++ b/tests/integration_tests/switch.rs @@ -525,9 +525,10 @@ fn test_switch_create_base_on_remote_with_slash(#[from(repo_with_remote)] repo: } /// Switching to a remote-only branch creates the tracking branch the docs -/// promise, under every `branch.autoSetupMerge`. The names match by -/// construction here, so the same rule `--create` runs under settles it — git's -/// `false` and `inherit` would otherwise leave the branch with no upstream. +/// promise. The names match by construction here, so the same rule `--create` +/// runs under settles it; the values worth enumerating are the two that would +/// otherwise leave the branch with no upstream, against the default and the +/// most permissive as controls. #[rstest] fn test_switch_dwim_from_remote_tracks( #[from(repo_with_remote)] repo: TestRepo, @@ -598,6 +599,19 @@ fn test_switch_create_base_outside_fetch_refspec(#[from(repo_with_remote)] repo: ); let branches = repo.git_output(&["branch", "--list", "release"]); assert!(branches.contains("release"), "release should be created"); + + // `simple` declines the tracking rather than guessing at it: git can't work + // out which remote branch `origin/release` stands for without a refspec. + let upstream = repo + .git_command() + .args(["rev-parse", "--abbrev-ref", "release@{upstream}"]) + .run() + .unwrap(); + assert!( + !upstream.status.success(), + "release should have no upstream, got {}", + String::from_utf8_lossy(&upstream.stdout).trim() + ); } /// When local branch already exists and tracks a remote, should report From 62aa46dfabd9df76ea37b49bc06eb184a9691d86 Mon Sep 17 00:00:00 2001 From: Maximilian Roos Date: Fri, 28 Aug 2026 17:12:12 -0700 Subject: [PATCH 4/4] docs(switch): say why qualifying a remote-only base matters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit inverted it. `git worktree add -b -- ` 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) Claude-Session: https://claude.ai/code/session_01NBxMUZo7awheuY85PNXCjE --- src/commands/worktree/switch.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/commands/worktree/switch.rs b/src/commands/worktree/switch.rs index d9c4e452b..157db6bf5 100644 --- a/src/commands/worktree/switch.rs +++ b/src/commands/worktree/switch.rs @@ -468,9 +468,11 @@ fn parse_ref_shortcut(input: &str) -> Option<(RefType, u32)> { /// returns the remote-qualified form so the validation in /// [`resolve_switch_target`] doesn't reject `wt switch -c new --base /// remote-only-branch`. Git's rev-parse doesn't auto-expand `foo` to -/// `refs/remotes/origin/foo`, though `git worktree add` would DWIM the bare -/// form on its own — so the qualification is load-bearing for the validation, -/// not for the creation. +/// `refs/remotes/origin/foo`. `git worktree add` does DWIM the bare form, but +/// destructively: given `-b ` it drops the `-b` and creates the remote +/// branch's own name instead. So qualifying here is what keeps `--create new-wt +/// --base remote-only-branch` on `new-wt`, which +/// `test_switch_create_with_remote_only_base` pins. fn resolve_base_ref( repo: &Repository, base: &str,