fix(worktree): the exit-code lines print on the run they describe, and the mid-run fence death does not exist - #1247
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two exit-code explanation lines in
prune-merged.ps1printed only when the run ended on2, so each went silent on the run it was written for. Both are hoisted out and keyed on their own condition, following the-ReapVenvsline 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
-Namemiss-Apply -Name alpha,ghostwherealphaprunes andghostmatches nothing. The guard at theSet-Exitalready reports1once something is removed.Done. removed 1in red, with no line saying half the instruction was refused. The hardcodedExit 2:in the line's own text was the tell.2then3, ending on3.The orphan line's
elseifbecomesif. That changes nothing: the old chain reached it whenever$exitwas3, because3is not2.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 printedExit 3: the occupancy fence was unavailable— sending an operator to fix a fence that was only ever worth2, over a3the broken directory set.Every tail line now opens
REFUSED:,FAILED:orORPHANED:, which is already this file's vocabulary: the three preamble refusals print bareREFUSED:and a failed removal prints bareFAILED:. The run's code stays in theDone.summary and in$LASTEXITCODE. NotFAILED (1)— that would read as a sibling of the per-candidateFAILED (exit $removeExit), which carries a git exit code and is a different thing. The-ReapVenvsline takes the same treatment rather than staying the odd one out; it landed withExit ${exit}:and had the identical hole.What was NOT fixed, and why
The brief's other path was "a
Set-Exit $EXIT_REFUSEDfires inside the apply loop when the occupancy fence is lost part way through, carrying no$removedguard, so a run can remove two worktrees, lose the fence on the third, and exit 2."No such run exists.
$occ2is read once, on the line aboveforeach ($d in $prunable), and nothing inside the loop re-reads it or mutatesAvailable(checked inoccupancy.ps1too: set at construction in three places, noAdd-Member, noScriptProperty, no assignment). So the branch is all-or-nothing — if the fence is down, iteration 1continues and so does every later one, and$removedstays0.Adding the guard there would be dead code that reads as protection, on a destructive tool. The invariant is recorded at the
$occ2read 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$occadds a SKIP reason to every candidate so$prunableis empty and the apply loop never runs; the mid-loop check, invariant as above; and the-Nameand-ReapVenvsguards, both ternary on$removed -gt 0.So the docs move back, partway
54e8c393b(in #1244) weakened the exit-2 meaning on two grounds.2is per-request and its causes are not a closed list. Unchanged.2does still promise nothing was removed.Three sites carried the retracted claim — the script header, the inline comment on
$EXIT_REFUSED, anddocs/WORKTREES.md. Three more still defined2as "nothing was attempted", which the base retracted correctly and this branch does not restore: a-Namemiss on a dry run attempts the whole decision pass and still exits2. 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_sokills the fence between the decision pass and the removal pass and assertscounts.removed == 0beside the2. It is now cited rather than left implicit.Reachability, over the AST
A grep for
^\s*exitmisses four keywords and any wrapper that reads the exit variable, so the file was parsed and everyExit,Return,Throw,BreakandContinuestatement classified by whether an ancestor is aFunctionDefinitionAst.The 2
Returns outside a function are not script-level either: they are thereturn $true/return $falseof the$matchesNamescriptblock literal. Counting aScriptBlockExpressionAstas 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
Exitsits inside a function, so noexitin the file is scoped to anything narrower than the process, and the two that end an ordinary run are bothexit $exit. Re-derived atc51781eee.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 exit1with no outcome line at all; the second reached exit3with 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-Nameline 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
2as controls. A refusal on a run that removed nothing is still a refusal.The
-Nametest needs a-Commandhelper, and both the reason and its cost are measured._argvinvokes throughpwsh -File, which passes every argument literally:-Name a,barrives as one elementa,band-Name a bas one elementa. No test going through it can express "one name hits and another misses". The operator invocationdocs/WORKTREES.mddocuments 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 of1unless you re-exit$LASTEXITCODE. Measured on pwsh 7.6.6, asked code to actual exit:-Command "exit N"-Command "& probe.ps1 -Code N"-Command "cmd /c exit N"-Command "& probe.ps1 -Code N; exit $LASTEXITCODE"-File probe.ps1 -Code NThe two traps have opposite remedies and must not be merged into one rule.
-Fileis faithful on the exit code and lossy on the array;-Commandis the reverse. An earlier commit on this branch stated the collapse as a property of-Commanditself, which would teach a reader to distrust-Command "exit 2"(sound) and to trust-Filefor 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 == 1is satisfied by 1, 2 and 3 alike. With it, deleting the$removed -gt 0ternary makes the test fail with "removed a worktree and reported exit 2".Checks run
pytest tests/test_worktree_prune_merged.pyc51781eee, tree unmoved across the runpytestover the four neighbouring files that readprune-merged.ps1orWORKTREES.mdruff format --check,ruff checkmypy(strict) on the changed test filepre-commithookprune-merged.ps1WORKTREES.mdthat firedWORKTREES.mdNot run, and a hosted runner must read them: the full suite, and anything Windows-service or NSSM. The
vaultextra is absent from this interpreter, so its modules deselected at collection.Notes for the lander
Branched from
guard-reapvenvswhile #1244 was open, then rebased ontomainwith--ontoonce #1244 squash-merged as6896b3921. 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
Returnsplit its own named predicate does not produce, a comment citingedf01a954— a SHA reachable from neithermainnorHEAD, sinceguard-reapvenvssquash-merged — and theExit ${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.