Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/src/content/docs/switch.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <branch>`, 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:
Expand Down
2 changes: 2 additions & 0 deletions plugins/worktrunk/skills/worktrunk/reference/switch.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <branch>`, 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:
Expand Down
2 changes: 2 additions & 0 deletions skills/worktrunk/reference/switch.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <branch>`, 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:
Expand Down
36 changes: 22 additions & 14 deletions src/commands/worktree/switch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
12 changes: 0 additions & 12 deletions src/git/repository/branch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 0 additions & 15 deletions src/git/repository/remotes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<remote>/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")`
Expand Down
97 changes: 67 additions & 30 deletions tests/integration_tests/switch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand All @@ -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<String> {
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")
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <branch>, 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:
Expand Down
Loading