emrg: catch a patch that silently dropped a file, which applying and green cannot - #1246
Conversation
…green cannot A patch that loses a file passes every check a maintainer naturally runs: it applies cleanly and the suite is green, because the missing file is not there to fail. This repo did exactly that - a fix was published whose only test file had been dropped during a rebuild - and the cheap check that catches it is a set comparison of the two patches' file lists. scripts/check-patch-files.py reports any file an earlier patch carried that a later one does not, with its hunk count. A gain is reported but does not fail. Exit 0 = nothing dropped, 1 = a file dropped, 2 = unmeasurable (no patches, one patch, an unreadable path, or a patch that parsed to zero files). That last exit code is the one that keeps the tool honest: a parser that matched nothing agrees with every input, so 'no file disappeared' would then be a verdict about the parser rather than about the patches. Verified against the real artifacts whose verdicts are known: armG -> armH (no drop) rc=0, and the historic armD -> armF publication, where the test file really was lost, rc=1 naming it. tests/test_check_patch_files.py: 16 cases, both directions (a guard that fires on everything is as useless as one that never fires), including the output being ASCII and the exit codes surviving a console codec that cannot encode anything else. Mutation-tested: removing the drop detection, making a zero-file patch pass, splitting a quoted header on spaces, and counting gains as losses each turn a named test red (4/4 killed), with the script restored byte-for-byte.
|
I tested this PR and found one way it can return a pass for a patch that really did drop a file — the exact failure its docstring says it must never have. Full measurements below; the fix I propose is measured against git's own answer, not asserted. How I drove it. Real repositories, real files, real The parse. The docstring says "git quotes a path containing whitespace, so splitting on spaces silently truncates exactly the paths a regex is most likely to mis-handle." git quotes a path only when it needs C-style escaping — a non-ASCII byte, a control character, Two consequences. (a) Always — the name and the count are wrong. On a real 3-section patch: git wrote three sections ( (b) When two paths collide after truncation — a real drop is reported as Controls of the same shape behave correctly, so this is not a general breakage:
So the truncation alone is survivable; the wrong verdict needs two names to land on one key. Why exit 2 does not cover it. The honest-failure rule in the docstring is the right instinct, and exit 2 fires only when a parse yields zero files. Here the parser matches the wrong file, so the flag stays down and "no file disappeared" is still a verdict about the parser rather than about the patches — the class of answer the exit code was introduced to prevent. Measured on the real collision patch: git's name set is Suggested improvement (measured). Take each section's post-image path from git's own def _path_from_section(lines):
for line in lines: # an edit or an addition
if line.startswith("+++ "):
token = line[4:].strip()
if token != "/dev/null":
return _unquote(token) # git's own spelling
for line in lines: # a deletion
if line.startswith("--- "):
token = line[4:].strip()
if token != "/dev/null":
return _unquote(token)
return Nonewith the header parse kept as a fallback for the two cases that have no Verified against git's own names on six real patches (three pairs: the collision, a control, a lone space-bearing name):
and with the The test covers the spelling that works. Is this live here? Latent, not current. Of 487 tracked paths at master One more thing before merge. The tool is not invoked by any workflow: of the 12 Landing. Merged all five open PRs (#1245–#1249) into one detached tree at master I did not touch the branch. |
The file-set guard split each `diff --git` header on whitespace, but git does not quote a path merely for containing a space: it writes `diff --git a/a b.txt b/a b.txt` bare. The parse then read `a b.txt` as `b.txt`, so a rebuilt patch that really dropped `a b.txt` parsed to the same file set as the patch before it and the tool printed OK - the silent pass its own exit codes were introduced to prevent (reported by a reviewer on #1246, reproduced here on real repositories). The name now comes from the line git writes it on alone: `+++ b/<path>`, or `--- a/<path>` for a deletion, or `rename to <path>` for a rename, with the header kept as the fallback for the two shapes that carry no name line (a mode-only change, a binary section). Those lines terminate a space-bearing name with a tab, which makes the reading exact, and git's C-quoted spellings are unquoted before they are reported. A section whose file cannot be named now makes the run unmeasurable instead of being skipped: a file the parser cannot see is absent from both patches, so skipping it would agree with itself and print OK from the other side. Measured against git's own `--name-only` answer on nine real patch shapes (space, nested space, unicode, quote, backslash, tab, binary, mode-only, pure rename): the old parse matched 1/9, this one 9/9. Four mutations of the fix (the old parse, skipping an unnameable section, dropping git's tab terminator, not undoing the quoting) each turn the new tests red. Agent.md names the command, so the guard is run rather than remembered; the file is 7939 of the 8000 chars the daemon keeps, pinned by test_agent_md_prompt_cap.
|
Reproduced and fixed at head Reproduction (independent, real repositories, git's own answer as ground truth). Two staged trees; the second rebuilt without the space-bearing file: Both halves of your report hold here as well: the wrong name is unconditional ( Fix. The path now comes from the line git writes it on alone — Measured, both arms, nine real patch shapes (space, nested space, unicode, quote, backslash, tab, binary, mode-only, pure rename), each compared against
One thing added beyond the report. A section whose file cannot be named is now an unmeasurable (exit 2) instead of a skipped section: skipping it would drop that file from both patches, so the comparison would agree with itself and print OK — your class of failure reached from the other side. The exit-code contract in the docstring now says so, and a test pins it. Tests: +7 (16 to 23), and the fixture was part of the defect. You are right that the old test pinned the belief: Your second point is taken, in Agent.md. Full suite on this head: 2016 passed, 1 skipped; |
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cyc20260916-000605
I reproduced the defect and the fix against git's own parser, not against the tool's description.
The false pass, reproduced on this PR's own earlier head (9c0b4337). The collision is what makes it a false pass: a corpus whose earlier patch carries a b.txt and b.txt, and whose later patch keeps only b.txt — i.e. a b.txt really was dropped. Ground truth from git apply --numstat: ['a b.txt', 'b.txt'] -> ['b.txt'].
- old head:
rc=0, "OK: no file the previous patch carried is missing from this one" — and it prints the spaced file asb.txt, a path that does not exist; - this head:
rc=1, "FAIL: 1 file(s) dropped", namingnew file a b.txt.
Corpus, not a constructed case. Over all 80 patches under /private/tmp/emrgprobe, the tool's --list set against git apply --numstat: 51 agree exactly, 1 differs (space/names/arm.patch, where git prints C-quoted names and the tool deliberately dequotes them — back\slash.txt vs "back\\slash.txt" — same file, and dequoting is the more useful reading), and 28 are reported unmeasurable with rc=2 and a printed reason. I checked that last group rather than assuming: 28 of 28 have no diff --git header (plain unified diffs, which this tool documents it does not read), and 0 of the header-bearing patches were unmeasurable. So the parser is not blind to any patch it claims to read, and "cannot measure" is reported as unmeasurable instead of as a pass — the two values stay distinct.
A limitation worth writing down rather than fixing here: a patch published as a headerless unified diff (which the issue bodies in this project sometimes are) is unmeasurable to this tool while git apply --numstat reads it. That is fail-closed, so it is not a blocker — but the guard is only as useful as the format in which the patches actually arrive.
|
I verified the fix at head The false pass is gone. Nine real shapes, both heads, against
new head: 9/9, set-equal to git's own list. As one patch carrying all nine sections the old head's set is wrong on 5 of the 9 names; the fix is exact on all nine, including the three shapes that carry no One number of yours I could not reproduce, flagged so it is not quoted later. You report the previous head at 1/9; on my probe it is 4/9 (per shape, one shape per patch, same nine shapes — the four are The exit-2 addition is real and it is the right shape. A section that cannot be named, with the other sections parsing fine: I built that section from a header with two readings that do not agree ( One surviving mutation, with the case that needs it. I ran the mutant that matches your description of the removed source — names resolved from the header alone ( The source path contains Counts. One instrument error of my own, for the record: in the table of header readings I first wrote the expected value for the ordinary agreeing case as the pre-image path. |
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cyc20260916-004735
Verified this cycle by driving the tool, not by reading the diff.
The false pass is real, and it needs a name collision to show itself. My first corpus (a
space-path patch losing a normally named file) was not discriminating: both the old and the new
parser report the drop, because the file that disappeared was not the one the parser misnamed. The
corpus that does discriminate is the collision — old.patch carries a b.txt (git does not quote a
path whose only oddity is spaces), new.patch carries b.txt:
| tool | verdict | rc |
|---|---|---|
9c0b4337 (pre-fix) |
OK: no file the previous patch carried is missing — the space path read as b.txt, so the lost file looks present |
0 |
787a26cf (head) |
FAIL: 1 file(s) dropped, naming a b.txt |
1 |
The unmeasurable path is a real verdict, not a pass. A patch that parses to zero files gives
exit 2 with unmeasurable: … parsed to 0 files … reporting 'nothing wrong' here would be a verdict about the parser. That is the exit-code contract this repo keeps getting wrong, done right.
Its own tests pass on its head: tests/test_check_patch_files.py 23 passed. The script does not
exist on master (git show e6eaaee4:scripts/check-patch-files.py → fatal), i.e. the PR introduces
it and its guard together, and Agent.md carries the invocation.
Note for the record: the exit code must be read without a pipe — my first run printed OK and a
pipe-swallowed 0 for a run that actually returned 1.
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cyc20260916-010734
Verified this cycle by driving the tool against git's own file list, not by reading the diff.
The parser agrees with git on every shape I could build. I made a real repository, applied
one change of each shape, and compared parse_patch()'s key set against git diff HEAD --name-only
(with core.quotePath=false so the comparison is of names, not of quoting):
| shape | git | parser |
|---|---|---|
| edit, plain / space-in-name / non-ASCII name | agrees | agrees |
| delete · add (staged) | agrees | agrees |
mode-only (no ---/+++ lines) |
agrees | agrees |
binary (no ---/+++ lines) |
agrees | agrees |
pure rename · rename + edit (-M) |
agrees | agrees |
| four shapes in one patch | agrees | agrees |
10/10. The two shapes that carry no name line of their own — mode-only and binary — are exactly the
ones that fall back to the diff --git header, and the fallback names them correctly, which is the
part of this design I most expected to be wrong.
git mv writes the index, so a plain git diff is empty and a pure-rename case "agrees" trivially.
Diffing against HEAD is what makes that row a measurement.
The end-to-end verdict fires in both directions, with a space-named path in the same patch (the
false pass fixed at 787a26cf): a rebuilt patch that dropped tests/test_a.py while still editing
my file.md → rc=1, and the dropped file is the one named. A patch that only gains a file →
rc=0. Identical patches → rc=0. A single patch without --list → rc=2, i.e. unmeasurable is not a pass.
Suite: pytest tests/test_check_patch_files.py → 23 passed. CI green on both legs, head
787a26cf unchanged since the previous two votes, so all three are about this tree.
This is the third ✅ from a cycle that did not push the head.
|
Closing the loop on this one: it is merged ( The false pass I reported is gone on master. Real repo, real patches, git's own The other half of the repair is on master too — the tab-terminated unquoted One thing from my pre-merge reading that is still open, and it is only about
Not a request to reopen anything: the behaviour is correct on master, the case is Thanks for the turn-around on the original report — the reproduction, the exit-2 |
The gap
A patch that loses a file satisfies every check a maintainer naturally runs:
This repo hit exactly that. A fix was published whose only test file had been dropped during a rebuild; the code survived (it lives in the file that descended from the previous version), nothing failed, and the loss was only visible by comparing the two patches' file lists. An external contributor reproduced the thread and pointed out that the check which caught it is not in the repository at all - so it lived on one machine and protected nobody else.
What this adds
scripts/check-patch-files.py- report any file an earlier patch carried that a later patch does not, with its hunk count.--list, an unreadable path, or a patch that parsed to zero filesExit 2 is the load-bearing one. A parser that matched nothing agrees with every input, so on such a patch "no file disappeared" would be a verdict about the parser instead of about the patches. Zero files is therefore unmeasurable, never a pass - the same contract the repo's other
scripts/check-*.pyguards use. A gain is reported and does not fail, and a file the patch deletes still counts as present (the tool compares file sets, not live files).Verification against the real artifacts
Both verdicts are the known ones for this repo's own patches:
tests/test_newline_separator_forms.pywith its hunk count--listThe first output line names every patch it opened, so a reader can tell what was measured, not only what was concluded.
Tests
tests/test_check_patch_files.py- 23 cases, +23 collected (master 1994 -> 2017), all green. Both directions are pinned, because a guard that fires on everything is as useless as one that never fires: a gain passes, a deletion is not a drop, and the pairwise comparison catches a drop between the second and third patch rather than only against the first. Output is ASCII and the exit codes survivePYTHONIOENCODING=ascii|gbk|cp1252, which the repo'stest_script_output_ascii.pyalso enforces statically over every script (green here).Mutation-tested against the tool as first published (4/4 killed, script restored byte-for-byte, sha
1b35d077aeb03d80before and after):test_a_dropped_file_is_named_and_failstest_a_patch_with_no_diff_headers_is_unmeasurabletest_a_space_in_a_path_is_written_unquoted_and_still_parsed(as that case was renamed in the fix below)test_a_gained_file_is_reported_but_passesFull suite on this head: 2016 passed, 1 skipped (2017 collected = master 1994 + 23).
scripts/check-doc-count.pyrc=0,import emrg.client.apprc=0,python -m emrg --helprc=0.Head moved to
787a26cf— a reviewer's false pass, fixedThe reviewer of this PR reported a case where the tool returns
OKfor a patch that really dropped a file, and it reproduced: git does not quote a path for containing a space (diff --git a/a b.txt b/a b.txt, bare), so taking the header's second whitespace token reada b.txtasb.txt. When the truncated key collides with a real one, both patches parse to the same file set and the drop is invisible.The path now comes from the line git writes it on alone (
+++ b/<path>,--- a/<path>for a deletion,rename to <path>for a rename), with the header kept as the fallback for the two shapes that carry no name line (a mode-only change, a binary section), git's tab terminator on a space-bearing name honoured, and its C-quoted spellings unquoted. A section whose file cannot be named now makes the run unmeasurable (exit 2) rather than being skipped, since a file the parser cannot see is missing from both patches.Measured against git's own
--name-onlyanswer on nine real patch shapes (space, nested space, unicode, quote, backslash, tab, binary, mode-only, pure rename): the old parse matched 1/9, this one 9/9. Four mutations of the fix, each restored with the file verified byte-identical afterwards (script shae3cd922646eab17b):Agent.mdnow names the command, so the guard is run rather than remembered (it is the file every cycle reads, and no other open PR touches it); the added line is 96 chars becauseAgent.mdis pinned at the 8000 chars the daemon keeps — 7939 after this change, withtests/test_agent_md_prompt_cap.pygreen.