fix(remove): refuse to strand a detached worktree when removing by branch - #3770
fix(remove): refuse to strand a detached worktree when removing by branch#3770worktrunk-bot wants to merge 8 commits into
Conversation
…anch `wt remove <branch>` resolves branch-first, and detaching a worktree's HEAD drops it out of that lookup. The removal degraded to a branch-only deletion: the branch was deleted, the worktree and its registration stayed behind, and the command exited 0. Branch-only resolution now checks whether a detached worktree sits at the branch's expected `worktree-path`, and refuses when one does, naming the path-based removal that reaches it. Closes #3769
worktrunk-bot
left a comment
There was a problem hiding this comment.
This touches the deletion surface (wt remove, src/commands/remove.rs / src/git/error.rs), so per the repo's review policy I'm not authorizing the merge and have requested review from @max-sixty. (Self-authored anyway, so this is a COMMENT.)
The diagnosis and the refusal direction both look right to me, and the reproduction test is the right one. Three things I'd want a second look at:
The guard sits one layer above the other producers of RemoveTarget::BranchOnly. The check runs in validate_remove_targets, which is only the wt remove <branch…> CLI path. Two other call sites construct that target and go straight to prepare_worktree_removal: parse_removal_target in src/commands/picker/mod.rs (a branch-only picker row's token is the bare branch name — its own unit test test_prepare_removal_resolves_branch_only_item exercises exactly that decode → prepare_removal route), and the CheckSource::Orphan arm in src/commands/step/prune.rs. So wt switch --branches + alt-x on the row for a branch whose worktree has since been detached still deletes the ref and leaves the worktree registered — the same #3769 outcome. The PR description's "the picker's rows are path-addressed (#1661 / #1665)" holds for worktree-backed rows, but a branch-only row is addressed by branch, and that's the row this bug lives on. (Traced by reading the code; I didn't drive the picker.) There's already a refusal of exactly this shape — same trigger, same wt remove <path> remedy — inside prepare_worktree_removal's RemoveTarget::BranchOnly arm in src/commands/repository_ext.rs, the one that bails with "Branch … gained a worktree @ … since it was selected". Putting the detached check next to it would cover every producer in one place; the cost is that prepare_worktree_removal has no UserConfig today, so compute_worktree_path would need one threaded in (the picker and prune both have config available at their call sites). That tradeoff is the judgment call I'd want a human on.
wt remove <default-branch> with a detached main worktree now gets the wrong message. compute_worktree_path returns the repo root for the default branch in a non-bare repo, so if the main worktree's HEAD is detached, wt remove main resolves BranchOnly (nothing has main checked out), matches the main worktree, and refuses with To remove the detached worktree, run wt remove <repo-root> — a command that then fails with The main worktree cannot be removed. Before this change it reached check_not_default_branch and said "The default branch cannot be removed", which is the accurate answer. Skipping the new check when the computed path is the repo root would keep that.
Minor: detached_worktree_for doesn't skip prunable entries, and WorktreeInfo::is_prunable's own docstring says "Most iteration over worktrees should skip prunable ones". A stale detached registration whose directory is already gone now blocks wt remove <branch> and names a path that isn't on disk. The suggested command does still work (paths_match canonicalizes through missing leaves), so this is a message-quality issue rather than a dead end.
Also worth stating somewhere user-visible: the refusal only fires when the detached worktree sits at the branch's templated path, so a worktree created outside the template — or one whose worktree-path config changed since — still gets the old silent branch-only deletion. The docs paragraph reads as though the refusal is unconditional.
| // registered (#3769). Refuse instead, and name the path — the | ||
| // only spelling that still reaches it. | ||
| if let Some(detached) = | ||
| worktrees.and_then(|wts| detached_worktree_for(repo, config, &branch, wts)) |
There was a problem hiding this comment.
This is the wt remove <branch…> CLI path only. RemoveTarget::BranchOnly is also built by parse_removal_target in src/commands/picker/mod.rs (branch-only rows, shown under --branches) and by the CheckSource::Orphan arm in src/commands/step/prune.rs, both of which call prepare_worktree_removal directly and so keep the pre-fix behaviour. The RemoveTarget::BranchOnly arm of prepare_worktree_removal in src/commands/repository_ext.rs already hosts the sibling guard ("Branch … gained a worktree @ … since it was selected"), with the same trigger and the same wt remove <path> remedy — that looks like the spot that covers every caller, at the cost of threading a UserConfig in for compute_worktree_path.
There was a problem hiding this comment.
Checked the shared placement and it breaks wt step prune: prune plans its whole sweep from one worktree-list snapshot, so a detached worktree it's about to remove as its own CheckSource::Linked candidate is still registered when the branch's CheckSource::Orphan plan is built. A refusal in prepare_worktree_removal would make that plan None → not removable, leaving prune unable to clean up either half — the agent-session workflow #3769 describes. So the guard stays on the CLI path, with the reasoning recorded at the call site.
That leaves the branch-only picker row, which you're right about. It's a weaker case — the detached worktree is its own visible row, so the user picked the branch row over an alternative in view — but it is the same mechanism, and the PR description now says so rather than claiming the picker is covered. Left for a maintainer to decide whether to extend.
Review follow-ups on the #3769 guard: - The default branch's expected path is the main worktree, so a detached main worktree matched and the refusal named `wt remove <repo-root>`, which fails. Skip non-linked worktrees so "Cannot remove the default branch" stays the answer. - Skip prunable entries: a registration whose directory is gone is stale metadata for `wt step prune`, not a worktree left on disk. - Record why the guard lives in `wt remove` rather than in the shared `prepare_worktree_removal`, and note in the docs that the branch and its detached worktree are matched by `worktree-path`.
worktrunk-bot
left a comment
There was a problem hiding this comment.
The prunable skip and the default-branch fallthrough both check out: test_remove_default_branch_with_detached_main_worktree pins the accurate Cannot remove the default branch message, and is_linked() is the right discriminator for it — cached git_dir lookup, and short-circuited behind the path match so it only runs for the one candidate. One new thing on the added line.
Still a COMMENT, not an authorization — deletion surface, @max-sixty's call.
Review follow-ups: an `is_linked()` error skipped the #3769 guard and deleted the ref, the outcome the guard exists to prevent — default to treating the worktree as linked instead. Correct the docstring's claim that the template match is the association the module already draws: a detached worktree is excluded from `is_worktree_at_expected_path` and rendered without the template, so this is a new and intentionally stronger link.
max-sixty
left a comment
There was a problem hiding this comment.
I'm not sure we need to have a specific category for "detached worktrees at the place where the branch would be"; I think we can just say the branch doesn't exist?
|
|
||
| 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 points the opposite way: the branch stops naming that worktree, so `wt remove <branch>` would delete the ref alone and leave the worktree registered. Removal refuses instead, and names the worktree — `wt remove <path>` is what reaches a detached one. A detached HEAD leaves no record of the branch, so the two are matched by `worktree-path`: a worktree placed outside that template is not recognized as the branch's, and the branch is deleted on its own. |
There was a problem hiding this comment.
this is way too verbose; create another PR to add guidance to have the share of the docs proportional to the share of the feature.
in this case it's a tiny share of the feature and probably have zero docs; it's implicit
There was a problem hiding this comment.
Cut it entirely — 30ba949. The refusal is a narrow guard whose own error message states it at the point it fires, so the help page now says nothing about it. #3772 adds the guidance to docs/CLAUDE.md as a new content principle: a behavior gets the share of the prose that matches its share of the command, and the zero case is named explicitly.
One thing the paragraph carried that is now only in the code: the refusal fires when the detached worktree sits at the branch's templated path, so a worktree placed outside worktree-path still gets the plain branch-only deletion. That bound is on detached_worktree_for's docstring and in the issue thread, not on the help page.
The refusal is a small share of wt remove and its own message explains itself, so the help page carries nothing about it.
The branch does exist here — it's the thing the bug deletes. In the repro the ref is live right up to the removal, and the output is But you're right that the category isn't new — I missed that wt already has it, on the switch side. With a detached worktree at That's
I'd take (1) if you're willing to see The option I'd argue against is having no check: the branch-only path deletes the ref and leaves the worktree registered, and once that worktree is removed by hand the commits made on its detached HEAD are unreachable — which is the Codex-session case in #3769. Docs feedback is applied (30ba949) and #3772 has the guidance. |
|
but I don't understand why this is hard: when we get the that this doesn't happen suggests we might need to make some broader changes... |
That lookup never happens — that's the bug.
Moving that lookup into Three things to know before calling that complete:
Happy to restructure the PR that way — say the word and I'll push it. |
…mand (#3772) Requested in [review feedback on #3770](#3770 (comment)): > this is way too verbose; create another PR to add guidance to have the share of the docs proportional to the share of the feature. > > in this case it's a tiny share of the feature and probably have zero docs; it's implicit The existing content principles in `docs/CLAUDE.md` govern what a piece of help text says and where it starts, but nothing says how *much* a behavior earns. That gap is what produced the paragraph the review was reading: a narrow refusal in `wt remove` got a full paragraph on the command's help page, ahead of behavior every user of the command meets. New principle 5 makes the sizing rule explicit, including the zero case — an edge case whose own error message states it at the moment it matters is left implicit. The paragraph that prompted this is already gone from #3770 (30ba949). Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
# Conflicts: # src/commands/remove.rs # src/git/error.rs
|
what's the current status? was this solved on the other branch? |
#3785 moved the leftover-directory report out of the BranchOnly arm into its own NoWorktreeAtPath arm, which this branch's detached guard sat on top of. The guard keeps the BranchOnly arm to itself; the directory verdict keeps main's new arm.
|
No — still live on $ git worktree list
/tmp/repro/myproject 7e014c7 [main]
/tmp/repro/myproject.feature 7e014c7 (detached HEAD)
$ wt remove feature
○ No worktree found for branch feature
✓ Removed branch feature (same commit as main, _)
$ echo $?
0
$ git worktree list # still registered
/tmp/repro/myproject 7e014c7 [main]
/tmp/repro/myproject.feature 7e014c7 (detached HEAD)The selector refactor doesn't reach this because it classifies path tokens, not branch tokens. Status of this PR: the guard, tests and the docs edit you asked for are in; it had gone red against So it's mergeable as-is, and it's still the narrow version: a refusal in Repro stepsgit init -q -b main /tmp/repro/myproject && cd /tmp/repro/myproject
git commit -qm init --allow-empty
wt switch --create feature
cd /tmp/repro/myproject.feature && git switch --detach -q HEAD
cd /tmp/repro/myproject && wt remove featureBuilt from |
worktrunk-bot
left a comment
There was a problem hiding this comment.
Still a COMMENT, not an authorization — deletion surface (wt remove, src/commands/remove.rs / src/git/error.rs), so per the repo's review policy this stays @max-sixty's call. Self-authored anyway.
The merge resolution in 80e796b holds up: the guard keeps the BranchOnly arm and #3785's leftover-directory verdict keeps its own NoWorktreeAtPath arm, with no path that reaches both. unwrap_or(true) is in.
Two new things, both the same seam. The guard runs in validate_remove_targets, ahead of every check prepare_worktree_removal makes, so it answers before the command knows whether a branch exists or whether it was going to delete one — and fires in two states where nothing is at risk. Both reproduced against a build of this branch at 80e796b.
1. It preempts the branch-existence check, and claims a branch that isn't there. prepare_worktree_removal's BranchOnly arm is what reports a typo or a remote-only name ("Check the branch exists locally, so a typo or a remote-only name reports itself rather than deleting nothing" — the exists_locally() / RemoteOnlyBranch block in src/commands/repository_ext.rs). The guard never gets there, so a name that is not a local branch but whose templated path holds a detached worktree now reports a branch that doesn't exist:
$ git branch -D feature # detach first, then drop the ref
$ wt remove feature
✗ Branch feature has no worktree — the worktree @ /tmp/repro/myproject.feature is detached
↳ To remove the detached worktree, run wt remove /tmp/repro/myproject.featureWithout the detached worktree at that path the same command says No branch named feature. The hint is still the right remedy, so this is message accuracy rather than a dead end — same shape as the default-branch case, which got a skip rather than a reworded title. A remote-only name lands the same way, reporting has no worktree instead of RemoteOnlyBranch.
2. It fires when the removal wasn't going to delete the ref. Branch-only removal under --no-delete-branch (or [remove] delete-branch = false) deletes nothing — it prints ○ No worktree found for branch … and exits 0. The guard makes it exit 1:
$ wt remove --no-delete-branch feature
✗ Branch feature has no worktree — the worktree @ /tmp/repro/myproject.feature is detached
↳ To remove the detached worktree, run wt remove /tmp/repro/myproject.featureNothing was going to be stranded there, because nothing was going to be deleted. For anyone running delete-branch = false that is the default path, so every wt remove <branch> whose worktree has been detached becomes a hard failure. The inline suggestion gates on deletion_mode.should_keep(), which restores the old exit 0 — worth naming the cost, though: what it restores is the ○ No worktree found for branch feature line, which is inaccurate for exactly the reason #3769 opens with. Keeping the message but not making it fatal is the other shape, and that's a design call rather than a fix.
Verification
Built this branch at 80e796b (cargo build), then in a scratch repo: wt switch --create feature, git switch --detach in the worktree. Both suggestions applied together rebuild clean and give:
$ wt remove feature # #3769 case — still refuses
✗ Branch feature has no worktree — the worktree @ …/myproject.feature is detached
$ wt remove --no-delete-branch feature # no longer fatal
○ No worktree found for branch feature
$ git branch -D feature && wt remove feature
✗ No branch named feature…e a ref 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 <name> has no worktree` instead of `No branch named <name>` (or `RemoteOnlyBranch`). Check `exists_locally` first, failing closed on a lookup error so the #3769 case is never skipped. 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()`.
worktrunk-bot
left a comment
There was a problem hiding this comment.
Still a COMMENT, not an authorization — deletion surface (wt remove, src/commands/remove.rs), so per the repo's review policy this stays @max-sixty's call. Self-authored anyway.
Both suggestions from the last round landed verbatim, and the two new tests pin the shapes they restore: test_remove_missing_branch_with_detached_worktree_at_its_path snapshots ✗ No branch named … with the branch-list hint, and test_remove_branch_with_detached_worktree_keeping_branch snapshots exit 0 with the ○ No worktree found for branch … line. One correction on the new comment (inline), and one thing the should_keep() gate leaves next to it.
The Keep gate's own reasoning covers a second case it doesn't gate. The comment's justification is that under Keep "this arm deletes nothing, so there is no ref to strand the worktree behind". That is also true under SafeDelete when the branch isn't integrated: the deletion attempt returns BranchDeletionOutcome::NotDeleted, the ref is retained, and the command exits 0 — remove_branch_only_unmerged pins that shape (○ Branch feature-unmerged retained; has unmerged changes, exit 0). The guard runs in validate_remove_targets, ahead of any integration check, so an unintegrated branch whose worktree has since been detached now hard-fails at exit 1 where it previously exited 0 having deleted nothing — the same "turns a no-op into a failure" shape the Keep gate just removed. Unlike Keep, integration status isn't free at guard time (it's Repository::integration_reason, run downstream at deletion time), so this isn't a one-line gate, and refusing is the conservative direction — but it is a behavior change for anyone carrying an unmerged branch whose worktree got detached, and it isn't what the comment describes. Traced by reading; not built.
…tees `exists_locally` reports a failed lookup as `Ok(false)`, so `unwrap_or(true)` never fires and the guard is skipped rather than held — what keeps that from deleting a ref is the identical call downstream in `prepare_worktree_removal`, which bails with `BranchNotFound` / `RemoteOnlyBranch`. The neighbouring `is_linked().unwrap_or(true)` *is* a real fail-closed, so say which is which. Also name the case the `Keep` gate's reasoning covers but doesn't gate: an unintegrated branch under `SafeDelete` retains its ref too, but only the downstream deletion attempt knows that, so a detached worktree refuses there rather than exiting 0. Pinned by `test_remove_unmerged_branch_with_detached_worktree_refuses`.
|
Closing — superseded by #3791, which re-derives this fix against current The branch stays put if anything needs to come back off it. |
Problem
wt remove <branch>on a worktree whose HEAD has since been detached deleted the branch, left the worktree registered, and exited 0 reporting success (#3769).Worktrees are addressed branch-first (
Repository::resolve_worktree), and detaching a HEAD severs the only link git records between a worktree and its branch. So the lookup finds nothing, resolution falls through toResolvedWorktree::BranchOnly, and the removal degrades to a plain ref deletion. Nothing in the output says a removal was skipped — the○ No worktree found for branch …line reads as information and the✓ Removed branch …line reads as the result.Reproduced exactly as reported before the fix:
Solution
Branch-only resolution in
wt removenow asks whether a removable detached worktree sits at the branch's expectedworktree-path, and refuses when one does:Refusing rather than removing follows the repo's data-safety rule (prefer failure over silent loss): once the HEAD is detached, only the path template associates the worktree with the branch, and commits made on a detached HEAD become unreachable when the worktree goes. The user removes the worktree by path, then deletes the branch — both steps explicit.
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 <path>refuses anyway — a detached main worktree matches the default branch, whose removal already reports the accurateCannot remove the default branch; and a prunable entry, whose directory is already gone.Placement
The guard sits in
validate_remove_targets(thewt remove <branch…>CLI path) rather than inprepare_worktree_removal, which every producer of aRemoveTarget::BranchOnlyshares.wt step pruneplans its whole sweep from one worktree-list snapshot, so a detached worktree it is about to remove as its ownCheckSource::Linkedcandidate is still registered when the branch'sCheckSource::Orphanplan is built — refusing there would make prune unable to clean up either half, which is exactly the agent-session workflow the issue describes.That leaves one surface with the old behavior:
alt-xon a branch-only picker row for a branch whose worktree has since been detached. It is a materially weaker case — the picker shows the detached worktree as its own row, so the user picked the branch row over a visible alternative — but it is the same mechanism, and worth a maintainer's call on whether to cover it.Testing
test_remove_branch_with_detached_worktree_refuses— the reproduction: asserts non-zero exit, the branch surviving, and the worktree left intact. Confirmed failing before the fix with the exact output from the issue.test_remove_branch_with_detached_worktree_message— snapshots the refusal and its hint.test_remove_default_branch_with_detached_main_worktree— a detached main worktree still reportsCannot remove the default branch, not a dead-end path suggestion.test_remove_branch_with_prunable_detached_worktree— a stale registration whose directory is gone still takes the branch-only path.test_remove_detached_worktree_in_multi— existing multi-target test; its comment already said "feature-b should fail (detached HEAD)" while the snapshot recordedexit_code: 0. Snapshot regenerated:feature-ais still removed,feature-brefuses, and the command exits 1 as a partial success.cargo test --lib --bins(2454 passed) andcargo test --test integration(1963 passed). The one failure,step_copy_ignored::test_copy_ignored_preserves_file_executable_permissions, is an artifact of this sandbox'sumask 0002(expects0644, gets0664) and is unrelated to this change.cargo fmt,cargo clippy --all-targets --all-featuresclean;--helptext updated insrc/cli/mod.rswith the generated mirrors regenerated bytest_docs_are_in_syncandtest_help.Closes #3769 — automated triage