path-sync 0.6.2__tar.gz → 0.7.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 (30) hide show
  1. {path_sync-0.6.2 → path_sync-0.7.0}/PKG-INFO +1 -1
  2. {path_sync-0.6.2 → path_sync-0.7.0}/path_sync/__init__.py +1 -1
  3. path_sync-0.7.0/path_sync/_internal/auto_merge.py +168 -0
  4. {path_sync-0.6.2 → path_sync-0.7.0}/path_sync/_internal/cmd_copy.py +55 -27
  5. {path_sync-0.6.2 → path_sync-0.7.0}/path_sync/_internal/cmd_dep_update.py +17 -4
  6. {path_sync-0.6.2 → path_sync-0.7.0}/path_sync/_internal/git_ops.py +0 -14
  7. {path_sync-0.6.2 → path_sync-0.7.0}/path_sync/_internal/models.py +14 -0
  8. {path_sync-0.6.2 → path_sync-0.7.0}/path_sync/_internal/models_dep.py +2 -1
  9. {path_sync-0.6.2 → path_sync-0.7.0}/path_sync/config.py +4 -0
  10. {path_sync-0.6.2 → path_sync-0.7.0}/pyproject.toml +1 -1
  11. {path_sync-0.6.2 → path_sync-0.7.0}/.gitignore +0 -0
  12. {path_sync-0.6.2 → path_sync-0.7.0}/LICENSE +0 -0
  13. {path_sync-0.6.2 → path_sync-0.7.0}/README.md +0 -0
  14. {path_sync-0.6.2 → path_sync-0.7.0}/path_sync/__main__.py +0 -0
  15. {path_sync-0.6.2 → path_sync-0.7.0}/path_sync/_internal/__init__.py +0 -0
  16. {path_sync-0.6.2 → path_sync-0.7.0}/path_sync/_internal/cmd_boot.py +0 -0
  17. {path_sync-0.6.2 → path_sync-0.7.0}/path_sync/_internal/cmd_options.py +0 -0
  18. {path_sync-0.6.2 → path_sync-0.7.0}/path_sync/_internal/cmd_validate.py +0 -0
  19. {path_sync-0.6.2 → path_sync-0.7.0}/path_sync/_internal/file_utils.py +0 -0
  20. {path_sync-0.6.2 → path_sync-0.7.0}/path_sync/_internal/header.py +0 -0
  21. {path_sync-0.6.2 → path_sync-0.7.0}/path_sync/_internal/log_capture.py +0 -0
  22. {path_sync-0.6.2 → path_sync-0.7.0}/path_sync/_internal/prompt_utils.py +0 -0
  23. {path_sync-0.6.2 → path_sync-0.7.0}/path_sync/_internal/typer_app.py +0 -0
  24. {path_sync-0.6.2 → path_sync-0.7.0}/path_sync/_internal/validation.py +0 -0
  25. {path_sync-0.6.2 → path_sync-0.7.0}/path_sync/_internal/verify.py +0 -0
  26. {path_sync-0.6.2 → path_sync-0.7.0}/path_sync/_internal/yaml_utils.py +0 -0
  27. {path_sync-0.6.2 → path_sync-0.7.0}/path_sync/copy.py +0 -0
  28. {path_sync-0.6.2 → path_sync-0.7.0}/path_sync/dep_update.py +0 -0
  29. {path_sync-0.6.2 → path_sync-0.7.0}/path_sync/sections.py +0 -0
  30. {path_sync-0.6.2 → path_sync-0.7.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.6.2
3
+ Version: 0.7.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
@@ -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.6.2"
8
+ VERSION = "0.7.0"
9
9
  __all__ = [
10
10
  "copy",
11
11
  "dep_update",
@@ -0,0 +1,168 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ import subprocess
6
+ import time
7
+ from enum import StrEnum
8
+ from pathlib import Path
9
+ from typing import NamedTuple
10
+
11
+ from pydantic import BaseModel, Field
12
+
13
+ from path_sync._internal.models import AutoMergeConfig
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class PRState(StrEnum):
19
+ MERGED = "MERGED"
20
+ OPEN = "OPEN"
21
+ CLOSED = "CLOSED"
22
+
23
+
24
+ FAILED_CHECK_STATES: frozenset[str] = frozenset(
25
+ {
26
+ "FAILURE",
27
+ "ERROR",
28
+ "TIMED_OUT",
29
+ "STARTUP_FAILURE",
30
+ "STALE",
31
+ "ACTION_REQUIRED",
32
+ }
33
+ )
34
+
35
+ COMPLETED_CHECK_STATES: frozenset[str] = frozenset(FAILED_CHECK_STATES | {"SUCCESS", "NEUTRAL", "SKIPPED", "CANCELLED"})
36
+
37
+
38
+ class CheckRun(BaseModel):
39
+ name: str
40
+ state: str
41
+
42
+ @property
43
+ def failed(self) -> bool:
44
+ return self.state in FAILED_CHECK_STATES
45
+
46
+ @property
47
+ def pending(self) -> bool:
48
+ return self.state not in COMPLETED_CHECK_STATES
49
+
50
+
51
+ class PRRef(NamedTuple):
52
+ dest_name: str
53
+ repo_path: Path
54
+ branch_or_url: str
55
+
56
+
57
+ class PRMergeResult(BaseModel):
58
+ dest_name: str
59
+ pr_url: str
60
+ branch: str
61
+ state: PRState
62
+ checks: list[CheckRun] = Field(default_factory=list)
63
+
64
+ @property
65
+ def failed_checks(self) -> list[CheckRun]:
66
+ return [c for c in self.checks if c.failed]
67
+
68
+ @property
69
+ def pending_checks(self) -> list[CheckRun]:
70
+ return [c for c in self.checks if c.pending]
71
+
72
+
73
+ def enable_auto_merge(repo_path: Path, pr_ref: str, config: AutoMergeConfig) -> None:
74
+ cmd = ["gh", "pr", "merge", "--auto", f"--{config.method}", pr_ref]
75
+ if config.delete_branch:
76
+ cmd.append("--delete-branch")
77
+ result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True)
78
+ if result.returncode != 0:
79
+ logger.warning(f"Auto-merge enable failed for {pr_ref}: {result.stderr.strip()}")
80
+ else:
81
+ logger.info(f"Enabled auto-merge ({config.method}) for {pr_ref}")
82
+
83
+
84
+ def get_pr_checks(repo_path: Path, pr_ref: str) -> list[CheckRun]:
85
+ cmd = ["gh", "pr", "checks", pr_ref, "--json", "name,state"]
86
+ result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True)
87
+ if result.returncode != 0:
88
+ logger.warning(f"Failed to get checks for {pr_ref}: {result.stderr.strip()}")
89
+ return []
90
+ return [CheckRun.model_validate(c) for c in json.loads(result.stdout)]
91
+
92
+
93
+ def get_pr_state(repo_path: Path, pr_ref: str) -> PRState:
94
+ cmd = ["gh", "pr", "view", pr_ref, "--json", "state", "-q", ".state"]
95
+ result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True)
96
+ if result.returncode != 0:
97
+ logger.warning(f"Failed to get PR state for {pr_ref}: {result.stderr.strip()}")
98
+ return PRState.OPEN
99
+ return PRState(result.stdout.strip())
100
+
101
+
102
+ def get_pr_url(repo_path: Path, pr_ref: str) -> str:
103
+ cmd = ["gh", "pr", "view", pr_ref, "--json", "url", "-q", ".url"]
104
+ result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True)
105
+ if result.returncode != 0:
106
+ return pr_ref
107
+ return result.stdout.strip() or pr_ref
108
+
109
+
110
+ def wait_for_merge(repo_path: Path, pr_ref: str, config: AutoMergeConfig, dest_name: str = "") -> PRMergeResult:
111
+ pr_url = get_pr_url(repo_path, pr_ref)
112
+ deadline = time.monotonic() + config.timeout_seconds
113
+
114
+ while time.monotonic() < deadline:
115
+ state = get_pr_state(repo_path, pr_ref)
116
+ if state == PRState.MERGED:
117
+ return PRMergeResult(dest_name=dest_name, pr_url=pr_url, branch=pr_ref, state=PRState.MERGED)
118
+ if state == PRState.CLOSED:
119
+ checks = get_pr_checks(repo_path, pr_ref)
120
+ return PRMergeResult(dest_name=dest_name, pr_url=pr_url, branch=pr_ref, state=PRState.CLOSED, checks=checks)
121
+ time.sleep(config.poll_interval_seconds)
122
+
123
+ checks = get_pr_checks(repo_path, pr_ref)
124
+ logger.warning(f"Timeout waiting for {pr_ref} after {config.timeout_seconds}s")
125
+ return PRMergeResult(dest_name=dest_name, pr_url=pr_url, branch=pr_ref, state=PRState.OPEN, checks=checks)
126
+
127
+
128
+ def handle_auto_merge(
129
+ pr_refs: list[PRRef],
130
+ config: AutoMergeConfig,
131
+ no_wait: bool = False,
132
+ ) -> list[PRMergeResult]:
133
+ if not pr_refs:
134
+ return []
135
+
136
+ pending_refs: list[PRRef] = []
137
+ for ref in pr_refs:
138
+ state = get_pr_state(ref.repo_path, ref.branch_or_url)
139
+ if state == PRState.MERGED:
140
+ logger.info(f"{ref.dest_name}: already merged")
141
+ continue
142
+ enable_auto_merge(ref.repo_path, ref.branch_or_url, config)
143
+ pending_refs.append(ref)
144
+
145
+ if no_wait:
146
+ logger.info("--no-wait: skipping merge polling")
147
+ return []
148
+
149
+ results: list[PRMergeResult] = []
150
+ for ref in pending_refs:
151
+ logger.info(f"Waiting for {ref.dest_name} to merge...")
152
+ result = wait_for_merge(ref.repo_path, ref.branch_or_url, config, dest_name=ref.dest_name)
153
+ results.append(result)
154
+
155
+ _log_summary(results)
156
+ return results
157
+
158
+
159
+ def _log_summary(results: list[PRMergeResult]) -> None:
160
+ if not results:
161
+ return
162
+ max_name = max(len(r.dest_name) for r in results)
163
+ header = f"{'Repo':<{max_name}} State Failed Checks"
164
+ logger.info(header)
165
+ logger.info("-" * len(header))
166
+ for r in results:
167
+ failed = ", ".join(c.name for c in r.failed_checks)
168
+ logger.info(f"{r.dest_name:<{max_name}} {r.state:<8} {failed}")
@@ -11,6 +11,7 @@ from pydantic import BaseModel
11
11
 
