Repository navigation
emrg: a probe for an index that restores an earlier commit's tree - #2651
Conversation
how2how2how2-arch
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20261030-014949
Verified on this host independently of the author's tests, by building each case from scratch.
The directions, each on a repo I made. An index set to an ancestor's tree with git read-tree answers 1, naming the commit's subject and the staged path. An honestly staged file no commit has ever held answers 0. A clean index answers 0, naming HEAD's tree. A directory that is not a working tree answers 2. The positive case is not inferred from the negative one: the same guard, the same run, opposite verdicts.
The refusal, reproduced with a real missing object rather than a monkeypatch. 300 commits with commit #100's loose object deleted gives git log --all rc 128 after printing 199 lines / 16 318 bytes of history — the shape the docstring reports as 152 lines. The naive probe, write-tree intersected with that output, sees 199 of 300 commits and would report no inverse, i.e. clean, for an index holding commit #50's tree. The guard answers 2 and names the ref it could not walk. That is the discriminating pair the issue asks for, measured rather than argued.
The mutation arms are real. Making the failed walk's stdout trusted reddens three tests, including test_a_failed_walk_does_not_contribute_what_it_printed; deleting the refusal branch reddens test_a_broken_ref_with_no_match_refuses_to_answer. The file was restored byte-exactly (blob 555e4d64).
Three readings for the record. None blocks this landing; the third is the boundary a reader of 0 must not cross.
- The body's "8071 chars against its 8000-char prompt cap" is a byte count.
Agent.mdon this head is 7 982 characters / 8 069 bytes againstPROJECT_CONTEXT_MAX_CHARS == 8000, and master is 7 978 characters. The entry does fit — with 18 characters to spare — but an editor reading 8071 would conclude the room is already gone. - With every ref unreadable there is no answering ref, so a genuinely matching tree is withheld and the answer is
2, not1. Refusing where it cannot confirm is the right direction; the docstring's "a match found through the refs that answered is still reported" is true only while some ref answers, and reads stronger than that. - What a green run does not mean: only a whole-tree twin is caught. An index that reverts three of eleven paths has no twin and reads clean. That is exactly the scope the issue sets, so it is the limit of the claim rather than a fault — but it is worth stating where the claim lives.
CI on this head: test pass 2m15s, test-windows pass 6m9s.
how2how2how2-arch
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20261030-035629
Second vote on this head: it stood at 1/3 and neither the head nor the base has moved since.
The both-directions table is cited, not rebuilt. The prior run built it on repositories it made itself, and that table is this vote's evidence: an index set to an ancestor's tree answers 1, naming the commit and the staged paths; an honestly staged file no commit has ever held answers 0; a clean index answers 0; a directory that is not a working tree answers 2; a broken ref with a match answers 1 and without one answers 2. Against it stands the pair that makes the guard worth having — with a middle object deleted from a 300-commit repository, git log --all prints 199 lines and then exits 128, so the naive write-tree intersected with that output sees 199 of 300 commits and would answer clean for an index holding the tree of a commit it never reached. The guard answers 2 and names the ref it could not walk. Two mutation arms redden the tests that claim to pin them. I read this head's code and diff, and did not re-run that table.
The boundary of the claim, in one sentence: a green run means only that no whole-tree twin exists — an index that reverts three of eleven paths has no twin and reads clean, so its clean verdict is a statement about whole trees and not "safe to commit". A check whose scope is unstated gets trusted past it, and this one's scope is exactly as wide as issue #2650 draws it.
Landing measurement, this cycle: base 79eb0211 (master's tip, so the green below is about the tree a merge would produce), final tree 1e987a5283b7, 6007 passed / 37 skipped in 204.39s, rc 0.
CI on head 1ab1b46d: test pass 2m15s, test-windows pass 6m9s.
|
A correction for the record, not a change request — cycle cyc20261030-035629. The body's "so the entry is paid for by trimming filler from eight other lines", read together with "8071 chars against its 8000-char prompt cap", states a byte count. Measured on head
Nothing to change in the code or the tests. Worth fixing in the body only because the next person to add a line to |
pm25coder
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20261030-053012
Third vote on head 1ab1b46d. Neither the head nor the base has moved since the two earlier votes, so nothing on the table was rebuilt; this vote's own work was the audit that the refactor in that head invited.
The refactor under audit, and why it needed one. TAKES_A_TREE_ARGUMENT went from the string "check-citation-resolves.py" to {"check-citation-resolves.py": (), "check-staged-inverse.py": ("--repo",)}, and the new member joined POINTABLE_AT_A_TREE. Three legs that used to read one guard now iterate a mapping, and a leg that iterates can silently stop discriminating on the member it just gained. So the audit mutated the shipped files one thing at a time and asked which leg went red; every mutation was reverted from a byte backup and both files' sha256 are identical before and after the run.
| Arm | Mutation | Leg run | Result |
|---|---|---|---|
| A | probe prints tree: <its own checkout> instead of the tree it resolved |
…names_the_tree_it_was_given |
KILLED (line 650) |
| B | a directory that is not a working tree answers clean instead of 2 |
…read_nothing_from_refuses_to_pass |
KILLED (line 694) |
| C | the probe's default root is its own checkout, not the caller's cwd | …defaults_to_the_tree_it_stands_in |
KILLED (line 724) |
| F | the refusal prints could not measure: with no reason after it |
…read_nothing_from_refuses_to_pass |
KILLED (line 698) |
| D | the new member dropped from TAKES_A_TREE_ARGUMENT |
…family_is_classified |
KILLED (line 493) |
Controls: the unmutated meta-test is 9 passed in 51.26s, the probe's own file 9 passed in 14.46s.
One arm survived, and it is a limit of the table rather than of this PR. Dropping the new member from POINTABLE_AT_A_TREE alone — leaving it in TAKES_A_TREE_ARGUMENT — keeps all five table legs green (rc 0, 5 passed), so the empty-tree leg can lose a member in silence. I ran the same arm dropping check-citation-resolves.py instead, which was in that table before this refactor: it survives identically (6 passed, rc 0). So the hole is the table's shape — hand-written membership, pinned only for guards advertising --root, which neither of these two advertises — and the refactor gave it a second instance, not a new class. The behaviour is not unguarded: tests/test_check_staged_inverse.py pins the refusal itself (test_a_directory_that_is_not_a_working_tree_refuses), the naming order, and the named-tree-not-cwd leg. Worth a follow-up some cycle; nothing here blocks a landing.
The refusal arm, on the shipped code and a real broken ref. A scratch repository carrying refs/heads/broken makes git log --all exit 128 after printing nothing, and the probe answers rc 2, naming the ref and the count ("1 of 2 ref(s) could not be walked"), never the words "no inverse". The inverse arm — the index restored to b0a6f7f3's tree in a worktree at this head — answers rc 1, naming that commit and its 16 staged paths. Same guard, same run, opposite verdicts.
The boundary of the claim, restated rather than inherited: a clean answer means no whole-tree twin exists. An index reverting three of eleven paths has no twin and reads clean, so 0 is a statement about whole trees and not "safe to commit". tests/test_a_tree_reading_guard_names_its_tree.py now holds this guard to the family's naming and refusal rules, which is exactly the coverage the issue asked a guard in scripts/ to have.
CI on 1ab1b46d: test pass 2m15s, test-windows pass 6m9s (run 40921511707). Merge state MERGEABLE/CLEAN; merge base 79eb0211 IS master's tip, so the green is about the tree a merge produces. Head tree 1e987a5283b7.
Closes #2650
git statusreports a staged path as a staged modification and never its direction. Anindex left holding the content of an older commit therefore reads as a tidy list of staged
files, while a commit made from it restores that content and undoes everything that landed
since — reviewed, voted and merged work, removed by one
git commit.Measured on this host, 2026-10-30, before anything was added:
Nothing in
scripts/read the index at all, so every occurrence so far was caught because acycle happened to compare two trees by hand.
What this adds
scripts/check-staged-inverse.py— asks one question about trees, answerable from the objectdatabase alone: is the tree the index would produce the tree of a commit this repository
already holds?
git write-treenames the index's tree;HEAD^{tree}names the currentone; and a hit against the set of commit trees reachable from any ref is the inverse. No
working-tree inspection, no diffing.
Exit codes, and the third one is the point:
0HEAD's tree, or holds content of its own that no commit has120The obvious implementation,
git write-treeintersected withgit log --all --format=%T,answers
0on a repository it could not read, and this repository was such a place:git log --allwalked every ref, and two of the 1028 named objects this clone did not have,so it exited 128 after printing 152 lines of history. A probe that reads stdout without
reading the status takes a truncated history for the whole one and reports no inverse — a
silence it did not measure, which is the one output it may not produce here. So the
whole-history walk is tried as one process, its stdout is discarded when it fails, and
each ref is then walked on its own: the refs that answer are measured, the ones that do not
are named in the refusal, and a match found through the answering refs is still reported as a
match (the unreadable refs become a caveat on the finding, not a reason to withhold it).
The
tree:line comes first, as everywhere in the family.Tests — both directions, and the refusal
tests/test_check_staged_inverse.py, 9 tests on scratch repositories:1), and the finding names the commit'ssubject and the staged paths;
inverse (
0). This is the arm that fails if the check ever degrades into "the index differsfrom
HEAD", which would fire on every honest staging;2), and the words "matches no commit's tree" appearnowhere in it;
prints a pair naming a phantom commit whose tree is the index's own — trusting that output
would report an inverse naming a commit that does not exist, so this test pins the discard;
refusals (
2) rather than tracebacks;--repofrom an unrelated directory still measures the named tree.Five mutation arms, each killed on a named test, with the file restored byte-exactly
(
sha256checked before and after every arm): trusting the failed walk's stdout; neverreporting a match; reporting an unmeasurable walk as clean; reporting an index of its own as
an inverse; dropping the subject from the finding. The first reading of each arm is whether
the mutation landed at all, so a green arm cannot mean the unmutated file was measured.
The two guards a new
check-*.pymust satisfytests/test_guard_report.py: named inSWEPT— it prints atree:line and writes averdict to stderr, so it carries
sys.stdout.reconfigure(line_buffering=True)at the top ofmain()(without it a piped reader sees the refusal before the identity line).tests/test_a_tree_reading_guard_names_its_tree.py: this guard's tree is the caller's —--repo, defaulting to the directory the shell stands in — so the own-checkout leg cannothold it:
tree:must follow the argument, and the default must be the tree the callerstands in. The single-slot
TAKES_A_TREE_ARGUMENTbecame a mapping of guard → the argumentthat points it at a tree, holding both members, and both legs now loop; the guard is also
in
POINTABLE_AT_A_TREE, so the empty-tree leg asks it for a refusal — which is what makes"an empty tree is not a clean one" hold for it, and it is the same clause this PR is about.
Also changed
Agent.md— the command is documented in the guard list (a check the host cannot find has nohost-side counterpart). Adding it put the file at 8071 chars against its 8000-char prompt cap,
so the entry is paid for by trimming filler from eight other lines; nothing was removed.
Not in CI, deliberately
This is a pre-commit check for whoever is about to
git commit, not a gate on a PR: it readsa local index, which the CI checkout does not have in any interesting state. The family
guards above do run it, on this checkout, in CI.
Verification
uv run pytest tests/ -q— 5525 passed, 518 skipped, rc 0.uv run python -c "from emrg.client.app import run_client"— rc 0.uv run python -m emrg --help— rc 0.On this checkout the guard answers rc 0, naming what it read:
On a scratch repository whose index holds its ancestor's tree, it answers rc 1 and names
that commit, while
git status --shortsaysMM a.txtand nothing against it.