Coverage for plugin/scripts/sync.py: 100%

126 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-10-26 04:21 +0000

1#!/usr/bin/env python3 

2"""Sync rhiza template files into this repo using a 3-way merge. 

3 

4A stdlib-only port of the `rhiza sync` command, bundled with this plugin so 

5`/rhiza:sync` (and `/rhiza:update`) work without the `rhiza` CLI installed: 

6clone the upstream template, materialise the previously-synced snapshot beside 

7it, and three-way merge the two into the working tree — preserving local edits 

8and leaving conflict markers where both sides changed a region. 

9 

10The merge itself lives in `_rhiza_merge.py`, which compares the two snapshot 

11directories directly. It used to render a `git diff --no-index` of one against 

12the other, apply it with `git apply -3`, then parse that diff text back into a 

13file list to merge each file with `git merge-file` anyway. Since nothing runs 

14`git apply` now, **`.rej` files are no longer produced**. 

15 

16Usage: 

17 uv run --python 3.12 --no-project python scripts/sync.py [TARGET] [--branch BRANCH] 

18 

19 TARGET repository root to sync (default: current directory) 

20 --branch template branch to use when template.yml has no `ref` 

21 (default: main) 

22 

23Requires `git` on PATH and Python >= 3.11 (uses ``datetime.UTC``); run it under 

24``uv run --python 3.12`` since the system ``python3`` may be older (macOS ships 

253.9). **Mutates the working tree.** Exit codes: 

26 0 synced cleanly (or already up to date) 

27 1 synced with conflicts — resolve the `<<<<<<<` markers, then commit (this is 

28 the expected outcome when local edits collide with upstream). Also returned 

29 when a locally-modified binary file could not be merged, which is reported 

30 by name since it leaves no marker behind. 

31 2 could not sync (dirty tree, invalid template.yml, unmanaged-file adoption 

32 conflict, or a git failure); adoption conflicts make no template writes 

33""" 

34 

35from __future__ import annotations 

36 

37import argparse 

38import datetime 

39import shutil 

40import subprocess # nosec B404 

41import sys 

42import tempfile 

43from pathlib import Path 

44 

45sys.path.insert(0, str(Path(__file__).resolve().parent)) 

46import _rhiza_git as git # noqa: E402 

47import _rhiza_merge as merge # noqa: E402 

48from _rhiza_common import SyncError, log # noqa: E402 

49from _rhiza_lock import ( # noqa: E402 

50 build_lock, 

51 clean_orphaned_files, 

52 previously_tracked, 

53 read_base_sha, 

54 unmanaged_conflicts, 

55 write_lock, 

56) 

57from _rhiza_lock import ( 

58 lock_path as resolve_lock_path, 

59) 

60from _rhiza_snapshot import ( # noqa: E402 

61 clone_template, 

62 copy_files, 

63 prepare_snapshot, 

64) 

65from _rhiza_template import Template, load_template, normalise_excludes # noqa: E402 

66 

67EXIT_OK = 0 

68EXIT_CONFLICTS = 1 

69EXIT_ERROR = 2 

70 

71 

72def _now() -> str: 

73 """Return the current UTC time as an ISO 8601 ``...Z`` timestamp (seam for tests).""" 

74 return datetime.datetime.now(datetime.UTC).strftime("%Y-%m-%dT%H:%M:%SZ") 

75 

76 

77# --------------------------------------------------------------------------- 

78# Template configuration 

79# --------------------------------------------------------------------------- 

80 

81 

82def _merge_with_base( 

83 ctx: git.GitContext, 

84 target: Path, 

85 upstream_snapshot: Path, 

86 base_sha: str, 

87 base_snapshot: Path, 

88 git_url: str, 

89 include_paths: list[str], 

90 excludes: set[str], 

91 path_map: dict[str, str], 

92) -> bool: 

93 """Materialise the base snapshot and three-way merge base->upstream into *target*.""" 

