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

1#!/usr/bin/env python3 

2"""Resolve sync conflicts by taking the upstream side — behind `/rhiza:update` step 6. 

3 

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. 

8 

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. 

14 

15Scope, deliberately narrow: 

16 

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. 

23 

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. 

33 

34Usage: 

35 uv run --python 3.12 --no-project python \ 

36 scripts/resolve_conflicts.py [TARGET] [--dry-run] [--json] 

37 

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""" 

43 

44from __future__ import annotations 

45 

46import argparse 

47import json 

48import sys 

49from pathlib import Path 

50from typing import Any 

51 

52OURS = "<<<<<<<" 

53SEPARATOR = "=======" 

54THEIRS = ">>>>>>>" 

55 

56EXIT_OK = 0 

57EXIT_REJECTS_REMAIN = 1 

58EXIT_MALFORMED = 2 

59 

60# Binary and vendored trees are never conflict-resolved by hand. 

61_SKIP_DIRS = frozenset({".git", "node_modules", "__pycache__", ".venv"}) 

62 

63 

64class MalformedConflict(Exception): 

65 """A conflict block that cannot be resolved without guessing.""" 

66 

67 

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. 

70 

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 

80 

81 

82def _our_side(marker: str, number: int) -> bool: 

83 """Handle a line on our side; return whether the separator was reached. 

84 

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 

92 

93 

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 

102 

103 

104def take_theirs(text: str) -> tuple[str, int]: 

105 """Replace every conflict block in *text* with its upstream side. 

106 

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" 

117 

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", [] 

130 

131 if state != "copy": 

132 raise MalformedConflict("unterminated conflict block at end of file") 

133 return "".join(out), resolved 

134 

135 

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 

145 

146 

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 

162 

163 

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 

186 

187 

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]] = [] 

192 

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}) 

207 

208 outstanding = [path.relative_to(target).as_posix() for path in rejects] 

209 

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 } 

216 

217 

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) 

233 

234 summary = resolve(Path(args.target).resolve(), dry_run=args.dry_run) 

235 

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"]) 

247 

248 

249if __name__ == "__main__": 

250 raise SystemExit(main())