path-sync 0.7.5__tar.gz → 0.7.7__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 (31) hide show
  1. {path_sync-0.7.5 → path_sync-0.7.7}/PKG-INFO +1 -1
  2. {path_sync-0.7.5 → path_sync-0.7.7}/path_sync/__init__.py +1 -1
  3. {path_sync-0.7.5 → path_sync-0.7.7}/path_sync/_internal/auto_merge.py +31 -11
  4. {path_sync-0.7.5 → path_sync-0.7.7}/path_sync/_internal/cmd_copy.py +20 -7
  5. {path_sync-0.7.5 → path_sync-0.7.7}/path_sync/_internal/cmd_dep_update.py +5 -2
  6. {path_sync-0.7.5 → path_sync-0.7.7}/path_sync/_internal/git_ops.py +35 -3
  7. {path_sync-0.7.5 → path_sync-0.7.7}/pyproject.toml +24 -6
  8. {path_sync-0.7.5 → path_sync-0.7.7}/.gitignore +0 -0
  9. {path_sync-0.7.5 → path_sync-0.7.7}/LICENSE +0 -0
  10. {path_sync-0.7.5 → path_sync-0.7.7}/README.md +0 -0
  11. {path_sync-0.7.5 → path_sync-0.7.7}/path_sync/__main__.py +0 -0
  12. {path_sync-0.7.5 → path_sync-0.7.7}/path_sync/_internal/__init__.py +0 -0
  13. {path_sync-0.7.5 → path_sync-0.7.7}/path_sync/_internal/cmd_boot.py +0 -0
  14. {path_sync-0.7.5 → path_sync-0.7.7}/path_sync/_internal/cmd_options.py +0 -0
  15. {path_sync-0.7.5 → path_sync-0.7.7}/path_sync/_internal/cmd_validate.py +0 -0
  16. {path_sync-0.7.5 → path_sync-0.7.7}/path_sync/_internal/file_utils.py +0 -0
  17. {path_sync-0.7.5 → path_sync-0.7.7}/path_sync/_internal/header.py +0 -0
  18. {path_sync-0.7.5 → path_sync-0.7.7}/path_sync/_internal/log_capture.py +0 -0
  19. {path_sync-0.7.5 → path_sync-0.7.7}/path_sync/_internal/models.py +0 -0
  20. {path_sync-0.7.5 → path_sync-0.7.7}/path_sync/_internal/models_dep.py +0 -0
  21. {path_sync-0.7.5 → path_sync-0.7.7}/path_sync/_internal/prompt_utils.py +0 -0
  22. {path_sync-0.7.5 → path_sync-0.7.7}/path_sync/_internal/repo_utils.py +0 -0
  23. {path_sync-0.7.5 → path_sync-0.7.7}/path_sync/_internal/typer_app.py +0 -0
  24. {path_sync-0.7.5 → path_sync-0.7.7}/path_sync/_internal/validation.py +0 -0
  25. {path_sync-0.7.5 → path_sync-0.7.7}/path_sync/_internal/verify.py +0 -0
  26. {path_sync-0.7.5 → path_sync-0.7.7}/path_sync/_internal/yaml_utils.py +0 -0
  27. {path_sync-0.7.5 → path_sync-0.7.7}/path_sync/config.py +0 -0
  28. {path_sync-0.7.5 → path_sync-0.7.7}/path_sync/copy.py +0 -0
  29. {path_sync-0.7.5 → path_sync-0.7.7}/path_sync/dep_update.py +0 -0
  30. {path_sync-0.7.5 → path_sync-0.7.7}/path_sync/sections.py +0 -0
  31. {path_sync-0.7.5 → path_sync-0.7.7}/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.5
