rhiza 0.8.3__py3-none-any.whl → 0.8.5__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.
- rhiza/__main__.py +22 -0
- rhiza/commands/init.py +213 -162
- rhiza/commands/materialize.py +328 -267
- rhiza/commands/migrate.py +99 -46
- rhiza/commands/uninstall.py +136 -57
- rhiza/commands/validate.py +182 -71
- rhiza/subprocess_utils.py +26 -0
- {rhiza-0.8.3.dist-info → rhiza-0.8.5.dist-info}/METADATA +8 -10
- rhiza-0.8.5.dist-info/RECORD +20 -0
- rhiza-0.8.3.dist-info/RECORD +0 -19
- {rhiza-0.8.3.dist-info → rhiza-0.8.5.dist-info}/WHEEL +0 -0
- {rhiza-0.8.3.dist-info → rhiza-0.8.5.dist-info}/entry_points.txt +0 -0
- {rhiza-0.8.3.dist-info → rhiza-0.8.5.dist-info}/licenses/LICENSE +0 -0
rhiza/commands/materialize.py
CHANGED
|
@@ -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
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
67
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
186
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
|
|
311
|
-
|
|
312
|
-
#
|
|
313
|
-
|
|
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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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,
|
|
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
|
-
|
|
341
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"
|