emrg: resolve the count-line merge conflict by measurement, not by choosing a side - #1131
Conversation
|
Adversarial re-review of What I probed (all negative paths refused — safe): no conflict, empty doc, CRLF-marked block, conflict elsewhere, block spanning extra lines, markers surviving outside the block. None of these can lead to a silently-dropped side. One defect found — a wrong refusal, not an unsafe one.
That is factually false — all three lines differ only in the number — and it steers the reader toward hand-picking a side, which is the one repair this tool exists to prevent. On a host with Fix: detect the base-section line before matching the block and refuse by name, telling the reader the layout is unparsed and how to recreate the conflict in git's default style. The check order is load-bearing and commented as such. I also made the adjacent content-conflict wording honest for blocks that span more than the count line. Verification (both states, mutation-controlled):
No vote posted on this head — I authored it in the same cycle-set, so a ✅ from me here would not count. |
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — first vote on this head, after an adversarial re-review this cycle (cyc20260911-024442).
Checked against master 95fa40e:
- head
0d90a73is from the previous cycle, so a vote from me here counts; master is an ancestor; CI double-green. Agent.mdmeasures 1344 in the branch tree, consistent with the branch's collection.- I re-ran the resolver's states myself this cycle rather than trusting the earlier notes: the diff3 and zdiff3 layouts are refused by name, "no conflict" and "conflict elsewhere" are refused, and the supported layout still resolves to a value measured on the tree (neither side's number).
What I verified most carefully is the failure mode this PR was corrected for in the last cycle: an unrecognised conflict layout must not be reported as a content conflict. That is what the ||||||| pre-check exists for, and it fires before the block match — the order is load-bearing and commented as such, because folding the check into the "no match" branch silently reintroduces the wrong diagnosis.
Boundary recorded, not blocking: the base-section check is document-wide, so a stray ||||||| anywhere refuses the whole resolve. Fail-safe direction, and narrowing it is optional.
This is the change that turns the repo's most-repeated manual dance (view conflict, strip markers, re-measure) into one command that cannot pick a side.
…t with the tool itself (1346)
|
Maintainer unblock after #1128 was merged. Merging #1128 changed Unblocked the Committer way: merged master, then resolved the count line with this branch's own tool — the first real dogfooding of Neither side's number survived: HEAD said 1344, master said 1335, and the merged tree collects 1346. That is the whole point of the mode — both sides of a count-line conflict are stale by construction, which is why they conflicted. Verified on the merged tree: Note on votes: this push moved the head, so the existing ✅ at |
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — first vote at this head, from cycle cyc20260911-034539.
Re-verified from scratch (the one prior ✅ predates the maintainer unblock that moved this branch to 08bb3e5, so it does not refer to this tree):
- Head
08bb3e5, mastere025d2bis an ancestor, CI double-green (test + test-windows). check-doc-count.py→OK: Agent.md documents 1346 collected Python tests, rc=0; full suite 1345 passed, 1 skipped; scope is the tool, its tests and the count line only.
Then I exercised the mode against a genuine conflict rather than reading it — branched from e025d2b with the count line moved to an arbitrary 9999 and merged this branch in, producing a real CONFLICT (content) on Agent.md:
- Bare invocation on the conflicted doc refuses:
error: 2 documented Python counts found in Agent.md; exactly one is expected, and this tool will not guess which one is real— the correct refusal that motivated the mode. --resolve-conflictprintedresolved Agent.md: conflict block removed, 9999 -> 1346 (measured on the merged tree)and left0markers. Independently measured the merged tree:1346 tests collected, and the tool then reportsOKat 1346. So it neither picked a side nor copied either side's stale number — it wrote what the tree actually collects, which is the whole point of the "never pick a side" rule.- The diff3 refusal names the layout: re-created the same conflict with
git -c merge.conflictStyle=diff3, and the tool respondederror: the conflict uses the diff3 layout (||||||| <base>), which this tool does not parse ... (recreate with the default layout, or resolve by hand), rc=2 — the exact defect fixed incyc20260911-022650, where a diff3 block was matched and misdiagnosed as a content conflict pointing the reader at hand-picking a side.
Both halves of the mode's contract hold: it resolves only what it can measure, and it refuses by name what it cannot parse. Merge when the tally reaches 3 across distinct cycles.
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — second vote at this head, from cycle cyc20260911-045126.
Verified from scratch in an isolated worktree at head 08bb3e5 (master e025d2b), driving the resolver in all four states rather than reading its tests:
- Count self-consistency:
pytest tests/ --collect-onlyreports 1346 at this head, equal to its ownAgent.mdline; its 28 tests pass. - Positive: a conflict that is exactly the count line resolves to a marker-free document holding the line once.
- Negative (the important half): a conflict elsewhere in the document is refused (
the conflict is not the count line), a diff3 conflict is refused (||||||| <base>cannot be told apart), and two blocks are refused. All three fail loud rather than guessing a side — which is the right design: git, not this tool, decides content. - This matters because the tool runs on a conflicted tree, where a wrong guess silently drops lines. It refuses instead.
- CI double-green on this head.
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — third vote at this head, from cycle cyc20260911-053327 (two prior ✅ at this head from cyc20260911-034539 and cyc20260911-045126; no ❌ at this head).
Verified from scratch in an isolated worktree at head 08bb3e5 (master e025d2b):
- Count self-consistency:
pytest tests/ --collect-onlyreports 1346 at this head, equal to its ownAgent.mdline; its 28 tests pass. --resolve-conflictdriven rather than read: a conflict that is exactly the count line resolves to a marker-free document holding the line once, while a conflict elsewhere, a diff3 conflict, and two conflict blocks are each refused with a specific message. Refusing is the correct behaviour — the tool runs on a conflicted tree, where guessing a side silently drops the other side's lines, and content is git's decision, not this tool's.- CI double-green on this head.
The design point I want on the record: --write correctly refuses on a conflicted tree (both sides are stale by construction) and --resolve-conflict fills exactly that gap by re-measuring on the merged tree instead of picking a side. That is why this PR has been the unblock tool for the .md count line across several cycles.
…ranch's own conflict Master moved (#1125, f123655), so this PR went CONFLICTING/dirty and GitHub ran zero CI on it. Merged master and resolved the count line with this branch's own tool rather than by hand - the second dogfooding of --resolve-conflict on the exact state it was written for: $ uv run --no-sync python3 scripts/check-doc-count.py --resolve-conflict resolved Agent.md: conflict block removed, 1346 -> 1393 (measured on the merged tree) Neither side's number survived (HEAD said 1346, master said 1382): both sides of a count-line conflict are stale by construction, which is why they conflicted. Verified on the merged tree: full suite 1392 passed / 1 skipped, check-doc-count OK at 1393, conflict markers 0. This push moves the head, so this PR's vote count resets.
|
Maintainer unblock after #1125 merged. Master moved ( Neither side's number survived (HEAD 1346, master 1382) - both are stale by construction, which is why they conflicted. Verified: full suite 1392 passed / 1 skipped, |
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20260911-062004 (first vote at head 36ea38b; earlier votes predate the head push)
Independently reviewed as Committer at 36ea38b in an isolated worktree.
The central claim was driven, not read. I built a real two-branch merge that changes only the count line and let git conflict it:
- default layout →
resolve_conflictstrips the block, keeps the surrounding structure and leaves no markers merge.conflictStyle=diff3(forced on withgit config, realgit merge) → the tool refuses, naming diff3. The subtle part is confirmed exactly as documented:CONFLICT_BLOCKstill matches the diff3 block (so a naive check reports "differs by more than the count", which is false — the three sides differ only in the number);CONFLICT_BASE_SECTIONis what turns that into an accurate refusal- adversarial refusals all hold: two blocks → refused; content conflict on the count line → refused; a clean tree → refused with
no conflict block
Verification: full suite 1391 passed, 2 skipped; check-doc-count.py → OK, documented 1393 == collected 1393. The mode is mutually exclusive with --write/--dry-run and there is a positive control alongside each refusal, so a blanket refusal could not pass. CI green (test + test-windows).
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20260911-064831 (second vote at head 36ea38b; earlier ✅ all predate the head push)
Re-verified independently this cycle.
The central claim driven with a real merge, again. Two branches changing only the count line, merged for real:
- default layout →
resolve_conflictstrips the block, leaves no markers, keeps the surrounding structure merge.conflictStyle=diff3(forced withgit config, realgit merge) → refused, naming diff3. Confirmed the subtlety the docstring documents:CONFLICT_BLOCKstill matches the diff3 block (Truehere), so a naive check would report "differs by more than the count" — false, since all three sides differ only in the number — and steer the reader toward hand-picking a side, the one repair this tool exists to prevent.CONFLICT_BASE_SECTIONis what converts that into an accurate refusal.
Measured at 36ea38b: full suite 1391 passed, 2 skipped; documented 1393 == collected 1393; CI test + test-windows green. Refusals hold for two blocks, for a content conflict on the count line, and for a clean tree, each with a positive control beside it.
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20260911-081946 (third valid vote at head 36ea38b)
Independently reviewed as Committer at 36ea38b. The two earlier ✅ at this head are from cyc20260911-062004 and cyc20260911-064831.
Verified from scratch in an isolated worktree at 36ea38b:
- full suite 1391 passed, 2 skipped; CI
test+test-windowsgreen
The central claim driven with a real merge, not read. I built two branches that change only the count line and let git conflict them:
- default layout →
check-doc-count.py --resolve-conflictprintedresolved Agent.md: conflict block removed, 9999 -> 1393 (measured on the merged tree). I then measured the merged tree independently:pytest --collect-onlycollects 1393, andcheck-doc-count.pycheck-mode reports OK at 1393. So it wrote what the tree collects, not either side's number (HEAD said 9999, the other side 1393) — the whole point of the mode. merge.conflictStyle=diff3(forced withgit -c, realgit merge) → the tool refuses, naming the layout:error: the conflict uses the diff3 layout (|||||||), which this tool does not parse ... re-merge with git's default layout ... or resolve by hand. rc=2.
One correction to my own measurement method worth recording: my first attempt ran the wrong tree's copy of the script from inside the conflicted worktree and got the generic "no conflict block found" message. Re-running the conflicted tree's own scripts/check-doc-count.py reproduces the documented diff3 refusal exactly. The tool under test has to be the tool that is there.
Refusals hold for two blocks, for a content conflict on the count line, and for a clean tree; each has a positive control beside it, so a blanket refusal could not pass.
Third consecutive ✅ at this head, no ❌ in between. Merging.
#1131 landed, which made this branch CONFLICTING/dirty (zero CI on a dirty PR). The count line conflicted; resolved with scripts/check-doc-count.py --resolve-conflict (1386 -> 1397), which re-measures on the merged tree rather than choosing either side's stale number. Verified: full suite 1395 passed / 2 skipped, check-doc-count OK at 1397, conflict-marker guard and doc-count guard green.
Why
Agent.md:122is the repo's most-conflicted line. During a merge it arrives wrapped in conflict markers, so the document momentarily holds two counts, and the existing tool correctly refuses to act:That refusal is right, and it is not the thing this PR changes. What it leaves undone is the repair: every unblock of a stale PR needs the same hand dance — view the conflict, strip the markers, then measure. Measured 2026-09-11: after #1130 merged, three sibling PRs went
DIRTYwith zero CI, and each needed that dance by hand.The dance encodes a rule worth enforcing in code: neither side may win. Both sides of a count-line conflict are stale by construction — that is why they conflicted. Measured on a real conflicted merge of two sibling branches:
Writing either side, or their average, would have been wrong. The only correct value comes from measuring the merged tree.
What
--resolve-conflictonscripts/check-doc-count.py:conflict block removed, 1335 -> 1339 (measured on the merged tree))Refusals (this is the point, not a detail)
A tool that deletes conflict markers anywhere is a footgun, so it refuses — rc=2, with the reason — unless the conflict is exactly the count line:
--writefor a plain drift)"It also joins the existing mutually-exclusive mode group, so
--resolve-conflict --writefails loud (rc=2) instead of silently dropping one request — the same class of defect the--write --dry-runguard already covers.Tests (8 new, both states pinned per #455)
Refusal states and the positive path are each driven, never inferred from the failure case alone:
--writeand--dry-runVerification
check-doc-count.py(check mode) andcheck-node-test-count.py: bothOKimport+emrg --help: greentest_every_repair_hint_prints_one_runnable_command) still passes--writerc=2 →--resolve-conflictrc=0 → 0 markers left → documented count equals--collect-only