path-sync 0.4.1__py3-none-any.whl → 0.6.0__py3-none-any.whl
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.
- path_sync/__init__.py +5 -3
- path_sync/_internal/cmd_copy.py +94 -89
- path_sync/_internal/cmd_dep_update.py +82 -97
- path_sync/_internal/cmd_options.py +22 -0
- path_sync/_internal/git_ops.py +9 -0
- path_sync/_internal/log_capture.py +38 -0
- path_sync/_internal/models.py +41 -4
- path_sync/_internal/models_dep.py +8 -26
- path_sync/_internal/prompt_utils.py +25 -0
- path_sync/_internal/verify.py +97 -0
- path_sync/copy.py +2 -0
- path_sync/dep_update.py +7 -7
- path_sync/sections.py +19 -1
- path_sync/validate_no_changes.py +4 -0
- {path_sync-0.4.1.dist-info → path_sync-0.6.0.dist-info}/METADATA +172 -4
- path_sync-0.6.0.dist-info/RECORD +29 -0
- path_sync-0.4.1.dist-info/RECORD +0 -24
- {path_sync-0.4.1.dist-info → path_sync-0.6.0.dist-info}/WHEEL +0 -0
- {path_sync-0.4.1.dist-info → path_sync-0.6.0.dist-info}/entry_points.txt +0 -0
- {path_sync-0.4.1.dist-info → path_sync-0.6.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import logging
|
|
4
|
+
import shutil
|
|
4
5
|
import subprocess
|
|
5
6
|
from dataclasses import dataclass, field
|
|
6
7
|
from enum import StrEnum
|
|
@@ -9,22 +10,20 @@ from pathlib import Path
|
|
|
9
10
|
import typer
|
|
10
11
|
from git import Repo
|
|
11
12
|
|
|
12
|
-
from path_sync._internal import git_ops
|
|
13
|
-
from path_sync._internal.
|
|
13
|
+
from path_sync._internal import cmd_options, git_ops, prompt_utils, verify
|
|
14
|
+
from path_sync._internal.log_capture import capture_log
|
|
15
|
+
from path_sync._internal.models import Destination, OnFailStrategy, find_repo_root
|
|
14
16
|
from path_sync._internal.models_dep import (
|
|
15
17
|
DepConfig,
|
|
16
|
-
OnFailStrategy,
|
|
17
18
|
UpdateEntry,
|
|
18
|
-
VerifyConfig,
|
|
19
19
|
resolve_dep_config_path,
|
|
20
20
|
)
|
|
21
21
|
from path_sync._internal.typer_app import app
|
|
22
|
+
from path_sync._internal.verify import StepFailure, VerifyStatus
|
|
22
23
|
from path_sync._internal.yaml_utils import load_yaml_model
|
|
23
24
|
|
|
24
25
|
logger = logging.getLogger(__name__)
|
|
25
26
|
|
|
26
|
-
MAX_STDERR_LINES = 20
|
|
27
|
-
|
|
28
27
|
|
|
29
28
|
class Status(StrEnum):
|
|
30
29
|
PASSED = "passed"
|
|
@@ -33,18 +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
|
-
stderr: str
|
|
42
|
-
on_fail: OnFailStrategy
|
|
43
|
-
|
|
44
35
|
@classmethod
|
|
45
|
-
def
|
|
46
|
-
|
|
47
|
-
return cls(step=step, returncode=e.returncode, stderr=stderr, on_fail=on_fail)
|
|
36
|
+
def from_verify_status(cls, vs: VerifyStatus) -> Status:
|
|
37
|
+
return cls(vs.value)
|
|
48
38
|
|
|
49
39
|
|
|
50
40
|
@dataclass
|
|
@@ -53,24 +43,27 @@ class RepoResult:
|
|
|
53
43
|
repo_path: Path
|
|
54
44
|
status: Status
|
|
55
45
|
failures: list[StepFailure] = field(default_factory=list)
|
|
46
|
+
log_content: str = ""
|
|
56
47
|
|
|
57
48
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
49
|
+
@dataclass
|
|
50
|
+
class DepUpdateOptions:
|
|
51
|
+
dry_run: bool = False
|
|
52
|
+
skip_verify: bool = False
|
|
53
|
+
reviewers: list[str] | None = None
|
|
54
|
+
assignees: list[str] | None = None
|
|
64
55
|
|
|
65
56
|
|
|
66
57
|
@app.command()
|
|
67
58
|
def dep_update(
|
|
68
59
|
name: str = typer.Option(..., "-n", "--name", help="Config name"),
|
|
69
60
|
dest_filter: str = typer.Option("", "-d", "--dest", help="Filter destinations (comma-separated)"),
|
|
70
|
-
work_dir: str = typer.Option("", "--work-dir", help="
|
|
61
|
+
work_dir: str = typer.Option("", "--work-dir", help="Clone repos here (overrides dest_path_relative)"),
|
|
71
62
|
dry_run: bool = typer.Option(False, "--dry-run", help="Preview without creating PRs"),
|
|
72
63
|
skip_verify: bool = typer.Option(False, "--skip-verify", help="Skip verification steps"),
|
|
73
64
|
src_root_opt: str = typer.Option("", "--src-root", help="Source repo root"),
|
|
65
|
+
pr_reviewers: str = cmd_options.pr_reviewers_option(),
|
|
66
|
+
pr_assignees: str = cmd_options.pr_assignees_option(),
|
|
74
67
|
) -> None:
|
|
75
68
|
"""Run dependency updates across repositories."""
|
|
76
69
|
src_root = Path(src_root_opt) if src_root_opt else find_repo_root(Path.cwd())
|
|
@@ -86,8 +79,15 @@ def dep_update(
|
|
|
86
79
|
filter_names = [n.strip() for n in dest_filter.split(",")]
|
|
87
80
|
destinations = [d for d in destinations if d.name in filter_names]
|
|
88
81
|
|
|
89
|
-
|
|
90
|
-
|
|
82
|
+
opts = DepUpdateOptions(
|
|
83
|
+
dry_run=dry_run,
|
|
84
|
+
skip_verify=skip_verify,
|
|
85
|
+
reviewers=cmd_options.split_csv(pr_reviewers) or config.pr.reviewers,
|
|
86
|
+
assignees=cmd_options.split_csv(pr_assignees) or config.pr.assignees,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
results = _update_and_validate(config, destinations, src_root, work_dir, opts)
|
|
90
|
+
_create_prs(config, results, opts)
|
|
91
91
|
|
|
92
92
|
if any(r.status == Status.SKIPPED for r in results):
|
|
93
93
|
raise typer.Exit(1)
|
|
@@ -98,12 +98,12 @@ def _update_and_validate(
|
|
|
98
98
|
destinations: list[Destination],
|
|
99
99
|
src_root: Path,
|
|
100
100
|
work_dir: str,
|
|
101
|
-
|
|
101
|
+
opts: DepUpdateOptions,
|
|
102
102
|
) -> list[RepoResult]:
|
|
103
103
|
results: list[RepoResult] = []
|
|
104
104
|
|
|
105
105
|
for dest in destinations:
|
|
106
|
-
result = _process_single_repo(config, dest, src_root, work_dir,
|
|
106
|
+
result = _process_single_repo(config, dest, src_root, work_dir, opts)
|
|
107
107
|
|
|
108
108
|
if result.status == Status.FAILED:
|
|
109
109
|
logger.error(f"{dest.name}: Verification failed, stopping")
|
|
@@ -120,131 +120,116 @@ def _process_single_repo(
|
|
|
120
120
|
dest: Destination,
|
|
121
121
|
src_root: Path,
|
|
122
122
|
work_dir: str,
|
|
123
|
-
|
|
123
|
+
opts: DepUpdateOptions,
|
|
124
|
+
) -> RepoResult:
|
|
125
|
+
with capture_log(dest.name) as read_log:
|
|
126
|
+
result = _process_single_repo_inner(config, dest, src_root, work_dir, opts)
|
|
127
|
+
result.log_content = read_log()
|
|
128
|
+
return result
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _process_single_repo_inner(
|
|
132
|
+
config: DepConfig,
|
|
133
|
+
dest: Destination,
|
|
134
|
+
src_root: Path,
|
|
135
|
+
work_dir: str,
|
|
136
|
+
opts: DepUpdateOptions,
|
|
124
137
|
) -> RepoResult:
|
|
125
138
|
logger.info(f"Processing {dest.name}...")
|
|
126
139
|
repo_path = _resolve_repo_path(dest, src_root, work_dir)
|
|
127
|
-
repo = _ensure_repo(dest, repo_path)
|
|
140
|
+
repo = _ensure_repo(dest, repo_path, dest.default_branch)
|
|
128
141
|
git_ops.prepare_copy_branch(repo, dest.default_branch, config.pr.branch, from_default=True)
|
|
129
142
|
|
|
130
143
|
if failure := _run_updates(config.updates, repo_path):
|
|
131
144
|
logger.warning(f"{dest.name}: Update failed with exit code {failure.returncode}")
|
|
132
|
-
return RepoResult(dest, repo_path, Status.SKIPPED, failures=[failure])
|
|
145
|
+
return RepoResult(dest=dest, repo_path=repo_path, status=Status.SKIPPED, failures=[failure])
|
|
133
146
|
|
|
134
147
|
if not git_ops.has_changes(repo):
|
|
135
148
|
logger.info(f"{dest.name}: No changes, skipping")
|
|
136
|
-
return RepoResult(dest, repo_path, Status.NO_CHANGES)
|
|
149
|
+
return RepoResult(dest=dest, repo_path=repo_path, status=Status.NO_CHANGES)
|
|
137
150
|
|
|
138
151
|
git_ops.commit_changes(repo, config.pr.title)
|
|
139
152
|
|
|
140
|
-
if skip_verify:
|
|
141
|
-
return RepoResult(dest, repo_path, Status.PASSED)
|
|
153
|
+
if opts.skip_verify:
|
|
154
|
+
return RepoResult(dest=dest, repo_path=repo_path, status=Status.PASSED)
|
|
142
155
|
|
|
143
156
|
return _verify_repo(repo, repo_path, config.verify, dest)
|
|
144
157
|
|
|
145
158
|
|
|
146
159
|
def _run_updates(updates: list[UpdateEntry], repo_path: Path) -> StepFailure | None:
|
|
147
|
-
"""Returns StepFailure on failure, None on success."""
|
|
148
160
|
try:
|
|
149
161
|
for update in updates:
|
|
150
|
-
|
|
162
|
+
verify.run_command(update.command, repo_path / update.workdir)
|
|
151
163
|
return None
|
|
152
164
|
except subprocess.CalledProcessError as e:
|
|
153
|
-
return StepFailure
|
|
165
|
+
return StepFailure(step=e.cmd, returncode=e.returncode, on_fail=OnFailStrategy.SKIP)
|
|
154
166
|
|
|
155
167
|
|
|
156
|
-
def _verify_repo(repo: Repo, repo_path: Path,
|
|
157
|
-
|
|
158
|
-
|
|
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)
|
|
159
173
|
|
|
160
174
|
|
|
161
|
-
def _create_prs(config: DepConfig, results: list[RepoResult],
|
|
175
|
+
def _create_prs(config: DepConfig, results: list[RepoResult], opts: DepUpdateOptions) -> None:
|
|
162
176
|
for result in results:
|
|
163
177
|
if result.status == Status.SKIPPED:
|
|
164
178
|
continue
|
|
165
179
|
|
|
166
|
-
if dry_run:
|
|
180
|
+
if opts.dry_run:
|
|
167
181
|
logger.info(f"[DRY RUN] Would create PR for {result.dest.name}")
|
|
168
182
|
continue
|
|
169
183
|
|
|
170
184
|
repo = git_ops.get_repo(result.repo_path)
|
|
171
185
|
git_ops.push_branch(repo, config.pr.branch, force=True)
|
|
172
186
|
|
|
173
|
-
body = _build_pr_body(result.failures)
|
|
187
|
+
body = _build_pr_body(result.log_content, result.failures)
|
|
174
188
|
git_ops.create_or_update_pr(
|
|
175
189
|
result.repo_path,
|
|
176
190
|
config.pr.branch,
|
|
177
191
|
config.pr.title,
|
|
178
192
|
body,
|
|
179
193
|
config.pr.labels or None,
|
|
194
|
+
reviewers=opts.reviewers,
|
|
195
|
+
assignees=opts.assignees,
|
|
180
196
|
auto_merge=config.pr.auto_merge,
|
|
181
197
|
)
|
|
182
198
|
logger.info(f"{result.dest.name}: PR created/updated")
|
|
183
199
|
|
|
184
200
|
|
|
185
201
|
def _resolve_repo_path(dest: Destination, src_root: Path, work_dir: str) -> Path:
|
|
202
|
+
if work_dir:
|
|
203
|
+
return Path(work_dir) / dest.name
|
|
186
204
|
if dest.dest_path_relative:
|
|
187
205
|
return (src_root / dest.dest_path_relative).resolve()
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
206
|
+
raise typer.BadParameter(f"No dest_path_relative for {dest.name}, --work-dir required")
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _ensure_repo(dest: Destination, repo_path: Path, default_branch: str) -> Repo:
|
|
210
|
+
if repo_path.exists():
|
|
211
|
+
if git_ops.is_git_repo(repo_path):
|
|
212
|
+
repo = git_ops.get_repo(repo_path)
|
|
213
|
+
git_ops.fetch_and_reset_to_default(repo, default_branch)
|
|
214
|
+
return repo
|
|
215
|
+
logger.warning(f"Invalid git repo at {repo_path}")
|
|
216
|
+
if not prompt_utils.prompt_confirm(f"Remove {repo_path} and re-clone?"):
|
|
217
|
+
raise typer.Abort()
|
|
218
|
+
shutil.rmtree(repo_path)
|
|
196
219
|
if not dest.repo_url:
|
|
197
220
|
raise ValueError(f"Dest {dest.name} not found at {repo_path} and no repo_url configured")
|
|
198
221
|
return git_ops.clone_repo(dest.repo_url, repo_path)
|
|
199
222
|
|
|
200
223
|
|
|
201
|
-
def
|
|
202
|
-
|
|
203
|
-
result = subprocess.run(cmd, shell=True, cwd=cwd, capture_output=True, text=True)
|
|
204
|
-
if result.returncode != 0:
|
|
205
|
-
stderr = result.stderr.strip()
|
|
206
|
-
logger.error(f"Command failed '{cmd}' in {cwd}: {stderr}")
|
|
207
|
-
raise subprocess.CalledProcessError(result.returncode, cmd, output=result.stdout, stderr=stderr)
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
def _run_verify_steps(
|
|
211
|
-
repo: Repo,
|
|
212
|
-
repo_path: Path,
|
|
213
|
-
verify: VerifyConfig,
|
|
214
|
-
) -> tuple[Status, list[StepFailure]]:
|
|
215
|
-
failures: list[StepFailure] = []
|
|
216
|
-
|
|
217
|
-
for step in verify.steps:
|
|
218
|
-
on_fail = step.on_fail or verify.on_fail
|
|
219
|
-
|
|
220
|
-
try:
|
|
221
|
-
_run_command(step.run, repo_path)
|
|
222
|
-
except subprocess.CalledProcessError as e:
|
|
223
|
-
failure = StepFailure.from_error(step.run, e, on_fail)
|
|
224
|
-
match on_fail:
|
|
225
|
-
case OnFailStrategy.FAIL:
|
|
226
|
-
return (Status.FAILED, [failure])
|
|
227
|
-
case OnFailStrategy.SKIP:
|
|
228
|
-
return (Status.SKIPPED, [failure])
|
|
229
|
-
case OnFailStrategy.WARN:
|
|
230
|
-
failures.append(failure)
|
|
231
|
-
continue
|
|
232
|
-
|
|
233
|
-
if step.commit:
|
|
234
|
-
git_ops.stage_and_commit(repo, step.commit.add_paths, step.commit.message)
|
|
224
|
+
def _build_pr_body(log_content: str, failures: list[StepFailure]) -> str:
|
|
225
|
+
body = "Automated dependency update."
|
|
235
226
|
|
|
236
|
-
|
|
227
|
+
if log_content.strip():
|
|
228
|
+
body += "\n\n## Command Output\n\n```\n" + log_content.strip() + "\n```"
|
|
237
229
|
|
|
230
|
+
if failures:
|
|
231
|
+
body += "\n\n---\n## Verification Issues\n"
|
|
232
|
+
for f in failures:
|
|
233
|
+
body += f"\n- `{f.step}` failed (exit code {f.returncode}, strategy: {f.on_fail})"
|
|
238
234
|
|
|
239
|
-
def _build_pr_body(failures: list[StepFailure]) -> str:
|
|
240
|
-
body = "Automated dependency update."
|
|
241
|
-
if not failures:
|
|
242
|
-
return body
|
|
243
|
-
|
|
244
|
-
body += "\n\n---\n## Verification Issues\n"
|
|
245
|
-
for f in failures:
|
|
246
|
-
body += f"\n### `{f.step}` (strategy: {f.on_fail})\n"
|
|
247
|
-
body += f"**Exit code:** {f.returncode}\n"
|
|
248
|
-
if f.stderr:
|
|
249
|
-
body += f"\n```\n{f.stderr}\n```\n"
|
|
250
235
|
return body
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Shared CLI options for commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def pr_reviewers_option() -> str:
|
|
9
|
+
return typer.Option("", "--pr-reviewers", help="Comma-separated PR reviewers")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def pr_assignees_option() -> str:
|
|
13
|
+
return typer.Option("", "--pr-assignees", help="Comma-separated PR assignees")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def pr_labels_option() -> str:
|
|
17
|
+
return typer.Option("", "--pr-labels", help="Comma-separated PR labels")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def split_csv(value: str) -> list[str] | None:
|
|
21
|
+
"""Split comma-separated string, returns None if empty."""
|
|
22
|
+
return [v.strip() for v in value.split(",")] if value else None
|
path_sync/_internal/git_ops.py
CHANGED
|
@@ -41,6 +41,15 @@ def get_default_branch(repo: Repo) -> str:
|
|
|
41
41
|
return "main"
|
|
42
42
|
|
|
43
43
|
|
|
44
|
+
def fetch_and_reset_to_default(repo: Repo, default_branch: str) -> None:
|
|
45
|
+
"""Fetch latest from remote and reset to default branch."""
|
|
46
|
+
logger.info(f"Fetching origin and resetting to {default_branch}")
|
|
47
|
+
repo.remotes.origin.fetch()
|
|
48
|
+
if repo.head.is_detached or repo.active_branch.name != default_branch:
|
|
49
|
+
repo.git.checkout(default_branch)
|
|
50
|
+
repo.git.reset("--hard", f"origin/{default_branch}")
|
|
51
|
+
|
|
52
|
+
|
|
44
53
|
def clone_repo(url: str, dest: Path) -> Repo:
|
|
45
54
|
logger.info(f"Cloning {url} to {dest}")
|
|
46
55
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import tempfile
|
|
5
|
+
from collections.abc import Callable, Generator
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
LOG_FORMAT = "%(message)s"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@contextmanager
|
|
13
|
+
def capture_log(name: str) -> Generator[Callable[[], str]]:
|
|
14
|
+
"""Capture path_sync logger output to a temp file.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
name: Used for temp file naming (e.g., repo name for debugging).
|
|
18
|
+
|
|
19
|
+
Yields:
|
|
20
|
+
Callable that flushes the handler and returns log content.
|
|
21
|
+
"""
|
|
22
|
+
with tempfile.TemporaryDirectory(prefix="path-sync-") as tmpdir:
|
|
23
|
+
log_path = Path(tmpdir) / f"{name}.log"
|
|
24
|
+
file_handler = logging.FileHandler(log_path, mode="w")
|
|
25
|
+
file_handler.setLevel(logging.INFO)
|
|
26
|
+
file_handler.setFormatter(logging.Formatter(LOG_FORMAT))
|
|
27
|
+
root_logger = logging.getLogger("path_sync")
|
|
28
|
+
root_logger.addHandler(file_handler)
|
|
29
|
+
try:
|
|
30
|
+
|
|
31
|
+
def read_log() -> str:
|
|
32
|
+
file_handler.flush()
|
|
33
|
+
return log_path.read_text() if log_path.exists() else ""
|
|
34
|
+
|
|
35
|
+
yield read_log
|
|
36
|
+
finally:
|
|
37
|
+
file_handler.close()
|
|
38
|
+
root_logger.removeHandler(file_handler)
|
path_sync/_internal/models.py
CHANGED
|
@@ -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
|
|
@@ -85,14 +111,19 @@ Synced from [{src_repo_name}]({src_repo_url}) @ `{src_sha_short}`
|
|
|
85
111
|
"""
|
|
86
112
|
|
|
87
113
|
|
|
88
|
-
class
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
body_suffix: str = ""
|
|
114
|
+
class PRFieldsBase(BaseModel):
|
|
115
|
+
"""Common PR fields shared by copy and dep-update commands."""
|
|
116
|
+
|
|
92
117
|
labels: list[str] = Field(default_factory=list)
|
|
93
118
|
reviewers: list[str] = Field(default_factory=list)
|
|
94
119
|
assignees: list[str] = Field(default_factory=list)
|
|
95
120
|
|
|
121
|
+
|
|
122
|
+
class PRDefaults(PRFieldsBase):
|
|
123
|
+
title: str = "chore: sync {name} files"
|
|
124
|
+
body_template: str = DEFAULT_BODY_TEMPLATE
|
|
125
|
+
body_suffix: str = ""
|
|
126
|
+
|
|
96
127
|
def format_body(
|
|
97
128
|
self,
|
|
98
129
|
src_repo_url: str,
|
|
@@ -122,6 +153,7 @@ class Destination(BaseModel):
|
|
|
122
153
|
default_branch: str = "main"
|
|
123
154
|
skip_sections: dict[str, list[str]] = Field(default_factory=dict)
|
|
124
155
|
skip_file_patterns: set[str] = Field(default_factory=set)
|
|
156
|
+
verify: VerifyConfig | None = None
|
|
125
157
|
|
|
126
158
|
def resolved_copy_branch(self, config_name: str) -> str:
|
|
127
159
|
return self.copy_branch or f"sync/{config_name}"
|
|
@@ -129,6 +161,9 @@ class Destination(BaseModel):
|
|
|
129
161
|
def is_skipped(self, dest_key: str) -> bool:
|
|
130
162
|
return any(fnmatch.fnmatch(dest_key, pat) for pat in self.skip_file_patterns)
|
|
131
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
|
+
|
|
132
167
|
|
|
133
168
|
class SrcConfig(BaseModel):
|
|
134
169
|
CONFIG_EXT: ClassVar[str] = ".src.yaml"
|
|
@@ -141,6 +176,8 @@ class SrcConfig(BaseModel):
|
|
|
141
176
|
pr_defaults: PRDefaults = Field(default_factory=PRDefaults)
|
|
142
177
|
paths: list[PathMapping] = Field(default_factory=list)
|
|
143
178
|
destinations: list[Destination] = Field(default_factory=list)
|
|
179
|
+
verify: VerifyConfig | None = None
|
|
180
|
+
wrap_synced_files: bool = False
|
|
144
181
|
|
|
145
182
|
def find_destination(self, name: str) -> Destination:
|
|
146
183
|
for dest in self.destinations:
|
|
@@ -1,46 +1,28 @@
|
|
|
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
|
|
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
|
|
38
21
|
|
|
39
22
|
|
|
40
|
-
class PRConfig(
|
|
23
|
+
class PRConfig(PRFieldsBase):
|
|
41
24
|
branch: str
|
|
42
25
|
title: str
|
|
43
|
-
labels: list[str] = Field(default_factory=list)
|
|
44
26
|
auto_merge: bool = False
|
|
45
27
|
|
|
46
28
|
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Shared prompt utilities for interactive CLI commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def prompt_confirm(message: str, no_prompt: bool = False) -> bool:
|
|
9
|
+
"""Prompt user for confirmation.
|
|
10
|
+
|
|
11
|
+
Args:
|
|
12
|
+
message: The prompt message to display
|
|
13
|
+
no_prompt: If True, auto-confirms without prompting
|
|
14
|
+
|
|
15
|
+
Returns:
|
|
16
|
+
True if confirmed, False otherwise.
|
|
17
|
+
Auto-confirms in non-interactive mode (CI) or when no_prompt=True.
|
|
18
|
+
"""
|
|
19
|
+
if no_prompt or not sys.stdin.isatty():
|
|
20
|
+
return True
|
|
21
|
+
try:
|
|
22
|
+
response = input(f"{message} [y/n]: ").strip().lower()
|
|
23
|
+
return response == "y"
|
|
24
|
+
except (EOFError, KeyboardInterrupt):
|
|
25
|
+
return False
|
|
@@ -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}")
|