mip_index_merge
mip_index_merge — auto-resolve the one conflict two MIP draft branches produce between them:
both append a row to the table in docs/MIPs/README.md at the same place, so git folds the two
additions into one hunk. The sibling of req_merge.py/uses_merge.py: scripts/mip-stack.sh
calls this after a cherry-pick fails, on docs/MIPs/README.md only — any other conflicted file
stops for a human before this module runs.
scripts/lib/mip_index_merge.py <conflicted-README> [<more> ...]
scripts/lib/mip_index_merge.py --self-test
Resolution rule: every non-blank line on both sides of a hunk must be an index row
(| [MIP-NNNN](./MIP-NNNN-….md) | … |). The merged hunk is the union of both sides' rows, one
per MIP number, sorted by number. A MIP that appears on both sides with different text is a
real edit (a status change, a reworded title), not a row add — that refuses, all-or-nothing, and
the script prints the human-resolve steps. Identical rows on both sides collapse to one.
1#!/usr/bin/env python3 2"""mip_index_merge — auto-resolve the one conflict two MIP draft branches produce between them: 3both append a row to the table in `docs/MIPs/README.md` at the same place, so git folds the two 4additions into one hunk. The sibling of `req_merge.py`/`uses_merge.py`: `scripts/mip-stack.sh` 5calls this after a cherry-pick fails, on `docs/MIPs/README.md` only — any other conflicted file 6stops for a human before this module runs. 7 8 scripts/lib/mip_index_merge.py <conflicted-README> [<more> ...] 9 scripts/lib/mip_index_merge.py --self-test 10 11Resolution rule: every non-blank line on both sides of a hunk must be an index row 12(`| [MIP-NNNN](./MIP-NNNN-….md) | … |`). The merged hunk is the union of both sides' rows, one 13per MIP number, sorted by number. A MIP that appears on both sides with *different* text is a 14real edit (a status change, a reworded title), not a row add — that refuses, all-or-nothing, and 15the script prints the human-resolve steps. Identical rows on both sides collapse to one. 16""" 17 18import argparse 19import re 20import sys 21from pathlib import Path 22 23ROW_RE = re.compile(r"^\|\s*\[MIP-(?P<num>\d{4})\]\(") 24 25# One conflict hunk. Either side may be EMPTY — the common shape here: the chain has rows the 26# draft never saw, the draft adds its row after them, git shows ours as nothing and theirs as the 27# row (or the reverse). Each side is captured with its trailing newlines and split afterwards. 28CONFLICT_RE = re.compile( 29 r"<<<<<<<[^\n]*\n" 30 r"(?P<ours>(?:.*\n)*?)" 31 r"(?:\|\|\|\|\|\|\|[^\n]*\n(?:.*\n)*?)?" 32 r"=======\n" 33 r"(?P<theirs>(?:.*\n)*?)" 34 r">>>>>>>[^\n]*\n?", 35) 36 37 38class Unresolvable(Exception): 39 pass 40 41 42def rows_of(lines: list[str]) -> dict[int, str]: 43 out: dict[int, str] = {} 44 for line in lines: 45 if not line.strip(): 46 continue 47 m = ROW_RE.match(line) 48 if not m: 49 raise Unresolvable(f"not a MIP index row: {line[:60]!r}") 50 num = int(m.group("num")) 51 if num in out and out[num] != line: 52 raise Unresolvable(f"MIP-{num:04d} appears twice on one side with different text") 53 out[num] = line 54 return out 55 56 57def resolve_hunk(ours_lines: list[str], theirs_lines: list[str]) -> tuple[list[str], list[str]]: 58 ours, theirs = rows_of(ours_lines), rows_of(theirs_lines) 59 log: list[str] = [] 60 merged: dict[int, str] = {} 61 for num in sorted(set(ours) | set(theirs)): 62 o, t = ours.get(num), theirs.get(num) 63 if o is not None and t is not None and o != t: 64 raise Unresolvable( 65 f"MIP-{num:04d} is on both sides with different text — an edit, not a row add" 66 ) 67 merged[num] = o if o is not None else t # type: ignore[assignment] 68 if (o is None) != (t is None): 69 log.append(f"MIP-{num:04d}: row kept from {'ours' if o is not None else 'theirs'}") 70 return [merged[n] for n in sorted(merged)], log 71 72 73def merge_text(text: str) -> tuple[str, list[str]]: 74 if "<<<<<<<" not in text: 75 return text, [] 76 log: list[str] = [] 77 78 def repl(m: re.Match[str]) -> str: 79 lines, hunk_log = resolve_hunk(m.group("ours").split("\n"), m.group("theirs").split("\n")) 80 log.extend(hunk_log) 81 return "".join(line + "\n" for line in lines) 82 83 merged = CONFLICT_RE.sub(repl, text) 84 if "<<<<<<<" in merged or ">>>>>>>" in merged: 85 raise Unresolvable("leftover conflict marker after substitution — unexpected shape") 86 return merged, log 87 88 89def resolve_files(paths: list[Path]) -> list[str]: 90 """All-or-nothing: every file must resolve, or nothing is written.""" 91 results: dict[Path, tuple[str, list[str]]] = {} 92 for path in paths: 93 try: 94 results[path] = merge_text(path.read_text()) 95 except Unresolvable as exc: 96 raise Unresolvable(f"{path}: {exc}") from exc 97 all_log: list[str] = [] 98 for path, (merged, log) in results.items(): 99 path.write_text(merged) 100 all_log.extend(f"{path}: {line}" for line in log) 101 return all_log 102 103 104# --- self-test ---------------------------------------------------------------------------------- 105 106 107def self_test() -> int: 108 row = "| [MIP-{n:04d}](./MIP-{n:04d}-x.md) | title {n} | Draft | 2026-09-06 | S | user value | do next | — |" 109 # 1. The recorded shape (2026-09-06): two drafts each appended a row after the same last row; 110 # ours carries 0023, theirs 0020 — the merge keeps both, in number order. 111 adjacent = ( 112 row.format(n=19) + "\n" 113 "<<<<<<< HEAD\n" + row.format(n=23) + "\n" 114 "=======\n" + row.format(n=20) + "\n" 115 ">>>>>>> 3f29f31 (MIP-0020: an Instagram account)\n" 116 "\nText after the table.\n" 117 ) 118 merged, log = merge_text(adjacent) 119 lines = [ln for ln in merged.split("\n") if ln.startswith("| [MIP-")] 120 assert lines == [row.format(n=19), row.format(n=20), row.format(n=23)], lines 121 assert "<<<<<<<" not in merged and merged.endswith("\nText after the table.\n"), merged 122 assert len(log) == 2, log 123 124 # 2. Both sides carry the same context rows plus one addition each: identical rows collapse. 125 both = ( 126 "<<<<<<< HEAD\n" 127 + row.format(n=21) 128 + "\n" 129 + row.format(n=22) 130 + "\n" 131 + row.format(n=23) 132 + "\n" 133 "=======\n" + row.format(n=20) + "\n" + row.format(n=21) + "\n" + row.format(n=22) + "\n" 134 ">>>>>>> theirs\n" 135 ) 136 merged2, log2 = merge_text(both) 137 assert [ln for ln in merged2.split("\n") if ln] == [ 138 row.format(n=n) for n in (20, 21, 22, 23) 139 ], merged2 140 assert len(log2) == 2, log2 141 142 # 2b. One side empty — the chain already had rows 0021–0023, the draft appends 0024 after a 143 # context the chain no longer has, so git shows ours as nothing and theirs as the row. 144 empty_side = ( 145 row.format(n=23) 146 + "\n<<<<<<< HEAD\n=======\n" 147 + row.format(n=24) 148 + "\n>>>>>>> theirs\n\nafter\n" 149 ) 150 merged_e, log_e = merge_text(empty_side) 151 assert merged_e == row.format(n=23) + "\n" + row.format(n=24) + "\n\nafter\n", merged_e 152 assert log_e == ["MIP-0024: row kept from theirs"], log_e 153 154 # 3. Same MIP on both sides with different text (a status edit) -> refuse; a non-row -> refuse. 155 for bad in ( 156 "<<<<<<< HEAD\n" 157 + row.format(n=20) 158 + "\n=======\n" 159 + row.format(n=20).replace("Draft", "Accepted") 160 + "\n>>>>>>> t\n", 161 "<<<<<<< HEAD\nsome prose\n=======\n" + row.format(n=20) + "\n>>>>>>> t\n", 162 ): 163 try: 164 merge_text(bad) 165 raise AssertionError(f"expected Unresolvable for {bad[:40]!r}") 166 except Unresolvable: 167 pass 168 169 # 4. resolve_files is all-or-nothing. 170 import tempfile 171 172 with tempfile.TemporaryDirectory() as tmp: 173 d = Path(tmp) 174 good, bad_f = d / "README.md", d / "OTHER.md" 175 good.write_text(adjacent) 176 bad_f.write_text("<<<<<<< HEAD\nprose\n=======\nother\n>>>>>>> t\n") 177 try: 178 resolve_files([good, bad_f]) 179 raise AssertionError("expected Unresolvable") 180 except Unresolvable: 181 pass 182 assert good.read_text() == adjacent 183 assert len(resolve_files([good])) == 2 and "<<<<<<<" not in good.read_text() 184 185 print("mip_index_merge self-test: PASSED") 186 return 0 187 188 189def main(argv: list[str]) -> int: 190 ap = argparse.ArgumentParser( 191 description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter 192 ) 193 ap.add_argument("files", nargs="*", type=Path) 194 ap.add_argument("--self-test", action="store_true") 195 args = ap.parse_args(argv) 196 if args.self_test: 197 return self_test() 198 if not args.files: 199 ap.print_help() 200 return 2 201 try: 202 for line in resolve_files(args.files): 203 print(line) 204 except Unresolvable as exc: 205 print(f"mip_index_merge: cannot auto-resolve — {exc}", file=sys.stderr) 206 return 1 207 return 0 208 209 210if __name__ == "__main__": 211 sys.exit(main(sys.argv[1:]))
Common base class for all non-exit exceptions.
43def rows_of(lines: list[str]) -> dict[int, str]: 44 out: dict[int, str] = {} 45 for line in lines: 46 if not line.strip(): 47 continue 48 m = ROW_RE.match(line) 49 if not m: 50 raise Unresolvable(f"not a MIP index row: {line[:60]!r}") 51 num = int(m.group("num")) 52 if num in out and out[num] != line: 53 raise Unresolvable(f"MIP-{num:04d} appears twice on one side with different text") 54 out[num] = line 55 return out
58def resolve_hunk(ours_lines: list[str], theirs_lines: list[str]) -> tuple[list[str], list[str]]: 59 ours, theirs = rows_of(ours_lines), rows_of(theirs_lines) 60 log: list[str] = [] 61 merged: dict[int, str] = {} 62 for num in sorted(set(ours) | set(theirs)): 63 o, t = ours.get(num), theirs.get(num) 64 if o is not None and t is not None and o != t: 65 raise Unresolvable( 66 f"MIP-{num:04d} is on both sides with different text — an edit, not a row add" 67 ) 68 merged[num] = o if o is not None else t # type: ignore[assignment] 69 if (o is None) != (t is None): 70 log.append(f"MIP-{num:04d}: row kept from {'ours' if o is not None else 'theirs'}") 71 return [merged[n] for n in sorted(merged)], log
74def merge_text(text: str) -> tuple[str, list[str]]: 75 if "<<<<<<<" not in text: 76 return text, [] 77 log: list[str] = [] 78 79 def repl(m: re.Match[str]) -> str: 80 lines, hunk_log = resolve_hunk(m.group("ours").split("\n"), m.group("theirs").split("\n")) 81 log.extend(hunk_log) 82 return "".join(line + "\n" for line in lines) 83 84 merged = CONFLICT_RE.sub(repl, text) 85 if "<<<<<<<" in merged or ">>>>>>>" in merged: 86 raise Unresolvable("leftover conflict marker after substitution — unexpected shape") 87 return merged, log
90def resolve_files(paths: list[Path]) -> list[str]: 91 """All-or-nothing: every file must resolve, or nothing is written.""" 92 results: dict[Path, tuple[str, list[str]]] = {} 93 for path in paths: 94 try: 95 results[path] = merge_text(path.read_text()) 96 except Unresolvable as exc: 97 raise Unresolvable(f"{path}: {exc}") from exc 98 all_log: list[str] = [] 99 for path, (merged, log) in results.items(): 100 path.write_text(merged) 101 all_log.extend(f"{path}: {line}" for line in log) 102 return all_log
All-or-nothing: every file must resolve, or nothing is written.
108def self_test() -> int: 109 row = "| [MIP-{n:04d}](./MIP-{n:04d}-x.md) | title {n} | Draft | 2026-09-06 | S | user value | do next | — |" 110 # 1. The recorded shape (2026-09-06): two drafts each appended a row after the same last row; 111 # ours carries 0023, theirs 0020 — the merge keeps both, in number order. 112 adjacent = ( 113 row.format(n=19) + "\n" 114 "<<<<<<< HEAD\n" + row.format(n=23) + "\n" 115 "=======\n" + row.format(n=20) + "\n" 116 ">>>>>>> 3f29f31 (MIP-0020: an Instagram account)\n" 117 "\nText after the table.\n" 118 ) 119 merged, log = merge_text(adjacent) 120 lines = [ln for ln in merged.split("\n") if ln.startswith("| [MIP-")] 121 assert lines == [row.format(n=19), row.format(n=20), row.format(n=23)], lines 122 assert "<<<<<<<" not in merged and merged.endswith("\nText after the table.\n"), merged 123 assert len(log) == 2, log 124 125 # 2. Both sides carry the same context rows plus one addition each: identical rows collapse. 126 both = ( 127 "<<<<<<< HEAD\n" 128 + row.format(n=21) 129 + "\n" 130 + row.format(n=22) 131 + "\n" 132 + row.format(n=23) 133 + "\n" 134 "=======\n" + row.format(n=20) + "\n" + row.format(n=21) + "\n" + row.format(n=22) + "\n" 135 ">>>>>>> theirs\n" 136 ) 137 merged2, log2 = merge_text(both) 138 assert [ln for ln in merged2.split("\n") if ln] == [ 139 row.format(n=n) for n in (20, 21, 22, 23) 140 ], merged2 141 assert len(log2) == 2, log2 142 143 # 2b. One side empty — the chain already had rows 0021–0023, the draft appends 0024 after a 144 # context the chain no longer has, so git shows ours as nothing and theirs as the row. 145 empty_side = ( 146 row.format(n=23) 147 + "\n<<<<<<< HEAD\n=======\n" 148 + row.format(n=24) 149 + "\n>>>>>>> theirs\n\nafter\n" 150 ) 151 merged_e, log_e = merge_text(empty_side) 152 assert merged_e == row.format(n=23) + "\n" + row.format(n=24) + "\n\nafter\n", merged_e 153 assert log_e == ["MIP-0024: row kept from theirs"], log_e 154 155 # 3. Same MIP on both sides with different text (a status edit) -> refuse; a non-row -> refuse. 156 for bad in ( 157 "<<<<<<< HEAD\n" 158 + row.format(n=20) 159 + "\n=======\n" 160 + row.format(n=20).replace("Draft", "Accepted") 161 + "\n>>>>>>> t\n", 162 "<<<<<<< HEAD\nsome prose\n=======\n" + row.format(n=20) + "\n>>>>>>> t\n", 163 ): 164 try: 165 merge_text(bad) 166 raise AssertionError(f"expected Unresolvable for {bad[:40]!r}") 167 except Unresolvable: 168 pass 169 170 # 4. resolve_files is all-or-nothing. 171 import tempfile 172 173 with tempfile.TemporaryDirectory() as tmp: 174 d = Path(tmp) 175 good, bad_f = d / "README.md", d / "OTHER.md" 176 good.write_text(adjacent) 177 bad_f.write_text("<<<<<<< HEAD\nprose\n=======\nother\n>>>>>>> t\n") 178 try: 179 resolve_files([good, bad_f]) 180 raise AssertionError("expected Unresolvable") 181 except Unresolvable: 182 pass 183 assert good.read_text() == adjacent 184 assert len(resolve_files([good])) == 2 and "<<<<<<<" not in good.read_text() 185 186 print("mip_index_merge self-test: PASSED") 187 return 0
190def main(argv: list[str]) -> int: 191 ap = argparse.ArgumentParser( 192 description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter 193 ) 194 ap.add_argument("files", nargs="*", type=Path) 195 ap.add_argument("--self-test", action="store_true") 196 args = ap.parse_args(argv) 197 if args.self_test: 198 return self_test() 199 if not args.files: 200 ap.print_help() 201 return 2 202 try: 203 for line in resolve_files(args.files): 204 print(line) 205 except Unresolvable as exc: 206 print(f"mip_index_merge: cannot auto-resolve — {exc}", file=sys.stderr) 207 return 1 208 return 0