3
+ Version: 0.7.7
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
@@ -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.5"
8
+ VERSION = "0.7.7"
9
9
  __all__ = [
10
10
  "copy",
11
11
  "dep_update",
@@ -38,6 +38,8 @@ COMPLETED_CHECK_STATES: frozenset[str] = frozenset(FAILED_CHECK_STATES | {"SUCCE
38
38
  class CheckRun(BaseModel):
39
39
  name: str
40
40
  state: str
41
+ workflow: str = ""
42
+ link: str = ""
41
43
 
42
44
  @property
43
45
  def failed(self) -> bool:
@@ -70,19 +72,20 @@ class PRMergeResult(BaseModel):
70
72
  return [c for c in self.checks if c.pending]
71
73
 
72
74
 
73
- def enable_auto_merge(repo_path: Path, pr_ref: str, config: AutoMergeConfig) -> None:
75
+ def enable_auto_merge(repo_path: Path, pr_ref: str, config: AutoMergeConfig, dest_name: str = "") -> None:
76
+ label = dest_name or pr_ref
74
77
  cmd = ["gh", "pr", "merge", "--auto", f"--{config.method}", pr_ref]
75
78
  if config.delete_branch:
76
79
  cmd.append("--delete-branch")
77
80
  result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True)
78
81
  if result.returncode != 0:
79
- logger.warning(f"Auto-merge enable failed for {pr_ref}: {result.stderr.strip()}")
82
+ logger.warning(f" {label}: auto-merge enable failed: {result.stderr.strip()}")
80
83
  else:
81
- logger.info(f"Enabled auto-merge ({config.method}) for {pr_ref}")
84
+ logger.info(f" {label}: enabled auto-merge ({config.method})")
82
85
 
83
86
 
84
87
  def get_pr_checks(repo_path: Path, pr_ref: str) -> list[CheckRun]:
85
- cmd = ["gh", "pr", "checks", pr_ref, "--json", "name,state"]
88
+ cmd = ["gh", "pr", "checks", pr_ref, "--json", "name,state,workflow,link"]
86
89
  result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True)
87
90
  if result.returncode != 0:
88
91
  logger.warning(f"Failed to get checks for {pr_ref}: {result.stderr.strip()}")
@@ -103,6 +106,7 @@ def get_pr_url(repo_path: Path, pr_ref: str) -> str:
103
106
  cmd = ["gh", "pr", "view", pr_ref, "--json", "url", "-q", ".url"]
104
107
  result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True)
105
108
  if result.returncode != 0:
109
+ logger.warning(f"Failed to get PR URL for {pr_ref}: {result.stderr.strip()}")
106
110
  return pr_ref
107
111
  return result.stdout.strip() or pr_ref
108
112
 
@@ -110,21 +114,31 @@ def get_pr_url(repo_path: Path, pr_ref: str) -> str:
110
114
  def wait_for_merge(repo_path: Path, pr_ref: str, config: AutoMergeConfig, dest_name: str = "") -> PRMergeResult:
111
115
  pr_url = get_pr_url(repo_path, pr_ref)
112
116
  deadline = time.monotonic() + config.timeout_seconds
117
+ label = dest_name or pr_ref
118
+ poll_count = 0
113
119
 
114
120
  while time.monotonic() < deadline:
115
121
  state = get_pr_state(repo_path, pr_ref)
116
122
  if state == PRState.MERGED:
123
+ logger.info(f"{label}: merged ({pr_url})")
117
124
  return PRMergeResult(dest_name=dest_name, pr_url=pr_url, branch=pr_ref, state=PRState.MERGED)
118
125
  if state == PRState.CLOSED:
119
126
  checks = get_pr_checks(repo_path, pr_ref)
127
+ logger.warning(f"{label}: PR closed without merging ({pr_url})")
120
128
  return PRMergeResult(dest_name=dest_name, pr_url=pr_url, branch=pr_ref, state=PRState.CLOSED, checks=checks)
129
+ poll_count += 1
130
+ elapsed = int(config.timeout_seconds - (deadline - time.monotonic()))
131
+ logger.info(f"{label}: still open (poll #{poll_count}, {elapsed}s elapsed)")
121
132
  time.sleep(config.poll_interval_seconds)
122
133
 
123
134
  checks = get_pr_checks(repo_path, pr_ref)
124
- logger.warning(f"Timeout waiting for {pr_ref} after {config.timeout_seconds}s")
135
+ logger.warning(f"Timeout waiting for {label} after {config.timeout_seconds}s ({pr_url})")
125
136
  return PRMergeResult(dest_name=dest_name, pr_url=pr_url, branch=pr_ref, state=PRState.OPEN, checks=checks)
126
137
 
127
138
 
