Coverage for src / rhiza / commands / sync.py: 100%
126 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"""Command for syncing Rhiza template files using diff/merge.
3This module implements the ``sync`` command. It uses a cruft-style diff/patch
4approach so that local customisations are preserved and upstream changes
5are applied safely.
7The approach:
81. Read the last-synced commit SHA from ``.rhiza/template.lock``.
92. Clone the template repository and obtain two tree snapshots:
10 - **base**: the template at the previously synced commit (the common ancestor).
11 - **upstream**: the template at the current HEAD of the configured branch.
123. Compute a diff between base and upstream using ``git diff --no-index``.
134. Apply the diff to the project using ``git apply -3`` for a 3-way merge.
145. Update the lock file.
16When no lock file exists (first sync), the command falls back to a simple
17copy and records the commit SHA.
18"""
20import dataclasses
21import datetime
22import shutil
23import tempfile
24from collections.abc import Mapping
25from pathlib import Path
26from typing import TYPE_CHECKING
28import yaml
29from loguru import logger
31from rhiza.models import GitContext, RhizaTemplate, TemplateLock
32from rhiza.models._git.snapshot import _excluded_set, _prepare_snapshot
34if TYPE_CHECKING:
35 from rhiza.models.bundle import RhizaBundles
37__all__ = ["sync"]
39_DEFAULT_BUNDLES_PATH = ".rhiza/template-bundles.yml"
42def _log_list(header: str, items: list[str]) -> None:
43 """Log a labelled list of items, if non-empty.
45 Args:
46 header: Label printed before the list.
47 items: Items to log; nothing is printed when the list is empty.
48 """
49 if items:
50 logger.info(f"{header}:")
51 for item in items:
52 logger.info(f" - {item}")
55def _load_template_from_project(target: Path, template_file: Path | None = None) -> RhizaTemplate:
56 """Load a :class:`RhizaTemplate` from a project directory.
58 Loads the configuration with :meth:`~rhiza.models.RhizaTemplate.from_yaml`
59 and checks that the required fields are present.
61 Args:
62 target: Path to the target repository (must contain ``.git`` and
63 ``.rhiza/template.yml``).
64 template_file: Optional explicit path to the template file. When
65 ``None`` the default ``<target>/.rhiza/template.yml`` is used.
67 Returns:
68 The loaded :class:`RhizaTemplate`.
70 Raises:
71 RuntimeError: If the template file is missing, malformed, or missing
72 required fields.
73 """
74 if template_file is None:
75 template_file = target / ".rhiza" / "template.yml"
77 try:
78 template = RhizaTemplate.from_yaml(template_file)
79 except (FileNotFoundError, yaml.YAMLError, ValueError, TypeError) as exc:
80 logger.error(f"Rhiza template is invalid in: {target}")
81 logger.error(f"{exc}")
82 logger.error("Fix the errors above and run 'rhiza sync' again")
83 raise RuntimeError("Rhiza template validation failed") from exc # noqa: TRY003
85 # When template_bundles_path is at its default and the template file is not at
86 # the default location, derive the bundles path from the template file's directory
87 # relative to the project root so that --path-to-template works consistently.
88 if template.template_bundles_path == _DEFAULT_BUNDLES_PATH:
89 try:
90 relative_dir = template_file.resolve().parent.relative_to(target)
91 derived = (relative_dir / "template-bundles.yml").as_posix()
92 if derived != _DEFAULT_BUNDLES_PATH:
93 template = dataclasses.replace(template, template_bundles_path=derived)
94 except ValueError:
95 pass # template_file is outside target root; keep default
97 if not template.template_repository:
98 logger.error("template-repository is not configured in template.yml")
99 raise RuntimeError("template-repository is required") # noqa: TRY003
101 if not template.templates and not template.include and not template.profiles:
102 logger.error("No templates, profiles, or include paths found in template.yml")
103 logger.error("Add 'templates', 'profiles', or 'include' to template.yml")
104 raise RuntimeError("No templates, profile, or include paths found in template.yml") # noqa: TRY003
106 _log_list("Profiles", template.profiles)
107 _log_list("Templates", template.templates)
108 _log_list("Include paths", template.include)
109 _log_list("Exclude paths", template.exclude)
111 return template
114def _validate_clone_config(template: RhizaTemplate) -> None:
115 """Validate that *template* carries enough configuration to clone.
117 Args:
118 template: The template configuration.
120 Raises:
121 ValueError: If ``template_repository`` is not set, or no templates,
122 profile, or include paths are configured.
123 """
124 if not template.template_repository:
125 raise ValueError("template_repository is not configured in template.yml") # noqa: TRY003
126 if not template.templates and not template.include and not template.profiles:
127 raise ValueError("No templates, profile, or include paths found in template.yml") # noqa: TRY003
130def _raise_unknown_profile(profile_name: str, bundles_path: str, available_profiles: Mapping[str, object]) -> None:
131 """Raise a ``ValueError`` describing a profile that is not defined.
133 Args:
134 profile_name: The profile that could not be found.
135 bundles_path: Path to the bundle definitions file (used in the message).
136 available_profiles: Mapping of the profiles that *are* defined.
138 Raises:
139 ValueError: Always — lists the available profiles when any exist.
140 """
141 sorted_available = sorted(available_profiles)
142 if sorted_available:
143 available_text = ", ".join(sorted_available)
144 raise ValueError( # noqa: TRY003
145 f"Profile '{profile_name}' was not found in {bundles_path}. Available profiles: {available_text}"
146 )
147 raise ValueError( # noqa: TRY003
148 f"Profile '{profile_name}' was not found in {bundles_path}. No profiles are defined."
149 )
152def _resolve_bundle_names(template: RhizaTemplate, bundles: "RhizaBundles", bundles_path: str) -> list[str]:
153 """Resolve configured profiles and templates to a deduplicated bundle-name list.
155 Profiles are expanded to their constituent bundle names (preserving order and
156 dropping duplicates) and then merged with any explicitly configured
157 ``templates``. When no profiles are configured, the explicit ``templates``
158 list is returned unchanged.
160 Args:
161 template: The template configuration.
162 bundles: Parsed bundle definitions from the template repository.
163 bundles_path: Path to the bundle definitions file (used in error messages).
165 Returns:
166 The ordered, deduplicated list of bundle names to check out.
168 Raises:
169 ValueError: If a configured profile is not defined in *bundles*.
170 """
171 if not template.profiles:
172 return template.templates
174 available_profiles = bundles.profiles or {}
175 profile_bundle_names: list[str] = []
176 for profile_name in template.profiles:
177 if profile_name not in available_profiles:
178 _raise_unknown_profile(profile_name, bundles_path, available_profiles)
179 for bundle in available_profiles[profile_name].bundles:
180 if bundle not in profile_bundle_names:
181 profile_bundle_names.append(bundle)
182 return list(dict.fromkeys(profile_bundle_names + template.templates))
185def _clone_template(
186 template: RhizaTemplate,
187 git_ctx: GitContext,
188 branch: str = "main",
189) -> tuple[Path, str, list[str], dict[str, str]]:
190 """Clone the upstream template repository and resolve include paths.
192 Clones the template repository using sparse checkout. When
193 ``templates`` are configured the corresponding bundle names are resolved
194 to file paths via :meth:`~rhiza.models.RhizaTemplate.resolve_include_paths`.
196 Args:
197 template: The template configuration.
198 git_ctx: Git context.
199 branch: Default branch to use when ``template_branch`` is not set
200 on the template.
202 Returns:
203 Tuple of ``(upstream_dir, upstream_sha, resolved_include, path_map)`` where
204 *upstream_dir* is a temporary directory containing the cloned repository
205 tree and *path_map* maps source paths to destination paths for remapped
206 entries. The caller is responsible for removing *upstream_dir* when done.
208 Raises:
209 ValueError: If ``template_repository`` is not set, the host is
210 unsupported, or no include paths / templates are configured.
211 subprocess.CalledProcessError: If a git operation fails.
212 """
213 from rhiza.models.bundle import RhizaBundles
215 _validate_clone_config(template)
217 rhiza_branch = template.template_branch or branch
218 include_paths = list(template.include)
219 upstream_dir = Path(tempfile.mkdtemp())
221 if template.profiles or template.templates:
222 # Checkout the bundle definitions file from template_repository @ template_branch
223 bundles_path = template.template_bundles_path
224 git_ctx.clone_repository(template.git_url, upstream_dir, rhiza_branch, [bundles_path])
226 # Load bundle definitions
227 bundles = RhizaBundles.from_yaml(upstream_dir / bundles_path)
229 # Resolve profiles → bundle names, then merge with explicit templates list
230 all_bundle_names = _resolve_bundle_names(template, bundles, bundles_path)
232 resolved_paths = bundles.resolve_to_paths(all_bundle_names)
233 path_map = bundles.resolve_to_path_map(all_bundle_names)
234 # Merge resolved bundle paths with any explicit include: paths (hybrid mode)
235 merged_paths = list(dict.fromkeys(resolved_paths + include_paths))
236 git_ctx.update_sparse_checkout(upstream_dir, merged_paths)
237 include_paths = merged_paths
238 else:
239 path_map = {}
240 git_ctx.clone_repository(template.git_url, upstream_dir, rhiza_branch, include_paths)
242 upstream_sha = git_ctx.get_head_sha(upstream_dir)
243 logger.info(f"Upstream HEAD: {upstream_sha[:12]}")
245 return upstream_dir, upstream_sha, include_paths, path_map
248def sync(
249 target: Path,
250 branch: str,
251 target_branch: str | None,
252 strategy: str,
253 template_file: Path | None = None,
254 lock_file: Path | None = None,
255) -> None:
256 """Sync Rhiza templates using cruft-style diff/merge.
258 Uses diff utilities to compute the diff between the base
259 (last-synced) and upstream (latest) template snapshots, then applies
260 the diff to the project using ``git apply -3`` for a 3-way merge.
262 Args:
263 target: Path to the target repository.
264 branch: The Rhiza template branch to use.
265 target_branch: Optional branch name to create/checkout in the target.
266 strategy: Sync strategy -- ``"merge"`` for 3-way merge,
267 or ``"diff"`` for dry-run showing what would change.
268 template_file: Optional explicit path to the ``template.yml`` file.
269 When ``None`` the default ``<target>/.rhiza/template.yml`` is used.
270 lock_file: Optional explicit path for the output lock file. When
271 ``None`` the default ``<target>/.rhiza/template.lock`` is used.
272 """
273 target = target.resolve()
274 logger.info(f"Target repository: {target}")
275 logger.info(f"Rhiza branch: {branch}")
276 logger.info(f"Sync strategy: {strategy}")
278 git_ctx = GitContext.default()
280 git_ctx.assert_status_clean(target)
281 git_ctx.handle_target_branch(target, target_branch)
283 template = _load_template_from_project(target, template_file=template_file)
285 # Capture original include before resolving bundles (templates: mode)
286 original_include = list(template.include)
288 logger.info(f"Cloning {template.template_repository}@{template.template_branch} (upstream)")
289 upstream_dir, upstream_sha, resolved_include, path_map = _clone_template(template, git_ctx, branch=branch)
291 # Synchronizes target with upstream template snapshot transactionally; cleans up resources
292 try:
293 lock_path = lock_file if lock_file is not None else target / ".rhiza" / "template.lock"
294 base_sha = TemplateLock.from_yaml(lock_path).config["sha"] if lock_path.exists() else None
296 upstream_snapshot = Path(tempfile.mkdtemp())
297 try:
298 excludes = _excluded_set(upstream_dir, template.exclude)
299 template_files = _prepare_snapshot(
300 upstream_dir, resolved_include, excludes, upstream_snapshot, path_map=path_map
301 )
302 logger.info(f"Upstream: {len(template_files)} file(s) to consider")
303 lock = TemplateLock(
304 sha=upstream_sha,
305 repo=template.template_repository,
306 host=template.template_host,
307 ref=template.template_branch,
308 include=original_include,
309 exclude=template.exclude,
310 templates=template.templates,
311 profiles=template.profiles,
312 files=[str(p) for p in template_files],
313 synced_at=datetime.datetime.now(datetime.UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
314 strategy=strategy,
315 )
317 # Build a resolved template view for merge operations (bundles → concrete paths)
318 resolved_template = dataclasses.replace(template, include=resolved_include, templates=[])
320 if strategy == "diff":
321 git_ctx.sync_diff(
322 target=target,
323 upstream_snapshot=upstream_snapshot,
324 )
325 else:
326 clean = git_ctx.sync_merge(
327 target=target,
328 upstream_snapshot=upstream_snapshot,
329 upstream_sha=upstream_sha,
330 base_sha=base_sha,
331 template_files=template_files,
332 template=resolved_template,
333 excludes=excludes,
334 lock=lock,
335 lock_file=lock_file,
336 path_map=path_map,
337 )
338 if not clean:
339 logger.error("Sync completed with conflicts — see the file list above for details")
340 logger.error(
341 "Resolve all conflicts locally (remove *.rej files and conflict markers),\n"
342 " then commit the result."
343 )
344 msg = "Sync completed with merge conflicts"
345 raise RuntimeError(msg)
346 finally:
347 if upstream_snapshot.exists():
348 shutil.rmtree(upstream_snapshot)
349 finally:
350 if upstream_dir.exists():
351 shutil.rmtree(upstream_dir)