path-sync 0.7.6__tar.gz → 0.7.8__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.
- {path_sync-0.7.6 → path_sync-0.7.8}/PKG-INFO +8 -3
- {path_sync-0.7.6 → path_sync-0.7.8}/README.md +7 -2
- {path_sync-0.7.6 → path_sync-0.7.8}/path_sync/__init__.py +1 -1
- {path_sync-0.7.6 → path_sync-0.7.8}/path_sync/_internal/auto_merge.py +3 -1
- {path_sync-0.7.6 → path_sync-0.7.8}/path_sync/_internal/cmd_copy.py +4 -2
- {path_sync-0.7.6 → path_sync-0.7.8}/path_sync/_internal/cmd_dep_update.py +5 -2
- {path_sync-0.7.6 → path_sync-0.7.8}/path_sync/_internal/cmd_validate.py +9 -2
- {path_sync-0.7.6 → path_sync-0.7.8}/path_sync/_internal/git_ops.py +33 -2
- {path_sync-0.7.6 → path_sync-0.7.8}/pyproject.toml +25 -6
- {path_sync-0.7.6 → path_sync-0.7.8}/.gitignore +0 -0
- {path_sync-0.7.6 → path_sync-0.7.8}/LICENSE +0 -0
- {path_sync-0.7.6 → path_sync-0.7.8}/path_sync/__main__.py +0 -0
- {path_sync-0.7.6 → path_sync-0.7.8}/path_sync/_internal/__init__.py +0 -0
- {path_sync-0.7.6 → path_sync-0.7.8}/path_sync/_internal/cmd_boot.py +0 -0
- {path_sync-0.7.6 → path_sync-0.7.8}/path_sync/_internal/cmd_options.py +0 -0
- {path_sync-0.7.6 → path_sync-0.7.8}/path_sync/_internal/file_utils.py +0 -0
- {path_sync-0.7.6 → path_sync-0.7.8}/path_sync/_internal/header.py +0 -0
- {path_sync-0.7.6 → path_sync-0.7.8}/path_sync/_internal/log_capture.py +0 -0
- {path_sync-0.7.6 → path_sync-0.7.8}/path_sync/_internal/models.py +0 -0
- {path_sync-0.7.6 → path_sync-0.7.8}/path_sync/_internal/models_dep.py +0 -0
- {path_sync-0.7.6 → path_sync-0.7.8}/path_sync/_internal/prompt_utils.py +0 -0
- {path_sync-0.7.6 → path_sync-0.7.8}/path_sync/_internal/repo_utils.py +0 -0
- {path_sync-0.7.6 → path_sync-0.7.8}/path_sync/_internal/typer_app.py +0 -0
- {path_sync-0.7.6 → path_sync-0.7.8}/path_sync/_internal/validation.py +0 -0
- {path_sync-0.7.6 → path_sync-0.7.8}/path_sync/_internal/verify.py +0 -0
- {path_sync-0.7.6 → path_sync-0.7.8}/path_sync/_internal/yaml_utils.py +0 -0
- {path_sync-0.7.6 → path_sync-0.7.8}/path_sync/config.py +0 -0
- {path_sync-0.7.6 → path_sync-0.7.8}/path_sync/copy.py +0 -0
- {path_sync-0.7.6 → path_sync-0.7.8}/path_sync/dep_update.py +0 -0
- {path_sync-0.7.6 → path_sync-0.7.8}/path_sync/sections.py +0 -0
- {path_sync-0.7.6 → path_sync-0.7.8}/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.
|
|
3
|
+
Version: 0.7.8
|
|
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
|
|
@@ -98,8 +98,10 @@ By default, prompts before each git operation. See [Usage Scenarios](#usage-scen
|
|
|
98
98
|
uvx path-sync validate-no-changes -b main
|
|
99
99
|
```
|
|
100
100
|
|
|
101
|
+
In CI, when the workflow runs on `pull_request`, the comparison branch is the PR base. On `push` or locally, pass `-b` with the branch to compare against (e.g. `-b SDLC` for a branch based on SDLC).
|
|
102
|
+
|
|
101
103
|
Options:
|
|
102
|
-
- `-b, --branch` -
|
|
104
|
+
- `-b, --branch` - Branch to compare against (default: main). When `GITHUB_BASE_REF` is set (e.g. in GitHub Actions), it overrides the default so CI can use the PR base without passing `-b`. If you set `GITHUB_BASE_REF`, use a non-empty branch name.
|
|
103
105
|
- `--skip-sections` - Comma-separated `path:section_id` pairs to skip (e.g., `justfile:coverage`)
|
|
104
106
|
|
|
105
107
|
## Usage Scenarios
|
|
@@ -376,7 +378,10 @@ jobs:
|
|
|
376
378
|
with:
|
|
377
379
|
fetch-depth: 0
|
|
378
380
|
- uses: astral-sh/setup-uv@v5
|
|
379
|
-
-
|
|
381
|
+
- name: Validate no changes to synced files
|
|
382
|
+
env:
|
|
383
|
+
GITHUB_BASE_REF: ${{ github.base_ref || 'main' }}
|
|
384
|
+
run: uvx path-sync validate-no-changes
|
|
380
385
|
```
|
|
381
386
|
|
|
382
387
|
**Validation skips automatically when:**
|
|
@@ -80,8 +80,10 @@ By default, prompts before each git operation. See [Usage Scenarios](#usage-scen
|
|
|
80
80
|
uvx path-sync validate-no-changes -b main
|
|
81
81
|
```
|
|
82
82
|
|
|
83
|
+
In CI, when the workflow runs on `pull_request`, the comparison branch is the PR base. On `push` or locally, pass `-b` with the branch to compare against (e.g. `-b SDLC` for a branch based on SDLC).
|
|
84
|
+
|
|
83
85
|
Options:
|
|
84
|
-
- `-b, --branch` -
|
|
86
|
+
- `-b, --branch` - Branch to compare against (default: main). When `GITHUB_BASE_REF` is set (e.g. in GitHub Actions), it overrides the default so CI can use the PR base without passing `-b`. If you set `GITHUB_BASE_REF`, use a non-empty branch name.
|
|
85
87
|
- `--skip-sections` - Comma-separated `path:section_id` pairs to skip (e.g., `justfile:coverage`)
|
|
86
88
|
|
|
87
89
|
## Usage Scenarios
|
|
@@ -358,7 +360,10 @@ jobs:
|
|
|
358
360
|
with:
|
|
359
361
|
fetch-depth: 0
|
|
360
362
|
- uses: astral-sh/setup-uv@v5
|
|
361
|
-
-
|
|
363
|
+
- name: Validate no changes to synced files
|
|
364
|
+
env:
|
|
365
|
+
GITHUB_BASE_REF: ${{ github.base_ref || 'main' }}
|
|
366
|
+
run: uvx path-sync validate-no-changes
|
|
362
367
|
```
|
|
363
368
|
|
|
364
369
|
**Validation skips automatically when:**
|
|
@@ -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:
|
|
@@ -83,7 +85,7 @@ def enable_auto_merge(repo_path: Path, pr_ref: str, config: AutoMergeConfig, des
|
|
|
83
85
|
|
|
84
86
|
|
|
85
87
|
def get_pr_checks(repo_path: Path, pr_ref: str) -> list[CheckRun]:
|
|
86
|
-
cmd = ["gh", "pr", "checks", pr_ref, "--json", "name,state"]
|
|
88
|
+
cmd = ["gh", "pr", "checks", pr_ref, "--json", "name,state,workflow,link"]
|
|
87
89
|
result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True)
|
|
88
90
|
if result.returncode != 0:
|
|
89
91
|
logger.warning(f"Failed to get checks for {pr_ref}: {result.stderr.strip()}")
|
|
@@ -607,8 +607,10 @@ def _push_and_pr(
|
|
|
607
607
|
if not prompt_utils.prompt_confirm(f"Push {dest.name} to origin?", opts.no_prompt):
|
|
608
608
|
return None
|
|
609
609
|
|
|
610
|
-
git_ops.push_branch(repo, copy_branch, force=True)
|
|
611
|
-
|
|
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)
|
|
612
614
|
|
|
613
615
|
if opts.no_pr or not prompt_utils.prompt_confirm(f"Create PR for {dest.name}?", opts.no_prompt):
|
|
614
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,
|
|
@@ -15,7 +15,13 @@ logger = logging.getLogger(__name__)
|
|
|
15
15
|
|
|
16
16
|
@app.command("validate-no-changes")
|
|
17
17
|
def validate_no_changes(
|
|
18
|
-
branch: str = typer.Option(
|
|
18
|
+
branch: str = typer.Option(
|
|
19
|
+
"main",
|
|
20
|
+
"-b",
|
|
21
|
+
"--branch",
|
|
22
|
+
help="Branch to compare against (default: main; uses GITHUB_BASE_REF when set and -b not passed)",
|
|
23
|
+
envvar="GITHUB_BASE_REF",
|
|
24
|
+
),
|
|
19
25
|
skip_sections_opt: str = typer.Option(
|
|
20
26
|
"",
|
|
21
27
|
"--skip-sections",
|
|
@@ -36,9 +42,10 @@ def validate_no_changes(
|
|
|
36
42
|
logger.info(f"On sync branch {current_branch}, validation skipped")
|
|
37
43
|
return
|
|
38
44
|
if current_branch == branch:
|
|
39
|
-
logger.info(f"On
|
|
45
|
+
logger.info(f"On base branch {branch}, validation skipped")
|
|
40
46
|
return
|
|
41
47
|
|
|
48
|
+
logger.info(f"Validating changes against branch {branch}")
|
|
42
49
|
skip_sections = parse_skip_sections(skip_sections_opt) if skip_sections_opt else None
|
|
43
50
|
unauthorized = validate_no_unauthorized_changes(repo_root, branch, skip_sections)
|
|
44
51
|
|
|
@@ -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
|
|
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",
|
|
@@ -243,8 +273,9 @@ def create_or_update_pr(
|
|
|
243
273
|
reviewers: list[str] | None = None,
|
|
244
274
|
assignees: list[str] | None = None,
|
|
245
275
|
) -> str:
|
|
276
|
+
body = _truncate_body(body) if body else ""
|
|
246
277
|
cmd = ["gh", "pr", "create", "--head", branch, "--title", title]
|
|
247
|
-
cmd.extend(["--body", body
|
|
278
|
+
cmd.extend(["--body", body])
|
|
248
279
|
if labels:
|
|
249
280
|
cmd.extend(["--label", ",".join(labels)])
|
|
250
281
|
if reviewers:
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[project]
|
|
4
4
|
name = "path-sync"
|
|
5
|
-
version = "0.7.
|
|
5
|
+
version = "0.7.8"
|
|
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,34 @@ 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
|
|
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",
|
|
109
|
+
"pytest-examples>=0.0.18",
|
|
94
110
|
"pytest-regressions>=2.6",
|
|
95
|
-
"ruff>=0.14",
|
|
96
111
|
]
|
|
112
|
+
# === OK_EDIT: path-sync dev-test ===
|
|
113
|
+
|
|
114
|
+
# === DO_NOT_EDIT: path-sync dev ===
|
|
115
|
+
dev = [{include-group = "dev-lint"}, {include-group = "dev-test"}]
|
|
97
116
|
# === OK_EDIT: path-sync dev ===
|
|
98
117
|
|
|
99
118
|
# === DO_NOT_EDIT: path-sync docs ===
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|