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.
Files changed (31) hide show
  1. {path_sync-0.7.6 → path_sync-0.7.8}/PKG-INFO +8 -3
  2. {path_sync-0.7.6 → path_sync-0.7.8}/README.md +7 -2
  3. {path_sync-0.7.6 → path_sync-0.7.8}/path_sync/__init__.py +1 -1
  4. {path_sync-0.7.6 → path_sync-0.7.8}/path_sync/_internal/auto_merge.py +3 -1
  5. {path_sync-0.7.6 → path_sync-0.7.8}/path_sync/_internal/cmd_copy.py +4 -2
  6. {path_sync-0.7.6 → path_sync-0.7.8}/path_sync/_internal/cmd_dep_update.py +5 -2
  7. {path_sync-0.7.6 → path_sync-0.7.8}/path_sync/_internal/cmd_validate.py +9 -2
  8. {path_sync-0.7.6 → path_sync-0.7.8}/path_sync/_internal/git_ops.py +33 -2
  9. {path_sync-0.7.6 → path_sync-0.7.8}/pyproject.toml +25 -6
  10. {path_sync-0.7.6 → path_sync-0.7.8}/.gitignore +0 -0
  11. {path_sync-0.7.6 → path_sync-0.7.8}/LICENSE +0 -0
  12. {path_sync-0.7.6 → path_sync-0.7.8}/path_sync/__main__.py +0 -0
  13. {path_sync-0.7.6 → path_sync-0.7.8}/path_sync/_internal/__init__.py +0 -0
  14. {path_sync-0.7.6 → path_sync-0.7.8}/path_sync/_internal/cmd_boot.py +0 -0
  15. {path_sync-0.7.6 → path_sync-0.7.8}/path_sync/_internal/cmd_options.py +0 -0
  16. {path_sync-0.7.6 → path_sync-0.7.8}/path_sync/_internal/file_utils.py +0 -0
  17. {path_sync-0.7.6 → path_sync-0.7.8}/path_sync/_internal/header.py +0 -0
  18. {path_sync-0.7.6 → path_sync-0.7.8}/path_sync/_internal/log_capture.py +0 -0
  19. {path_sync-0.7.6 → path_sync-0.7.8}/path_sync/_internal/models.py +0 -0
  20. {path_sync-0.7.6 → path_sync-0.7.8}/path_sync/_internal/models_dep.py +0 -0
  21. {path_sync-0.7.6 → path_sync-0.7.8}/path_sync/_internal/prompt_utils.py +0 -0
  22. {path_sync-0.7.6 → path_sync-0.7.8}/path_sync/_internal/repo_utils.py +0 -0
  23. {path_sync-0.7.6 → path_sync-0.7.8}/path_sync/_internal/typer_app.py +0 -0
  24. {path_sync-0.7.6 → path_sync-0.7.8}/path_sync/_internal/validation.py +0 -0
  25. {path_sync-0.7.6 → path_sync-0.7.8}/path_sync/_internal/verify.py +0 -0
  26. {path_sync-0.7.6 → path_sync-0.7.8}/path_sync/_internal/yaml_utils.py +0 -0
  27. {path_sync-0.7.6 → path_sync-0.7.8}/path_sync/config.py +0 -0
  28. {path_sync-0.7.6 → path_sync-0.7.8}/path_sync/copy.py +0 -0
  29. {path_sync-0.7.6 → path_sync-0.7.8}/path_sync/dep_update.py +0 -0
  30. {path_sync-0.7.6 → path_sync-0.7.8}/path_sync/sections.py +0 -0
  31. {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.6
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` - Default branch to compare against (default: main)
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
- - run: uvx path-sync validate-no-changes -b main
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` - Default branch to compare against (default: main)
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
- - run: uvx path-sync validate-no-changes -b main
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:**
@@ -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.6"
8
+ VERSION = "0.7.8"
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:
@@ -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
- 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)
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("main", "-b", "--branch", help="Default branch to compare against"),
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 default branch {branch}, validation skipped")
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 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",
@@ -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 or ""])
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.6"
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 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",
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