12
12
  from path_sync import sections
13
13
  from path_sync._internal import cmd_options, git_ops, header, prompt_utils, verify
14
+ from path_sync._internal.auto_merge import PRRef, handle_auto_merge
14
15
  from path_sync._internal.file_utils import ensure_parents_write_text
15
16
  from path_sync._internal.log_capture import capture_log
16
17
  from path_sync._internal.models import (
@@ -53,6 +54,8 @@ class CopyOptions(BaseModel):
53
54
  no_pr: bool = False
54
55
  skip_orphan_cleanup: bool = False
55
56
  skip_verify: bool = False
57
+ no_wait: bool = False
58
+ no_auto_merge: bool = False
56
59
  pr_title: str = ""
57
60
  labels: list[str] | None = None
58
61
  reviewers: list[str] | None = None
@@ -130,6 +133,16 @@ def copy(
130
133
  "--skip-verify",
131
134
  help="Skip verification steps after syncing",
132
135
  ),
136
+ no_wait: bool = typer.Option(
137
+ False,
138
+ "--no-wait",
139
+ help="Enable auto-merge but skip polling for merge completion",
140
+ ),
141
+ no_auto_merge: bool = typer.Option(
142
+ False,
143
+ "--no-auto-merge",
144
+ help="Skip auto-merge even when configured",
145
+ ),
133
146
  ) -> None:
