Coverage for src / rhiza / models / _git / merge.py: 100%

164 statements  

« prev     ^ index     » next       coverage.py v7.14.0, created at 2026-07-16 12:51 +0000

1"""The 3-way merge strategy: apply, fallback, conflict scanning, and lock update.""" 

2 

3import shutil 

4import subprocess # nosec B404 

5import tempfile 

6from dataclasses import dataclass 

7from pathlib import Path 

8from typing import TYPE_CHECKING 

9 

10from loguru import logger 

11 

12from rhiza.models._git import lock_io 

13from rhiza.models._git.diff import DiffMixin 

14from rhiza.models._git.remote import RemoteOpsMixin 

15from rhiza.models._git.snapshot import _prepare_snapshot 

16 

17if TYPE_CHECKING: 

18 from rhiza.models.lock import TemplateLock 

19 from rhiza.models.template import RhizaTemplate 

20 

21 

22@dataclass(frozen=True) 

23class _MergePaths: 

24 """The three on-disk locations for a single file in a 3-way merge.""" 

25 

26 target: Path 

27 upstream: Path 

28 base: Path 

29 

30 

31class MergeMixin(RemoteOpsMixin, DiffMixin): 

32 """Apply template changes to the target via a cruft-style 3-way merge.""" 

33 

34 def _apply_non_merge(self, rel_path: str, paths: "_MergePaths", *, is_new: bool, is_deleted: bool) -> None: 

35 """Handle the non-3-way cases: additions, deletions, and overwrite-from-upstream.""" 

36 if is_deleted: 

37 if paths.target.exists(): 

38 paths.target.unlink() 

39 logger.debug(f"[merge-file] Deleted: {rel_path}") 

40 return 

41 

42 # New, missing-in-target, or no-base: just take upstream when it exists. 

43 if paths.upstream.exists(): 

44 target_existed = paths.target.exists() 

45 paths.target.parent.mkdir(parents=True, exist_ok=True) 

46 shutil.copy2(paths.upstream, paths.target) 

47 if is_new: 

48 logger.debug(f"[merge-file] Added: {rel_path}") 

49 elif not target_existed: 

50 logger.debug(f"[merge-file] Created (missing in target): {rel_path}") 

51 else: 

52 logger.debug(f"[merge-file] Overwrite (no base): {rel_path}") 

53 

54 def _git_merge_file(self, rel_path: str, paths: "_MergePaths") -> tuple[bool, bool]: 

55 """Run ``git merge-file`` for one modified file. 

56 

57 Returns: 

58 A ``(is_clean, is_conflict)`` tuple. ``is_clean`` is False on either 

59 a conflict or a merge error; ``is_conflict`` is True only when 

60 conflict markers were written and need manual resolution. 

61 """ 

62 result = subprocess.run( # nosec B603 # noqa: S603 

63 [ 

64 self.executable, 

65 "merge-file", 

66 "-L", 

67 "HEAD", 

68 "-L", 

69 "base", 

70 "-L", 

71 "rhiza-template", 

72 str(paths.target), 

73 str(paths.base), 

74 str(paths.upstream), 

75 ], 

76 capture_output=True, 

77 env=self.env, 

78 ) 

79 

80 if result.returncode > 0: 

81 logger.warning(f"[merge-file] Conflict in {rel_path} — resolve markers manually") 

82 return False, True 

83 if result.returncode < 0: 

84 logger.warning(f"[merge-file] Error merging {rel_path}: {result.stderr.decode().strip()}") 

85 return False, False 

86 logger.debug(f"[merge-file] Clean merge: {rel_path}") 

87 return True, False 

88 

89 def _merge_file_fallback( 

90 self, 

91 diff: str, 

92 target: Path, 

93 base_snapshot: Path, 

94 upstream_snapshot: Path, 

95 ) -> bool: 

