path-sync 0.1.0__py3-none-any.whl → 0.2.1__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 CHANGED
@@ -6,7 +6,7 @@ from typing import Annotated
6
6
 
7
7
  import typer
8
8
 
9
- from path_sync import git_ops, workflow_gen
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
- always_paths: Annotated[
31
- list[str], typer.Option("-p", "--path", help="Always paths (glob patterns)")
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 all files"),
34
+ regen: bool = typer.Option(False, "--regen", help="Regenerate config"),
35
35
  ) -> None:
36
- """Initialize or update SRC repo tooling."""
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, SrcConfig)
38
+ config_path = resolve_config_path(repo_root, name)
39
39
 
40
40
  if config_path.exists() and not regen:
41
- config = load_yaml_model(config_path, SrcConfig)
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 always_paths]
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
- always_paths=path_mappings,
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, workflow_gen
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.tools_changes + self.orphans_deleted
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
- skip_checkout: bool = False
73
+ no_checkout: bool = False
66
74
  checkout_from_default: bool = False
67
- force_push: bool = True
68
- force_tools_pr: bool = False
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-no-header-updates",
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
- force_push: bool = typer.Option(True, "--force-push/--no-force-push"),
98
- force_tools_pr: bool = typer.Option(
99
- False, "--force-tools-pr", help="Create PR even if only tools changed"
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
- skip_checkout=skip_dest_checkout,
170
+ no_checkout=no_checkout,
126
171
  checkout_from_default=checkout_from_default,
127
- force_push=force_push,
128
- force_tools_pr=force_tools_pr,
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 not opts.skip_checkout and not opts.dry_run:
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
- return _commit_and_pr(
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
- result.tools_changes = _sync_tools_update(config, dest, dest_root, opts)
234
- result.orphans_deleted = _cleanup_orphans(
235
- dest_root, config.name, result.synced_paths, opts.dry_run
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,13 +389,13 @@ 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
- if not header.has_header(existing) and not force_overwrite:
394
+ has_header = header.has_header(existing)
395
+ if not has_header and not force_overwrite:
332
396
  logger.info(f"Skipping {dest_path} (header removed - opted out)")
333
397
  return 0
334
- if header.remove_header(existing) == src_content:
398
+ if header.remove_header(existing) == src_content and has_header:
335
399
  return 0
336
400
 
337
401
  new_content = header.add_header(src_content, dest_path.suffix, config_name)
@@ -366,9 +430,8 @@ def _copy_with_sections(
366
430
 
367
431
  new_content = header.add_header(new_body, dest_path.suffix, config_name)
368
432
 
369
- if dest_path.exists():
370
- if dest_path.read_text() == new_content:
371
- return 0
433
+ if dest_path.exists() and dest_path.read_text() == new_content:
434
+ return 0
372
435
 
373
436
  if dry_run:
374
437
  logger.info(f"[DRY RUN] Would write: {dest_path}")
@@ -408,100 +471,6 @@ def _find_files_with_config(dest_root: Path, config_name: str) -> list[Path]:
408
471
  return result
409
472
 
410
473
 
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
474
  def _commit_and_pr(
506
475
  config: SrcConfig,
507
476
  repo,
@@ -511,19 +480,26 @@ def _commit_and_pr(
511
480
  src_repo_url: str,
512
481
  opts: CopyOptions,
513
482
  log_path: Path,
514
- ) -> int:
515
- if opts.no_commit:
516
- return 1
483
+ ) -> None:
484
+ if opts.local:
485
+ logger.info("Local mode: skipping commit/push/PR")
486
+ return
517
487
 
518
- git_ops.commit_changes(repo, f"chore: sync {config.name} from {sha[:8]}")
488
+ if not _prompt("Commit changes?", opts.no_prompt):
489
+ return
519
490
 
520
- if opts.no_push:
521
- return 1
491
+ commit_msg = f"chore: sync {config.name} from {sha[:8]}"
492
+ git_ops.commit_changes(repo, commit_msg)
493
+ typer.echo(f" Committed: {commit_msg}", err=True)
522
494
 
523
- git_ops.push_branch(repo, dest.copy_branch, force=opts.force_push)
495
+ if not _prompt("Push to origin?", opts.no_prompt):
496
+ return
524
497
 
525
- if opts.no_pr:
526
- return 1
498
+ git_ops.push_branch(repo, dest.copy_branch, force=True)
499
+ typer.echo(f" Pushed: {dest.copy_branch} (force)", err=True)
500
+
501
+ if opts.no_pr or not _prompt("Create PR?", opts.no_prompt):
502
+ return
527
503
 
528
504
  sync_log = log_path.read_text() if log_path.exists() else ""
529
505
  pr_body = config.pr_defaults.format_body(
@@ -534,7 +510,7 @@ def _commit_and_pr(
534
510
  )
535
511
 
536
512
  title = opts.pr_title.format(name=config.name, dest_name=dest.name)
537
- git_ops.create_or_update_pr(
513
+ pr_url = git_ops.create_or_update_pr(
538
514
  dest_root,
539
515
  dest.copy_branch,
540
516
  title,
@@ -543,4 +519,5 @@ def _commit_and_pr(
543
519
  opts.pr_reviewers.split(",") if opts.pr_reviewers else None,
544
520
  opts.pr_assignees.split(",") if opts.pr_assignees else None,
545
521
  )
546
- return 1
522
+ if pr_url:
523
+ typer.echo(f" Created PR: {pr_url}", err=True)
@@ -1,4 +1,11 @@
1
- from path_sync.cmd_copy import _cleanup_orphans, _sync_path
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
 
@@ -48,6 +55,27 @@ def test_sync_skips_opted_out_file(tmp_path):
48
55
  assert (dest_root / "file.py").read_text() == "local content without header"
49
56
 
50
57
 
58
+ def test_force_overwrite_adds_header_when_content_matches(tmp_path):
59
+ src_root = tmp_path / "src"
60
+ dest_root = tmp_path / "dest"
61
+ src_root.mkdir()
62
+ dest_root.mkdir()
63
+
64
+ content = "same content"
65
+ (src_root / "file.py").write_text(content)
66
+ (dest_root / "file.py").write_text(content) # No header, same content
67
+
68
+ mapping = PathMapping(src_path="file.py")
69
+ changes, _ = _sync_path(
70
+ mapping, src_root, dest_root, _make_dest(), CONFIG_NAME, False, True
71
+ )
72
+
73
+ assert changes == 1
74
+ result = (dest_root / "file.py").read_text()
75
+ assert has_header(result)
76
+ assert content in result
77
+
78
+
51
79
  def test_cleanup_orphans(tmp_path):
52
80
  dest_root = tmp_path / "dest"
53
81
  dest_root.mkdir()
@@ -133,3 +161,20 @@ keep this
133
161
 
134
162
  assert changes == 0
135
163
  assert "keep this" in (dest_root / "file.sh").read_text()
164
+
165
+
166
+ def test_ensure_dest_repo_dry_run_errors_if_missing(tmp_path):
167
+ dest = _make_dest()
168
+ dest_root = tmp_path / "missing_repo"
169
+ with pytest.raises(ValueError, match="Destination repo not found"):
170
+ _ensure_dest_repo(dest, dest_root, dry_run=True)
171
+
172
+
173
+ def test_copy_options_defaults():
174
+ opts = CopyOptions()
175
+ assert not opts.dry_run
176
+ assert not opts.force_overwrite
177
+ assert not opts.no_checkout
178
+ assert not opts.local
179
+ assert not opts.no_prompt
180
+ 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.1
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=sN5NKVDLfo7uNvJuNw3dFK1LhjWd8CuhTWByu70Z_EI,3577
4
- path_sync/cmd_copy.py,sha256=sK1Gpf3ag7vouXZuyhdJjyvfXvP_Lt694s0DEmUhs40,16872
5
- path_sync/cmd_copy_test.py,sha256=Dq8NjDOo9cuY1YnnM1zW0ETVv9w_o1ECgOkBntnF_N4,3858
6
- path_sync/cmd_validate.py,sha256=Fv33Md730mecs2pH4S9Yew7AvI1aZ-LQ8ozcZ1jzhVw,1505
3
+ path_sync/cmd_boot.py,sha256=hYSrMF9QHVXX5feO2UE3lFSJvV38tEsJaNl0sjU2gbw,2896
4
+ path_sync/cmd_copy.py,sha256=RcIv3s6HDiOHU3Z1Q5HYzVIOGDc_4cYqYGyj9Gpnjlk,15701
5
+ path_sync/cmd_copy_test.py,sha256=dcpRaFAmXuUavIvtA59_In4Fa58FKRklNcYmeGsEuVA,5049
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=aI9ypIj1W6aWf-GcRayu1uIXypfkQSsIeKXrf5Vb7V4,4196
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.1.0.dist-info/METADATA,sha256=OxFqeFx5sgyiFS-rkAilm2Bh20oGcB8TyslrW3l68Hg,6652
22
- path_sync-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
23
- path_sync-0.1.0.dist-info/entry_points.txt,sha256=jTsL0c-9gP-4_Jt3EPgihtpLcwQR0AFAf1AUpD50AlI,54
24
- path_sync-0.1.0.dist-info/RECORD,,
20
+ path_sync-0.2.1.dist-info/METADATA,sha256=4-DnsGErtoA24qkvnBMXK4HuKm7YR-ZhX4FZ5h2zXXQ,7785
21
+ path_sync-0.2.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
22
+ path_sync-0.2.1.dist-info/entry_points.txt,sha256=jTsL0c-9gP-4_Jt3EPgihtpLcwQR0AFAf1AUpD50AlI,54
23
+ path_sync-0.2.1.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)