Coverage for plugin/scripts/resolve_conflicts.py: 100%
116 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-15 14:46 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-15 14:46 +0000
1#!/usr/bin/env python3
2"""Resolve sync conflicts by taking the upstream side — behind `/rhiza:update` step 6.
4`scripts/sync.py` exits **1** when a template change collides with a local edit, leaving
5`<<<<<<< ======= >>>>>>>` markers. `/update`'s policy is to
6take the **upstream** side everywhere: a rhiza-managed file is the template's to own, and
7local divergence in one is drift to be undone, not work to preserve.
9That policy was prose, which is the wrong home for text surgery. Conflict resolution is
10the one step that **rewrites files the user did not author** — a marker left behind ships
11`<<<<<<<` into a repo, and a mis-parsed block silently discards upstream's change. Prose
12also cannot be tested: nothing executes it, so the documented procedure had no coverage
13at all beyond `scan_conflict_artifacts` unit tests.
15Scope, deliberately narrow:
17* **Conflict markers are resolved.** Each ``<<<<<<< … ======= … >>>>>>>`` block is
18 replaced by its *theirs* section. Nested or malformed blocks are refused rather than
19 guessed at.
20* **`*.rej` files are reported, never applied.** Re-deriving where an unplaceable hunk
21 belongs is exactly the judgement that corrupts files, so this exits non-zero and leaves
22 it to a human.
24 The sync no longer produces rejects at all: ``git apply --reject`` was the only thing
25 that ever created one, and `_rhiza_merge.py` replaced the whole ``git apply`` path. An
26 earlier version of this script *deleted* a reject sitting beside a file it had just
27 resolved, on the sound reasoning that `sync.py` then emitted both artifacts for one
28 collision — markers plus the identical hunk — so applying the reject too would apply
29 the change twice. That cause is gone, and with it the justification: a reject found
30 today came from an older sync or a hand-run ``git apply``, and deleting it because we
31 happened to resolve markers in the same file would be a guess about contents nobody
32 checked.
34Usage:
35 uv run --python 3.12 --no-project python \
36 scripts/resolve_conflicts.py [TARGET] [--dry-run] [--json]
38Exit codes:
39 0 no conflicts, or every marker resolved and no rejects remain
40 1 `*.rej` files remain — they need a human (the sync cannot create these)
41 2 a malformed conflict block was found and nothing was written
42"""
44from __future__ import annotations
46import argparse
47import json
48import sys
49from pathlib import Path
50from typing import Any
52OURS = "<<<<<<<"
53SEPARATOR = "======="
54THEIRS = ">>>>>>>"
56EXIT_OK = 0
57EXIT_REJECTS_REMAIN = 1
58EXIT_MALFORMED = 2
60# Binary and vendored trees are never conflict-resolved by hand.
61_SKIP_DIRS = frozenset({".git", "node_modules", "__pycache__", ".venv"})
64class MalformedConflict(Exception):
65 """A conflict block that cannot be resolved without guessing."""
68def _outside_block(marker: str, line: str, number: int, out: list[str]) -> bool:
69 """Handle a line outside any conflict block; return whether a block just opened.
71 A separator or closing marker out here means the file's markers are out of order,
72 which is exactly the case that must not be guessed at.
73 """
74 if marker == OURS:
75 return True
76 if marker in (SEPARATOR, THEIRS):
77 raise MalformedConflict(f"line {number}: {marker!r} outside a conflict block")
78 out.append(line)
79 return False
82def _our_side(marker: str, number: int) -> bool:
83 """Handle a line on our side; return whether the separator was reached.
85 The line itself is dropped either way — taking upstream means our side is discarded.
86 """
87 if marker == SEPARATOR:
88 return True
89 if marker == OURS:
90 raise MalformedConflict(f"line {number}: nested conflict block")
91 return False
94def _their_side(marker: str, line: str, number: int, theirs: list[str]) -> bool:
95 """Collect a line on the upstream side; return whether the block just closed."""
96 if marker == THEIRS:
97 return True
98 if marker in (OURS, SEPARATOR):
99 raise MalformedConflict(f"line {number}: {marker!r} inside the upstream side")
100 theirs.append(line)
101 return False
104def take_theirs(text: str) -> tuple[str, int]:
105 """Replace every conflict block in *text* with its upstream side.
107 A three-state machine — copy → ours → theirs — with one handler per state, each
108 returning whether the state it was given has ended. Returns
109 ``(resolved_text, blocks_resolved)``. Raises :class:`MalformedConflict` when a block is
110 unterminated or the markers are out of order: the caller must see that rather than
111 receive a plausible-looking file.
112 """
113 out: list[str] = []
114 theirs: list[str] = []
115 resolved = 0
116 state = "copy"
118 for number, line in enumerate(text.splitlines(keepends=True), start=1):
119 marker = line.split(" ", 1)[0].rstrip("\r\n")
120 if state == "copy":
121 if _outside_block(marker, line, number, out):
122 state, theirs = "ours", []
123 elif state == "ours":
124 if _our_side(marker, number):
125 state = "theirs"
126 elif _their_side(marker, line, number, theirs):
127 out.extend(theirs)
128 resolved += 1
129 state, theirs = "copy", []
131 if state != "copy":
132 raise MalformedConflict("unterminated conflict block at end of file")
133 return "".join(out), resolved
136def _walk(target: Path) -> list[Path]:
137 """Return the candidate files under *target*, skipping vendored trees."""
138 found = []
139 for path in sorted(target.rglob("*")):
140 if any(part in _SKIP_DIRS for part in path.parts):
141 continue
142 if path.is_file():
143 found.append(path)
144 return found
147def find_conflicts(target: Path) -> tuple[list[Path], list[Path]]:
148 """Return ``(files_with_markers, reject_files)`` under *target*."""
149 marked: list[Path] = []
150 rejects: list[Path] = []
151 for path in _walk(target):
152 if path.suffix == ".rej":
153 rejects.append(path)
154 continue
155 try:
156 text = path.read_text(encoding="utf-8")
157 except (OSError, UnicodeDecodeError):
158 continue # binary or unreadable: never a text conflict
159 if any(line.startswith(OURS) for line in text.splitlines()):
160 marked.append(path)
161 return marked, rejects
164def _resolve_notes(
165 marked: list[Path],
166 rejects: list[Path],
167 outstanding: list[str],
168 resolved: list[dict[str, Any]],
169 *,
170 dry_run: bool,
171) -> list[str]:
172 """Build the notes for a completed resolve pass."""
173 notes: list[str] = []
174 if dry_run and resolved:
175 notes.append("dry run — nothing was written")
176 if outstanding:
177 notes.append(
178 f"{len(outstanding)} .rej file(s) remain. The sync no longer creates these — "
179 "`git apply --reject` was the only thing that ever did, and it is gone — so one "
180 "here came from an older sync or a hand-run `git apply`, and its contents cannot "
181 "be assumed redundant. Apply them by hand, then delete the .rej."
182 )
183 if not marked and not rejects:
184 notes.append("no conflicts found")
185 return notes
188def resolve(target: Path, *, dry_run: bool = False) -> dict[str, Any]:
189 """Take the upstream side throughout *target*; return a summary dict."""
190 marked, rejects = find_conflicts(target)
191 resolved: list[dict[str, Any]] = []
193 for path in marked:
194 original = path.read_text(encoding="utf-8")
195 try:
196 new_text, blocks = take_theirs(original)
197 except MalformedConflict as exc:
198 return {
199 "resolved": [],
200 "rejects": [p.relative_to(target).as_posix() for p in rejects],
201 "notes": [f"{path.relative_to(target).as_posix()}: {exc} — nothing was written"],
202 "exit_code": EXIT_MALFORMED,
203 }
204 if not dry_run:
205 path.write_text(new_text, encoding="utf-8")
206 resolved.append({"path": path.relative_to(target).as_posix(), "blocks": blocks})
208 outstanding = [path.relative_to(target).as_posix() for path in rejects]
210 return {
211 "resolved": resolved,
212 "rejects": outstanding,
213 "notes": _resolve_notes(marked, rejects, outstanding, resolved, dry_run=dry_run),
214 "exit_code": EXIT_REJECTS_REMAIN if outstanding else EXIT_OK,
215 }
218def main(argv: list[str] | None = None) -> int:
219 """Entry point: resolve conflict markers and return an exit code."""
220 parser = argparse.ArgumentParser(
221 description="Resolve sync conflicts by taking the upstream side.",
222 )
223 parser.add_argument(
224 "target", nargs="?", default=".", help="Repository root (default: current directory)."
225 )
226 parser.add_argument(
227 "--dry-run", action="store_true", help="Report what would change, write nothing."
228 )
229 parser.add_argument(
230 "--json", dest="json_output", action="store_true", help="Emit the summary as JSON."
231 )
232 args = parser.parse_args(argv)
234 summary = resolve(Path(args.target).resolve(), dry_run=args.dry_run)
236 if args.json_output:
237 print(json.dumps(summary, indent=2))
238 else:
239 for entry in summary["resolved"]:
240 print(f"resolved {entry['path']}: {entry['blocks']} block(s) -> upstream")
241 for path in summary["rejects"]:
242 print(f"reject {path}", file=sys.stderr)
243 for note in summary["notes"]:
244 stream = sys.stdout if summary["exit_code"] == EXIT_OK else sys.stderr
245 print(f"note {note}", file=stream)
246 return int(summary["exit_code"])
249if __name__ == "__main__":
250 raise SystemExit(main())