fix(remove): name the detached worktree a branch-only removal leaves behind - #3791
fix(remove): name the detached worktree a branch-only removal leaves behind#3791max-sixty wants to merge 8 commits into
Conversation
…anch Detaching a worktree's HEAD severs the only link git records between it and the branch, so `wt remove <branch>` 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) <noreply@anthropic.com>
worktrunk-bot
left a comment
There was a problem hiding this comment.
Holding off on approval per the repo's data-loss policy: the diff edits src/commands/remove.rs (wt remove, the branch-deletion path) and src/git/error.rs's removal hints. The change narrows what gets deleted rather than widening it, but the deletion surface is a human's call — normally I'd request review from @max-sixty, who is the author here.
The guard itself checks out. compute_worktree_path returns the repo root for the default branch in a non-bare repo, so the main worktree does land at the expected path and is_linked() is what excludes it; unwrap_or(true) failing closed there is the right direction. I traced the wt step prune justification too — a live detached worktree enters the sweep as CheckSource::Linked with wt.head as its integration ref while the orphan branch enters as CheckSource::Orphan, so both halves really are candidates in one snapshot.
The hint double-escapes its path. Inline suggestion on src/git/error.rs. format_path_for_display already returns a shell-ready token, and suggest_command escapes it again — so the rendered hint is wt remove '~/repo.feature', not the wt remove ~/repo.feature this PR's description shows. For a path that genuinely needs escaping it stops being cosmetic. Note the same shape already exists outside this diff, in the "gained a worktree since it was selected" bail in prepare_worktree_removal (src/commands/repository_ext.rs, the arm matching RemoveTarget::BranchOnly that calls suggest_command("remove", &[&path], &[]) on a format_path_for_display result) — happy to push a commit fixing both if you want them to move together.
#3770 is still open. This supersedes it, but nothing has closed it yet, so the two now sit in the list with identical titles.
One residual asymmetry, for your judgment rather than a change request: prune reaches the stranded state this guard refuses to create, when the two halves disagree on integration. If the detached worktree's HEAD is unmerged (not removable) but the branch it used to hold is integrated, the orphan-branch candidate is deleted and the detached worktree stays registered — branch gone, worktree left, which is the shape the new error calls out. The docstring's reasoning covers the case where prune can clean up both; this is the case where it cleans up only the branch.
`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) <noreply@anthropic.com>
worktrunk-bot
left a comment
There was a problem hiding this comment.
Two behaviors #3770's last review round turned up in the identical guard, neither of which is in this diff — flagging them here since this is the version that lands. Both were built and pinned on fix/issue-3769 (94f7f8b0, f1f29b61), green on test (linux|macos|windows) plus codecov/patch, so they carry over as-is.
1. The guard preempts the branch-existence check, and claims a branch that isn't there. prepare_worktree_removal's branch-only arm is what reports a typo or a remote-only name (the exists_locally() / RemoteOnlyBranch block in src/commands/repository_ext.rs). detached_worktree_for fires ahead of it, so a name that is not a local branch but whose templated path holds a detached worktree 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 one @ /tmp/repro/myproject.feature is detachedWithout the detached worktree at that path the same command says No branch named feature. Fix is one check at the top of detached_worktree_for:
// 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()?;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, so for anyone running delete-branch = false every wt remove <branch> whose worktree has been detached becomes a hard failure, protecting nothing. deletion_mode is already in scope at the call site:
if let Some(detached) = worktrees
.filter(|_| !deletion_mode.should_keep())
.and_then(|wts| detached_worktree_for(repo, config, &branch, wts))
{The cost of that gate is that what comes back is the ○ No worktree found for branch feature line, which is inaccurate for exactly the reason #3769 opens with — making it accurate without making it fatal is a separate message-design call.
One case the gate's reasoning covers but deliberately doesn't gate: an unintegrated branch under SafeDelete retains its ref too (BranchDeletionOutcome::NotDeleted, exit 0), but only the downstream deletion attempt knows that — Repository::integration_reason isn't free at guard time — so those refuse at exit 1 rather than exiting 0. That's a behavior change worth naming in a comment; refusing is the conservative direction since the path it names is still on disk.
Tests
Three on fix/issue-3769, all snapshot-based alongside the existing detached-worktree cases in tests/integration_tests/remove.rs:
test_remove_missing_branch_with_detached_worktree_at_its_path— detach,git branch -D, thenwt remove <name>reports✗ No branch named …with the branch-list hint.test_remove_branch_with_detached_worktree_keeping_branch—--no-delete-branchexits 0 with○ No worktree found for branch ….test_remove_unmerged_branch_with_detached_worktree_refuses— pins theSafeDelete-unmerged refusal at exit 1.
…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>`. 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) <noreply@anthropic.com>
worktrunk-bot
left a comment
There was a problem hiding this comment.
Both behaviors from the last round are fixed and pinned, and #3770 is closed now, so that observation is stale. The exists_locally gate lands ahead of compute_worktree_path, and gating on !deletion_mode.should_keep() reads correctly against the config path too — handle_remove_command folds [remove] delete-branch into delete_branch before passing !delete_branch as keep_branch, so Keep covers the config form as well as the flag.
The help text still describes the refusal as unconditional. Inline suggestion on src/cli/mod.rs. The added paragraph in the Branch cleanup section says wt remove <branch> "refuses instead and names the path" — written when the guard was unconditional. After this push, someone running delete-branch = false gets ○ No worktree found for branch <name> and exit 0, which is the exact message #3769 opens with. The section is about branch deletion, so context does most of the work, but the sentence is the one place a delete-branch = false user would go looking, and it currently promises them a protection they don't get. Applying the suggestion desyncs the three rendered mirrors and help_remove_long.snap — cargo run -- hook pre-merge --yes regenerates both sets.
Minor, take it or leave it: detached_worktree_for now runs rev-parse --verify refs/heads/<branch> before the template match, and prepare_worktree_removal's branch-only arm runs the same lookup a few lines downstream, so every branch-only removal pays it twice. Ordering the existence check after the find is semantically identical — all three are ANDed predicates over the same inputs — and skips the subprocess entirely in the common case where no detached worktree sits at the templated path. The comment explaining why the check exists reads the same in either position.
…e a ref 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) <noreply@anthropic.com>
worktrunk-bot
left a comment
There was a problem hiding this comment.
The reworded paragraph matches the guard's gate — !deletion_mode.should_keep() is what makes --no-delete-branch / [remove] delete-branch = false fall through to the no-op, and all three rendered mirrors plus help_remove_long.snap moved with src/cli/mod.rs in the same commit. That was the last open finding; nothing new on this push.
Still not approving, per the repo's data-loss policy for the wt remove deletion surface — same hold as my first review, not a fresh concern.
The guard this branch added was solving the wrong problem. Detaching a worktree's HEAD severs the only link git records between it and the branch, so the branch really has no worktree and deleting the ref alone is the correct operation — and it never lost anything: `SafeDelete` retains an unintegrated branch, so the only refs it deleted were ones worktrunk's own integration test calls lossless, and even a forced `-D` leaves the commits reachable through the detached worktree's HEAD, which is a GC root. What #3769 actually reported was silence. `○ No worktree found for branch <name>` is true and still reads as "nothing is there" while a directory sits at exactly that path, so the removal now names it and the `wt remove <path>` that clears it, on stderr and in `--format=json`'s new `detached_worktree`. Refusing instead cost a legitimate branch cleanup and let a branch address a detached worktree — the one thing the worktree model says a branch cannot name. `detached_worktree_for` survives to find the path; it no longer decides whether the command runs, which is also why the guard-scoping it needed (a branch-existence check, a `deletion_mode` gate) goes with it: an extra line of output is harmless where a refusal had to be narrowed case by case. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
worktrunk-bot
left a comment
There was a problem hiding this comment.
The rewrite from refusal to annotation reads as the right call, and the premise holds: SafeDelete retains an unintegrated branch, so in three of four cases the old guard was refusing to protect a ref nothing was going to delete. Three findings on the new shape, none blocking.
Following the new hint after -D drops the last reference to the commits. The description's case for dropping the refusal is that nothing is lost because "a detached worktree's HEAD is a GC root, and the commit survives gc --prune=now with no branch pointing at it" — which is true right up until the user follows the hint the same command just printed. wt remove <path> on a detached worktree renders (detached HEAD, no branch to delete) and nothing else — the detached arm in handle_remove_output has no reachability check — so the one command that reads most like "nothing to lose" is the one that unroots the commit. Pre-existing behavior on the path spelling; what's new is that this PR routes the user there in one guided step, immediately after -D deleted the ref that was keeping the commit named. Whether the hint should say anything when the detached HEAD isn't reachable from any ref, or whether typing -D is consent enough, is your call rather than a change request.
Reproduced against this branch's binary
$ git worktree add -b feature ../repo.feature && git -C ../repo.feature checkout --detach HEAD
$ git -C ../repo.feature commit -m "unmerged work" # then detach
$ wt remove -D feature --foreground --yes
○ No worktree found for branch feature; a detached worktree is @ /tmp/…/repo.feature
↳ To remove the detached worktree, run wt remove /tmp/…/repo.feature
✓ Removed branch feature (--force-delete)
$ wt remove /tmp/…/repo.feature --foreground --yes
◎ Removing worktree @ /tmp/…/repo.feature... (detached HEAD, no branch to delete)
✓ Removed worktree @ /tmp/…/repo.feature (detached HEAD, no branch to delete) (3 files · 68 B)
$ git log --all --oneline
7c01f45 init # the unmerged commit is no longer reachable
$ git cat-file -e ba31643 && echo present
present # unreferenced, GC-eligibleUnder the default SafeDelete the branch is retained, so the commit stays named and the sequence is harmless — -D is what makes it reachable.
The main-worktree exclusion is load-bearing and untested. is_linked().unwrap_or(true) in detached_worktree_for is the only thing keeping wt remove -D main from naming the repo root: compute_worktree_path returns repo_root for the default branch in a non-bare repo (the if !is_bare && branch == default_branch early return in src/commands/worktree/resolve.rs), and a detached main worktree is branch: None, not prunable, and path-matching — so every other predicate in the find passes. test_remove_default_branch_with_detached_main_worktree doesn't reach the annotation at all: check_not_default_branch errors first, and it only fires when !deletion_mode.is_force(). With -D the flow runs all the way through, which is where the guard earns its keep. Inline suggestion adds that case; it needs cargo insta test --accept --test integration for the new snapshot.
Verified against this branch's binary
$ git checkout --detach HEAD # in the main worktree
$ wt remove -D main --foreground --yes
○ No worktree found for branch main
✓ Removed branch main (--force-delete)No a detached worktree is @ … line — correct, and nothing currently pins it.
Minor: a locked detached worktree gets a hint that can't run. The docstring excludes the main worktree because "the hint would name a command that can't run"; a locked worktree is the same shape and isn't excluded — wt remove feature prints To remove the detached worktree, run wt remove /tmp/…/repo.feature, and that command exits 1 with Cannot remove repo.feature, worktree is locked. It's a soft dead end, since the lock error carries its own git worktree unlock hint, so narrowing the docstring's claim rather than adding a fourth exclusion may be the whole fix.
Holding approval per the repo's data-loss policy — the diff edits src/commands/remove.rs's branch-deletion path. Same standing hold as my earlier reviews, not a fresh concern.
`compute_worktree_path` returns the repo root for the default branch of a non-bare repo, so a detached main worktree passes every predicate in `detached_worktree_for` except `is_linked()` — which is the only thing stopping the removal from pointing a user at a directory `wt remove` refuses outright. Nothing pinned that: the existing default-branch test never reaches the annotation, because `check_not_default_branch` errors first and only fires when the removal isn't forced. `-D` skips that error and runs the whole way through, so that is where the test belongs. Verified by dropping the predicate, which makes the new snapshot name the repo root. The docstring said the exclusion exists because "the hint would name a command that can't run", which a locked worktree also satisfies while being named all the same. What actually decides it is whether the hint leads anywhere: a lock refusal carries the `git worktree unlock` that clears the way, and the main worktree's carries nothing. Co-Authored-By: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…er (#3796) Comment-only. Two adjacent comments in `prepare_worktree_removal`'s `WorktreePath` arm describe a routing that cf822f7 ("guard a registered path that no longer holds its worktree", #3785) changed underneath them. The branch-only cleanup's comment still ends with *"A detached worktree has no branch to fall back to, so it proceeds and surfaces the removal error."* It no longer proceeds. A detached worktree whose directory is gone fails the first arm's `wt.branch.as_deref()` test, and git reports exactly that registration as `prunable` — verified against git directly: ``` $ git worktree add --detach ../det HEAD && rm -rf ../det && git worktree list --porcelain worktree /tmp/gt/det HEAD 7c8964f04dc09ce2dff86351d5b63906ac4de41e detached prunable gitdir file points to non-existent location ``` So it lands in the `else if wt.is_prunable()` arm added by that commit and is refused at planning with `WorktreeMissing` plus the `git worktree prune` hint. Behavior is unchanged by this PR; the old path also ended in an error, just git's raw one. That second arm's own comment has the mirror-image gap: it opens *"Registered, directory present"*, which names only the deleted-and-recreated shape, while the detached-and-absent shape reaches it too. It also says the cleanup above "needs the directory gone" when that arm wants a branch *and* an absent directory — the missing half is precisely why the detached case falls through. Both comments now name the two shapes the arm catches and what each cleanup actually requires. No regression test: the change is comments, and the routing it describes is already pinned by the prunable-registration tests that landed with #3785. Deliberately narrow. The asymmetry it exposes — a stale branch-carrying registration is cleaned up by `wt remove` while a stale detached one is refused — overlaps #3769 and #3791, so it is left to those rather than folded in here. --------- Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
…947) Found by `review-reviewers` analysing `max-sixty/worktrunk` ([run 31510773013](https://github.com/max-sixty/tend/actions/runs/31510773013)). Evidence log: https://gist.github.com/a88c03f4d0c3fb1791060ff3dd97d1c4 ## What happened Worktrunk's Claude subscription hit its weekly limit during the window, and two runs failed with `You've hit your weekly limit · resets 2am (UTC)` (visible in each session JSONL, zero tokens billed on the first): | Run | Workflow | Agent outcome | Recorded on the tracker? | |---|---|---|---| | [31504411090](https://github.com/max-sixty/worktrunk/actions/runs/31504411090) | `tend-mention` | `claude -p` exit 1 | yes — filed [worktrunk#3800](max-sixty/worktrunk#3800) | | [31505266914](https://github.com/max-sixty/worktrunk/actions/runs/31505266914) | `tend-review` on [#3791](max-sixty/worktrunk#3791) | `claude -p` exit 1 | **no** | The quota exhaustion itself isn't a tend defect. What the second run exposed is: `Report failure` ran, hit a GitHub 502, and **exited 1 instead of degrading**, so the row was never appended. #3800 still reads `updated_at: 2026-08-11T15:00:53Z` with zero comments and a single row — it reports one stranded run when there were two, and the run it omits is the one whose review of #3791's new HEAD never happened. <details><summary>Log evidence for the attribution</summary> From run 31505266914's `Report failure` step (13.8 s, ending in failure): ``` 2026-08-11T15:09:23.2761440Z non-200 OK status code: 502 Bad Gateway body: "<!DOCTYPE html>... 2026-08-11T15:09:23.3052745Z ##[error]Process completed with exit code 1. 2026-08-11T15:09:23.3059103Z ##[end-action id=__max-sixty_tend.__run_13;outcome=failure;conclusion=failure;duration_ms=13816] ``` The three earlier `gh` calls in the script are each ruled out, which leaves the append: - `run_issue_ensure_label` is `2>/dev/null || true`, so it can neither emit that stderr nor abort. - `run_issue_canonical` is read through `if ! EXISTING=$(...)`, whose failure path prints `::warning::Could not read this repo's tend-outage issues...` and exits 0. No `::warning::` appears anywhere in the run log, so the read succeeded. - `$EXISTING` was therefore #3800, and control reached the bare `gh issue comment` — the only unguarded write left. </details> ## Root cause `report-failure.sh` guards its read and leaves its append bare: ```bash if ! EXISTING=$(run_issue_canonical "$LABEL" open "$TITLE"); then echo "::warning::Could not read this repo's ${LABEL} issues, ..." exit 0 fi if [ -n "$EXISTING" ]; then printf '%s\n' "$ROW" | gh issue comment "$EXISTING" -F - # <- aborts under `set -e` ``` `rate-limit-preflight.sh`, the sibling caller of the same `lib/run-issue.sh`, already guards the identical call — added in `e5f0f9b`, whose comment reasons about exactly this: > Left bare it would abort here under `set -e`, costing the run the annotation below, which is worth more than the row: the issue already exists, so the annotation can still name what to close, while the row is one line of evidence among the rows the other refusals appended. That argument transfers verbatim; `report-failure.sh` was simply never given the same treatment. This is the common write path, not a corner: once a tracker is open, every later failure in the same incident appends through it — a previous outage cluster put 8 rows on [worktrunk#3780](max-sixty/worktrunk#3780) this way, all through this one call. ## The fix Wrap the append, warn, let the step end clean. Deliberately **not** symmetric — the create branch keeps its abort, because `test_report_failure_propagates_a_failed_create` already fixes that policy and the reasoning still holds: with no tracker open, a failed create leaves no record of the outage anywhere, so reddening the step is the only surviving signal. An append has a tracker that already carries the incident. The new test's docstring names the asymmetry so it doesn't get "tidied" later. `test_report_failure_survives_a_failed_append_to_the_open_tracker` reproduces the production failure: without the change it fails with `returncode 1` and the row dropped; with it, exit 0 plus the warning. Full file passes (41 tests). ## Gate assessment - **Gate 1 — confidence: High, acted on.** One production occurrence this window, but not a fresh judgement call: `e5f0f9b` is the project's already-accepted ruling that this exact mechanism on this exact call is a defect, applied to one of the two call sites. The remaining site has now fired. Failure is **structural** — given a 5xx on the append, the abort is deterministic, not a model behaviour that might go differently on a replay. - **Gate 2 — magnitude: targeted fix, normal bar.** One `if` wrapper plus a warning line, mirroring an existing guard byte-for-byte. It removes an inconsistency between two callers rather than introducing new policy. - **Dedup.** [#859](#859) (persistent failures appending *too many* rows) and [#809](#809) (matrix-leg dedup) both work the opposite axis — how many rows to write, not what happens when a write fails. [#857](#857) widens which step failures report at all. None touch the abort. No open PR modifies this file. ## Not addressed here The stranded `tend-review` on [worktrunk#3791](max-sixty/worktrunk#3791) has no retry path — the run failed before stamping the commit, and nothing re-fires until the next push, so that HEAD stays unreviewed. [#816](#816) raised both halves of this ("nothing re-runs the trigger it names… leaves the PR silently un-reviewed forever") and was closed COMPLETED on 2026-08-07; the naming half did ship, via the nightly enricher. On this evidence the re-run half looks still live, but that's one observation, so it goes in the evidence log to accumulate rather than reopening anything here. The two do compound, which is worth flagging: the tracker is the list a maintainer would re-run from, so a dropped row makes a stranded run correspondingly harder to find. That's the argument for this one-line guard, not for widening the PR. --------- Co-authored-by: tend-agent <270458913+tend-agent@users.noreply.github.com>
worktrunk-bot
left a comment
There was a problem hiding this comment.
Both findings from the last round are closed: test_remove_default_branch_force_with_detached_main_worktree reaches detached_worktree_for with -D and pins the is_linked() exclusion (snapshot shows the bare ○ No worktree found for branch main, no annotation), and the docstring now states what the exclusion actually turns on. That rewording checks out — GitError::CannotRemoveMainWorktree renders its title alone, while GitError::WorktreeLocked carries To unlock, run git worktree unlock <path>, so "the hint leads somewhere" is the distinction that separates them, not "the command succeeds".
codecov/patch is red at 97.05% (66/68), and both misses are pre-existing. They're src/output/handlers.rs's RetainedCheckedOut and RetainedRaced arms, where eprintln!("{}", info_message(&branch_info)); became announce_branch_info(); — the same statement rewritten in place. Codecov's own base report marks both lines as misses on 91b7bef, so the patch counts two lines this change didn't make uncovered. Every other file in the diff is 100%. Handing over the arithmetic per the repo's coverage policy rather than proposing test churn to move the number; covering those two arms is a separate, pre-existing question.
affected tests (linux, advisory) failed in the test-setup step, not the suite: apt-get got 403 Forbidden from packages.microsoft.com for both the azure-cli and prod repos. Transient infra — test (linux), fast-checks, and code-coverage all pass on this head.
Holding approval per the repo's data-loss policy — the diff edits src/commands/remove.rs's branch-deletion path. Same standing hold as my earlier reviews. The -D-then-follow-the-hint observation from the last round stands as your call, not a change request.
Coverage arithmetic
$ curl -sL "$API/compare/?base=91b7beff…&head=37562f5c…" | jq '.files[] | select(.has_diff) | {name: .name.head, patch: .totals.patch}'
src/commands/remove.rs 33/33 100%
src/output/handlers.rs 19/21 90.47%
src/commands/repository_ext.rs 2/2 100%
src/commands/picker/mod.rs 6/6 100%
src/commands/worktree/types.rs 5/5 100%
src/git/repository/worktrees.rs 1/1 100%
66/68 97.05% (target 98.13%)
$ # the two misses
1333: + announce_branch_info(); # RetainedCheckedOut arm
1341: + announce_branch_info(); # RetainedRaced arm
$ # same statements on the base, before this PR touched them
$ curl -sL "$API/file_report/src/output/handlers.rs?sha=91b7beff…" \
| jq -r '.line_coverage[] | select(.[0] == 1297 or .[0] == 1305)'
[1297, 1] # 1 = miss
[1305, 1]
wt remove <branch>on a worktree whose HEAD has since been detached reported○ No worktree found for branch <name>and said nothing more, while a directory sat at exactly that branch's path. That line is true — detaching severs the only link git records between a worktree and its branch, so the branch really has no worktree — but it reads as "nothing is there".The removal now names what it leaves behind:
--format=jsoncarries the same fact asdetached_worktreeon a branch-only removal, since the human line goes to stderr and "a script checking the exit code sees a clean run" was half of what the issue reported.The directory is found by matching the
worktree-pathtemplate, which after a detach is all that still connects the two. That is a weaker association than the rest of the module draws — the template records where a worktree would go, not that this one belongs to that branch — and it is why this only ever adds a line. A mention that occasionally points at a coincidence costs nothing. Three states go unmatched, each because naming them would mislead: a worktree checked out on some other branch (that branch names it), the main worktree (whose path-based removalwt removerefuses, so the hint would be a dead end), and a prunable entry (nothing on disk to point at).Why this doesn't refuse the removal, and what changed from the earlier commits on this branch
The first four commits made
wt remove <branch>refuse in this situation, following the issue's "either remove the worktree, or refuse". Reviewing the premise rather than the menu, refusing was the wrong call:SafeDelete— the default — retains an unintegrated branch, so the only refs the old behavior deleted were ones worktrunk's own six-condition integration test defines as lossless. Evenwt remove -Don an unmerged branch leaves the commits reachable: a detached worktree's HEAD is a GC root, and the commit survivesgc --prune=nowwith no branch pointing at it.--no-delete-branch, unintegratedSafeDelete, and integratedSafeDelete— and in the fourth the user had typed-D.CLAUDE.md: a path "names what a branch cannot (a detached worktree, one of two checkouts of a branch)". The guard let a branch reach one anyway, via a path template.detached_worktree_forsurvives to find the path; it no longer decides whether the command runs. The scoping the guard had accumulated over three review rounds — a branch-existence check, adeletion_modegate — goes with it, since an extra line of output needs no narrowing.Two fixes from those rounds stand on their own and are kept:
format_path_for_displayalready returns a shell-ready token, so composing it withsuggest_commandescaped it twice: a worktree under$HOMErendered aswt remove '~/repo.feature', where the quoting suppresses the tilde expansion, and a path needing real quoting came out carrying literal quote characters. Three call sites had it, two predating this branch.format_path_for_displaynow documents the trap, which until then lived only inpr_mr_switch_hint's docstring.live_sibling_checkout's docstring, explaining why that predicate stays onexists()rather than theworktree_is_unusableunion the rest of the removal path uses: the two disagree only on a present directory that no longer holds its worktree, and since the predicate only ever gates a deletion, it takes the conservative test.Testing
test_remove_branch_whose_worktree_was_detachedpins the behavior — branch removed, worktree untouched, output naming it — andtest_remove_json_reports_detached_worktreepins the machine channel. Snapshots cover the wording, the--no-delete-branchno-op, an unintegrated branch whose ref is retained, a prunable entry (which is named nothing, having nothing on disk), a detached main worktree (where the default-branch refusal is still the accurate answer), and the multi-target case.Fixes #3769
Thanks to @chachi for the report — the repro runs exactly as written, and the diagnosis in it (the branch→worktree lookup, not detached removal) is what this addresses.