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

1"""Command for syncing Rhiza template files using diff/merge. 

2 

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. 

6 

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. 

15 

16When no lock file exists (first sync), the command falls back to a simple 

17copy and records the commit SHA. 

18""" 

19 

20import dataclasses 

21import datetime 

22import shutil 

23import tempfile 

24from pathlib import Path 

25 

26from loguru import logger 

27 

28from rhiza.models import GitContext, RhizaTemplate, TemplateLock 

29from rhiza.models._git_utils import _excluded_set, _prepare_snapshot 

30 

31__all__ = ["sync"] 

32 

33_DEFAULT_BUNDLES_PATH = ".rhiza/template-bundles.yml" 

34 

35 

36def _log_list(header: str, items: list[str]) -> None: 

37 """Log a labelled list of items, if non-empty. 

38 

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}") 

47 

48 

49def _load_template_from_project(target: Path, template_file: Path | None = None) -> RhizaTemplate: 

50 """Validate and load a :class:`RhizaTemplate` from a project directory. 

51 

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. 

55 

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. 

61 

62 Returns: 

63 The loaded and validated :class:`RhizaTemplate`. 

64 

65 Raises: 

66 RuntimeError: If validation fails or required fields are missing. 

67 """ 

68 from rhiza.commands.validate import validate 

69 

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 

75 

76 if template_file is None: 

77 template_file = target / ".rhiza" / "template.yml" 

78 template = RhizaTemplate.from_yaml(template_file) 

79 

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 

91 

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 

95 

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 

100 

101 _log_list("Templates", template.templates) 

102 _log_list("Include paths", template.include) 

103 _log_list("Exclude paths", template.exclude) 

104 

105 return template 

106 

107 

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. 

114 

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`. 

118 

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. 

124 

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. 

129 

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 

136 

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 

141 

142 rhiza_branch = template.template_branch or branch 

143 include_paths = list(template.include) 

144 upstream_dir = Path(tempfile.mkdtemp()) 

145 

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]) 

150 

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) 

160 

161 upstream_sha = git_ctx.get_head_sha(upstream_dir) 

162 logger.info(f"Upstream HEAD: {upstream_sha[:12]}") 

163 

164 return upstream_dir, upstream_sha, include_paths 

165 

166 

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. 

176 

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. 

180 

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}") 

196 

197 git_ctx = GitContext.default() 

198 

199 git_ctx.assert_status_clean(target) 

200 git_ctx.handle_target_branch(target, target_branch) 

201 

202 template = _load_template_from_project(target, template_file=template_file) 

203 

204 # Capture original include before resolving bundles (templates: mode) 

205 original_include = list(template.include) 

206 

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) 

209 

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 

214 

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 ) 

232 

233 # Build a resolved template view for merge operations (bundles → concrete paths) 

234 resolved_template = dataclasses.replace(template, include=resolved_include, templates=[]) 

235 

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)