139
+ SEPARATOR_WIDTH = 40
140
+
141
+
128
142
  def handle_auto_merge(
129
143
  pr_refs: list[PRRef],
130
144
  config: AutoMergeConfig,
@@ -133,22 +147,27 @@ def handle_auto_merge(
133
147
  if not pr_refs:
134
148
  return []
135
149
 
150
+ line = "─" * SEPARATOR_WIDTH
151
+ logger.info(f"\n{line}")
152
+ logger.info(" Auto-merge")
153
+ logger.info(line)
154
+
136
155
  pending_refs: list[PRRef] = []
137
156
  for ref in pr_refs:
138
157
  state = get_pr_state(ref.repo_path, ref.branch_or_url)
139
158
  if state == PRState.MERGED:
140
- logger.info(f"{ref.dest_name}: already merged")
159
+ logger.info(f" {ref.dest_name}: already merged")
141
160
  continue
142
- enable_auto_merge(ref.repo_path, ref.branch_or_url, config)
161
+ enable_auto_merge(ref.repo_path, ref.branch_or_url, config, dest_name=ref.dest_name)
143
162
  pending_refs.append(ref)
144
163
 
145
164
  if no_wait:
146
- logger.info("--no-wait: skipping merge polling")
165
+ logger.info(" --no-wait: skipping merge polling")
147
166
  return []
148
167
 
149
168
  results: list[PRMergeResult] = []
150
169
  for ref in pending_refs:
151
- logger.info(f"Waiting for {ref.dest_name} to merge...")
170
+ logger.info(f" Waiting for {ref.dest_name} to merge...")
152
171
  result = wait_for_merge(ref.repo_path, ref.branch_or_url, config, dest_name=ref.dest_name)
153
172
  results.append(result)
154
173
 
@@ -160,9 +179,10 @@ def _log_summary(results: list[PRMergeResult]) -> None:
160
179
  if not results:
161
180
  return
162
181
  max_name = max(len(r.dest_name) for r in results)
163
- header = f"{'Repo':<{max_name}} State Failed Checks"
182
+ max_url = max(len(r.pr_url) for r in results)
183
+ header = f"{'Repo':<{max_name}} {'PR':<{max_url}} State Failed Checks"
164
184
  logger.info(header)
165
185
  logger.info("-" * len(header))
166
186
  for r in results:
167
187
  failed = ", ".join(c.name for c in r.failed_checks)
168
- logger.info(f"{r.dest_name:<{max_name}} {r.state:<8} {failed}")
188
+ logger.info(f"{r.dest_name:<{max_name}} {r.pr_url:<{max_url}} {r.state:<8} {failed}")
@@ -258,8 +258,10 @@ def _sync_destination(
258
258
  dest_root = resolve_repo_path(dest, src_root, opts.work_dir)
259
259
  dest_repo = ensure_repo(dest, dest_root, dry_run=opts.dry_run)
260
260
  copy_branch = dest.resolved_copy_branch(config.name)
261
+ _print_dest_header(dest)
261
262
 
262
263
  if _skip_already_synced(dest.name, dest_root, copy_branch, commit_ts, opts, config):
264
+ typer.echo(" (already synced, skipped)", err=True)
263
265
  return 0, None
264
266
 
265
267
  if not opts.no_checkout and prompt_utils.prompt_confirm(f"Switch {dest.name} to {copy_branch}?", opts.no_prompt):
@@ -270,10 +272,10 @@ def _sync_destination(
270
272
  from_default=opts.checkout_from_default,
271
273
  )
272
274
  result = _sync_paths(config, dest, src_root, dest_root, opts)
273
- _print_sync_summary(dest, result)
275
+ _print_sync_summary(result)
274
276
 
275
277
  if result.total == 0:
276
- logger.info(f"{dest.name}: No changes")
278
+ typer.echo(" No changes", err=True)
277
279
  _close_stale_pr(dest_root, copy_branch, opts, config)
278
280
  return 0, None
279
281
 
@@ -304,14 +306,23 @@ def _sync_destination(
304
306
  return result.total, pr_ref
305
307
 
306
308
 
307
- def _print_sync_summary(dest: Destination, result: SyncResult) -> None:
308
- typer.echo(f"\nSyncing to {dest.name}...", err=True)
309
+ SEPARATOR_WIDTH = 40
310
+
311
+
312
+ def _print_dest_header(dest: Destination) -> None:
313
+ line = "─" * SEPARATOR_WIDTH
314
+ typer.echo(f"\n{line}", err=True)
315
+ typer.echo(f" {dest.name}", err=True)
316
+ typer.echo(line, err=True)
317
+
318
+
319
+ def _print_sync_summary(result: SyncResult) -> None:
309
320
  if result.content_changes > 0:
310
321
  typer.echo(f" [{result.content_changes} files synced]", err=True)
311
322
  if result.orphans_deleted > 0:
312
323
  typer.echo(f" [-] {result.orphans_deleted} orphans deleted", err=True)
313
324
  if result.total > 0:
314
- typer.echo(f"\n{result.total} changes ready.", err=True)
325
+ typer.echo(f" {result.total} changes ready.", err=True)
315
326
 
316
327
 
317
328
  def _sync_paths(
@@ -596,8 +607,10 @@ def _push_and_pr(
596
607
  if not prompt_utils.prompt_confirm(f"Push {dest.name} to origin?", opts.no_prompt):
597
608
  return None
598
609
 
599
- git_ops.push_branch(repo, copy_branch, force=True)
600
- typer.echo(f" Pushed: {copy_branch} (force)", err=True)
610
+ if git_ops.push_branch(repo, copy_branch, force=True):
611
+ typer.echo(f" Pushed: {copy_branch} (force)", err=True)
612
+ else:
613
+ typer.echo(f" Skipped push for {copy_branch}: content unchanged on remote", err=True)
601
614
 
602
615
  if opts.no_pr or not prompt_utils.prompt_confirm(f"Create PR for {dest.name}?", opts.no_prompt):
603
616
  return None
@@ -201,9 +201,12 @@ def _create_prs(config: DepConfig, results: list[RepoResult], opts: DepUpdateOpt
201
201
  continue
202
202
 
203
203
  repo = git_ops.get_repo(result.repo_path)
204
- git_ops.push_branch(repo, config.pr.branch, force=True)
205
-
206
204
  body = _build_pr_body(result.log_content, result.failures)
205
+
206
+ if not git_ops.push_branch(repo, config.pr.branch, force=True):
207
+ git_ops.update_pr_body(result.repo_path, config.pr.branch, body)
208
+ continue
209
+
207
210
  pr_url = git_ops.create_or_update_pr(
208
211
  result.repo_path,
209
212
  config.pr.branch,
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import logging
4
4
  import os
5
+ import re
5
6
  import subprocess
6
7
  from contextlib import suppress
7
8
  from pathlib import Path
@@ -10,6 +11,20 @@ from git import GitCommandError, InvalidGitRepositoryError, NoSuchPathError, Rep
10
11
 
11
12
  logger = logging.getLogger(__name__)
12
13
 
14
+ GH_PR_BODY_MAX_CHARS = 64536 # real limit is 65536, but we leave some buffer
15
+ _TRUNCATION_NOTICE = "\n\n... (truncated, output too long for PR body)"
16
+ _CODE_FENCE_RE = re.compile(r"^`{3,}", re.MULTILINE)
17
+
18
+
19
+ def _truncate_body(body: str) -> str:
20
+ if len(body) <= GH_PR_BODY_MAX_CHARS:
21
+ return body
22
+ cut = GH_PR_BODY_MAX_CHARS - len(_TRUNCATION_NOTICE)
23
+ truncated = body[:cut]
24
+ fence_count = len(_CODE_FENCE_RE.findall(truncated))
25
+ suffix = "\n```" + _TRUNCATION_NOTICE if fence_count % 2 else _TRUNCATION_NOTICE
26
+ return truncated[: GH_PR_BODY_MAX_CHARS - len(suffix)] + suffix
27
+
13
28
 
14
29
  def _auth_url(url: str) -> str:
15
30
  """Inject GH_TOKEN into HTTPS URL for authentication."""
@@ -145,10 +160,24 @@ def _ensure_git_user(repo: Repo) -> None:
145
160
  repo.config_writer().set_value("user", "email", "path-sync[bot]@users.noreply.github.com").release()
146
161
 
147
162
 
148
- def push_branch(repo: Repo, branch: str, force: bool = True) -> None:
163
+ def remote_branch_has_same_content(repo: Repo, branch: str) -> bool:
164
+ """Check if origin/{branch} has identical file content (tree) as local {branch}."""
165
+ with suppress(GitCommandError):
166
+ local_tree = repo.git.rev_parse(f"{branch}^{{tree}}")
167
+ remote_tree = repo.git.rev_parse(f"origin/{branch}^{{tree}}")
168
+ return local_tree == remote_tree
169
+ return False
170
+
171
+
172
+ def push_branch(repo: Repo, branch: str, force: bool = True) -> bool:
173
+ """Push branch to origin. Returns False if skipped (content unchanged on remote)."""
174
+ if force and remote_branch_has_same_content(repo, branch):
175
+ logger.info(f"Skipping push for {branch}: content unchanged on remote")
176
+ return False
149
177
  logger.info(f"Pushing {branch}" + (" (force)" if force else ""))
150
178
  args = ["--force", "-u", "origin", branch] if force else ["-u", "origin", branch]
151
179
  repo.git.push(*args)
180
+ return True
152
181
 
153
182
 
154
183
  def _get_repo_full_name(repo_path: Path) -> str | None:
@@ -189,6 +218,7 @@ def update_pr_body(repo_path: Path, branch: str, body: str) -> bool:
189
218
  if not pr_number:
190
219
  return False
191
220
 
221
+ body = _truncate_body(body)
192
222
  cmd = [
193
223
  "gh",
194
224
  "api",
@@ -202,7 +232,8 @@ def update_pr_body(repo_path: Path, branch: str, body: str) -> bool:
202
232
  if result.returncode != 0:
203
233
  logger.warning(f"Failed to update PR body: {result.stderr}")
204
234
  return False
205
- logger.info("Updated PR body")
235
+ pr_url = f"https://github.com/{repo_full}/pull/{pr_number}"
236
+ logger.info(f"Updated PR body: {pr_url}")
206
237
  return True
207
238
 
208
239
 
@@ -242,8 +273,9 @@ def create_or_update_pr(
242
273
  reviewers: list[str] | None = None,
243
274
  assignees: list[str] | None = None,
244
275
  ) -> str:
276
+ body = _truncate_body(body) if body else ""
245
277
  cmd = ["gh", "pr", "create", "--head", branch, "--title", title]
246
- cmd.extend(["--body", body or ""])
278
+ cmd.extend(["--body", body])
247
279
  if labels:
248
280
  cmd.extend(["--label", ",".join(labels)])
249
281
  if reviewers:
@@ -2,7 +2,7 @@
2
2
 
3
3
  [project]
4
4
  name = "path-sync"
5
- version = "0.7.5"
5
+ version = "0.7.7"
6
6
  description = "Sync files from a source repo to multiple destination repos"
7
7
  requires-python = ">=3.13"
8
8
  license = "MIT"
@@ -68,12 +68,13 @@ target-version = "py313"
68
68
  # UP046: use type parameters on functions instead of TypeVar
69
69
  # UP047: use type parameters instead of `Generic[T]` subclass
70
70
  extend-ignore = ["E501", "UP006", "UP007", "UP035", "UP040", "UP046", "UP047"]
71
- extend-select = ["Q", "RUF100", "C90", "UP", "I", "T"]
71
+ extend-select = ["Q", "RUF100", "C90", "UP", "I", "T", "FURB"]
72
72
  # === OK_EDIT: path-sync ruff ===
73
73
 
74
74
  # === DO_NOT_EDIT: path-sync pytest ===
75
75
  [tool.pytest.ini_options]
76
- addopts = "-s -vv --log-cli-level=INFO"
76
+ addopts = "-s -vv --log-cli-level=INFO -m 'not manual'"
77
+ markers = ["manual: test requires manual env setup (not run in CI)"]
77
78
  python_files = ["*_test.py", "test_*.py"]
78
79
  asyncio_mode = "auto"
79
80
  # === OK_EDIT: path-sync pytest ===
@@ -84,16 +85,33 @@ omit = ["*_test.py"]
84
85
  exclude_lines = ["no cov", "if __name__ == .__main__.:"]
85
86
  # === OK_EDIT: path-sync coverage ===
86
87
 
87
- # === DO_NOT_EDIT: path-sync dev ===
88
+ # === DO_NOT_EDIT: path-sync vulture ===
89
+ [tool.vulture]
90
+ min_confidence = 80
91
+ ignore_names = ["mock_*", "Mock*", "*_mock"]
92
+ exclude = [".venv/", "*test.py"]
93
+ # === OK_EDIT: path-sync vulture ===
94
+
95
+ # === DO_NOT_EDIT: path-sync dev-lint ===
88
96
  [dependency-groups]
89
- dev = [
97
+ dev-lint = [
90
98
  "pyright>=1.1",
99
+ "ruff>=0.14",
100
+ "vulture>=2.14",
101
+ ]
102
+ # === OK_EDIT: path-sync dev-lint ===
103
+
104
+ # === DO_NOT_EDIT: path-sync dev-test ===
105
+ dev-test = [
91
106
  "pytest>=9.0",
92
107
  "pytest-asyncio>=0.24",
93
108
  "pytest-cov>=7.0",
94
109
  "pytest-regressions>=2.6",
95
- "ruff>=0.14",
96
110
  ]
111
+ # === OK_EDIT: path-sync dev-test ===
112
+
113
+ # === DO_NOT_EDIT: path-sync dev ===
114
+ dev = [{include-group = "dev-lint"}, {include-group = "dev-test"}]
97
115
  # === OK_EDIT: path-sync dev ===
98
116
 
99
117
  # === DO_NOT_EDIT: path-sync docs ===
File without changes
File without changes
File without changes
File without changes
File without changes