Coverage for src / rhiza / commands / sync.py: 100%
117 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-06-15 18:22 +0000
« prev ^ index » next coverage.py v7.14.0, created at 2026-06-15 18:22 +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 pathlib import Path
26from loguru import logger
28from rhiza.models import GitContext, RhizaTemplate, TemplateLock
29from rhiza.models._git_utils import _excluded_set, _prepare_snapshot
31__all__ = ["sync"]
33_DEFAULT_BUNDLES_PATH = ".rhiza/template-bundles.yml"
36def _log_list(header: str, items: list[str]) -> None:
37 """Log a labelled list of items, if non-empty.
39 Args:
40 header: Label printed before the list.
41 items: Items to log; nothing is printed when the list is empty.
42 """
43 if items:
44 logger.info(f"{header}:")
45 for item in items:
46 logger.info(f" - {item}")
49def _load_template_from_project(target: Path, template_file: Path | None = None) -> RhizaTemplate:
50 """Validate and load a :class:`RhizaTemplate` from a project directory.
52 Validates the project's ``template.yml`` via :func:`~rhiza.commands.validate.validate`,
53 then loads the configuration with :meth:`~rhiza.models.RhizaTemplate.from_yaml` and
54 checks that the required fields are present.
56 Args:
57 target: Path to the target repository (must contain ``.git`` and
58 ``.rhiza/template.yml``).
59 template_file: Optional explicit path to the template file. When
60 ``None`` the default ``<target>/.rhiza/template.yml`` is used.
62 Returns:
63 The loaded and validated :class:`RhizaTemplate`.
65 Raises:
66 RuntimeError: If validation fails or required fields are missing.
67 """
68 from rhiza.commands.validate import validate
70 valid = validate(target, template_file=template_file)
71 if not valid:
72 logger.error(f"Rhiza template is invalid in: {target}")
73 logger.error("Please fix validation errors and try again")
74 raise RuntimeError("Rhiza template validation failed") # noqa: TRY003
76 if template_file is None:
77 template_file = target / ".rhiza" / "template.yml"
78 template = RhizaTemplate.from_yaml(template_file)
80 # When template_bundles_path is at its default and the template file is not at
81 # the default location, derive the bundles path from the template file's directory
82 # relative to the project root so that --path-to-template works consistently.
83 if template.template_bundles_path == _DEFAULT_BUNDLES_PATH:
84 try:
85 relative_dir = template_file.resolve().parent.relative_to(target)
86 derived = (relative_dir / "template-bundles.yml").as_posix()
87 if derived != _DEFAULT_BUNDLES_PATH:
88 template = dataclasses.replace(template, template_bundles_path=derived)
89 except ValueError:
90 pass # template_file is outside target root; keep default
92 if not template.template_repository:
93 logger.error("template-repository is not configured in template.yml")
94 raise RuntimeError("template-repository is required") # noqa: TRY003
96 if not template.templates and not template.include and not template.profiles:
97 logger.error("No templates, profiles, or include paths found in template.yml")
98 logger.error("Add 'templates', 'profiles', or 'include' to template.yml")
99 raise RuntimeError("No templates, profile, or include paths found in template.yml") # noqa: TRY003
101 _log_list("Profiles", template.profiles)
102 _log_list("Templates", template.templates)
103 _log_list("Include paths", template.include)
104 _log_list("Exclude paths", template.exclude)
106 return template
109def _clone_template(
110 template: RhizaTemplate,
111 git_ctx: GitContext,
112 branch: str = "main",
113) -> tuple[Path, str, list[str], dict[str, str]]:
114 """Clone the upstream template repository and resolve include paths.
116 Clones the template repository using sparse checkout. When
117 ``templates`` are configured the corresponding bundle names are resolved
118 to file paths via :meth:`~rhiza.models.RhizaTemplate.resolve_include_paths`.
120 Args:
121 template: The template configuration.
122 git_ctx: Git context.
123 branch: Default branch to use when ``template_branch`` is not set
124 on the template.
126 Returns:
127 Tuple of ``(upstream_dir, upstream_sha, resolved_include, path_map)`` where
128 *upstream_dir* is a temporary directory containing the cloned repository
129 tree and *path_map* maps source paths to destination paths for remapped
130 entries. The caller is responsible for removing *upstream_dir* when done.
132 Raises:
133 ValueError: If ``template_repository`` is not set, the host is
134 unsupported, or no include paths / templates are configured.
135 subprocess.CalledProcessError: If a git operation fails.
136 """
137 from rhiza.models.bundle import RhizaBundles
139 if not template.template_repository:
140 raise ValueError("template_repository is not configured in template.yml") # noqa: TRY003
141 if not template.templates and not template.include and not template.profiles:
142 raise ValueError("No templates, profile, or include paths found in template.yml") # noqa: TRY003
144 rhiza_branch = template.template_branch or branch
145 include_paths = list(template.include)
146 upstream_dir = Path(tempfile.mkdtemp())
148 if template.profiles or template.templates:
149 # Checkout the bundle definitions file from template_repository @ template_branch
150 bundles_path = template.template_bundles_path
151 git_ctx.clone_repository(template.git_url, upstream_dir, rhiza_branch, [bundles_path])
153 # Load bundle definitions
154 bundles = RhizaBundles.from_yaml(upstream_dir / bundles_path)
156 # Resolve profiles → bundle names, then merge with explicit templates list
157 if template.profiles:
158 available_profiles = bundles.profiles or {}
159 profile_bundle_names: list[str] = []
160 for profile_name in template.profiles:
161 if profile_name not in available_profiles:
162 sorted_available = sorted(available_profiles)
163 if sorted_available:
164 available_text = ", ".join(sorted_available)
165 raise ValueError( # noqa: TRY003
166 f"Profile '{profile_name}' was not found in {bundles_path}. "
167 f"Available profiles: {available_text}"
168 )
169 raise ValueError( # noqa: TRY003
170 f"Profile '{profile_name}' was not found in {bundles_path}. No profiles are defined."
171 )
172 for bundle in available_profiles[profile_name].bundles:
173 if bundle not in profile_bundle_names:
174 profile_bundle_names.append(bundle)
175 all_bundle_names = list(dict.fromkeys(profile_bundle_names + template.templates))
176 else:
177 all_bundle_names = template.templates
179 resolved_paths = bundles.resolve_to_paths(all_bundle_names)
180 path_map = bundles.resolve_to_path_map(all_bundle_names)
181 # Merge resolved bundle paths with any explicit include: paths (hybrid mode)
182 merged_paths = list(dict.fromkeys(resolved_paths + include_paths))
183 git_ctx.update_sparse_checkout(upstream_dir, merged_paths)
184 include_paths = merged_paths
185 else:
186 path_map = {}
187 git_ctx.clone_repository(template.git_url, upstream_dir, rhiza_branch, include_paths)
189 upstream_sha = git_ctx.get_head_sha(upstream_dir)
190 logger.info(f"Upstream HEAD: {upstream_sha[:12]}")
192 return upstream_dir, upstream_sha, include_paths, path_map
195def sync(
196 target: Path,
197 branch: str,
198 target_branch: str | None,
199 strategy: str,
200 template_file: Path | None = None,
201 lock_file: Path | None = None,
202) -> None:
203 """Sync Rhiza templates using cruft-style diff/merge.
205 Uses diff utilities to compute the diff between the base
206 (last-synced) and upstream (latest) template snapshots, then applies
207 the diff to the project using ``git apply -3`` for a 3-way merge.
209 Args:
210 target: Path to the target repository.
211 branch: The Rhiza template branch to use.
212 target_branch: Optional branch name to create/checkout in the target.
213 strategy: Sync strategy -- ``"merge"`` for 3-way merge,
214 or ``"diff"`` for dry-run showing what would change.
215 template_file: Optional explicit path to the ``template.yml`` file.
216 When ``None`` the default ``<target>/.rhiza/template.yml`` is used.
217 lock_file: Optional explicit path for the output lock file. When
218 ``None`` the default ``<target>/.rhiza/template.lock`` is used.
219 """
220 target = target.resolve()
221 logger.info(f"Target repository: {target}")
222 logger.info(f"Rhiza branch: {branch}")
223 logger.info(f"Sync strategy: {strategy}")
225 git_ctx = GitContext.default()
227 git_ctx.assert_status_clean(target)
228 git_ctx.handle_target_branch(target, target_branch)
230 template = _load_template_from_project(target, template_file=template_file)
232 # Capture original include before resolving bundles (templates: mode)
233 original_include = list(template.include)
235 logger.info(f"Cloning {template.template_repository}@{template.template_branch} (upstream)")
236 upstream_dir, upstream_sha, resolved_include, path_map = _clone_template(template, git_ctx, branch=branch)
238 # Synchronizes target with upstream template snapshot transactionally; cleans up resources
239 try:
240 lock_path = lock_file if lock_file is not None else target / ".rhiza" / "template.lock"
241 base_sha = TemplateLock.from_yaml(lock_path).config["sha"] if lock_path.exists() else None
243 upstream_snapshot = Path(tempfile.mkdtemp())
244 try:
245 excludes = _excluded_set(upstream_dir, template.exclude)
246 materialized = _prepare_snapshot(
247 upstream_dir, resolved_include, excludes, upstream_snapshot, path_map=path_map
248 )
249 logger.info(f"Upstream: {len(materialized)} file(s) to consider")
250 lock = TemplateLock(
251 sha=upstream_sha,
252 repo=template.template_repository,
253 host=template.template_host,
254 ref=template.template_branch,
255 include=original_include,
256 exclude=template.exclude,
257 templates=template.templates,
258 profiles=template.profiles,
259 files=[str(p) for p in materialized],
260 synced_at=datetime.datetime.now(datetime.UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
261 strategy=strategy,
262 )
264 # Build a resolved template view for merge operations (bundles → concrete paths)
265 resolved_template = dataclasses.replace(template, include=resolved_include, templates=[])
267 if strategy == "diff":
268 git_ctx.sync_diff(
269 target=target,
270 upstream_snapshot=upstream_snapshot,
271 )
272 else:
273 clean = git_ctx.sync_merge(
274 target=target,
275 upstream_snapshot=upstream_snapshot,
276 upstream_sha=upstream_sha,
277 base_sha=base_sha,
278 materialized=materialized,
279 template=resolved_template,
280 excludes=excludes,
281 lock=lock,
282 lock_file=lock_file,
283 path_map=path_map,
284 )
285 if not clean:
286 logger.error("Sync completed with conflicts — see the file list above for details")
287 logger.error(
288 "Resolve all conflicts locally (remove *.rej files and conflict markers),\n"
289 " then commit the result."
290 )
291 msg = "Sync completed with merge conflicts"
292 raise RuntimeError(msg)
293 finally:
294 if upstream_snapshot.exists():
295 shutil.rmtree(upstream_snapshot)
296 finally:
297 if upstream_dir.exists():
298 shutil.rmtree(upstream_dir)