Skip to content

fix(worktree): the exit-code lines print on the run they describe, and the mid-run fence death does not exist - #1247

Merged
wshallwshall merged 5 commits into
mainfrom
claude/b-exit2-operator-line
Sep 17, 2026
Merged

wshallwshall merged 5 commits into
mainfrom
claude/b-exit2-operator-line

Conversation

@wshallwshall

@wshallwshall wshallwshall commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator

Two exit-code explanation lines in prune-merged.ps1 printed only when the run ended on 2, so each went silent on the run it was written for. Both are hoisted out and keyed on their own condition, following the -ReapVenvs line that was moved out for the same reason in #1244 (6896b3921).

The brief that produced this named two remaining paths. One of them does not exist, and that is the second half of this PR.

What was fixed

Path Scenario What the operator saw
The -Name miss -Apply -Name alpha,ghost where alpha prunes and ghost matches nothing. The guard at the Set-Exit already reports 1 once something is removed. Done. removed 1 in red, with no line saying half the instruction was refused. The hardcoded Exit 2: in the line's own text was the tell.
The occupancy fence The fence is unreadable and an earlier run left a broken directory. 2 then 3, ending on 3. The recovery recipe, with nothing saying the fence was down and every candidate was ineligible for that reason.

The orphan line's elseif becomes if. That changes nothing: the old chain reached it whenever $exit was 3, because 3 is not 2.

And a tail line now names its outcome, not the run's code

The first attempt at this fix interpolated ${exit}, which gets the arithmetic right and the meaning wrong. On the very run the fence line exists for it printed Exit 3: the occupancy fence was unavailable — sending an operator to fix a fence that was only ever worth 2, over a 3 the broken directory set.

Every tail line now opens REFUSED:, FAILED: or ORPHANED:, which is already this file's vocabulary: the three preamble refusals print bare REFUSED: and a failed removal prints bare FAILED:. The run's code stays in the Done. summary and in $LASTEXITCODE. Not FAILED (1) — that would read as a sibling of the per-candidate FAILED (exit $removeExit), which carries a git exit code and is a different thing. The -ReapVenvs line takes the same treatment rather than staying the odd one out; it landed with Exit ${exit}: and had the identical hole.

What was NOT fixed, and why

The brief's other path was "a Set-Exit $EXIT_REFUSED fires inside the apply loop when the occupancy fence is lost part way through, carrying no $removed guard, so a run can remove two worktrees, lose the fence on the third, and exit 2."

No such run exists. $occ2 is read once, on the line above foreach ($d in $prunable), and nothing inside the loop re-reads it or mutates Available (checked in occupancy.ps1 too: set at construction in three places, no Add-Member, no ScriptProperty, no assignment). So the branch is all-or-nothing — if the fence is down, iteration 1 continues and so does every later one, and $removed stays 0.

Adding the guard there would be dead code that reads as protection, on a destructive tool. The invariant is recorded at the $occ2 read instead, naming what would have to be re-derived if that read ever moves inside the loop.

Seven sites can produce 2, and not one can co-occur with a removal: three bare exits in the preamble, before a candidate set exists; the decision-pass fence check, where an unavailable $occ adds a SKIP reason to every candidate so $prunable is empty and the apply loop never runs; the mid-loop check, invariant as above; and the -Name and -ReapVenvs guards, both ternary on $removed -gt 0.

So the docs move back, partway

