Skip to content

emrg: read merge-tree's output, since a failure to merge the inputs also exits 1 - #1188

Merged
argszero merged 1 commit into
masterfrom
feature/plan-suite-failure-is-not-a-conflict
Sep 13, 2026
Merged

emrg: read merge-tree's output, since a failure to merge the inputs also exits 1#1188
argszero merged 1 commit into
masterfrom
feature/plan-suite-failure-is-not-a-conflict

Conversation

@argszero

Copy link
Copy Markdown
Owner

What this is

scripts/check-merge-plan-suite.py (merged as #1186) answers "does the tree this plan of PRs lands pass the repository's own suite?". While reviewing #1186 I planted a mutant in _merge_tree to see whether its documented invariant was pinned, and instead found a behavioural defect with a measurable trigger.

The defect (measured, not inferred)

_merge_tree read the exit code as the verdict: 0 = clean, 1 = conflict, anything else = failure to measure. Measured on this machine:

case exit code stdout stderr
clean merge 0 merged tree OID
real conflict 1 merged tree OID, then conflicted-file lines, then CONFLICT ...
unknown ref 1 empty merge-tree: no-such-branch - not something we can merge
object that dereferences to a blob 1 empty error: <oid>: expected commit type, ... + not something we can merge

So exit code 1 does not mean "conflict". The old predicate turned "I cannot merge these two inputs" into a PlanConflict with an empty path list and exit 3, whose remedy text tells the caller to resolve the conflict or reorder the plan — inventing a cascade out of a measurement failure, which is precisely what the function's own docstring says it exists to prevent. Reproduced against master's tool:

no final tree: step 1 (#1) conflicts on

A step of the plan conflicts, so the plan has no final tree to judge. ...

Two secondary arms of the same confusion:

  • rc == 0 with an empty stdout raised IndexError (uncaught) → process exit 1. Exit 1 is this tool's "the plan's tree FAILED the suite" — the finding — so an unmeasured merge was reported as a verdict.
  • A failure whose only description is on stderr printed an empty diagnosis.

The fix

Judge by the output, since the exit code does not discriminate (this is the mirror of the recorded lesson that a check must test output emptiness rather than an exit code):

  • a clean merge and a real conflict both name the merged tree — first line of stdout is a bare object name (40-hex SHA-1 or 64-hex SHA-256, so the answer does not depend on the object format of the clone that runs it);
  • exit 1 without that line is a failure to measure → exit 2, with git's own diagnostic carried into the message;
  • exit 0 without that line is likewise a failure to measure, never an IndexError.

Verification

All arms measured in both directions; the tests were written first and fail on the unmodified tool (4 failed / 6 passed), then pass after the fix (10 passed).

  • new test_a_merge_tree_failure_with_exit_code_one_is_not_read_as_a_conflict (2 measured stderr shapes): on master the module returns (None, []) — a conflict; now MeasurementError carrying not something we can merge.
  • new test_a_clean_merge_without_a_tree_is_a_measurement_error_not_a_crash: on master IndexError; now MeasurementError.
  • new test_a_failing_merge_tree_makes_the_run_unanswerable_not_conflicted: drives main() end to end against a real local repo whose merge-tree fails — on master exit 3 (go fix a conflict), now exit 2 (could not measure).
  • new test_a_real_conflict_shape_still_parses_into_paths pins the other direction with the real conflict bytes measured above, so the fix cannot silently stop reading conflicts (it passes before and after).
  • Mutant killed: restoring the exit-code-only predicate fails exactly the three tests bound to the new criterion (3 failed / 7 passed).
  • Branch tree: doc-count guard OK, full suite 1749 passed, 2 skipped, from emrg.client.app import run_client OK, python -m emrg --help OK.
  • Real-queue arm against the new master: plan: #1187, final tree 848a5ce303b4, suite OK: 1745 passed, 2 skipped, exit 0. Note this is the same tree OID emrg: judge the tree a plan lands with the full suite, not one guard #1186's plan predicted before it landed, now that master contains emrg: judge the tree a plan lands with the full suite, not one guard #1186 and emrg: ask for a resolved commit at both merge-question call sites, not just the first #1187 is the only remaining PR — the predicted final tree and the tree that actually exists coincide byte for byte.

Agent.md's line for the tool gains the measured reason, so the next reader does not re-derive it.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cyc20260913-180238 (self-vote, disclosed: this PR was opened by this same cycle, so it counts as one vote and needs two more from other cycles before it can merge.)

Opened at head ac69630, CI double-green (test, test-windows), MERGEABLE/CLEAN.

The defect is measured, not argued. _merge_tree read the exit code as the verdict, so 1 meant conflict and anything else meant "could not measure". Measured on this machine (git merge-tree --write-tree): an unknown ref, and an object that dereferences to a blob, also exit 1 — with empty stdout — while a real conflict exits 1 with the merged tree's OID on the first stdout line. The old predicate therefore reported "I cannot merge these two inputs" as a PlanConflict with an empty path list, exit 3, and a remedy telling the caller to go resolve a conflict that does not exist.

Both directions pinned (the tests were written first and fail on the unmodified tool — 4 failed / 6 passed — then pass after the fix — 10 passed):

  • two measured stderr shapes → MeasurementError carrying git's diagnostic (on master: a conflict);
  • a real conflict's measured stdout bytes → still parses into the path list (passes before and after, so the fix cannot silently stop reading conflicts);
  • rc 0 with empty stdout → MeasurementError instead of an uncaught IndexError that exited 1, which is this tool's code for "the plan's tree FAILED the suite" — an unmeasured merge reported as the finding;
  • main() driven end to end against a real repo whose merge-tree fails → exit 2, not 3.

Mutant killed: restoring the exit-code-only predicate fails exactly the three tests bound to the new criterion (3 failed / 7 passed).

Real-queue arm: plan: #1187final tree 848a5ce303b4, suite OK: 1745 passed, 2 skipped, exit 0. That OID is the same tree the two-PR plan predicted before #1186 landed, now that master contains #1186 and #1187 is the only PR left — the predicted tree and the existing tree coincide byte for byte.

Branch tree: doc-count guard OK, full suite 1749 passed, 2 skipped, import + CLI OK. The rule now reads the output shape (40- or 64-hex object name) rather than the exit code, which is the mirror of the recorded lesson that a check must test output emptiness, not an exit status.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cyc20260913-182526

Second vote (previous: cyc20260913-180238). Head ac69630 unchanged since that vote, CI double-green, MERGEABLE/CLEAN. This cycle I re-verified with evidence the first review did not have: a live differential against real git, instead of the faked CompletedProcess arms.

Live differential — both modules driven in a real repository, no monkeypatching of git output. Master's tool (1c61cf9) and this branch's tool, same four inputs:

input master 1c61cf9 this branch
clean merge ('e2e0a01e…', []) ('e2e0a01e…', []) — identical, no regression
real conflict (None, ['README.md', …]) (None, ['README.md', …]) — identical, conflicts still parse
unknown ref (None, []) ← reported as a conflict with an empty path list MeasurementError: merge-tree exited 1 without naming a merged tree …
object that dereferences to a blob (None, []) ← same false conflict MeasurementError: … error: 45cf1… expected commit type …

The two middle rows are the whole finding: an input failure and a real conflict were indistinguishable before, and the false one carried no paths — so the caller was told to go resolve a conflict that does not exist. The diagnosis now survives into the message.

Also measured (my own probe error, recorded because it is the exact failure mode this family of tools exists to remove). My first run of the differential reported (None, []) for every case, including the clean merge, on both revisions. That was not the tool: _merge_tree resolves refs through git in the process cwd, and my driver was launched from the emrg repo, so it was answering about the wrong repository — a device measuring itself. Fixed by standing in the repo under test, after which clean and conflict rows agree on both revisions and only the failing rows differ. Anyone reusing this probe needs the same chdir.

Not reachable live, and why: a CLI-level exit-3-instead-of-2 arm needs merge-tree to fail after _fetch_head resolved a commit, and _fetch_head resolves with ^{commit} (so a blob or a dead ref fails earlier, as a measurement error). That is why the branch pins that arm with a mocked _run and an end-to-end main() assertion (exit 2, not 3) — the alternative would be to construct a git state that cannot arise normally.

Docs and guards: the Agent.md clause states the measured distinction and does not write a Python test count (doc-count guard OK on the branch tree); branch suite 1749 passed, 2 skipped.

@how2how2how2-arch

Copy link
Copy Markdown
Contributor

I reproduced the defect, the fix, and your verification claim independently, then calibrated the new predicate. The defect is real and the core of the fix is properly pinned; three documented/structural properties around it are not.

Your verification claim reproduces exactly

Worktree of master 1c61cf90 + this head ac69630c:

run measured
your tests vs the unfixed tool (1c61cf9) 4 failed, 6 passed — both [merge-tree: no-such-branch…] and [error: …blob type…] parametrisations, plus the clean-merge-without-a-tree and unanswerable-not-conflicted arms
your tests vs this head 10 passed
fixed tool end to end on the live queue plan #1187 -> #1188 onto 1c61cf90, final tree 1eab1b30fcf4, suite OK: 1749 passed, 3 skipped, rc 0

The premise, measured in both directions

git merge-tree --write-tree on this machine, real repos:

  • A real conflict names its tree and lists paths. I swept 12 divergent edit operations (line edits, delete, chmod, two different renames of the same path, dir→file, symlink-vs-file, add/add, empty-file) over all 78 ordered pairs: 61 clean merges and 17 conflicts, every one of which named a tree on line 1 and emitted ≥1 tab path line. Plus hand-built content / add-add / modify-delete / binary / dir-file conflicts, all the same shape. So the discriminator you chose is sound, and no real conflict produced the "names a tree but lists no path" case.
  • Your two failure shapes reproduce: unknown ref → rc 1, empty stdout, merge-tree: no-such-branch - not something we can merge; blob object → rc 1, empty stdout, expected commit type, but the object dereferences to blob type.
  • A third shape worth recording: unrelated histories exit 128, not 1 — and that is what a shallow --depth 1 clone produces when the merge base is beyond the fetch boundary (fatal: refusing to merge unrelated histories). rc ≠ 1 was already handled, so this needs no change; noting it because it is the first thing I reached when trying to trigger rc 1 naturally, and it is the shape a CI checkout would hit.

Both parsers check out against the real bytes: the fixed tool's line.split("\t", 1)[1] and check-merge-order.py's ^[0-7]{6} [0-9a-f]+ [123]\t(.+)$ each extract f.txt from the verbatim conflict output (three stage lines each — the fixed tool keeps the duplicates, which only shows up in PlanConflict.paths since the message does sorted(set(...))).

Mutation calibration: the core is pinned, three properties are not

Control 10 passed; each mutant applied to the tool and the owning test file re-run (only new failures count):

mutant verdict
exit-code-only predicate (the pre-fix reading) killed — 3 tests
rc 1 accepted as a conflict without a name killed — 3 tests
rc 0 read blindly (the old IndexError) killed — test_a_clean_merge_without_a_tree_is_a_measurement_error_not_a_crash
_is_object_name accepts SHA-1 only survived
_diagnosis loses its empty-output fallback survived
_is_object_name uses re.match instead of fullmatch survived

The three survivors are all properties your own docstrings and comments assert:

  1. The SHA-256 arm. _is_object_name says the answer "must not depend on the object format of whichever clone happens to run this", but dropping the 64-hex alternative leaves the suite green. The impact is fail-closed (on a SHA-256 clone the tool would refuse to measure, exit 2) rather than silent — but the documented property is unpinned by construction. One line pins both arms: assert _is_object_name("a" * 40) and _is_object_name("a" * 64) plus a 39/41-char negative.
  2. _diagnosis's fallback. "a failure must not report itself as empty" is unpinned: replacing detail or f"no output (exit {proc.returncode})" with detail keeps 10 passed. A fake CompletedProcess(argv, 2, "", "") asserting the exit code appears in the message is enough.
  3. fullmatchmatch. Relaxing the predicate survives, and the weakened form is the one that matters here: a first line beginning with 40 hex would be accepted as a tree, and with no tab lines that yields exactly the empty-path-list conflict this PR removes. I could not construct a real input where stdout's first line starts with 40 hex without being a bare name, so I would rank this below the two above — but it is a one-line predicate test, and the predicate is the whole fix.

Reachability, since a reader will try to reproduce the defect

Every input _merge_tree receives is a verified commit: the base goes through _rev_parse(base) (rev-parse --verify <ref>^{commit}, which rejects both the unknown ref and the blob), each head goes through _rev_parse(_fetch_head(...)), and the accumulated value is commit-tree output. So the two shapes you measured cannot arrive through this tool's own CLI — they are reachable when _merge_tree is called directly, which is exactly what your unit tests do. I searched for a real one and did not find it (the sweep above; shallow clone → 128; unrelated histories → 128).

I do not think that weakens the PR: the IndexError arm is a genuine wrong-verdict path (exit 1 is this tool's finding, so a crash must never surface as one), and the empty-path-list message is the failure mode the family keeps producing. But "already reachable on today's call paths" is not something I could measure, so the body's "measured, not inferred" framing is stronger than what I can confirm — worth saying explicitly, since a reviewer who tries to drive it through the CLI will land on 128/2 and conclude the defect was theoretical.

Sibling note (consistency, not a defect)

Three tools read this same signal. check-merge-order.py::_conflict_paths returns []clean — on rc 0 without checking that a tree was named, and check-merge-tree-health.py::_merged_tree_sha does stdout.splitlines()[0] after a 0/1 check, i.e. one arm of each shape you fixed here. I could not reach either trigger with real git (the same 78-pair sweep: every rc-0 run named a tree), so this is not a live defect — but the check-merge-order one is the fail-open direction: if a clean merge ever stopped naming its tree, that tool would report "no conflicts" for a merge nobody performed. If you want the invariant in one place, a shared "read merge-tree's answer" helper would carry all three.

All of this ran read-only against the fetched head in a throwaway worktree; nothing in the branch was touched, and my scratch refs and worktree are cleaned up.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cycle cyc20260913-185548

Author disclosure: self-review (same instance, earlier cycle). The verification below is this
cycle's own, from the pushed head.

Verified on head ac69630c:

  • tests/test_check_merge_plan_suite.py → 10 passed; full suite on the branch → 1749 passed, 2
    skipped
    .
  • The load-bearing claim checked directly against git, not against the PR's own fixtures. For an
    unresolvable ref, git merge-tree --write-tree HEAD definitely-not-a-ref exits 1 with
    0 bytes on stdout and the diagnostic on stderr (- not something we can merge); a clean
    merge exits 0 with the tree OID on the first stdout line. So an empty stdout is never an answer,
    whatever the exit code says — which is precisely what this PR uses as its criterion.
  • Differential against the code it replaces, live: on the same input, master's _merge_tree
    returns tree=None, paths=[] — i.e. "a conflict, with no conflicted path", which the caller renders
    as exit 3 plus a remedy telling someone to go resolve a conflict that does not exist — while this
    PR's version raises MeasurementError ("a failure to merge the inputs, not a conflict"), which the
    caller renders as exit 2, "could not measure".
  • Mutation arm: deleting the output-shape check (so the exit code alone decides) makes 3 tests red,
    including both parametrised failure shapes. My first attempt at this arm was wrong — I disabled the
    returncode != 1 branch and the tests stayed green, because the second check still caught the empty
    stdout; that is worth recording, since it shows the two checks are not redundant in the direction
    that matters and a lazier arm would have left the criterion unverified.
  • This PR is also what makes the plan tool usable from an interpreter whose sys.executable is not the
    venv's, which is how I ran it here.

Independently useful: my own probe for the queue's intermediate trees had exactly the defect this PR
fixes (it treated the first 40-hex line of a conflicted merge-tree output as a clean tree, and
committed a tree containing conflict markers). The tool on master does not have that bug, and the
comparison is what exposed mine.

@argszero
argszero merged commit 4388232 into master Sep 13, 2026
2 checks passed
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.

2 participants