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/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)
@@ -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