diff --git a/docs/src/content/docs/switch.md b/docs/src/content/docs/switch.md index 3736f2b87..a0d279705 100644 --- a/docs/src/content/docs/switch.md +++ b/docs/src/content/docs/switch.md @@ -32,6 +32,8 @@ $ 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. + ## Creating worktrees If the branch already has a worktree, `wt switch` changes directories to it. Otherwise, it creates one: diff --git a/plugins/worktrunk/skills/worktrunk/reference/switch.md b/plugins/worktrunk/skills/worktrunk/reference/switch.md index afaa82b39..b6a83744c 100644 --- a/plugins/worktrunk/skills/worktrunk/reference/switch.md +++ b/plugins/worktrunk/skills/worktrunk/reference/switch.md @@ -19,6 +19,8 @@ $ 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. + ## Creating worktrees If the branch already has a worktree, `wt switch` changes directories to it. Otherwise, it creates one: diff --git a/skills/worktrunk/reference/switch.md b/skills/worktrunk/reference/switch.md index afaa82b39..b6a83744c 100644 --- a/skills/worktrunk/reference/switch.md +++ b/skills/worktrunk/reference/switch.md @@ -19,6 +19,8 @@ $ 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. + ## Creating worktrees If the branch already has a worktree, `wt switch` changes directories to it. Otherwise, it creates one: diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 6a460250c..0ac5f4ba2 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -627,6 +627,8 @@ $ 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. + ## Creating worktrees If the branch already has a worktree, `wt switch` changes directories to it. Otherwise, it creates one: diff --git a/src/commands/worktree/switch.rs b/src/commands/worktree/switch.rs index 493652186..a11c30785 100644 --- a/src/commands/worktree/switch.rs +++ b/src/commands/worktree/switch.rs @@ -959,7 +959,28 @@ fn execute_switch( // branch as the *value* of `-b`, which is safe even when // the branch name starts with `-`. let worktree_path_str = worktree_path.to_string_lossy(); - let mut args: Vec<&str> = vec!["worktree", "add"]; + 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. + // + // 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() { + args.extend(["-c", "branch.autoSetupMerge=simple"]); + } + + args.extend(["worktree", "add"]); // For DWIM fallback: when the branch doesn't exist locally, // git worktree add relies on DWIM to auto-create it from a @@ -1028,19 +1049,6 @@ fn execute_switch( .into()); } - // Safety: unset unsafe upstream when creating a new branch from a remote - // tracking branch. When `git worktree add -b feature origin/main` runs, - // git sets feature to track origin/main. This is dangerous because - // `git push` would push to main instead of the feature branch. - // See: https://github.com/max-sixty/worktrunk/issues/713 - if *create_branch - && let Some(base) = base_branch - && repo.is_remote_tracking_branch(base) - { - // Unset the upstream to prevent accidental pushes - branch_handle.unset_upstream()?; - } - // `--base pr:N` / `--base mr:N` against a same-repo PR/MR: the // user asked for a custom local name pointing at an existing // remote branch — wire up tracking so `git push` from the new diff --git a/src/git/repository/branch.rs b/src/git/repository/branch.rs index c8a514e0c..bc9900e8e 100644 --- a/src/git/repository/branch.rs +++ b/src/git/repository/branch.rs @@ -126,18 +126,6 @@ impl<'a> Branch<'a> { .and_then(|b| b.upstream_short.clone())) } - /// Unset the upstream tracking branch for this branch. - /// - /// This removes the tracking relationship, preventing accidental pushes - /// to the wrong branch (e.g., when a feature branch was created from origin/main). - pub fn unset_upstream(&self) -> anyhow::Result<()> { - // `--` separates the option from the positional branch name so a - // hyphen-prefixed branch cannot be misread as a flag. - self.repo - .run_command(&["branch", "--unset-upstream", "--", &self.name])?; - Ok(()) - } - /// Get the URL of the remote where this branch would be pushed. /// /// Uses `%(push:remotename)` which returns either a remote name or URL directly diff --git a/src/git/repository/remotes.rs b/src/git/repository/remotes.rs index ddecd9d9c..b5a0d9843 100644 --- a/src/git/repository/remotes.rs +++ b/src/git/repository/remotes.rs @@ -427,21 +427,6 @@ impl Repository { .and_then(|config| config.list.url) } - /// Check if a ref is a remote tracking branch. - /// - /// Returns true if the ref appears in the remote-branch inventory - /// (e.g., `origin/main`). Returns false for local branches, tags, SHAs, - /// non-existent refs, and `/HEAD` symrefs (which the inventory - /// excludes). - /// - /// Resolved from the remote-branch inventory — no subprocess calls once - /// it's populated. - pub fn is_remote_tracking_branch(&self, ref_name: &str) -> bool { - self.remote_branches() - .ok() - .is_some_and(|branches| branches.iter().any(|r| r.short_name == ref_name)) - } - /// Strip the remote prefix from a remote-tracking branch name. /// /// Given a name like `origin/username/feature-1`, returns `Some("username/feature-1")` diff --git a/tests/integration_tests/switch.rs b/tests/integration_tests/switch.rs index 7c06b6621..51738df9f 100644 --- a/tests/integration_tests/switch.rs +++ b/tests/integration_tests/switch.rs @@ -358,7 +358,7 @@ fn test_switch_create_with_remote_only_base(#[from(repo_with_remote)] repo: Test ); // The new branch must exist and must NOT track the remote base - // (same safety property as test_switch_create_from_remote_base_no_upstream). + // (same safety property as test_switch_create_from_remote_base_upstream). let branch_output = repo.git_output(&["branch", "--list", "new-wt"]); assert!(branch_output.contains("new-wt"), "branch should be created"); @@ -373,38 +373,75 @@ fn test_switch_create_with_remote_only_base(#[from(repo_with_remote)] repo: Test ); } -/// When creating a new branch from a remote tracking branch (e.g., origin/main), -/// the new branch should NOT track the remote base branch. -/// This prevents accidental `git push` to the base branch (e.g., pushing to main). -/// This is the bug fix for GitHub issue #713. -#[rstest] -fn test_switch_create_from_remote_base_no_upstream(#[from(repo_with_remote)] repo: TestRepo) { - // Create a new branch with --base pointing to a remote tracking branch - let output = repo - .wt_command() - .args(["switch", "--create", "my-feature", "--base=origin/main"]) - .output() - .unwrap(); - assert!(output.status.success(), "switch should succeed"); +/// `--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. +/// +/// 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. + repo.run_git(&["push", "origin", "main:release"]); + repo.run_git(&["fetch", "origin"]); + + let create = |branch: &str| { + let output = repo + .wt_command() + .args(["switch", "--create", branch, "--base=origin/release"]) + .output() + .unwrap(); + assert!( + output.status.success(), + "switch --create {branch} should succeed; stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let branches = repo.git_output(&["branch", "--list", branch]); + assert!(branches.contains(branch), "{branch} should be created"); + }; + 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()) + }; - // Verify the branch was created - let branch_output = repo.git_output(&["branch", "--list", "my-feature"]); - assert!( - branch_output.contains("my-feature"), - "branch should be created" - ); + // Different name: no upstream, so a bare `git push` cannot reach `release`. + create("my-feature"); + assert_eq!(upstream("my-feature"), None); - // Verify the branch does NOT have an upstream (no tracking) - // Using rev-parse to check for upstream - should fail for untracked branches - let upstream_check = repo - .git_command() - .args(["rev-parse", "--abbrev-ref", "my-feature@{upstream}"]) - .run() - .unwrap(); + // 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")); - assert!( - !upstream_check.status.success(), - "branch should NOT have upstream tracking (to prevent accidental push to origin/main)" + // `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); + + // `always` is the user asking for git's inheriting behaviour explicitly. + repo.run_git(&["config", "branch.autoSetupMerge", "always"]); + create("explicit-always"); + assert_eq!( + upstream("explicit-always").as_deref(), + Some("origin/release") ); } 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 5f4d14a05..6141422dc 100644 --- a/tests/snapshots/integration__integration_tests__help__help_switch_long.snap +++ b/tests/snapshots/integration__integration_tests__help__help_switch_long.snap @@ -148,6 +148,9 @@ 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. + Creating worktrees If the branch already has a worktree, wt switch changes directories to it. Otherwise, it creates one: