Coverage for src / rhiza / commands / sync.py: 100%
94 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-02 07:04 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-02 07:04 +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:
97 logger.error("No templates or include paths found in template.yml")
98 logger.error("Add either 'templates' or 'include' list in template.yml")
99 raise RuntimeError("No templates or include paths found in template.yml") # noqa: TRY003
101 _log_list("Templates", template.templates)
102 _log_list("Include paths", template.include)
103 _log_list("Exclude paths", template.exclude)
105 return template
108def _clone_template(
109 template: RhizaTemplate,
110 git_ctx: GitContext,
111 branch: str = "main",
112) -> tuple[Path, str, list[str]]:
113 """Clone the upstream template repository and resolve include paths.
115 Clones the template repository using sparse checkout. When
116 ``templates`` are configured the corresponding bundle names are resolved
117 to file paths via :meth:`~rhiza.models.RhizaTemplate.resolve_include_paths`.
119 Args:
120 template: The template configuration.
121 git_ctx: Git context.
122 branch: Default branch to use when ``template_branch`` is not set
123 on the template.
125 Returns:
126 Tuple of ``(upstream_dir, upstream_sha, resolved_include)`` where
127 *upstream_dir* is a temporary directory containing the cloned repository
128 tree. The caller is responsible for removing *upstream_dir* when done.
130 Raises:
131 ValueError: If ``template_repository`` is not set, the host is
132 unsupported, or no include paths / templates are configured.
133 subprocess.CalledProcessError: If a git operation fails.
134 """
135 from rhiza.models.bundle import RhizaBundles
137 if not template.template_repository:
138 raise ValueError("template_repository is not configured in template.yml") # noqa: TRY003
139 if not template.templates and not template.include:
140 raise ValueError("No templates or include paths found in template.yml") # noqa: TRY003
142 rhiza_branch = template.template_branch or branch
143 include_paths = list(template.include)
144 upstream_dir = Path(tempfile.mkdtemp())
146 if template.templates:
147 # Checkout the bundle definitions file from template_repository @ template_branch
148 bundles_path = template.template_bundles_path
149 git_ctx.clone_repository(template.git_url, upstream_dir, rhiza_branch, [bundles_path])
151 # Load bundle definitions, resolve bundle names to paths, update sparse checkout
152 bundles = RhizaBundles.from_yaml(upstream_dir / bundles_path)
153 resolved_paths = bundles.resolve_to_paths(template.templates)
154 # Merge resolved bundle paths with any explicit include: paths (hybrid mode)
155 merged_paths = list(dict.fromkeys(resolved_paths + include_paths))
156 git_ctx.update_sparse_checkout(upstream_dir, merged_paths)
157 include_paths = merged_paths
158 else:
159 git_ctx.clone_repository(template.git_url, upstream_dir, rhiza_branch, include_paths)
161 upstream_sha = git_ctx.get_head_sha(upstream_dir)
162 logger.info(f"Upstream HEAD: {upstream_sha[:12]}")
164 return upstream_dir, upstream_sha, include_paths
167def sync(
168 target: Path,
169 branch: str,
170 target_branch: str | None,
171 strategy: str,
172 template_file: Path | None = None,
173 lock_file: Path | None = None,
174) -> None:
175 """Sync Rhiza templates using cruft-style diff/merge.
177 Uses diff utilities to compute the diff between the base
178 (last-synced) and upstream (latest) template snapshots, then applies
179 the diff to the project using ``git apply -3`` for a 3-way merge.
181 Args:
182 target: Path to the target repository.
183 branch: The Rhiza template branch to use.
184 target_branch: Optional branch name to create/checkout in the target.
185 strategy: Sync strategy -- ``"merge"`` for 3-way merge,
186 or ``"diff"`` for dry-run showing what would change.
187 template_file: Optional explicit path to the ``template.yml`` file.
188 When ``None`` the default ``<target>/.rhiza/template.yml`` is used.
189 lock_file: Optional explicit path for the output lock file. When
190 ``None`` the default ``<target>/.rhiza/template.lock`` is used.
191 """
192 target = target.resolve()
193 logger.info(f"Target repository: {target}")
194 logger.info(f"Rhiza branch: {branch}")
195 logger.info(f"Sync strategy: {strategy}")
197 git_ctx = GitContext.default()
199 git_ctx.assert_status_clean(target)
200 git_ctx.handle_target_branch(target, target_branch)
202 template = _load_template_from_project(target, template_file=template_file)
204 # Capture original include before resolving bundles (templates: mode)
205 original_include = list(template.include)
207 logger.info(f"Cloning {template.template_repository}@{template.template_branch} (upstream)")
208 upstream_dir, upstream_sha, resolved_include = _clone_template(template, git_ctx, branch=branch)
210 # Synchronizes target with upstream template snapshot transactionally; cleans up resources
211 try:
212 lock_path = lock_file if lock_file is not None else target / ".rhiza" / "template.lock"
213 base_sha = TemplateLock.from_yaml(lock_path).config["sha"] if lock_path.exists() else None
215 upstream_snapshot = Path(tempfile.mkdtemp())
216 try:
217 excludes = _excluded_set(upstream_dir, template.exclude)
218 materialized = _prepare_snapshot(upstream_dir, resolved_include, excludes, upstream_snapshot)
219 logger.info(f"Upstream: {len(materialized)} file(s) to consider")
220 lock = TemplateLock(
221 sha=upstream_sha,
222 repo=template.template_repository,
223 host=template.template_host,
224 ref=template.template_branch,
225 include=original_include,
226 exclude=template.exclude,
227 templates=template.templates,
228 files=[str(p) for p in materialized],
229 synced_at=datetime.datetime.now(datetime.UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
230 strategy=strategy,
231 )
233 # Build a resolved template view for merge operations (bundles → concrete paths)
234 resolved_template = dataclasses.replace(template, include=resolved_include, templates=[])
236 if strategy == "diff":
237 git_ctx.sync_diff(
238 target=target,
239 upstream_snapshot=upstream_snapshot,
240 )
241 else:
242 git_ctx.sync_merge(
243 target=target,
244 upstream_snapshot=upstream_snapshot,
245 upstream_sha=upstream_sha,
246 base_sha=base_sha,
247 materialized=materialized,
248 template=resolved_template,
249 excludes=excludes,
250 lock=lock,
251 lock_file=lock_file,
252 )
253 finally:
254 if upstream_snapshot.exists():
255 shutil.rmtree(upstream_snapshot)
256 finally:
257 if upstream_dir.exists():
258 shutil.rmtree(upstream_dir)