Skip to content

fix(remove): name the detached worktree a branch-only removal leaves behind - #3791

Open
max-sixty wants to merge 8 commits into
mainfrom
worktree-path-not-found-error
Open

fix(remove): name the detached worktree a branch-only removal leaves behind#3791
max-sixty wants to merge 8 commits into
mainfrom
worktree-path-not-found-error

Conversation

@max-sixty

@max-sixty max-sixty commented Aug 9, 2026

Copy link
Copy Markdown
Owner

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:

$ wt remove feature
○ No worktree found for branch feature; a detached worktree is @ ~/repo.feature
↳ To remove the detached worktree, run wt remove ~/repo.feature
✓ Removed branch feature (same commit as main, _)

--format=json carries the same fact as detached_worktree on 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-path template, 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 removal wt remove refuses, 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:

  • Nothing was ever lost. 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. Even wt remove -D on an unmerged branch leaves the commits reachable: a detached worktree's HEAD is a GC root, and the commit survives gc --prune=now with no branch pointing at it.
  • So the refusal protected nothing in three of four cases--no-delete-branch, unintegrated SafeDelete, and integrated SafeDelete — and in the fourth the user had typed -D.
  • It contradicted the addressing model. 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.
  • It cost real work: one branch with a detached directory at its path failed a whole multi-branch cleanup.

detached_worktree_for survives 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, a deletion_mode gate — goes with it, since an extra line of output needs no narrowing.

Two fixes from those rounds stand on their own and are kept:

  • The double-escaped hint path. format_path_for_display already returns a shell-ready token, so composing it with suggest_command escaped it twice: a worktree under $HOME rendered as wt 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_display now documents the trap, which until then lived only in pr_mr_switch_hint's docstring.
  • live_sibling_checkout's docstring, explaining why that predicate stays on exists() rather than the worktree_is_unusable union 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_detached pins the behavior — branch removed, worktree untouched, output naming it — and test_remove_json_reports_detached_worktree pins the machine channel. Snapshots cover the wording, the --no-delete-branch no-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.

This was written by Claude Code on behalf of max-sixty

…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 worktrunk-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

Comment thread src/git/error.rs Outdated
`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 worktrunk-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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 detached

Without 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, then wt remove <name> reports ✗ No branch named … with the branch-list hint.
  • test_remove_branch_with_detached_worktree_keeping_branch--no-delete-branch exits 0 with ○ No worktree found for branch ….
  • test_remove_unmerged_branch_with_detached_worktree_refuses — pins the SafeDelete-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 worktrunk-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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>
@max-sixty max-sixty changed the title fix(remove): refuse to strand a detached worktree when removing by branch fix(remove): name the detached worktree a branch-only removal leaves behind Aug 11, 2026

@worktrunk-bot worktrunk-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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-eligible

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

Comment thread tests/integration_tests/remove.rs
`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>
max-sixty pushed a commit that referenced this pull request Aug 11, 2026
…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>
max-sixty pushed a commit to max-sixty/tend that referenced this pull request Aug 11, 2026
…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 worktrunk-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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]

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

Labels

None yet

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