path-sync 0.1.0__py3-none-any.whl → 0.2.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_boot.py +9 -25
- path_sync/cmd_copy.py +130 -154
- path_sync/cmd_copy_test.py +25 -1
- path_sync/cmd_validate.py +3 -0
- path_sync/models.py +0 -13
- path_sync-0.2.0.dist-info/METADATA +271 -0
- {path_sync-0.1.0.dist-info → path_sync-0.2.0.dist-info}/RECORD +9 -10
- path_sync/workflow_gen.py +0 -130
- path_sync-0.1.0.dist-info/METADATA +0 -200
- {path_sync-0.1.0.dist-info → path_sync-0.2.0.dist-info}/WHEEL +0 -0
- {path_sync-0.1.0.dist-info → path_sync-0.2.0.dist-info}/entry_points.txt +0 -0
path_sync/cmd_boot.py
CHANGED
|
@@ -6,7 +6,7 @@ from typing import Annotated
|
|
|
6
6
|
|
|
7
7
|
import typer
|
|
8
8
|
|
|
9
|
-
from path_sync import git_ops
|
|
9
|
+
from path_sync import git_ops
|
|
10
10
|
from path_sync.file_utils import ensure_parents_write_text
|
|
11
11
|
from path_sync.models import (
|
|
12
12
|
Destination,
|
|
@@ -27,44 +27,35 @@ def boot(
|
|
|
27
27
|
dest_paths: Annotated[
|
|
28
28
|
list[str], typer.Option("-d", "--dest", help="Destination relative paths")
|
|
29
29
|
] = [],
|
|
30
|
-
|
|
31
|
-
list[str], typer.Option("-p", "--path", help="
|
|
30
|
+
sync_paths: Annotated[
|
|
31
|
+
list[str], typer.Option("-p", "--path", help="Paths to sync (glob patterns)")
|
|
32
32
|
] = [],
|
|
33
33
|
dry_run: bool = typer.Option(False, "--dry-run", help="Preview without writing"),
|
|
34
|
-
regen: bool = typer.Option(False, "--regen", help="Regenerate
|
|
34
|
+
regen: bool = typer.Option(False, "--regen", help="Regenerate config"),
|
|
35
35
|
) -> None:
|
|
36
|
-
"""Initialize or update SRC repo
|
|
36
|
+
"""Initialize or update SRC repo config."""
|
|
37
37
|
repo_root = find_repo_root(Path.cwd())
|
|
38
|
-
config_path = resolve_config_path(repo_root, name
|
|
38
|
+
config_path = resolve_config_path(repo_root, name)
|
|
39
39
|
|
|
40
40
|
if config_path.exists() and not regen:
|
|
41
|
-
|
|
41
|
+
load_yaml_model(config_path, SrcConfig)
|
|
42
42
|
logger.info(f"Using existing config: {config_path}")
|
|
43
43
|
else:
|
|
44
44
|
src_repo = git_ops.get_repo(repo_root)
|
|
45
45
|
src_repo_url = git_ops.get_remote_url(src_repo, "origin")
|
|
46
46
|
|
|
47
47
|
destinations = _build_destinations(repo_root, dest_paths)
|
|
48
|
-
path_mappings = [PathMapping(src_path=p) for p in
|
|
48
|
+
path_mappings = [PathMapping(src_path=p) for p in sync_paths]
|
|
49
49
|
|
|
50
50
|
config = SrcConfig(
|
|
51
51
|
name=name,
|
|
52
52
|
src_repo_url=src_repo_url,
|
|
53
|
-
|
|
53
|
+
paths=path_mappings,
|
|
54
54
|
destinations=destinations,
|
|
55
55
|
)
|
|
56
56
|
config_content = dump_yaml_model(config)
|
|
57
57
|
_write_file(config_path, config_content, dry_run, "config")
|
|
58
58
|
|
|
59
|
-
if config.src_tools_update.github_workflows:
|
|
60
|
-
workflow_path = repo_root / workflow_gen.copy_workflow_path(name)
|
|
61
|
-
if not workflow_path.exists() or regen:
|
|
62
|
-
content = workflow_gen.generate_copy_workflow(config)
|
|
63
|
-
_write_file(workflow_path, content, dry_run, "workflow")
|
|
64
|
-
|
|
65
|
-
if config.src_tools_update.justfile:
|
|
66
|
-
_update_justfile(repo_root, name, dry_run)
|
|
67
|
-
|
|
68
59
|
if dry_run:
|
|
69
60
|
logger.info("Dry run complete - no files written")
|
|
70
61
|
else:
|
|
@@ -96,10 +87,3 @@ def _write_file(path: Path, content: str, dry_run: bool, desc: str) -> None:
|
|
|
96
87
|
return
|
|
97
88
|
ensure_parents_write_text(path, content)
|
|
98
89
|
logger.info(f"Wrote {desc}: {path}")
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
def _update_justfile(repo_root: Path, name: str, dry_run: bool) -> None:
|
|
102
|
-
justfile_path = repo_root / "justfile"
|
|
103
|
-
workflow_gen.update_justfile(
|
|
104
|
-
justfile_path, name, workflow_gen.JustfileRecipeKind.COPY, dry_run
|
|
105
|
-
)
|
path_sync/cmd_copy.py
CHANGED
|
@@ -2,7 +2,6 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import glob
|
|
4
4
|
import logging
|
|
5
|
-
import subprocess
|
|
6
5
|
import tempfile
|
|
7
6
|
from contextlib import contextmanager
|
|
8
7
|
from dataclasses import dataclass, field
|
|
@@ -10,7 +9,7 @@ from pathlib import Path
|
|
|
10
9
|
|
|
11
10
|
import typer
|
|
12
11
|
|
|
13
|
-
from path_sync import git_ops, header, sections
|
|
12
|
+
from path_sync import git_ops, header, sections
|
|
14
13
|
from path_sync.file_utils import ensure_parents_write_text
|
|
15
14
|
from path_sync.models import (
|
|
16
15
|
LOG_FORMAT,
|
|
@@ -30,16 +29,25 @@ EXIT_CHANGES = 1
|
|
|
30
29
|
EXIT_ERROR = 2
|
|
31
30
|
|
|
32
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
|
+
|
|
33
42
|
@dataclass
|
|
34
43
|
class SyncResult:
|
|
35
44
|
content_changes: int = 0
|
|
36
|
-
tools_changes: int = 0
|
|
37
45
|
orphans_deleted: int = 0
|
|
38
46
|
synced_paths: set[Path] = field(default_factory=set)
|
|
39
47
|
|
|
40
48
|
@property
|
|
41
49
|
def total(self) -> int:
|
|
42
|
-
return self.content_changes + self.
|
|
50
|
+
return self.content_changes + self.orphans_deleted
|
|
43
51
|
|
|
44
52
|
|
|
45
53
|
@contextmanager
|
|
@@ -62,13 +70,12 @@ def capture_sync_log(dest_name: str):
|
|
|
62
70
|
class CopyOptions:
|
|
63
71
|
dry_run: bool = False
|
|
64
72
|
force_overwrite: bool = False
|
|
65
|
-
|
|
73
|
+
no_checkout: bool = False
|
|
66
74
|
checkout_from_default: bool = False
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
no_commit: bool = False
|
|
70
|
-
no_push: bool = False
|
|
75
|
+
local: bool = False
|
|
76
|
+
no_prompt: bool = False
|
|
71
77
|
no_pr: bool = False
|
|
78
|
+
skip_orphan_cleanup: bool = False
|
|
72
79
|
pr_title: str = ""
|
|
73
80
|
pr_labels: str = ""
|
|
74
81
|
pr_reviewers: str = ""
|
|
@@ -84,27 +91,65 @@ def copy(
|
|
|
84
91
|
dry_run: bool = typer.Option(False, "--dry-run", help="Preview without writing"),
|
|
85
92
|
force_overwrite: bool = typer.Option(
|
|
86
93
|
False,
|
|
87
|
-
"--force-
|
|
88
|
-
help="Overwrite files even if header removed",
|
|
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",
|
|
89
106
|
),
|
|
90
|
-
detailed_exit_code: bool = typer.Option(False, "--detailed-exit-code"),
|
|
91
|
-
skip_dest_checkout: bool = typer.Option(False, "--skip-dest-checkout"),
|
|
92
107
|
checkout_from_default: bool = typer.Option(
|
|
93
108
|
False,
|
|
94
109
|
"--checkout-from-default",
|
|
95
110
|
help="Reset to origin/default before sync (for CI)",
|
|
96
111
|
),
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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",
|
|
100
152
|
),
|
|
101
|
-
no_commit: bool = typer.Option(False, "--no-commit"),
|
|
102
|
-
no_push: bool = typer.Option(False, "--no-push"),
|
|
103
|
-
no_pr: bool = typer.Option(False, "--no-pr"),
|
|
104
|
-
pr_title: str = typer.Option("", "--pr-title"),
|
|
105
|
-
pr_labels: str = typer.Option("", "--pr-labels"),
|
|
106
|
-
pr_reviewers: str = typer.Option("", "--pr-reviewers"),
|
|
107
|
-
pr_assignees: str = typer.Option("", "--pr-assignees"),
|
|
108
153
|
) -> None:
|
|
109
154
|
"""Copy files from SRC to DEST repositories."""
|
|
110
155
|
src_root = find_repo_root(Path.cwd())
|
|
@@ -122,13 +167,12 @@ def copy(
|
|
|
122
167
|
opts = CopyOptions(
|
|
123
168
|
dry_run=dry_run,
|
|
124
169
|
force_overwrite=force_overwrite,
|
|
125
|
-
|
|
170
|
+
no_checkout=no_checkout,
|
|
126
171
|
checkout_from_default=checkout_from_default,
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
no_commit=no_commit,
|
|
130
|
-
no_push=no_push,
|
|
172
|
+
local=local,
|
|
173
|
+
no_prompt=no_prompt,
|
|
131
174
|
no_pr=no_pr,
|
|
175
|
+
skip_orphan_cleanup=skip_orphan_cleanup,
|
|
132
176
|
pr_title=pr_title or config.pr_defaults.title,
|
|
133
177
|
pr_labels=pr_labels or ",".join(config.pr_defaults.labels),
|
|
134
178
|
pr_reviewers=pr_reviewers or ",".join(config.pr_defaults.reviewers),
|
|
@@ -168,9 +212,21 @@ def _sync_destination(
|
|
|
168
212
|
log_path: Path,
|
|
169
213
|
) -> int:
|
|
170
214
|
dest_root = (src_root / dest.dest_path_relative).resolve()
|
|
171
|
-
dest_repo = _ensure_dest_repo(dest, dest_root)
|
|
172
215
|
|
|
173
|
-
if
|
|
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:
|
|
174
230
|
git_ops.prepare_copy_branch(
|
|
175
231
|
repo=dest_repo,
|
|
176
232
|
default_branch=dest.default_branch,
|
|
@@ -179,30 +235,38 @@ def _sync_destination(
|
|
|
179
235
|
)
|
|
180
236
|
|
|
181
237
|
result = _sync_paths(config, dest, src_root, dest_root, opts)
|
|
238
|
+
_print_sync_summary(dest, result)
|
|
182
239
|
|
|
183
240
|
if result.total == 0:
|
|
184
241
|
logger.info(f"{dest.name}: No changes")
|
|
185
242
|
return 0
|
|
186
|
-
logger.info(f"{dest.name}: Found {result.total} changes")
|
|
187
|
-
if (
|
|
188
|
-
result.content_changes == 0
|
|
189
|
-
and result.orphans_deleted == 0
|
|
190
|
-
and not opts.force_tools_pr
|
|
191
|
-
):
|
|
192
|
-
logger.info(f"{dest.name}: Tools-only changes, skipping PR")
|
|
193
|
-
return 0
|
|
194
243
|
|
|
195
244
|
if opts.dry_run:
|
|
196
|
-
logger.info(f"{dest.name}: Would make {result.total} changes")
|
|
197
245
|
return result.total
|
|
198
246
|
|
|
199
|
-
|
|
247
|
+
_commit_and_pr(
|
|
200
248
|
config, dest_repo, dest_root, dest, current_sha, src_repo_url, opts, log_path
|
|
201
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)
|
|
202
261
|
|
|
203
262
|
|
|
204
|
-
def _ensure_dest_repo(dest: Destination, dest_root: Path):
|
|
263
|
+
def _ensure_dest_repo(dest: Destination, dest_root: Path, dry_run: bool):
|
|
205
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
|
+
)
|
|
206
270
|
if not dest.repo_url:
|
|
207
271
|
raise ValueError(f"Dest {dest.name} not found and no repo_url configured")
|
|
208
272
|
git_ops.clone_repo(dest.repo_url, dest_root)
|
|
@@ -230,10 +294,10 @@ def _sync_paths(
|
|
|
230
294
|
result.content_changes += changes
|
|
231
295
|
result.synced_paths.update(paths)
|
|
232
296
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
+
)
|
|
237
301
|
return result
|
|
238
302
|
|
|
239
303
|
|
|
@@ -325,7 +389,6 @@ def _copy_with_header(
|
|
|
325
389
|
src_content, dest_path, skip_list, config_name, dry_run, force_overwrite
|
|
326
390
|
)
|
|
327
391
|
|
|
328
|
-
# No sections: full-file replacement
|
|
329
392
|
if dest_path.exists():
|
|
330
393
|
existing = dest_path.read_text()
|
|
331
394
|
if not header.has_header(existing) and not force_overwrite:
|
|
@@ -366,9 +429,8 @@ def _copy_with_sections(
|
|
|
366
429
|
|
|
367
430
|
new_content = header.add_header(new_body, dest_path.suffix, config_name)
|
|
368
431
|
|
|
369
|
-
if dest_path.exists():
|
|
370
|
-
|
|
371
|
-
return 0
|
|
432
|
+
if dest_path.exists() and dest_path.read_text() == new_content:
|
|
433
|
+
return 0
|
|
372
434
|
|
|
373
435
|
if dry_run:
|
|
374
436
|
logger.info(f"[DRY RUN] Would write: {dest_path}")
|
|
@@ -408,100 +470,6 @@ def _find_files_with_config(dest_root: Path, config_name: str) -> list[Path]:
|
|
|
408
470
|
return result
|
|
409
471
|
|
|
410
472
|
|
|
411
|
-
def _sync_tools_update(
|
|
412
|
-
config: SrcConfig,
|
|
413
|
-
dest: Destination,
|
|
414
|
-
dest_root: Path,
|
|
415
|
-
opts: CopyOptions,
|
|
416
|
-
) -> int:
|
|
417
|
-
changes = 0
|
|
418
|
-
|
|
419
|
-
if dest.tools_update.github_workflows:
|
|
420
|
-
wf_path = dest_root / workflow_gen.validate_workflow_path(config.name)
|
|
421
|
-
if not wf_path.exists():
|
|
422
|
-
content = workflow_gen.generate_validate_workflow(
|
|
423
|
-
name=config.name,
|
|
424
|
-
copy_branch=dest.copy_branch,
|
|
425
|
-
default_branch=dest.default_branch,
|
|
426
|
-
)
|
|
427
|
-
if opts.dry_run:
|
|
428
|
-
logger.info(f"[DRY RUN] Would write workflow: {wf_path}")
|
|
429
|
-
else:
|
|
430
|
-
ensure_parents_write_text(wf_path, content)
|
|
431
|
-
logger.info(f"Wrote workflow: {wf_path}")
|
|
432
|
-
changes += 1
|
|
433
|
-
|
|
434
|
-
if dest.tools_update.justfile:
|
|
435
|
-
changes += _sync_justfile_recipe(config.name, dest_root, opts)
|
|
436
|
-
|
|
437
|
-
if dest.tools_update.path_sync_wheel:
|
|
438
|
-
_sync_wheel(dest_root, opts)
|
|
439
|
-
|
|
440
|
-
return changes
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
def _sync_justfile_recipe(name: str, dest_root: Path, opts: CopyOptions) -> int:
|
|
444
|
-
justfile_path = dest_root / "justfile"
|
|
445
|
-
changed = workflow_gen.update_justfile(
|
|
446
|
-
justfile_path, name, workflow_gen.JustfileRecipeKind.VALIDATE, opts.dry_run
|
|
447
|
-
)
|
|
448
|
-
return 1 if changed else 0
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
def _sync_wheel(dest_root: Path, opts: CopyOptions) -> None:
|
|
452
|
-
pkg_root = Path(__file__).parent.parent
|
|
453
|
-
wheel = _build_wheel(pkg_root, opts.dry_run)
|
|
454
|
-
if not wheel:
|
|
455
|
-
return
|
|
456
|
-
|
|
457
|
-
dest_dir = dest_root / ".github"
|
|
458
|
-
dest_wheel = dest_dir / wheel.name
|
|
459
|
-
|
|
460
|
-
for old_wheel in dest_dir.glob("path_sync-*.whl"):
|
|
461
|
-
if old_wheel != dest_wheel:
|
|
462
|
-
if opts.dry_run:
|
|
463
|
-
logger.info(f"[DRY RUN] Would remove old wheel: {old_wheel}")
|
|
464
|
-
else:
|
|
465
|
-
old_wheel.unlink()
|
|
466
|
-
logger.info(f"Removed old wheel: {old_wheel}")
|
|
467
|
-
|
|
468
|
-
if opts.dry_run:
|
|
469
|
-
logger.info(f"[DRY RUN] Would copy wheel: {dest_wheel}")
|
|
470
|
-
return
|
|
471
|
-
|
|
472
|
-
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
473
|
-
dest_wheel.write_bytes(wheel.read_bytes())
|
|
474
|
-
logger.info(f"Copied wheel: {dest_wheel}")
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
def _build_wheel(pkg_root: Path, dry_run: bool) -> Path | None:
|
|
478
|
-
dist_dir = pkg_root / "dist"
|
|
479
|
-
|
|
480
|
-
if dry_run:
|
|
481
|
-
logger.info("[DRY RUN] Would build wheel")
|
|
482
|
-
wheels = sorted(dist_dir.glob("path_sync-*.whl")) if dist_dir.exists() else []
|
|
483
|
-
return wheels[-1] if wheels else None
|
|
484
|
-
|
|
485
|
-
if dist_dir.exists():
|
|
486
|
-
for old in dist_dir.glob("path_sync-*.whl"):
|
|
487
|
-
old.unlink()
|
|
488
|
-
|
|
489
|
-
logger.info("Building wheel...")
|
|
490
|
-
result = subprocess.run(
|
|
491
|
-
["uv", "build", "--wheel"],
|
|
492
|
-
cwd=pkg_root,
|
|
493
|
-
capture_output=True,
|
|
494
|
-
text=True,
|
|
495
|
-
)
|
|
496
|
-
if result.returncode != 0:
|
|
497
|
-
logger.error(f"Failed to build wheel: {result.stderr}")
|
|
498
|
-
raise RuntimeError("Wheel build failed")
|
|
499
|
-
|
|
500
|
-
wheels = sorted(dist_dir.glob("path_sync-*.whl"))
|
|
501
|
-
assert len(wheels) == 1, f"Expected 1 wheel, got {len(wheels)}"
|
|
502
|
-
return wheels[0]
|
|
503
|
-
|
|
504
|
-
|
|
505
473
|
def _commit_and_pr(
|
|
506
474
|
config: SrcConfig,
|
|
507
475
|
repo,
|
|
@@ -511,19 +479,26 @@ def _commit_and_pr(
|
|
|
511
479
|
src_repo_url: str,
|
|
512
480
|
opts: CopyOptions,
|
|
513
481
|
log_path: Path,
|
|
514
|
-
) ->
|
|
515
|
-
if opts.
|
|
516
|
-
|
|
482
|
+
) -> None:
|
|
483
|
+
if opts.local:
|
|
484
|
+
logger.info("Local mode: skipping commit/push/PR")
|
|
485
|
+
return
|
|
517
486
|
|
|
518
|
-
|
|
487
|
+
if not _prompt("Commit changes?", opts.no_prompt):
|
|
488
|
+
return
|
|
519
489
|
|
|
520
|
-
|
|
521
|
-
|
|
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)
|
|
522
493
|
|
|
523
|
-
|
|
494
|
+
if not _prompt("Push to origin?", opts.no_prompt):
|
|
495
|
+
return
|
|
524
496
|
|
|
525
|
-
|
|
526
|
-
|
|
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
|
|
527
502
|
|
|
528
503
|
sync_log = log_path.read_text() if log_path.exists() else ""
|
|
529
504
|
pr_body = config.pr_defaults.format_body(
|
|
@@ -534,7 +509,7 @@ def _commit_and_pr(
|
|
|
534
509
|
)
|
|
535
510
|
|
|
536
511
|
title = opts.pr_title.format(name=config.name, dest_name=dest.name)
|
|
537
|
-
git_ops.create_or_update_pr(
|
|
512
|
+
pr_url = git_ops.create_or_update_pr(
|
|
538
513
|
dest_root,
|
|
539
514
|
dest.copy_branch,
|
|
540
515
|
title,
|
|
@@ -543,4 +518,5 @@ def _commit_and_pr(
|
|
|
543
518
|
opts.pr_reviewers.split(",") if opts.pr_reviewers else None,
|
|
544
519
|
opts.pr_assignees.split(",") if opts.pr_assignees else None,
|
|
545
520
|
)
|
|
546
|
-
|
|
521
|
+
if pr_url:
|
|
522
|
+
typer.echo(f" Created PR: {pr_url}", err=True)
|
path_sync/cmd_copy_test.py
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from path_sync.cmd_copy import (
|
|
4
|
+
CopyOptions,
|
|
5
|
+
_cleanup_orphans,
|
|
6
|
+
_ensure_dest_repo,
|
|
7
|
+
_sync_path,
|
|
8
|
+
)
|
|
2
9
|
from path_sync.header import add_header, has_header
|
|
3
10
|
from path_sync.models import Destination, PathMapping
|
|
4
11
|
|
|
@@ -133,3 +140,20 @@ keep this
|
|
|
133
140
|
|
|
134
141
|
assert changes == 0
|
|
135
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
|
path_sync/cmd_validate.py
CHANGED
|
@@ -32,6 +32,9 @@ def validate_no_changes(
|
|
|
32
32
|
if current_branch.startswith("sync/"):
|
|
33
33
|
logger.info(f"On sync branch {current_branch}, validation skipped")
|
|
34
34
|
return
|
|
35
|
+
if current_branch == branch:
|
|
36
|
+
logger.info(f"On default branch {branch}, validation skipped")
|
|
37
|
+
return
|
|
35
38
|
|
|
36
39
|
skip_sections = (
|
|
37
40
|
parse_skip_sections(skip_sections_opt) if skip_sections_opt else None
|
path_sync/models.py
CHANGED
|
@@ -58,17 +58,6 @@ class HeaderConfig(BaseModel):
|
|
|
58
58
|
)
|
|
59
59
|
|
|
60
60
|
|
|
61
|
-
class ToolsUpdate(BaseModel):
|
|
62
|
-
justfile: bool = True
|
|
63
|
-
path_sync_wheel: bool = True
|
|
64
|
-
github_workflows: bool = True
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
class SrcToolsUpdate(BaseModel):
|
|
68
|
-
justfile: bool = True
|
|
69
|
-
github_workflows: bool = True
|
|
70
|
-
|
|
71
|
-
|
|
72
61
|
DEFAULT_BODY_TEMPLATE = """\
|
|
73
62
|
Synced from [{src_repo_name}]({src_repo_url}) @ `{src_sha_short}`
|
|
74
63
|
|
|
@@ -119,7 +108,6 @@ class Destination(BaseModel):
|
|
|
119
108
|
copy_branch: str = "sync/path-sync"
|
|
120
109
|
default_branch: str = "main"
|
|
121
110
|
skip_sections: dict[str, list[str]] = Field(default_factory=dict)
|
|
122
|
-
tools_update: ToolsUpdate = Field(default_factory=ToolsUpdate)
|
|
123
111
|
|
|
124
112
|
|
|
125
113
|
class SrcConfig(BaseModel):
|
|
@@ -130,7 +118,6 @@ class SrcConfig(BaseModel):
|
|
|
130
118
|
src_repo_url: str = ""
|
|
131
119
|
schedule: str = "0 6 * * *"
|
|
132
120
|
header_config: HeaderConfig = Field(default_factory=HeaderConfig)
|
|
133
|
-
src_tools_update: SrcToolsUpdate = Field(default_factory=SrcToolsUpdate)
|
|
134
121
|
pr_defaults: PRDefaults = Field(default_factory=PRDefaults)
|
|
135
122
|
paths: list[PathMapping] = Field(default_factory=list)
|
|
136
123
|
destinations: list[Destination] = Field(default_factory=list)
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: path-sync
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Author-email: EspenAlbert <espen.albert1@gmail.com>
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Classifier: Development Status :: 4 - Beta
|
|
7
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
8
|
+
Requires-Python: >=3.13
|
|
9
|
+
Requires-Dist: gitpython>=3.1.0
|
|
10
|
+
Requires-Dist: pydantic>=2.0
|
|
11
|
+
Requires-Dist: pyyaml>=6.0
|
|
12
|
+
Requires-Dist: typer>=0.16.0
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# path-sync
|
|
16
|
+
|
|
17
|
+
Sync files from a source repo to multiple destination repos.
|
|
18
|
+
|
|
19
|
+
## Overview
|
|
20
|
+
|
|
21
|
+
**Problem**: You have shared config files (linter rules, CI templates, editor settings) that should be consistent across multiple repositories. Manual copying leads to drift.
|
|
22
|
+
|
|
23
|
+
**Solution**: path-sync provides one-way file syncing with clear ownership:
|
|
24
|
+
|
|
25
|
+
| Term | Definition |
|
|
26
|
+
|------|------------|
|
|
27
|
+
| **SRC** | Source repository containing the canonical files |
|
|
28
|
+
| **DEST** | Destination repository receiving synced files |
|
|
29
|
+
| **Header** | Comment added to synced files marking them as managed |
|
|
30
|
+
| **Section** | Marked region within a file for partial syncing |
|
|
31
|
+
|
|
32
|
+
**Key behaviors**:
|
|
33
|
+
- SRC owns synced content; DEST should not edit it
|
|
34
|
+
- Files with headers are updated on each sync
|
|
35
|
+
- Remove a header to opt-out (file becomes DEST-owned)
|
|
36
|
+
- Orphaned files (removed from SRC) are deleted in DEST
|
|
37
|
+
|
|
38
|
+
## Installation
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
# From PyPI
|
|
42
|
+
uvx path-sync --help
|
|
43
|
+
|
|
44
|
+
# Or install in project
|
|
45
|
+
uv pip install path-sync
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Quick Start
|
|
49
|
+
|
|
50
|
+
### 1. Bootstrap a source config
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
path-sync boot -n myconfig -d ../dest-repo1 -d ../dest-repo2 -p '.cursor/**/*.mdc'
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Creates `.github/myconfig.src.yaml` with auto-detected git remote and destinations.
|
|
57
|
+
|
|
58
|
+
### 2. Copy files to destinations
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
path-sync copy -n myconfig
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
By default, prompts before each git operation. See [Usage Scenarios](#usage-scenarios) for common patterns.
|
|
65
|
+
|
|
66
|
+
| Flag | Description |
|
|
67
|
+
|------|-------------|
|
|
68
|
+
| `-d dest1,dest2` | Filter specific destinations |
|
|
69
|
+
| `--dry-run` | Preview without writing (requires existing repos) |
|
|
70
|
+
| `-y, --no-prompt` | Skip confirmations (for CI) |
|
|
71
|
+
| `--local` | No git ops after sync (no commit/push/PR) |
|
|
72
|
+
| `--no-checkout` | Skip branch switching before sync |
|
|
73
|
+
| `--checkout-from-default` | Reset to origin/default before sync |
|
|
74
|
+
| `--no-pr` | Push but skip PR creation |
|
|
75
|
+
| `--force-overwrite` | Overwrite files even if header removed (opted out) |
|
|
76
|
+
| `--detailed-exit-code` | Exit 0=no changes, 1=changes, 2=error |
|
|
77
|
+
| `--skip-orphan-cleanup` | Skip deletion of orphaned synced files |
|
|
78
|
+
| `--pr-title` | Override PR title (supports `{name}`, `{dest_name}`) |
|
|
79
|
+
| `--pr-labels` | Comma-separated PR labels |
|
|
80
|
+
| `--pr-reviewers` | Comma-separated PR reviewers |
|
|
81
|
+
| `--pr-assignees` | Comma-separated PR assignees |
|
|
82
|
+
|
|
83
|
+
### 3. Validate (run in dest repo)
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
uvx path-sync validate-no-changes -b main
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Options:
|
|
90
|
+
- `-b, --branch` - Default branch to compare against (default: main)
|
|
91
|
+
- `--skip-sections` - Comma-separated `path:section_id` pairs to skip (e.g., `justfile:coverage`)
|
|
92
|
+
|
|
93
|
+
## Usage Scenarios
|
|
94
|
+
|
|
95
|
+
| Scenario | Command |
|
|
96
|
+
|----------|---------|
|
|
97
|
+
| Interactive sync | `copy -n cfg` |
|
|
98
|
+
| CI fresh sync | `copy -n cfg --checkout-from-default -y` |
|
|
99
|
+
| Local preview | `copy -n cfg --dry-run` |
|
|
100
|
+
| Local test files | `copy -n cfg --local` |
|
|
101
|
+
| Already on branch | `copy -n cfg --no-checkout --local` |
|
|
102
|
+
| Push, manual PR | `copy -n cfg --no-pr -y` |
|
|
103
|
+
| Force opted-out | `copy -n cfg --force-overwrite` |
|
|
104
|
+
|
|
105
|
+
## Section Markers
|
|
106
|
+
|
|
107
|
+
For partial file syncing (e.g., `justfile`, `pyproject.toml`), wrap sections with markers:
|
|
108
|
+
|
|
109
|
+
```makefile
|
|
110
|
+
# === DO_NOT_EDIT: path-sync default ===
|
|
111
|
+
lint:
|
|
112
|
+
ruff check .
|
|
113
|
+
# === OK_EDIT ===
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
- **`DO_NOT_EDIT: path-sync {id}`** - Start of managed section with identifier
|
|
117
|
+
- **`OK_EDIT`** - End marker (content below is editable)
|
|
118
|
+
|
|
119
|
+
During sync, only content within markers is replaced. Destination can have extra sections.
|
|
120
|
+
|
|
121
|
+
Use `skip_sections` in destination config to exclude specific sections from sync:
|
|
122
|
+
|
|
123
|
+
```yaml
|
|
124
|
+
destinations:
|
|
125
|
+
- name: dest1
|
|
126
|
+
dest_path_relative: ../dest1
|
|
127
|
+
skip_sections:
|
|
128
|
+
justfile: [coverage] # keep local coverage recipe
|
|
129
|
+
```
|
|
130
|
+
## Config Reference
|
|
131
|
+
|
|
132
|
+
**Source config** (`.github/{name}.src.yaml`):
|
|
133
|
+
|
|
134
|
+
```yaml
|
|
135
|
+
name: cursor
|
|
136
|
+
src_repo_url: https://github.com/user/src-repo
|
|
137
|
+
schedule: "0 6 * * *"
|
|
138
|
+
paths:
|
|
139
|
+
- src_path: .cursor/**/*.mdc
|
|
140
|
+
- src_path: templates/justfile
|
|
141
|
+
dest_path: justfile
|
|
142
|
+
destinations:
|
|
143
|
+
- name: dest1
|
|
144
|
+
repo_url: https://github.com/user/dest1
|
|
145
|
+
dest_path_relative: ../dest1
|
|
146
|
+
copy_branch: sync/path-sync
|
|
147
|
+
default_branch: main
|
|
148
|
+
skip_sections:
|
|
149
|
+
justfile: [coverage]
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
| Field | Description |
|
|
153
|
+
|-------|-------------|
|
|
154
|
+
| `name` | Config identifier |
|
|
155
|
+
| `src_repo_url` | Source repo URL (auto-detected from git remote) |
|
|
156
|
+
| `schedule` | Cron for scheduled sync workflow |
|
|
157
|
+
| `paths` | Files/globs to sync (`src_path` required, `dest_path` optional) |
|
|
158
|
+
| `destinations` | Target repos with sync settings |
|
|
159
|
+
| `header_config` | Comment style per extension (has defaults) |
|
|
160
|
+
| `pr_defaults` | PR title, labels, reviewers, assignees |
|
|
161
|
+
|
|
162
|
+
## Header Format
|
|
163
|
+
|
|
164
|
+
Synced files have a header comment identifying the source config:
|
|
165
|
+
|
|
166
|
+
```python
|
|
167
|
+
# path-sync copy -n myconfig
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Comment style is extension-aware:
|
|
171
|
+
|
|
172
|
+
| Extension | Format |
|
|
173
|
+
|-----------|--------|
|
|
174
|
+
| `.py`, `.sh`, `.yaml` | `# path-sync copy -n {name}` |
|
|
175
|
+
| `.go`, `.js`, `.ts` | `// path-sync copy -n {name}` |
|
|
176
|
+
| `.md`, `.mdc`, `.html` | `<!-- path-sync copy -n {name} -->` |
|
|
177
|
+
|
|
178
|
+
Remove this header to opt-out of future syncs for that file.
|
|
179
|
+
|
|
180
|
+
## GitHub Actions
|
|
181
|
+
|
|
182
|
+
### Source repo workflow
|
|
183
|
+
|
|
184
|
+
Create `.github/workflows/path_sync_copy.yaml`:
|
|
185
|
+
|
|
186
|
+
```yaml
|
|
187
|
+
name: path-sync copy
|
|
188
|
+
on:
|
|
189
|
+
schedule:
|
|
190
|
+
- cron: "0 6 * * *"
|
|
191
|
+
workflow_dispatch:
|
|
192
|
+
|
|
193
|
+
jobs:
|
|
194
|
+
sync:
|
|
195
|
+
runs-on: ubuntu-latest
|
|
196
|
+
steps:
|
|
197
|
+
- uses: actions/checkout@v4
|
|
198
|
+
- uses: astral-sh/setup-uv@v5
|
|
199
|
+
- run: uvx path-sync copy -n myconfig --checkout-from-default -y
|
|
200
|
+
env:
|
|
201
|
+
GH_TOKEN: ${{ secrets.GH_PAT }}
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
### Destination repo validation
|
|
205
|
+
|
|
206
|
+
Create `.github/workflows/path_sync_validate.yaml`:
|
|
207
|
+
|
|
208
|
+
```yaml
|
|
209
|
+
name: path-sync validate
|
|
210
|
+
on:
|
|
211
|
+
push:
|
|
212
|
+
branches-ignore:
|
|
213
|
+
- main
|
|
214
|
+
- sync/**
|
|
215
|
+
pull_request:
|
|
216
|
+
branches:
|
|
217
|
+
- main
|
|
218
|
+
|
|
219
|
+
jobs:
|
|
220
|
+
validate:
|
|
221
|
+
runs-on: ubuntu-latest
|
|
222
|
+
steps:
|
|
223
|
+
- uses: actions/checkout@v4
|
|
224
|
+
with:
|
|
225
|
+
fetch-depth: 0
|
|
226
|
+
- uses: astral-sh/setup-uv@v5
|
|
227
|
+
- run: uvx path-sync validate-no-changes -b main
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
**Validation skips automatically when:**
|
|
231
|
+
- On a `sync/*` branch (path-sync uses `sync/path-sync` by default)
|
|
232
|
+
- On the default branch (comparing against itself)
|
|
233
|
+
|
|
234
|
+
The workflow triggers exclude these branches too, reducing unnecessary CI runs.
|
|
235
|
+
|
|
236
|
+
### PAT Requirements
|
|
237
|
+
|
|
238
|
+
Create a **Fine-grained PAT** at <https://github.com/settings/tokens?type=beta>
|
|
239
|
+
|
|
240
|
+
| Permission | Scope |
|
|
241
|
+
|------------|-------|
|
|
242
|
+
| Contents | Read/write (push branches) |
|
|
243
|
+
| Pull requests | Read/write (create PRs) |
|
|
244
|
+
| Workflows | Read/write (if syncing `.github/workflows/`) |
|
|
245
|
+
| Metadata | Read (always required) |
|
|
246
|
+
|
|
247
|
+
Add as repository secret: `GH_PAT`
|
|
248
|
+
|
|
249
|
+
### Common Errors
|
|
250
|
+
|
|
251
|
+
| Error | Fix |
|
|
252
|
+
|-------|-----|
|
|
253
|
+
| `HTTP 404: Not Found` | Add repo to PAT's repository access |
|
|
254
|
+
| `HTTP 403: Resource not accessible` | Add Contents + Pull requests permissions |
|
|
255
|
+
| `GraphQL: Resource not accessible` | Use GH_PAT, not GITHUB_TOKEN |
|
|
256
|
+
| `HTTP 422: Required status check` | Exclude `sync/*` from branch protection |
|
|
257
|
+
|
|
258
|
+
## Alternatives Considered
|
|
259
|
+
|
|
260
|
+
| Tool | Why Not |
|
|
261
|
+
|------|---------|
|
|
262
|
+
| [repo-file-sync-action](https://github.com/BetaHuhn/repo-file-sync-action) | No local CLI, no validation |
|
|
263
|
+
| [Copier](https://copier.readthedocs.io/) | Merge-based (conflicts), no multi-dest |
|
|
264
|
+
| [Cruft](https://cruft.github.io/cruft/) | Patch-based, single dest |
|
|
265
|
+
|
|
266
|
+
**Why path-sync:**
|
|
267
|
+
- One SRC to many DEST repos
|
|
268
|
+
- Local CLI + CI support
|
|
269
|
+
- Section-level sync for shared files
|
|
270
|
+
- Validation enforced across repos
|
|
271
|
+
- Clear ownership (no merge conflicts)
|
|
@@ -1,24 +1,23 @@
|
|
|
1
1
|
path_sync/__init__.py,sha256=fdUhqFKjflvMkVbUuA_RMMv4am652KWK8N3IVQzj6ok,63
|
|
2
2
|
path_sync/__main__.py,sha256=Hh0Na0BlS0EwBAerhWD4mnaB-oMo6jufFVQBuzNZk3g,296
|
|
3
|
-
path_sync/cmd_boot.py,sha256=
|
|
4
|
-
path_sync/cmd_copy.py,sha256=
|
|
5
|
-
path_sync/cmd_copy_test.py,sha256=
|
|
6
|
-
path_sync/cmd_validate.py,sha256=
|
|
3
|
+
path_sync/cmd_boot.py,sha256=hYSrMF9QHVXX5feO2UE3lFSJvV38tEsJaNl0sjU2gbw,2896
|
|
4
|
+
path_sync/cmd_copy.py,sha256=J18euCE2toO88evMqKWb8uwmx2LLnVxNdV86W--PDVg,15654
|
|
5
|
+
path_sync/cmd_copy_test.py,sha256=CLUzHJl_BBWqKkpODBthM4RlxKwF3Wo053EV3Nefqsk,4425
|
|
6
|
+
path_sync/cmd_validate.py,sha256=wcMo_JR2jxFtqaNQt1mk_Mnwy_jPSpFHCXFuzNOphEU,1624
|
|
7
7
|
path_sync/conftest.py,sha256=-iu7W2Bh2aRzTR1x3aMnkWkrVnDMp6ezdh1AFgNcYUE,343
|
|
8
8
|
path_sync/file_utils.py,sha256=5C33qzKFQdwChi5YwUWBujj126t0P6dbGSU_5hWExpE,194
|
|
9
9
|
path_sync/git_ops.py,sha256=1ixXKtseZAWAJP9uJcAb78IGQOmRekriv-n_T1nx0mI,5945
|
|
10
10
|
path_sync/header.py,sha256=2YSCj7ainj5TPFINBHn8Uc2ECu591pZfd7NSOZMX5XA,2293
|
|
11
11
|
path_sync/header_test.py,sha256=R9jwOulSKR70HrFoxYBXUx3DGjdzfB2tZNu9sjL_3rA,1401
|
|
12
|
-
path_sync/models.py,sha256=
|
|
12
|
+
path_sync/models.py,sha256=GR2J8PRtAORltvnL73In6zjdNbkELtUomHdwPHfsWwU,3832
|
|
13
13
|
path_sync/models_test.py,sha256=m9kZbl3CGABrg58owNLx4Aiv5LPvKz0t61o0336J5x8,2052
|
|
14
14
|
path_sync/sections.py,sha256=jzzzt2e-umjY0Ab-d7W-29y7aVVBtf_vEX_abFEdZdo,3681
|
|
15
15
|
path_sync/sections_test.py,sha256=lsApYMe1BqGL7T0sYhbs28VCrDm9dmkq0G3JAvGOlPI,3546
|
|
16
16
|
path_sync/typer_app.py,sha256=lEGMRXql3Se3VbmwAohvpUaL2cbY-RwhPUq8kL7bPbc,177
|
|
17
17
|
path_sync/validation.py,sha256=RP9SWd69mGvB4MxGx4RFJiyIQ-X482Y5h7G7VYAOxiE,2766
|
|
18
18
|
path_sync/validation_test.py,sha256=NdK1JHAtjGIm7m7uVm0fM751ttJhKXrS21gt1eEDhP0,3071
|
|
19
|
-
path_sync/workflow_gen.py,sha256=YkgXt3YD0Bi41fjHe1NgjL6FsAcviM_QaEKpCB4WVNo,3732
|
|
20
19
|
path_sync/yaml_utils.py,sha256=yj6Bl54EltjLEcVKaiA5Ahb9byT6OUMh0xIEzTsrvnQ,498
|
|
21
|
-
path_sync-0.
|
|
22
|
-
path_sync-0.
|
|
23
|
-
path_sync-0.
|
|
24
|
-
path_sync-0.
|
|
20
|
+
path_sync-0.2.0.dist-info/METADATA,sha256=YXdWMZ1YWX9JVRVjC4qXE0WkoO1z3x7xsAlaEjGc27w,7785
|
|
21
|
+
path_sync-0.2.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
22
|
+
path_sync-0.2.0.dist-info/entry_points.txt,sha256=jTsL0c-9gP-4_Jt3EPgihtpLcwQR0AFAf1AUpD50AlI,54
|
|
23
|
+
path_sync-0.2.0.dist-info/RECORD,,
|
path_sync/workflow_gen.py
DELETED
|
@@ -1,130 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import logging
|
|
4
|
-
from enum import StrEnum
|
|
5
|
-
from pathlib import Path
|
|
6
|
-
|
|
7
|
-
from path_sync.models import SrcConfig
|
|
8
|
-
|
|
9
|
-
logger = logging.getLogger(__name__)
|
|
10
|
-
|
|
11
|
-
COPY_WORKFLOW_TEMPLATE = """name: "Path Sync: {name} copy"
|
|
12
|
-
|
|
13
|
-
on:
|
|
14
|
-
workflow_dispatch:
|
|
15
|
-
inputs:
|
|
16
|
-
dest:
|
|
17
|
-
description: "Filter destinations (comma-separated)"
|
|
18
|
-
required: false
|
|
19
|
-
default: ""
|
|
20
|
-
extra_args:
|
|
21
|
-
description: "Extra args for path-sync copy"
|
|
22
|
-
required: false
|
|
23
|
-
default: ""
|
|
24
|
-
schedule:
|
|
25
|
-
- cron: "{schedule}"
|
|
26
|
-
|
|
27
|
-
jobs:
|
|
28
|
-
sync:
|
|
29
|
-
runs-on: ubuntu-latest
|
|
30
|
-
steps:
|
|
31
|
-
- uses: actions/checkout@v4
|
|
32
|
-
- uses: astral-sh/setup-uv@v4
|
|
33
|
-
- name: Run path-sync copy
|
|
34
|
-
env:
|
|
35
|
-
GH_TOKEN: ${{{{ secrets.GH_PAT }}}}
|
|
36
|
-
run: |
|
|
37
|
-
ARGS="-n {name}"
|
|
38
|
-
if [ -n "${{{{ inputs.dest }}}}" ]; then
|
|
39
|
-
ARGS="$ARGS -d ${{{{ inputs.dest }}}}"
|
|
40
|
-
fi
|
|
41
|
-
if [ -n "${{{{ inputs.extra_args }}}}" ]; then
|
|
42
|
-
ARGS="$ARGS ${{{{ inputs.extra_args }}}}"
|
|
43
|
-
fi
|
|
44
|
-
uv run path-sync copy $ARGS
|
|
45
|
-
"""
|
|
46
|
-
|
|
47
|
-
WHEEL_GLOB = ".github/path_sync-*.whl"
|
|
48
|
-
|
|
49
|
-
VALIDATE_WORKFLOW_TEMPLATE = """name: "Path Sync: {name} validate"
|
|
50
|
-
|
|
51
|
-
on:
|
|
52
|
-
push:
|
|
53
|
-
branches-ignore:
|
|
54
|
-
- "{copy_branch}"
|
|
55
|
-
- "{default_branch}"
|
|
56
|
-
|
|
57
|
-
jobs:
|
|
58
|
-
validate:
|
|
59
|
-
runs-on: ubuntu-latest
|
|
60
|
-
steps:
|
|
61
|
-
- uses: actions/checkout@v4
|
|
62
|
-
- uses: astral-sh/setup-uv@v4
|
|
63
|
-
- name: Validate no unauthorized changes
|
|
64
|
-
run: uv run --with {wheel_glob} path-sync validate-no-changes -n {name}
|
|
65
|
-
"""
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
def generate_copy_workflow(config: SrcConfig) -> str:
|
|
69
|
-
return COPY_WORKFLOW_TEMPLATE.format(name=config.name, schedule=config.schedule)
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
def generate_validate_workflow(name: str, copy_branch: str, default_branch: str) -> str:
|
|
73
|
-
return VALIDATE_WORKFLOW_TEMPLATE.format(
|
|
74
|
-
name=name,
|
|
75
|
-
copy_branch=copy_branch,
|
|
76
|
-
default_branch=default_branch,
|
|
77
|
-
wheel_glob=WHEEL_GLOB,
|
|
78
|
-
)
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
def copy_workflow_path(name: str) -> str:
|
|
82
|
-
return f".github/workflows/path_sync_{name}_copy.yaml"
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
def validate_workflow_path(name: str) -> str:
|
|
86
|
-
return f".github/workflows/path_sync_{name}_validate.yaml"
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
class JustfileRecipeKind(StrEnum):
|
|
90
|
-
COPY = "copy"
|
|
91
|
-
VALIDATE = "validate"
|
|
92
|
-
|
|
93
|
-
def recipe_name(self, config_name: str) -> str:
|
|
94
|
-
if self == JustfileRecipeKind.COPY:
|
|
95
|
-
return f"path-sync-{config_name}"
|
|
96
|
-
return f"path-sync-validate-{config_name}"
|
|
97
|
-
|
|
98
|
-
def generate_recipe(self, config_name: str) -> str:
|
|
99
|
-
recipe_name = self.recipe_name(config_name)
|
|
100
|
-
if self == JustfileRecipeKind.COPY:
|
|
101
|
-
return f"\n# path-sync: copy files to destinations\n{recipe_name}:\n uv run path-sync copy -n {config_name}\n"
|
|
102
|
-
return f"\n# path-sync: validate no unauthorized changes\n{recipe_name}:\n uv run --with {WHEEL_GLOB} path-sync validate-no-changes -n {config_name}\n"
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
def update_justfile(
|
|
106
|
-
justfile_path: Path,
|
|
107
|
-
config_name: str,
|
|
108
|
-
kind: JustfileRecipeKind,
|
|
109
|
-
dry_run: bool,
|
|
110
|
-
) -> bool:
|
|
111
|
-
"""Update justfile with recipe. Returns True if changes were made."""
|
|
112
|
-
recipe = kind.generate_recipe(config_name)
|
|
113
|
-
recipe_marker = f"{kind.recipe_name(config_name)}:"
|
|
114
|
-
|
|
115
|
-
if justfile_path.exists():
|
|
116
|
-
existing = justfile_path.read_text()
|
|
117
|
-
if recipe_marker in existing:
|
|
118
|
-
logger.info(f"Justfile recipe already exists: {recipe_marker}")
|
|
119
|
-
return False
|
|
120
|
-
new_content = existing.rstrip() + recipe
|
|
121
|
-
else:
|
|
122
|
-
new_content = recipe.lstrip()
|
|
123
|
-
|
|
124
|
-
if dry_run:
|
|
125
|
-
logger.info(f"[DRY RUN] Would update justfile with recipe: {recipe_marker}")
|
|
126
|
-
return True
|
|
127
|
-
|
|
128
|
-
justfile_path.write_text(new_content)
|
|
129
|
-
logger.info(f"Added justfile recipe: {recipe_marker}")
|
|
130
|
-
return True
|
|
@@ -1,200 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: path-sync
|
|
3
|
-
Version: 0.1.0
|
|
4
|
-
Author-email: EspenAlbert <espen.albert1@gmail.com>
|
|
5
|
-
License-Expression: MIT
|
|
6
|
-
Classifier: Development Status :: 4 - Beta
|
|
7
|
-
Classifier: Programming Language :: Python :: 3.12
|
|
8
|
-
Classifier: Programming Language :: Python :: 3.13
|
|
9
|
-
Requires-Python: >=3.13
|
|
10
|
-
Requires-Dist: gitpython>=3.1.0
|
|
11
|
-
Requires-Dist: pydantic>=2.0
|
|
12
|
-
Requires-Dist: pyyaml>=6.0
|
|
13
|
-
Requires-Dist: typer>=0.16.0
|
|
14
|
-
Description-Content-Type: text/markdown
|
|
15
|
-
|
|
16
|
-
# path-sync
|
|
17
|
-
|
|
18
|
-
Sync files from a source repo to multiple destination repos.
|
|
19
|
-
|
|
20
|
-
## Installation
|
|
21
|
-
|
|
22
|
-
```bash
|
|
23
|
-
uv pip install -e path-sync/
|
|
24
|
-
```
|
|
25
|
-
|
|
26
|
-
## Quick Start
|
|
27
|
-
|
|
28
|
-
### 1. Bootstrap a source config
|
|
29
|
-
|
|
30
|
-
```bash
|
|
31
|
-
# From your source repo root
|
|
32
|
-
path-sync boot -n myconfig -d ../dest-repo1 -d ../dest-repo2 -p '.cursor/**/*.mdc'
|
|
33
|
-
```
|
|
34
|
-
|
|
35
|
-
Creates `.github/myconfig.src.yaml` with:
|
|
36
|
-
- Auto-detected `src_repo_url` from git remote
|
|
37
|
-
- Destinations with names extracted from paths
|
|
38
|
-
- Always paths from `-p` patterns
|
|
39
|
-
|
|
40
|
-
### 2. Copy files to destinations
|
|
41
|
-
|
|
42
|
-
```bash
|
|
43
|
-
path-sync copy -n myconfig
|
|
44
|
-
```
|
|
45
|
-
|
|
46
|
-
Options:
|
|
47
|
-
- `-d lz,help` - filter specific destinations
|
|
48
|
-
- `--dry-run` - preview without changes
|
|
49
|
-
- `--validate-first` - run validation before copying
|
|
50
|
-
- `--skip-dest-checkout` - use current branch in dest
|
|
51
|
-
- `--force-ignore-sha-match` - re-copy even if SHA unchanged
|
|
52
|
-
- `--force-no-header-updates` - overwrite files even if header removed (opted out)
|
|
53
|
-
- `--no-pr` - skip PR creation
|
|
54
|
-
|
|
55
|
-
### 3. Validate no unauthorized changes (run in dest repo)
|
|
56
|
-
|
|
57
|
-
```bash
|
|
58
|
-
# If path-sync is installed
|
|
59
|
-
path-sync validate-no-changes -n myconfig
|
|
60
|
-
|
|
61
|
-
# Or using the copied wheel (no installation needed)
|
|
62
|
-
uv run --with .github/path_sync-*.whl path-sync validate-no-changes -n myconfig
|
|
63
|
-
```
|
|
64
|
-
|
|
65
|
-
### 4. Clean orphaned synced files (run in dest repo)
|
|
66
|
-
|
|
67
|
-
```bash
|
|
68
|
-
path-sync clean
|
|
69
|
-
```
|
|
70
|
-
|
|
71
|
-
## Config Structure
|
|
72
|
-
|
|
73
|
-
**Source config** (`.github/{name}.src.yaml`):
|
|
74
|
-
```yaml
|
|
75
|
-
type: SRC
|
|
76
|
-
name: cursor
|
|
77
|
-
src_repo_url: https://github.com/user/src-repo
|
|
78
|
-
schedule: "0 6 * * *"
|
|
79
|
-
always_paths:
|
|
80
|
-
- src_path: .cursor/**/*.mdc
|
|
81
|
-
scaffold_paths:
|
|
82
|
-
- src_path: templates/README.md
|
|
83
|
-
destinations:
|
|
84
|
-
- name: dest1
|
|
85
|
-
repo_url: https://github.com/user/dest1
|
|
86
|
-
dest_path_relative: ../dest1
|
|
87
|
-
copy_branch: sync/path-sync
|
|
88
|
-
default_branch: main
|
|
89
|
-
tools_update:
|
|
90
|
-
justfile: true # Add validate recipe to justfile
|
|
91
|
-
path_sync_wheel: true # Copy path-sync wheel to .github/
|
|
92
|
-
github_workflows: true # Generate validation workflow
|
|
93
|
-
```
|
|
94
|
-
|
|
95
|
-
## Opt-out Mechanism
|
|
96
|
-
|
|
97
|
-
Synced files have a header comment (e.g., `<!-- DO NOT EDIT: path-sync destination file -->`).
|
|
98
|
-
Remove this header to opt-out of future syncs for that file.
|
|
99
|
-
|
|
100
|
-
## Header Configuration
|
|
101
|
-
|
|
102
|
-
The header text and comment styles are configurable in `header_config`:
|
|
103
|
-
|
|
104
|
-
```yaml
|
|
105
|
-
header_config:
|
|
106
|
-
header_text: "DO NOT EDIT: path-sync destination file" # customize message
|
|
107
|
-
comment_prefixes: # extension -> prefix mapping
|
|
108
|
-
.py: "#"
|
|
109
|
-
.md: "<!--"
|
|
110
|
-
comment_suffixes: # optional closing (for HTML-style comments)
|
|
111
|
-
.md: " -->"
|
|
112
|
-
```
|
|
113
|
-
|
|
114
|
-
**Behavior:**
|
|
115
|
-
- **Defaults included**: Running `boot` generates config with all supported extensions pre-configured
|
|
116
|
-
- **Inherited by DEST**: The `header_config` from SRC is copied to DEST config during `copy`
|
|
117
|
-
- **Only configured extensions synced**: Files with extensions not in `comment_prefixes` are skipped
|
|
118
|
-
- **Validation uses DEST config**: The `validate-no-changes` and `clean` commands read header config from the dest config
|
|
119
|
-
|
|
120
|
-
**Supported extensions (defaults):**
|
|
121
|
-
|
|
122
|
-
| Extension | Prefix | Suffix |
|
|
123
|
-
|-----------|--------|--------|
|
|
124
|
-
| `.py`, `.sh`, `.yaml`, `.yml` | `#` | |
|
|
125
|
-
| `.go`, `.js`, `.ts` | `//` | |
|
|
126
|
-
| `.md`, `.mdc`, `.html` | `<!--` | `-->` |
|
|
127
|
-
|
|
128
|
-
## GitHub Actions
|
|
129
|
-
|
|
130
|
-
**Source repo**: Set `src_tools_update.github_workflows: true` and run `boot --regen` to generate:
|
|
131
|
-
- `.github/workflows/path_sync_{name}_copy.yaml` - scheduled sync workflow
|
|
132
|
-
|
|
133
|
-
**Destination repos**: Set `tools_update.github_workflows: true` per destination to auto-generate:
|
|
134
|
-
- `.github/workflows/path_sync_{name}_validate.yaml` - validation on push (uses the copied wheel)
|
|
135
|
-
|
|
136
|
-
### PAT Requirements
|
|
137
|
-
|
|
138
|
-
Create a **Fine-grained Personal Access Token** at <https://github.com/settings/tokens?type=beta>
|
|
139
|
-
|
|
140
|
-
**Repository access**: Select each destination repository
|
|
141
|
-
|
|
142
|
-
**Permissions required per destination repo**:
|
|
143
|
-
- **Contents**: Read and write (push branches)
|
|
144
|
-
- **Pull requests**: Read and write (create PRs)
|
|
145
|
-
- **Workflows**: Read and write (required if syncing workflow files to `.github/workflows/`)
|
|
146
|
-
- **Metadata**: Read (always required)
|
|
147
|
-
|
|
148
|
-
**Classic PAT alternative**: Use `repo` + `workflow` scopes
|
|
149
|
-
|
|
150
|
-
### Setup
|
|
151
|
-
|
|
152
|
-
1. Create the PAT with permissions above
|
|
153
|
-
2. Add as repository secret: Settings > Secrets > Actions > `GH_PAT`
|
|
154
|
-
3. The workflow uses `GH_TOKEN: ${{ secrets.GH_PAT }}` for `gh` CLI auth
|
|
155
|
-
|
|
156
|
-
### Verify Access
|
|
157
|
-
|
|
158
|
-
```bash
|
|
159
|
-
# Test from any machine with gh CLI
|
|
160
|
-
export GH_TOKEN=your_pat
|
|
161
|
-
gh api repos/owner/dest-repo
|
|
162
|
-
gh pr list --repo owner/dest-repo
|
|
163
|
-
```
|
|
164
|
-
|
|
165
|
-
### Branch Protection
|
|
166
|
-
|
|
167
|
-
The `copy_branch` (default: `sync/path-sync`) is where path-sync pushes changes. This branch typically doesn't need protection rules.
|
|
168
|
-
|
|
169
|
-
**Potential issues**:
|
|
170
|
-
- If wildcard branch protection (e.g., `*`) blocks pushes, exclude `sync/*` pattern
|
|
171
|
-
- Required status checks on the PR target branch may delay merge
|
|
172
|
-
|
|
173
|
-
**Workarounds**:
|
|
174
|
-
- Add `sync/path-sync` to branch protection bypass list
|
|
175
|
-
- Or use a bot/machine user account with bypass permissions
|
|
176
|
-
- The `--no-pr` flag skips PR creation if you prefer manual work
|
|
177
|
-
|
|
178
|
-
### Common Errors
|
|
179
|
-
|
|
180
|
-
| Error | Cause | Fix |
|
|
181
|
-
|-------|-------|-----|
|
|
182
|
-
| `HTTP 404: Not Found` | PAT lacks repo access | Add repo to PAT's repository access |
|
|
183
|
-
| `HTTP 403: Resource not accessible` | Missing permission | Add Contents + Pull requests permissions |
|
|
184
|
-
| `GraphQL: Resource not accessible by integration` | Using GITHUB_TOKEN | Use GH_PAT secret instead |
|
|
185
|
-
| `HTTP 422: Required status check` | Branch protection rules | Bypass or exclude `sync/*` branches |
|
|
186
|
-
|
|
187
|
-
## Alternatives Considered
|
|
188
|
-
|
|
189
|
-
| Tool | Description | Why Not |
|
|
190
|
-
|------|-------------|---------|
|
|
191
|
-
| [repo-file-sync-action](https://github.com/BetaHuhn/repo-file-sync-action) | GitHub Action for file sync | No local CLI, no validation workflow, no justfile support |
|
|
192
|
-
| [Copier](https://copier.readthedocs.io/) | Template-based project generation | Merge-based (both SRC and DEST can edit), no multi-dest, no validation |
|
|
193
|
-
| [Cruft](https://cruft.github.io/cruft/) | Cookiecutter with updates | Patch-based merging, single dest, no CI validation |
|
|
194
|
-
|
|
195
|
-
**Why path-sync:**
|
|
196
|
-
- One SRC to multiple DEST repos (not 1:1)
|
|
197
|
-
- Local CLI support (not GitHub Action only)
|
|
198
|
-
- Justfile integration for dev workflow
|
|
199
|
-
- Validation checks enforced across many repos
|
|
200
|
-
- Clear ownership: SRC or DEST, never both (no merge conflicts)
|
|
File without changes
|
|
File without changes
|