path-sync 0.7.2__tar.gz → 0.7.3__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 (30) hide show
  1. {path_sync-0.7.2 → path_sync-0.7.3}/PKG-INFO +31 -2
  2. {path_sync-0.7.2 → path_sync-0.7.3}/README.md +30 -1
  3. {path_sync-0.7.2 → path_sync-0.7.3}/path_sync/__init__.py +1 -1
  4. {path_sync-0.7.2 → path_sync-0.7.3}/path_sync/_internal/cmd_copy.py +36 -2
  5. {path_sync-0.7.2 → path_sync-0.7.3}/path_sync/_internal/cmd_dep_update.py +10 -2
  6. {path_sync-0.7.2 → path_sync-0.7.3}/path_sync/_internal/git_ops.py +31 -0
  7. {path_sync-0.7.2 → path_sync-0.7.3}/path_sync/_internal/models.py +31 -2
  8. {path_sync-0.7.2 → path_sync-0.7.3}/path_sync/_internal/models_dep.py +1 -0
  9. {path_sync-0.7.2 → path_sync-0.7.3}/pyproject.toml +1 -1
  10. {path_sync-0.7.2 → path_sync-0.7.3}/.gitignore +0 -0
  11. {path_sync-0.7.2 → path_sync-0.7.3}/LICENSE +0 -0
  12. {path_sync-0.7.2 → path_sync-0.7.3}/path_sync/__main__.py +0 -0
  13. {path_sync-0.7.2 → path_sync-0.7.3}/path_sync/_internal/__init__.py +0 -0
  14. {path_sync-0.7.2 → path_sync-0.7.3}/path_sync/_internal/auto_merge.py +0 -0
  15. {path_sync-0.7.2 → path_sync-0.7.3}/path_sync/_internal/cmd_boot.py +0 -0
  16. {path_sync-0.7.2 → path_sync-0.7.3}/path_sync/_internal/cmd_options.py +0 -0
  17. {path_sync-0.7.2 → path_sync-0.7.3}/path_sync/_internal/cmd_validate.py +0 -0
  18. {path_sync-0.7.2 → path_sync-0.7.3}/path_sync/_internal/file_utils.py +0 -0
  19. {path_sync-0.7.2 → path_sync-0.7.3}/path_sync/_internal/header.py +0 -0
  20. {path_sync-0.7.2 → path_sync-0.7.3}/path_sync/_internal/log_capture.py +0 -0
  21. {path_sync-0.7.2 → path_sync-0.7.3}/path_sync/_internal/prompt_utils.py +0 -0
  22. {path_sync-0.7.2 → path_sync-0.7.3}/path_sync/_internal/typer_app.py +0 -0
  23. {path_sync-0.7.2 → path_sync-0.7.3}/path_sync/_internal/validation.py +0 -0
  24. {path_sync-0.7.2 → path_sync-0.7.3}/path_sync/_internal/verify.py +0 -0
  25. {path_sync-0.7.2 → path_sync-0.7.3}/path_sync/_internal/yaml_utils.py +0 -0
  26. {path_sync-0.7.2 → path_sync-0.7.3}/path_sync/config.py +0 -0
  27. {path_sync-0.7.2 → path_sync-0.7.3}/path_sync/copy.py +0 -0
  28. {path_sync-0.7.2 → path_sync-0.7.3}/path_sync/dep_update.py +0 -0
  29. {path_sync-0.7.2 → path_sync-0.7.3}/path_sync/sections.py +0 -0
  30. {path_sync-0.7.2 → path_sync-0.7.3}/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.7.2
3
+ Version: 0.7.3
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
@@ -43,6 +43,8 @@ Sync files from a source repo to multiple destination repos.
43
43
  - Files with headers are updated on each sync
44
44
  - Remove a header to opt-out (file becomes DEST-owned)
45
45
  - Orphaned files (removed from SRC) are deleted in DEST
