rhiza 0.8.3__py3-none-any.whl → 0.8.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,128 +17,81 @@ from loguru import logger
17
17
 
18
18
  from rhiza.commands.validate import validate
19
19
  from rhiza.models import RhizaTemplate
20
+ from rhiza.subprocess_utils import get_git_executable
20
21
 
21
22
 
22
- def __expand_paths(base_dir: Path, paths: list[str]) -> list[Path]:
23
- """Expand files/directories relative to base_dir into a flat list of files.
24
-
25
- Given a list of paths relative to ``base_dir``, return a flat list of all
26
- individual files.
27
-
28
- Args:
29
- base_dir: The base directory to resolve paths against.
30
- paths: List of relative path strings (files or directories).
31
-
32
- Returns:
33
- A flat list of Path objects representing all individual files found.
34
- """
35
- all_files = []
36
- for p in paths:
37
- full_path = base_dir / p
38
- # Check if the path is a regular file
39
- if full_path.is_file():
40
- all_files.append(full_path)
41
- # If it's a directory, recursively find all files within it
42
- elif full_path.is_dir():
43
- all_files.extend([f for f in full_path.rglob("*") if f.is_file()])
44
- else:
45
- # Path does not exist in the cloned repository - skip it silently
46
- # This can happen if the template repo doesn't have certain paths
47
- logger.debug(f"Path not found in template repository: {p}")
48
- continue
49
- return all_files
50
-
51
-
52
- def materialize(target: Path, branch: str, target_branch: str | None, force: bool) -> None:
53
- """Materialize Rhiza templates into the target repository.
54
-
55
- This performs a sparse checkout of the template repository and copies the
56
- selected files into the target repository, recording all files under
57
- template control in `.rhiza/history`.
23
+ def _handle_target_branch(
24
+ target: Path, target_branch: str | None, git_executable: str, git_env: dict[str, str]
25
+ ) -> None:
26
+ """Handle target branch creation or checkout if specified.
58
27
 
59
28
  Args:
