From cc4aacf99f4d7ccaf176673f5ebdbed151c62967 Mon Sep 17 00:00:00 2001 From: Maximilian Roos Date: Sun, 9 Aug 2026 14:53:28 -0700 Subject: [PATCH 1/4] fix(remove): refuse to strand a detached worktree when removing by branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detaching a worktree's HEAD severs the only link git records between it and the branch, so `wt remove ` dropped out of the branch-first lookup, degraded to a branch-only deletion, and exited 0 — deleting the ref and leaving the worktree registered. The failing half was silent. The `worktree-path` template is what still connects the two, so the removal matches against it and refuses, naming the path that reaches the worktree. Three states are deliberately not matched: a worktree on some other branch (that branch names it), the main worktree (whose path-based removal refuses too, so the hint would be a dead end), and a prunable entry (stale metadata, not a worktree on disk). The guard sits in `wt remove` rather than `prepare_worktree_removal`, which every producer of a branch-only target shares: `wt step prune` plans its whole sweep from one worktree-list snapshot, so a detached worktree it is about to remove as its own candidate is still registered when the branch's plan is built — refusing there would leave prune unable to clean up either half. `live_sibling_checkout` keeps `exists()` rather than the union predicate, and now says why: the two only disagree on a directory that is present but no longer holds its worktree, where calling it dead deletes a branch a checkout still resolves. Fixes #3769 Co-Authored-By: Claude Opus 5 (1M context) --- docs/content/remove.md | 2 + .../skills/worktrunk/reference/remove.md | 2 + skills/worktrunk/reference/remove.md | 2 + src/cli/mod.rs | 2 + src/commands/remove.rs | 84 +++++++++++++++- src/commands/repository_ext.rs | 15 ++- src/git/error.rs | 43 ++++++++ tests/integration_tests/remove.rs | 98 ++++++++++++++++++- ...gration_tests__help__help_remove_long.snap | 3 + ...branch_with_detached_worktree_message.snap | 63 ++++++++++++ ...ranch_with_prunable_detached_worktree.snap | 64 ++++++++++++ ...lt_branch_with_detached_main_worktree.snap | 63 ++++++++++++ ...ve__remove_detached_worktree_in_multi.snap | 26 +++-- 13 files changed, 452 insertions(+), 15 deletions(-) create mode 100644 tests/snapshots/integration__integration_tests__remove__remove_branch_with_detached_worktree_message.snap create mode 100644 tests/snapshots/integration__integration_tests__remove__remove_branch_with_prunable_detached_worktree.snap create mode 100644 tests/snapshots/integration__integration_tests__remove__remove_default_branch_with_detached_main_worktree.snap diff --git a/docs/content/remove.md b/docs/content/remove.md index 6b14753f48..680b074542 100644 --- a/docs/content/remove.md +++ b/docs/content/remove.md @@ -56,6 +56,8 @@ Branches matching these conditions and with empty working trees are dimmed in `w Those six ask whether deleting loses work. A branch checked out in a second worktree (only reachable via `git worktree add --force`) fails a different test: deleting the ref would leave that worktree unable to resolve `HEAD`, which is why `git branch -d` refuses the same delete. Such a branch is retained whatever `-D` asks, and the surviving checkout is named. +Detaching a worktree's HEAD severs the only link git records between it and the branch, so `wt remove ` would delete the ref and leave the worktree registered. It refuses instead and names the path, the one spelling that still removes the worktree. + ## Force flags Worktrunk has two force flags for different situations: diff --git a/plugins/worktrunk/skills/worktrunk/reference/remove.md b/plugins/worktrunk/skills/worktrunk/reference/remove.md index 0eee006fdb..c3373f6bef 100644 --- a/plugins/worktrunk/skills/worktrunk/reference/remove.md +++ b/plugins/worktrunk/skills/worktrunk/reference/remove.md @@ -55,6 +55,8 @@ Branches matching these conditions and with empty working trees are dimmed in `w Those six ask whether deleting loses work. A branch checked out in a second worktree (only reachable via `git worktree add --force`) fails a different test: deleting the ref would leave that worktree unable to resolve `HEAD`, which is why `git branch -d` refuses the same delete. Such a branch is retained whatever `-D` asks, and the surviving checkout is named. +Detaching a worktree's HEAD severs the only link git records between it and the branch, so `wt remove ` would delete the ref and leave the worktree registered. It refuses instead and names the path, the one spelling that still removes the worktree. + ## Force flags Worktrunk has two force flags for different situations: diff --git a/skills/worktrunk/reference/remove.md b/skills/worktrunk/reference/remove.md index 0eee006fdb..c3373f6bef 100644 --- a/skills/worktrunk/reference/remove.md +++ b/skills/worktrunk/reference/remove.md @@ -55,6 +55,8 @@ Branches matching these conditions and with empty working trees are dimmed in `w Those six ask whether deleting loses work. A branch checked out in a second worktree (only reachable via `git worktree add --force`) fails a different test: deleting the ref would leave that worktree unable to resolve `HEAD`, which is why `git branch -d` refuses the same delete. Such a branch is retained whatever `-D` asks, and the surviving checkout is named. +Detaching a worktree's HEAD severs the only link git records between it and the branch, so `wt remove ` would delete the ref and leave the worktree registered. It refuses instead and names the path, the one spelling that still removes the worktree. + ## Force flags Worktrunk has two force flags for different situations: diff --git a/src/cli/mod.rs b/src/cli/mod.rs index c4e91f75c2..bd5e118265 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1286,6 +1286,8 @@ Branches matching these conditions and with empty working trees are dimmed in `w Those six ask whether deleting loses work. A branch checked out in a second worktree (only reachable via `git worktree add --force`) fails a different test: deleting the ref would leave that worktree unable to resolve `HEAD`, which is why `git branch -d` refuses the same delete. Such a branch is retained whatever `-D` asks, and the surviving checkout is named. +Detaching a worktree's HEAD severs the only link git records between it and the branch, so `wt remove ` would delete the ref and leave the worktree registered. It refuses instead and names the path, the one spelling that still removes the worktree. + ## Force flags Worktrunk has two force flags for different situations: diff --git a/src/commands/remove.rs b/src/commands/remove.rs index 2d1e7545f5..4af450535d 100644 --- a/src/commands/remove.rs +++ b/src/commands/remove.rs @@ -16,7 +16,7 @@ use crate::output::{BackgroundFallbackMode, RemovalExecution, handle_remove_outp use super::hook_plan::{ApprovedHookPlan, HookPlanBuilder}; use super::hooks::HookAnnouncer; use super::repository_ext::RepositoryCliExt; -use super::worktree::{BranchFate, RemovalPlan}; +use super::worktree::{BranchFate, RemovalPlan, compute_worktree_path}; use super::{RemoveTarget, flag_pair}; /// The execution mode `--foreground` selects; the background default falls @@ -58,6 +58,56 @@ impl RemovePlans { } } +/// The removable detached worktree sitting where `branch`'s worktree belongs, +/// if any. +/// +/// Detaching a worktree's HEAD severs the only link git records between it and +/// the branch, so once that happens the `worktree-path` template is all that +/// still connects the two. That is a stronger association than the rest of the +/// module draws — [`is_worktree_at_expected_path`] returns false for a detached +/// worktree, and [`worktree_display_name`] renders one as `dir_name (detached)` +/// without consulting the template — and it is intentional here: the template +/// match is what keeps the removal from stranding it. Three cases are +/// deliberately not matched, because refusing on them would name a removal that +/// can't happen or protect nothing: +/// +/// - a worktree checked out on some *other* branch — that branch names it, so +/// removing this one strands nothing; +/// - the main worktree, which `wt remove ` refuses — the hint would name +/// a command that can't run, and the branch falls through to the accurate +/// [`CannotRemoveDefaultBranch`](worktrunk::git::GitError::CannotRemoveDefaultBranch). +/// A bare repo has no main worktree, so its default-branch checkout is +/// matched like any other and the hint works; +/// - a prunable entry, whose directory is already gone — stale metadata for +/// `wt step prune` to sweep, not a worktree left on disk. +/// +/// A template that won't expand yields no expected path and so no match: the +/// guard can't assert what it can't compute, and refusing every branch-only +/// removal on a broken template would cost more than the case it guards. +/// +/// [`is_worktree_at_expected_path`]: super::worktree::is_worktree_at_expected_path +/// [`worktree_display_name`]: super::worktree::worktree_display_name +fn detached_worktree_for<'a>( + repo: &Repository, + config: &UserConfig, + branch: &str, + worktrees: &'a [worktrunk::git::WorktreeInfo], +) -> Option<&'a Path> { + let expected = compute_worktree_path(repo, branch, config).ok()?; + worktrees + .iter() + .find(|wt| { + wt.branch.is_none() + && !wt.is_prunable() + && worktrunk::path::paths_match(&wt.path, &expected) + // Fail closed: a `git_dir` lookup that fails says nothing about + // whether the worktree is linked, and skipping the guard there + // deletes the ref and strands the worktree. + && repo.worktree_at(&wt.path).is_linked().unwrap_or(true) + }) + .map(|wt| wt.path.as_path()) +} + /// Validate all removal targets, returning categorized plans. /// /// Resolves each branch name, determines whether it's the current worktree, @@ -65,6 +115,7 @@ impl RemovePlans { /// Errors are collected (not fatal) to support partial success. fn validate_remove_targets( repo: &Repository, + config: &UserConfig, branches: Vec, keep_branch: bool, force_delete: bool, @@ -126,7 +177,34 @@ fn validate_remove_targets( // otherwise (see its shared-branch handling). RemoveTarget::WorktreePath(path_canonical) } - ResolvedWorktree::BranchOnly { branch } => RemoveTarget::BranchOnly(branch), + ResolvedWorktree::BranchOnly { branch } => { + // A detached worktree is invisible to the branch-first lookup, + // so a branch whose worktree has since been detached resolves + // here and would have its ref deleted with the worktree left + // registered. Refuse instead, and name the path — the only + // spelling that still reaches it. + // + // The guard belongs to this command rather than to + // `prepare_worktree_removal`, which every producer of a + // `BranchOnly` target shares: `wt step prune` plans its whole + // sweep from one worktree-list snapshot, so a detached + // worktree it is about to remove as its own candidate is still + // registered when the branch's plan is built. Refusing there + // would leave prune unable to clean up either half. + if let Some(detached) = + worktrees.and_then(|wts| detached_worktree_for(repo, config, &branch, wts)) + { + plans.record_error( + GitError::DetachedWorktreeForBranch { + branch, + path: detached.to_path_buf(), + } + .into(), + ); + continue; + } + RemoveTarget::BranchOnly(branch) + } // Resolution tried the argument as a branch and as a worktree path // and matched neither, so a directory sitting there is a leftover // skeleton rather than anything wt can remove. Only a typed @@ -386,6 +464,7 @@ pub fn handle_remove_command(args: RemoveArgs, yes: bool) -> anyhow::Result<()> // Multi-worktree removal: validate ALL first, then approve, then execute let plans = validate_remove_targets( &repo, + &config, branches, !delete_branch, args.force_delete, @@ -493,6 +572,7 @@ mod tests { let plans = validate_remove_targets( &repo, + &UserConfig::default(), vec!["missing-worktree".to_string(), "branch-only".to_string()], false, false, diff --git a/src/commands/repository_ext.rs b/src/commands/repository_ext.rs index 1b308fe583..1ec385a33a 100644 --- a/src/commands/repository_ext.rs +++ b/src/commands/repository_ext.rs @@ -578,10 +578,17 @@ pub(crate) fn compute_integration_reason( /// with an unresolvable `HEAD`, so every removal that could delete a branch /// asks this first. /// -/// Only a live directory counts. A sibling entry whose directory is already -/// gone is stale metadata awaiting `git worktree prune`, not a checkout with -/// anything to lose — retaining a branch for it would strand the branch and -/// point the user at a directory that isn't there. +/// Only a live directory counts: a sibling entry whose directory is gone is +/// stale metadata awaiting `git worktree prune`, not a checkout with anything +/// to lose, and retaining a branch for it would strand the branch and point the +/// user at a directory that isn't there. +/// +/// `exists()` is the test, not [`Repository::worktree_is_unusable`], which the +/// rest of the removal path uses. The two disagree on a directory that is +/// present but no longer holds its worktree, and the disagreement is +/// asymmetric: calling a dead sibling live retains a branch nobody needed, +/// while calling a live one dead deletes a branch a checkout still resolves. +/// This answer only ever gates a deletion, so it takes the conservative test. pub(crate) fn live_sibling_checkout<'a>( worktrees: &'a [WorktreeInfo], branch: &str, diff --git a/src/git/error.rs b/src/git/error.rs index 2acb68b85f..0fed06b90b 100644 --- a/src/git/error.rs +++ b/src/git/error.rs @@ -588,6 +588,28 @@ pub enum GitError { WorktreeNotFound { branch: String, }, + /// A branch with no worktree, whose worktree's place is taken by a + /// registered worktree with a detached HEAD. + /// + /// Detaching severs the only link git records between a worktree and a + /// branch, so the branch-first lookup reports no worktree and an operation + /// addressed by branch degrades to acting on the ref alone. For removal + /// that means deleting the ref, leaving the worktree registered, and + /// reporting success. Refusing is the honest answer, and the path is the + /// only spelling that still reaches the worktree. + /// + /// Distinct from [`GitError::WorktreeNotFound`], where the branch has no + /// checkout anywhere and creating one is the right suggestion. + /// + /// [`GitError::WorktreePathOccupied`] reports the same physical state to + /// `wt switch`, which wants the worktree back on the branch and says so. + /// Removal wants it gone, so the two carry different hints and split on the + /// occupied-by-another-branch case: that one blocks a switch, and leaves a + /// removal nothing to strand. + DetachedWorktreeForBranch { + branch: String, + path: PathBuf, + }, /// A worktree selector matched neither a branch nor a worktree path. /// /// Distinct from [`GitError::WorktreeNotFound`], which means the branch @@ -883,6 +905,13 @@ impl GitError { cformat!("Branch {branch} has no worktree") } + GitError::DetachedWorktreeForBranch { branch, path } => { + let path_display = format_path_for_display(path); + cformat!( + "Branch {branch} has no worktree; the one @ {path_display} is detached" + ) + } + GitError::WorktreeSelectorNotFound { selector } => { cformat!("No branch or worktree named {selector}") } @@ -1441,6 +1470,20 @@ impl GitError { ) } + GitError::DetachedWorktreeForBranch { path, .. } => { + let title = self.title(); + let display_path = format_path_for_display(path); + let remove_cmd = suggest_command("remove", &[&display_path], &[]); + write!( + f, + "{}\n{}", + error_message(&title), + hint_message(cformat!( + "To remove the detached worktree, run {remove_cmd}" + )) + ) + } + GitError::WorktreeSelectorNotFound { .. } => { let title = self.title(); write!( diff --git a/tests/integration_tests/remove.rs b/tests/integration_tests/remove.rs index 6e13a0c62d..b8e0d7f6b8 100644 --- a/tests/integration_tests/remove.rs +++ b/tests/integration_tests/remove.rs @@ -292,6 +292,98 @@ fn test_remove_locked_worktree_directory_missing(mut repo: TestRepo) { ); } +/// Regression test for #3769: `wt remove ` on a worktree whose HEAD has +/// since been detached deleted the branch, left the worktree registered, and +/// exited 0. Detaching severs the only link git records between the two, so the +/// branch-first lookup found no worktree and the removal degraded to +/// branch-only — half the operation, and the silent half. +/// +/// Refusing is defensible because the path still reaches the worktree, which +/// `test_remove_detached_worktree_by_path` pins. +#[rstest] +fn test_remove_branch_whose_worktree_was_detached(mut repo: TestRepo) { + let _worktree_path = repo.add_worktree("detached-later"); + repo.detach_head_in_worktree("detached-later"); + + let output = repo + .wt_command() + .args(["remove", "detached-later", "--foreground", "--yes"]) + .output() + .unwrap(); + assert!( + !output.status.success(), + "wt remove should refuse a branch whose worktree has been detached.\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + + // The branch must survive — deleting it is the half that used to run. + let branch_exists = repo + .git_command() + .args(["branch", "--list", "detached-later"]) + .run() + .unwrap(); + assert!( + !String::from_utf8_lossy(&branch_exists.stdout) + .trim() + .is_empty(), + "Branch should NOT be deleted while a detached worktree occupies its path", + ); + + // ...and so must the worktree it would otherwise have stranded. + let list_after = repo + .git_command() + .args(["worktree", "list", "--porcelain"]) + .run() + .unwrap(); + assert!( + String::from_utf8_lossy(&list_after.stdout).contains("detached-later"), + "Detached worktree should still be registered", + ); +} + +/// The refusal names the worktree and the path-based removal that reaches it. +#[rstest] +fn test_remove_branch_with_detached_worktree_message(mut repo: TestRepo) { + repo.add_worktree("feature-detached-strand"); + repo.detach_head_in_worktree("feature-detached-strand"); + + assert_cmd_snapshot!(make_snapshot_cmd( + &repo, + "remove", + &["feature-detached-strand"], + None + )); +} + +/// The default branch's expected path is the main worktree, so a detached main +/// worktree would match the #3769 guard — but `wt remove ` refuses +/// too, so pointing at it would be a dead end. The default-branch refusal, which +/// is the accurate answer, must still be what surfaces. +#[rstest] +fn test_remove_default_branch_with_detached_main_worktree(repo: TestRepo) { + repo.run_git(&["checkout", "--detach", "HEAD"]); + + assert_cmd_snapshot!(make_snapshot_cmd(&repo, "remove", &["main"], None)); +} + +/// A detached worktree whose directory is already gone is stale metadata, not a +/// worktree the #3769 guard protects — the branch-only removal proceeds rather +/// than refusing and naming a path that isn't on disk. +#[rstest] +fn test_remove_branch_with_prunable_detached_worktree(mut repo: TestRepo) { + let worktree_path = repo.add_worktree("feature-detached-gone"); + repo.detach_head_in_worktree("feature-detached-gone"); + std::fs::remove_dir_all(&worktree_path).unwrap(); + + assert_cmd_snapshot!(make_snapshot_cmd( + &repo, + "remove", + &["-D", "feature-detached-gone"], + None + )); +} + #[rstest] fn test_remove_by_name_from_main(mut repo: TestRepo) { // Create a worktree @@ -2622,8 +2714,10 @@ fn test_remove_detached_worktree_in_multi(mut repo: TestRepo) { // Detach HEAD in feature-b repo.detach_head_in_worktree("feature-b"); - // From main, try to multi-remove both - // feature-a should succeed, feature-b should fail (detached HEAD) + // From main, try to multi-remove both. feature-a is removed; feature-b is + // refused, because detaching dropped it out of the branch-first lookup and + // removing it by branch would delete the ref and strand the worktree + // (#3769). Partial success is the point: one refusal doesn't stop the rest. assert_cmd_snapshot!(make_snapshot_cmd( &repo, "remove", diff --git a/tests/snapshots/integration__integration_tests__help__help_remove_long.snap b/tests/snapshots/integration__integration_tests__help__help_remove_long.snap index 0806c5764f..878926a396 100644 --- a/tests/snapshots/integration__integration_tests__help__help_remove_long.snap +++ b/tests/snapshots/integration__integration_tests__help__help_remove_long.snap @@ -35,6 +35,7 @@ info: 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" --- @@ -147,6 +148,8 @@ Branches matching these conditions and with empty working trees are dimmed in [ Those six ask whether deleting loses work. A branch checked out in a second worktree (only reachable via git worktree add --force) fails a different test: deleting the ref would leave that worktree unable to resolve HEAD, which is why git branch -d refuses the same delete. Such a branch is retained whatever -D asks, and the surviving checkout is named. +Detaching a worktree's HEAD severs the only link git records between it and the branch, so wt remove  would delete the ref and leave the worktree registered. It refuses instead and names the path, the one spelling that still removes the worktree. + Force flags Worktrunk has two force flags for different situations: diff --git a/tests/snapshots/integration__integration_tests__remove__remove_branch_with_detached_worktree_message.snap b/tests/snapshots/integration__integration_tests__remove__remove_branch_with_detached_worktree_message.snap new file mode 100644 index 0000000000..9160fd423e --- /dev/null +++ b/tests/snapshots/integration__integration_tests__remove__remove_branch_with_detached_worktree_message.snap @@ -0,0 +1,63 @@ +--- +source: tests/integration_tests/remove.rs +info: + program: wt + args: + - remove + - feature-detached-strand + 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_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 + LC_ALL: C + LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" + 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_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]" +--- +success: false +exit_code: 1 +----- stdout ----- + +----- stderr ----- +✗ Branch feature-detached-strand has no worktree; the one @ _REPO_.feature-detached-strand is detached +↳ To remove the detached worktree, run wt remove _REPO_.feature-detached-strand diff --git a/tests/snapshots/integration__integration_tests__remove__remove_branch_with_prunable_detached_worktree.snap b/tests/snapshots/integration__integration_tests__remove__remove_branch_with_prunable_detached_worktree.snap new file mode 100644 index 0000000000..de6befd2c1 --- /dev/null +++ b/tests/snapshots/integration__integration_tests__remove__remove_branch_with_prunable_detached_worktree.snap @@ -0,0 +1,64 @@ +--- +source: tests/integration_tests/remove.rs +info: + program: wt + args: + - remove + - "-D" + - feature-detached-gone + 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_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 + LC_ALL: C + LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" + 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_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]" +--- +success: true +exit_code: 0 +----- stdout ----- + +----- stderr ----- +○ No worktree found for branch feature-detached-gone +✓ Removed branch feature-detached-gone (--force-delete) diff --git a/tests/snapshots/integration__integration_tests__remove__remove_default_branch_with_detached_main_worktree.snap b/tests/snapshots/integration__integration_tests__remove__remove_default_branch_with_detached_main_worktree.snap new file mode 100644 index 0000000000..2d6a87c459 --- /dev/null +++ b/tests/snapshots/integration__integration_tests__remove__remove_default_branch_with_detached_main_worktree.snap @@ -0,0 +1,63 @@ +--- +source: tests/integration_tests/remove.rs +info: + program: wt + args: + - remove + - main + 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_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 + LC_ALL: C + LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" + 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_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]" +--- +success: false +exit_code: 1 +----- stdout ----- + +----- stderr ----- +✗ Cannot remove the default branch main +↳ To force-delete, run wt remove -D main diff --git a/tests/snapshots/integration__integration_tests__remove__remove_detached_worktree_in_multi.snap b/tests/snapshots/integration__integration_tests__remove__remove_detached_worktree_in_multi.snap index 86d45938b4..d6b367aaff 100644 --- a/tests/snapshots/integration__integration_tests__remove__remove_detached_worktree_in_multi.snap +++ b/tests/snapshots/integration__integration_tests__remove__remove_detached_worktree_in_multi.snap @@ -8,12 +8,23 @@ info: - feature-b 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 @@ -36,19 +47,20 @@ 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]" --- -success: true -exit_code: 0 +success: false +exit_code: 1 ----- stdout ----- ----- stderr ----- +✗ Branch feature-b has no worktree; the one @ _REPO_.feature-b is detached +↳ To remove the detached worktree, run wt remove _REPO_.feature-b ◎ Removing feature-a worktree in background ↳ Branch unmerged; to delete, run wt remove -D feature-a -○ No worktree found for branch feature-b -○ Branch feature-b retained; has unmerged changes -↳ To delete the unmerged branch, run wt remove -D feature-b From 27a0e2154238043c94b147606521b865c703fe65 Mon Sep 17 00:00:00 2001 From: Maximilian Roos Date: Sun, 9 Aug 2026 15:13:54 -0700 Subject: [PATCH 2/4] fix(errors): stop double-escaping the path in worktree-removal hints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `format_path_for_display` already returns a shell-ready token, so routing its result through `suggest_command` escapes it a second time. A worktree under $HOME rendered as `wt remove '~/repo.feature'`, where the quoting suppresses the tilde expansion the unquoted form in the line above it relies on; a path that genuinely needs escaping came out carrying literal quote characters and resolved to nothing. The three hints that composed the two now interpolate the formatted path directly, as the neighbouring `rm -rf {path}` and `git worktree unlock {path}` hints already do. `format_path_for_display` documents the trap so the next caller doesn't repeat it — `pr_mr_switch_hint` had recorded it only in its own docstring. Co-Authored-By: Claude Opus 5 (1M context) --- src/commands/repository_ext.rs | 8 +++++--- src/git/error.rs | 6 ++++-- src/git/repository/worktrees.rs | 7 +++---- src/path.rs | 8 ++++++++ 4 files changed, 20 insertions(+), 9 deletions(-) diff --git a/src/commands/repository_ext.rs b/src/commands/repository_ext.rs index 1ec385a33a..4292261a99 100644 --- a/src/commands/repository_ext.rs +++ b/src/commands/repository_ext.rs @@ -8,7 +8,7 @@ use worktrunk::git::{ parse_porcelain_z, parse_untracked_files, }; use worktrunk::path::format_path_for_display; -use worktrunk::styling::{eprintln, format_with_gutter, suggest_command, warning_message}; +use worktrunk::styling::{eprintln, format_with_gutter, warning_message}; /// Target for worktree removal. #[derive(Debug)] @@ -167,10 +167,12 @@ impl RepositoryCliExt for Repository { .iter() .find(|wt| wt.branch.as_deref() == Some(branch.as_str())) { + // `path` is already shell-ready, so the suggested command + // interpolates it rather than passing it through + // `suggest_command`, which would escape it a second time. let path = format_path_for_display(&wt.path); bail!(cformat!( - "Branch {branch} gained a worktree @ {path} since it was selected; to remove that worktree, run {}", - suggest_command("remove", &[&path], &[]) + "Branch {branch} gained a worktree @ {path} since it was selected; to remove that worktree, run wt remove {path}" )); } // Check the branch exists locally, so a typo or a remote-only diff --git a/src/git/error.rs b/src/git/error.rs index 0fed06b90b..9db0f70519 100644 --- a/src/git/error.rs +++ b/src/git/error.rs @@ -1472,14 +1472,16 @@ impl GitError { GitError::DetachedWorktreeForBranch { path, .. } => { let title = self.title(); + // `format_path_for_display` already returns a shell-ready + // token, so the command is built by interpolation — routing it + // through `suggest_command` would escape it a second time. let display_path = format_path_for_display(path); - let remove_cmd = suggest_command("remove", &[&display_path], &[]); write!( f, "{}\n{}", error_message(&title), hint_message(cformat!( - "To remove the detached worktree, run {remove_cmd}" + "To remove the detached worktree, run wt remove {display_path}" )) ) } diff --git a/src/git/repository/worktrees.rs b/src/git/repository/worktrees.rs index 8f87c49bc3..b563752cd4 100644 --- a/src/git/repository/worktrees.rs +++ b/src/git/repository/worktrees.rs @@ -13,9 +13,7 @@ use super::{ normalize_selector, resolve_input_path, }; use crate::path::{format_path_for_display, paths_match}; -use crate::styling::{ - eprintln, format_with_gutter, hint_message, suggest_command, warning_message, -}; +use crate::styling::{eprintln, format_with_gutter, hint_message, warning_message}; impl Repository { /// List all worktrees for this repository. @@ -757,7 +755,8 @@ fn warn_duplicate_checkout(branch: &str, paths: &[PathBuf]) { // removes exactly the worktree named and retains the branch the others // still hold, so it's safe to suggest for a duplicate. for extra in &paths[1..] { - let cmd = suggest_command("remove", &[&format_path_for_display(extra)], &[]); + // Already shell-ready; `suggest_command` would escape it again. + let cmd = format!("wt remove {}", format_path_for_display(extra)); eprintln!( "{}", hint_message(cformat!("To drop a duplicate, run {cmd}")) diff --git a/src/path.rs b/src/path.rs index 4ded02115e..f18f436d28 100644 --- a/src/path.rs +++ b/src/path.rs @@ -103,6 +103,14 @@ fn needs_shell_escaping(s: &str) -> bool { /// Uses POSIX shell escaping since all our hints target POSIX-compatible shells /// (bash, zsh, fish, and Git Bash on Windows). /// +/// The result is already shell-ready, so a hint embeds it by interpolation +/// (`rm -rf {path}`). Passing it to [`suggest_command`] escapes it a second +/// time: `~/repo` becomes `'~/repo'`, which the shell no longer tilde-expands, +/// and `'/tmp/my repo'` becomes a string carrying literal quote characters that +/// resolves to nothing. +/// +/// [`suggest_command`]: crate::styling::suggest_command +/// /// # Examples /// - `/Users/alex/repo` → `~/repo` (no escaping needed) /// - `/Users/alex/my repo` → `'/Users/alex/my repo'` (needs quoting, use original) From 56ada0bf23bf6f38bae78914282bd3af71cd99c6 Mon Sep 17 00:00:00 2001 From: Maximilian Roos Date: Sun, 9 Aug 2026 15:39:24 -0700 Subject: [PATCH 3/4] fix(remove): scope the detached-worktree guard to removals that delete a ref MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard fired ahead of `prepare_worktree_removal`'s branch-existence check, so a name that is not a local branch but whose templated path holds a detached worktree reported `Branch has no worktree` instead of `No branch named `. Check `exists_locally` first. It also fired under `--no-delete-branch` / `[remove] delete-branch = false`, where the branch-only arm deletes nothing — nothing was going to strand the worktree, so the guard turned a no-op exit 0 into a hard failure. Gate on `deletion_mode.should_keep()`. An unintegrated branch under `SafeDelete` retains its ref too, but only the deletion attempt downstream knows that, so those still refuse rather than exiting 0. That is the conservative direction — the worktree the refusal names is on disk — and a test pins it as a decision rather than an accident. Co-Authored-By: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com> Co-Authored-By: Claude Opus 5 (1M context) --- src/commands/remove.rs | 27 +++++++- tests/integration_tests/remove.rs | 63 +++++++++++++++++++ ...with_detached_worktree_keeping_branch.snap | 63 +++++++++++++++++++ ...ch_with_detached_worktree_at_its_path.snap | 63 +++++++++++++++++++ ...branch_with_detached_worktree_refuses.snap | 63 +++++++++++++++++++ 5 files changed, 277 insertions(+), 2 deletions(-) create mode 100644 tests/snapshots/integration__integration_tests__remove__remove_branch_with_detached_worktree_keeping_branch.snap create mode 100644 tests/snapshots/integration__integration_tests__remove__remove_missing_branch_with_detached_worktree_at_its_path.snap create mode 100644 tests/snapshots/integration__integration_tests__remove__remove_unmerged_branch_with_detached_worktree_refuses.snap diff --git a/src/commands/remove.rs b/src/commands/remove.rs index 4af450535d..f4d013276f 100644 --- a/src/commands/remove.rs +++ b/src/commands/remove.rs @@ -85,6 +85,9 @@ impl RemovePlans { /// guard can't assert what it can't compute, and refusing every branch-only /// removal on a broken template would cost more than the case it guards. /// +/// A name that is no local branch at all is rejected before any of that, so the +/// refusal never asserts a branch that isn't there. +/// /// [`is_worktree_at_expected_path`]: super::worktree::is_worktree_at_expected_path /// [`worktree_display_name`]: super::worktree::worktree_display_name fn detached_worktree_for<'a>( @@ -93,6 +96,15 @@ fn detached_worktree_for<'a>( branch: &str, worktrees: &'a [worktrunk::git::WorktreeInfo], ) -> Option<&'a Path> { + // Downstream, `prepare_worktree_removal` is what reports a typo, a deleted + // branch, or a remote-only name; a guard that fires ahead of it would + // assert a branch that doesn't exist. `exists_locally` reports a failed + // lookup as `false` rather than an error, so this can't fail closed — but + // the same call downstream then refuses the removal, so a guard skipped + // that way still never deletes a ref. + if !repo.branch(branch).exists_locally().unwrap_or(true) { + return None; + } let expected = compute_worktree_path(repo, branch, config).ok()?; worktrees .iter() @@ -191,8 +203,19 @@ fn validate_remove_targets( // worktree it is about to remove as its own candidate is still // registered when the branch's plan is built. Refusing there // would leave prune unable to clean up either half. - if let Some(detached) = - worktrees.and_then(|wts| detached_worktree_for(repo, config, &branch, wts)) + // + // Under `Keep` (`--no-delete-branch`, or + // `[remove] delete-branch = false`) this arm deletes nothing, + // so there is no ref to strand the worktree behind — the guard + // would turn a no-op into a failure. `SafeDelete` on an + // unintegrated branch retains its ref too, but only the + // deletion attempt downstream knows that + // (`Repository::integration_reason`), so those refuse here + // rather than exiting 0 — the conservative direction, since + // what the refusal names is still on disk. + if let Some(detached) = worktrees + .filter(|_| !deletion_mode.should_keep()) + .and_then(|wts| detached_worktree_for(repo, config, &branch, wts)) { plans.record_error( GitError::DetachedWorktreeForBranch { diff --git a/tests/integration_tests/remove.rs b/tests/integration_tests/remove.rs index b8e0d7f6b8..e1464fb0f0 100644 --- a/tests/integration_tests/remove.rs +++ b/tests/integration_tests/remove.rs @@ -367,6 +367,69 @@ fn test_remove_default_branch_with_detached_main_worktree(repo: TestRepo) { assert_cmd_snapshot!(make_snapshot_cmd(&repo, "remove", &["main"], None)); } +/// The #3769 guard runs ahead of the branch-existence check in +/// `prepare_worktree_removal`, so it must not claim a branch that isn't there: +/// a name whose templated path holds a detached worktree still reports the +/// missing branch once the ref is gone. +#[rstest] +fn test_remove_missing_branch_with_detached_worktree_at_its_path(mut repo: TestRepo) { + repo.add_worktree("feature-detached-orphan"); + repo.detach_head_in_worktree("feature-detached-orphan"); + repo.run_git(&["branch", "-D", "feature-detached-orphan"]); + + assert_cmd_snapshot!(make_snapshot_cmd( + &repo, + "remove", + &["feature-detached-orphan"], + None + )); +} + +/// Under `--no-delete-branch` the branch-only removal deletes nothing, so there +/// is no ref whose deletion could strand the detached worktree — the #3769 +/// guard must not turn that no-op into a failure. +#[rstest] +fn test_remove_branch_with_detached_worktree_keeping_branch(mut repo: TestRepo) { + repo.add_worktree("feature-detached-keep"); + repo.detach_head_in_worktree("feature-detached-keep"); + + assert_cmd_snapshot!(make_snapshot_cmd( + &repo, + "remove", + &["--no-delete-branch", "feature-detached-keep"], + None + )); +} + +/// An unintegrated branch keeps its ref under `SafeDelete` too, but only the +/// deletion attempt downstream knows that — so unlike the `--no-delete-branch` +/// case above, a detached worktree refuses here rather than exiting 0 with the +/// branch retained. The refusal names a worktree that is still on disk, so this +/// pins the conservative direction rather than an accident. +#[rstest] +fn test_remove_unmerged_branch_with_detached_worktree_refuses(mut repo: TestRepo) { + let worktree_path = repo.add_worktree("feature-detached-unmerged"); + std::fs::write(worktree_path.join("feature.txt"), "new feature").unwrap(); + repo.git_command() + .args(["add", "feature.txt"]) + .current_dir(&worktree_path) + .run() + .unwrap(); + repo.git_command() + .args(["commit", "-m", "Add feature"]) + .current_dir(&worktree_path) + .run() + .unwrap(); + repo.detach_head_in_worktree("feature-detached-unmerged"); + + assert_cmd_snapshot!(make_snapshot_cmd( + &repo, + "remove", + &["feature-detached-unmerged"], + None + )); +} + /// A detached worktree whose directory is already gone is stale metadata, not a /// worktree the #3769 guard protects — the branch-only removal proceeds rather /// than refusing and naming a path that isn't on disk. diff --git a/tests/snapshots/integration__integration_tests__remove__remove_branch_with_detached_worktree_keeping_branch.snap b/tests/snapshots/integration__integration_tests__remove__remove_branch_with_detached_worktree_keeping_branch.snap new file mode 100644 index 0000000000..57b01888ea --- /dev/null +++ b/tests/snapshots/integration__integration_tests__remove__remove_branch_with_detached_worktree_keeping_branch.snap @@ -0,0 +1,63 @@ +--- +source: tests/integration_tests/remove.rs +info: + program: wt + args: + - remove + - "--no-delete-branch" + - feature-detached-keep + 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_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 + LC_ALL: C + LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" + 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_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]" +--- +success: true +exit_code: 0 +----- stdout ----- + +----- stderr ----- +○ No worktree found for branch feature-detached-keep diff --git a/tests/snapshots/integration__integration_tests__remove__remove_missing_branch_with_detached_worktree_at_its_path.snap b/tests/snapshots/integration__integration_tests__remove__remove_missing_branch_with_detached_worktree_at_its_path.snap new file mode 100644 index 0000000000..d51d91b34a --- /dev/null +++ b/tests/snapshots/integration__integration_tests__remove__remove_missing_branch_with_detached_worktree_at_its_path.snap @@ -0,0 +1,63 @@ +--- +source: tests/integration_tests/remove.rs +info: + program: wt + args: + - remove + - feature-detached-orphan + 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_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 + LC_ALL: C + LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" + 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_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]" +--- +success: false +exit_code: 1 +----- stdout ----- + +----- stderr ----- +✗ No branch named feature-detached-orphan +↳ To list branches, run wt list --branches --remotes diff --git a/tests/snapshots/integration__integration_tests__remove__remove_unmerged_branch_with_detached_worktree_refuses.snap b/tests/snapshots/integration__integration_tests__remove__remove_unmerged_branch_with_detached_worktree_refuses.snap new file mode 100644 index 0000000000..6a75bc3c84 --- /dev/null +++ b/tests/snapshots/integration__integration_tests__remove__remove_unmerged_branch_with_detached_worktree_refuses.snap @@ -0,0 +1,63 @@ +--- +source: tests/integration_tests/remove.rs +info: + program: wt + args: + - remove + - feature-detached-unmerged + 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_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 + LC_ALL: C + LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" + 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_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]" +--- +success: false +exit_code: 1 +----- stdout ----- + +----- stderr ----- +✗ Branch feature-detached-unmerged has no worktree; the one @ _REPO_.feature-detached-unmerged is detached +↳ To remove the detached worktree, run wt remove _REPO_.feature-detached-unmerged From dee7d77ae7d9217ef5ccc33ac9491380e9d0535b Mon Sep 17 00:00:00 2001 From: Maximilian Roos Date: Sun, 9 Aug 2026 16:03:00 -0700 Subject: [PATCH 4/4] docs(remove): scope the detached-worktree note to removals that delete a ref MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The paragraph was written when the guard was unconditional, so it promised a refusal that `--no-delete-branch` and `[remove] delete-branch = false` turn off — and those are the users most likely to go looking, since what they actually see is the inaccurate `○ No worktree found for branch …` that #3769 opens with. Co-Authored-By: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com> Co-Authored-By: Claude Opus 5 (1M context) --- docs/content/remove.md | 2 +- plugins/worktrunk/skills/worktrunk/reference/remove.md | 2 +- skills/worktrunk/reference/remove.md | 2 +- src/cli/mod.rs | 2 +- .../integration__integration_tests__help__help_remove_long.snap | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/content/remove.md b/docs/content/remove.md index 680b074542..e09fcb276f 100644 --- a/docs/content/remove.md +++ b/docs/content/remove.md @@ -56,7 +56,7 @@ Branches matching these conditions and with empty working trees are dimmed in `w Those six ask whether deleting loses work. A branch checked out in a second worktree (only reachable via `git worktree add --force`) fails a different test: deleting the ref would leave that worktree unable to resolve `HEAD`, which is why `git branch -d` refuses the same delete. Such a branch is retained whatever `-D` asks, and the surviving checkout is named. -Detaching a worktree's HEAD severs the only link git records between it and the branch, so `wt remove ` would delete the ref and leave the worktree registered. It refuses instead and names the path, the one spelling that still removes the worktree. +Detaching a worktree's HEAD severs the only link git records between it and the branch, so a `wt remove ` that would delete the ref refuses instead and names the path, the one spelling that still removes the worktree. Under `--no-delete-branch` (or `delete-branch = false`) no ref is deleted, so nothing is stranded and the removal stays a no-op. ## Force flags diff --git a/plugins/worktrunk/skills/worktrunk/reference/remove.md b/plugins/worktrunk/skills/worktrunk/reference/remove.md index c3373f6bef..1ea9738f22 100644 --- a/plugins/worktrunk/skills/worktrunk/reference/remove.md +++ b/plugins/worktrunk/skills/worktrunk/reference/remove.md @@ -55,7 +55,7 @@ Branches matching these conditions and with empty working trees are dimmed in `w Those six ask whether deleting loses work. A branch checked out in a second worktree (only reachable via `git worktree add --force`) fails a different test: deleting the ref would leave that worktree unable to resolve `HEAD`, which is why `git branch -d` refuses the same delete. Such a branch is retained whatever `-D` asks, and the surviving checkout is named. -Detaching a worktree's HEAD severs the only link git records between it and the branch, so `wt remove ` would delete the ref and leave the worktree registered. It refuses instead and names the path, the one spelling that still removes the worktree. +Detaching a worktree's HEAD severs the only link git records between it and the branch, so a `wt remove ` that would delete the ref refuses instead and names the path, the one spelling that still removes the worktree. Under `--no-delete-branch` (or `delete-branch = false`) no ref is deleted, so nothing is stranded and the removal stays a no-op. ## Force flags diff --git a/skills/worktrunk/reference/remove.md b/skills/worktrunk/reference/remove.md index c3373f6bef..1ea9738f22 100644 --- a/skills/worktrunk/reference/remove.md +++ b/skills/worktrunk/reference/remove.md @@ -55,7 +55,7 @@ Branches matching these conditions and with empty working trees are dimmed in `w Those six ask whether deleting loses work. A branch checked out in a second worktree (only reachable via `git worktree add --force`) fails a different test: deleting the ref would leave that worktree unable to resolve `HEAD`, which is why `git branch -d` refuses the same delete. Such a branch is retained whatever `-D` asks, and the surviving checkout is named. -Detaching a worktree's HEAD severs the only link git records between it and the branch, so `wt remove ` would delete the ref and leave the worktree registered. It refuses instead and names the path, the one spelling that still removes the worktree. +Detaching a worktree's HEAD severs the only link git records between it and the branch, so a `wt remove ` that would delete the ref refuses instead and names the path, the one spelling that still removes the worktree. Under `--no-delete-branch` (or `delete-branch = false`) no ref is deleted, so nothing is stranded and the removal stays a no-op. ## Force flags diff --git a/src/cli/mod.rs b/src/cli/mod.rs index bd5e118265..7bbf6f4b97 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1286,7 +1286,7 @@ Branches matching these conditions and with empty working trees are dimmed in `w Those six ask whether deleting loses work. A branch checked out in a second worktree (only reachable via `git worktree add --force`) fails a different test: deleting the ref would leave that worktree unable to resolve `HEAD`, which is why `git branch -d` refuses the same delete. Such a branch is retained whatever `-D` asks, and the surviving checkout is named. -Detaching a worktree's HEAD severs the only link git records between it and the branch, so `wt remove ` would delete the ref and leave the worktree registered. It refuses instead and names the path, the one spelling that still removes the worktree. +Detaching a worktree's HEAD severs the only link git records between it and the branch, so a `wt remove ` that would delete the ref refuses instead and names the path, the one spelling that still removes the worktree. Under `--no-delete-branch` (or `delete-branch = false`) no ref is deleted, so nothing is stranded and the removal stays a no-op. ## Force flags diff --git a/tests/snapshots/integration__integration_tests__help__help_remove_long.snap b/tests/snapshots/integration__integration_tests__help__help_remove_long.snap index 878926a396..7959609ebd 100644 --- a/tests/snapshots/integration__integration_tests__help__help_remove_long.snap +++ b/tests/snapshots/integration__integration_tests__help__help_remove_long.snap @@ -148,7 +148,7 @@ Branches matching these conditions and with empty working trees are dimmed in [ Those six ask whether deleting loses work. A branch checked out in a second worktree (only reachable via git worktree add --force) fails a different test: deleting the ref would leave that worktree unable to resolve HEAD, which is why git branch -d refuses the same delete. Such a branch is retained whatever -D asks, and the surviving checkout is named. -Detaching a worktree's HEAD severs the only link git records between it and the branch, so wt remove  would delete the ref and leave the worktree registered. It refuses instead and names the path, the one spelling that still removes the worktree. +Detaching a worktree's HEAD severs the only link git records between it and the branch, so a wt remove  that would delete the ref refuses instead and names the path, the one spelling that still removes the worktree. Under --no-delete-branch (or delete-branch = false) no ref is deleted, so nothing is stranded and the removal stays a no-op. Force flags