96 """Apply *diff* file-by-file using ``git merge-file``. 

97 

98 Unlike ``git apply -3``, ``git merge-file`` works directly on the file 

99 contents from *base_snapshot* and *upstream_snapshot*, so it does not 

100 require the template's blob objects to exist in the target repository. 

101 

102 Conflict markers (``<<<<<<< HEAD`` / ``=======`` / ``>>>>>>> rhiza-template``) are left in 

103 place for manual resolution when both sides changed the same region. 

104 

105 Args: 

106 diff: Unified diff string (used only for file-list parsing). 

107 target: Path to the target repository. 

108 base_snapshot: Directory containing files at the previously-synced SHA. 

109 upstream_snapshot: Directory containing files at the new upstream SHA. 

110 

111 Returns: 

112 True if every file merged cleanly, False if any conflicts remain. 

113 """ 

114 file_entries = self._parse_diff_filenames(diff) 

115 all_clean = True 

116 conflict_files: list[str] = [] 

117 

118 for rel_path, is_new, is_deleted in file_entries: 

119 is_clean, is_conflict = self._merge_one_file( 

120 rel_path, 

121 target, 

122 base_snapshot, 

123 upstream_snapshot, 

124 is_new=is_new, 

125 is_deleted=is_deleted, 

126 ) 

127 if not is_clean: 

128 all_clean = False 

129 if is_conflict: 

130 conflict_files.append(rel_path) 

131 

132 if conflict_files: 

133 detail = "\n".join(f" {f}" for f in conflict_files) 

134 logger.warning( 

135 f"The following file(s) have conflict markers to resolve:\n{detail}\n" 

136 " Resolve each <<<<<<< / ======= / >>>>>>> block and remove the markers\n" 

137 " before committing." 

138 ) 

139 

140 return all_clean 

141 

142 def _merge_one_file( 

143 self, 

144 rel_path: str, 

145 target: Path, 

146 base_snapshot: Path, 

147 upstream_snapshot: Path, 

148 *, 

149 is_new: bool, 

150 is_deleted: bool, 

151 ) -> tuple[bool, bool]: 

152 """Merge a single file, returning ``(is_clean, is_conflict)``. 

153 

154 Files that were added, deleted, are absent from the target, or lack a 

155 base/upstream counterpart cannot be three-way merged and are applied 

156 wholesale via :meth:`_apply_non_merge`; everything else goes through 

157 ``git merge-file``. 

158 

159 Args: 

160 rel_path: Path of the file relative to the repository root. 

161 target: Path to the target repository. 

162 base_snapshot: Directory containing files at the previously-synced SHA. 

163 upstream_snapshot: Directory containing files at the new upstream SHA. 

164 is_new: Whether the diff marks this file as newly added. 

165 is_deleted: Whether the diff marks this file as deleted. 

166 

167 Returns: 

168 ``(is_clean, is_conflict)`` — ``is_clean`` is ``False`` when the 

169 merge failed, ``is_conflict`` is ``True`` when markers were written. 

170 """ 

171 paths = _MergePaths( 

172 target=target / rel_path, 

173 upstream=upstream_snapshot / rel_path, 

174 base=base_snapshot / rel_path, 

175 ) 

176 

177 if is_new or is_deleted or not paths.target.exists() or not (paths.base.exists() and paths.upstream.exists()): 

178 self._apply_non_merge(rel_path, paths, is_new=is_new, is_deleted=is_deleted) 

179 return True, False 

180 

181 return self._git_merge_file(rel_path, paths) 

182 

183 def _scan_conflict_artifacts(self, target: Path) -> tuple[list[str], list[str]]: 

184 """Scan *target* for merge-conflict artifacts left by git. 

185 

186 Looks for: 

187 

188 - ``*.rej`` files produced by ``git apply --reject``. 

189 - Text files that contain ``<<<<<<<`` conflict markers (from 

190 ``git apply -3`` or ``git merge-file``). 

191 

192 Args: 

193 target: Root of the working tree to scan. 

194 

195 Returns: 

196 A ``(rej_files, marker_files)`` tuple, each a sorted list of 

197 paths relative to *target*. 

198 """ 

199 rej_files: list[str] = [] 

200 marker_files: list[str] = [] 

201 for path in sorted(target.rglob("*")): 

202 if not path.is_file(): 

203 continue 

204 rel = str(path.relative_to(target)) 

205 if path.suffix == ".rej": 

206 rej_files.append(rel) 

207 else: 

208 try: 

209 # Read up to 1 MB to avoid stalling on large binary files. 