60
- target (Path): Path to the target repository.
61
- branch (str): The Rhiza template branch to use.
62
- target_branch (str | None): Optional branch name to create/checkout in
63
- the target repository.
64
- force (bool): Whether to overwrite existing files.
29
+ target: Path to the target repository.
30
+ target_branch: Optional branch name to create/checkout.
31
+ git_executable: Path to git executable.
32
+ git_env: Environment variables for git commands.
65
33
  """
66
- # Resolve to absolute path to avoid any ambiguity
67
- target = target.resolve()
68
-
69
- logger.info(f"Target repository: {target}")
70
- logger.info(f"Rhiza branch: {branch}")
34
+ if not target_branch:
35
+ return
71
36
 
72
- # Set environment to prevent git from prompting for credentials
73
- # This ensures non-interactive behavior during git operations
74
- git_env = os.environ.copy()
75
- git_env["GIT_TERMINAL_PROMPT"] = "0"
37
+ logger.info(f"Creating/checking out target branch: {target_branch}")
38
+ try:
39
+ # Check if branch already exists using git rev-parse
40
+ result = subprocess.run(
41
+ [git_executable, "rev-parse", "--verify", target_branch],
42
+ cwd=target,
43
+ capture_output=True,
44
+ text=True,
45
+ env=git_env,
46
+ )
76
47
 
77
- # -----------------------
78
- # Handle target branch creation/checkout if specified
79
- # -----------------------
80
- # When a target branch is specified, we either checkout an existing branch
81
- # or create a new one. This allows users to materialize templates onto a
82
- # separate branch for review before merging to main.
83
- if target_branch:
84
- logger.info(f"Creating/checking out target branch: {target_branch}")
85
- try:
86
- # Check if branch already exists using git rev-parse
87
- # Returns 0 if the branch exists, non-zero otherwise
88
- result = subprocess.run(
89
- ["git", "rev-parse", "--verify", target_branch],
48
+ if result.returncode == 0:
49
+ # Branch exists, switch to it
50
+ logger.info(f"Branch '{target_branch}' exists, checking out...")
51
+ subprocess.run(
52
+ [git_executable, "checkout", target_branch],
90
53
  cwd=target,
91
- capture_output=True,
92
- text=True,
54
+ check=True,
93
55
  env=git_env,
94
56
  )
57
+ else:
58
+ # Branch doesn't exist, create it from current HEAD
59
+ logger.info(f"Creating new branch '{target_branch}'...")
60
+ subprocess.run(
61
+ [git_executable, "checkout", "-b", target_branch],
62
+ cwd=target,
63
+ check=True,
64
+ env=git_env,
65
+ )
66
+ except subprocess.CalledProcessError as e:
67
+ logger.error(f"Failed to create/checkout branch '{target_branch}': {e}")
68
+ sys.exit(1)
95
69
 
96
- if result.returncode == 0:
97
- # Branch exists, switch to it
98
- logger.info(f"Branch '{target_branch}' exists, checking out...")
99
- subprocess.run(
100
- ["git", "checkout", target_branch],
101
- cwd=target,
102
- check=True,
103
- env=git_env,
104
- )
105
- else:
106
- # Branch doesn't exist, create it from current HEAD
107
- logger.info(f"Creating new branch '{target_branch}'...")
108
- subprocess.run(
109
- ["git", "checkout", "-b", target_branch],
110
- cwd=target,
111
- check=True,
112
- env=git_env,
113
- )
114
- except subprocess.CalledProcessError as e:
115
- logger.error(f"Failed to create/checkout branch '{target_branch}': {e}")
116
- sys.exit(1)
117
-
118
- # -----------------------
70
+
71
+ def _validate_and_load_template(target: Path, branch: str) -> tuple[RhizaTemplate, str, str, list[str], list[str]]:
72
+ """Validate configuration and load template settings.
73
+
74
+ Args:
75
+ target: Path to the target repository.
76
+ branch: The Rhiza template branch to use (CLI argument).
77
+
78
+ Returns:
79
+ Tuple of (template, rhiza_repo, rhiza_branch, include_paths, excluded_paths).
80
+ """
119
81
  # Validate Rhiza configuration
120
- # -----------------------
121
- # The validate function checks if template.yml exists and is valid
122
- # Returns True if valid, False otherwise
123
82
  valid = validate(target)
124
-
125
83
  if not valid:
126
84
  logger.error(f"Rhiza template is invalid in: {target}")
127
85
  logger.error("Please fix validation errors and try again")
128
86
  sys.exit(1)
129
87
 
130
- # Load the template configuration from the validated file
131
- # Validation ensures the file exists at .rhiza/template.yml
88
+ # Load the template configuration
132
89
  template_file = target / ".rhiza" / "template.yml"
133
90
  template = RhizaTemplate.from_yaml(template_file)
134
91
 
135
92
  # Extract template configuration settings
136
- # These define where to clone from and what to materialize
137
93
  rhiza_repo = template.template_repository
138
- # Use CLI arg if template doesn't specify a branch
139
94
  rhiza_branch = template.template_branch or branch
140
- # Default to GitHub if not specified
141
- rhiza_host = template.template_host or "github"
142
95
  include_paths = template.include
143
96
  excluded_paths = template.exclude
144
97
 
@@ -148,21 +101,32 @@ def materialize(target: Path, branch: str, target_branch: str | None, force: boo
148
101
  logger.error("Add at least one path to the 'include' list in template.yml")
149
102
  raise RuntimeError("No include paths found in template.yml")
150
103
 
151
- # Log the paths we'll be including for transparency
104
+ # Log the paths we'll be including
152
105
  logger.info("Include paths:")
153
106
  for p in include_paths:
154
107
  logger.info(f" - {p}")
155
108
 
156
- # Log excluded paths if any are defined
157
109
  if excluded_paths:
158
110
  logger.info("Exclude paths:")
159
111
  for p in excluded_paths:
160
112
  logger.info(f" - {p}")
161
113
 
162
- # -----------------------
163
- # Construct git clone URL based on host
164
- # -----------------------
165
- # Support both GitHub and GitLab template repositories
114
+ return template, rhiza_repo, rhiza_branch, include_paths, excluded_paths
115
+
116
+
117
+ def _construct_git_url(rhiza_repo: str, rhiza_host: str) -> str:
118
+ """Construct git clone URL based on host.
119
+
120
+ Args:
121
+ rhiza_repo: Repository name in 'owner/repo' format.
122
+ rhiza_host: Git hosting platform ('github' or 'gitlab').
123
+
124
+ Returns:
125
+ Git URL for cloning.
126
+
127
+ Raises:
128
+ ValueError: If rhiza_host is not supported.
129
+ """
166
130
  if rhiza_host == "gitlab":
167
131
  git_url = f"https://gitlab.com/{rhiza_repo}.git"
168
132
  logger.debug(f"Using GitLab repository: {git_url}")
@@ -173,144 +137,160 @@ def materialize(target: Path, branch: str, target_branch: str | None, force: boo
173
137
  logger.error(f"Unsupported template-host: {rhiza_host}")
174
138
  logger.error("template-host must be 'github' or 'gitlab'")
175
139
  raise ValueError(f"Unsupported template-host: {rhiza_host}. Must be 'github' or 'gitlab'.")
140
+ return git_url
176
141
 
177
- # -----------------------
178
- # Sparse clone template repo
179
- # -----------------------
180
- # Create a temporary directory for the sparse clone
181
- # This will be cleaned up in the finally block
182
- tmp_dir = Path(tempfile.mkdtemp())
183
- materialized_files: list[Path] = []
184
142
 
185
- logger.info(f"Cloning {rhiza_repo}@{rhiza_branch} from {rhiza_host} into temporary directory")
186
- logger.debug(f"Temporary directory: {tmp_dir}")
143
+ def _clone_template_repository(
144
+ tmp_dir: Path,
145
+ git_url: str,
146
+ rhiza_branch: str,
147
+ include_paths: list[str],
148
+ git_executable: str,
149
+ git_env: dict[str, str],
150
+ ) -> None:
151
+ """Clone template repository with sparse checkout.
187
152
 
