path-sync 0.7.4__tar.gz → 0.7.6__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.4 → path_sync-0.7.6}/PKG-INFO +1 -1
  2. {path_sync-0.7.4 → path_sync-0.7.6}/path_sync/__init__.py +1 -1
  3. {path_sync-0.7.4 → path_sync-0.7.6}/path_sync/_internal/auto_merge.py +28 -10
  4. {path_sync-0.7.4 → path_sync-0.7.6}/path_sync/_internal/cmd_copy.py +22 -21
  5. {path_sync-0.7.4 → path_sync-0.7.6}/path_sync/_internal/cmd_dep_update.py +4 -27
  6. {path_sync-0.7.4 → path_sync-0.7.6}/path_sync/_internal/git_ops.py +2 -1
  7. path_sync-0.7.6/path_sync/_internal/repo_utils.py +37 -0
  8. {path_sync-0.7.4 → path_sync-0.7.6}/pyproject.toml +1 -1
  9. {path_sync-0.7.4 → path_sync-0.7.6}/.gitignore +0 -0
  10. {path_sync-0.7.4 → path_sync-0.7.6}/LICENSE +0 -0
  11. {path_sync-0.7.4 → path_sync-0.7.6}/README.md +0 -0
  12. {path_sync-0.7.4 → path_sync-0.7.6}/path_sync/__main__.py +0 -0
  13. {path_sync-0.7.4 → path_sync-0.7.6}/path_sync/_internal/__init__.py +0 -0
  14. {path_sync-0.7.4 → path_sync-0.7.6}/path_sync/_internal/cmd_boot.py +0 -0
  15. {path_sync-0.7.4 → path_sync-0.7.6}/path_sync/_internal/cmd_options.py +0 -0
  16. {path_sync-0.7.4 → path_sync-0.7.6}/path_sync/_internal/cmd_validate.py +0 -0
  17. {path_sync-0.7.4 → path_sync-0.7.6}/path_sync/_internal/file_utils.py +0 -0
  18. {path_sync-0.7.4 → path_sync-0.7.6}/path_sync/_internal/header.py +0 -0
  19. {path_sync-0.7.4 → path_sync-0.7.6}/path_sync/_internal/log_capture.py +0 -0
  20. {path_sync-0.7.4 → path_sync-0.7.6}/path_sync/_internal/models.py +0 -0
  21. {path_sync-0.7.4 → path_sync-0.7.6}/path_sync/_internal/models_dep.py +0 -0
  22. {path_sync-0.7.4 → path_sync-0.7.6}/path_sync/_internal/prompt_utils.py +0 -0
  23. {path_sync-0.7.4 → path_sync-0.7.6}/path_sync/_internal/typer_app.py +0 -0
  24. {path_sync-0.7.4 → path_sync-0.7.6}/path_sync/_internal/validation.py +0 -0
  25. {path_sync-0.7.4 → path_sync-0.7.6}/path_sync/_internal/verify.py +0 -0
  26. {path_sync-0.7.4 → path_sync-0.7.6}/path_sync/_internal/yaml_utils.py +0 -0
  27. {path_sync-0.7.4 → path_sync-0.7.6}/path_sync/config.py +0 -0
  28. {path_sync-0.7.4 → path_sync-0.7.6}/path_sync/copy.py +0 -0
  29. {path_sync-0.7.4 → path_sync-0.7.6}/path_sync/dep_update.py +0 -0
  30. {path_sync-0.7.4 → path_sync-0.7.6}/path_sync/sections.py +0 -0
  31. {path_sync-0.7.4 → path_sync-0.7.6}/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.4
3
+ Version: 0.7.6
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.4"
8
+ VERSION = "0.7.6"
9
9
  __all__ = [
10
10
  "copy",
11
11
  "dep_update",
@@ -70,15 +70,16 @@ class PRMergeResult(BaseModel):
70
70
  return [c for c in self.checks if c.pending]
71
71
 
72
72
 
73
- def enable_auto_merge(repo_path: Path, pr_ref: str, config: AutoMergeConfig) -> None:
73
+ def enable_auto_merge(repo_path: Path, pr_ref: str, config: AutoMergeConfig, dest_name: str = "") -> None:
74
+ label = dest_name or pr_ref
74
75
  cmd = ["gh", "pr", "merge", "--auto", f"--{config.method}", pr_ref]
75
76
  if config.delete_branch:
76
77
  cmd.append("--delete-branch")
77
78
  result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True)
78
79
  if result.returncode != 0:
79
- logger.warning(f"Auto-merge enable failed for {pr_ref}: {result.stderr.strip()}")
80
+ logger.warning(f" {label}: auto-merge enable failed: {result.stderr.strip()}")
80
81
  else:
