Coverage for scripts/_rhiza_merge.py: 100%
79 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-31 06:18 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-31 06:18 +0000
1#!/usr/bin/env python3
2"""Three-way merge of a template snapshot into the working tree — `sync.py`'s merge.
4`sync.py` holds three trees on disk by the time it merges: the **base** snapshot (the
5template as it was at the previously-synced SHA), the **upstream** snapshot (the template
6now), and the **target** working tree. A three-way merge is exactly what those three
7inputs are for.
9It used to reach that merge the long way round: render a unified diff of base→upstream
10with ``git diff --no-index``, apply it with ``git apply -3``, and — when the target repo
11lacked the template's blob objects, which it always does — parse the diff text back into
12a file list and merge each file with ``git merge-file`` after all. `git apply --reject`
13was the last resort, scattering ``.rej`` files.
15The diff was pure overhead. It was generated from two directories we already had, only to
16be parsed again to recover the file list those same directories state directly. Removing
17it deletes ``get_diff``, ``apply_diff``, ``_apply_reject``, ``merge_file_fallback``,
18``parse_diff_filenames``, ``_DiffFileState`` and ``_path_after`` — the diff plumbing, not
19the merge — and takes ``.rej`` files with it, since nothing runs ``git apply`` any more.
21`git merge-file` is the merge, and it is a subprocess, so nothing about this module's
22licence changes. (The one library that would have replaced it, `merge3`, is
23GPL-2.0-or-later against this plugin's MIT — a real incompatibility, and unnecessary
24given git is already a hard requirement.)
26What the merge decides, per file, from the three trees:
28 in upstream, not in base new upstream file -> copy it in
29 in base, not in upstream upstream deleted it -> delete it
30 identical in base + upstream template never changed -> **leave the local file alone**
31 absent from the target nothing to merge into -> copy upstream in
32 otherwise both sides moved -> `git merge-file`
34That third rule is the important one: a file the template did not change is never
35rewritten, whatever the user did to it.
37`git merge-file` exit codes are no longer flattened to a boolean. 0 is clean, a small
38positive number is that many conflicted regions (markers written), and 255 is a refusal —
39in practice a binary file, which cannot be three-way merged at all. A refusal leaves the
40target **untouched** and is reported: silently overwriting a locally-modified binary
41would be data loss with nothing to show the user.
42"""
44from __future__ import annotations
46import shutil
47from dataclasses import dataclass, field
48from pathlib import Path
50import _rhiza_git as git
52# `git merge-file` returns 255 for "I will not merge this" (binary, unreadable). Any
53# other non-zero value is a conflict count.
54_MERGE_REFUSED = 255
56# How much of a file to sniff for NUL before calling it binary.
57_SNIFF_BYTES = 8192
60@dataclass
61class MergeOutcome:
62 """What the merge did, path by path."""
64 merged: list[str] = field(default_factory=list)
65 conflicted: list[str] = field(default_factory=list)
66 unmergeable: list[str] = field(default_factory=list)
67 deleted: list[str] = field(default_factory=list)
69 @property
70 def clean(self) -> bool:
71 """True when every file landed without a conflict or a refusal."""
72 return not self.conflicted and not self.unmergeable
75@dataclass(frozen=True)
76class Change:
77 """One template file that differs between the base and upstream snapshots."""
79 path: str
80 is_new: bool
81 is_deleted: bool
84def _relative_files(root: Path) -> set[str]:
85 """Return every file under *root*, as POSIX-relative paths."""
86 return {p.relative_to(root).as_posix() for p in root.rglob("*") if p.is_file()}
89def _same_content(left: Path, right: Path) -> bool:
90 """Are these two files byte-identical?"""
91 try:
92 return left.read_bytes() == right.read_bytes()
93 except OSError: # pragma: no cover - unreadable snapshot files
94 return False
97def is_binary(path: Path) -> bool:
98 """Does *path* look binary — i.e. contain a NUL in its first few KiB?
100 The same heuristic git uses. `git merge-file` refuses binary input, so this lets the
101 refusal be predicted and reported as such rather than surfacing as a bare error.
102 """
103 try:
104 return b"\x00" in path.read_bytes()[:_SNIFF_BYTES]
105 except OSError: # pragma: no cover - unreadable target files
106 return False
109def changed_files(base_snapshot: Path, upstream_snapshot: Path) -> list[Change]:
110 """Return the template files that differ between the two snapshots.
112 Read straight off the two trees. This is the list the old code recovered by parsing
113 the text of a diff it had generated from these very directories.
115 Files identical in both are **omitted**, which is what guarantees that an unchanged
116 template file is never touched in the target.
117 """
118 base_files = _relative_files(base_snapshot)
119 upstream_files = _relative_files(upstream_snapshot)
121 changes: list[Change] = []
122 for path in sorted(base_files | upstream_files):
123 in_base, in_upstream = path in base_files, path in upstream_files
124 if in_base and in_upstream:
125 if _same_content(base_snapshot / path, upstream_snapshot / path):
126 continue
127 changes.append(Change(path, is_new=False, is_deleted=False))
128 elif in_upstream:
129 changes.append(Change(path, is_new=True, is_deleted=False))
130 else:
131 changes.append(Change(path, is_new=False, is_deleted=True))
132 return changes
135def _delete(target_path: Path) -> None:
136 """Remove a file the template no longer ships."""
137 if target_path.exists():
138 target_path.unlink()
141def _copy(upstream_path: Path, target_path: Path) -> None:
142 """Install an upstream file wholesale, creating parent directories."""
143 target_path.parent.mkdir(parents=True, exist_ok=True)
144 shutil.copy2(upstream_path, target_path)
147def merge_one(
148 ctx: git.GitContext,
149 change: Change,
150 target: Path,
151 base_snapshot: Path,
152 upstream_snapshot: Path,
153 outcome: MergeOutcome,
154) -> None:
155 """Apply a single *change* to *target*, recording what happened in *outcome*."""
156 target_path = target / change.path
157 base_path = base_snapshot / change.path
158 upstream_path = upstream_snapshot / change.path
160 if change.is_deleted:
161 _delete(target_path)
162 outcome.deleted.append(change.path)
163 return
165 # Nothing local to preserve, or no base to merge against: take upstream whole.
166 if change.is_new or not target_path.exists():
167 _copy(upstream_path, target_path)
168 outcome.merged.append(change.path)
169 return
171 # The user has not touched it — upstream wins without a merge, and without any
172 # chance of `git merge-file` mangling an unchanged file.
173 if _same_content(target_path, base_path):
174 _copy(upstream_path, target_path)
175 outcome.merged.append(change.path)
176 return
178 if is_binary(target_path) or is_binary(upstream_path) or is_binary(base_path):
179 # Locally modified *and* binary. Leave it alone and say so: there is no merge to
180 # perform and no marker to leave, so overwriting would lose work invisibly.
181 outcome.unmergeable.append(change.path)
182 return
184 status = git.merge_file(ctx, target_path, base_path, upstream_path)
185 if status == 0:
186 outcome.merged.append(change.path)
187 elif status == _MERGE_REFUSED:
188 outcome.unmergeable.append(change.path)
189 else:
190 outcome.conflicted.append(change.path)
193def merge_trees(
194 ctx: git.GitContext, target: Path, base_snapshot: Path, upstream_snapshot: Path
195) -> MergeOutcome:
196 """Merge *upstream_snapshot* into *target*, using *base_snapshot* as the merge base."""
197 outcome = MergeOutcome()
198 for change in changed_files(base_snapshot, upstream_snapshot):
199 merge_one(ctx, change, target, base_snapshot, upstream_snapshot, outcome)
200 return outcome