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

1#!/usr/bin/env python3 

2"""Three-way merge of a template snapshot into the working tree — `sync.py`'s merge. 

3 

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. 

8 

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. 

14 

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. 

20 

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

25 

26What the merge decides, per file, from the three trees: 

27 

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` 

33 

34That third rule is the important one: a file the template did not change is never 

35rewritten, whatever the user did to it. 

36 

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

43 

44from __future__ import annotations 

45 

46import shutil 

47from dataclasses import dataclass, field 

48from pathlib import Path 

49 

50import _rhiza_git as git 

51 

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 

55 

56# How much of a file to sniff for NUL before calling it binary. 

57_SNIFF_BYTES = 8192 

58 

59 

60@dataclass 

61class MergeOutcome: 

62 """What the merge did, path by path.""" 

63 

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) 

68 

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 

73 

74 

75@dataclass(frozen=True) 

76class Change: 

77 """One template file that differs between the base and upstream snapshots.""" 

78 

79 path: str 

80 is_new: bool 

81 is_deleted: bool 

82 

83 

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

87 

88 

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 

95 

96 

97def is_binary(path: Path) -> bool: 

98 """Does *path* look binary — i.e. contain a NUL in its first few KiB? 

99 

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 

107 

108 

109def changed_files(base_snapshot: Path, upstream_snapshot: Path) -> list[Change]: 

110 """Return the template files that differ between the two snapshots. 

111 

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. 

114 

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) 

120 

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 

133 

134 

135def _delete(target_path: Path) -> None: 

136 """Remove a file the template no longer ships.""" 

137 if target_path.exists(): 

138 target_path.unlink() 

139 

140 

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) 

145 

146 

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 

159 

160 if change.is_deleted: 

161 _delete(target_path) 

162 outcome.deleted.append(change.path) 

163 return 

164 

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 

170 

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 

177 

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 

183 

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) 

191 

192 

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