Skip to content

feat(coord): distinguish an untracked-but-recoverable file from one genuinely at risk (BACKLOG #1298) - #580

Open
wshallwshall wants to merge 4 commits into
mainfrom
claude/builder-2-1298-recoverable
Open

feat(coord): distinguish an untracked-but-recoverable file from one genuinely at risk (BACKLOG #1298)#580
wshallwshall wants to merge 4 commits into
mainfrom
claude/builder-2-1298-recoverable

Conversation

@wshallwshall

Copy link
Copy Markdown
Collaborator

New script (scripts/coord/recoverable.ps1) classifying every untracked file in a worktree as RECOVERABLE, AT-RISK-absent, or AT-RISK-modified, and exiting 1 if anything is at risk. docs/BACKLOG.md untouched (verified, count 0) -- the #1298 row is not this PR's to author.

What it fixes: the worktree-removal safety dialog's 'will be permanently discarded' warning is an INDEX test presented as a LOSS test. A tree branched behind main and holding a file that landed since main advanced reads as untracked there while tracked on main -- byte-identical, fully recoverable -- and gets warned about as if it were unique. Reproduced before building: a tree detached behind main, holding main's own copy of a since-landed file, reported ?? while its blob matched origin/main's blob exactly.

Two commits: the feature, then the quality-review follow-up applied before handoff (author held it for exactly this):

  • One fewer git process per file (a redundant cat-file -e collapsed into the following rev-parse).
  • A machine-readable Reason field (identical/absent/modified/unreadable) replacing a classification that only existed inside an interpolated English sentence.
  • Mutation-verified the arm the docs called the whole point: collapsing 'modified' into 'absent' now reds 1 of 7 tests. It silently survived before this commit.
  • Consolidated a reproduction case stated three times (script comment, docs, test docstring) down to one copy, in the docs.
  • Fixed a real trap surfaced while doing this work: git rev-parse <ref>:<missing-path> prints the pathspec itself to stdout and exits 128 -- a truthiness check on the captured value alone reads a missing path as a valid sha. Only checking $LASTEXITCODE first catches it.

Deliberately not in scope, named so a reviewer isn't surprised: batching the git calls (two measurement angles disagreed -- huge win at 300 files, no win and a real cost at the actual population size of <=2); relocating the script to scripts/worktree/ (a Dispatcher scope call, not this branch's); fleet.ps1/prune-merged.ps1/unbacked_check.ps1 still assert the claim this script refutes elsewhere (reported to the Dispatcher as separate content).

Verification, explicitly scoped: 70 tests pass across test_coord_recoverable + three citation/partition gates, on the CI-matched lane venv (seven extras). ruff/ruff format/mypy clean on every changed file. Full suite not run.

wshallwshall and others added 2 commits August 25, 2026 12:31
…LOG #1298)

The archive dialog warns that untracked files "will be permanently discarded". It reasons
from "not in THIS WORKTREE'S index" straight to "will be lost", and skips the question
that decides it: is the content somewhere else.

The two come apart for an ordinary and constant reason. A tree branched behind `main` does
not have the files that landed since, so a copy of one is untracked THERE while tracked on
`main` -- recoverable, and warned about anyway. Every session branched behind `main` meets
this, on every file that landed since, and the prompt arrives when a seat is finishing.

REPRODUCED BEFORE BUILDING, on a worktree detached at 720f943, one commit before
tests/test_ci_retry_native_crash.py landed at 6e758a8, holding main's copy of that file:

    git status --porcelain           ->  ?? tests/test_ci_retry_native_crash.py
    git hash-object <file>           ->  5498a64
    git rev-parse origin/main:<file> ->  5498a64

Identical. The dialog called that permanent loss.

scripts/coord/recoverable.ps1 answers it: every untracked file classified RECOVERABLE,
AT-RISK (absent from the ref), or AT-RISK (on the ref but MODIFIED here). Exit 1 if any is
at risk, so it works as a check and not only as something to read.

THE THIRD VERDICT IS WHY AN EXISTENCE CHECK IS NOT ENOUGH. "Is it on main" answers YES for
a file whose local edit is the only thing that would be lost. I nearly shipped a test that
missed it -- my first contrast case used a NEW FILENAME, which exercises "absent from
main", not "on main but modified". Different arms; I redid it against the same path.

TWO RULES, BOTH ABOUT WHICH WAY TO BE WRONG:

* Anything the script cannot read is reported AT-RISK, never clean -- the direction
  occupancy.ps1 states for its own fence. A false AT-RISK costs a look; a false
  RECOVERABLE costs the file.
* -NoFetch is safe for the same reason: a stale ref can only fail to contain something that
  has since landed, so it can only move a file toward AT-RISK. It cannot invent a match.
  The ref and its sha are printed with every run, because a verdict quoted without the ref
  it was computed against cannot be re-checked by whoever reads it.

--untracked-files=all, because git's default collapses an untracked DIRECTORY to one entry
and every file beneath it would go unexamined while the run still printed a verdict. -z,
because porcelain v1 QUOTES paths containing spaces and parsing that form is a second,
silently different unescaper. Both are pinned by tests.

THE PARTITION GUARD WAS RUN AS A POSITIVE CONTROL, NOT ASSUMED. With the new module
unregistered, tests/test_tooling_partition.py went RED naming it; after adding the manifest
line, `-m tooling` selects all 7 and `-m "not tooling"` deselects all 7. An unclassified
test module is BACKLOG #1262's defect and it does not announce itself.

Verification, with scope: tests/test_coord_recoverable.py (7), plus
test_tooling_partition.py, test_ci_tooling_gate.py, test_citation_line_check.py,
test_dangling_citation_check.py and test_backlog_citation_check.py -- 114 passed, on
.venv/Scripts/python.exe (CPython 3.14.6 non-freethreaded, the seven CI extras). ruff
0.15.22 (== the constraints.lock pin) check and format --check clean; mypy strict clean.
recoverable.ps1 parses with 0 AST errors. NOT the full suite.

SCOPE THE ROW STATES AND THIS COMMIT HONOURS: the dialog is the Claude Code harness and is
NOT this repository's code. Nothing here changes its wording; this answers the question it
raises but cannot itself answer.

No test was removed or weakened. The ledger row is not mine to author and is not in this
commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… (BACKLOG #1298)

Follow-up to the #1298 commit, applying a four-angle review that completed after it. The
review ran 4 agents over the change; none returned empty.

ONE GIT PROCESS FEWER PER FILE. `rev-parse <ref>:<path>` already exits non-zero when the
path is absent from the ref, so the `cat-file -e` probe that preceded it asked a question
the next line answered on its way to the sha. Two review angles found this independently.
Verified: `git rev-parse origin/main:no/such/path` exits 128, `origin/main:README.md`
exits 0.

AND IT HAS A SHARP EDGE THE EXIT-CODE CHECK IS LOAD-BEARING FOR: rev-parse PRINTS THE SPEC
ITSELF TO STDOUT on failure. `origin/main:no/such/zzq.txt` comes back on stdout with exit
128, so a truthiness test on the captured value alone would read a missing path as a valid
sha. The guard tests $LASTEXITCODE first and then nulls the variable explicitly.

THE PAYLOAD NOW MATCHES WHAT THE DOCS SELL. `Verdict` is deliberately BINARY -- would this
be lost -- but three of its four causes are AT-RISK, so the three-way distinction the docs
table advertised existed only inside `Detail`, an English sentence with the ref name
interpolated into it. A consumer wanting to tell "absent" from "modified" had to parse
prose. `Reason` is now a closed set: identical, absent, modified, unreadable.

THE ARM THE DOCS CALLED THE WHOLE POINT WAS UNTESTED, and now is not. Mutation-verified
with the harness carrying its own controls -- anchor asserted unique, file hash asserted
CHANGED before scoring: collapsing `modified` into `absent` (two causes that share the
AT-RISK verdict, so no Verdict assertion can see it) reds 1 of 7. Before this commit that
mutation SURVIVED.

STATED ONCE, NOT THREE TIMES. The measured reproduction with its shas lived in the script
header, the docs subsection and the test docstring -- six sha citations maintained in two
files. The docs copy is now the only one; the header points at it. (SDS-3.5.)

THE DOCS SUBSECTION IS A `##`, NOT A `###` UNDER "Remove one". The question governs every
way a tree goes away -- remove.ps1, prune-merged.ps1, a rescue, the archive dialog, a
manual delete -- so a 53-line subsection nested under the 32-line section about the one
script it does NOT concern is hidden from exactly the reader looking for it.

Also: dead `$verdict = $null` / `$detail = $null` removed (every branch assigns both),
while the `$wtHash` / `$refHash` resets are KEPT with a comment saying why -- without them
a hash from the previous file survives into a row nobody hashed. The two pairs looked
identical, which is how the next reader deletes all four. And the test's two inline row
unpackers are replaced by one `by_path` helper, so the isinstance dance is written once.

DELIBERATELY NOT APPLIED, and each is reported rather than silently dropped:

* BATCHING THE GIT CALLS. The two angles disagree and both measured. REUSE: 300 untracked
  files cost 41,220 ms today against 427 ms batched, and `alloc.ps1:126` already documents
  this exact mistake. EFFICIENCY: the real population is N<=2, where batching buys nothing
  and costs the per-file UNREADABLE isolation. Both are right; which wins depends on
  whether this is ever pointed at a large tree, and that is not settled here.
* MOVING THE FILE to scripts/worktree/. The altitude case is strong -- zero mefor-coord
  hits, no seat, claim, lock, mail or registry read, and its documented neighbours are
  remove.ps1 and prune-merged.ps1. But the dispatch brief named scripts/coord/ explicitly,
  and moving it against that is a scope call that is not mine to make unasked.
* THE LARGEST FINDING, which is out of this item's scope entirely: fleet.ps1:367,
  prune-merged.ps1:570-577 and unbacked_check.ps1:273 all still assert the index-as-loss
  claim this script refutes, and unlike the archive dialog they ARE this repository's code.
  Reported to the dispatcher as content for its own row. Widening a claimed item to three
  unrelated files -- one of them the most destructive script in the tree -- is the
  cross-lane collision the file grouping exists to prevent.

Verification, with scope: tests/test_coord_recoverable.py, test_tooling_partition.py,
test_citation_line_check.py, test_dangling_citation_check.py -- 70 passed, on
.venv/Scripts/python.exe (CPython 3.14.6 non-freethreaded, seven CI extras). ruff 0.15.22
(== the constraints.lock pin) check and format --check clean; mypy strict clean. NOT the
full suite.

No test was removed or weakened. The ledger row is not mine to author and is not in this
commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@wshallwshall
wshallwshall enabled auto-merge (squash) August 25, 2026 17:41
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