153
+ Args:
154
+ tmp_dir: Temporary directory for cloning.
155
+ git_url: Git repository URL.
156
+ rhiza_branch: Branch to clone.
157
+ include_paths: Paths to include in sparse checkout.
158
+ git_executable: Path to git executable.
159
+ git_env: Environment variables for git commands.
160
+ """
161
+ # Clone the repository using sparse checkout
188
162
  try:
189
- # Clone the repository using sparse checkout for efficiency
190
- # --depth 1: Only fetch the latest commit (shallow clone)
191
- # --filter=blob:none: Don't download file contents initially
192
- # --sparse: Enable sparse checkout mode
193
- # This combination allows us to clone only the paths we need
194
- try:
195
- logger.debug("Executing git clone with sparse checkout")
196
- subprocess.run(
197
- [
198
- "git",
199
- "clone",
200
- "--depth",
201
- "1",
202
- "--filter=blob:none",
203
- "--sparse",
204
- "--branch",
205
- rhiza_branch,
206
- git_url,
207
- str(tmp_dir),
208
- ],
209
- check=True,
210
- capture_output=True,
211
- text=True,
212
- env=git_env,
213
- )
214
- logger.debug("Git clone completed successfully")
215
- except subprocess.CalledProcessError as e:
216
- logger.error(f"Failed to clone repository: {e}")
217
- if e.stderr:
218
- logger.error(f"Git error: {e.stderr.strip()}")
219
- logger.error(f"Check that the repository '{rhiza_repo}' exists and branch '{rhiza_branch}' is valid")
220
- raise
221
-
222
- # Initialize sparse checkout in cone mode
223
- # Cone mode is more efficient and uses pattern matching
224
- try:
225
- logger.debug("Initializing sparse checkout")
226
- subprocess.run(
227
- ["git", "sparse-checkout", "init", "--cone"],
228
- cwd=tmp_dir,
229
- check=True,
230
- capture_output=True,
231
- text=True,
232
- env=git_env,
233
- )
234
- logger.debug("Sparse checkout initialized")
235
- except subprocess.CalledProcessError as e:
236
- logger.error(f"Failed to initialize sparse checkout: {e}")
237
- if e.stderr:
238
- logger.error(f"Git error: {e.stderr.strip()}")
239
- raise
240
-
241
- # Set sparse checkout paths to only checkout the files/directories we need
242
- # --skip-checks: Don't validate that patterns match existing files
243
- try:
244
- logger.debug(f"Setting sparse checkout paths: {include_paths}")
245
- subprocess.run(
246
- ["git", "sparse-checkout", "set", "--skip-checks", *include_paths],
247
- cwd=tmp_dir,
248
- check=True,
249
- capture_output=True,
250
- text=True,
251
- env=git_env,
252
- )
253
- logger.debug("Sparse checkout paths configured")
254
- except subprocess.CalledProcessError as e:
255
- logger.error(f"Failed to set sparse checkout paths: {e}")
256
- if e.stderr:
257
- logger.error(f"Git error: {e.stderr.strip()}")
258
- raise
259
-
260
- # -----------------------
261
- # Expand include/exclude paths
262
- # -----------------------
263
- # Convert directory paths to individual file paths for precise control
264
- logger.debug("Expanding included paths to individual files")
265
- all_files = __expand_paths(tmp_dir, include_paths)
266
- logger.info(f"Found {len(all_files)} file(s) in included paths")
267
-
268
- # Create a set of excluded files for fast lookup
269
- logger.debug("Expanding excluded paths to individual files")
270
- excluded_files = {f.resolve() for f in __expand_paths(tmp_dir, excluded_paths)}
271
- if excluded_files:
272
- logger.info(f"Excluding {len(excluded_files)} file(s) based on exclude patterns")
273
-
274
- # Filter out excluded files from the list of files to copy
275
- files_to_copy = [f for f in all_files if f.resolve() not in excluded_files]
276
- logger.info(f"Will materialize {len(files_to_copy)} file(s) to target repository")
277
-
278
- # -----------------------
279
- # Copy files into target repo
280
- # -----------------------
281
- # Copy each file from the temporary clone to the target repository
282
- # Preserve file metadata (timestamps, permissions) with copy2
283
- logger.info("Copying files to target repository...")
284
- for src_file in files_to_copy:
285
- # Calculate destination path maintaining relative structure
286
- dst_file = target / src_file.relative_to(tmp_dir)
287
- relative_path = dst_file.relative_to(target)
288
-
289
- # Track this file for .rhiza.history
290
- materialized_files.append(relative_path)
291
-
292
- # Check if file already exists and handle based on force flag
293
- if dst_file.exists() and not force:
294
- logger.warning(f"{relative_path} already exists — use --force to overwrite")
295
- continue
296
-
297
- # Create parent directories if they don't exist
298
- dst_file.parent.mkdir(parents=True, exist_ok=True)
299
-
300
- # Copy file with metadata preservation
301
- shutil.copy2(src_file, dst_file)
302
- logger.success(f"[ADD] {relative_path}")
163
+ logger.debug("Executing git clone with sparse checkout")
164
+ subprocess.run(
165
+ [
166
+ git_executable,
167
+ "clone",
168
+ "--depth",
169
+ "1",
170
+ "--filter=blob:none",
171
+ "--sparse",
172
+ "--branch",
173
+ rhiza_branch,
174
+ git_url,
175
+ str(tmp_dir),
176
+ ],
177
+ check=True,
178
+ capture_output=True,
179
+ text=True,
180
+ env=git_env,
181
+ )
182
+ logger.debug("Git clone completed successfully")
183
+ except subprocess.CalledProcessError as e:
184
+ logger.error(f"Failed to clone repository: {e}")
185
+ if e.stderr:
186
+ logger.error(f"Git error: {e.stderr.strip()}")
187
+ logger.error(f"Check that the repository exists and branch '{rhiza_branch}' is valid")
188
+ raise
189
+
190
+ # Initialize sparse checkout in cone mode
191
+ try:
192
+ logger.debug("Initializing sparse checkout")
193
+ subprocess.run(
194
+ [git_executable, "sparse-checkout", "init", "--cone"],
195
+ cwd=tmp_dir,
196
+ check=True,
197
+ capture_output=True,
198
+ text=True,
199
+ env=git_env,
200
+ )
201
+ logger.debug("Sparse checkout initialized")
202
+ except subprocess.CalledProcessError as e:
203
+ logger.error(f"Failed to initialize sparse checkout: {e}")
204
+ if e.stderr:
205
+ logger.error(f"Git error: {e.stderr.strip()}")
206
+ raise
207
+
208
+ # Set sparse checkout paths
209
+ try:
210
+ logger.debug(f"Setting sparse checkout paths: {include_paths}")
211
+ subprocess.run(
212
+ [git_executable, "sparse-checkout", "set", "--skip-checks", *include_paths],
213
+ cwd=tmp_dir,
214
+ check=True,
215
+ capture_output=True,
216
+ text=True,
217
+ env=git_env,
218
+ )
219
+ logger.debug("Sparse checkout paths configured")
220
+ except subprocess.CalledProcessError as e:
221
+ logger.error(f"Failed to set sparse checkout paths: {e}")
222
+ if e.stderr:
223
+ logger.error(f"Git error: {e.stderr.strip()}")
224
+ raise
225
+
226
+
227
+ def _copy_files_to_target(
228
+ tmp_dir: Path,
229
+ target: Path,
230
+ include_paths: list[str],
231
+ excluded_paths: list[str],
232
+ force: bool,
233
+ ) -> list[Path]:
234
+ """Copy files from temporary clone to target repository.
303
235
 
