Repository navigation
Conversation
…v merges rows by glob and their gates as sets Two branches that each add a gate to one row, or rows beside each other, stopped every rebase on this file, though the right merge is mechanical. The driver runs `git merge-file --diff3`, then resolves each conflicted hunk row by row against its base: a gate either side added is in, a gate one side removed is out, the why and exceptions columns take the side that changed them. A comment both sides changed, a why both changed differently, or a row one side removed and the other changed stays a conflict (exit 1, markers left). .gitattributes names it; `--install` registers it in a clone's git config, and the registered command falls back to the text merge in a checkout from before the driver. Replayed on #179's real conflict (two hunks under the text merge): merged clean, each row's gates equal to the three-way set rule. A git rebase of that branch in a scratch repo with the driver: rc 0. Without the driver file: the text conflict as before. --self-test: 10 cases. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Verdict: MUST_FIX: The driver reports a delete-versus-move-and-edit conflict as a clean merge.
Must fix
1. Preserve row identity across conflict-hunk boundaries
tools/gate-paths-merge.py:98: Base rows are a,b,c,d. Ours deletes b and adds a gate to d; theirs moves b after c and changes its gates. Git's conflict contains only d in its base. The hunk-local lookup treats b as new, restores it, and returns 0 instead of retaining the delete/edit conflict.
Fix this before clones install the driver; later rebases will accept the restored row without asking the user to resolve it.
A conservative fix leaves textual conflicts unresolved when existing rows were reordered. Replace line 150 with:
text, left = p.stdout, 0
if p.returncode:
orders = []
for path in (base, ours, theirs):
with open(path) as f:
orders.append(rows_of(f.read().splitlines())[1])
bo, oo, to = orders
moved = any(order is None for order in orders)
if not moved:
moved = any(
[k for k in order if k in bo] != [k for k in bo if k in order]
for order in (oo, to)
)
text, left = (p.stdout, 1) if moved else merge_text(p.stdout)Append this regression to cases:
("deleted row moved and edited on the other side",
base.replace("a/*.rs\tgate-x gate-y\twhy a\n", "").replace("gate-z\twhy c", "gate-z gate-o\twhy c"),
base.replace("a/*.rs\tgate-x gate-y\twhy a\n", "").replace("c/*.rs", "a/*.rs\tgate-x gate-y gate-t\twhy a\nc/*.rs"),
1, None),The regression fails on the current driver. The proposed guard passes the existing ten cases and preserves markers on the reproduced failure.
2. Make the self-test reject defects in both merge directions
tools/gate-paths-merge.py:163: Replacing line 73 with gates = list(og) or line 58 with return ours leaves all ten cases green. These mutants retain a gate theirs removed or discard a why theirs changed. This is a proven test gap with a mechanical fix (kind B). Add coverage now so later changes cannot silently break either direction. Append these fixtures to cases:
("theirs removes a gate while ours adds one", base.replace("gate-x gate-y\twhy a", "gate-x gate-y gate-o\twhy a"),
base.replace("gate-x gate-y\twhy a", "gate-y\twhy a"), 0,
base.replace("gate-x gate-y\twhy a", "gate-y gate-o\twhy a")),
("theirs changes why while ours adds a gate", base.replace("gate-x gate-y\twhy a", "gate-x gate-y gate-o\twhy a"),
base.replace("why a", "why a2"), 0,
base.replace("gate-x gate-y\twhy a", "gate-x gate-y gate-o\twhy a2")),3. Remove incident history from the docstring
tools/gate-paths-merge.py:15: Lines 15–17 include prior-rebase history, including "three times on one PR in a day". AGENTS.md Conventions requires that history to stay in commit messages. This is a direct contract violation with a mechanical fix (kind B). Remove it before it becomes stale. Replace lines 15–17 with:
OURS and the exit is 1, git's "not merged".Checked
Duplicates: no other row-merge owner found; the other rows_of functions read binary rows or trace tables.
Complexity: no Rust function changed; no crapper targets. This is tool code, with no inference hot path.
Silent failure: 3 sites followed. Hunk lookup restores a deleted row with exit 0; the empty-row insertion default stays defined; failed rev-parse reaches a checked git-config command and exits nonzero.
Tests: ten original cases pass; both named mutants also pass them. The new fixtures catch those mutants. The delete/move/edit regression fails on current production; the proposed guard passes all 13 cases and preserves the reproduced conflict markers.
Gates: no existing gate, band, tolerance or lint moved or disappeared; one tool self-test was registered.
Escape catalog: C1–C4 match none; the shell fallback uses exec and preserves Git's exit status.
Notes
Dryer and crapper did not run because no Rust file changed.
🔨 Shingler, bloomery's review bot: approves a clean pull request and requests changes on a must-fix (how it works)
…self-test covers both merge directions A row one side deleted and the other moved into another hunk is absent from the first hunk's base, so the hunk-local merge treated it as new and put it back with exit 0. When an existing row's order changed, the driver leaves git's markers. The self-test also has the other direction of a removed gate and of a changed why: the earlier cases passed a gates list taken from ours alone, and a pick that always returns ours. The docstring no longer records how often a rebase hit this file.
There was a problem hiding this comment.
Since the last review:
-
- Still open: tools/gate-paths-merge.py:161. Moving a deleted row across a comment preserves row-key order and bypasses the new guard.
-
- Fixed: tools/gate-paths-merge.py:214. The reverse-direction fixtures reject both reported mutants.
-
- Fixed: tools/gate-paths-merge.py:13. The docstring's incident history was removed.
Verdict: MUST_FIX: A row moved across a comment still bypasses deletion-versus-edit conflict detection.
Must fix
1. Check deletion versus edit against the complete input tables
tools/gate-paths-merge.py:161: Ours deletes a and inserts o before the header. Theirs moves a before that header and changes its gates. Git conflicts on the leading insertions with an empty hunk base; both sides delete the original a. The row-key projections detect no reorder, so the resolver restores a and returns 0.
Fix this before installation: later rebases otherwise accept the restored row without asking for resolution.
Replace lines 153–166 with:
tables = []
for path in (base, ours, theirs):
with open(path) as f:
tables.append(rows_of(f.read().splitlines()))
(rb, bo, _), (ro, oo, _), (rt, tho, _) = tables
blocked = any(order is None for _, order, _ in tables)
if not blocked:
blocked = any(
[k for k in order if k in bo] != [k for k in bo if k in order]
for order in (oo, tho)
) or any(
(key not in ro and key in rt and rt[key] != row)
or (key not in rt and key in ro and ro[key] != row)
for key, row in rb.items()
)
if blocked:
text, left = p.stdout, p.stdout.count(OURS_M) or 1
reason = "a row moved, or was deleted on one side and changed on the other"Append this fixture to cases; the current guard returns 0 instead of 1:
("deleted row moved across header and edited",
"o/*.rs\tgate-o\twhy o\n" + base.replace("a/*.rs\tgate-x gate-y\twhy a\n", ""),
"a/*.rs\tgate-x gate-y gate-t\twhy a\n" + base.replace("a/*.rs\tgate-x gate-y\twhy a\n", ""),
1, None),Checked
Duplicates: 0 new functions, types or constants in the incremental diff; no new duplicate owner.
Complexity: Python driver gains a guard branch; no Rust function changed.
Silent failure: 2 new guard sites followed; the header relocation reaches a clean exit with the deleted row restored.
Tests: the reverse-direction fixtures reject keeping ours' gates (line 71) and returning ours' unchanged field (56) in memory. The added relocation fixture changes row-key order and reaches the guard (164). With constructed diff3 output, the header-relocation probe returns 0; the proposed replacement returns 1 and preserves the markers.
Gates: no gate, threshold, tolerance or lint was removed or relaxed.
Escape catalog: C1–C4 match none.
Notes
Dryer and crapper did not run because no Rust file changed. Driver probes used in-memory files and constructed diff3 output; the Git subprocess and full self-test were not run.
🔨 Shingler, bloomery's review bot: approves a clean pull request and requests changes on a must-fix (how it works)
…s a comment stays a conflict Moving that row across a comment keeps row-key order, and the conflict hunk's base is empty, so the hunk-local merge put the row back. The guard now reads the whole tables: a base row missing on one side and different on the other leaves git's markers.
There was a problem hiding this comment.
Since the last review: the reported conflicted fixture is fixed. A clean text-merge variant still bypasses the same check below. The previously fixed reverse-direction tests and docstring remain fixed.
Verdict: MUST_FIX: A clean text merge still restores a row deleted on one side and edited on the other.
Must fix
1. Check deletion versus edit even when Git returns zero
tools/gate-paths-merge.py:156: Remove the o/*.rs insertion from the new fixture. Both sides delete the original a; theirs also inserts edited a before the header. These text edits do not conflict. Git returns 0, so the driver skips the whole-table check, restores a, and reports success instead of a conflict.
Fix this before installation; later rebases otherwise accept this semantic conflict without resolution.
Replace lines 155–177 with the following. Check deletion/edit conflicts unconditionally and supply markers when Git produced none:
contents, tables = [], []
for path in (base, ours, theirs):
with open(path) as f:
contents.append(f.read())
tables.append(rows_of(contents[-1].splitlines()))
(rb, bo, _), (ro, oo, _), (rt, tho, _) = tables
valid = all(order is not None for _, order, _ in tables)
deleted_changed = valid and any(
(key not in ro and key in rt and rt[key] != row)
or (key not in rt and key in ro and ro[key] != row)
for key, row in rb.items()
)
blocked = deleted_changed or (p.returncode and (
not valid or any(
[k for k in order if k in bo] != [k for k in bo if k in order]
for order in (oo, tho)
)
))
if blocked:
text = p.stdout
if not p.returncode:
b, o, t = (s if s.endswith("\n") else s + "\n" for s in contents)
text = f"<<<<<<< ours\n{o}||||||| base\n{b}=======\n{t}>>>>>>> theirs\n"
left = text.count(OURS_M) or 1
reason = "a row moved, or was deleted on one side and changed on the other"
else:
text, left = merge_text(p.stdout) if p.returncode else (p.stdout, 0)Append this fixture to cases:
("deleted row moved across header without a text conflict",
base.replace("a/*.rs\tgate-x gate-y\twhy a\n", ""),
"a/*.rs\tgate-x gate-y gate-t\twhy a\n"
+ base.replace("a/*.rs\tgate-x gate-y\twhy a\n", ""),
1, None),Checked
Duplicates: 0 new functions, types or constants in the incremental diff; no new duplicate owner.
Complexity: driver adds a deletion/edit decision; no Rust function changed.
Silent failure: 2 decision sites followed; conflicted input is protected, but clean Git output bypasses validation.
Tests: all 14 existing fixtures pass with real Git and memory-backed inputs. Removing the deletion/edit check at lines 166–170 makes the added fixture fail. The proposed replacement passes those fixtures and rejects the clean variant in both deletion directions with exit 1 and markers.
Gates: no gate, tolerance or lint was removed or relaxed.
Escape catalog: C1–C4 match none.
Notes
Dryer and crapper did not run because no Rust file changed. Validation ran the fixture cases through the driver and actual Git merge-file with memory-backed inputs; the self-test command and full repository checks were not run.
🔨 Shingler, bloomery's review bot: approves a clean pull request and requests changes on a must-fix (how it works)
…e side deletes a row the other edits
A duplicate glob is ordinary table data, so a second row no longer blanks the rest of the file. A glob with at most one row on every side still merges that row: gates as a set, and the why and exceptions from the side that changed them. A glob with two or more rows on any side is picked as one tuple, and deleting it is the empty tuple, so a deletion against an edit conflicts even when git's text merge is clean. The conflict names the glob and its line numbers. The order check lists each glob once, at its first row.
There was a problem hiding this comment.
Since the last review: the deletion/edit check on clean Git output is fixed in both directions. The reverse-direction tests and docstring remain fixed.
Verdict: MUST_FIX: A successful merge can restore a deleted gate, and row grouping has two owners.
Must fix
1. Validate the emitted duplicate tuple
tools/gate-paths-merge.py:238: Base rows are a(gate-x), b, a(gate-y). Ours moves the first a after b; theirs deletes it. decide selects only a(gate-y), but Git and the driver return 0 with both rows. The deleted gate is restored. The whole-table check only tests for None; its selected tuple never constrains the output.
Fix this before clones install the driver and accept these merges.
Insert this before with open(ours, "w"). Leave a conflict whenever the emitted duplicate tuple differs from its owner’s decision:
if not left:
actual, _, _ = line_index(text)
mismatched = [
g for g in globs
if max(len(rb.get(g, ())), len(ro.get(g, ())), len(rt.get(g, ())), len(actual.get(g, ()))) > 1
and tuple(tuple(r) for r in actual.get(g, ()))
!= decide(rb.get(g, ()), ro.get(g, ()), rt.get(g, ()))
]
if mismatched:
b, o, t = (s if s.endswith("\n") else s + "\n" for s in texts)
text = "<<<<<<< ours\n%s||||||| base\n%s=======\n%s>>>>>>> theirs\n" % (o, b, t)
left = 1
reason = "merged rows differ from the selected tuple: " + ", ".join(mismatched)Append this regression to extra:
("a moved duplicate member versus deletion",
head + "a/*.rs\tgate-x\twhy1\nb/*.rs\tgate-z\twhy b\na/*.rs\tgate-y\twhy2\n",
head + "b/*.rs\tgate-z\twhy b\na/*.rs\tgate-x\twhy1\na/*.rs\tgate-y\twhy2\n",
head + "b/*.rs\tgate-z\twhy b\na/*.rs\tgate-y\twhy2\n",
1, None, None),2. Reuse the existing row-grouping owner
tools/gate-paths-merge.py:57 duplicates tools/gate-paths-merge.py:41: both split tab fields, group rows by glob, and collect first-occurrence order. This violates R14. Line-number collection is the only additional job.
Reuse the owner now; later grouping changes otherwise require two edits. Replace line_index with:
def line_index(text):
"""The rows_of grouping, plus each glob's 1-based line numbers."""
lines = text.splitlines()
rows, order, _ = rows_of(lines)
at = {glob: [] for glob in order}
for n, line in enumerate(lines, 1):
if is_row(line):
at[line.split("\t", 1)[0]].append(n)
return rows, at, orderChecked
Duplicates: line_index repeats rows_of's grouping and first-occurrence order. Reuse removes six lines and adds no functions.
Complexity: driver adds whole-table decisions; resolve adds row cursors. No Rust function changed.
Silent failure: the successful-output path does not validate the selected tuple; the regression confirms a deleted gate is restored.
Tests: the validator confirmed 28 fixtures pass with actual Git and memory-backed inputs. The added regression fails on head; both fixes pass all 29 fixtures. The tuple check rejects the moved-member case in both directions with exit 1 and markers.
Gates: no threshold, tolerance, lint or gate was removed or relaxed.
Escape catalog: C1–C4 match none. Tool code touches no inference hot path.
Notes
Dryer and crapper did not run because no Rust file changed. Full check-recipes was not run. The PR body contains no FAIL-first output for the added tuple fixtures.
🔨 Shingler, bloomery's review bot: approves a clean pull request and requests changes on a must-fix (how it works)
…-way decision of each glob A duplicate glob's member that ours moved and theirs deleted merged clean as text with both rows back: the deleted gate came back. The driver now reads the merged text once more, whichever path made it, and every glob's rows must be what decide() chose (the gates as a set, so a side's own gate order is no change); any other result is a conflict that names the globs. line_index reuses rows_of's grouping. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Since the last review:
Tuple validation is fixed: tools/gate-paths-merge.py:244 rejects output that restores a deleted duplicate member.
Row-grouping duplication is fixed: tools/gate-paths-merge.py:60 reuses rows_of.
Verdict: CLEAN: Both earlier must-fix items are resolved, and no new blocking defect was found.
Checked
Duplicates: 1 new function checked; no duplicate owner found. No dryer pairs were reported.
Complexity: driver adds a final validation branch. No Rust functions changed.
Silent failure: 1 new validation site followed through clean Git output and resolved hunks; mismatched rows return 1 with conflict markers.
Tests: The validator checked both new fixtures and reversed the deletion case using supplied Git output. Dropping validation at line 240 fails the deletion fixture; comparing gate order at line 71 fails the reordering fixture. Actual Git merged the base/head tables unchanged, and the driver accepted its output in memory. The validator also confirmed line_index agrees with rows_of and reports the correct line positions.
Gates: no threshold, tolerance, lint or gate was removed or relaxed.
Escape catalog: C1–C4 matched none. This tool touches no inference hot path.
Notes
Dryer and crapper did not run because no Rust file changed. The filesystem self-test and full check-recipes were not run. The PR body contains no FAIL-first output for the new fixtures.
🔨 Shingler, bloomery's review bot: approves a clean pull request and requests changes on a must-fix (how it works)
What and why
Two branches that each add a gate to one row of
tools/gate-paths.tsv, or change rows beside each other, stop every rebase on that file, though the right merge is mechanical (three times on #128 in a day, again on #179).tools/gate-paths-merge.pyis a git merge driver for this one file: it runsgit merge-file --diff3, then resolves each conflicted hunk row by row against its base. A gate either side added is in, a gate one side removed is out, and the why and exceptions columns take the side that changed them. A comment both sides changed, a why both changed differently, or a row one side removed and the other changed stays a conflict (exit 1, markers left)..gitattributesnames it. A clone runspython3 tools/gate-paths-merge.py --installonce to register it (worktrees share the clone's config); the registered command falls back to the text merge in a checkout from before the driver. Until then nothing changes.Code shape
selftests=(.Duplication found or removed: none.
What I ran
🤖 Generated with Claude Code