Skip to content

fix(remove): refuse to strand a detached worktree when removing by branch - #3770

Closed
worktrunk-bot wants to merge 8 commits into
mainfrom
fix/issue-3769
Closed

fix(remove): refuse to strand a detached worktree when removing by branch#3770
worktrunk-bot wants to merge 8 commits into
mainfrom
fix/issue-3769

Conversation

@worktrunk-bot

@worktrunk-bot worktrunk-bot commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

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 to ResolvedWorktree::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:

○ No worktree found for branch feature-detached-strand
✓ Removed branch feature-detached-strand (same commit as main, _)

Solution

Branch-only resolution in wt remove now asks whether a removable detached worktree sits at the branch's expected worktree-path, and refuses when one does:

✗ Branch feature has no worktree — the worktree @ ~/code/myproject.feature is detached
↳ To remove the detached worktree, run wt remove ~/code/myproject.feature

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 accurate Cannot remove the default branch; and a prunable entry, whose directory is already gone.

Placement

The guard sits in validate_remove_targets (the wt remove <branch…> CLI path) rather than in prepare_worktree_removal, which every producer of a RemoveTarget::BranchOnly 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 CheckSource::Linked candidate is still registered when the branch's CheckSource::Orphan plan 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-x on 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 reports Cannot 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 recorded exit_code: 0. Snapshot regenerated: feature-a is still removed, feature-b refuses, and the command exits 1 as a partial success.
  • Full suite: cargo test --lib --bins (2454 passed) and cargo test --test integration (1963 passed). The one failure, step_copy_ignored::test_copy_ignored_preserves_file_executable_permissions, is an artifact of this sandbox's umask 0002 (expects 0644, gets 0664) and is unrelated to this change.
  • cargo fmt, cargo clippy --all-targets --all-features clean; --help text updated in src/cli/mod.rs with the generated mirrors regenerated by test_docs_are_in_sync and test_help.

Closes #3769 — automated triage

…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 worktrunk-bot added the automated-fix Automated CI fix label Aug 8, 2026

@worktrunk-bot worktrunk-bot left a comment

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.

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.

Comment thread src/commands/remove.rs Outdated
Comment thread src/commands/remove.rs
Comment thread src/commands/remove.rs Outdated
// 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))

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.

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.

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.

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.

@worktrunk-bot
worktrunk-bot requested a review from max-sixty August 8, 2026 03:14
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 worktrunk-bot left a comment

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.

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.

Comment thread src/commands/remove.rs Outdated
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 max-sixty left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

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?

Comment thread docs/content/remove.md Outdated

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.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

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

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.

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.
@worktrunk-bot

Copy link
Copy Markdown
Collaborator Author

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?

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 ✓ Removed branch feature with the worktree still registered. "No branch named feature" is also already taken: it's what wt remove prints for a genuinely missing branch, from the BranchOnly arm of prepare_worktree_removal, so reusing it here would report the opposite of what happened.

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 feature's templated path:

$ wt switch feature
✗ Cannot switch to feature — there's a detached worktree at the expected path /tmp/scratch/repo.feature
↳ To switch the worktree at /tmp/scratch/repo.feature to feature, run cd /tmp/scratch/repo.feature && git switch feature

That's GitError::WorktreePathOccupied { occupant: None }, raised from the same association this PR uses — the branch's computed worktree-path, occupied by a worktree no branch names. So DetachedWorktreeForBranch is a second copy of a category wt already ships, not a first one. Two ways to collapse it:

  1. Reuse WorktreePathOccupied in wt remove too. Its hint is arguably the better remedy: reattaching (cd <path> && git switch feature) restores the link, and wt remove feature then takes both halves in one command — verified in a scratch repo. The cost is the title, Cannot switch to <branch>, which has to be reworded to serve both commands, changing an error wt switch already ships.
  2. Keep the message, drop the enum variant — an inline bail! where it's detected, matching the neighbouring refusal in prepare_worktree_removal ("Branch X gained a worktree @ P since it was selected…"). Smallest diff, but a plain anyhow error has no render_diagnostic, so the title + hint block degrades to one raw line.

I'd take (1) if you're willing to see wt switch's wording move, (2) if not. Happy to push either.

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.

@max-sixty

Copy link
Copy Markdown
Owner

but I don't understand why this is hard: when we get the remove command, we should look up the location of the branch and find that the branch doesn't exist, no??

that this doesn't happen suggests we might need to make some broader changes...

@worktrunk-bot

