Skip to content

feat(worktree): report the trees prune-merged must never remove (BACKLOG #1294) - #451

Merged
wshallwshall merged 4 commits into
mainfrom
lander/prune-report-only
Aug 19, 2026
Merged

feat(worktree): report the trees prune-merged must never remove (BACKLOG #1294)#451
wshallwshall merged 4 commits into
mainfrom
lander/prune-report-only

Conversation

@wshallwshall

Copy link
Copy Markdown
Collaborator

Closes BACKLOG #1294.

prune-merged.ps1 now evaluates every registered worktree and emits REPORT-ONLY rows plus a
copy-pasteable command block for the ones outside its candidate set.

No -Apply path for them, no -Name override, and worktree_gate.ps1 is unchanged. The
exclusions stay exactly as they were -- .claude/worktrees is where EnterWorktree relocates a live
session, and this tool once removed an occupied worktree, deregistering it and then failing to delete
the directory, after which every git command in the working session failed. Only discovery is
automated; the human stays the actuator.

Measured motivation: 36 registered worktrees, 11 reachable by this tool. On 2026-08-19 the owner
removed 12 of the excluded ones by hand off a list assembled manually, because nothing produced it.

The risk model took three cuts, and the controls caught each

Cut 1 listed every idle clean tree under a "look finished" banner -- printing a removal command
beside rows reading 37 commit(s) not on origin/main. The content column contradicted the banner,
and the banner is what gets read.

Cut 2 withheld anything whose touched paths still differed from main. It failed its own
positive control
: a branch whose content had landed, as a squash, was withheld because main's
copy of a shared file had moved on for unrelated reasons. In a repo where docs/BACKLOG.md changes
hourly that withholds everything, and an always-empty report is not a safe report -- it is an ignored
one.

Cut 3 asks the question that matters. git worktree remove does not delete branches:

  • a tree on a branch keeps its commits through the ref after removal -- rung 2 of the
    recoverability ladder, so no content test is meaningful;
  • a detached tree is the only one removal can strand -- rung 3, a race against gc -- so it is
    listed only when some other ref already contains its tip.

Fails closed throughout

An unavailable fence, an unreadable activity time, or an unreadable ref list all withhold. The
withheld count is printed and carried in the JSON so the omission is visible; the withheld
trees are not named, because naming them re-creates the suggestion the withholding exists to
avoid.

Occupancy uses Get-WorktreeOccupants from occupancy.ps1 rather than a hand-rolled filter. The
session field is WorktreePath; my first version guessed Worktree/Cwd, which matches nothing and
would have reported every tree as unoccupied -- the failure direction that suggests removals.

Tests

Three added, and the negative control was run on all three: neutering the collection reds every
one.

One of them originally passed with the feature completely broken, because it asserted a tree was
absent from a list that was empty. It now asserts the list is populated first, and its -IdleHours
was wrong for the fixture -- which is why that assertion fires.

78 passed. ruff check and ruff format --check clean on the changed test file.

@wshallwshall
wshallwshall enabled auto-merge (squash) August 19, 2026 15:38
…LOG #1294)

Evaluates every registered worktree and emits REPORT-ONLY rows, plus a
copy-pasteable command block, for the ones outside the candidate set. There is
no -Apply path for them, no -Name override, and worktree_gate.ps1 is unchanged.
The exclusions stay exactly as they were: that directory is where EnterWorktree
relocates a live session, and this tool once removed an occupied one. Only
DISCOVERY is automated -- the human stays the actuator.

THE RISK MODEL TOOK THREE CUTS AND THE CONTROLS CAUGHT EACH.

Cut 1 listed every idle clean tree under a "look finished" banner, printing a
removal command beside rows reading "37 commit(s) not on origin/main". The
column contradicted the banner and the banner is what gets read.

Cut 2 withheld anything whose touched paths still differed from main. That
failed its own positive control: a branch whose content HAD landed as a squash
was withheld because main's copy of a shared file had moved on for unrelated
reasons. In this repo that withholds everything, and an always-empty report is
not a safe report, it is an ignored one.

Cut 3 asks the question that matters. `git worktree remove` does not delete
branches, so a tree on a branch keeps its commits through the ref and no content
test is meaningful. A DETACHED tree is the only one removal can strand, so it is
listed only when some other ref already contains its tip. That is rung 2 versus
rung 3 of the recoverability ladder, not a merge test.

Uses Get-WorktreeOccupants from occupancy.ps1 rather than a hand-rolled filter:
the session field is WorktreePath, and my first version guessed Worktree/Cwd,
which matches nothing and reports every tree as unoccupied -- the failure
direction that suggests removals.

Withholding fails closed throughout: an unavailable fence, an unreadable
activity time or an unreadable ref list all withhold. The withheld COUNT is
printed and carried in the JSON so the omission is visible, but the withheld
trees are not named -- naming them would re-create the suggestion the
withholding exists to avoid.

Three tests, and the negative control was run on all three: neutering the
collection reds every one. One of them originally passed with the feature fully
broken, because it asserted a tree was ABSENT from a list that was empty; it now
asserts the list is populated first, and its -IdleHours was wrong for the
fixture, which is why that assertion fires.

78 passed. ruff check and ruff format --check clean on the changed test file.
Banner flipped to SHIPPED with the three-cut history recorded, since the reason
the shape is a REPORTER rather than a wider fence is the part worth keeping.

Verified with parse_items rather than by eye, and diffed against origin/main by
item number: exactly #1294 changed, open True -> False; nothing added, nothing
removed; totals 311/231 -> 311/230 open, the expected 0/-1/+1 for closing one.

The first draft of this edit put a second banner character inside the item body
as a nested quote. That is the exact defect docs/LEDGER-GATE.md records -- a
character from either alphabet inside a body parses as a status banner -- so it
was removed rather than kept as emphasis.
A defect in the report-only path as first written. The commands it emits are
plain `git worktree remove`, which -- unlike this script's own -Apply path
(Remove-ClaimsHeldBy, BACKLOG #345) -- does NOT release the claims that worktree
holds. So a reported row whose tree held a claim handed the operator a command
that stranded it.

A stranded claim is worse than an orphaned worktree: claim.ps1 -Release is
worktree-scoped, so once the holder is gone nobody can release it normally and
the key reads as actively-being-built forever. Measured on this clone today, 19
of 28 live claims were already orphaned exactly that way.

Withholding rather than emitting a release command beside the removal, because a
claim is positive evidence the tree is NOT finished -- somebody registered work
in it.

Unreadable is not absent, matching Remove-ClaimsHeldBy: a claim file that cannot
be parsed might name this worktree, so it withholds. Fails closed.

Runs as a second pass only because $claimsDir is not resolved until later in the
script.

Test asserts the tree IS reported before the claim exists, so it cannot pass
vacuously, and derives the common dir with rev-parse rather than typing .git/ --
in a worktree .git is a FILE, so the bare form writes the claim where nothing
reads it, which looks exactly like the feature working. Negative control run:
disabling the filter reds it.

79 passed. ruff check and ruff format clean.
@wshallwshall
wshallwshall force-pushed the lander/prune-report-only branch from 2aa306c to 0eb2bd9 Compare August 19, 2026 15:47
@wshallwshall
wshallwshall merged commit 79fca39 into main Aug 19, 2026
38 of 39 checks passed
@wshallwshall
wshallwshall deleted the lander/prune-report-only branch August 19, 2026 18:06
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.

1 participant