path-sync 0.2.0__py3-none-any.whl → 0.3.0__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.
- path_sync/__init__.py +10 -1
- path_sync/__main__.py +3 -3
- path_sync/config.py +14 -0
- path_sync/copy.py +4 -0
- path_sync/sections.py +61 -104
- {path_sync-0.2.0.dist-info → path_sync-0.3.0.dist-info}/METADATA +11 -5
- path_sync-0.3.0.dist-info/RECORD +10 -0
- path_sync-0.3.0.dist-info/licenses/LICENSE +21 -0
- path_sync/cmd_boot.py +0 -89
- path_sync/cmd_copy.py +0 -522
- path_sync/cmd_copy_test.py +0 -159
- path_sync/cmd_validate.py +0 -51
- path_sync/conftest.py +0 -15
- path_sync/file_utils.py +0 -7
- path_sync/git_ops.py +0 -188
- path_sync/header.py +0 -80
- path_sync/header_test.py +0 -41
- path_sync/models.py +0 -142
- path_sync/models_test.py +0 -69
- path_sync/sections_test.py +0 -128
- path_sync/typer_app.py +0 -8
- path_sync/validation.py +0 -84
- path_sync/validation_test.py +0 -114
- path_sync/yaml_utils.py +0 -19
- path_sync-0.2.0.dist-info/RECORD +0 -23
- {path_sync-0.2.0.dist-info → path_sync-0.3.0.dist-info}/WHEEL +0 -0
- {path_sync-0.2.0.dist-info → path_sync-0.3.0.dist-info}/entry_points.txt +0 -0
path_sync/cmd_copy.py
DELETED
|
@@ -1,522 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import glob
|
|
4
|
-
import logging
|
|
5
|
-
import tempfile
|
|
6
|
-
from contextlib import contextmanager
|
|
7
|
-
from dataclasses import dataclass, field
|
|
8
|
-
from pathlib import Path
|
|
9
|
-
|
|
10
|
-
import typer
|
|
11
|
-
|
|
12
|
-
from path_sync import git_ops, header, sections
|
|
13
|
-
from path_sync.file_utils import ensure_parents_write_text
|
|
14
|
-
from path_sync.models import (
|
|
15
|
-
LOG_FORMAT,
|
|
16
|
-
Destination,
|
|
17
|
-
PathMapping,
|
|
18
|
-
SrcConfig,
|
|
19
|
-
find_repo_root,
|
|
20
|
-
resolve_config_path,
|
|
21
|
-
)
|
|
22
|
-
from path_sync.typer_app import app
|
|
23
|
-
from path_sync.yaml_utils import load_yaml_model
|
|
24
|
-
|
|
25
|
-
logger = logging.getLogger(__name__)
|
|
26
|
-
|
|
27
|
-
EXIT_NO_CHANGES = 0
|
|
28
|
-
EXIT_CHANGES = 1
|
|
29
|
-
EXIT_ERROR = 2
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
def _prompt(message: str, no_prompt: bool) -> bool:
|
|
33
|
-
if no_prompt:
|
|
34
|
-
return True
|
|
35
|
-
try:
|
|
36
|
-
response = input(f"{message} [y/n]: ").strip().lower()
|
|
37
|
-
return response == "y"
|
|
38
|
-
except (EOFError, KeyboardInterrupt):
|
|
39
|
-
return False
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
@dataclass
|
|
43
|
-
class SyncResult:
|
|
44
|
-
content_changes: int = 0
|
|
45
|
-
orphans_deleted: int = 0
|
|
46
|
-
synced_paths: set[Path] = field(default_factory=set)
|
|
47
|
-
|
|
48
|
-
@property
|
|
49
|
-
def total(self) -> int:
|
|
50
|
-
return self.content_changes + self.orphans_deleted
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
@contextmanager
|
|
54
|
-
def capture_sync_log(dest_name: str):
|
|
55
|
-
with tempfile.TemporaryDirectory(prefix="path-sync-") as tmpdir:
|
|
56
|
-
log_path = Path(tmpdir) / f"{dest_name}.log"
|
|
57
|
-
file_handler = logging.FileHandler(log_path, mode="w")
|
|
58
|
-
file_handler.setLevel(logging.INFO)
|
|
59
|
-
file_handler.setFormatter(logging.Formatter(LOG_FORMAT))
|
|
60
|
-
root_logger = logging.getLogger("path_sync")
|
|
61
|
-
root_logger.addHandler(file_handler)
|
|
62
|
-
try:
|
|
63
|
-
yield log_path
|
|
64
|
-
finally:
|
|
65
|
-
file_handler.close()
|
|
66
|
-
root_logger.removeHandler(file_handler)
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
@dataclass
|
|
70
|
-
class CopyOptions:
|
|
71
|
-
dry_run: bool = False
|
|
72
|
-
force_overwrite: bool = False
|
|
73
|
-
no_checkout: bool = False
|
|
74
|
-
checkout_from_default: bool = False
|
|
75
|
-
local: bool = False
|
|
76
|
-
no_prompt: bool = False
|
|
77
|
-
no_pr: bool = False
|
|
78
|
-
skip_orphan_cleanup: bool = False
|
|
79
|
-
pr_title: str = ""
|
|
80
|
-
pr_labels: str = ""
|
|
81
|
-
pr_reviewers: str = ""
|
|
82
|
-
pr_assignees: str = ""
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
@app.command()
|
|
86
|
-
def copy(
|
|
87
|
-
name: str = typer.Option(..., "-n", "--name", help="Config name"),
|
|
88
|
-
dest_filter: str = typer.Option(
|
|
89
|
-
"", "-d", "--dest", help="Filter destinations (comma-separated)"
|
|
90
|
-
),
|
|
91
|
-
dry_run: bool = typer.Option(False, "--dry-run", help="Preview without writing"),
|
|
92
|
-
force_overwrite: bool = typer.Option(
|
|
93
|
-
False,
|
|
94
|
-
"--force-overwrite",
|
|
95
|
-
help="Overwrite files even if header removed (opted out)",
|
|
96
|
-
),
|
|
97
|
-
detailed_exit_code: bool = typer.Option(
|
|
98
|
-
False,
|
|
99
|
-
"--detailed-exit-code",
|
|
100
|
-
help="Exit 0=no changes, 1=changes, 2=error",
|
|
101
|
-
),
|
|
102
|
-
no_checkout: bool = typer.Option(
|
|
103
|
-
False,
|
|
104
|
-
"--no-checkout",
|
|
105
|
-
help="Skip branch switching before sync",
|
|
106
|
-
),
|
|
107
|
-
checkout_from_default: bool = typer.Option(
|
|
108
|
-
False,
|
|
109
|
-
"--checkout-from-default",
|
|
110
|
-
help="Reset to origin/default before sync (for CI)",
|
|
111
|
-
),
|
|
112
|
-
local: bool = typer.Option(
|
|
113
|
-
False,
|
|
114
|
-
"--local",
|
|
115
|
-
help="No git operations after sync (no commit/push/PR)",
|
|
116
|
-
),
|
|
117
|
-
no_prompt: bool = typer.Option(
|
|
118
|
-
False,
|
|
119
|
-
"-y",
|
|
120
|
-
"--no-prompt",
|
|
121
|
-
help="Skip confirmations (for CI)",
|
|
122
|
-
),
|
|
123
|
-
no_pr: bool = typer.Option(
|
|
124
|
-
False,
|
|
125
|
-
"--no-pr",
|
|
126
|
-
help="Push but skip PR creation",
|
|
127
|
-
),
|
|
128
|
-
pr_title: str = typer.Option(
|
|
129
|
-
"",
|
|
130
|
-
"--pr-title",
|
|
131
|
-
help="Override PR title (supports {name}, {dest_name})",
|
|
132
|
-
),
|
|
133
|
-
pr_labels: str = typer.Option(
|
|
134
|
-
"",
|
|
135
|
-
"--pr-labels",
|
|
136
|
-
help="Comma-separated PR labels",
|
|
137
|
-
),
|
|
138
|
-
pr_reviewers: str = typer.Option(
|
|
139
|
-
"",
|
|
140
|
-
"--pr-reviewers",
|
|
141
|
-
help="Comma-separated PR reviewers",
|
|
142
|
-
),
|
|
143
|
-
pr_assignees: str = typer.Option(
|
|
144
|
-
"",
|
|
145
|
-
"--pr-assignees",
|
|
146
|
-
help="Comma-separated PR assignees",
|
|
147
|
-
),
|
|
148
|
-
skip_orphan_cleanup: bool = typer.Option(
|
|
149
|
-
False,
|
|
150
|
-
"--skip-orphan-cleanup",
|
|
151
|
-
help="Skip deletion of orphaned synced files",
|
|
152
|
-
),
|
|
153
|
-
) -> None:
|
|
154
|
-
"""Copy files from SRC to DEST repositories."""
|
|
155
|
-
src_root = find_repo_root(Path.cwd())
|
|
156
|
-
config_path = resolve_config_path(src_root, name)
|
|
157
|
-
|
|
158
|
-
if not config_path.exists():
|
|
159
|
-
logger.error(f"Config not found: {config_path}")
|
|
160
|
-
raise typer.Exit(EXIT_ERROR if detailed_exit_code else 1)
|
|
161
|
-
|
|
162
|
-
config = load_yaml_model(config_path, SrcConfig)
|
|
163
|
-
src_repo = git_ops.get_repo(src_root)
|
|
164
|
-
current_sha = git_ops.get_current_sha(src_repo)
|
|
165
|
-
src_repo_url = git_ops.get_remote_url(src_repo, config.git_remote)
|
|
166
|
-
|
|
167
|
-
opts = CopyOptions(
|
|
168
|
-
dry_run=dry_run,
|
|
169
|
-
force_overwrite=force_overwrite,
|
|
170
|
-
no_checkout=no_checkout,
|
|
171
|
-
checkout_from_default=checkout_from_default,
|
|
172
|
-
local=local,
|
|
173
|
-
no_prompt=no_prompt,
|
|
174
|
-
no_pr=no_pr,
|
|
175
|
-
skip_orphan_cleanup=skip_orphan_cleanup,
|
|
176
|
-
pr_title=pr_title or config.pr_defaults.title,
|
|
177
|
-
pr_labels=pr_labels or ",".join(config.pr_defaults.labels),
|
|
178
|
-
pr_reviewers=pr_reviewers or ",".join(config.pr_defaults.reviewers),
|
|
179
|
-
pr_assignees=pr_assignees or ",".join(config.pr_defaults.assignees),
|
|
180
|
-
)
|
|
181
|
-
|
|
182
|
-
destinations = config.destinations
|
|
183
|
-
if dest_filter:
|
|
184
|
-
filter_names = [n.strip() for n in dest_filter.split(",")]
|
|
185
|
-
destinations = [d for d in destinations if d.name in filter_names]
|
|
186
|
-
|
|
187
|
-
total_changes = 0
|
|
188
|
-
for dest in destinations:
|
|
189
|
-
try:
|
|
190
|
-
with capture_sync_log(dest.name) as log_path:
|
|
191
|
-
changes = _sync_destination(
|
|
192
|
-
config, dest, src_root, current_sha, src_repo_url, opts, log_path
|
|
193
|
-
)
|
|
194
|
-
total_changes += changes
|
|
195
|
-
except Exception as e:
|
|
196
|
-
logger.error(f"Failed to sync {dest.name}: {e}")
|
|
197
|
-
if detailed_exit_code:
|
|
198
|
-
raise typer.Exit(EXIT_ERROR)
|
|
199
|
-
raise
|
|
200
|
-
|
|
201
|
-
if detailed_exit_code:
|
|
202
|
-
raise typer.Exit(EXIT_CHANGES if total_changes > 0 else EXIT_NO_CHANGES)
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
def _sync_destination(
|
|
206
|
-
config: SrcConfig,
|
|
207
|
-
dest: Destination,
|
|
208
|
-
src_root: Path,
|
|
209
|
-
current_sha: str,
|
|
210
|
-
src_repo_url: str,
|
|
211
|
-
opts: CopyOptions,
|
|
212
|
-
log_path: Path,
|
|
213
|
-
) -> int:
|
|
214
|
-
dest_root = (src_root / dest.dest_path_relative).resolve()
|
|
215
|
-
|
|
216
|
-
if opts.dry_run and not dest_root.exists():
|
|
217
|
-
raise ValueError(
|
|
218
|
-
f"Destination repo not found: {dest_root}. "
|
|
219
|
-
"Clone it first or run without --dry-run."
|
|
220
|
-
)
|
|
221
|
-
|
|
222
|
-
dest_repo = _ensure_dest_repo(dest, dest_root, opts.dry_run)
|
|
223
|
-
|
|
224
|
-
should_checkout = (
|
|
225
|
-
not opts.no_checkout
|
|
226
|
-
and not opts.dry_run
|
|
227
|
-
and _prompt(f"Switch to {dest.copy_branch}?", opts.no_prompt)
|
|
228
|
-
)
|
|
229
|
-
if should_checkout:
|
|
230
|
-
git_ops.prepare_copy_branch(
|
|
231
|
-
repo=dest_repo,
|
|
232
|
-
default_branch=dest.default_branch,
|
|
233
|
-
copy_branch=dest.copy_branch,
|
|
234
|
-
from_default=opts.checkout_from_default,
|
|
235
|
-
)
|
|
236
|
-
|
|
237
|
-
result = _sync_paths(config, dest, src_root, dest_root, opts)
|
|
238
|
-
_print_sync_summary(dest, result)
|
|
239
|
-
|
|
240
|
-
if result.total == 0:
|
|
241
|
-
logger.info(f"{dest.name}: No changes")
|
|
242
|
-
return 0
|
|
243
|
-
|
|
244
|
-
if opts.dry_run:
|
|
245
|
-
return result.total
|
|
246
|
-
|
|
247
|
-
_commit_and_pr(
|
|
248
|
-
config, dest_repo, dest_root, dest, current_sha, src_repo_url, opts, log_path
|
|
249
|
-
)
|
|
250
|
-
return result.total
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
def _print_sync_summary(dest: Destination, result: SyncResult) -> None:
|
|
254
|
-
typer.echo(f"\nSyncing to {dest.name}...", err=True)
|
|
255
|
-
if result.content_changes > 0:
|
|
256
|
-
typer.echo(f" [{result.content_changes} files synced]", err=True)
|
|
257
|
-
if result.orphans_deleted > 0:
|
|
258
|
-
typer.echo(f" [-] {result.orphans_deleted} orphans deleted", err=True)
|
|
259
|
-
if result.total > 0:
|
|
260
|
-
typer.echo(f"\n{result.total} changes ready.", err=True)
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
def _ensure_dest_repo(dest: Destination, dest_root: Path, dry_run: bool):
|
|
264
|
-
if not dest_root.exists():
|
|
265
|
-
if dry_run:
|
|
266
|
-
raise ValueError(
|
|
267
|
-
f"Destination repo not found: {dest_root}. "
|
|
268
|
-
"Clone it first or run without --dry-run."
|
|
269
|
-
)
|
|
270
|
-
if not dest.repo_url:
|
|
271
|
-
raise ValueError(f"Dest {dest.name} not found and no repo_url configured")
|
|
272
|
-
git_ops.clone_repo(dest.repo_url, dest_root)
|
|
273
|
-
return git_ops.get_repo(dest_root)
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
def _sync_paths(
|
|
277
|
-
config: SrcConfig,
|
|
278
|
-
dest: Destination,
|
|
279
|
-
src_root: Path,
|
|
280
|
-
dest_root: Path,
|
|
281
|
-
opts: CopyOptions,
|
|
282
|
-
) -> SyncResult:
|
|
283
|
-
result = SyncResult()
|
|
284
|
-
for mapping in config.paths:
|
|
285
|
-
changes, paths = _sync_path(
|
|
286
|
-
mapping,
|
|
287
|
-
src_root,
|
|
288
|
-
dest_root,
|
|
289
|
-
dest,
|
|
290
|
-
config.name,
|
|
291
|
-
opts.dry_run,
|
|
292
|
-
opts.force_overwrite,
|
|
293
|
-
)
|
|
294
|
-
result.content_changes += changes
|
|
295
|
-
result.synced_paths.update(paths)
|
|
296
|
-
|
|
297
|
-
if not opts.skip_orphan_cleanup:
|
|
298
|
-
result.orphans_deleted = _cleanup_orphans(
|
|
299
|
-
dest_root, config.name, result.synced_paths, opts.dry_run
|
|
300
|
-
)
|
|
301
|
-
return result
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
def _sync_path(
|
|
305
|
-
mapping: PathMapping,
|
|
306
|
-
src_root: Path,
|
|
307
|
-
dest_root: Path,
|
|
308
|
-
dest: Destination,
|
|
309
|
-
config_name: str,
|
|
310
|
-
dry_run: bool,
|
|
311
|
-
force_overwrite: bool,
|
|
312
|
-
) -> tuple[int, set[Path]]:
|
|
313
|
-
src_pattern = src_root / mapping.src_path
|
|
314
|
-
changes = 0
|
|
315
|
-
synced: set[Path] = set()
|
|
316
|
-
|
|
317
|
-
if "*" in mapping.src_path:
|
|
318
|
-
glob_prefix = mapping.src_path.split("*")[0].rstrip("/")
|
|
319
|
-
dest_base = mapping.dest_path or glob_prefix
|
|
320
|
-
matches = glob.glob(str(src_pattern), recursive=True)
|
|
321
|
-
if not matches:
|
|
322
|
-
logger.warning(f"Glob matched no files: {mapping.src_path}")
|
|
323
|
-
for src_file in matches:
|
|
324
|
-
src_path = Path(src_file)
|
|
325
|
-
if src_path.is_file():
|
|
326
|
-
rel = src_path.relative_to(src_root / glob_prefix)
|
|
327
|
-
dest_path = dest_root / dest_base / rel
|
|
328
|
-
dest_key = str(Path(dest_base) / rel)
|
|
329
|
-
changes += _copy_with_header(
|
|
330
|
-
src_path,
|
|
331
|
-
dest_path,
|
|
332
|
-
dest,
|
|
333
|
-
dest_key,
|
|
334
|
-
config_name,
|
|
335
|
-
dry_run,
|
|
336
|
-
force_overwrite,
|
|
337
|
-
)
|
|
338
|
-
synced.add(dest_path)
|
|
339
|
-
elif src_pattern.is_dir():
|
|
340
|
-
dest_base = mapping.resolved_dest_path()
|
|
341
|
-
for src_file in src_pattern.rglob("*"):
|
|
342
|
-
if src_file.is_file():
|
|
343
|
-
rel = src_file.relative_to(src_pattern)
|
|
344
|
-
dest_path = dest_root / dest_base / rel
|
|
345
|
-
dest_key = str(Path(dest_base) / rel)
|
|
346
|
-
changes += _copy_with_header(
|
|
347
|
-
src_file,
|
|
348
|
-
dest_path,
|
|
349
|
-
dest,
|
|
350
|
-
dest_key,
|
|
351
|
-
config_name,
|
|
352
|
-
dry_run,
|
|
353
|
-
force_overwrite,
|
|
354
|
-
)
|
|
355
|
-
synced.add(dest_path)
|
|
356
|
-
elif src_pattern.is_file():
|
|
357
|
-
dest_base = mapping.resolved_dest_path()
|
|
358
|
-
dest_path = dest_root / dest_base
|
|
359
|
-
changes += _copy_with_header(
|
|
360
|
-
src_pattern,
|
|
361
|
-
dest_path,
|
|
362
|
-
dest,
|
|
363
|
-
dest_base,
|
|
364
|
-
config_name,
|
|
365
|
-
dry_run,
|
|
366
|
-
force_overwrite,
|
|
367
|
-
)
|
|
368
|
-
synced.add(dest_path)
|
|
369
|
-
else:
|
|
370
|
-
logger.warning(f"Source not found: {mapping.src_path}")
|
|
371
|
-
|
|
372
|
-
return changes, synced
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
def _copy_with_header(
|
|
376
|
-
src: Path,
|
|
377
|
-
dest_path: Path,
|
|
378
|
-
dest: Destination,
|
|
379
|
-
dest_key: str,
|
|
380
|
-
config_name: str,
|
|
381
|
-
dry_run: bool,
|
|
382
|
-
force_overwrite: bool = False,
|
|
383
|
-
) -> int:
|
|
384
|
-
src_content = src.read_text()
|
|
385
|
-
skip_list = dest.skip_sections.get(dest_key, [])
|
|
386
|
-
|
|
387
|
-
if sections.has_sections(src_content):
|
|
388
|
-
return _copy_with_sections(
|
|
389
|
-
src_content, dest_path, skip_list, config_name, dry_run, force_overwrite
|
|
390
|
-
)
|
|
391
|
-
|
|
392
|
-
if dest_path.exists():
|
|
393
|
-
existing = dest_path.read_text()
|
|
394
|
-
if not header.has_header(existing) and not force_overwrite:
|
|
395
|
-
logger.info(f"Skipping {dest_path} (header removed - opted out)")
|
|
396
|
-
return 0
|
|
397
|
-
if header.remove_header(existing) == src_content:
|
|
398
|
-
return 0
|
|
399
|
-
|
|
400
|
-
new_content = header.add_header(src_content, dest_path.suffix, config_name)
|
|
401
|
-
if dry_run:
|
|
402
|
-
logger.info(f"[DRY RUN] Would write: {dest_path}")
|
|
403
|
-
return 1
|
|
404
|
-
|
|
405
|
-
ensure_parents_write_text(dest_path, new_content)
|
|
406
|
-
logger.info(f"Wrote: {dest_path}")
|
|
407
|
-
return 1
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
def _copy_with_sections(
|
|
411
|
-
src_content: str,
|
|
412
|
-
dest_path: Path,
|
|
413
|
-
skip_list: list[str],
|
|
414
|
-
config_name: str,
|
|
415
|
-
dry_run: bool,
|
|
416
|
-
force_overwrite: bool,
|
|
417
|
-
) -> int:
|
|
418
|
-
src_sections = sections.extract_sections(src_content)
|
|
419
|
-
|
|
420
|
-
if dest_path.exists():
|
|
421
|
-
existing = dest_path.read_text()
|
|
422
|
-
if not header.has_header(existing) and not force_overwrite:
|
|
423
|
-
logger.info(f"Skipping {dest_path} (header removed - opted out)")
|
|
424
|
-
return 0
|
|
425
|
-
dest_body = header.remove_header(existing)
|
|
426
|
-
new_body = sections.replace_sections(dest_body, src_sections, skip_list)
|
|
427
|
-
else:
|
|
428
|
-
new_body = src_content
|
|
429
|
-
|
|
430
|
-
new_content = header.add_header(new_body, dest_path.suffix, config_name)
|
|
431
|
-
|
|
432
|
-
if dest_path.exists() and dest_path.read_text() == new_content:
|
|
433
|
-
return 0
|
|
434
|
-
|
|
435
|
-
if dry_run:
|
|
436
|
-
logger.info(f"[DRY RUN] Would write: {dest_path}")
|
|
437
|
-
return 1
|
|
438
|
-
|
|
439
|
-
ensure_parents_write_text(dest_path, new_content)
|
|
440
|
-
logger.info(f"Wrote: {dest_path}")
|
|
441
|
-
return 1
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
def _cleanup_orphans(
|
|
445
|
-
dest_root: Path,
|
|
446
|
-
config_name: str,
|
|
447
|
-
synced_paths: set[Path],
|
|
448
|
-
dry_run: bool,
|
|
449
|
-
) -> int:
|
|
450
|
-
deleted = 0
|
|
451
|
-
for path in _find_files_with_config(dest_root, config_name):
|
|
452
|
-
if path not in synced_paths:
|
|
453
|
-
if dry_run:
|
|
454
|
-
logger.info(f"[DRY RUN] Would delete orphan: {path}")
|
|
455
|
-
else:
|
|
456
|
-
path.unlink()
|
|
457
|
-
logger.info(f"Deleted orphan: {path}")
|
|
458
|
-
deleted += 1
|
|
459
|
-
return deleted
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
def _find_files_with_config(dest_root: Path, config_name: str) -> list[Path]:
|
|
463
|
-
result = []
|
|
464
|
-
for ext in header.COMMENT_PREFIXES:
|
|
465
|
-
for path in dest_root.rglob(f"*{ext}"):
|
|
466
|
-
if ".git" in path.parts:
|
|
467
|
-
continue
|
|
468
|
-
if header.file_get_config_name(path) == config_name:
|
|
469
|
-
result.append(path)
|
|
470
|
-
return result
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
def _commit_and_pr(
|
|
474
|
-
config: SrcConfig,
|
|
475
|
-
repo,
|
|
476
|
-
dest_root: Path,
|
|
477
|
-
dest: Destination,
|
|
478
|
-
sha: str,
|
|
479
|
-
src_repo_url: str,
|
|
480
|
-
opts: CopyOptions,
|
|
481
|
-
log_path: Path,
|
|
482
|
-
) -> None:
|
|
483
|
-
if opts.local:
|
|
484
|
-
logger.info("Local mode: skipping commit/push/PR")
|
|
485
|
-
return
|
|
486
|
-
|
|
487
|
-
if not _prompt("Commit changes?", opts.no_prompt):
|
|
488
|
-
return
|
|
489
|
-
|
|
490
|
-
commit_msg = f"chore: sync {config.name} from {sha[:8]}"
|
|
491
|
-
git_ops.commit_changes(repo, commit_msg)
|
|
492
|
-
typer.echo(f" Committed: {commit_msg}", err=True)
|
|
493
|
-
|
|
494
|
-
if not _prompt("Push to origin?", opts.no_prompt):
|
|
495
|
-
return
|
|
496
|
-
|
|
497
|
-
git_ops.push_branch(repo, dest.copy_branch, force=True)
|
|
498
|
-
typer.echo(f" Pushed: {dest.copy_branch} (force)", err=True)
|
|
499
|
-
|
|
500
|
-
if opts.no_pr or not _prompt("Create PR?", opts.no_prompt):
|
|
501
|
-
return
|
|
502
|
-
|
|
503
|
-
sync_log = log_path.read_text() if log_path.exists() else ""
|
|
504
|
-
pr_body = config.pr_defaults.format_body(
|
|
505
|
-
src_repo_url=src_repo_url,
|
|
506
|
-
src_sha=sha,
|
|
507
|
-
sync_log=sync_log,
|
|
508
|
-
dest_name=dest.name,
|
|
509
|
-
)
|
|
510
|
-
|
|
511
|
-
title = opts.pr_title.format(name=config.name, dest_name=dest.name)
|
|
512
|
-
pr_url = git_ops.create_or_update_pr(
|
|
513
|
-
dest_root,
|
|
514
|
-
dest.copy_branch,
|
|
515
|
-
title,
|
|
516
|
-
pr_body,
|
|
517
|
-
opts.pr_labels.split(",") if opts.pr_labels else None,
|
|
518
|
-
opts.pr_reviewers.split(",") if opts.pr_reviewers else None,
|
|
519
|
-
opts.pr_assignees.split(",") if opts.pr_assignees else None,
|
|
520
|
-
)
|
|
521
|
-
if pr_url:
|
|
522
|
-
typer.echo(f" Created PR: {pr_url}", err=True)
|
path_sync/cmd_copy_test.py
DELETED
|
@@ -1,159 +0,0 @@
|
|
|
1
|
-
import pytest
|
|
2
|
-
|
|
3
|
-
from path_sync.cmd_copy import (
|
|
4
|
-
CopyOptions,
|
|
5
|
-
_cleanup_orphans,
|
|
6
|
-
_ensure_dest_repo,
|
|
7
|
-
_sync_path,
|
|
8
|
-
)
|
|
9
|
-
from path_sync.header import add_header, has_header
|
|
10
|
-
from path_sync.models import Destination, PathMapping
|
|
11
|
-
|
|
12
|
-
CONFIG_NAME = "test-config"
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
def _make_dest(**kwargs) -> Destination:
|
|
16
|
-
defaults = {"name": "test", "dest_path_relative": "."}
|
|
17
|
-
return Destination(**(defaults | kwargs)) # pyright: ignore[reportArgumentType]
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def test_sync_single_file(tmp_path):
|
|
21
|
-
src_root = tmp_path / "src"
|
|
22
|
-
dest_root = tmp_path / "dest"
|
|
23
|
-
src_root.mkdir()
|
|
24
|
-
dest_root.mkdir()
|
|
25
|
-
|
|
26
|
-
(src_root / "file.py").write_text("content")
|
|
27
|
-
|
|
28
|
-
mapping = PathMapping(src_path="file.py", dest_path="out.py")
|
|
29
|
-
changes, synced = _sync_path(
|
|
30
|
-
mapping, src_root, dest_root, _make_dest(), CONFIG_NAME, False, False
|
|
31
|
-
)
|
|
32
|
-
|
|
33
|
-
assert changes == 1
|
|
34
|
-
assert dest_root / "out.py" in synced
|
|
35
|
-
result = (dest_root / "out.py").read_text()
|
|
36
|
-
assert has_header(result)
|
|
37
|
-
assert f"path-sync copy -n {CONFIG_NAME}" in result
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
def test_sync_skips_opted_out_file(tmp_path):
|
|
41
|
-
src_root = tmp_path / "src"
|
|
42
|
-
dest_root = tmp_path / "dest"
|
|
43
|
-
src_root.mkdir()
|
|
44
|
-
dest_root.mkdir()
|
|
45
|
-
|
|
46
|
-
(src_root / "file.py").write_text("new content")
|
|
47
|
-
(dest_root / "file.py").write_text("local content without header")
|
|
48
|
-
|
|
49
|
-
mapping = PathMapping(src_path="file.py")
|
|
50
|
-
changes, _ = _sync_path(
|
|
51
|
-
mapping, src_root, dest_root, _make_dest(), CONFIG_NAME, False, False
|
|
52
|
-
)
|
|
53
|
-
|
|
54
|
-
assert changes == 0
|
|
55
|
-
assert (dest_root / "file.py").read_text() == "local content without header"
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
def test_cleanup_orphans(tmp_path):
|
|
59
|
-
dest_root = tmp_path / "dest"
|
|
60
|
-
dest_root.mkdir()
|
|
61
|
-
|
|
62
|
-
# File with matching config header - will be orphaned
|
|
63
|
-
orphan = dest_root / "orphan.py"
|
|
64
|
-
orphan.write_text(add_header("orphan content", ".py", CONFIG_NAME))
|
|
65
|
-
|
|
66
|
-
# File with different config - should not be deleted
|
|
67
|
-
other = dest_root / "other.py"
|
|
68
|
-
other.write_text(add_header("other content", ".py", "other-config"))
|
|
69
|
-
|
|
70
|
-
synced: set = set() # No files synced
|
|
71
|
-
deleted = _cleanup_orphans(dest_root, CONFIG_NAME, synced, dry_run=False)
|
|
72
|
-
|
|
73
|
-
assert deleted == 1
|
|
74
|
-
assert not orphan.exists()
|
|
75
|
-
assert other.exists()
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
def test_sync_with_sections_replaces_managed(tmp_path):
|
|
79
|
-
src_root = tmp_path / "src"
|
|
80
|
-
dest_root = tmp_path / "dest"
|
|
81
|
-
src_root.mkdir()
|
|
82
|
-
dest_root.mkdir()
|
|
83
|
-
|
|
84
|
-
src_content = """\
|
|
85
|
-
# === DO_NOT_EDIT: path-sync standard ===
|
|
86
|
-
new recipe
|
|
87
|
-
# === OK_EDIT ==="""
|
|
88
|
-
(src_root / "file.sh").write_text(src_content)
|
|
89
|
-
|
|
90
|
-
dest_content = add_header(
|
|
91
|
-
"""\
|
|
92
|
-
# === DO_NOT_EDIT: path-sync standard ===
|
|
93
|
-
old recipe
|
|
94
|
-
# === OK_EDIT ===
|
|
95
|
-
# my custom stuff""",
|
|
96
|
-
".sh",
|
|
97
|
-
CONFIG_NAME,
|
|
98
|
-
)
|
|
99
|
-
(dest_root / "file.sh").write_text(dest_content)
|
|
100
|
-
|
|
101
|
-
mapping = PathMapping(src_path="file.sh")
|
|
102
|
-
changes, _ = _sync_path(
|
|
103
|
-
mapping, src_root, dest_root, _make_dest(), CONFIG_NAME, False, False
|
|
104
|
-
)
|
|
105
|
-
|
|
106
|
-
assert changes == 1
|
|
107
|
-
result = (dest_root / "file.sh").read_text()
|
|
108
|
-
assert "new recipe" in result
|
|
109
|
-
assert "old recipe" not in result
|
|
110
|
-
assert "# my custom stuff" in result
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
def test_sync_with_sections_skip(tmp_path):
|
|
114
|
-
src_root = tmp_path / "src"
|
|
115
|
-
dest_root = tmp_path / "dest"
|
|
116
|
-
src_root.mkdir()
|
|
117
|
-
dest_root.mkdir()
|
|
118
|
-
|
|
119
|
-
src_content = """\
|
|
120
|
-
# === DO_NOT_EDIT: path-sync standard ===
|
|
121
|
-
source
|
|
122
|
-
# === OK_EDIT ==="""
|
|
123
|
-
(src_root / "file.sh").write_text(src_content)
|
|
124
|
-
|
|
125
|
-
dest_content = add_header(
|
|
126
|
-
"""\
|
|
127
|
-
# === DO_NOT_EDIT: path-sync standard ===
|
|
128
|
-
keep this
|
|
129
|
-
# === OK_EDIT ===""",
|
|
130
|
-
".sh",
|
|
131
|
-
CONFIG_NAME,
|
|
132
|
-
)
|
|
133
|
-
(dest_root / "file.sh").write_text(dest_content)
|
|
134
|
-
|
|
135
|
-
dest = _make_dest(skip_sections={"file.sh": ["standard"]})
|
|
136
|
-
mapping = PathMapping(src_path="file.sh")
|
|
137
|
-
changes, _ = _sync_path(
|
|
138
|
-
mapping, src_root, dest_root, dest, CONFIG_NAME, False, False
|
|
139
|
-
)
|
|
140
|
-
|
|
141
|
-
assert changes == 0
|
|
142
|
-
assert "keep this" in (dest_root / "file.sh").read_text()
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
def test_ensure_dest_repo_dry_run_errors_if_missing(tmp_path):
|
|
146
|
-
dest = _make_dest()
|
|
147
|
-
dest_root = tmp_path / "missing_repo"
|
|
148
|
-
with pytest.raises(ValueError, match="Destination repo not found"):
|
|
149
|
-
_ensure_dest_repo(dest, dest_root, dry_run=True)
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
def test_copy_options_defaults():
|
|
153
|
-
opts = CopyOptions()
|
|
154
|
-
assert not opts.dry_run
|
|
155
|
-
assert not opts.force_overwrite
|
|
156
|
-
assert not opts.no_checkout
|
|
157
|
-
assert not opts.local
|
|
158
|
-
assert not opts.no_prompt
|
|
159
|
-
assert not opts.no_pr
|