46
+ - Idempotent: PR body is left unchanged when already synced from an equal or newer source commit
47
+ - Stale PRs auto-close when source and destination are already in sync (zero file changes)
46
48
 
47
49
  ## Installation
48
50
 
@@ -225,10 +227,24 @@ destinations:
225
227
  | `paths` | Files/globs to sync (see path options below) |
226
228
  | `destinations` | Target repos with sync settings |
227
229
  | `header_config` | Comment style per extension (has defaults) |
228
- | `pr_defaults` | PR title, labels, reviewers, assignees |
230
+ | `pr_defaults` | PR title, body template, labels, reviewers, assignees |
229
231
  | `wrap_synced_files` | Wrap synced files in section markers (default: `false`) |
232
+ | `keep_pr_on_no_changes` | Keep stale PR open instead of auto-closing when sync produces zero changes (default: `false`) |
233
+ | `force_resync` | Ignore the "PR already synced from newer commit" check, always run the full sync (default: `false`) |
230
234
  | `verify` | Verification steps to run after syncing (see [Verify Steps](#verify-steps-in-copy)) |
231
235
 
236
+ **`body_template` variables** (available in `pr_defaults.body_template`):
237
+
238
+ | Variable | Description |
239
+ |----------|-------------|
240
+ | `{src_repo_name}` | Source repo name (derived from remote URL) |
241
+ | `{src_repo_url}` | Source repo URL |
242
+ | `{src_sha}` | Full SHA of the source commit |
243
+ | `{src_sha_short}` | Short SHA (first 8 chars) of the source commit |
244
+ | `{src_commit_ts}` | ISO 8601 timestamp of the source commit |
245
+ | `{sync_log}` | Log of synced file operations |
246
+ | `{dest_name}` | Destination name |
247
+
232
248
  **Path options**:
233
249
 
234
250
  | Field | Description |
@@ -301,6 +317,18 @@ Comment style is extension-aware:
301
317
 
302
318
  Remove this header to opt-out of future syncs for that file.
303
319
 
320
+ ## PR Body Metadata
321
+
322
+ path-sync embeds a hidden HTML comment in PR bodies to track the source commit:
323
+
324
+ ```html
325
+ <!-- path-sync: sha=abc1234 ts=2026-02-12T10:00:00+00:00 -->
326
+ ```
327
+
328
+ This is primarily useful when syncing from a feature/PR branch instead of `main`. When a scheduled CI job runs against `main`, the source commit timestamp will be older than the PR branch sync. path-sync detects this and skips the PR body overwrite, preserving the newer sync state.
329
+
330
+ If `copy` or `dep-update` finds zero file changes, any open PR for the sync branch is closed automatically (disable with `keep_pr_on_no_changes: true`). The timestamp check can be bypassed with `force_resync: true` (copy only). Existing PRs without metadata are always overwritten.
331
+
304
332
  ## GitHub Actions
305
333
 
306
334
  ### Source repo workflow
@@ -440,6 +468,7 @@ pr:
440
468
  | `updates` | Commands to run (in order) |
441
469
  | `verify.on_fail` | Default failure strategy: `skip`, `fail`, `warn` |
442
470
  | `verify.steps` | Verification commands with optional commit/on_fail |
471
+ | `keep_pr_on_no_changes` | Keep stale PR open instead of auto-closing when no changes (default: `false`) |
443
472
  | `pr.auto_merge` | Enable GitHub auto-merge after PR creation |
444
473
 
445
474
  ### CLI Flags
@@ -25,6 +25,8 @@ Sync files from a source repo to multiple destination repos.
25
25
  - Files with headers are updated on each sync
26
26
  - Remove a header to opt-out (file becomes DEST-owned)
27
27
  - Orphaned files (removed from SRC) are deleted in DEST
28
+ - Idempotent: PR body is left unchanged when already synced from an equal or newer source commit
29
+ - Stale PRs auto-close when source and destination are already in sync (zero file changes)
28
30
 
29
31
  ## Installation
30
32
 
@@ -207,10 +209,24 @@ destinations:
207
209
  | `paths` | Files/globs to sync (see path options below) |
208
210
  | `destinations` | Target repos with sync settings |
209
211
  | `header_config` | Comment style per extension (has defaults) |
210
- | `pr_defaults` | PR title, labels, reviewers, assignees |
212
+ | `pr_defaults` | PR title, body template, labels, reviewers, assignees |
211
213
  | `wrap_synced_files` | Wrap synced files in section markers (default: `false`) |
214
+ | `keep_pr_on_no_changes` | Keep stale PR open instead of auto-closing when sync produces zero changes (default: `false`) |
215
+ | `force_resync` | Ignore the "PR already synced from newer commit" check, always run the full sync (default: `false`) |
212
216
  | `verify` | Verification steps to run after syncing (see [Verify Steps](#verify-steps-in-copy)) |
213
217
 
218
+ **`body_template` variables** (available in `pr_defaults.body_template`):
219
+
220
+ | Variable | Description |
221
+ |----------|-------------|
222
+ | `{src_repo_name}` | Source repo name (derived from remote URL) |
223
+ | `{src_repo_url}` | Source repo URL |
224
+ | `{src_sha}` | Full SHA of the source commit |
225
+ | `{src_sha_short}` | Short SHA (first 8 chars) of the source commit |
226
+ | `{src_commit_ts}` | ISO 8601 timestamp of the source commit |
227
+ | `{sync_log}` | Log of synced file operations |
228
+ | `{dest_name}` | Destination name |
229
+
214
230
  **Path options**:
215
231
 
216
232
  | Field | Description |
@@ -283,6 +299,18 @@ Comment style is extension-aware:
283
299
 
284
300
  Remove this header to opt-out of future syncs for that file.
285
301
 
302
+ ## PR Body Metadata
303
+
304
+ path-sync embeds a hidden HTML comment in PR bodies to track the source commit:
305
+
306
+ ```html
307
+ <!-- path-sync: sha=abc1234 ts=2026-02-12T10:00:00+00:00 -->
308
+ ```
309
+
310
+ This is primarily useful when syncing from a feature/PR branch instead of `main`. When a scheduled CI job runs against `main`, the source commit timestamp will be older than the PR branch sync. path-sync detects this and skips the PR body overwrite, preserving the newer sync state.
311
+
312
+ If `copy` or `dep-update` finds zero file changes, any open PR for the sync branch is closed automatically (disable with `keep_pr_on_no_changes: true`). The timestamp check can be bypassed with `force_resync: true` (copy only). Existing PRs without metadata are always overwritten.
313
+
286
314
  ## GitHub Actions
287
315
 
288
316
  ### Source repo workflow
@@ -422,6 +450,7 @@ pr:
422
450
  | `updates` | Commands to run (in order) |
423
451
  | `verify.on_fail` | Default failure strategy: `skip`, `fail`, `warn` |
424
452
  | `verify.steps` | Verification commands with optional commit/on_fail |
453
+ | `keep_pr_on_no_changes` | Keep stale PR open instead of auto-closing when no changes (default: `false`) |
425
454
  | `pr.auto_merge` | Enable GitHub auto-merge after PR creation |
426
455
 
427
456
  ### CLI Flags
@@ -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.7.2"
8
+ VERSION = "0.7.3"
9
9
  __all__ = [
10
10
  "copy",
11
11
  "dep_update",
@@ -20,6 +20,7 @@ from path_sync._internal.models import (
20
20
  SrcConfig,
21
21
  SyncMode,
22
22
  find_repo_root,
23
+ pr_already_synced,
23
24
  resolve_config_path,
24
25
  )
25
26
  from path_sync._internal.typer_app import app
@@ -194,6 +195,7 @@ def copy(
194
195
  def _run_copy(config: SrcConfig, src_root: Path, dest_filter: str, opts: CopyOptions) -> int:
195
196
  src_repo = git_ops.get_repo(src_root)
196
197
  current_sha = git_ops.get_current_sha(src_repo)
198
+ commit_ts = git_ops.get_commit_timestamp(src_repo)
197
199
  src_repo_url = git_ops.get_remote_url(src_repo, config.git_remote)
198
200
 
199
201
  destinations = config.destinations
@@ -205,7 +207,9 @@ def _run_copy(config: SrcConfig, src_root: Path, dest_filter: str, opts: CopyOpt
205
207
  pr_refs: list[PRRef] = []
206
208
  for dest in destinations:
207
209
  with capture_log(dest.name) as read_log:
208
- changes, pr_ref = _sync_destination(config, dest, src_root, current_sha, src_repo_url, opts, read_log)
210
+ changes, pr_ref = _sync_destination(
211
+ config, dest, src_root, current_sha, commit_ts, src_repo_url, opts, read_log
212
+ )
209
213
  total_changes += changes
210
214
  if pr_ref:
211
215
  pr_refs.append(pr_ref)
@@ -216,11 +220,33 @@ def _run_copy(config: SrcConfig, src_root: Path, dest_filter: str, opts: CopyOpt
216
220
  return total_changes
217
221
 
218
222
 
223
+ def _close_stale_pr(dest_root: Path, copy_branch: str, opts: CopyOptions, config: SrcConfig) -> None:
224
+ if opts.dry_run or opts.no_pr or config.keep_pr_on_no_changes:
225
+ return
226
+ if git_ops.has_open_pr(dest_root, copy_branch):
227
+ git_ops.close_pr(dest_root, copy_branch, "Closing: source and destination are in sync, no changes needed")
228
+
229
+
230
+ def _skip_already_synced(
231
+ dest_name: str, dest_root: Path, copy_branch: str, commit_ts: str, opts: CopyOptions, config: SrcConfig
232
+ ) -> bool:
233
+ if opts.skip_commit or opts.dry_run or opts.no_pr or config.force_resync:
234
+ return False
235
+ existing_body = git_ops.get_pr_body(dest_root, copy_branch)
236
+ if metadata := pr_already_synced(existing_body, commit_ts):
237
+ logger.info(
238
+ f"{dest_name}: open PR already synced from {metadata.sha[:8]} ({metadata.ts} >= {commit_ts}), skipping"
239
+ )
240
+ return True
241
+ return False
242
+
243
+
219
244
  def _sync_destination(
220
245
  config: SrcConfig,
221
246
  dest: Destination,
222
247
  src_root: Path,
223
248
  current_sha: str,
249
+ commit_ts: str,
224
250
  src_repo_url: str,
225
251
  opts: CopyOptions,
226
252
  read_log: Callable[[], str],
@@ -233,6 +259,9 @@ def _sync_destination(
233
259
  dest_repo = _ensure_dest_repo(dest, dest_root, opts.dry_run)
234
260
  copy_branch = dest.resolved_copy_branch(config.name)
235
261
 
262
+ if _skip_already_synced(dest.name, dest_root, copy_branch, commit_ts, opts, config):
263
+ return 0, None
264
+
236
265
  if not opts.no_checkout and prompt_utils.prompt_confirm(f"Switch {dest.name} to {copy_branch}?", opts.no_prompt):
237
266
  git_ops.prepare_copy_branch(
238
267
  repo=dest_repo,
@@ -245,6 +274,7 @@ def _sync_destination(
245
274
 
246
275
  if result.total == 0:
247
276
  logger.info(f"{dest.name}: No changes")
277
+ _close_stale_pr(dest_root, copy_branch, opts, config)
248
278
  return 0, None
249
279
 
250
280
  should_skip_commit = opts.skip_commit or opts.dry_run
@@ -268,7 +298,9 @@ def _sync_destination(
268
298
  logger.warning(f"{dest.name}: Verification skipped due to failure")
269
299
  return result.total, None
270
300
 
271
- pr_ref = _push_and_pr(config, dest_repo, dest_root, dest, current_sha, src_repo_url, opts, read_log, verify_result)
301
+ pr_ref = _push_and_pr(
302
+ config, dest_repo, dest_root, dest, current_sha, commit_ts, src_repo_url, opts, read_log, verify_result
303
+ )
272
304
  return result.total, pr_ref
273
305
 
274
306
 
@@ -552,6 +584,7 @@ def _push_and_pr(
552
584
  dest_root: Path,
553
585
  dest: Destination,
554
586
  sha: str,
587
+ commit_ts: str,
555
588
  src_repo_url: str,
556
589
  opts: CopyOptions,
557
590
  read_log: Callable[[], str],
@@ -585,6 +618,7 @@ def _push_and_pr(
585
618
  src_sha=sha,
586
619
  sync_log=sync_log,
587
620
  dest_name=dest.name,
621
+ src_commit_ts=commit_ts,
588
622
  )
589
623
 
590
624
  if verify_result.failures:
@@ -119,8 +119,16 @@ def _update_and_validate(
119
119
  logger.error(f"{dest.name}: Verification failed, stopping")
120
120
  raise typer.Exit(1)
121
121
 
122
- if result.status != Status.NO_CHANGES:
123
- results.append(result)
122
+ if result.status == Status.NO_CHANGES:
123
+ if (
124
+ not opts.dry_run
125
+ and not config.keep_pr_on_no_changes
126
+ and git_ops.has_open_pr(result.repo_path, config.pr.branch)
127
+ ):
128
+ git_ops.close_pr(result.repo_path, config.pr.branch, "Closing: no dependency changes needed")
129
+ continue
130
+
131
+ results.append(result)
124
132
 
125
133
  return results
126
134
 
@@ -97,6 +97,10 @@ def get_current_sha(repo: Repo) -> str:
97
97
  return repo.head.commit.hexsha
98
98
 
99
99
 
100
+ def get_commit_timestamp(repo: Repo) -> str:
101
+ return repo.head.commit.committed_datetime.isoformat()
102
+
103
+
100
104
  def get_remote_url(repo: Repo, remote_name: str = "origin") -> str:
101
105
  try:
102
106
  return repo.remote(remote_name).url
@@ -202,6 +206,33 @@ def update_pr_body(repo_path: Path, branch: str, body: str) -> bool:
202
206
  return True
203
207
 
204
208
 
209
+ def get_pr_body(repo_path: Path, branch: str) -> str | None:
210
+ cmd = ["gh", "pr", "view", branch, "--json", "body,state", "-q", 'select(.state == "OPEN") | .body']
211
+ result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True)
212
+ if result.returncode != 0:
213
+ return None
214
+ body = result.stdout.strip()
215
+ return body or None
216
+
217
+
218
+ def has_open_pr(repo_path: Path, branch: str) -> bool:
219
+ cmd = ["gh", "pr", "view", branch, "--json", "state", "-q", ".state"]
220
+ result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True)
221
+ if result.returncode != 0:
222
+ return False
223
+ return result.stdout.strip() == "OPEN"
224
+
225
+
226
+ def close_pr(repo_path: Path, branch: str, comment: str) -> bool:
227
+ cmd = ["gh", "pr", "close", branch, "-c", comment]
228
+ result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True)
229
+ if result.returncode != 0:
230
+ logger.warning(f"Failed to close PR for {branch}: {result.stderr}")
231
+ return False
232
+ logger.info(f"Closed PR for {branch}")
233
+ return True
234
+
235
+
205
236
  def create_or_update_pr(
206
237
  repo_path: Path,
207
238
  branch: str,
@@ -2,9 +2,10 @@ from __future__ import annotations
2
2
 
3
3
  import fnmatch
4
4
  import glob as glob_mod
5
+ import re
5
6
  from enum import StrEnum
6
7
  from pathlib import Path
7
- from typing import ClassVar
8
+ from typing import ClassVar, NamedTuple
8
9
 
9
10
  from pydantic import BaseModel, Field, model_validator
10
11
 
@@ -104,6 +105,29 @@ class PathMapping(BaseModel):
104
105
 
105
106
  HEADER_TEMPLATE = "path-sync copy -n {config_name}"
106
107
 
108
+ SYNC_METADATA_PATTERN = re.compile(r"<!-- path-sync: sha=(?P<sha>[0-9a-f]+) ts=(?P<ts>[^\s]+) -->")
109
+
110
+
111
+ class SyncMetadata(NamedTuple):
112
+ sha: str
113
+ ts: str
114
+
115
+
116
+ def parse_sync_metadata(body: str) -> SyncMetadata | None:
117
+ if match := SYNC_METADATA_PATTERN.search(body):
118
+ return SyncMetadata(sha=match["sha"], ts=match["ts"])
119
+ return None
120
+
121
+
122
+ def pr_already_synced(pr_body: str | None, commit_ts: str) -> SyncMetadata | None:
123
+ """Return metadata if pr_body was synced from a source commit >= commit_ts."""
124
+ if not pr_body:
125
+ return None
126
+ metadata = parse_sync_metadata(pr_body)
127
+ if metadata and metadata.ts >= commit_ts:
128
+ return metadata
129
+ return None
130
+
107
131
 
108
132
  class HeaderConfig(BaseModel):
109
133
  comment_prefixes: dict[str, str] = Field(default_factory=dict)
@@ -111,7 +135,8 @@ class HeaderConfig(BaseModel):
111
135
 
112
136
 
113
137
  DEFAULT_BODY_TEMPLATE = """\
114
- Synced from [{src_repo_name}]({src_repo_url}) @ `{src_sha_short}`
138
+ <!-- path-sync: sha={src_sha_short} ts={src_commit_ts} -->
139
+ Synced from [{src_repo_name}]({src_repo_url}) @ `{src_sha_short}` ({src_commit_ts})
115
140
 
116
141
  <details>
117
142
  <summary>Sync Log</summary>
@@ -143,6 +168,7 @@ class PRDefaults(PRFieldsBase):
143
168
  src_sha: str,
144
169
  sync_log: str,
145
170
  dest_name: str,
171
+ src_commit_ts: str = "",
146
172
  ) -> str:
147
173
  src_repo_name = src_repo_url.rstrip("/").rsplit("/", 1)[-1].removesuffix(".git")
148
174
  body = self.body_template.format(
@@ -152,6 +178,7 @@ class PRDefaults(PRFieldsBase):
152
178
  src_sha_short=src_sha[:8],
153
179
  sync_log=sync_log,
154
180
  dest_name=dest_name,
181
+ src_commit_ts=src_commit_ts,
155
182
  )
156
183
  if self.body_suffix:
157
184
  body = f"{body}\n---\n{self.body_suffix}"
@@ -194,6 +221,8 @@ class SrcConfig(BaseModel):
194
221
  verify: VerifyConfig | None = None
195
222
  wrap_synced_files: bool = False
196
223
  auto_merge: AutoMergeConfig | None = None
224
+ keep_pr_on_no_changes: bool = False
225
+ force_resync: bool = False
197
226
 
198
227
  @model_validator(mode="after")
199
228
  def _validate_include_groups(self) -> SrcConfig:
@@ -37,6 +37,7 @@ class DepConfig(BaseModel):
37
37
  verify: VerifyConfig = Field(default_factory=VerifyConfig)
38
38
  pr: PRConfig
39
39
  auto_merge: AutoMergeConfig | None = None
40
+ keep_pr_on_no_changes: bool = False
40
41
 
41
42
  def load_destinations(self, repo_root: Path) -> list[Destination]:
42
43
  src_config_path = resolve_config_path(repo_root, self.from_config)
@@ -2,7 +2,7 @@
2
2
 
3
3
  [project]
4
4
  name = "path-sync"
5
- version = "0.7.2"
5
+ version = "0.7.3"
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