54e8c393b (in #1244) weakened the exit-2 meaning on two grounds.

  • Kept: the list of refusal causes was closed and had gone stale. 2 is per-request and its causes are not a closed list. Unchanged.
  • Retracted: "a fence that dies PART WAY through the apply loop sets 2 over removals that already landed". 2 does still promise nothing was removed.

Three sites carried the retracted claim — the script header, the inline comment on $EXIT_REFUSED, and docs/WORKTREES.md. Three more still defined 2 as "nothing was attempted", which the base retracted correctly and this branch does not restore: a -Name miss on a dry run attempts the whole decision pass and still exits 2. Those are corrected too, though none was introduced here.

The tree already contradicted the mid-run claim: test_a_fence_that_dies_mid_run_refuses_and_says_so kills the fence between the decision pass and the removal pass and asserts counts.removed == 0 beside the 2. It is now cited rather than left implicit.

Reachability, over the AST

A grep for ^\s*exit misses four keywords and any wrapper that reads the exit variable, so the file was parsed and every Exit, Return, Throw, Break and Continue statement classified by whether an ancestor is a FunctionDefinitionAst.

89 total.  outside a function:  5 Exit, 1 Throw, 20 Continue, 2 Return
           inside one:         47 Return, 13 Continue, 1 Break

The 2 Returns outside a function are not script-level either: they are the return $true / return $false of the $matchesName scriptblock literal. Counting a ScriptBlockExpressionAst as a nesting level too moves exactly those two rows and nothing else — that is the whole of what the two predicates disagree about.

What the argument rests on survives both readings: 0 Exit sits inside a function, so no exit in the file is scoped to anything narrower than the process, and the two that end an ordinary run are both exit $exit. Re-derived at c51781eee.

Tests

Both new tests were red against the unfixed script for the right reason — every setup assertion passed and only the operator line was missing. The first reached Done. removed 1, failed 0, skipped 0 of 1 candidate(s) and exit 1 with no outcome line at all; the second reached exit 3 with the orphan recipe and no fence line.

Two more pin what the hoist made load-bearing:

  • test_a_clean_run_prints_no_exit_code_line_at_all. Silence on a green run used to be structural; now it holds only because the sites setting those conditions also raise the code. Mutated by forcing the -Name line to print unconditionally: it fails with "a green run explained a code it did not exit with: FAILED:" while its positive control (the run really prunes two worktrees) still holds, so the failure is attributable.
  • test_run_text_parsed_reports_the_scripts_own_exit_code. See below.

The neighbouring refusals stay at 2 as controls. A refusal on a run that removed nothing is still a refusal.

The -Name test needs a -Command helper, and both the reason and its cost are measured. _argv invokes through pwsh -File, which passes every argument literally: -Name a,b arrives as one element a,b and -Name a b as one element a. No test going through it can express "one name hits and another misses". The operator invocation docs/WORKTREES.md documents is a direct call from a pwsh prompt, which parses the array.

But under -Command, an invoked script's or native command's non-zero exit is reported as pwsh's own "a command failed" status of 1 unless you re-exit $LASTEXITCODE. Measured on pwsh 7.6.6, asked code to actual exit:

form 0 1 2 3
-Command "exit N" 0 1 2 3 propagates
-Command "& probe.ps1 -Code N" 0 1 1 1 collapses — this helper
-Command "cmd /c exit N" 0 1 1 1 collapses
-Command "& probe.ps1 -Code N; exit $LASTEXITCODE" 0 1 2 3 propagates
-File probe.ps1 -Code N 0 1 2 3 propagates

The two traps have opposite remedies and must not be merged into one rule. -File is faithful on the exit code and lossy on the array; -Command is the reverse. An earlier commit on this branch stated the collapse as a property of -Command itself, which would teach a reader to distrust -Command "exit 2" (sound) and to trust -File for an array (not sound). The narrower reading came from a peer session that could not reproduce the wide one; the table above is my own run of it.

Without the re-exit clause assert returncode == 1 is satisfied by 1, 2 and 3 alike. With it, deleting the $removed -gt 0 ternary makes the test fail with "removed a worktree and reported exit 2".

Checks run

Check Result
pytest tests/test_worktree_prune_merged.py 104 passed, at c51781eee, tree unmoved across the run
pytest over the four neighbouring files that read prune-merged.ps1 or WORKTREES.md 227 passed, 2 skipped (run before the last two commits; those touch no file they read)
ruff format --check, ruff check pass
mypy (strict) on the changed test file pass
every pre-commit hook pass at all five commits, unbypassed
non-ASCII added to prune-merged.ps1 0, against a control of 188 in WORKTREES.md that fired
non-ASCII added to WORKTREES.md em dashes only, matching that file's existing convention

Not run, and a hosted runner must read them: the full suite, and anything Windows-service or NSSM. The vault extra is absent from this interpreter, so its modules deselected at collection.

Notes for the lander

Branched from guard-reapvenvs while #1244 was open, then rebased onto main with --onto once #1244 squash-merged as 6896b3921. The diff is the five commits above and nothing of #1244's.

The last two commits repair defects an adversarial review found in the first two: a helper that could not distinguish exit 2 from exit 1, an AST census whose published Return split its own named predicate does not produce, a comment citing edf01a954 — a SHA reachable from neither main nor HEAD, since guard-reapvenvs squash-merged — and the Exit ${exit}: attribution above. They are kept as separate commits rather than squashed so the sequence is legible.

No BACKLOG number is cited. This came from a direct brief rather than a ledger row, and citing a number that is not allocated would resolve to unrelated work the day somebody allocates it.

wshallwshall added 5 commits September 17, 2026 16:56
Two of prune-merged.ps1's three exit-code explanation lines sat inside
`if ($exit -eq $EXIT_REFUSED)`, and two different things take a run out of
that branch. Each line then went silent on exactly the run where the halves
of the report disagree.

The -Name miss. Its Set-Exit already carries `if ($removed -gt 0)`, so
`-Apply -Name alpha,ghost` where alpha prunes and ghost matches nothing
removes one worktree and reports 1. The line naming the miss never printed:
the operator read `Done. removed 1` in red with nothing saying that half the
instruction was refused. The hardcoded "Exit 2:" in its own text is the tell.

The occupancy fence. Any more severe code silences it the same way. A run
that cannot read the fence AND finds a directory an earlier run broke sets 2
then 3, ends on 3, and prints the recovery recipe with no word that the fence
was down and every candidate was ineligible for that reason.

Both are the failure the comment beside the -ReapVenvs line already
describes, and that line was moved out of this branch for it in edf01a9.
These two follow it: each is keyed on its own condition and interpolates the
code. The orphan line's `elseif` becomes `if`, which changes nothing -- the
old chain reached it whenever $exit was 3, because 3 is not 2.

Two tests, both red against the unfixed script for the right reason: the
first reached `Done. removed 1` and exit 1 with no exit-code line at all, the
second reached exit 3 with the orphan recipe and no fence line. The
neighbouring refusals stay at 2 as controls -- a refusal on a run that
removed nothing is still a refusal, and all 102 tests in the file pass.

The -Name test needs a helper, and the reason is measured. `_argv` invokes
through `pwsh -File`, which passes every argument literally: `-Name a,b`
arrives as one element `a,b` and `-Name a b` arrives as one element `a`. No
test going through it can express "one name hits and another misses". The
operator invocation docs/WORKTREES.md documents is a direct call from a pwsh
prompt, which parses the array, so `run_text_parsed` goes through -Command.
The -File reading is asserted beside it as a control, so the departure is
shown to be doing work rather than claimed to be.
…omises nothing was removed

54e8c39 weakened the exit-2 meaning on two grounds. One holds: the list of
refusal causes was closed and had gone stale, so "2 is per-request and its
causes are not a closed list" stays exactly as written.

The other does not. It read "The universal is false on its own terms too: a
fence that dies PART WAY through the apply loop sets 2 over removals that
already landed ... the mid-run fence death does not [report 1]". No such run
exists. $occ2 is read once, on the line above `foreach ($d in $prunable)`,
and nothing inside the loop re-reads it or mutates Available, so the fence
cannot die part way through as far as this script can observe. If it is down,
iteration 1 skips and so does every later one, and $removed stays 0.

Seven sites can produce 2, and not one can co-occur with a removal: three
bare exits in the preamble, before a candidate set exists; the decision-pass
fence check, where an unavailable $occ adds a SKIP reason to every candidate
so $prunable is empty and the apply loop never runs; the mid-loop fence
check, invariant as above; and the -Name and -ReapVenvs guards, both ternary
on `$removed -gt 0`.

Established over the AST rather than by grep, because a grep for `^\s*exit`
misses four keywords and any wrapper that reads the exit variable. Parsing
the file and classifying every Exit, Return, Throw, Break and Continue by
whether an ancestor is a FunctionDefinitionAst gives 89: 5 Exit and 1 Throw
at top level, 49 Return and 1 Break nested, 20 Continue at top level and 13
nested. 0 Exit sits inside any function, and the two that end an ordinary run
are both `exit $exit`. Re-derived at this commit's tree, unchanged from the
reading at 54e8c39.

The pin already existed and is now cited rather than left implicit:
test_a_fence_that_dies_mid_run_refuses_and_says_so kills the fence between
the decision pass and the removal pass and asserts counts.removed is 0 beside
the 2. So the claim this commit retracts was contradicted by a test in the
same tree.

THE Set-Exit AT THE MID-LOOP FENCE CHECK IS DELIBERATELY NOT GUARDED. Adding
`if ($removed -gt 0)` there would be dead code that reads as protection, on a
destructive tool where that is the worst kind of comment. The invariant is
recorded at the $occ2 read instead, naming what would have to be re-derived
if that read ever moves inside the loop.

Three sites carried the retracted claim: this script's header paragraph, the
inline comment on $EXIT_REFUSED, and docs/WORKTREES.md. The fourth site the
census turned up, WORKTREES.md's -ReapVenvs sentence, describes the guard
rather than the exception and is correct as it stands.
… AST census names its own predicate

Two defects in the two commits above, both found by an adversarial review of
this branch and both reproduced before being fixed.

THE HELPER COLLAPSED EVERY NON-ZERO CODE TO 1. `pwsh -Command` reports its
own success or failure, not the script's. Measured on pwsh 7.6.6 against a
probe that exits with what it is asked for:

    asked                            0   1   2   3
    -File                            0   1   2   3
    -Command                         0   1   1   1
    -Command "; exit $LASTEXITCODE"  0   1   2   3

So `assert proc.returncode == 1` in the -Name test was satisfied by 1, 2 and
3 alike, while reading identically to the genuine one in
test_the_venv_refusal_is_explained_on_the_run_that_reports_FAILED, which goes
through -File. Only the stdout assertion beside it was doing any work.

The clause is added and the assertion now discriminates: with the guard at
the -Name Set-Exit deleted, the run exits 2 and the test fails with "removed
a worktree and reported exit 2". Before the clause it would have passed.

test_run_text_parsed_reports_the_scripts_own_exit_code pins the helper on its
own, so the next test written through it cannot inherit the hole. -IdleHours
-1 is the subject -- the cheapest refusal in the script, decided in the
preamble before the fence is read -- and the same refusal through run_text
(-File) is the control. Both must read 2.

THE CENSUS PUBLISHED A NUMBER ITS NAMED PREDICATE DOES NOT PRODUCE. The
paragraph says "by whether an ancestor is a FunctionDefinitionAst" and then
published "49 Return ... nested". Under that predicate the split is 47 inside
a function and 2 outside. The 2 are `return $true` / `return $false` in the
$matchesName scriptblock literal, whose ancestor chain ends in
ScriptBlockExpressionAst. The instrument used had counted a scriptblock as a
nesting level; the sentence described the narrower test.

Both readings are now given, along with the two rows that separate them --
they are the only two. Re-derived at this tree under both predicates. The
load-bearing half is unaffected: 0 Exit sits inside a function under either,
which is what makes "no exit here is scoped to anything narrower than the
process" true.

This matters more than a wrong count because the paragraph's whole purchase
is "measured over the parsed script rather than by grep". A reader who re-ran
the named instrument got a different table and no way to tell which half of
the paragraph was wrong.

103 tests pass in the file, up one for the new pin.
Hoisting those lines out of "if the run exited 2" fixed when they print and
broke what they claim. `Exit ${exit}:` gets the arithmetic right and the
meaning wrong: on the very run the fence line now exists for -- fence down
AND a directory broken on disk -- it printed

    Exit 3: the occupancy fence was unavailable, so nothing was eligible.
    Exit 3: a directory is broken on disk RIGHT NOW.

The first is false as a causal statement. The fence sets 2; the broken
directory is what makes it 3. An operator fixes the fence and the code stays
3. The previous commit's own test pinned that wording, so it was about to
become the published reading.

Every tail line now opens with the outcome word, which is already this file's
vocabulary -- the three preamble refusals print bare `REFUSED:` and a failed
removal prints bare `FAILED:`. The run's code stays in the `Done.` summary
and in $LASTEXITCODE, which are the only surfaces that ever knew it.

Not `FAILED (1)`: that would have read as a sibling of the per-candidate
`FAILED (exit $removeExit)` twelve hundred lines up, which carries a git exit
code and is a different thing.

The -ReapVenvs line takes the same treatment rather than staying the odd one
out. It landed with `Exit ${exit}:` in #1244 and has the identical hole.

A GREEN RUN MUST STAY SILENT, AND NOTHING PINNED IT. Before the hoist that
was structural. Now it holds only because the sites setting those conditions
also raise the code, and every assertion in the file was a presence check.
test_a_clean_run_prints_no_exit_code_line_at_all checks absence, with the
positive control in the same invocation -- the run prunes two worktrees and
exits 0. Mutated by forcing the -Name line to print unconditionally: the test
fails with "a green run explained a code it did not exit with: FAILED:" while
the control assertion still holds, so the failure is attributable.

THE FENCE TEST READ TWO PROCESSES AND ONE CODE. It established exit 3 from
the -Json run, then string-matched a separate text run whose own code it
never read -- and the script branches on $Json in its preamble refusals, so
equal codes were an assumption. It now reads the text run's returncode.

THE SHA CITATION WOULD NOT RESOLVE. The comment cited edf01a9, which is
reachable from neither origin/main nor HEAD -- guard-reapvenvs squash-merged,
so that content is on main as 6896b39. A fresh clone gets `fatal: bad
object`. Same family as this repo's rule against citing a #N you have not
allocated. Replaced with #1244 (6896b39), which is an ancestor of both. The
control for that reading is tests/test_worktree_prune_merged.py's own
a824cc7 citation, which IS an ancestor of main.

Three sites still defined 2 as "nothing was ATTEMPTED", which the base
retracted and this branch did not restore -- a -Name miss on a dry run
attempts the whole decision pass and still exits 2. What 2 promises is that
nothing was REMOVED. Corrected at prune-merged.ps1's venv-placement comment
and at two sites in the venv test. None was introduced here; one sits twelve
lines from code this branch touches.

104 tests pass in the file.
The helper's docstring said "-Command reports its own success or failure,
not the script's: it collapses every non-zero code to 1". Too wide. A peer
session could not reproduce it from a bare `exit N` and mapped the boundary;
reproduced here on pwsh 7.6.6, asked code to actual exit:

    form                                             0  1  2  3
    -Command "exit N"                                0  1  2  3   propagates
    -Command "& probe.ps1 -Code N"                   0  1  1  1   COLLAPSES
    -Command "cmd /c exit N"                         0  1  1  1   COLLAPSES
    -Command "& probe.ps1 -Code N; exit $LASTEXIT"   0  1  2  3   propagates
    -File probe.ps1 -Code N                          0  1  2  3   propagates
    -Command "$x=N; exit $x"                         0  1  2  3   propagates

What collapses is an INVOKED script or a NATIVE command whose non-zero exit
is the last thing -Command did: pwsh reports its own "a command failed"
status of 1 rather than the callee's code. The remedy is unchanged and the
table confirms it on both collapsing forms.

THE WIDE VERSION IS WORSE THAN IMPRECISE, BECAUSE THE TWO TRAPS IN THIS
HELPER HAVE OPPOSITE REMEDIES. -File is faithful on the exit code and lossy
on an array argument; -Command is the reverse. Read as a property of
-Command itself, the collapse teaches a reader to distrust
`-Command "exit 2"`, which is sound, and to trust -File for an array, which
is not. Merging them into one rule gets both halves wrong.

Two smaller corrections in the same docstring, from the same review round:

* the -File control was claimed for "the tests below". One test carries it,
  and it is now named.
* "every path is single-quoted" covered the paths and not `extra`, which is
  passed verbatim. Deliberately -- quoting `-Apply` would make it a
  positional string rather than a switch -- so the docstring now says which
  is which, and that a value carrying a space needs its own quoting.

104 tests pass, unchanged; this commit touches no assertion.
@wshallwshall
wshallwshall added this pull request to the merge queue Sep 17, 2026
Merged via the queue into main with commit 9f8e463 Sep 17, 2026
41 checks passed
@wshallwshall
wshallwshall deleted the claude/b-exit2-operator-line branch September 17, 2026 23:19
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