scripts/extract_conflict_context.py
#!/usr/bin/env python3
"""Summarize and extract compact merge-conflict context from a Git repository."""
from __future__ import annotations
import argparse
import difflib
import json
import re
import subprocess
import sys
from pathlib import Path
START_RE = re.compile(r"^<<<<<<<(?: (.*))?$")
BASE_RE = re.compile(r"^\|\|\|\|\|\|\|(?: (.*))?$")
END_RE = re.compile(r"^>>>>>>>(?: (.*))?$")
def run_git(repo_root: Path, *args: str) -> str:
result = subprocess.run(
["git", "-C", str(repo_root), *args],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
message = result.stderr.strip() or result.stdout.strip() or "unknown git error"
raise RuntimeError(f"git {' '.join(args)} failed: {message}")
return result.stdout
def find_repo_root(start: Path) -> Path:
result = subprocess.run(
["git", "-C", str(start), "rev-parse", "--show-toplevel"],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
message = result.stderr.strip() or result.stdout.strip() or "not a git repository"
raise RuntimeError(message)
return Path(result.stdout.strip()).resolve()
def get_unmerged_entries(repo_root: Path) -> dict[str, dict[int, dict[str, str]]]:
entries: dict[str, dict[int, dict[str, str]]] = {}
output = run_git(repo_root, "ls-files", "-u", "-z")
for record in output.split("\0"):
if not record:
continue
metadata, path = record.split("\t", 1)
mode, object_id, stage_text = metadata.split()
file_entry = entries.setdefault(path, {})
file_entry[int(stage_text)] = {"mode": mode, "object_id": object_id}
return entries
def read_text_file(path: Path) -> list[str] | None:
if not path.exists() or path.is_dir():
return None
try:
text = path.read_text(encoding="utf-8", errors="replace")
except OSError:
return None
if "\x00" in text:
return None
return text.splitlines()
def read_stage_text(repo_root: Path, path: str, stage: int) -> list[str] | None:
result = subprocess.run(
["git", "-C", str(repo_root), "show", f":{stage}:{path}"],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
return None
if "\x00" in result.stdout:
return None
return result.stdout.splitlines()
def truncate_lines(lines: list[str], max_lines: int) -> list[str]:
if len(lines) <= max_lines:
return lines
omitted = len(lines) - max_lines
return [*lines[:max_lines], f"... ({omitted} more lines omitted)"]
def build_diff(
left_lines: list[str],
right_lines: list[str],
left_label: str,
right_label: str,
max_lines: int,
) -> list[str]:
diff = list(
difflib.unified_diff(
left_lines,
right_lines,
fromfile=left_label,
tofile=right_label,
lineterm="",
)
)
if not diff:
diff = ["(no textual diff)"]
return truncate_lines(diff, max_lines)
def classify_conflict(stages: list[int], marker_hunks: int) -> str:
if marker_hunks:
return "text"
stage_set = set(stages)
if stage_set == {2, 3}:
return "add/add"
if stage_set == {1, 2}:
return "deleted-by-them"
if stage_set == {1, 3}:
return "deleted-by-us"
if stage_set == {1, 2, 3}:
return "index-only"
return "unmerged"
def normalize_requested_path(repo_root: Path, raw_path: str) -> str:
path = Path(raw_path)
candidate = path.resolve() if path.is_absolute() else (repo_root / path).resolve()
try:
return str(candidate.relative_to(repo_root))
except ValueError as error:
raise RuntimeError(f"path is outside repository: {raw_path}") from error
def parse_conflict_hunks(lines: list[str], context: int) -> tuple[list[dict[str, object]], str | None]:
hunks: list[dict[str, object]] = []
index = 0
while index < len(lines):
start_match = START_RE.match(lines[index])
if not start_match:
index += 1
continue
start_index = index
ours_label = start_match.group(1) or "ours"
index += 1
ours: list[str] = []
base: list[str] = []
theirs: list[str] = []
base_label: str | None = None
theirs_label = "theirs"
while index < len(lines):
base_match = BASE_RE.match(lines[index])
if base_match:
base_label = base_match.group(1) or "base"
index += 1
while index < len(lines) and lines[index] != "=======":
base.append(lines[index])
index += 1
break
if lines[index] == "=======":
break
ours.append(lines[index])
index += 1
if index >= len(lines) or lines[index] != "=======":
return hunks, f"unterminated conflict starting at line {start_index + 1}"
index += 1
end_index = index
while index < len(lines):
end_match = END_RE.match(lines[index])
if end_match:
theirs_label = end_match.group(1) or "theirs"
end_index = index
index += 1
break
theirs.append(lines[index])
index += 1
else:
return hunks, f"unterminated conflict starting at line {start_index + 1}"
hunks.append(
{
"start_line": start_index + 1,
"end_line": end_index + 1,
"before_context": lines[max(0, start_index - context):start_index],
"ours": ours,
"ours_label": ours_label,
"base": base or None,
"base_label": base_label,
"theirs": theirs,
"theirs_label": theirs_label,
"after_context": lines[index:index + context],
}
)
return hunks, None
def build_summary_report(repo_root: Path, path: str, stage_entries: dict[int, dict[str, str]], context: int) -> dict[str, object]:
worktree_lines = read_text_file(repo_root / path)
hunks: list[dict[str, object]] = []
parse_error = None
if worktree_lines is not None:
hunks, parse_error = parse_conflict_hunks(worktree_lines, context)
stages = sorted(stage_entries)
return {
"path": path,
"stages": stages,
"conflict_type": classify_conflict(stages, len(hunks)),
"marker_hunks": len(hunks),
"parse_error": parse_error,
"worktree_present": worktree_lines is not None,
"hunks": hunks,
}
def build_index_preview(repo_root: Path, report: dict[str, object], max_lines: int) -> dict[str, object]:
path = str(report["path"])
ours = read_stage_text(repo_root, path, 2)
theirs = read_stage_text(repo_root, path, 3)
base = read_stage_text(repo_root, path, 1)
preview: dict[str, object] = {
"ours": truncate_lines(ours, max_lines) if ours else None,
"theirs": truncate_lines(theirs, max_lines) if theirs else None,
"base": truncate_lines(base, max_lines) if base else None,
}
if ours and theirs:
preview["ours_vs_theirs_diff"] = build_diff(ours, theirs, "ours", "theirs", max_lines)
return preview
def section_lines(title: str, lines: list[str] | None) -> list[str]:
if lines is None:
return [f"{title}:", " (not present)"]
if not lines:
return [f"{title}:", " (empty)"]
return [f"{title}:", *[f" {line}" for line in lines]]
def render_summary_text(repo_root: Path, reports: list[dict[str, object]]) -> str:
lines = [f"repo: {repo_root}", f"conflicted files: {len(reports)}"]
for report in reports:
stages = ",".join(str(stage) for stage in report["stages"])
lines.append(
f"- {report['path']} | type={report['conflict_type']} | stages={stages} | hunks={report['marker_hunks']}"
)
if report["parse_error"]:
lines.append(f" parse-error: {report['parse_error']}")
lines.append("use --file <path> for compact hunk details or --all for every file")
return "\n".join(lines)
def render_detail_text(
repo_root: Path,
report: dict[str, object],
max_lines: int,
) -> str:
lines = [
f"== {report['path']} ==",
f"type: {report['conflict_type']}",
f"stages: {', '.join(str(stage) for stage in report['stages'])}",
]
parse_error = report["parse_error"]
if parse_error:
lines.append(f"parse-error: {parse_error}")
hunks = report["hunks"]
if hunks:
lines.append(f"hunks: {len(hunks)}")
for index, hunk in enumerate(hunks, start=1):
ours = list(hunk["ours"])
theirs = list(hunk["theirs"])
diff = build_diff(
ours,
theirs,
str(hunk["ours_label"]),
str(hunk["theirs_label"]),
max_lines,
)
lines.extend(
[
"",
f"[hunk {index}] current lines {hunk['start_line']}-{hunk['end_line']}",
*section_lines("before", truncate_lines(list(hunk["before_context"]), max_lines)),
*section_lines(
f"ours ({hunk['ours_label']})",
truncate_lines(ours, max_lines),
),
]
)
if hunk["base"] is not None:
lines.extend(
section_lines(
f"base ({hunk['base_label'] or 'base'})",
truncate_lines(list(hunk["base"]), max_lines),
)
)
lines.extend(
[
*section_lines(
f"theirs ({hunk['theirs_label']})",
truncate_lines(theirs, max_lines),
),
*section_lines("ours vs theirs diff", diff),
*section_lines("after", truncate_lines(list(hunk["after_context"]), max_lines)),
]
)
return "\n".join(lines)
preview = build_index_preview(repo_root, report, max_lines)
lines.append("hunks: 0")
lines.append("index preview:")
lines.extend(section_lines("ours", preview["ours"]))
lines.extend(section_lines("base", preview["base"]))
lines.extend(section_lines("theirs", preview["theirs"]))
if "ours_vs_theirs_diff" in preview:
lines.extend(section_lines("ours vs theirs diff", preview["ours_vs_theirs_diff"]))
return "\n".join(lines)
def render_json(
repo_root: Path,
reports: list[dict[str, object]],
include_details: bool,
max_lines: int,
) -> str:
files: list[dict[str, object]] = []
for report in reports:
file_entry: dict[str, object] = {
"path": report["path"],
"conflict_type": report["conflict_type"],
"stages": report["stages"],
"marker_hunks": report["marker_hunks"],
"parse_error": report["parse_error"],
}
if include_details:
if report["hunks"]:
file_entry["hunks"] = [
{
"start_line": hunk["start_line"],
"end_line": hunk["end_line"],
"before_context": truncate_lines(list(hunk["before_context"]), max_lines),
"ours_label": hunk["ours_label"],
"ours": truncate_lines(list(hunk["ours"]), max_lines),
"base_label": hunk["base_label"],
"base": truncate_lines(list(hunk["base"]), max_lines) if hunk["base"] else None,
"theirs_label": hunk["theirs_label"],
"theirs": truncate_lines(list(hunk["theirs"]), max_lines),
"after_context": truncate_lines(list(hunk["after_context"]), max_lines),
"ours_vs_theirs_diff": build_diff(
list(hunk["ours"]),
list(hunk["theirs"]),
str(hunk["ours_label"]),
str(hunk["theirs_label"]),
max_lines,
),
}
for hunk in report["hunks"]
]
else:
file_entry["index_preview"] = build_index_preview(repo_root, report, max_lines)
files.append(file_entry)
return json.dumps(
{
"repo_root": str(repo_root),
"conflicted_files": files,
},
indent=2,
)
def main() -> int:
parser = argparse.ArgumentParser(
description="Summarize and extract compact merge-conflict context."
)
parser.add_argument("--repo", default=".", help="Path inside the target repository.")
parser.add_argument(
"--file",
action="append",
default=[],
help="Conflicted file to inspect in detail. Repeat to inspect multiple files.",
)
parser.add_argument(
"--all",
action="store_true",
help="Print detailed output for every conflicted file.",
)
parser.add_argument(
"--json",
action="store_true",
help="Emit JSON instead of text.",
)
parser.add_argument(
"--context",
type=int,
default=2,
help="Lines of surrounding context to include around each conflict hunk.",
)
parser.add_argument(
"--max-lines",
type=int,
default=40,
help="Maximum lines to print for each section before truncating.",
)
args = parser.parse_args()
if args.all and args.file:
parser.error("--all cannot be combined with --file")
if args.context < 0:
parser.error("--context must be non-negative")
if args.max_lines <= 0:
parser.error("--max-lines must be positive")
try:
repo_root = find_repo_root(Path(args.repo).resolve())
entries = get_unmerged_entries(repo_root)
except RuntimeError as error:
print(f"error: {error}", file=sys.stderr)
return 2
reports = [
build_summary_report(repo_root, path, entries[path], args.context)
for path in sorted(entries)
]
if not reports:
message = json.dumps({"repo_root": str(repo_root), "conflicted_files": []}, indent=2) if args.json else f"repo: {repo_root}\nconflicted files: 0"
print(message)
return 0
if args.all:
selected_reports = reports
elif args.file:
try:
requested_paths = {normalize_requested_path(repo_root, path) for path in args.file}
except RuntimeError as error:
print(f"error: {error}", file=sys.stderr)
return 2
known_paths = {str(report["path"]) for report in reports}
missing = sorted(requested_paths - known_paths)
if missing:
for path in missing:
print(f"error: conflicted file not found: {path}", file=sys.stderr)
return 2
selected_reports = [report for report in reports if report["path"] in requested_paths]
else:
selected_reports = []
if args.json:
print(render_json(repo_root, selected_reports or reports, bool(selected_reports), args.max_lines))
return 0
if not selected_reports:
print(render_summary_text(repo_root, reports))
return 0
print("\n\n".join(render_detail_text(repo_root, report, args.max_lines) for report in selected_reports))
return 0
if __name__ == "__main__":
raise SystemExit(main())