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
« 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.
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.
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**.
16Usage:
17 uv run --python 3.12 --no-project python scripts/sync.py [TARGET] [--branch BRANCH]
19 TARGET repository root to sync (default: current directory)
20 --branch template branch to use when template.yml has no `ref`
21 (default: main)
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"""
35from __future__ import annotations
37import argparse
38import datetime
39import shutil
40import subprocess # nosec B404
41import sys
42import tempfile
43from pathlib import Path
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
67EXIT_OK = 0
68EXIT_CONFLICTS = 1
69EXIT_ERROR = 2
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")
77# ---------------------------------------------------------------------------
78# Template configuration
79# ---------------------------------------------------------------------------
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)
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
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
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
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)
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
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()
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
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)
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
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)
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)
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
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
288if __name__ == "__main__":
289 raise SystemExit(main())