81
- logger.info(f"Enabled auto-merge ({config.method}) for {pr_ref}")
82
+ logger.info(f" {label}: enabled auto-merge ({config.method})")
82
83
 
83
84
 
84
85
  def get_pr_checks(repo_path: Path, pr_ref: str) -> list[CheckRun]:
@@ -103,6 +104,7 @@ def get_pr_url(repo_path: Path, pr_ref: str) -> str:
103
104
  cmd = ["gh", "pr", "view", pr_ref, "--json", "url", "-q", ".url"]
104
105
  result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True)
105
106
  if result.returncode != 0:
107
+ logger.warning(f"Failed to get PR URL for {pr_ref}: {result.stderr.strip()}")
106
108
  return pr_ref
107
109
  return result.stdout.strip() or pr_ref
108
110
 
@@ -110,21 +112,31 @@ def get_pr_url(repo_path: Path, pr_ref: str) -> str:
110
112
  def wait_for_merge(repo_path: Path, pr_ref: str, config: AutoMergeConfig, dest_name: str = "") -> PRMergeResult:
111
113
  pr_url = get_pr_url(repo_path, pr_ref)
112
114
  deadline = time.monotonic() + config.timeout_seconds
115
+ label = dest_name or pr_ref
116
+ poll_count = 0
113
117
 
114
118
  while time.monotonic() < deadline:
115
119
  state = get_pr_state(repo_path, pr_ref)
116
120
  if state == PRState.MERGED:
121
+ logger.info(f"{label}: merged ({pr_url})")
117
122
  return PRMergeResult(dest_name=dest_name, pr_url=pr_url, branch=pr_ref, state=PRState.MERGED)
118
123
  if state == PRState.CLOSED:
119
124
  checks = get_pr_checks(repo_path, pr_ref)
125
+ logger.warning(f"{label}: PR closed without merging ({pr_url})")
120
126
  return PRMergeResult(dest_name=dest_name, pr_url=pr_url, branch=pr_ref, state=PRState.CLOSED, checks=checks)
127
+ poll_count += 1
128
+ elapsed = int(config.timeout_seconds - (deadline - time.monotonic()))
129
+ logger.info(f"{label}: still open (poll #{poll_count}, {elapsed}s elapsed)")
121
130
  time.sleep(config.poll_interval_seconds)
122
131
 
123
132
  checks = get_pr_checks(repo_path, pr_ref)
124
- logger.warning(f"Timeout waiting for {pr_ref} after {config.timeout_seconds}s")
133
+ logger.warning(f"Timeout waiting for {label} after {config.timeout_seconds}s ({pr_url})")
125
134
  return PRMergeResult(dest_name=dest_name, pr_url=pr_url, branch=pr_ref, state=PRState.OPEN, checks=checks)
126
135
 
127
136
 