94 base_clone = Path(tempfile.mkdtemp()) 

95 try: 

96 git.clone(ctx, git_url, base_clone, include_paths, sha=base_sha) 

97 prepare_snapshot(base_clone, include_paths, excludes, base_snapshot, path_map) 

98 except (subprocess.CalledProcessError, OSError): 

99 log("Could not check out base commit — treating all files as new") 

100 finally: 

101 shutil.rmtree(base_clone, ignore_errors=True) 

102 

103 outcome = merge.merge_trees(ctx, target, base_snapshot, upstream_snapshot) 

104 if not (outcome.merged or outcome.conflicted or outcome.unmergeable or outcome.deleted): 

105 log("Template unchanged since last sync — nothing to apply") 

106 return True 

107 

108 log( 

109 f"Merged {len(outcome.merged)} file(s)" 

110 + (f", deleted {len(outcome.deleted)}" if outcome.deleted else "") 

111 ) 

112 for path in outcome.conflicted: 

113 log(f" conflict: {path} — `<<<<<<<` markers written") 

114 for path in outcome.unmergeable: 

115 # Named individually because there is no marker to find these by: the file was 

116 # left exactly as the user had it, which is the safe choice but an invisible one. 

117 log(f" cannot merge: {path} — locally modified and not text; left untouched") 

118 return outcome.clean 

119 

120 

121def _run_merge( 

122 ctx: git.GitContext, 

123 target: Path, 

124 template: Template, 

125 upstream_snapshot: Path, 

126 upstream_sha: str, 

127 base_sha: str | None, 

128 template_files: list[Path], 

129 include_paths: list[str], 

130 excludes: set[str], 

131 path_map: dict[str, str], 

132 lock_path: Path, 

133) -> bool: 

134 """Apply the upstream snapshot to *target*, clean orphans, write the lock; return clean-ness.""" 

135 tracked_before = previously_tracked(lock_path) 

136 base_snapshot = Path(tempfile.mkdtemp()) 

137 try: 

138 if base_sha: 

139 clean = _merge_with_base( 

140 ctx, 

141 target, 

142 upstream_snapshot, 

143 base_sha, 

144 base_snapshot, 

145 template.git_url, 

146 include_paths, 

147 excludes, 

148 path_map, 

149 ) 

150 else: 

151 log("First sync — copying all template files") 

152 copy_files(upstream_snapshot, target, template_files) 

153 clean = True 

154 

155 missing = [p for p in template_files if not (target / p).exists()] 

156 if missing: 

157 log(f"Restoring {len(missing)} template file(s) missing from target") 

158 copy_files(upstream_snapshot, target, missing) 

159 

160 clean_orphaned_files(target, template_files, excludes, tracked_before) 

161 lock = build_lock(upstream_sha, template, [p.as_posix() for p in template_files], _now()) 

162 write_lock(target, lock, lock_path) 

163 finally: 

164 shutil.rmtree(base_snapshot, ignore_errors=True) 

165 return clean 

166 

167 

168def sync(target: Path, branch: str, adopt_unmanaged: bool = False) -> int: 

169 """Run the sync and return a process exit code (see the module docstring).""" 

170 target = target.resolve() 

171 ctx = git.GitContext.default() 

172 

173 dirty = git.status_porcelain(ctx, target) 

174 if dirty: 

175 log("Working tree is not clean — commit or stash your changes before syncing:") 

176 for line in dirty: 

177 log(f" {line}") 

178 return EXIT_ERROR 

179 

180 template = load_template(target, target / ".rhiza" / "template.yml") 

181 lock_path = resolve_lock_path(target, None) 

182 base_sha = read_base_sha(lock_path) 

183 

184 log(f"Cloning {template.repository}@{template.ref or branch}") 

185 upstream_dir, upstream_sha, include_paths, path_map = clone_template(ctx, template, branch) 

186 upstream_snapshot = Path(tempfile.mkdtemp()) 

187 try: 

