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:]))
ROW_RE = re.compile('^\\|\\s*\\[MIP-(?P<num>\\d{4})\\]\\(')
CONFLICT_RE = re.compile('<<<<<<<[^\\n]*\\n(?P<ours>(?:.*\\n)*?)(?:\\|\\|\\|\\|\\|\\|\\|[^\\n]*\\n(?:.*\\n)*?)?=======\\n(?P<theirs>(?:.*\\n)*?)>>>>>>>[^\\n]*\\n?')
class Unresolvable(builtins.Exception):
39class Unresolvable(Exception):
40    pass

Common base class for all non-exit exceptions.

def rows_of(lines: list[str]) -> dict[int, str]:
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
def resolve_hunk( ours_lines: list[str], theirs_lines: list[str]) -> tuple[list[str], list[str]]:
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
def merge_text(text: str) -> tuple[str, list[str]]:
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
def resolve_files(paths: list[pathlib.Path]) -> list[str]:
 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.

def self_test() -> int:
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
def main(argv: list[str]) -> int:
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