210 content = path.read_bytes()[:1_048_576] 

211 if b"<<<<<<<" in content: 

212 marker_files.append(rel) 

213 except OSError: 

214 pass 

215 return rej_files, marker_files 

216 

217 def _apply_diff( 

218 self, 

219 diff: str, 

220 target: Path, 

221 base_snapshot: Path | None = None, 

222 upstream_snapshot: Path | None = None, 

223 ) -> bool: 

224 """Apply a diff to the target project using ``git apply -3`` (3-way merge). 

225 

226 When ``git apply -3`` fails because the template's blob objects are absent 

227 from the target repository *and* both *base_snapshot* and 

228 *upstream_snapshot* are provided, falls back to :func:`_merge_file_fallback` 

229 which uses ``git merge-file`` on the on-disk snapshot files instead. 

230 

231 Otherwise falls back to ``git apply --reject``. 

232 

233 Args: 

234 diff: Unified diff string. 

235 target: Path to the target repository. 

236 base_snapshot: Optional directory containing files at the base SHA. 

237 upstream_snapshot: Optional directory containing files at the upstream SHA. 

238 

239 Returns: 

240 True if the diff applied cleanly, False if there were conflicts. 

241 """ 

242 if not diff.strip(): 

243 return True 

244 

245 try: 

246 self._git_apply(["apply", "-3"], diff, target) 

247 except subprocess.CalledProcessError as e: 

248 stderr = self._decode_stderr(e.stderr) 

249 

250 # git apply -3 cannot do a real 3-way merge when the template blobs are 

251 # not present in the target repository's object store. If we have the 

252 # snapshot directories on disk, use git merge-file instead — it works 

253 # directly on file content and needs no shared git history. 

254 if base_snapshot is not None and upstream_snapshot is not None and "lacks the necessary blob" in stderr: 

255 logger.debug("git apply -3 lacks blob objects; switching to git merge-file fallback") 

256 return self._merge_file_fallback(diff, target, base_snapshot, upstream_snapshot) 

257 

258 if stderr: 

259 logger.warning(f"3-way merge had conflicts:\n{stderr.strip()}") 

260 return self._apply_reject(diff, target) 

261 else: 

262 return True 

263 

264 @staticmethod 

265 def _decode_stderr(stderr: bytes | str | None) -> str: 

266 """Decode subprocess stderr to a string, treating ``None`` as empty. 

267 

268 Args: 

269 stderr: Captured stderr as bytes, str, or None. 

270 

271 Returns: 

272 The decoded stderr, or an empty string when there was none. 

273 """ 

274 return stderr.decode() if isinstance(stderr, bytes) else (stderr or "") 

275 

276 def _git_apply(self, mode_args: list[str], diff: str, target: Path) -> None: 

277 """Run ``git apply`` with *mode_args*, feeding *diff* on stdin. 

278 

279 Args: 

280 mode_args: Git apply arguments (e.g. ``["apply", "-3"]``). 

281 diff: Unified diff string. 

282 target: Path to the target repository. 

283 

284 Raises: 

285 subprocess.CalledProcessError: If the git apply invocation exits non-zero. 

286 """ 

287 subprocess.run( # nosec B603 # noqa: S603 

288 [self.executable, *mode_args], 

289 input=diff.encode() if isinstance(diff, str) else diff, 

290 cwd=target, 

291 check=True, 

292 capture_output=True, 

293 env=self.env, 

294 ) 

295 

296 def _apply_reject(self, diff: str, target: Path) -> bool: 

297 """Apply *diff* with ``git apply --reject`` and report conflict artifacts. 

298 

299 Args: 

300 diff: Unified diff string. 

301 target: Path to the target repository. 

302 

303 Returns: 

304 Always False — a ``--reject`` apply means the 3-way merge did not 

305 apply cleanly. 

306 """ 

307 try: 

308 self._git_apply(["apply", "--reject"], diff, target) 

309 except subprocess.CalledProcessError as e2: 

310 stderr2 = self._decode_stderr(e2.stderr) 

311 if stderr2: 

312 logger.warning(stderr2.strip()) 

313 

314 # Scan and report any conflict artifacts left behind so users know 

