path-sync 0.5.0__tar.gz → 0.6.0__tar.gz

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.
Files changed (29) hide show
  1. {path_sync-0.5.0 → path_sync-0.6.0}/PKG-INFO +69 -4
  2. {path_sync-0.5.0 → path_sync-0.6.0}/README.md +68 -3
  3. {path_sync-0.5.0 → path_sync-0.6.0}/path_sync/__init__.py +1 -1
  4. {path_sync-0.5.0 → path_sync-0.6.0}/path_sync/_internal/cmd_copy.py +73 -29
  5. {path_sync-0.5.0 → path_sync-0.6.0}/path_sync/_internal/cmd_dep_update.py +12 -54
  6. {path_sync-0.5.0 → path_sync-0.6.0}/path_sync/_internal/models.py +32 -0
  7. {path_sync-0.5.0 → path_sync-0.6.0}/path_sync/_internal/models_dep.py +7 -24
  8. path_sync-0.6.0/path_sync/_internal/verify.py +97 -0
  9. {path_sync-0.5.0 → path_sync-0.6.0}/path_sync/dep_update.py +7 -7
  10. {path_sync-0.5.0 → path_sync-0.6.0}/path_sync/sections.py +19 -1
  11. {path_sync-0.5.0 → path_sync-0.6.0}/pyproject.toml +1 -1
  12. {path_sync-0.5.0 → path_sync-0.6.0}/.gitignore +0 -0
  13. {path_sync-0.5.0 → path_sync-0.6.0}/LICENSE +0 -0
  14. {path_sync-0.5.0 → path_sync-0.6.0}/path_sync/__main__.py +0 -0
  15. {path_sync-0.5.0 → path_sync-0.6.0}/path_sync/_internal/__init__.py +0 -0
  16. {path_sync-0.5.0 → path_sync-0.6.0}/path_sync/_internal/cmd_boot.py +0 -0
  17. {path_sync-0.5.0 → path_sync-0.6.0}/path_sync/_internal/cmd_options.py +0 -0
  18. {path_sync-0.5.0 → path_sync-0.6.0}/path_sync/_internal/cmd_validate.py +0 -0
  19. {path_sync-0.5.0 → path_sync-0.6.0}/path_sync/_internal/file_utils.py +0 -0
  20. {path_sync-0.5.0 → path_sync-0.6.0}/path_sync/_internal/git_ops.py +0 -0
  21. {path_sync-0.5.0 → path_sync-0.6.0}/path_sync/_internal/header.py +0 -0
  22. {path_sync-0.5.0 → path_sync-0.6.0}/path_sync/_internal/log_capture.py +0 -0
  23. {path_sync-0.5.0 → path_sync-0.6.0}/path_sync/_internal/prompt_utils.py +0 -0
  24. {path_sync-0.5.0 → path_sync-0.6.0}/path_sync/_internal/typer_app.py +0 -0
  25. {path_sync-0.5.0 → path_sync-0.6.0}/path_sync/_internal/validation.py +0 -0
  26. {path_sync-0.5.0 → path_sync-0.6.0}/path_sync/_internal/yaml_utils.py +0 -0
  27. {path_sync-0.5.0 → path_sync-0.6.0}/path_sync/config.py +0 -0
  28. {path_sync-0.5.0 → path_sync-0.6.0}/path_sync/copy.py +0 -0
  29. {path_sync-0.5.0 → path_sync-0.6.0}/path_sync/validate_no_changes.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: path-sync
3
- Version: 0.5.0
3
+ Version: 0.6.0
4
4
  Summary: Sync files from a source repo to multiple destination repos
5
5
  Author-email: EspenAlbert <espen.albert1@gmail.com>
6
6
  License-Expression: MIT
