From cd6a2891721ca9faca6e55781a32e46fb9c02ab5 Mon Sep 17 00:00:00 2001 From: bong-water-water-bong Date: Thu, 12 Nov 2026 14:31:32 -0400 Subject: [PATCH] conflict_triage: --markers classifies a tree that is ALREADY merged, which is the state a long merge is in engine#327's draft reports "23 files / 92 hunks still carrying conflict markers" -- the state of a merge whose resolutions are half done, for hours at a time. `conflict_triage.py` could only classify what a merge *would* conflict on, which is a different question (it answers 202 paths on the same merge), so the grouping a resolver actually works from was being done by hand with `git grep -c | awk`. conflict_triage.py --markers REF [--json] reports hunk counts per path and rolls them up by the directory two levels deep, which is where these trees divide: on engine#327's head that is 23 files / 83 hunks, 50 of them under `ggml/src/ggml-hrx` -- and that subtree is add/add on both sides, so those 50 are one decision per file rather than 50 readings. Two parsing bugs, both found by running it on the real tree and both now arms: - `git grep -c ` prefixes every line with the ref, so `::` has to be split on the LAST colon. Splitting on the first took the ref as the path and `":"` as the number, `int()` threw, every row was dropped, and the mode reported "no conflict markers" on a tree with 83 of them -- the same unreadable-is-not-zero defect this tool exists to report in other people's merges; - `git grep` needs the `--` separator between the ref and any pathspec, otherwise the revision form is not parsed as one and the call matches nothing. Self-test: 17 arms, including a two-hunk fixture whose count must survive and the two area cases; reverting the parse fails the first ("got []"). Refs engine#2138, engine#2278. --- tools/ci/conflict_triage.py | 87 +++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/tools/ci/conflict_triage.py b/tools/ci/conflict_triage.py index 9c2cbcd60..fd9b8d316 100755 --- a/tools/ci/conflict_triage.py +++ b/tools/ci/conflict_triage.py @@ -34,15 +34,22 @@ on a tree nobody wants to disturb. usage: conflict_triage.py [--base BASE] [--json] + conflict_triage.py --markers REF [--json] classify the markers already committed in a tree conflict_triage.py --self-test + +The two halves answer different questions and both are needed on a long merge. `` says what a merge *would* +conflict on; `--markers REF` says what a tree that has already been merged still *carries*, which is the state a +work-in-progress merge is in for hours and the state engine#327's draft reports (23 files, 92 hunks). exit: 0 no conflicts, 1 conflicts found, 2 usage or git error """ import argparse import json +import os import pathlib import re import subprocess import sys +import tempfile KINDS = ("content", "add/add", "mod/del", "rename") @@ -194,6 +201,64 @@ def summarise(triaged): return total, per +MARK = "<<<<<<<" + + +def marker_rows(repo, ref): + """[(path, hunks)] for every path in `ref` whose blob carries conflict markers.""" + out = git(repo, "grep", "-c", f"^{MARK}", ref, "--") + # `git grep -c` exits 1 with no output when nothing matches, which is the clean case rather than an error. + if out.returncode == 1 and not out.stdout.strip(): + return [] + if out.returncode != 0: + die(f"conflict_triage: git grep failed on {ref!r}: {out.stderr.strip()[:300]}") + rows = [] + prefix = f"{ref}:" + for line in out.stdout.splitlines(): + rest = line[len(prefix):] if line.startswith(prefix) else line + # the count is after the LAST colon: a path may not contain one, but splitting at the first colon took + # "" as the path and ":" as the number, which silently produced an empty list + path, _, count = rest.rpartition(":") + try: + rows.append((path, int(count))) + except ValueError: + continue + return sorted(rows) + + +def area_of(path): + """The grouping a resolver works in: the directory two levels deep, which is where these trees divide.""" + parts = path.split("/") + if parts[0] == "ggml" and len(parts) > 2: + return "/".join(parts[:3]) + return "/".join(parts[:2]) if len(parts) > 1 else parts[0] + + +def markers(repo, ref, as_json=False): + rows = marker_rows(repo, ref) + if not rows: + print(f"conflict_triage: no conflict markers in {ref}") + return 0 + per_area = {} + for path, hunks in rows: + a = area_of(path) + f, h = per_area.get(a, (0, 0)) + per_area[a] = (f + 1, h + hunks) + total = sum(h for _, h in rows) + if as_json: + print(json.dumps({"ref": ref, "total_files": len(rows), "total_hunks": total, + "areas": {k: {"files": v[0], "hunks": v[1]} for k, v in sorted(per_area.items())}, + "rows": [{"path": p, "hunks": h} for p, h in rows]}, indent=2)) + return 1 + for path, hunks in rows: + print(f"{hunks:4d} {path}") + print() + for a, (f, h) in sorted(per_area.items(), key=lambda kv: -kv[1][1]): + print(f"conflict_triage: {a:32} {f:3d} file(s) {h:4d} hunks") + print(f"conflict_triage: {len(rows)} file(s), {total} hunk(s) still carry markers in {ref}") + return 1 + + def run(repo, theirs, base=None, as_json=False): result = merge_tree(repo, base, theirs) if not result["paths"]: @@ -266,6 +331,24 @@ def check_that(ok, what): # The exit codes are the contract: 2 for "I could not answer", 1 for "conflicts", 0 for "none". A bad ref used to # take the 1 path (SystemExit's default) or, before the refs were verified, the 0 path -- both worth an arm. check_that(_die_rc() == 2, "die() exits 2, not SystemExit's default 1 (which is 'conflicts found')") + # `--markers` parsing, both bugs found by running it on engine#327: `git grep -c` prefixes every line with the + # ref, and the count is after the LAST colon -- splitting at the first colon took "" as the path and + # ":" as the number, so every row was dropped and the mode reported a clean tree. + with tempfile.TemporaryDirectory() as repo: + subprocess.run(["git", "init", "-q"], cwd=repo, check=True) + subprocess.run(["git", "config", "user.email", "t@example.invalid"], cwd=repo, check=True) + subprocess.run(["git", "config", "user.name", "t"], cwd=repo, check=True) + with open(os.path.join(repo, "a.cpp"), "w") as f: + f.write("int a;\n<<<<<<< ours\nx\n=======\ny\n>>>>>>> theirs\n<<<<<<< ours\nz\n=======\nw\n>>>>>>> theirs\n") + with open(os.path.join(repo, "clean.cpp"), "w") as f: + f.write("int b;\n") + subprocess.run(["git", "add", "-A"], cwd=repo, check=True) + subprocess.run(["git", "commit", "-q", "-m", "m"], cwd=repo, check=True) + got = marker_rows(repo, "HEAD") + check_that(got == [("a.cpp", 2)], f"markers: a two-hunk file is reported with its count (got {got})") + check_that(area_of("ggml/src/ggml-hrx/runtime/x.cpp") == "ggml/src/ggml-hrx", + "markers: the area is the directory a resolver works in") + check_that(area_of("src/models/qwen4exp.cpp") == "src/models", "markers: and for a src subdirectory") print(f"conflict_triage self-test: {'ok' if not failures else f'{failures} failure(s)'}") return 1 if failures else 0 @@ -276,12 +359,16 @@ def main(): ap.add_argument("theirs", nargs="?") ap.add_argument("--base", default=None) ap.add_argument("--json", action="store_true") + ap.add_argument("--markers", metavar="REF", default=None, + help="classify the conflict markers already committed in REF") ap.add_argument("--self-test", action="store_true") args = ap.parse_args() if "--self-test-die" in sys.argv: die("conflict_triage: self-test of die()") if args.self_test: return self_test() + if args.markers: + return markers(args.repo or ".", args.markers, args.json) if not args.repo or not args.theirs: ap.print_help() return 2