315 # exactly which files need attention. 

316 self._report_conflict_artifacts(target) 

317 return False 

318 

319 def _report_conflict_artifacts(self, target: Path) -> None: 

320 """Scan *target* and emit guidance for any ``.rej`` files or conflict markers left behind.""" 

321 rej_files, marker_files = self._scan_conflict_artifacts(target) 

322 if rej_files: 

323 rej_detail = "\n".join(f" {f.removesuffix('.rej')} (unresolved hunks saved to {f})" for f in rej_files) 

324 logger.warning( 

325 f"The following file(s) have unresolved hunks:\n{rej_detail}\n" 

326 " Open each .rej file, manually apply the diff hunks to the source file,\n" 

327 " then delete the .rej file before committing." 

328 ) 

329 if marker_files: 

330 marker_detail = "\n".join(f" {f}" for f in marker_files) 

331 logger.warning( 

332 f"The following file(s) contain conflict markers:\n{marker_detail}\n" 

333 " Resolve each <<<<<<< / ======= / >>>>>>> block and remove the markers\n" 

334 " before committing." 

335 ) 

336 if not rej_files and not marker_files: 

337 logger.warning("Some changes could not be applied cleanly — check the working tree for partial edits.") 

338 

339 def _copy_files_to_target(self, snapshot_dir: Path, target: Path, template_files: list[Path]) -> None: 

340 """Copy all template files from a snapshot into the target project. 

341 

342 Args: 

343 snapshot_dir: Directory containing the snapshot files. 

344 target: Path to the target repository. 

345 template_files: List of relative file paths to copy. 

346 """ 

347 for rel_path in sorted(template_files): 

348 src = snapshot_dir / rel_path 

349 dst = target / rel_path 

350 dst.parent.mkdir(parents=True, exist_ok=True) 

351 shutil.copy2(src, dst) 

352 logger.success(f"[COPY] {rel_path}") 

353 

354 def sync_merge( 

355 self, 

356 target: Path, 

357 upstream_snapshot: Path, 

358 upstream_sha: str, 

359 base_sha: str | None, 

360 template_files: list[Path], 

361 template: "RhizaTemplate", 

362 excludes: set[str], 

363 lock: "TemplateLock", 

364 lock_file: "Path | None" = None, 

365 path_map: "dict[str, str] | None" = None, 

366 ) -> bool: 

367 """Execute the merge strategy (cruft-style 3-way merge). 

368 

369 When a base SHA exists, computes the diff between base and upstream 

370 snapshots and applies it via ``git apply -3``. On first sync (no base), 

371 falls back to a simple copy. 

372 

373 Args: 

374 target: Path to the target repository. 

375 upstream_snapshot: Path to the upstream snapshot directory. 

376 upstream_sha: HEAD SHA of the upstream template. 

377 base_sha: Previously synced commit SHA, or None for first sync. 

378 template_files: List of relative file paths. 

379 template: The :class:`~rhiza.models.RhizaTemplate` driving this sync. 

380 excludes: Set of relative paths to exclude. 

381 lock: Pre-built :class:`~rhiza.models.TemplateLock` for this sync. 

382 lock_file: Optional explicit path for the lock file. When ``None`` 

383 the default ``<target>/.rhiza/template.lock`` is used. 

384 path_map: Optional source→destination path mapping for remapped 

385 bundle file entries. 

386 

387 Returns: 

388 True if all changes applied cleanly, False if any conflicts remain. 

389 """ 

390 # Snapshot the currently-tracked files before the merge runs. The merge 

391 # may write a new lock (e.g. on the "template unchanged" early-return path 

392 # in _merge_with_base), so we must read the old state first to ensure 

393 # orphan cleanup compares against the previous sync, not the new one. 

394 old_tracked_files = lock_io._read_previously_tracked_files(target, lock_file=lock_file) 

395 

396 base_snapshot = Path(tempfile.mkdtemp()) 

397 clean = True 

398 try: 

399 if base_sha: 

400 clean = self._merge_with_base( 

401 target, 

402 upstream_snapshot, 

403 upstream_sha, 

404 base_sha, 

405 base_snapshot, 

406 template, 

407 excludes, 

408 lock, 

409 lock_file=lock_file, 

410 path_map=path_map, 

411 ) 