304
- finally:
305
- # Clean up the temporary directory
306
- logger.debug(f"Cleaning up temporary directory: {tmp_dir}")
307
- shutil.rmtree(tmp_dir)
236
+ Args:
237
+ tmp_dir: Temporary directory with cloned files.
238
+ target: Target repository path.
239
+ include_paths: Paths to include.
240
+ excluded_paths: Paths to exclude.
241
+ force: Whether to overwrite existing files.
308
242
 
309
- # -----------------------
310
- # Warn about workflow files
311
- # -----------------------
312
- # GitHub Actions workflow files require special permissions to modify
313
- # Check if any of the materialized files are workflow files
243
+ Returns:
244
+ List of materialized file paths (relative to target).
245
+ """
246
+ # Expand paths to individual files
247
+ logger.debug("Expanding included paths to individual files")
248
+ all_files = __expand_paths(tmp_dir, include_paths)
249
+ logger.info(f"Found {len(all_files)} file(s) in included paths")
250
+
251
+ # Create set of excluded files
252
+ logger.debug("Expanding excluded paths to individual files")
253
+ excluded_files = {f.resolve() for f in __expand_paths(tmp_dir, excluded_paths)}
254
+ if excluded_files:
255
+ logger.info(f"Excluding {len(excluded_files)} file(s) based on exclude patterns")
256
+
257
+ # Filter out excluded files
258
+ files_to_copy = [f for f in all_files if f.resolve() not in excluded_files]
259
+ logger.info(f"Will materialize {len(files_to_copy)} file(s) to target repository")
260
+
261
+ # Copy files to target repository
262
+ logger.info("Copying files to target repository...")
263
+ materialized_files: list[Path] = []
264
+
265
+ for src_file in files_to_copy:
266
+ # Calculate destination path maintaining relative structure
267
+ dst_file = target / src_file.relative_to(tmp_dir)
268
+ relative_path = dst_file.relative_to(target)
269
+
270
+ # Track this file for history
271
+ materialized_files.append(relative_path)
272
+
273
+ # Check if file exists and handle based on force flag
274
+ if dst_file.exists() and not force:
275
+ logger.warning(f"{relative_path} already exists — use --force to overwrite")
276
+ continue
277
+
278
+ # Create parent directories if needed
279
+ dst_file.parent.mkdir(parents=True, exist_ok=True)
280
+
281
+ # Copy file with metadata preservation
282
+ shutil.copy2(src_file, dst_file)
283
+ logger.success(f"[ADD] {relative_path}")
284
+
285
+ return materialized_files
286
+
287
+
288
+ def _warn_about_workflow_files(materialized_files: list[Path]) -> None:
289
+ """Warn if workflow files were materialized.
290
+
291
+ Args:
292
+ materialized_files: List of materialized file paths.
293
+ """
314
294
  workflow_files = [p for p in materialized_files if p.parts[:2] == (".github", "workflows")]
315
295
 
316
296
  if workflow_files:
@@ -320,16 +300,19 @@ def materialize(target: Path, branch: str, target_branch: str | None, force: boo
320
300
  )
321
301
  logger.info(f"Workflow files affected: {len(workflow_files)}")
322
302
 
323
- # -----------------------
324
- # Clean up orphaned files
325
- # -----------------------
326
- # Read the old history file to find files that are no longer
327
- # part of the current materialization and should be deleted
328
- # Check both new and old locations for backward compatibility
303
+
304
+ def _clean_orphaned_files(target: Path, materialized_files: list[Path]) -> None:
305
+ """Clean up files that are no longer maintained by template.
306
+
307
+ Args:
308
+ target: Target repository path.
309
+ materialized_files: List of currently materialized files.
310
+ """
311
+ # Read old history file
329
312
  new_history_file = target / ".rhiza" / "history"
330
313
  old_history_file = target / ".rhiza.history"
331
314
 
332
- # Prefer new location, but check old location for migration
315
+ # Prefer new location, check old for migration
333
316
  if new_history_file.exists():
334
317
  history_file = new_history_file
335
318
  logger.debug(f"Reading existing history file from new location: {history_file.relative_to(target)}")
@@ -337,25 +320,20 @@ def materialize(target: Path, branch: str, target_branch: str | None, force: boo
337
320
  history_file = old_history_file
338
321
  logger.debug(f"Reading existing history file from old location: {history_file.relative_to(target)}")
339
322
  else:
340
- history_file = new_history_file # Default to new location for creation
341
- logger.debug("No existing history file found, will create new one")
323
+ logger.debug("No existing history file found")
324
+ return
342
325
 
343
326
  previously_tracked_files: set[Path] = set()
327
+ with history_file.open("r", encoding="utf-8") as f:
328
+ for line in f:
329
+ line = line.strip()
330
+ if line and not line.startswith("#"):
331
+ previously_tracked_files.add(Path(line))
344
332
 
345
- if history_file.exists():
346
- with history_file.open("r", encoding="utf-8") as f:
347
- for line in f:
348
- line = line.strip()
349
- # Skip comments and empty lines
350
- if line and not line.startswith("#"):
351
- previously_tracked_files.add(Path(line))
333
+ logger.debug(f"Found {len(previously_tracked_files)} file(s) in previous history")
352
334
 
353
- logger.debug(f"Found {len(previously_tracked_files)} file(s) in previous history")
354
-
355
- # Convert materialized_files list to a set for comparison
335
+ # Find orphaned files
356
336
  currently_materialized_files = set(materialized_files)
357
-
358
- # Find orphaned files (in old history but not in new materialization)
359
337
  orphaned_files = previously_tracked_files - currently_materialized_files
360
338
 
361
339
  if orphaned_files:
@@ -373,15 +351,18 @@ def materialize(target: Path, branch: str, target_branch: str | None, force: boo
373
351
  else:
374
352
  logger.debug("No orphaned files to clean up")
375
353
 
376
- # -----------------------
377
- # Write history file
378
- # -----------------------
379
- # This file tracks which files were materialized by Rhiza
380
- # Useful for understanding which files came from the template
381
- # Always write to new location (.rhiza/history)
382
- history_file = target / ".rhiza" / "history"
383
354
 
384
- # Ensure .rhiza directory exists
355
+ def _write_history_file(target: Path, materialized_files: list[Path], rhiza_repo: str, rhiza_branch: str) -> None:
356
+ """Write history file tracking materialized files.
357
+
358
+ Args:
359
+ target: Target repository path.
360
+ materialized_files: List of materialized files.
361
+ rhiza_repo: Template repository name.
362
+ rhiza_branch: Template branch name.
363
+ """
364
+ # Always write to new location
365
+ history_file = target / ".rhiza" / "history"
385
366
  history_file.parent.mkdir(parents=True, exist_ok=True)
386
367
 
387
368
  logger.debug(f"Writing history file: {history_file.relative_to(target)}")
@@ -392,7 +373,6 @@ def materialize(target: Path, branch: str, target_branch: str | None, force: boo
392
373
  f.write(f"# Template branch: {rhiza_branch}\n")
393
374
  f.write("#\n")
394
375
  f.write("# Files under template control:\n")
395
- # Sort files for consistent ordering
396
376
  for file_path in sorted(materialized_files):
397
377
  f.write(f"{file_path}\n")
398
378
 
@@ -407,8 +387,89 @@ def materialize(target: Path, branch: str, target_branch: str | None, force: boo
407
387
  except Exception as e:
408
388
  logger.warning(f"Could not remove old history file: {e}")
409
389
 
410
- logger.success("Rhiza templates materialized successfully")
411
390
 
391
+ def __expand_paths(base_dir: Path, paths: list[str]) -> list[Path]:
392
+ """Expand files/directories relative to base_dir into a flat list of files.
393
+
394
+ Given a list of paths relative to ``base_dir``, return a flat list of all
395
+ individual files.
396
+
397
+ Args:
398
+ base_dir: The base directory to resolve paths against.
399
+ paths: List of relative path strings (files or directories).
400
+
401
+ Returns:
402
+ A flat list of Path objects representing all individual files found.
403
+ """
404
+ all_files = []
405
+ for p in paths:
406
+ full_path = base_dir / p
407
+ # Check if the path is a regular file
408
+ if full_path.is_file():
409
+ all_files.append(full_path)
410
+ # If it's a directory, recursively find all files within it
411
+ elif full_path.is_dir():
412
+ all_files.extend([f for f in full_path.rglob("*") if f.is_file()])
413
+ else:
414
+ # Path does not exist in the cloned repository - skip it silently
415
+ # This can happen if the template repo doesn't have certain paths
416
+ logger.debug(f"Path not found in template repository: {p}")
417
+ continue
418
+ return all_files
419
+
420
+
421
+ def materialize(target: Path, branch: str, target_branch: str | None, force: bool) -> None:
422
+ """Materialize Rhiza templates into the target repository.
423
+
424
+ This performs a sparse checkout of the template repository and copies the
425
+ selected files into the target repository, recording all files under
426
+ template control in `.rhiza/history`.
427
+
428
+ Args:
429
+ target (Path): Path to the target repository.
430
+ branch (str): The Rhiza template branch to use.
431
+ target_branch (str | None): Optional branch name to create/checkout in
432
+ the target repository.
433
+ force (bool): Whether to overwrite existing files.
434
+ """
435
+ target = target.resolve()
436
+ logger.info(f"Target repository: {target}")
437
+ logger.info(f"Rhiza branch: {branch}")
438
+
439
+ # Setup git environment
440
+ git_executable = get_git_executable()
441
+ logger.debug(f"Using git executable: {git_executable}")
442
+ git_env = os.environ.copy()
443
+ git_env["GIT_TERMINAL_PROMPT"] = "0"
444
+
445
+ # Handle target branch if specified
446
+ _handle_target_branch(target, target_branch, git_executable, git_env)
447
+
448
+ # Validate and load template configuration
449
+ template, rhiza_repo, rhiza_branch, include_paths, excluded_paths = _validate_and_load_template(target, branch)
450
+ rhiza_host = template.template_host or "github"
451
+
452
+ # Construct git URL
453
+ git_url = _construct_git_url(rhiza_repo, rhiza_host)
454
+
455
+ # Clone template repository
456
+ tmp_dir = Path(tempfile.mkdtemp())
457
+ logger.info(f"Cloning {rhiza_repo}@{rhiza_branch} from {rhiza_host} into temporary directory")
458
+ logger.debug(f"Temporary directory: {tmp_dir}")
459
+
460
+ try:
461
+ _clone_template_repository(tmp_dir, git_url, rhiza_branch, include_paths, git_executable, git_env)
462
+ materialized_files = _copy_files_to_target(tmp_dir, target, include_paths, excluded_paths, force)
463
+ finally:
464
+ logger.debug(f"Cleaning up temporary directory: {tmp_dir}")
465
+ shutil.rmtree(tmp_dir)
466
+
467
+ # Post-processing
468
+ _warn_about_workflow_files(materialized_files)
469
+ _clean_orphaned_files(target, materialized_files)
470
+ _write_history_file(target, materialized_files, rhiza_repo, rhiza_branch)
471
+
472
+ logger.success("Rhiza templates materialized successfully")
412
473
  logger.info(
413
474
  "Next steps:\n"
414
475
  " 1. Review changes:\n"