Skip to content
Merged
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
37 changes: 37 additions & 0 deletions src/commands/worktree/switch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1031,6 +1031,21 @@ fn execute_switch(
Repository::SLOW_OPERATION_DELAY_MS,
progress_msg,
) {
// A new branch whose name is a path prefix of (or sits
// under) an existing branch can't be created: git stores
// refs as file paths, so `release` and `release/2026.4`
// can't coexist. Surface that as a clear, actionable
// error instead of git's raw "cannot lock ref" text.
if *create_branch

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: this fires on any git worktree add failure once a colliding branch exists, so it can mask an unrelated cause with the namespace message. E.g. wt switch --create release --from badref when release/2026.4 exists — git actually failed on the invalid base ref, but we'd report the namespace collision and swallow invalid reference: badref. The path-occupancy and non-create checks are already guarded earlier in validate_worktree_creation, so the realistic remaining case is a bad --from. Narrow, and the reported message is still a real problem the user would hit next, so not blocking — just flagging the unconditional swap of git's error. Could tighten by only substituting when the collision is the plausible cause, but that risks re-introducing error-text parsing, so the current tradeoff may well be the right call.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good flag — I chased down whether the misattribution can actually happen, and for the --base case it can't: --create --base <ref> resolves and validates the base with ref_exists() up front at switch.rs:605, so an invalid base errors with ReferenceNotFound before git worktree add ever runs. Verified end-to-end — wt switch --create release --base badref with release/2026.4 present reports ✗ No branch, tag, or commit named badref, not the namespace collision.

So by the time we reach the failing git worktree add: the path is pre-checked in validate_worktree_creation, the base is either a validated ref or None (→ HEAD, always valid), and --create is set. The only inputs left that can fail there are the D/F collision itself plus rare environmental/race failures — and in the environmental case the swap only fires if a colliding branch also exists, where the namespace conflict is a genuine latent blocker anyway.

I did prototype the tightening you suggested — gate the swap on base_branch.as_deref().is_none_or(|b| repo.ref_exists(b)...) — but since the base is already validated at line 605, its false branch is structurally unreachable, so it'd be speculative dead code (and would trip codecov/patch). Dropped it. The unconditional swap is the right call here; leaving it as-is.

&& let Some(conflicting) =
detect_branch_namespace_conflict(repo, &branch)
{
return Err(GitError::BranchNamespaceConflict {
branch: branch.clone(),
conflicting,
}
.into());
}
return Err(worktree_creation_error(
&e,
branch.clone(),
Expand Down Expand Up @@ -1209,6 +1224,28 @@ fn execute_switch(
}
}

/// Detect a git ref directory/file (D/F) conflict for a branch about to be
/// created, returning an existing branch it collides with.
///
/// Git stores refs as file paths under `refs/heads/`, so a branch name can't
/// be both a file and a directory: creating `release` fails when
/// `release/2026.4` exists, and creating `release/foo` fails when `release`
/// exists. This inspects the cached local-branch inventory (no extra
/// subprocess) for either shape and returns the first colliding branch.
fn detect_branch_namespace_conflict(repo: &Repository, branch: &str) -> Option<String> {
let prefix = format!("{branch}/");
repo.local_branches()
.ok()?
.iter()
.map(|b| b.name.as_str())
.find(|name| {
// `branch` is a directory prefix of an existing branch, or an
// existing branch is a directory prefix of `branch`.
name.starts_with(&prefix) || branch.starts_with(&format!("{name}/"))
})
.map(String::from)
}

/// Build a `GitError::WorktreeCreationFailed` from a failed `git worktree add`,
/// extracting the underlying command output for the error message.
fn worktree_creation_error(
Expand Down
32 changes: 32 additions & 0 deletions src/git/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,16 @@ pub enum GitError {
/// The git command that failed, shown separately from git output
command: Option<FailedCommand>,
},
/// A new branch can't be created because its name collides with the
/// directory namespace of an existing branch. Git stores refs as file
/// paths, so `release` and `release/2026.4` can't both exist — one would
/// have to be a file and a directory at the same path.
BranchNamespaceConflict {
/// The branch the user tried to create.
branch: String,
/// An existing branch whose name collides with `branch`.
conflicting: String,
},
WorktreeRemovalFailed {
branch: String,
path: PathBuf,
Expand Down Expand Up @@ -668,6 +678,13 @@ impl GitError {
None => cformat!("Failed to create worktree for <bold>{branch}</>"),
},

GitError::BranchNamespaceConflict {
branch,
conflicting,
} => cformat!(
"Cannot create branch <bold>{branch}</> — it collides with existing branch <bold>{conflicting}</>"
),

GitError::WorktreeRemovalFailed { branch, path, .. } => {
let path_display = format_path_for_display(path);
cformat!(
Expand Down Expand Up @@ -1008,6 +1025,21 @@ impl GitError {
Ok(())
}

GitError::BranchNamespaceConflict {
branch,
conflicting,
} => {
let title = self.title();
write!(
f,
"{}\n{}",
error_message(&title),
hint_message(cformat!(
"Git stores branches as file paths, so <underline>{branch}</> and <underline>{conflicting}</> can't both exist. Pick a different name, or rename the existing branch."
))
)
}

GitError::WorktreeRemovalFailed {
error,
remaining_entries,
Expand Down
17 changes: 17 additions & 0 deletions tests/integration_tests/switch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,23 @@ fn test_switch_create_existing_branch_error(mut repo: TestRepo) {
);
}

/// Creating a branch whose name is a path prefix of an existing branch is a git
/// ref directory/file conflict: `release` can't be created while `release/2026.4`
/// exists. Regression for #3527 — surface a clear namespace-collision error
/// instead of git's raw "cannot lock ref" text.
#[rstest]
fn test_switch_create_branch_namespace_conflict(repo: TestRepo) {
// An existing branch living under the `release/` namespace.
repo.run_git(&["branch", "release/2026.4"]);

// `release` can't be both a branch and a directory of branches.
snapshot_switch(
"switch_create_namespace_conflict",
&repo,
&["--create", "release"],
);
}

/// When --execute is passed and the branch already exists, the error hint should
/// include --execute and trailing args in the suggested command.
#[rstest]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
---
source: tests/integration_tests/switch.rs
info:
program: wt
args:
- switch
- "--create"
- release
env:
APPDATA: "[TEST_CONFIG_HOME]"
CLAUDE_CONFIG_DIR: "[TEST_CLAUDE_CONFIG]"
CLICOLOR_FORCE: "1"
COLUMNS: "500"
GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z"
GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z"
GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]"
GIT_CONFIG_SYSTEM: /dev/null
GIT_TERMINAL_PROMPT: "0"
HOME: "[TEST_HOME]"
LANG: C
LC_ALL: C
LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]"
MOCK_CONFIG_DIR: "[MOCK_CONFIG_DIR]"
OPENCODE_CONFIG_DIR: "[TEST_OPENCODE_CONFIG]"
PATH: "[PATH]"
TERM: alacritty
USERPROFILE: "[TEST_HOME]"
WORKTRUNK_APPROVALS_PATH: "[TEST_APPROVALS]"
WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]"
WORKTRUNK_SYSTEM_CONFIG_PATH: "[TEST_SYSTEM_CONFIG]"
WORKTRUNK_TEST_BASH_INSTALLED: "0"
WORKTRUNK_TEST_CLAUDE_INSTALLED: "0"
WORKTRUNK_TEST_CODEX_INSTALLED: "0"
WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1"
WORKTRUNK_TEST_EPOCH: "1735776000"
WORKTRUNK_TEST_FISH_INSTALLED: "0"
WORKTRUNK_TEST_GEMINI_INSTALLED: "0"
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_SKIP_URL_HEALTH_CHECK: "1"
WORKTRUNK_TEST_ZSH_INSTALLED: "0"
XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]"
---
success: false
exit_code: 1
----- stdout -----

----- stderr -----
✗ Cannot create branch release — it collides with existing branch release/2026.4
↳ Git stores branches as file paths, so release and release/2026.4 can't both exist. Pick a different name, or rename the existing branch.
Loading