Copy link
Copy Markdown
Collaborator Author

when we get the remove command, we should look up the location of the branch and find that the branch doesn't exist, no??

That lookup never happens — that's the bug. resolve_worktree is ref-first: it asks git's worktree list which worktree has feature checked out (worktree_for_branch), a detached entry carries no branch line there, so the answer is "none" and it falls through to branch-only. It never computes where feature's worktree would be.

wt switch does compute it — compute_worktree_path, then worktree_at_path — which is why switch already reports this state accurately and remove doesn't. Same repo, two answers, because only one of them looks at the location. So the asymmetry is the thing to fix, and you're right that it's broader than wt remove.

Moving that lookup into resolve_worktree is more contained than it sounds: three production callers (require_selected_branch, require_worktree, wt remove), and Repository already has user_config(), so layering doesn't block it — compute_worktree_path moves into the git layer beside the resolver. It also lands your first point: with the resolver answering, wt remove needs no new category at all, it reuses the WorktreePathOccupied that wt switch already raises here, and DetachedWorktreeForBranch comes out of GitError entirely.

Three things to know before calling that complete:

  • The picker's branch-only row and wt step prune build BranchOnly without going through the resolver (a decoded token; a worktree-list snapshot), so they don't inherit the fix. Prune shouldn't — it removes both halves in one sweep — but the picker row would need its own handling.
  • The fallthrough would newly call default_branch(), which can reach git ls-remote once per fresh clone, so the lookup wants to stay on the branch-only path rather than every resolution.
  • Any location-based answer is only as good as the template: a worktree moved outside worktree-path stays invisible to it. That bound doesn't go away, it just moves.

Happy to restructure the PR that way — say the word and I'll push it.

max-sixty pushed a commit that referenced this pull request Aug 8, 2026
…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
@max-sixty

Copy link
Copy Markdown
Owner

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.
@worktrunk-bot

Copy link
Copy Markdown
Collaborator Author

No — still live on main. Just reproduced it against main at f13ef96 (i.e. after #3773, #3785 and #3786 all landed):

$ 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. feature is a valid branch name, so path_selector_directory returns on its first condition before touching the filesystem, and resolve_selector falls through to BranchOnly exactly as before — the location of feature's worktree is still never computed. What #3785 changed is that there is now one ladder rather than four hand-assembled ones, which makes the resolver-level fix you pointed at (compute where the branch's worktree would be, find the detached one, stop calling that "no worktree") a single site instead of the three call sites I costed it at on Friday. The two caveats from that comment survive: the picker's branch-only row and wt step prune build BranchOnly without going through the resolver, and the fallthrough would newly call default_branch(), which can reach git ls-remote once on a fresh clone.

Status of this PR: the guard, tests and the docs edit you asked for are in; it had gone red against main (#3785 moved the leftover-directory report into its own NoWorktreeAtPath arm, which the guard was sitting on top of). Merged and resolved in 80e796b — the guard keeps the BranchOnly arm, the directory verdict keeps main's new arm. Green locally: 177 remove integration tests, cargo fmt, cargo clippy --all-targets --all-features, test_docs_are_in_sync.

So it's mergeable as-is, and it's still the narrow version: a refusal in wt remove only. The broader shape you were describing — the resolver answering "where would this branch's worktree be" — would replace it and drop DetachedWorktreeForBranch from GitError entirely. That's the call I still need from you; I'll push whichever you prefer.

Repro steps
git 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 feature

Built from origin/main at f13ef96 (wt --versionwt f13ef9663). Relevant code: resolve_selector and path_selector_directory.

@worktrunk-bot worktrunk-bot left a comment

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.

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.feature

Without 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.feature

Nothing 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

Comment thread src/commands/remove.rs
Comment thread src/commands/remove.rs Outdated
…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 worktrunk-bot left a comment

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.

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.

Comment thread src/commands/remove.rs
…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`.
@worktrunk-bot

Copy link
Copy Markdown
Collaborator Author

Closing — superseded by #3791, which re-derives this fix against current main (the arm this branch patched was reshaped by #3785 and #3786) and carries the same design: the worktree-path template match, the DetachedWorktreeForBranch error, the guard in wt remove rather than prepare_worktree_removal, and three of the four tests. It also corrects the main-worktree claim I made here — a bare repo has no main worktree, so its default-branch checkout is matched by the template.

The branch stays put if anything needs to come back off it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

automated-fix Automated CI fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

wt remove <branch> silently strands the worktree when its HEAD is detached

2 participants