412 else: 

413 logger.info("First sync — copying all template files") 

414 self._copy_files_to_target(upstream_snapshot, target, template_files) 

415 

416 # Restore any template-managed files that are absent from the target. 

417 # This can happen when files tracked by the template do not exist in the 

418 # downstream repository — for example when the template snapshot was 

419 # unchanged since the last sync so no diff was applied, but the files 

420 # were never present or were manually deleted. 

421 missing_from_target = [p for p in template_files if not (target / p).exists()] 

422 if missing_from_target: 

423 logger.info(f"Restoring {len(missing_from_target)} template file(s) missing from target") 

424 self._copy_files_to_target(upstream_snapshot, target, missing_from_target) 

425 

426 lock_io._warn_about_workflow_files(template_files) 

427 lock_io._clean_orphaned_files( 

428 target, 

429 template_files, 

430 excludes=excludes, 

431 base_snapshot=base_snapshot, 

432 previously_tracked_files=old_tracked_files if old_tracked_files else None, 

433 lock_file=lock_file, 

434 ) 

435 lock_io._write_lock(target, lock, lock_file=lock_file) 

436 logger.success(f"Sync complete — {len(template_files)} file(s) processed") 

437 finally: 

438 if base_snapshot.exists(): 

439 shutil.rmtree(base_snapshot) 

440 

441 return clean 

442 

443 def _merge_with_base( 

444 self, 

445 target: Path, 

446 upstream_snapshot: Path, 

447 upstream_sha: str, # noqa: ARG002 # part of the merge-call signature; lock carries the sha 

448 base_sha: str, 

449 base_snapshot: Path, 

450 template: "RhizaTemplate", 

451 excludes: set[str], 

452 lock: "TemplateLock", 

453 lock_file: "Path | None" = None, 

454 path_map: "dict[str, str] | None" = None, 

455 ) -> bool: 

456 """Compute and apply the diff between base and upstream snapshots. 

457 

458 Args: 

459 target: Path to the target repository. 

460 upstream_snapshot: Path to the upstream snapshot directory. 

461 upstream_sha: HEAD SHA of the upstream template. 

462 base_sha: Previously synced commit SHA. 

463 base_snapshot: Directory to populate with the base snapshot. 

464 template: The :class:`~rhiza.models.RhizaTemplate` driving this sync. 

465 excludes: Set of relative paths to exclude. 

466 lock: Pre-built :class:`~rhiza.models.TemplateLock` for this sync. 

467 lock_file: Optional explicit path for the lock file. When ``None`` 

468 the default ``<target>/.rhiza/template.lock`` is used. 

469 path_map: Optional source→destination path mapping for remapped 

470 bundle file entries. 

471 

472 Returns: 

473 True if all changes applied cleanly, False if any conflicts remain. 

474 """ 

475 logger.info(f"Cloning base snapshot at {base_sha[:12]}") 

476 base_clone = Path(tempfile.mkdtemp()) 

477 try: 

478 self.clone_at_sha(template.git_url, base_sha, base_clone, template.include) 

479 _prepare_snapshot(base_clone, template.include, excludes, base_snapshot, path_map=path_map) 

480 except Exception: # noqa: BLE001 # clone/snapshot can fail many ways; on any failure treat all files as new 

481 logger.warning("Could not checkout base commit — treating all files as new") 

482 finally: 

483 if base_clone.exists(): 

484 shutil.rmtree(base_clone) 

485 

486 diff = self.get_diff(base_snapshot, upstream_snapshot) 

487 

488 if not diff.strip(): 

489 logger.success("Template unchanged since last sync — nothing to apply") 

490 lock_io._write_lock(target, lock, lock_file=lock_file) 

491 return True 

492 

493 logger.info("Applying template changes via 3-way merge (cruft)...") 

494 clean = self._apply_diff(diff, target, base_snapshot=base_snapshot, upstream_snapshot=upstream_snapshot) 

495 

496 if clean: 

497 logger.success("All changes applied cleanly") 

498 else: 

499 logger.warning("Some changes had conflicts. Check for *.rej files and resolve manually.") 

500 

501 return clean