134
147
  """Copy files from SRC to DEST repositories."""
135
148
  if name and config_path_opt:
@@ -147,9 +160,6 @@ def copy(
147
160
  raise typer.Exit(EXIT_ERROR if detailed_exit_code else 1)
148
161
 
149
162
  config = load_yaml_model(config_path, SrcConfig)
150
- src_repo = git_ops.get_repo(src_root)
151
- current_sha = git_ops.get_current_sha(src_repo)
152
- src_repo_url = git_ops.get_remote_url(src_repo, config.git_remote)
153
163
 
154
164
  opts = CopyOptions(
155
165
  dry_run=dry_run,
@@ -161,31 +171,49 @@ def copy(
161
171
  no_pr=no_pr,
162
172
  skip_orphan_cleanup=skip_orphan_cleanup,
163
173
  skip_verify=skip_verify,
174
+ no_wait=no_wait,
175
+ no_auto_merge=no_auto_merge,
164
176
  pr_title=pr_title or config.pr_defaults.title,
165
177
  labels=cmd_options.split_csv(pr_labels) or config.pr_defaults.labels,
166
178
  reviewers=cmd_options.split_csv(pr_reviewers) or config.pr_defaults.reviewers,
167
179
  assignees=cmd_options.split_csv(pr_assignees) or config.pr_defaults.assignees,
168
180
  )
169
181
 
182
+ try:
183
+ total_changes = _run_copy(config, src_root, dest_filter, opts)
184
+ except Exception as e:
185
+ if detailed_exit_code:
186
+ logger.error(f"Copy failed: {e}")
187
+ raise typer.Exit(EXIT_ERROR)
188
+ raise
189
+
190
+ if detailed_exit_code:
191
+ raise typer.Exit(EXIT_CHANGES if total_changes > 0 else EXIT_NO_CHANGES)
192
+
193
+
194
+ def _run_copy(config: SrcConfig, src_root: Path, dest_filter: str, opts: CopyOptions) -> int:
195
+ src_repo = git_ops.get_repo(src_root)
196
+ current_sha = git_ops.get_current_sha(src_repo)
197
+ src_repo_url = git_ops.get_remote_url(src_repo, config.git_remote)
198
+
170
199
  destinations = config.destinations
171
200
  if dest_filter:
172
201
  filter_names = [n.strip() for n in dest_filter.split(",")]
173
202
  destinations = [d for d in destinations if d.name in filter_names]
174
203
 
175
204
  total_changes = 0
205
+ pr_refs: list[PRRef] = []
176
206
  for dest in destinations:
177
- try:
178
- with capture_log(dest.name) as read_log:
179
- changes = _sync_destination(config, dest, src_root, current_sha, src_repo_url, opts, read_log)
180
- total_changes += changes
181
- except Exception as e:
182
- logger.error(f"Failed to sync {dest.name}: {e}")
183
- if detailed_exit_code:
184
- raise typer.Exit(EXIT_ERROR)
185
- raise
207
+ 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)
209
+ total_changes += changes
210
+ if pr_ref:
211
+ pr_refs.append(pr_ref)
186
212
 
187
- if detailed_exit_code:
188
- raise typer.Exit(EXIT_CHANGES if total_changes > 0 else EXIT_NO_CHANGES)
213
+ if config.auto_merge and pr_refs and not opts.no_auto_merge:
214
+ handle_auto_merge(pr_refs, config.auto_merge, no_wait=opts.no_wait)
215
+
216
+ return total_changes
189
217
 
190
218
 
191
219
  def _sync_destination(
@@ -196,7 +224,7 @@ def _sync_destination(
196
224
  src_repo_url: str,
197
225
  opts: CopyOptions,
198
226
  read_log: Callable[[], str],
199
- ) -> int:
227
+ ) -> tuple[int, PRRef | None]:
200
228
  dest_root = (src_root / dest.dest_path_relative).resolve()
201
229
 
202
230
  if opts.dry_run and not dest_root.exists():
@@ -205,7 +233,6 @@ def _sync_destination(
205
233
  dest_repo = _ensure_dest_repo(dest, dest_root, opts.dry_run)
206
234
  copy_branch = dest.resolved_copy_branch(config.name)
207
235
 
208
- # --no-checkout skips branch switching (assumes already on correct branch)
209
236
  if not opts.no_checkout and prompt_utils.prompt_confirm(f"Switch {dest.name} to {copy_branch}?", opts.no_prompt):
210
237
  git_ops.prepare_copy_branch(
211
238
  repo=dest_repo,
@@ -218,9 +245,8 @@ def _sync_destination(
218
245
 
219
246
  if result.total == 0:
220
247
  logger.info(f"{dest.name}: No changes")
221
- return 0
248
+ return 0, None
222
249
 
223
- # --skip-commit and --dry-run skip commit; otherwise prompt
224
250
  should_skip_commit = opts.skip_commit or opts.dry_run
225
251
  if not should_skip_commit and prompt_utils.prompt_confirm(f"Commit changes to {dest.name}?", opts.no_prompt):
226
252
  sync_commit_msg = f"chore: sync {config.name} from {current_sha[:8]}"
@@ -240,10 +266,10 @@ def _sync_destination(
240
266
 
241
267
  if verify_result.status == VerifyStatus.SKIPPED:
242
268
  logger.warning(f"{dest.name}: Verification skipped due to failure")
243
- return result.total
269
+ return result.total, None
244
270
 
245
- _push_and_pr(config, dest_repo, dest_root, dest, current_sha, src_repo_url, opts, read_log, verify_result)
246
- return result.total
271
+ pr_ref = _push_and_pr(config, dest_repo, dest_root, dest, current_sha, src_repo_url, opts, read_log, verify_result)
272
+ return result.total, pr_ref
247
273
 
248
274
 
249
275
  def _print_sync_summary(dest: Destination, result: SyncResult) -> None:
@@ -526,29 +552,28 @@ def _push_and_pr(
526
552
  opts: CopyOptions,
527
553
  read_log: Callable[[], str],
528
554
  verify_result: VerifyResult,
529
- ) -> None:
555
+ ) -> PRRef | None:
530
556
  if opts.skip_commit or opts.dry_run:
531
557
  logger.info("Skipping push/PR (--skip-commit or --dry-run)")
532
- return
558
+ return None
533
559
 
534
560
  copy_branch = dest.resolved_copy_branch(config.name)
535
561
 
536
- # Commit any remaining changes from verify steps without their own commit config
537
562
  if git_ops.has_changes(repo):
538
563
  if not prompt_utils.prompt_confirm(f"Commit remaining changes to {dest.name}?", opts.no_prompt):
539
- return
564
+ return None
540
565
  commit_msg = f"chore: post-sync changes for {config.name}"
541
566
  git_ops.commit_changes(repo, commit_msg)
542
567
  typer.echo(f" Committed: {commit_msg}", err=True)
543
568
 
544
569
  if not prompt_utils.prompt_confirm(f"Push {dest.name} to origin?", opts.no_prompt):
545
- return
570
+ return None
546
571
 
547
572
  git_ops.push_branch(repo, copy_branch, force=True)
548
573
  typer.echo(f" Pushed: {copy_branch} (force)", err=True)
549
574
 
550
575
  if opts.no_pr or not prompt_utils.prompt_confirm(f"Create PR for {dest.name}?", opts.no_prompt):
551
- return
576
+ return None
552
577
 
553
578
  sync_log = read_log()
554
579
  pr_body = config.pr_defaults.format_body(
@@ -574,6 +599,9 @@ def _push_and_pr(
574
599
  if pr_url:
575
600
  typer.echo(f" Created PR: {pr_url}", err=True)
576
601
 
602
+ branch_or_url = pr_url or copy_branch
603
+ return PRRef(dest_name=dest.name, repo_path=dest_root, branch_or_url=branch_or_url)
604
+
577
605
 
578
606
  def _append_verify_warnings(body: str, failures: list[StepFailure]) -> str:
579
607
  body += "\n\n---\n## Verification Warnings\n"
@@ -11,6 +11,7 @@ import typer
11
11
  from git import Repo
12
12
 
13
13
  from path_sync._internal import cmd_options, git_ops, prompt_utils, verify
14
+ from path_sync._internal.auto_merge import PRRef, handle_auto_merge
14
15
  from path_sync._internal.log_capture import capture_log
15
16
  from path_sync._internal.models import Destination, OnFailStrategy, find_repo_root
16
17
  from path_sync._internal.models_dep import (
@@ -50,6 +51,8 @@ class RepoResult:
50
51
  class DepUpdateOptions:
51
52
  dry_run: bool = False
52
53
  skip_verify: bool = False
54
+ no_wait: bool = False
55
+ no_auto_merge: bool = False
53
56
  reviewers: list[str] | None = None
54
57
  assignees: list[str] | None = None
55
58
 
@@ -61,6 +64,8 @@ def dep_update(
61
64
  work_dir: str = typer.Option("", "--work-dir", help="Clone repos here (overrides dest_path_relative)"),
62
65
  dry_run: bool = typer.Option(False, "--dry-run", help="Preview without creating PRs"),
63
66
  skip_verify: bool = typer.Option(False, "--skip-verify", help="Skip verification steps"),
67
+ no_wait: bool = typer.Option(False, "--no-wait", help="Enable auto-merge but skip polling for merge completion"),
68
+ no_auto_merge: bool = typer.Option(False, "--no-auto-merge", help="Skip auto-merge even when configured"),
64
69
  src_root_opt: str = typer.Option("", "--src-root", help="Source repo root"),
65
70
  pr_reviewers: str = cmd_options.pr_reviewers_option(),
66
71
  pr_assignees: str = cmd_options.pr_assignees_option(),
@@ -82,12 +87,17 @@ def dep_update(
82
87
  opts = DepUpdateOptions(
83
88
  dry_run=dry_run,
84
89
  skip_verify=skip_verify,
90
+ no_wait=no_wait,
91
+ no_auto_merge=no_auto_merge,
85
92
  reviewers=cmd_options.split_csv(pr_reviewers) or config.pr.reviewers,
86
93
  assignees=cmd_options.split_csv(pr_assignees) or config.pr.assignees,
87
94
  )
88
95
 
89
96
  results = _update_and_validate(config, destinations, src_root, work_dir, opts)
90
- _create_prs(config, results, opts)
97
+ pr_refs = _create_prs(config, results, opts)
98
+
99
+ if config.auto_merge and pr_refs and not opts.no_auto_merge:
100
+ handle_auto_merge(pr_refs, config.auto_merge, no_wait=opts.no_wait)
91
101
 
92
102
  if any(r.status == Status.SKIPPED for r in results):
93
103
  raise typer.Exit(1)
@@ -172,7 +182,8 @@ def _verify_repo(repo: Repo, repo_path: Path, fallback_verify: verify.VerifyConf
172
182
  return RepoResult(dest=dest, repo_path=repo_path, status=status, failures=result.failures)
173
183
 
174
184
 
175
- def _create_prs(config: DepConfig, results: list[RepoResult], opts: DepUpdateOptions) -> None:
185
+ def _create_prs(config: DepConfig, results: list[RepoResult], opts: DepUpdateOptions) -> list[PRRef]:
186
+ pr_refs: list[PRRef] = []
176
187
  for result in results:
177
188
  if result.status == Status.SKIPPED:
178
189
  continue
@@ -185,7 +196,7 @@ def _create_prs(config: DepConfig, results: list[RepoResult], opts: DepUpdateOpt
185
196
  git_ops.push_branch(repo, config.pr.branch, force=True)
186
197
 
187
198
  body = _build_pr_body(result.log_content, result.failures)
188
- git_ops.create_or_update_pr(
199
+ pr_url = git_ops.create_or_update_pr(
189
200
  result.repo_path,
190
201
  config.pr.branch,
191
202
  config.pr.title,
@@ -193,9 +204,11 @@ def _create_prs(config: DepConfig, results: list[RepoResult], opts: DepUpdateOpt
193
204
  config.pr.labels or None,
194
205
  reviewers=opts.reviewers,
195
206
  assignees=opts.assignees,
196
- auto_merge=config.pr.auto_merge,
197
207
  )
198
208
  logger.info(f"{result.dest.name}: PR created/updated")
209
+ branch_or_url = pr_url or config.pr.branch
210
+ pr_refs.append(PRRef(dest_name=result.dest.name, repo_path=result.repo_path, branch_or_url=branch_or_url))
211
+ return pr_refs
199
212
 
200
213
 
201
214
  def _resolve_repo_path(dest: Destination, src_root: Path, work_dir: str) -> Path:
@@ -210,7 +210,6 @@ def create_or_update_pr(
210
210
  labels: list[str] | None = None,
211
211
  reviewers: list[str] | None = None,
212
212
  assignees: list[str] | None = None,
213
- auto_merge: bool = False,
214
213
  ) -> str:
215
214
  cmd = ["gh", "pr", "create", "--head", branch, "--title", title]
216
215
  cmd.extend(["--body", body or ""])
@@ -226,26 +225,13 @@ def create_or_update_pr(
226
225
  if "already exists" in result.stderr:
227
226
  logger.info("PR already exists, updating body")
228
227
  update_pr_body(repo_path, branch, body)
229
- if auto_merge:
230
- _enable_auto_merge(repo_path, branch)
231
228
  return ""
232
229
  raise RuntimeError(f"Failed to create PR: {result.stderr}")
233
230
  pr_url = result.stdout.strip()
234
231
  logger.info(f"Created PR: {pr_url}")
235
- if auto_merge:
236
- _enable_auto_merge(repo_path, pr_url)
237
232
  return pr_url
238
233
 
239
234
 
240
- def _enable_auto_merge(repo_path: Path, pr_ref: str) -> None:
241
- cmd = ["gh", "pr", "merge", "--auto", "--squash", pr_ref]
242
- result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True)
243
- if result.returncode != 0:
244
- logger.warning(f"Auto-merge failed (branch protection may not be configured): {result.stderr}")
245
- else:
246
- logger.info(f"Enabled auto-merge for {pr_ref}")
247
-
248
-
249
235
  def file_has_git_changes(repo: Repo, file_path: Path, base_ref: str = "HEAD") -> bool:
250
236
  rel_path = str(file_path.relative_to(repo.working_dir))
251
237
  diff = repo.git.diff("--name-only", base_ref, "--", rel_path)
@@ -29,6 +29,19 @@ def _default_exclude_dirs() -> set[str]:
29
29
  return set(DEFAULT_EXCLUDE_DIRS)
30
30
 
31
31
 
32
+ class MergeMethod(StrEnum):
33
+ SQUASH = "squash"
34
+ MERGE = "merge"
35
+ REBASE = "rebase"
36
+
37
+
38
+ class AutoMergeConfig(BaseModel):
39
+ method: MergeMethod = MergeMethod.MERGE
40
+ delete_branch: bool = True
41
+ poll_interval_seconds: int = 30
42
+ timeout_seconds: int = 900
43
+
44
+
32
45
  class SyncMode(StrEnum):
33
46
  SYNC = "sync"
34
47
  REPLACE = "replace"
@@ -178,6 +191,7 @@ class SrcConfig(BaseModel):
178
191
  destinations: list[Destination] = Field(default_factory=list)
179
192
  verify: VerifyConfig | None = None
180
193
  wrap_synced_files: bool = False
194
+ auto_merge: AutoMergeConfig | None = None
181
195
 
182
196
  def find_destination(self, name: str) -> Destination:
183
197
  for dest in self.destinations:
@@ -6,6 +6,7 @@ from typing import ClassVar
6
6
  from pydantic import BaseModel, Field
7
7
 
8
8
  from path_sync._internal.models import (
9
+ AutoMergeConfig,
9
10
  Destination,
10
11
  PRFieldsBase,
11
12
  SrcConfig,
@@ -23,7 +24,6 @@ class UpdateEntry(BaseModel):
23
24
  class PRConfig(PRFieldsBase):
24
25
  branch: str
25
26
  title: str
26
- auto_merge: bool = False
27
27
 
28
28
 
29
29
  class DepConfig(BaseModel):
@@ -36,6 +36,7 @@ class DepConfig(BaseModel):
36
36
  updates: list[UpdateEntry]
37
37
  verify: VerifyConfig = Field(default_factory=VerifyConfig)
38
38
  pr: PRConfig
39
+ auto_merge: AutoMergeConfig | None = None
39
40
 
40
41
  def load_destinations(self, repo_root: Path) -> list[Destination]:
41
42
  src_config_path = resolve_config_path(repo_root, self.from_config)
@@ -1,13 +1,17 @@
1
1
  # Generated by pkg-ext
2
+ from path_sync._internal.models import AutoMergeConfig as _AutoMergeConfig
2
3
  from path_sync._internal.models import Destination as _Destination
3
4
  from path_sync._internal.models import HeaderConfig as _HeaderConfig
5
+ from path_sync._internal.models import MergeMethod as _MergeMethod
4
6
  from path_sync._internal.models import PathMapping as _PathMapping
5
7
  from path_sync._internal.models import PRDefaults as _PRDefaults
6
8
  from path_sync._internal.models import SrcConfig as _SrcConfig
7
9
  from path_sync._internal.models import SyncMode as _SyncMode
8
10
 
11
+ AutoMergeConfig = _AutoMergeConfig
9
12
  Destination = _Destination
10
13
  HeaderConfig = _HeaderConfig
14
+ MergeMethod = _MergeMethod
11
15
  PRDefaults = _PRDefaults
12
16
  PathMapping = _PathMapping
13
17
  SrcConfig = _SrcConfig
@@ -2,7 +2,7 @@
2
2
 
3
3
  [project]
4
4
  name = "path-sync"
5
- version = "0.6.2"
5
+ version = "0.7.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