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
« 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."""
3import shutil
4import subprocess # nosec B404
5import tempfile
6from dataclasses import dataclass
7from pathlib import Path
8from typing import TYPE_CHECKING
10from loguru import logger
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
17if TYPE_CHECKING:
18 from rhiza.models.lock import TemplateLock
19 from rhiza.models.template import RhizaTemplate
22@dataclass(frozen=True)
23class _MergePaths:
24 """The three on-disk locations for a single file in a 3-way merge."""
26 target: Path
27 upstream: Path
28 base: Path
31class MergeMixin(RemoteOpsMixin, DiffMixin):
32 """Apply template changes to the target via a cruft-style 3-way merge."""
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
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}")
54 def _git_merge_file(self, rel_path: str, paths: "_MergePaths") -> tuple[bool, bool]:
55 """Run ``git merge-file`` for one modified file.
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 )
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
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``.
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.
102 Conflict markers (``<<<<<<< HEAD`` / ``=======`` / ``>>>>>>> rhiza-template``) are left in
103 place for manual resolution when both sides changed the same region.
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.
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] = []
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)
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 )
140 return all_clean
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)``.
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``.
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.
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 )
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
181 return self._git_merge_file(rel_path, paths)
183 def _scan_conflict_artifacts(self, target: Path) -> tuple[list[str], list[str]]:
184 """Scan *target* for merge-conflict artifacts left by git.
186 Looks for:
188 - ``*.rej`` files produced by ``git apply --reject``.
189 - Text files that contain ``<<<<<<<`` conflict markers (from
190 ``git apply -3`` or ``git merge-file``).
192 Args:
193 target: Root of the working tree to scan.
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
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).
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.
231 Otherwise falls back to ``git apply --reject``.
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.
239 Returns:
240 True if the diff applied cleanly, False if there were conflicts.
241 """
242 if not diff.strip():
243 return True
245 try:
246 self._git_apply(["apply", "-3"], diff, target)
247 except subprocess.CalledProcessError as e:
248 stderr = self._decode_stderr(e.stderr)
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)
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
264 @staticmethod
265 def _decode_stderr(stderr: bytes | str | None) -> str:
266 """Decode subprocess stderr to a string, treating ``None`` as empty.
268 Args:
269 stderr: Captured stderr as bytes, str, or None.
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 "")
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.
279 Args:
280 mode_args: Git apply arguments (e.g. ``["apply", "-3"]``).
281 diff: Unified diff string.
282 target: Path to the target repository.
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 )
296 def _apply_reject(self, diff: str, target: Path) -> bool:
297 """Apply *diff* with ``git apply --reject`` and report conflict artifacts.
299 Args:
300 diff: Unified diff string.
301 target: Path to the target repository.
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())
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
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.")
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.
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}")
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).
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.
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.
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)
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)
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)
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)
441 return clean
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.
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.
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)
486 diff = self.get_diff(base_snapshot, upstream_snapshot)
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
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)
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.")
501 return clean