@@ -77,13 +77,14 @@ By default, prompts before each git operation. See [Usage Scenarios](#usage-scen
77
77
  | `-d dest1,dest2` | Filter specific destinations |
78
78
  | `--dry-run` | Preview without writing (requires existing repos) |
79
79
  | `-y, --no-prompt` | Skip confirmations (for CI) |
80
- | `--local` | No git ops after sync (no commit/push/PR) |
80
+ | `--skip-commit` | No git ops after sync (no commit/push/PR). Alias: `--local` |
81
81
  | `--no-checkout` | Skip branch switching (assumes already on correct branch) |
82
82
  | `--checkout-from-default` | Reset to origin/default before sync |
83
83
  | `--no-pr` | Push but skip PR creation |
84
84
  | `--force-overwrite` | Overwrite files even if header removed (opted out) |
85
85
  | `--detailed-exit-code` | Exit 0=no changes, 1=changes, 2=error |
86
86
  | `--skip-orphan-cleanup` | Skip deletion of orphaned synced files |
87
+ | `--skip-verify` | Skip verification steps after syncing |
87
88
  | `--pr-title` | Override PR title (supports `{name}`, `{dest_name}`) |
88
89
  | `--pr-labels` | Comma-separated PR labels |
89
90
  | `--pr-reviewers` | Comma-separated PR reviewers |
@@ -106,12 +107,12 @@ Options:
106
107
  | Interactive sync | `copy -n cfg` |
107
108
  | CI fresh sync | `copy -n cfg --checkout-from-default -y` |
108
109
  | Local preview | `copy -n cfg --dry-run` |
109
- | Local test files | `copy -n cfg --local` |
110
+ | Local test files | `copy -n cfg --skip-commit` |
110
111
  | Already on branch | `copy -n cfg --no-checkout` |
111
112
  | Push, manual PR | `copy -n cfg --no-pr -y` |
112
113
  | Force opted-out | `copy -n cfg --force-overwrite` |
113
114
 
114
- **Interactive prompt behavior**: Declining the checkout prompt syncs files but skips commit/push/PR (same as `--local`). Use `--no-checkout` when you're already on the correct branch and want to proceed with git operations.
115
+ **Interactive prompt behavior**: Each git operation (checkout, commit, push, PR) prompts independently. Use `--no-checkout` to skip the branch switch prompt. Use `--skip-commit` to skip all git operations after sync.
115
116
 
116
117
  ## Section Markers
117
118
 
@@ -139,6 +140,36 @@ destinations:
139
140
  justfile: [coverage] # keep local coverage recipe
140
141
  ```
141
142
 
143
+ ## Wrapping Synced Files
144
+
145
+ For files without section markers, `wrap_synced_files` automatically wraps content in a `synced` section. This lets destinations add content before/after the synced content.
146
+
147
+ **Without wrapping** (default):
148
+ ```python
149
+ # path-sync copy -n myconfig
150
+ def hello():
151
+ pass
152
+ ```
153
+
154
+ **With wrapping** (`wrap_synced_files: true`):
155
+ ```python
156
+ # path-sync copy -n myconfig
157
+ # === DO_NOT_EDIT: path-sync synced ===
158
+ def hello():
159
+ pass
160
+ # === OK_EDIT: path-sync synced ===
161
+ ```
162
+
163
+ Destinations can add content outside the section markers. Per-path override via `wrap: false`:
164
+
165
+ ```yaml
166
+ wrap_synced_files: true
167
+ paths:
168
+ - src_path: templates/base.py # wrapped
169
+ - src_path: .editorconfig
170
+ wrap: false # not wrapped
171
+ ```
172
+
142
173
  ## Skipping Files per Destination
143
174
 
144
175
  Use `skip_file_patterns` to exclude files for specific destinations. Patterns match against the **destination path** (after `dest_path` remapping):
@@ -195,6 +226,8 @@ destinations:
195
226
  | `destinations` | Target repos with sync settings |
196
227
  | `header_config` | Comment style per extension (has defaults) |
197
228
  | `pr_defaults` | PR title, labels, reviewers, assignees |
229
+ | `wrap_synced_files` | Wrap synced files in section markers (default: `false`) |
230
+ | `verify` | Verification steps to run after syncing (see [Verify Steps](#verify-steps-in-copy)) |
198
231
 
199
232
  **Path options**:
200
233
 
@@ -205,6 +238,7 @@ destinations:
205
238
  | `sync_mode` | `sync` (default), `replace`, or `scaffold` |
206
239
  | `exclude_dirs` | Directory names to skip (defaults: `__pycache__`, `.git`, `.venv`, etc.) |
207
240
  | `exclude_file_patterns` | Filename patterns to skip, supports globs (`*.pyc`, `test_*.py`) |
241
+ | `wrap` | Override global `wrap_synced_files` for this path (`true`/`false`) |
208
242
 
209
243
  **Destination options**:
210
244
 
@@ -217,6 +251,37 @@ destinations:
217
251
  | `default_branch` | Default branch to compare against (defaults to `main`) |
218
252
  | `skip_sections` | Map of `{dest_path: [section_ids]}` to preserve locally |
219
253
  | `skip_file_patterns` | Patterns to skip for this destination (matches dest path, fnmatch syntax) |
254
+ | `verify` | Per-destination verify config (overrides source-level verify) |
255
+
256
+ ## Verify Steps in Copy
257
+
258
+ Run verification steps after syncing files. Synced files are committed first, then verify steps run and can make additional commits.
259
+
260
+ ```yaml
261
+ name: myconfig
262
+ verify:
263
+ on_fail: warn # default: warn (also: skip, fail)
264
+ steps:
265
+ - run: just fmt
266
+ commit:
267
+ message: "style: format synced files"
268
+ add_paths: ["."]
269
+ on_fail: warn
270
+ - run: just test
271
+ ```
272
+
273
+ Per-destination override:
274
+
275
+ ```yaml
276
+ destinations:
277
+ - name: dest1
278
+ dest_path_relative: ../dest1
279
+ verify:
280
+ steps:
281
+ - run: npm run build
282
+ ```
283
+
284
+ Use `--skip-verify` to disable verification steps.
220
285
 
221
286
  ## Header Format
222
287
 
@@ -59,13 +59,14 @@ By default, prompts before each git operation. See [Usage Scenarios](#usage-scen
59
59
  | `-d dest1,dest2` | Filter specific destinations |
60
60
  | `--dry-run` | Preview without writing (requires existing repos) |
61
61
  | `-y, --no-prompt` | Skip confirmations (for CI) |
62
- | `--local` | No git ops after sync (no commit/push/PR) |
62
+ | `--skip-commit` | No git ops after sync (no commit/push/PR). Alias: `--local` |
63
63
  | `--no-checkout` | Skip branch switching (assumes already on correct branch) |
64
64
  | `--checkout-from-default` | Reset to origin/default before sync |
65
65
  | `--no-pr` | Push but skip PR creation |
66
66
  | `--force-overwrite` | Overwrite files even if header removed (opted out) |
67
67
  | `--detailed-exit-code` | Exit 0=no changes, 1=changes, 2=error |
68
68
  | `--skip-orphan-cleanup` | Skip deletion of orphaned synced files |
69
+ | `--skip-verify` | Skip verification steps after syncing |
69
70
  | `--pr-title` | Override PR title (supports `{name}`, `{dest_name}`) |
70
71
  | `--pr-labels` | Comma-separated PR labels |
71
72
  | `--pr-reviewers` | Comma-separated PR reviewers |
@@ -88,12 +89,12 @@ Options:
88
89
  | Interactive sync | `copy -n cfg` |
89
90
  | CI fresh sync | `copy -n cfg --checkout-from-default -y` |
90
91
  | Local preview | `copy -n cfg --dry-run` |
91
- | Local test files | `copy -n cfg --local` |
92
+ | Local test files | `copy -n cfg --skip-commit` |
92
93
  | Already on branch | `copy -n cfg --no-checkout` |
93
94
  | Push, manual PR | `copy -n cfg --no-pr -y` |
94
95
  | Force opted-out | `copy -n cfg --force-overwrite` |
95
96
 
96
- **Interactive prompt behavior**: Declining the checkout prompt syncs files but skips commit/push/PR (same as `--local`). Use `--no-checkout` when you're already on the correct branch and want to proceed with git operations.
97
+ **Interactive prompt behavior**: Each git operation (checkout, commit, push, PR) prompts independently. Use `--no-checkout` to skip the branch switch prompt. Use `--skip-commit` to skip all git operations after sync.
97
98
 
98
99
  ## Section Markers
99
100
 
@@ -121,6 +122,36 @@ destinations:
121
122
  justfile: [coverage] # keep local coverage recipe
122
123
  ```
123
124
 
125
+ ## Wrapping Synced Files
126
+
127
+ For files without section markers, `wrap_synced_files` automatically wraps content in a `synced` section. This lets destinations add content before/after the synced content.
128
+
129
+ **Without wrapping** (default):
130
+ ```python
131
+ # path-sync copy -n myconfig
132
+ def hello():
133
+ pass
134
+ ```
135
+
136
+ **With wrapping** (`wrap_synced_files: true`):
137
+ ```python
138
+ # path-sync copy -n myconfig
139
+ # === DO_NOT_EDIT: path-sync synced ===
140
+ def hello():
141
+ pass
142
+ # === OK_EDIT: path-sync synced ===
143
+ ```
144
+
145
+ Destinations can add content outside the section markers. Per-path override via `wrap: false`:
146
+
147
+ ```yaml
148
+ wrap_synced_files: true
149
+ paths:
150
+ - src_path: templates/base.py # wrapped
151
+ - src_path: .editorconfig
152
+ wrap: false # not wrapped
153
+ ```
154
+
124
155
  ## Skipping Files per Destination
125
156
 
126
157
  Use `skip_file_patterns` to exclude files for specific destinations. Patterns match against the **destination path** (after `dest_path` remapping):
@@ -177,6 +208,8 @@ destinations:
177
208
  | `destinations` | Target repos with sync settings |
178
209
  | `header_config` | Comment style per extension (has defaults) |
179
210
  | `pr_defaults` | PR title, labels, reviewers, assignees |
211
+ | `wrap_synced_files` | Wrap synced files in section markers (default: `false`) |
212
+ | `verify` | Verification steps to run after syncing (see [Verify Steps](#verify-steps-in-copy)) |
180
213
 
181
214
  **Path options**:
182
215
 
@@ -187,6 +220,7 @@ destinations:
187
220
  | `sync_mode` | `sync` (default), `replace`, or `scaffold` |
188
221
  | `exclude_dirs` | Directory names to skip (defaults: `__pycache__`, `.git`, `.venv`, etc.) |
189
222
  | `exclude_file_patterns` | Filename patterns to skip, supports globs (`*.pyc`, `test_*.py`) |
223
+ | `wrap` | Override global `wrap_synced_files` for this path (`true`/`false`) |
190
224
 
191
225
  **Destination options**:
192
226
 
@@ -199,6 +233,37 @@ destinations:
199
233
  | `default_branch` | Default branch to compare against (defaults to `main`) |
200
234
  | `skip_sections` | Map of `{dest_path: [section_ids]}` to preserve locally |
201
235
  | `skip_file_patterns` | Patterns to skip for this destination (matches dest path, fnmatch syntax) |
236
+ | `verify` | Per-destination verify config (overrides source-level verify) |
237
+
238
+ ## Verify Steps in Copy
239
+
240
+ Run verification steps after syncing files. Synced files are committed first, then verify steps run and can make additional commits.
241
+
242
+ ```yaml
243
+ name: myconfig
244
+ verify:
245
+ on_fail: warn # default: warn (also: skip, fail)
246
+ steps:
247
+ - run: just fmt
248
+ commit:
249
+ message: "style: format synced files"
250
+ add_paths: ["."]
251
+ on_fail: warn
252
+ - run: just test
253
+ ```
254
+
255
+ Per-destination override:
256
+
257
+ ```yaml
258
+ destinations:
259
+ - name: dest1
260
+ dest_path_relative: ../dest1
261
+ verify:
262
+ steps:
263
+ - run: npm run build
264
+ ```
265
+
266
+ Use `--skip-verify` to disable verification steps.
202
267
 
203
268
  ## Header Format
204
269
 
@@ -5,7 +5,7 @@ from path_sync import dep_update
5
5
  from path_sync import validate_no_changes
6
6
  from path_sync import config
7
7
 
8
- VERSION = "0.5.0"
8
+ VERSION = "0.6.0"
9
9
  __all__ = [
10
10
  "copy",
11
11
  "dep_update",
@@ -10,7 +10,7 @@ import typer
10
10
  from pydantic import BaseModel
11
11
 
12
12
  from path_sync import sections
13
- from path_sync._internal import cmd_options, git_ops, header, prompt_utils
13
+ from path_sync._internal import cmd_options, git_ops, header, prompt_utils, verify
14
14
  from path_sync._internal.file_utils import ensure_parents_write_text
15
15
  from path_sync._internal.log_capture import capture_log
16
16
  from path_sync._internal.models import (
@@ -22,6 +22,7 @@ from path_sync._internal.models import (
22
22
  resolve_config_path,
23
23
  )
24
24
  from path_sync._internal.typer_app import app
25
+ from path_sync._internal.verify import StepFailure, VerifyResult, VerifyStatus
25
26
  from path_sync._internal.yaml_utils import load_yaml_model
26
27
 
27
28
  logger = logging.getLogger(__name__)
@@ -47,10 +48,11 @@ class CopyOptions(BaseModel):
47
48
  force_overwrite: bool = False
48
49
  no_checkout: bool = False
49
50
  checkout_from_default: bool = False
50
- local: bool = False
51
+ skip_commit: bool = False
51
52
  no_prompt: bool = False
52
53
  no_pr: bool = False
53
54
  skip_orphan_cleanup: bool = False
55
+ skip_verify: bool = False
54
56
  pr_title: str = ""
55
57
  labels: list[str] | None = None
56
58
  reviewers: list[str] | None = None
@@ -93,8 +95,9 @@ def copy(
93
95
  "--checkout-from-default",
94
96
  help="Reset to origin/default before sync (for CI)",
95
97
  ),
96
- local: bool = typer.Option(
98
+ skip_commit: bool = typer.Option(
97
99
  False,
100
+ "--skip-commit",
98
101
  "--local",
99
102
  help="No git operations after sync (no commit/push/PR)",
100
103
  ),
@@ -122,6 +125,11 @@ def copy(
122
125
  "--skip-orphan-cleanup",
123
126
  help="Skip deletion of orphaned synced files",
124
127
  ),
128
+ skip_verify: bool = typer.Option(
129
+ False,
130
+ "--skip-verify",
131
+ help="Skip verification steps after syncing",
132
+ ),
125
133
  ) -> None:
126
134
  """Copy files from SRC to DEST repositories."""
127
135
  if name and config_path_opt:
@@ -148,10 +156,11 @@ def copy(
148
156
  force_overwrite=force_overwrite,
149
157
  no_checkout=no_checkout,
150
158
  checkout_from_default=checkout_from_default,
151
- local=local,
159
+ skip_commit=skip_commit,
152
160
  no_prompt=no_prompt,
153
161
  no_pr=no_pr,
154
162
  skip_orphan_cleanup=skip_orphan_cleanup,
163
+ skip_verify=skip_verify,
155
164
  pr_title=pr_title or config.pr_defaults.title,
156
165
  labels=cmd_options.split_csv(pr_labels) or config.pr_defaults.labels,
157
166
  reviewers=cmd_options.split_csv(pr_reviewers) or config.pr_defaults.reviewers,
@@ -196,23 +205,14 @@ def _sync_destination(
196
205
  dest_repo = _ensure_dest_repo(dest, dest_root, opts.dry_run)
197
206
  copy_branch = dest.resolved_copy_branch(config.name)
198
207
 
199
- # --no-checkout means "I'm already on the right branch"
200
- # Prompt decline means "skip git operations for this run"
201
- if opts.dry_run:
202
- skip_git_ops = True
203
- elif opts.no_checkout:
204
- skip_git_ops = False
205
- elif prompt_utils.prompt_confirm(f"Switch {dest.name} to {copy_branch}?", opts.no_prompt):
208
+ # --no-checkout skips branch switching (assumes already on correct branch)
209
+ if not opts.no_checkout and prompt_utils.prompt_confirm(f"Switch {dest.name} to {copy_branch}?", opts.no_prompt):
206
210
  git_ops.prepare_copy_branch(
207
211
  repo=dest_repo,
208
212
  default_branch=dest.default_branch,
209
213
  copy_branch=copy_branch,
210
214
  from_default=opts.checkout_from_default,
211
215
  )
212
- skip_git_ops = False
213
- else:
214
- skip_git_ops = True
215
-
216
216
  result = _sync_paths(config, dest, src_root, dest_root, opts)
217
217
  _print_sync_summary(dest, result)
218
218
 
@@ -220,10 +220,29 @@ def _sync_destination(
220
220
  logger.info(f"{dest.name}: No changes")
221
221
  return 0
222
222
 
223
- if skip_git_ops:
224
- return result.total
223
+ # --skip-commit and --dry-run skip commit; otherwise prompt
224
+ should_skip_commit = opts.skip_commit or opts.dry_run
225
+ if not should_skip_commit and prompt_utils.prompt_confirm(f"Commit changes to {dest.name}?", opts.no_prompt):
226
+ sync_commit_msg = f"chore: sync {config.name} from {current_sha[:8]}"
227
+ git_ops.commit_changes(dest_repo, sync_commit_msg)
228
+
229
+ verify_result = VerifyResult()
230
+ effective_verify = dest.resolve_verify(config.verify)
231
+ if not opts.skip_verify and effective_verify.steps:
232
+ verify_result = verify.run_verify_steps(
233
+ dest_repo, dest_root, effective_verify, dry_run=opts.dry_run, skip_commit=opts.skip_commit
234
+ )
235
+ verify.log_verify_summary(dest.name, verify_result)
236
+
237
+ if verify_result.status == VerifyStatus.FAILED:
238
+ logger.error(f"{dest.name}: Verification failed, stopping")
239
+ raise typer.Exit(EXIT_ERROR)
240
+
241
+ if verify_result.status == VerifyStatus.SKIPPED:
242
+ logger.warning(f"{dest.name}: Verification skipped due to failure")
243
+ return result.total
225
244
 
226
- _commit_and_pr(config, dest_repo, dest_root, dest, current_sha, src_repo_url, opts, read_log)
245
+ _push_and_pr(config, dest_repo, dest_root, dest, current_sha, src_repo_url, opts, read_log, verify_result)
227
246
  return result.total
228
247
 
229
248
 
@@ -264,6 +283,7 @@ def _sync_paths(
264
283
  config.name,
265
284
  opts.dry_run,
266
285
  opts.force_overwrite,
286
+ config.wrap_synced_files,
267
287
  )
268
288
  result.content_changes += changes
269
289
  result.synced_paths.update(paths)
@@ -314,10 +334,12 @@ def _sync_path(
314
334
  config_name: str,
315
335
  dry_run: bool,
316
336
  force_overwrite: bool,
337
+ wrap_synced_files: bool = False,
317
338
  ) -> tuple[int, set[Path]]:
318
339
  changes = 0
319
340
  synced: set[Path] = set()
320
341
 
342
+ should_wrap = mapping.should_wrap(wrap_synced_files)
321
343
  for src_path, dest_key, dest_path in _iter_sync_files(mapping, src_root, dest_root):
322
344
  if dest.is_skipped(dest_key):
323
345
  continue
@@ -330,6 +352,7 @@ def _sync_path(
330
352
  mapping.sync_mode,
331
353
  dry_run,
332
354
  force_overwrite,
355
+ should_wrap,
333
356
  )
334
357
  synced.add(dest_path)
335
358
 
@@ -345,6 +368,7 @@ def _copy_file(
345
368
  sync_mode: SyncMode,
346
369
  dry_run: bool,
347
370
  force_overwrite: bool = False,
371
+ should_wrap: bool = False,
348
372
  ) -> int:
349
373
  try:
350
374
  src_content = header.remove_header(src.read_text())
@@ -358,7 +382,7 @@ def _copy_file(
358
382
  return _handle_replace(src_content, dest_path, dry_run)
359
383
  case SyncMode.SYNC:
360
384
  skip_list = dest.skip_sections.get(dest_key, [])
361
- return _handle_sync(src_content, dest_path, skip_list, config_name, dry_run, force_overwrite)
385
+ return _handle_sync(src_content, dest_path, skip_list, config_name, dry_run, force_overwrite, should_wrap)
362
386
 
363
387
 
364
388
  def _copy_binary_file(src: Path, dest_path: Path, sync_mode: SyncMode, dry_run: bool) -> int:
@@ -402,10 +426,15 @@ def _handle_sync(
402
426
  config_name: str,
403
427
  dry_run: bool,
404
428
  force_overwrite: bool,
429
+ should_wrap: bool = False,
405
430
  ) -> int:
406
431
  if sections.has_sections(src_content, dest_path):
407
432
  return _handle_sync_sections(src_content, dest_path, skip_list, config_name, dry_run, force_overwrite)
408
433
 
434
+ if should_wrap:
435
+ wrapped = sections.wrap_in_synced_section(src_content, dest_path)
436
+ return _handle_sync_sections(wrapped, dest_path, skip_list, config_name, dry_run, force_overwrite)
437
+
409
438
  if dest_path.exists():
410
439
  existing = dest_path.read_text()
411
440
  has_hdr = header.has_header(existing)
@@ -436,7 +465,7 @@ def _handle_sync_sections(
436
465
  dry_run: bool,
437
466
  force_overwrite: bool,
438
467
  ) -> int:
439
- src_sections = sections.extract_sections(src_content, dest_path)
468
+ src_sections = sections.parse_sections(src_content, dest_path)
440
469
 
441
470
  if dest_path.exists():
442
471
  existing = dest_path.read_text()
@@ -445,6 +474,9 @@ def _handle_sync_sections(
445
474
  return 0
446
475
  dest_body = header.remove_header(existing)
447
476
  new_body = sections.replace_sections(dest_body, src_sections, dest_path, skip_list)
477
+ elif skip_list:
478
+ filtered = [s for s in src_sections if s.id not in skip_list]
479
+ new_body = sections.build_sections_content(filtered, dest_path)
448
480
  else:
449
481
  new_body = src_content
450
482
 
@@ -484,7 +516,7 @@ def _find_files_with_config(dest_root: Path, config_name: str) -> list[Path]:
484
516
  return result
485
517
 
486
518
 
487
- def _commit_and_pr(
519
+ def _push_and_pr(
488
520
  config: SrcConfig,
489
521
  repo,
490
522
  dest_root: Path,
@@ -493,19 +525,21 @@ def _commit_and_pr(
493
525
  src_repo_url: str,
494
526
  opts: CopyOptions,
495
527
  read_log: Callable[[], str],
528
+ verify_result: VerifyResult,
496
529
  ) -> None:
497
- if opts.local:
498
- logger.info("Local mode: skipping commit/push/PR")
530
+ if opts.skip_commit or opts.dry_run:
531
+ logger.info("Skipping push/PR (--skip-commit or --dry-run)")
499
532
  return
500
533
 
501
534
  copy_branch = dest.resolved_copy_branch(config.name)
502
535
 
503
- if not prompt_utils.prompt_confirm(f"Commit changes to {dest.name}?", opts.no_prompt):
504
- return
505
-
506
- commit_msg = f"chore: sync {config.name} from {sha[:8]}"
507
- git_ops.commit_changes(repo, commit_msg)
508
- typer.echo(f" Committed: {commit_msg}", err=True)
536
+ # Commit any remaining changes from verify steps without their own commit config
537
+ if git_ops.has_changes(repo):
538
+ if not prompt_utils.prompt_confirm(f"Commit remaining changes to {dest.name}?", opts.no_prompt):
539
+ return
540
+ commit_msg = f"chore: post-sync changes for {config.name}"
541
+ git_ops.commit_changes(repo, commit_msg)
542
+ typer.echo(f" Committed: {commit_msg}", err=True)
509
543
 
510
544
  if not prompt_utils.prompt_confirm(f"Push {dest.name} to origin?", opts.no_prompt):
511
545
  return
@@ -524,6 +558,9 @@ def _commit_and_pr(
524
558
  dest_name=dest.name,
525
559
  )
526
560
 
561
+ if verify_result.failures:
562
+ pr_body = _append_verify_warnings(pr_body, verify_result.failures)
563
+
527
564
  title = opts.pr_title.format(name=config.name, dest_name=dest.name)
528
565
  pr_url = git_ops.create_or_update_pr(
529
566
  dest_root,
@@ -536,3 +573,10 @@ def _commit_and_pr(
536
573
  )
537
574
  if pr_url:
538
575
  typer.echo(f" Created PR: {pr_url}", err=True)
576
+
577
+
578
+ def _append_verify_warnings(body: str, failures: list[StepFailure]) -> str:
579
+ body += "\n\n---\n## Verification Warnings\n"
580
+ for f in failures:
581
+ body += f"\n- `{f.step}` failed (exit code {f.returncode}, strategy: {f.on_fail})"
582
+ return body
@@ -10,17 +10,16 @@ from pathlib import Path
10
10
  import typer
11
11
  from git import Repo
12
12
 
13
- from path_sync._internal import cmd_options, git_ops, prompt_utils
13
+ from path_sync._internal import cmd_options, git_ops, prompt_utils, verify
14
14
  from path_sync._internal.log_capture import capture_log
15
- from path_sync._internal.models import Destination, find_repo_root
15
+ from path_sync._internal.models import Destination, OnFailStrategy, find_repo_root
16
16
  from path_sync._internal.models_dep import (
17
17
  DepConfig,
18
- OnFailStrategy,
19
18
  UpdateEntry,
20
- VerifyConfig,
21
19
  resolve_dep_config_path,
22
20
  )
23
21
  from path_sync._internal.typer_app import app
22
+ from path_sync._internal.verify import StepFailure, VerifyStatus
24
23
  from path_sync._internal.yaml_utils import load_yaml_model
25
24
 
26
25
  logger = logging.getLogger(__name__)
@@ -33,12 +32,9 @@ class Status(StrEnum):
33
32
  NO_CHANGES = "no_changes"
34
33
  FAILED = "failed"
35
34
 
36
-
37
- @dataclass
38
- class StepFailure:
39
- step: str
40
- returncode: int
41
- on_fail: OnFailStrategy
35
+ @classmethod
36
+ def from_verify_status(cls, vs: VerifyStatus) -> Status:
37
+ return cls(vs.value)
42
38
 
43
39
 
44
40
  @dataclass
@@ -163,15 +159,17 @@ def _process_single_repo_inner(
163
159
  def _run_updates(updates: list[UpdateEntry], repo_path: Path) -> StepFailure | None:
164
160
  try:
165
161
  for update in updates:
166
- _run_command(update.command, repo_path / update.workdir)
162
+ verify.run_command(update.command, repo_path / update.workdir)
167
163
  return None
168
164
  except subprocess.CalledProcessError as e:
169
165
  return StepFailure(step=e.cmd, returncode=e.returncode, on_fail=OnFailStrategy.SKIP)
170
166
 
171
167
 
172
- def _verify_repo(repo: Repo, repo_path: Path, verify: VerifyConfig, dest: Destination) -> RepoResult:
173
- status, failures = _run_verify_steps(repo, repo_path, verify)
174
- return RepoResult(dest=dest, repo_path=repo_path, status=status, failures=failures)
168
+ def _verify_repo(repo: Repo, repo_path: Path, fallback_verify: verify.VerifyConfig, dest: Destination) -> RepoResult:
169
+ effective_verify = dest.resolve_verify(fallback_verify)
170
+ result = verify.run_verify_steps(repo, repo_path, effective_verify)
171
+ status = Status.from_verify_status(result.status)
172
+ return RepoResult(dest=dest, repo_path=repo_path, status=status, failures=result.failures)
175
173
 
176
174
 
177
175
  def _create_prs(config: DepConfig, results: list[RepoResult], opts: DepUpdateOptions) -> None:
@@ -223,46 +221,6 @@ def _ensure_repo(dest: Destination, repo_path: Path, default_branch: str) -> Rep
223
221
  return git_ops.clone_repo(dest.repo_url, repo_path)
224
222
 
225
223
 
226
- def _run_command(cmd: str, cwd: Path) -> None:
227
- logger.info(f"Running: {cmd}")
228
- result = subprocess.run(cmd, shell=True, cwd=cwd, capture_output=True, text=True)
229
- prefix = cmd.split()[0]
230
- for line in result.stdout.strip().splitlines():
231
- logger.info(f"[{prefix}] {line}")
232
- for line in result.stderr.strip().splitlines():
233
- if result.returncode != 0:
234
- logger.error(f"[{prefix}] {line}")
235
- else:
236
- logger.info(f"[{prefix}] {line}")
237
- if result.returncode != 0:
238
- raise subprocess.CalledProcessError(result.returncode, cmd, output=result.stdout, stderr=result.stderr)
239
-
240
-
241
- def _run_verify_steps(repo: Repo, repo_path: Path, verify: VerifyConfig) -> tuple[Status, list[StepFailure]]:
242
- failures: list[StepFailure] = []
243
-
244
- for step in verify.steps:
245
- on_fail = step.on_fail or verify.on_fail
246
-
247
- try:
248
- _run_command(step.run, repo_path)
249
- except subprocess.CalledProcessError as e:
250
- failure = StepFailure(step=step.run, returncode=e.returncode, on_fail=on_fail)
251
- match on_fail:
252
- case OnFailStrategy.FAIL:
253
- return (Status.FAILED, [failure])
254
- case OnFailStrategy.SKIP:
255
- return (Status.SKIPPED, [failure])
256
- case OnFailStrategy.WARN:
257
- failures.append(failure)
258
- continue
259
-
260
- if step.commit:
261
- git_ops.stage_and_commit(repo, step.commit.add_paths, step.commit.message)
262
-
263
- return (Status.WARN if failures else Status.PASSED, failures)
264
-
265
-
266
224
  def _build_pr_body(log_content: str, failures: list[StepFailure]) -> str:
267
225
  body = "Automated dependency update."
268
226
 
@@ -35,12 +35,38 @@ class SyncMode(StrEnum):
35
35
  SCAFFOLD = "scaffold"
36
36
 
37
37
 
38
+ class OnFailStrategy(StrEnum):
39
+ SKIP = "skip"
40
+ FAIL = "fail"
41
+ WARN = "warn"
42
+
43
+
44
+ class CommitConfig(BaseModel):
45
+ message: str
46
+ add_paths: list[str] = Field(default_factory=lambda: ["."])
47
+
48
+
49
+ class VerifyStep(BaseModel):
50
+ run: str
51
+ commit: CommitConfig | None = None
52
+ on_fail: OnFailStrategy | None = None
53
+
54
+
55
+ class VerifyConfig(BaseModel):
56
+ on_fail: OnFailStrategy = OnFailStrategy.WARN
57
+ steps: list[VerifyStep] = Field(default_factory=list)
58
+
59
+
38
60
  class PathMapping(BaseModel):
39
61
  src_path: str
40
62
  dest_path: str = ""
41
63
  sync_mode: SyncMode = SyncMode.SYNC
42
64
  exclude_dirs: set[str] = Field(default_factory=_default_exclude_dirs)
43
65
  exclude_file_patterns: set[str] = Field(default_factory=set)
66
+ wrap: bool | None = None
67
+
68
+ def should_wrap(self, config_default: bool) -> bool:
69
+ return self.wrap if self.wrap is not None else config_default
44
70
 
45
71
  def resolved_dest_path(self) -> str:
46
72
  return self.dest_path or self.src_path
@@ -127,6 +153,7 @@ class Destination(BaseModel):
127
153
  default_branch: str = "main"
128
154
  skip_sections: dict[str, list[str]] = Field(default_factory=dict)
129
155
  skip_file_patterns: set[str] = Field(default_factory=set)
156
+ verify: VerifyConfig | None = None
130
157
 
131
158
  def resolved_copy_branch(self, config_name: str) -> str:
132
159
  return self.copy_branch or f"sync/{config_name}"
@@ -134,6 +161,9 @@ class Destination(BaseModel):
134
161
  def is_skipped(self, dest_key: str) -> bool:
135
162
  return any(fnmatch.fnmatch(dest_key, pat) for pat in self.skip_file_patterns)
136
163
 
164
+ def resolve_verify(self, fallback: VerifyConfig | None) -> VerifyConfig:
165
+ return self.verify if self.verify is not None else (fallback or VerifyConfig())
166
+
137
167
 
138
168
  class SrcConfig(BaseModel):
139
169
  CONFIG_EXT: ClassVar[str] = ".src.yaml"
@@ -146,6 +176,8 @@ class SrcConfig(BaseModel):
146
176
  pr_defaults: PRDefaults = Field(default_factory=PRDefaults)
147
177
  paths: list[PathMapping] = Field(default_factory=list)
148
178
  destinations: list[Destination] = Field(default_factory=list)
179
+ verify: VerifyConfig | None = None
180
+ wrap_synced_files: bool = False
149
181
 
150
182
  def find_destination(self, name: str) -> Destination:
151
183
  for dest in self.destinations:
@@ -1,37 +1,20 @@
1
1
  from __future__ import annotations
2
2
 
3
- from enum import StrEnum
4
3
  from pathlib import Path
5
4
  from typing import ClassVar
6
5
 
7
6
  from pydantic import BaseModel, Field
8
7
 
9
- from path_sync._internal.models import Destination, PRFieldsBase, SrcConfig, resolve_config_path
8
+ from path_sync._internal.models import (
9
+ Destination,
10
+ PRFieldsBase,
11
+ SrcConfig,
12
+ VerifyConfig,
13
+ resolve_config_path,
14
+ )
10
15
  from path_sync._internal.yaml_utils import load_yaml_model
11
16
 
12
17
 
13
- class OnFailStrategy(StrEnum):
14
- SKIP = "skip"
15
- FAIL = "fail"
16
- WARN = "warn"
17
-
18
-
19
- class CommitConfig(BaseModel):
20
- message: str
21
- add_paths: list[str] = Field(default_factory=lambda: ["."])
22
-
23
-
24
- class VerifyStep(BaseModel):
25
- run: str
26
- commit: CommitConfig | None = None
27
- on_fail: OnFailStrategy | None = None
28
-
29
-
30
- class VerifyConfig(BaseModel):
31
- on_fail: OnFailStrategy = OnFailStrategy.SKIP
32
- steps: list[VerifyStep] = Field(default_factory=list)
33
-
34
-
35
18
  class UpdateEntry(BaseModel):
36
19
  workdir: str = "."
37
20
  command: str
@@ -0,0 +1,97 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import subprocess
5
+ from dataclasses import dataclass, field
6
+ from enum import StrEnum
7
+ from pathlib import Path
8
+
9
+ from git import Repo
10
+
11
+ from path_sync._internal import git_ops
12
+ from path_sync._internal.models import OnFailStrategy, VerifyConfig
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class VerifyStatus(StrEnum):
18
+ PASSED = "passed"
19
+ SKIPPED = "skipped"
20
+ WARN = "warn"
21
+ FAILED = "failed"
22
+
23
+
24
+ @dataclass
25
+ class StepFailure:
26
+ step: str
27
+ returncode: int
28
+ on_fail: OnFailStrategy
29
+
30
+
31
+ @dataclass
32
+ class VerifyResult:
33
+ status: VerifyStatus = VerifyStatus.PASSED
34
+ failures: list[StepFailure] = field(default_factory=list)
35
+
36
+
37
+ def run_command(cmd: str, cwd: Path, dry_run: bool = False) -> None:
38
+ if dry_run:
39
+ logger.info(f"[DRY RUN] Would run: {cmd} from {cwd}")
40
+ return
41
+ logger.info(f"Running: {cmd}")
42
+ result = subprocess.run(cmd, shell=True, cwd=cwd, capture_output=True, text=True)
43
+ prefix = cmd.split()[0]
44
+ for line in result.stdout.strip().splitlines():
45
+ logger.info(f"[{prefix}] {line}")
46
+ for line in result.stderr.strip().splitlines():
47
+ if result.returncode != 0:
48
+ logger.error(f"[{prefix}] {line}")
49
+ else:
50
+ logger.info(f"[{prefix}] {line}")
51
+ if result.returncode != 0:
52
+ raise subprocess.CalledProcessError(result.returncode, cmd, output=result.stdout, stderr=result.stderr)
53
+
54
+
55
+ def run_verify_steps(
56
+ repo: Repo, repo_path: Path, verify: VerifyConfig, dry_run: bool = False, skip_commit: bool = False
57
+ ) -> VerifyResult:
58
+ if not verify.steps:
59
+ return VerifyResult()
60
+
61
+ failures: list[StepFailure] = []
62
+
63
+ for step in verify.steps:
64
+ on_fail = step.on_fail or verify.on_fail
65
+
66
+ try:
67
+ run_command(step.run, repo_path, dry_run=dry_run)
68
+ except subprocess.CalledProcessError as e:
69
+ failure = StepFailure(step=step.run, returncode=e.returncode, on_fail=on_fail)
70
+ match on_fail:
71
+ case OnFailStrategy.FAIL:
72
+ return VerifyResult(status=VerifyStatus.FAILED, failures=[failure])
73
+ case OnFailStrategy.SKIP:
74
+ return VerifyResult(status=VerifyStatus.SKIPPED, failures=[failure])
75
+ case OnFailStrategy.WARN:
76
+ failures.append(failure)
77
+ continue
78
+
79
+ if step.commit and not dry_run and not skip_commit:
80
+ git_ops.stage_and_commit(repo, step.commit.add_paths, step.commit.message)
81
+
82
+ status = VerifyStatus.WARN if failures else VerifyStatus.PASSED
83
+ return VerifyResult(status=status, failures=failures)
84
+
85
+
86
+ def log_verify_summary(name: str, result: VerifyResult) -> None:
87
+ match result.status:
88
+ case VerifyStatus.PASSED:
89
+ logger.info(f"Verification passed for {name}")
90
+ case VerifyStatus.WARN:
91
+ logger.warning(f"Verification completed with warnings for {name}")
92
+ for f in result.failures:
93
+ logger.warning(f" {f.step} failed (exit {f.returncode})")
94
+ case VerifyStatus.SKIPPED:
95
+ logger.warning(f"Verification skipped for {name}")
96
+ case VerifyStatus.FAILED:
97
+ logger.error(f"Verification failed for {name}")
@@ -1,18 +1,18 @@
1
1
  # Generated by pkg-ext
2
2
  from path_sync._internal.cmd_dep_update import dep_update as _dep_update
3
- from path_sync._internal.models_dep import CommitConfig as _CommitConfig
3
+ from path_sync._internal.models import CommitConfig as _CommitConfig
4
+ from path_sync._internal.models import OnFailStrategy as _OnFailStrategy
5
+ from path_sync._internal.models import VerifyConfig as _VerifyConfig
6
+ from path_sync._internal.models import VerifyStep as _VerifyStep
4
7
  from path_sync._internal.models_dep import DepConfig as _DepConfig
5
- from path_sync._internal.models_dep import OnFailStrategy as _OnFailStrategy
6
8
  from path_sync._internal.models_dep import PRConfig as _PRConfig
7
9
  from path_sync._internal.models_dep import UpdateEntry as _UpdateEntry
8
- from path_sync._internal.models_dep import VerifyConfig as _VerifyConfig
9
- from path_sync._internal.models_dep import VerifyStep as _VerifyStep
10
10
 
11
11
  dep_update = _dep_update
12
12
  CommitConfig = _CommitConfig
13
- DepConfig = _DepConfig
14
13
  OnFailStrategy = _OnFailStrategy
15
- PRConfig = _PRConfig
16
- UpdateEntry = _UpdateEntry
17
14
  VerifyConfig = _VerifyConfig
18
15
  VerifyStep = _VerifyStep
16
+ DepConfig = _DepConfig
17
+ PRConfig = _PRConfig
18
+ UpdateEntry = _UpdateEntry
@@ -5,6 +5,7 @@ from pathlib import Path
5
5
  from zero_3rdparty.sections import (
6
6
  Section,
7
7
  SectionChanges,
8
+ SectionPart,
8
9
  get_comment_config,
9
10
  )
10
11
  from zero_3rdparty.sections import (
@@ -25,16 +26,22 @@ from zero_3rdparty.sections import (
25
26
  from zero_3rdparty.sections import (
26
27
  wrap_in_default_section as _wrap_in_default_section,
27
28
  )
29
+ from zero_3rdparty.sections import (
30
+ wrap_section as _wrap_section,
31
+ )
28
32
 
29
33
  __all__ = [
30
34
  "Section",
31
35
  "SectionChanges",
36
+ "SectionPart",
37
+ "build_sections_content",
32
38
  "changed_sections",
33
39
  "extract_sections",
34
40
  "has_sections",
35
41
  "parse_sections",
36
42
  "replace_sections",
37
43
  "wrap_in_default_section",
44
+ "wrap_in_synced_section",
38
45
  ]
39
46
 
40
47
  TOOL_NAME = "path-sync"
@@ -52,13 +59,17 @@ def wrap_in_default_section(content: str, path: Path) -> str:
52
59
  return _wrap_in_default_section(content, TOOL_NAME, get_comment_config(path))
53
60
 
54
61
 
62
+ def wrap_in_synced_section(content: str, path: Path) -> str:
63
+ return _wrap_section(content, "synced", TOOL_NAME, get_comment_config(path))
64
+
65
+
55
66
  def extract_sections(content: str, path: Path) -> dict[str, str]:
56
67
  return _extract_sections(content, TOOL_NAME, get_comment_config(path), str(path))
57
68
 
58
69
 
59
70
  def replace_sections(
60
71
  dest_content: str,
61
- src_sections: dict[str, str],
72
+ src_sections: list[Section],
62
73
  path: Path,
63
74
  skip_sections: list[str] | None = None,
64
75
  *,
@@ -81,3 +92,10 @@ def changed_sections(
81
92
  skip: set[str] | None = None,
82
93
  ) -> SectionChanges:
83
94
  return _changed_sections(baseline_content, current_content, TOOL_NAME, get_comment_config(path), skip, str(path))
95
+
96
+
97
+ def build_sections_content(section_list: list[Section], path: Path) -> str:
98
+ """Build file content from a list of Section objects."""
99
+ config = get_comment_config(path)
100
+ parts = [_wrap_section(s.content, s.id, TOOL_NAME, config) for s in section_list]
101
+ return "\n".join(parts)
@@ -2,7 +2,7 @@
2
2
 
3
3
  [project]
4
4
  name = "path-sync"
5
- version = "0.5.0"
5
+ version = "0.6.0"
6
6
  description = "Sync files from a source repo to multiple destination repos"
7
7
  requires-python = ">=3.13"
8
8
  license = "MIT"
File without changes
File without changes
File without changes
File without changes