137
+ SEPARATOR_WIDTH = 40
138
+
139
+
128
140
  def handle_auto_merge(
129
141
  pr_refs: list[PRRef],
130
142
  config: AutoMergeConfig,
@@ -133,22 +145,27 @@ def handle_auto_merge(
133
145
  if not pr_refs:
134
146
  return []
135
147
 
148
+ line = "─" * SEPARATOR_WIDTH
149
+ logger.info(f"\n{line}")
150
+ logger.info(" Auto-merge")
151
+ logger.info(line)
152
+
136
153
  pending_refs: list[PRRef] = []
137
154
  for ref in pr_refs:
138
155
  state = get_pr_state(ref.repo_path, ref.branch_or_url)
139
156
  if state == PRState.MERGED:
140
- logger.info(f"{ref.dest_name}: already merged")
157
+ logger.info(f" {ref.dest_name}: already merged")
141
158
  continue
142
- enable_auto_merge(ref.repo_path, ref.branch_or_url, config)
159
+ enable_auto_merge(ref.repo_path, ref.branch_or_url, config, dest_name=ref.dest_name)
143
160
  pending_refs.append(ref)
144
161
 
145
162
  if no_wait:
146
- logger.info("--no-wait: skipping merge polling")
163
+ logger.info(" --no-wait: skipping merge polling")
147
164
  return []
148
165
 
149
166
  results: list[PRMergeResult] = []
150
167
  for ref in pending_refs:
151
- logger.info(f"Waiting for {ref.dest_name} to merge...")
168
+ logger.info(f" Waiting for {ref.dest_name} to merge...")
152
169
  result = wait_for_merge(ref.repo_path, ref.branch_or_url, config, dest_name=ref.dest_name)
153
170
  results.append(result)
154
171
 
@@ -160,9 +177,10 @@ def _log_summary(results: list[PRMergeResult]) -> None:
160
177
  if not results:
161
178
  return
162
179
  max_name = max(len(r.dest_name) for r in results)
163
- header = f"{'Repo':<{max_name}} State Failed Checks"
180
+ max_url = max(len(r.pr_url) for r in results)
181
+ header = f"{'Repo':<{max_name}} {'PR':<{max_url}} State Failed Checks"
164
182
  logger.info(header)
165
183
  logger.info("-" * len(header))
166
184
  for r in results:
167
185
  failed = ", ".join(c.name for c in r.failed_checks)
168
- logger.info(f"{r.dest_name:<{max_name}} {r.state:<8} {failed}")
186
+ logger.info(f"{r.dest_name:<{max_name}} {r.pr_url:<{max_url}} {r.state:<8} {failed}")
@@ -23,6 +23,7 @@ from path_sync._internal.models import (
23
23
  pr_already_synced,
24
24
  resolve_config_path,
25
25
  )
26
+ from path_sync._internal.repo_utils import ensure_repo, resolve_repo_path
26
27
  from path_sync._internal.typer_app import app
27
28
  from path_sync._internal.verify import StepFailure, VerifyResult, VerifyStatus
28
29
  from path_sync._internal.yaml_utils import load_yaml_model
@@ -57,6 +58,7 @@ class CopyOptions(BaseModel):
57
58
  skip_verify: bool = False
58
59
  no_wait: bool = False
59
60
  no_auto_merge: bool = False
61
+ work_dir: str = ""
60
62
  pr_title: str = ""
61
63
  labels: list[str] | None = None
62
64
  reviewers: list[str] | None = None
@@ -78,6 +80,7 @@ def copy(
78
80
  help="Source repo root (default: find git root from cwd)",
79
81
  ),
80
82
  dest_filter: str = typer.Option("", "-d", "--dest", help="Filter destinations (comma-separated)"),
83
+ work_dir: str = typer.Option("", "--work-dir", help="Clone repos here (overrides dest_path_relative)"),
81
84
  dry_run: bool = typer.Option(False, "--dry-run", help="Preview without writing"),
82
85
  force_overwrite: bool = typer.Option(
83
86
  False,
@@ -174,6 +177,7 @@ def copy(
174
177
  skip_verify=skip_verify,
175
178
  no_wait=no_wait,
176
179
  no_auto_merge=no_auto_merge,
180
+ work_dir=work_dir,
177
181
  pr_title=pr_title or config.pr_defaults.title,
178
182
  labels=cmd_options.split_csv(pr_labels) or config.pr_defaults.labels,
179
183
  reviewers=cmd_options.split_csv(pr_reviewers) or config.pr_defaults.reviewers,
@@ -251,15 +255,13 @@ def _sync_destination(
251
255
  opts: CopyOptions,
252
256
  read_log: Callable[[], str],
253
257
  ) -> tuple[int, PRRef | None]:
254
- dest_root = (src_root / dest.dest_path_relative).resolve()
255
-
256
- if opts.dry_run and not dest_root.exists():
257
- raise ValueError(f"Destination repo not found: {dest_root}. Clone it first or run without --dry-run.")
258
-
259
- dest_repo = _ensure_dest_repo(dest, dest_root, opts.dry_run)
258
+ dest_root = resolve_repo_path(dest, src_root, opts.work_dir)
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,24 +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)
315
-
316
-
317
- def _ensure_dest_repo(dest: Destination, dest_root: Path, dry_run: bool):
318
- if not dest_root.exists():
319
- if dry_run:
320
- raise ValueError(f"Destination repo not found: {dest_root}. Clone it first or run without --dry-run.")
321
- if not dest.repo_url:
322
- raise ValueError(f"Dest {dest.name} not found and no repo_url configured")
323
- git_ops.clone_repo(dest.repo_url, dest_root)
324
- return git_ops.get_repo(dest_root)
325
+ typer.echo(f" {result.total} changes ready.", err=True)
325
326
 
326
327
 
327
328
  def _sync_paths(
@@ -1,7 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import logging
4
- import shutil
5
4
  import subprocess
6
5
  from dataclasses import dataclass, field
7
6
  from enum import StrEnum
@@ -10,7 +9,7 @@ from pathlib import Path
10
9
  import typer
11
10
  from git import Repo
12
11
 
13
- from path_sync._internal import cmd_options, git_ops, prompt_utils, verify
12
+ from path_sync._internal import cmd_options, git_ops, verify
14
13
  from path_sync._internal.auto_merge import PRRef, handle_auto_merge
15
14
  from path_sync._internal.log_capture import capture_log
16
15
  from path_sync._internal.models import Destination, OnFailStrategy, find_repo_root
@@ -19,6 +18,7 @@ from path_sync._internal.models_dep import (
19
18
  UpdateEntry,
20
19
  resolve_dep_config_path,
21
20
  )
21
+ from path_sync._internal.repo_utils import ensure_repo, resolve_repo_path
22
22
  from path_sync._internal.typer_app import app
23
23
  from path_sync._internal.verify import StepFailure, VerifyStatus
24
24
  from path_sync._internal.yaml_utils import load_yaml_model
@@ -154,8 +154,8 @@ def _process_single_repo_inner(
154
154
  opts: DepUpdateOptions,
155
155
  ) -> RepoResult:
156
156
  logger.info(f"Processing {dest.name}...")
157
- repo_path = _resolve_repo_path(dest, src_root, work_dir)
158
- repo = _ensure_repo(dest, repo_path, dest.default_branch)
157
+ repo_path = resolve_repo_path(dest, src_root, work_dir)
158
+ repo = ensure_repo(dest, repo_path)
159
159
  git_ops.prepare_copy_branch(repo, dest.default_branch, config.pr.branch, from_default=True)
160
160
 
161
161
  if failure := _run_updates(config.updates, repo_path):
@@ -219,29 +219,6 @@ def _create_prs(config: DepConfig, results: list[RepoResult], opts: DepUpdateOpt
219
219
  return pr_refs
220
220
 
221
221
 
222
- def _resolve_repo_path(dest: Destination, src_root: Path, work_dir: str) -> Path:
223
- if work_dir:
224
- return Path(work_dir) / dest.name
225
- if dest.dest_path_relative:
226
- return (src_root / dest.dest_path_relative).resolve()
227
- raise typer.BadParameter(f"No dest_path_relative for {dest.name}, --work-dir required")
228
-
229
-
230
- def _ensure_repo(dest: Destination, repo_path: Path, default_branch: str) -> Repo:
231
- if repo_path.exists():
232
- if git_ops.is_git_repo(repo_path):
233
- repo = git_ops.get_repo(repo_path)
234
- git_ops.fetch_and_reset_to_default(repo, default_branch)
235
- return repo
236
- logger.warning(f"Invalid git repo at {repo_path}")
237
- if not prompt_utils.prompt_confirm(f"Remove {repo_path} and re-clone?"):
238
- raise typer.Abort()
239
- shutil.rmtree(repo_path)
240
- if not dest.repo_url:
241
- raise ValueError(f"Dest {dest.name} not found at {repo_path} and no repo_url configured")
242
- return git_ops.clone_repo(dest.repo_url, repo_path)
243
-
244
-
245
222
  def _build_pr_body(log_content: str, failures: list[StepFailure]) -> str:
246
223
  body = "Automated dependency update."
247
224
 
@@ -202,7 +202,8 @@ def update_pr_body(repo_path: Path, branch: str, body: str) -> bool:
202
202
  if result.returncode != 0:
203
203
  logger.warning(f"Failed to update PR body: {result.stderr}")
204
204
  return False
205
- logger.info("Updated PR body")
205
+ pr_url = f"https://github.com/{repo_full}/pull/{pr_number}"
206
+ logger.info(f"Updated PR body: {pr_url}")
206
207
  return True
207
208
 
208
209
 
@@ -0,0 +1,37 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import shutil
5
+ from pathlib import Path
6
+
7
+ from git import Repo
8
+
9
+ from path_sync._internal import git_ops, prompt_utils
10
+ from path_sync._internal.models import Destination
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ def resolve_repo_path(dest: Destination, src_root: Path, work_dir: str) -> Path:
16
+ if work_dir:
17
+ return Path(work_dir) / dest.name
18
+ if dest.dest_path_relative:
19
+ return (src_root / dest.dest_path_relative).resolve()
20
+ raise ValueError(f"No dest_path_relative for {dest.name}, use --work-dir")
21
+
22
+
23
+ def ensure_repo(dest: Destination, repo_path: Path, dry_run: bool = False) -> Repo:
24
+ if repo_path.exists():
25
+ if git_ops.is_git_repo(repo_path):
26
+ return git_ops.get_repo(repo_path)
27
+ logger.warning(f"Invalid git repo at {repo_path}")
28
+ if dry_run:
29
+ raise ValueError(f"Invalid git repo at {repo_path}, cannot re-clone in dry-run mode")
30
+ if not prompt_utils.prompt_confirm(f"Remove {repo_path} and re-clone?"):
31
+ raise ValueError(f"Aborted: user declined to remove invalid repo at {repo_path}")
32
+ shutil.rmtree(repo_path)
33
+ if dry_run:
34
+ raise ValueError(f"Destination repo not found: {repo_path}. Clone it first or run without --dry-run.")
35
+ if not dest.repo_url:
36
+ raise ValueError(f"Dest {dest.name} not found at {repo_path} and no repo_url configured")
37
+ return git_ops.clone_repo(dest.repo_url, repo_path)
@@ -2,7 +2,7 @@
2
2
 
3
3
  [project]
4
4
  name = "path-sync"
5
- version = "0.7.4"
5
+ version = "0.7.6"
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
File without changes