188 excludes = normalise_excludes(template.exclude) 

189 template_files = prepare_snapshot( 

190 upstream_dir, include_paths, excludes, upstream_snapshot, path_map 

191 ) 

192 log(f"Upstream: {len(template_files)} file(s) to consider") 

193 conflicts = unmanaged_conflicts(target, template_files, previously_tracked(lock_path)) 

194 if conflicts: 

195 blocked = [conflict for conflict in conflicts if not conflict.adoptable] 

196 if not adopt_unmanaged or blocked: 

197 log("Sync refused: template paths collide with unmanaged repository paths:") 

198 for conflict in conflicts: 

199 log(f" conflict: {conflict.path.as_posix()} — {conflict.detail}") 

200 log("No template files or lock were written.") 

201 if blocked: 

202 log( 

203 "Move or exclude the blocking directory/path; " 

204 "--adopt-unmanaged cannot replace it safely." 

205 ) 

206 else: 

207 log( 

208 "Move or exclude these paths, or rerun with " 

209 "--adopt-unmanaged to replace them and record ownership." 

210 ) 

211 return EXIT_ERROR 

212 

213 for conflict in conflicts: 

214 log(f" adopting: {conflict.path.as_posix()} — {conflict.detail}") 

215 adopted_files: list[Path] = [] 

216 for conflict in conflicts: 

217 conflict_path = target / conflict.path 

218 if conflict.alias: 

219 conflict_path.unlink() 

220 else: 

221 if conflict_path.is_symlink(): 

222 conflict_path.unlink() 

223 adopted_files.append(conflict.path) 

224 if adopted_files: 

225 copy_files(upstream_snapshot, target, adopted_files) 

226 

227 clean = _run_merge( 

228 ctx, 

229 target, 

230 template, 

231 upstream_snapshot, 

232 upstream_sha, 

233 base_sha, 

234 template_files, 

235 include_paths, 

236 excludes, 

237 path_map, 

238 lock_path, 

239 ) 

240 finally: 

241 shutil.rmtree(upstream_snapshot, ignore_errors=True) 

242 shutil.rmtree(upstream_dir, ignore_errors=True) 

243 

244 if not clean: 

245 log("Conflicts remain — resolve `<<<<<<<` markers, then commit.") 

246 return EXIT_CONFLICTS 

247 log(f"Sync complete — {len(template_files)} file(s) processed") 

248 return EXIT_OK 

249 

250 

251def main(argv: list[str] | None = None) -> int: 

252 """Entry point: parse args, run the sync, and translate failures to exit codes.""" 

253 parser = argparse.ArgumentParser( 

254 description="Sync rhiza template files into this repo (3-way merge)." 

255 ) 

256 parser.add_argument( 

257 "target", 

258 nargs="?", 

259 default=".", 

260 help="Repository root to sync (default: current directory).", 

261 ) 

262 parser.add_argument( 

263 "--branch", 

264 "-b", 

265 default="main", 

266 help="Template branch to use when template.yml has no `ref` (default: main).", 

267 ) 

268 parser.add_argument( 

269 "--adopt-unmanaged", 

270 action="store_true", 

271 help="Replace conflicting unmanaged files and record them as template-owned.", 

272 ) 

273 args = parser.parse_args(argv) 

274 try: 

275 return sync(Path(args.target), args.branch, args.adopt_unmanaged) 

276 except SyncError as exc: 

277 log(f"error: {exc}") 

278 return EXIT_ERROR 

279 except subprocess.CalledProcessError as exc: 

280 stderr = exc.stderr.decode() if isinstance(exc.stderr, bytes) else (exc.stderr or "") 

281 log(f"error: git failed: {stderr.strip() or exc}") 

282 return EXIT_ERROR 

283 except RuntimeError as exc: 

284 log(f"error: {exc}") 

285 return EXIT_ERROR 

286 

287 

288if __name__ == "__main__": 

289 raise SystemExit(main())