path-sync 0.7.7__tar.gz → 0.8.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 (33) hide show
  1. {path_sync-0.7.7 → path_sync-0.8.0}/PKG-INFO +78 -5
  2. {path_sync-0.7.7 → path_sync-0.8.0}/README.md +76 -3
  3. {path_sync-0.7.7 → path_sync-0.8.0}/path_sync/__init__.py +3 -1
  4. {path_sync-0.7.7 → path_sync-0.8.0}/path_sync/__main__.py +1 -1
  5. {path_sync-0.7.7 → path_sync-0.8.0}/path_sync/_internal/auto_merge.py +4 -4
  6. {path_sync-0.7.7 → path_sync-0.8.0}/path_sync/_internal/cmd_boot.py +6 -2
  7. path_sync-0.8.0/path_sync/_internal/cmd_pull.py +407 -0
  8. {path_sync-0.7.7 → path_sync-0.8.0}/path_sync/_internal/cmd_validate.py +9 -2
  9. {path_sync-0.7.7 → path_sync-0.8.0}/path_sync/_internal/git_ops.py +33 -8
  10. {path_sync-0.7.7 → path_sync-0.8.0}/path_sync/_internal/prompt_utils.py +9 -0
  11. {path_sync-0.7.7 → path_sync-0.8.0}/path_sync/_internal/verify.py +1 -1
  12. path_sync-0.8.0/path_sync/pull.py +6 -0
  13. {path_sync-0.7.7 → path_sync-0.8.0}/pyproject.toml +19 -2
  14. {path_sync-0.7.7 → path_sync-0.8.0}/.gitignore +0 -0
  15. {path_sync-0.7.7 → path_sync-0.8.0}/LICENSE +0 -0
  16. {path_sync-0.7.7 → path_sync-0.8.0}/path_sync/_internal/__init__.py +0 -0
  17. {path_sync-0.7.7 → path_sync-0.8.0}/path_sync/_internal/cmd_copy.py +0 -0
  18. {path_sync-0.7.7 → path_sync-0.8.0}/path_sync/_internal/cmd_dep_update.py +0 -0
  19. {path_sync-0.7.7 → path_sync-0.8.0}/path_sync/_internal/cmd_options.py +0 -0
  20. {path_sync-0.7.7 → path_sync-0.8.0}/path_sync/_internal/file_utils.py +0 -0
  21. {path_sync-0.7.7 → path_sync-0.8.0}/path_sync/_internal/header.py +0 -0
  22. {path_sync-0.7.7 → path_sync-0.8.0}/path_sync/_internal/log_capture.py +0 -0
  23. {path_sync-0.7.7 → path_sync-0.8.0}/path_sync/_internal/models.py +0 -0
  24. {path_sync-0.7.7 → path_sync-0.8.0}/path_sync/_internal/models_dep.py +0 -0
  25. {path_sync-0.7.7 → path_sync-0.8.0}/path_sync/_internal/repo_utils.py +0 -0
  26. {path_sync-0.7.7 → path_sync-0.8.0}/path_sync/_internal/typer_app.py +0 -0
  27. {path_sync-0.7.7 → path_sync-0.8.0}/path_sync/_internal/validation.py +0 -0
  28. {path_sync-0.7.7 → path_sync-0.8.0}/path_sync/_internal/yaml_utils.py +0 -0
  29. {path_sync-0.7.7 → path_sync-0.8.0}/path_sync/config.py +0 -0
  30. {path_sync-0.7.7 → path_sync-0.8.0}/path_sync/copy.py +0 -0
  31. {path_sync-0.7.7 → path_sync-0.8.0}/path_sync/dep_update.py +0 -0
  32. {path_sync-0.7.7 → path_sync-0.8.0}/path_sync/sections.py +0 -0
  33. {path_sync-0.7.7 → path_sync-0.8.0}/path_sync/validate_no_changes.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: path-sync
3
- Version: 0.7.7
3
+ Version: 0.8.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
@@ -43,6 +43,7 @@ Sync files from a source repo to multiple destination repos.
43
43
  - Files with headers are updated on each sync
44
44
  - Remove a header to opt-out (file becomes DEST-owned)
45
45
  - Orphaned files (removed from SRC) are deleted in DEST
46
+ - Dest improvements can be harvested back to SRC with `pull` (local working tree only; you commit SRC yourself)
46
47
  - Idempotent: PR body is left unchanged when already synced from an equal or newer source commit
47
48
  - Stale PRs auto-close when source and destination are already in sync (zero file changes)
48
49
 
@@ -92,14 +93,81 @@ By default, prompts before each git operation. See [Usage Scenarios](#usage-scen
92
93
  | `--pr-reviewers` | Comma-separated PR reviewers |
93
94
  | `--pr-assignees` | Comma-separated PR assignees |
94
95
 
95
- ### 3. Validate (run in dest repo)
96
+ ### 3. Pull changes from a destination
97
+
98
+ Harvest newer dest content into SRC. Reads one dest working tree and writes into SRC. Does not commit, push, open a PR, or run `copy`. The dest repo must already exist at `dest_path_relative`.
99
+
100
+ ```bash
101
+ path-sync pull -n myconfig -d dest1
102
+ path-sync pull -n myconfig -d dest1 --dest-only
103
+ path-sync pull -n myconfig -d dest1 --dest-only -i '.cursor/*'
104
+ ```
105
+
106
+ Unlike `copy`, `pull` never auto-applies. `--dry-run` and non-TTY list candidates and write nothing.
107
+
108
+ | | `copy` | `pull` |
109
+ |---|--------|--------|
110
+ | Direction | SRC → DEST | DEST → SRC |
111
+ | `-d` | Comma-separated filter (optional) | Exactly one dest (required) |
112
+ | Non-TTY / no-write flags | `-y` auto-confirms git ops | Never writes (`--dry-run` or non-TTY lists only) |
113
+ | Default | Overwrites dest managed content | Only newer mapped content (git timestamp after content diff) |
114
+
115
+ | Flag | Description |
116
+ |------|-------------|
117
+ | `-d dest` | Required. Exactly one destination name (not comma-separated). |
118
+ | `--dest-only` | Also harvest dest files with no SRC counterpart (additive on mapped pull). |
119
+ | `--dry-run` | Print candidates; no prompt; no write. Non-TTY behaves the same. |
120
+ | `-i`, `--include` | Allowlist on SRC-relative path ([fnmatch](https://docs.python.org/3/library/fnmatch.html); quote globs in zsh). |
121
+ | `-e`, `--exclude` | Denylist after include (same matcher). |
122
+
123
+ | Scenario | Command |
124
+ |----------|---------|
125
+ | Interactive harvest | `pull -n cfg -d dest1` |
126
+ | Preview candidates | `pull -n cfg -d dest1 --dry-run` |
127
+ | Scripted / CI list only | `pull -n cfg -d dest1` (non-TTY) |
128
+
129
+ **Pull behavior**:
130
+ - **Mapped files**: Content compare first; equal content skips git. Pull only when dest git time is newer than SRC. Dirty dest or SRC path: warn and skip.
131
+ - **Sectioned dest files**: Copies managed section bodies only; dest text after `OK_EDIT` stays in dest; `skip_sections` honored.
132
+ - **Whole-file dest**: Copies file with path-sync header stripped; no header added on SRC.
133
+ - **Dest-only**: Whole file to mapped SRC path; does not edit `.src.yaml`.
134
+ - **Path filters**: `-i` / `-e` apply after collect; match SRC-relative path; empty after filter prints `No pull candidates.`
135
+ - **Confirm**: One list, one `y/n`; yes applies all rows.
136
+
137
+ **Dest-only example** (dir mapping; config unchanged):
138
+
139
+ ```yaml
140
+ # .github/cursor.src.yaml (excerpt)
141
+ paths:
142
+ - src_path: .cursor/**/*.mdc
143
+ destinations:
144
+ - name: lz
145
+ dest_path_relative: ../lz
146
+ ```
147
+
148
+ ```bash
149
+ # ../lz/.cursor/rules/bar.mdc exists; SRC has no bar.mdc yet
150
+ path-sync pull -n cursor -d lz --dest-only
151
+ ```
152
+
153
+ Creates `.cursor/rules/bar.mdc` in SRC. To limit a mixed dest-only list:
154
+
155
+ ```bash
156
+ path-sync pull -n cursor -d lz --dest-only -i '.cursor/*'
157
+ ```
158
+
159
+ See also: [pull command reference](docs/pull/index.md).
160
+
161
+ ### 4. Validate (run in dest repo)
96
162
 
97
163
  ```bash
98
164
  uvx path-sync validate-no-changes -b main
99
165
  ```
100
166
 
167
+ 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).
168
+
101
169
  Options:
102
- - `-b, --branch` - Default branch to compare against (default: main)
170
+ - `-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
171
  - `--skip-sections` - Comma-separated `path:section_id` pairs to skip (e.g., `justfile:coverage`)
104
172
 
105
173
  ## Usage Scenarios
@@ -159,6 +227,8 @@ def hello():
159
227
  # === DO_NOT_EDIT: path-sync synced ===
160
228
  def hello():
161
229
  pass
230
+
231
+
162
232
  # === OK_EDIT: path-sync synced ===
163
233
  ```
164
234
 
@@ -376,7 +446,10 @@ jobs:
376
446
  with:
377
447
  fetch-depth: 0
378
448
  - uses: astral-sh/setup-uv@v5
379
- - run: uvx path-sync validate-no-changes -b main
449
+ - name: Validate no changes to synced files
450
+ env:
451
+ GITHUB_BASE_REF: ${{ github.base_ref || 'main' }}
452
+ run: uvx path-sync validate-no-changes
380
453
  ```
381
454
 
382
455
  **Validation skips automatically when:**
@@ -25,6 +25,7 @@ Sync files from a source repo to multiple destination repos.
25
25
  - Files with headers are updated on each sync
26
26
  - Remove a header to opt-out (file becomes DEST-owned)
27
27
  - Orphaned files (removed from SRC) are deleted in DEST
28
+ - Dest improvements can be harvested back to SRC with `pull` (local working tree only; you commit SRC yourself)
28
29
  - Idempotent: PR body is left unchanged when already synced from an equal or newer source commit
29
30
  - Stale PRs auto-close when source and destination are already in sync (zero file changes)
30
31
 
@@ -74,14 +75,81 @@ By default, prompts before each git operation. See [Usage Scenarios](#usage-scen
74
75
  | `--pr-reviewers` | Comma-separated PR reviewers |
75
76
  | `--pr-assignees` | Comma-separated PR assignees |
76
77
 
77
- ### 3. Validate (run in dest repo)
78
+ ### 3. Pull changes from a destination
79
+
80
+ Harvest newer dest content into SRC. Reads one dest working tree and writes into SRC. Does not commit, push, open a PR, or run `copy`. The dest repo must already exist at `dest_path_relative`.
81
+
82
+ ```bash
83
+ path-sync pull -n myconfig -d dest1
84
+ path-sync pull -n myconfig -d dest1 --dest-only
85
+ path-sync pull -n myconfig -d dest1 --dest-only -i '.cursor/*'
86
+ ```
87
+
88
+ Unlike `copy`, `pull` never auto-applies. `--dry-run` and non-TTY list candidates and write nothing.
89
+
90
+ | | `copy` | `pull` |
91
+ |---|--------|--------|
92
+ | Direction | SRC → DEST | DEST → SRC |
93
+ | `-d` | Comma-separated filter (optional) | Exactly one dest (required) |
94
+ | Non-TTY / no-write flags | `-y` auto-confirms git ops | Never writes (`--dry-run` or non-TTY lists only) |
95
+ | Default | Overwrites dest managed content | Only newer mapped content (git timestamp after content diff) |
96
+
97
+ | Flag | Description |
98
+ |------|-------------|
99
+ | `-d dest` | Required. Exactly one destination name (not comma-separated). |
100
+ | `--dest-only` | Also harvest dest files with no SRC counterpart (additive on mapped pull). |
101
+ | `--dry-run` | Print candidates; no prompt; no write. Non-TTY behaves the same. |
102
+ | `-i`, `--include` | Allowlist on SRC-relative path ([fnmatch](https://docs.python.org/3/library/fnmatch.html); quote globs in zsh). |
103
+ | `-e`, `--exclude` | Denylist after include (same matcher). |
104
+
105
+ | Scenario | Command |
106
+ |----------|---------|
107
+ | Interactive harvest | `pull -n cfg -d dest1` |
108
+ | Preview candidates | `pull -n cfg -d dest1 --dry-run` |
109
+ | Scripted / CI list only | `pull -n cfg -d dest1` (non-TTY) |
110
+
111
+ **Pull behavior**:
112
+ - **Mapped files**: Content compare first; equal content skips git. Pull only when dest git time is newer than SRC. Dirty dest or SRC path: warn and skip.
113
+ - **Sectioned dest files**: Copies managed section bodies only; dest text after `OK_EDIT` stays in dest; `skip_sections` honored.
114
+ - **Whole-file dest**: Copies file with path-sync header stripped; no header added on SRC.
115
+ - **Dest-only**: Whole file to mapped SRC path; does not edit `.src.yaml`.
116
+ - **Path filters**: `-i` / `-e` apply after collect; match SRC-relative path; empty after filter prints `No pull candidates.`
117
+ - **Confirm**: One list, one `y/n`; yes applies all rows.
118
+
119
+ **Dest-only example** (dir mapping; config unchanged):
120
+
121
+ ```yaml
122
+ # .github/cursor.src.yaml (excerpt)
123
+ paths:
124
+ - src_path: .cursor/**/*.mdc
125
+ destinations:
126
+ - name: lz
127
+ dest_path_relative: ../lz
128
+ ```
129
+
130
+ ```bash
131
+ # ../lz/.cursor/rules/bar.mdc exists; SRC has no bar.mdc yet
132
+ path-sync pull -n cursor -d lz --dest-only
133
+ ```
134
+
135
+ Creates `.cursor/rules/bar.mdc` in SRC. To limit a mixed dest-only list:
136
+
137
+ ```bash
138
+ path-sync pull -n cursor -d lz --dest-only -i '.cursor/*'
139
+ ```
140
+
141
+ See also: [pull command reference](docs/pull/index.md).
142
+
143
+ ### 4. Validate (run in dest repo)
78
144
 
79
145
  ```bash
80
146
  uvx path-sync validate-no-changes -b main
81
147
  ```
82
148
 
149
+ 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).
150
+
83
151
  Options:
84
- - `-b, --branch` - Default branch to compare against (default: main)
152
+ - `-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
153
  - `--skip-sections` - Comma-separated `path:section_id` pairs to skip (e.g., `justfile:coverage`)
86
154
 
87
155
  ## Usage Scenarios
@@ -141,6 +209,8 @@ def hello():
141
209
  # === DO_NOT_EDIT: path-sync synced ===
142
210
  def hello():
143
211
  pass
212
+
213
+
144
214
  # === OK_EDIT: path-sync synced ===
145
215
  ```
146
216
 
@@ -358,7 +428,10 @@ jobs:
358
428
  with:
359
429
  fetch-depth: 0
360
430
  - uses: astral-sh/setup-uv@v5
361
- - run: uvx path-sync validate-no-changes -b main
431
+ - name: Validate no changes to synced files
432
+ env:
433
+ GITHUB_BASE_REF: ${{ github.base_ref || 'main' }}
434
+ run: uvx path-sync validate-no-changes
362
435
  ```
363
436
 
364
437
  **Validation skips automatically when:**
@@ -2,13 +2,15 @@
2
2
  # flake8: noqa
3
3
  from path_sync import copy
4
4
  from path_sync import dep_update
5
+ from path_sync import pull
5
6
  from path_sync import validate_no_changes
6
7
  from path_sync import config
7
8
 
8
- VERSION = "0.7.7"
9
+ VERSION = "0.8.0"
9
10
  __all__ = [
10
11
  "copy",
11
12
  "dep_update",
13
+ "pull",
12
14
  "validate_no_changes",
13
15
  "config",
14
16
  ]
@@ -1,6 +1,6 @@
1
1
  import logging
2
2
 
3
- from path_sync._internal import cmd_boot, cmd_copy, cmd_dep_update, cmd_validate # noqa: F401
3
+ from path_sync._internal import cmd_boot, cmd_copy, cmd_dep_update, cmd_pull, cmd_validate # noqa: F401
4
4
  from path_sync._internal.models import LOG_FORMAT
5
5
  from path_sync._internal.typer_app import app
6
6
 
@@ -77,7 +77,7 @@ def enable_auto_merge(repo_path: Path, pr_ref: str, config: AutoMergeConfig, des
77
77
  cmd = ["gh", "pr", "merge", "--auto", f"--{config.method}", pr_ref]
78
78
  if config.delete_branch:
79
79
  cmd.append("--delete-branch")
80
- result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True)
80
+ result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True, check=False)
81
81
  if result.returncode != 0:
82
82
  logger.warning(f" {label}: auto-merge enable failed: {result.stderr.strip()}")
83
83
  else:
@@ -86,7 +86,7 @@ def enable_auto_merge(repo_path: Path, pr_ref: str, config: AutoMergeConfig, des
86
86
 
87
87
  def get_pr_checks(repo_path: Path, pr_ref: str) -> list[CheckRun]:
88
88
  cmd = ["gh", "pr", "checks", pr_ref, "--json", "name,state,workflow,link"]
89
- result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True)
89
+ result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True, check=False)
90
90
  if result.returncode != 0:
91
91
  logger.warning(f"Failed to get checks for {pr_ref}: {result.stderr.strip()}")
92
92
  return []
@@ -95,7 +95,7 @@ def get_pr_checks(repo_path: Path, pr_ref: str) -> list[CheckRun]:
95
95
 
96
96
  def get_pr_state(repo_path: Path, pr_ref: str) -> PRState:
97
97
  cmd = ["gh", "pr", "view", pr_ref, "--json", "state", "-q", ".state"]
98
- result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True)
98
+ result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True, check=False)
99
99
  if result.returncode != 0:
100
100
  logger.warning(f"Failed to get PR state for {pr_ref}: {result.stderr.strip()}")
101
101
  return PRState.OPEN
@@ -104,7 +104,7 @@ def get_pr_state(repo_path: Path, pr_ref: str) -> PRState:
104
104
 
105
105
  def get_pr_url(repo_path: Path, pr_ref: str) -> str:
106
106
  cmd = ["gh", "pr", "view", pr_ref, "--json", "url", "-q", ".url"]
107
- result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True)
107
+ result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True, check=False)
108
108
  if result.returncode != 0:
109
109
  logger.warning(f"Failed to get PR URL for {pr_ref}: {result.stderr.strip()}")
110
110
  return pr_ref
@@ -24,8 +24,8 @@ logger = logging.getLogger(__name__)
24
24
  @app.command()
25
25
  def boot(
26
26
  name: str = typer.Option(..., "-n", "--name", help="Config name"),
27
- dest_paths: Annotated[list[str], typer.Option("-d", "--dest", help="Destination relative paths")] = [],
28
- sync_paths: Annotated[list[str], typer.Option("-p", "--path", help="Paths to sync (glob patterns)")] = [],
27
+ dest_paths: Annotated[list[str] | None, typer.Option("-d", "--dest", help="Destination relative paths")] = None,
28
+ sync_paths: Annotated[list[str] | None, typer.Option("-p", "--path", help="Paths to sync (glob patterns)")] = None,
29
29
  dry_run: bool = typer.Option(False, "--dry-run", help="Preview without writing"),
30
30
  regen: bool = typer.Option(False, "--regen", help="Regenerate config"),
31
31
  src_root_opt: str = typer.Option(
@@ -35,6 +35,10 @@ def boot(
35
35
  ),
36
36
  ) -> None:
37
37
  """Initialize or update SRC repo config."""
38
+ if sync_paths is None:
39
+ sync_paths = []
40
+ if dest_paths is None:
41
+ dest_paths = []
38
42
  repo_root = Path(src_root_opt) if src_root_opt else find_repo_root(Path.cwd())
39
43
  config_path = resolve_config_path(repo_root, name)
40
44
 
@@ -0,0 +1,407 @@
1
+ from __future__ import annotations
2
+
3
+ import fnmatch
4
+ import logging
5
+ import sys
6
+ from dataclasses import dataclass
7
+ from datetime import UTC, datetime
8
+ from enum import StrEnum
9
+ from pathlib import Path
10
+ from typing import NamedTuple
11
+
12
+ import typer
13
+ from git import Repo
14
+ from pydantic import BaseModel, Field
15
+
16
+ from path_sync import sections
17
+ from path_sync._internal import git_ops, header, prompt_utils
18
+ from path_sync._internal.cmd_copy import _iter_sync_files
19
+ from path_sync._internal.file_utils import ensure_parents_write_text
20
+ from path_sync._internal.models import (
21
+ Destination,
22
+ PathMapping,
23
+ SrcConfig,
24
+ SyncMode,
25
+ find_repo_root,
26
+ resolve_config_path,
27
+ )
28
+ from path_sync._internal.repo_utils import resolve_repo_path
29
+ from path_sync._internal.typer_app import app
30
+ from path_sync._internal.yaml_utils import load_yaml_model
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+ _EMPTY_STR_LIST: list[str] = []
35
+ _INCLUDE_OPTION = typer.Option(_EMPTY_STR_LIST, "-i", "--include", help="Keep paths matching pattern")
36
+ _EXCLUDE_OPTION = typer.Option(_EMPTY_STR_LIST, "-e", "--exclude", help="Drop paths matching pattern")
37
+
38
+
39
+ class PullKind(StrEnum):
40
+ SECTIONS = "sections"
41
+ WHOLE = "whole"
42
+ BINARY = "binary"
43
+
44
+
45
+ class PullOptions(BaseModel):
46
+ dry_run: bool = False
47
+ dest_only: bool = False
48
+ include: list[str] = Field(default_factory=list)
49
+ exclude: list[str] = Field(default_factory=list)
50
+
51
+
52
+ class _DestFileMap(NamedTuple):
53
+ src_path: Path
54
+ dest_key: str
55
+
56
+
57
+ @dataclass
58
+ class PullCandidate:
59
+ src_path: Path
60
+ dest_path: Path
61
+ dest_key: str
62
+ kind: PullKind
63
+ section_ids: list[str]
64
+ dest_ts: int | None
65
+ src_ts: int | None
66
+ skip_sections: list[str]
67
+ dest_only: bool = False
68
+
69
+
70
+ def _validate_dest_name(dest: str) -> str:
71
+ if not dest.strip():
72
+ raise ValueError("Missing destination: -d is required")
73
+ if "," in dest:
74
+ raise ValueError("Exactly one destination required; comma-separated list not supported")
75
+ return dest.strip()
76
+
77
+
78
+ def _format_unix(ts: int) -> str:
79
+ return datetime.fromtimestamp(ts, UTC).strftime("%Y-%m-%d")
80
+
81
+
82
+ def _managed_section_map(content: str, path: Path, skip: list[str]) -> dict[str, str]:
83
+ body = header.remove_header(content)
84
+ extracted = sections.extract_sections(body, path)
85
+ return {k: v for k, v in extracted.items() if k not in skip}
86
+
87
+
88
+ def _diff_section_ids(dest_map: dict[str, str], src_map: dict[str, str]) -> list[str]:
89
+ ids = set(dest_map) | set(src_map)
90
+ return sorted(i for i in ids if dest_map.get(i) != src_map.get(i))
91
+
92
+
93
+ def _is_opted_out(dest_text: str, sync_mode: SyncMode) -> bool:
94
+ return sync_mode == SyncMode.SYNC and not header.has_header(dest_text)
95
+
96
+
97
+ def _collect_mapped_candidates(
98
+ config: SrcConfig,
99
+ dest: Destination,
100
+ src_root: Path,
101
+ dest_root: Path,
102
+ src_repo: Repo,
103
+ dest_repo: Repo,
104
+ ) -> list[PullCandidate]:
105
+ candidates: list[PullCandidate] = []
106
+ for mapping in config.resolve_paths(dest):
107
+ for src_path, dest_key, dest_path in _iter_sync_files(mapping, src_root, dest_root):
108
+ if dest.is_skipped(dest_key):
109
+ continue
110
+ if not dest_path.exists():
111
+ logger.warning(f"Dest not found: {dest_path}")
112
+ continue
113
+ if not src_path.exists():
114
+ continue
115
+ if mapping.sync_mode == SyncMode.SCAFFOLD:
116
+ continue
117
+
118
+ skip_list = dest.skip_sections.get(dest_key, [])
119
+ candidate = _candidate_for_path(
120
+ src_path,
121
+ dest_path,
122
+ dest_key,
123
+ mapping.sync_mode,
124
+ skip_list,
125
+ src_repo,
126
+ dest_repo,
127
+ )
128
+ if candidate:
129
+ candidates.append(candidate)
130
+ return candidates
131
+
132
+
133
+ def _src_for_dest_file(
134
+ mapping: PathMapping,
135
+ dest_path: Path,
136
+ src_root: Path,
137
+ dest_root: Path,
138
+ ) -> _DestFileMap | None:
139
+ if "*" in mapping.src_path:
140
+ glob_prefix = mapping.src_path.split("*")[0].rstrip("/")
141
+ dest_base = mapping.dest_path or glob_prefix
142
+ src_base = glob_prefix
143
+ elif (dest_root / mapping.resolved_dest_path()).is_dir():
144
+ dest_base = mapping.resolved_dest_path()
145
+ src_base = mapping.src_path
146
+ elif dest_path == dest_root / mapping.resolved_dest_path():
147
+ dest_base = mapping.resolved_dest_path()
148
+ return _DestFileMap(src_root / mapping.src_path, dest_base)
149
+ else:
150
+ return None
151
+
152
+ dest_anchor = dest_root / dest_base
153
+ if dest_path.is_relative_to(dest_anchor):
154
+ rel = dest_path.relative_to(dest_anchor)
155
+ return _DestFileMap(src_root / src_base / rel, str(Path(dest_base) / rel))
156
+ return None
157
+
158
+
159
+ def _dest_only_candidate(src_path: Path, dest_path: Path, dest_key: str) -> PullCandidate:
160
+ try:
161
+ dest_path.read_text()
162
+ except UnicodeDecodeError:
163
+ kind = PullKind.BINARY
164
+ else:
165
+ kind = PullKind.WHOLE
166
+ return PullCandidate(
167
+ src_path=src_path,
168
+ dest_path=dest_path,
169
+ dest_key=dest_key,
170
+ kind=kind,
171
+ section_ids=[],
172
+ dest_ts=None,
173
+ src_ts=None,
174
+ skip_sections=[],
175
+ dest_only=True,
176
+ )
177
+
178
+
179
+ def _collect_dest_only_candidates(
180
+ config: SrcConfig,
181
+ dest: Destination,
182
+ src_root: Path,
183
+ dest_root: Path,
184
+ dest_repo: Repo,
185
+ skip_keys: set[str],
186
+ ) -> list[PullCandidate]:
187
+ candidates: list[PullCandidate] = []
188
+ seen = set(skip_keys)
189
+ for mapping in config.resolve_paths(dest):
190
+ if mapping.sync_mode == SyncMode.SCAFFOLD:
191
+ continue
192
+ for dest_path in mapping.expand_dest_paths(dest_root):
193
+ if dest_path.is_dir() or mapping.is_excluded(dest_path):
194
+ continue
195
+ mapped = _src_for_dest_file(mapping, dest_path, src_root, dest_root)
196
+ if mapped is None:
197
+ continue
198
+ if dest.is_skipped(mapped.dest_key) or mapped.src_path.exists() or mapped.dest_key in seen:
199
+ continue
200
+ if git_ops.path_is_tracked_dirty(dest_repo, dest_path):
201
+ logger.warning(f"Skipping dirty dest path: {dest_path}")
202
+ continue
203
+ seen.add(mapped.dest_key)
204
+ candidates.append(_dest_only_candidate(mapped.src_path, dest_path, mapped.dest_key))
205
+ return candidates
206
+
207
+
208
+ def _text_pull_kind(
209
+ src_path: Path,
210
+ dest_path: Path,
211
+ dest_text: str,
212
+ src_text: str,
213
+ sync_mode: SyncMode,
214
+ skip_list: list[str],
215
+ ) -> tuple[PullKind, list[str]] | None:
216
+ if _is_opted_out(dest_text, sync_mode):
217
+ return None
218
+
219
+ dest_body = header.remove_header(dest_text)
220
+ if header.has_known_comment_prefix(dest_path) and sections.has_sections(dest_body, dest_path):
221
+ dest_map = _managed_section_map(dest_text, dest_path, skip_list)
222
+ src_map = _managed_section_map(src_text, src_path, skip_list)
223
+ if dest_map == src_map:
224
+ return None
225
+ return PullKind.SECTIONS, _diff_section_ids(dest_map, src_map)
226
+
227
+ if header.remove_header(dest_text) == src_text:
228
+ return None
229
+ return PullKind.WHOLE, []
230
+
231
+
232
+ def _candidate_for_path(
233
+ src_path: Path,
234
+ dest_path: Path,
235
+ dest_key: str,
236
+ sync_mode: SyncMode,
237
+ skip_list: list[str],
238
+ src_repo: Repo,
239
+ dest_repo: Repo,
240
+ ) -> PullCandidate | None:
241
+ try:
242
+ dest_text = dest_path.read_text()
243
+ src_text = src_path.read_text()
244
+ except UnicodeDecodeError:
245
+ if dest_path.read_bytes() == src_path.read_bytes():
246
+ return None
247
+ kind, section_ids = PullKind.BINARY, []
248
+ else:
249
+ kind_info = _text_pull_kind(src_path, dest_path, dest_text, src_text, sync_mode, skip_list)
250
+ if kind_info is None:
251
+ return None
252
+ kind, section_ids = kind_info
253
+
254
+ if git_ops.path_is_dirty(dest_repo, dest_path):
255
+ logger.warning(f"Skipping dirty dest path: {dest_path}")
256
+ return None
257
+ if git_ops.path_is_dirty(src_repo, src_path):
258
+ logger.warning(f"Skipping dirty src path: {src_path}")
259
+ return None
260
+
261
+ dest_ts = git_ops.file_last_commit_unix(dest_repo, dest_path)
262
+ src_ts = git_ops.file_last_commit_unix(src_repo, src_path)
263
+ if dest_ts is None or src_ts is None or dest_ts <= src_ts:
264
+ return None
265
+
266
+ return PullCandidate(
267
+ src_path=src_path,
268
+ dest_path=dest_path,
269
+ dest_key=dest_key,
270
+ kind=kind,
271
+ section_ids=section_ids,
272
+ dest_ts=dest_ts,
273
+ src_ts=src_ts,
274
+ skip_sections=skip_list,
275
+ )
276
+
277
+
278
+ def _keep_pull_path(rel: str, include: list[str], exclude: list[str]) -> bool:
279
+ if include and not any(fnmatch.fnmatch(rel, pat) for pat in include):
280
+ return False
281
+ return not (exclude and any(fnmatch.fnmatch(rel, pat) for pat in exclude))
282
+
283
+
284
+ def _format_candidate_line(candidate: PullCandidate, src_root: Path) -> str:
285
+ rel = str(candidate.src_path.relative_to(src_root))
286
+ if candidate.dest_only:
287
+ return f" {rel:<30} dest-only"
288
+ if candidate.kind == PullKind.SECTIONS:
289
+ detail = f"sections: {', '.join(candidate.section_ids)}"
290
+ elif candidate.kind == PullKind.WHOLE:
291
+ detail = "whole file"
292
+ else:
293
+ detail = "binary"
294
+ dest_ts = candidate.dest_ts
295
+ src_ts = candidate.src_ts
296
+ assert dest_ts is not None and src_ts is not None
297
+ ts = f"dest {_format_unix(dest_ts)} > src {_format_unix(src_ts)}"
298
+ return f" {rel:<30} {detail:<30} {ts}"
299
+
300
+
301
+ def _print_candidates(dest_name: str, candidates: list[PullCandidate], src_root: Path) -> None:
302
+ typer.echo(f"\nPull from {dest_name} into src?\n", err=True)
303
+ for c in candidates:
304
+ typer.echo(_format_candidate_line(c, src_root), err=True)
305
+
306
+
307
+ def _apply_candidate(candidate: PullCandidate) -> None:
308
+ if candidate.kind == PullKind.BINARY:
309
+ candidate.src_path.parent.mkdir(parents=True, exist_ok=True)
310
+ candidate.src_path.write_bytes(candidate.dest_path.read_bytes())
311
+ logger.info(f"Pulled binary: {candidate.src_path}")
312
+ return
313
+
314
+ if candidate.kind == PullKind.SECTIONS:
315
+ dest_body = header.remove_header(candidate.dest_path.read_text())
316
+ dest_sections = sections.parse_sections(dest_body, candidate.dest_path)
317
+ new_content = sections.replace_sections(
318
+ candidate.src_path.read_text(),
319
+ dest_sections,
320
+ candidate.src_path,
321
+ skip_sections=candidate.skip_sections,
322
+ keep_deleted_sections=True,
323
+ )
324
+ else:
325
+ new_content = header.remove_header(candidate.dest_path.read_text())
326
+
327
+ ensure_parents_write_text(candidate.src_path, new_content)
328
+ logger.info(f"Pulled: {candidate.src_path}")
329
+
330
+
331
+ def _run_pull(config: SrcConfig, dest: Destination, src_root: Path, opts: PullOptions) -> None:
332
+ dest_root = resolve_repo_path(dest, src_root, "")
333
+ if not git_ops.is_git_repo(dest_root):
334
+ raise ValueError(f"Not a git repository: {dest_root}")
335
+
336
+ src_repo = git_ops.get_repo(src_root)
337
+ dest_repo = git_ops.get_repo(dest_root)
338
+ candidates = _collect_mapped_candidates(config, dest, src_root, dest_root, src_repo, dest_repo)
339
+ if opts.dest_only:
340
+ skip_keys = {c.dest_key for c in candidates}
341
+ candidates.extend(_collect_dest_only_candidates(config, dest, src_root, dest_root, dest_repo, skip_keys))
342
+
343
+ candidates = [
344
+ c for c in candidates if _keep_pull_path(str(c.src_path.relative_to(src_root)), opts.include, opts.exclude)
345
+ ]
346
+
347
+ if not candidates:
348
+ typer.echo("No pull candidates.", err=True)
349
+ return
350
+
351
+ _print_candidates(dest.name, candidates, src_root)
352
+
353
+ if opts.dry_run or not sys.stdin.isatty():
354
+ return
355
+ if prompt_utils.prompt_pull_confirm("Confirm?"):
356
+ for c in candidates:
357
+ _apply_candidate(c)
358
+
359
+
360
+ @app.command()
361
+ def pull(
362
+ name: str = typer.Option("", "-n", "--name", help="Config name"),
363
+ config_path_opt: str = typer.Option("", "-c", "--config-path", help="Full path to config file"),
364
+ src_root_opt: str = typer.Option("", "--src-root", help="Source repo root"),
365
+ dest_name: str = typer.Option(..., "-d", "--dest", help="Destination name (exactly one)"),
366
+ dry_run: bool = typer.Option(False, "--dry-run", help="Print candidates without writing (same as non-TTY)"),
367
+ dest_only: bool = typer.Option(False, "--dest-only", help="Also harvest dest files with no src counterpart"),
368
+ include: list[str] = _INCLUDE_OPTION,
369
+ exclude: list[str] = _EXCLUDE_OPTION,
370
+ ) -> None:
371
+ """Harvest newer mapped dest files into src after one confirm.
372
+
373
+ --dest-only also copies dest-only files. Quote glob patterns (e.g. -i '.cursor/*').
374
+ """
375
+ if name and config_path_opt:
376
+ logger.error("Cannot use both --name and --config-path")
377
+ raise typer.Exit(1)
378
+ if not name and not config_path_opt:
379
+ logger.error("Either --name or --config-path is required")
380
+ raise typer.Exit(1)
381
+
382
+ try:
383
+ dest_filter = _validate_dest_name(dest_name)
384
+ except ValueError as e:
385
+ logger.error(str(e))
386
+ raise typer.Exit(1)
387
+
388
+ src_root = Path(src_root_opt) if src_root_opt else find_repo_root(Path.cwd())
389
+ config_path = Path(config_path_opt) if config_path_opt else resolve_config_path(src_root, name)
390
+ if not config_path.exists():
391
+ logger.error(f"Config not found: {config_path}")
392
+ raise typer.Exit(1)
393
+
394
+ config = load_yaml_model(config_path, SrcConfig)
395
+ dest = config.find_destination(dest_filter)
396
+ opts = PullOptions(
397
+ dry_run=dry_run,
398
+ dest_only=dest_only,
399
+ include=include,
400
+ exclude=exclude,
401
+ )
402
+
403
+ try:
404
+ _run_pull(config, dest, src_root, opts)
405
+ except ValueError as e:
406
+ logger.error(f"Pull failed: {e}")
407
+ raise typer.Exit(1)
@@ -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
 
@@ -4,6 +4,7 @@ import logging
4
4
  import os
5
5
  import re
6
6
  import subprocess
7
+ from configparser import NoOptionError, NoSectionError
7
8
  from contextlib import suppress
8
9
  from pathlib import Path
9
10
 
@@ -155,7 +156,7 @@ def _ensure_git_user(repo: Repo) -> None:
155
156
  """Configure git user if not already set."""
156
157
  try:
157
158
  repo.config_reader().get_value("user", "name")
158
- except Exception:
159
+ except (NoOptionError, NoSectionError):
159
160
  repo.config_writer().set_value("user", "name", "path-sync[bot]").release()
160
161
  repo.config_writer().set_value("user", "email", "path-sync[bot]@users.noreply.github.com").release()
161
162
 
@@ -191,7 +192,7 @@ def _get_repo_full_name(repo_path: Path) -> str | None:
191
192
  "-q",
192
193
  '.owner.login + "/" + .name',
193
194
  ]
194
- result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True)
195
+ result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True, check=False)
195
196
  if result.returncode != 0:
196
197
  logger.warning(f"Failed to get repo info: {result.stderr}")
197
198
  return None
@@ -201,7 +202,7 @@ def _get_repo_full_name(repo_path: Path) -> str | None:
201
202
  def _get_pr_number(repo_path: Path, branch: str) -> str | None:
202
203
  """Get PR number for a branch."""
203
204
  cmd = ["gh", "pr", "view", branch, "--json", "number", "-q", ".number"]
204
- result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True)
205
+ result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True, check=False)
205
206
  if result.returncode != 0:
206
207
  logger.warning(f"Failed to get PR number: {result.stderr}")
207
208
  return None
@@ -228,7 +229,7 @@ def update_pr_body(repo_path: Path, branch: str, body: str) -> bool:
228
229
  "-f",
229
230
  f"body={body}",
230
231
  ]
231
- result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True)
232
+ result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True, check=False)
232
233
  if result.returncode != 0:
233
234
  logger.warning(f"Failed to update PR body: {result.stderr}")
234
235
  return False
@@ -239,7 +240,7 @@ def update_pr_body(repo_path: Path, branch: str, body: str) -> bool:
239
240
 
240
241
  def get_pr_body(repo_path: Path, branch: str) -> str | None:
241
242
  cmd = ["gh", "pr", "view", branch, "--json", "body,state", "-q", 'select(.state == "OPEN") | .body']
242
- result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True)
243
+ result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True, check=False)
243
244
  if result.returncode != 0:
244
245
  return None
245
246
  body = result.stdout.strip()
@@ -248,7 +249,7 @@ def get_pr_body(repo_path: Path, branch: str) -> str | None:
248
249
 
249
250
  def has_open_pr(repo_path: Path, branch: str) -> bool:
250
251
  cmd = ["gh", "pr", "view", branch, "--json", "state", "-q", ".state"]
251
- result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True)
252
+ result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True, check=False)
252
253
  if result.returncode != 0:
253
254
  return False
254
255
  return result.stdout.strip() == "OPEN"
@@ -256,7 +257,7 @@ def has_open_pr(repo_path: Path, branch: str) -> bool:
256
257
 
257
258
  def close_pr(repo_path: Path, branch: str, comment: str) -> bool:
258
259
  cmd = ["gh", "pr", "close", branch, "-c", comment]
259
- result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True)
260
+ result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True, check=False)
260
261
  if result.returncode != 0:
261
262
  logger.warning(f"Failed to close PR for {branch}: {result.stderr}")
262
263
  return False
@@ -283,7 +284,7 @@ def create_or_update_pr(
283
284
  if assignees:
284
285
  cmd.extend(["--assignee", ",".join(assignees)])
285
286
 
286
- result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True)
287
+ result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True, check=False)
287
288
  if result.returncode != 0:
288
289
  if "already exists" in result.stderr:
289
290
  logger.info("PR already exists, updating body")
@@ -314,3 +315,27 @@ def get_file_content_at_ref(repo: Repo, file_path: Path, ref: str) -> str | None
314
315
  with suppress(GitCommandError):
315
316
  return repo.git.show(f"{ref}:{rel_path}")
316
317
  return None
318
+
319
+
320
+ def path_porcelain_status(repo: Repo, file_path: Path) -> str | None:
321
+ rel_path = str(file_path.relative_to(repo.working_dir))
322
+ status = repo.git.status("--porcelain", "--", rel_path).strip()
323
+ return status or None
324
+
325
+
326
+ def path_is_dirty(repo: Repo, file_path: Path) -> bool:
327
+ return path_porcelain_status(repo, file_path) is not None
328
+
329
+
330
+ def path_is_tracked_dirty(repo: Repo, file_path: Path) -> bool:
331
+ if status := path_porcelain_status(repo, file_path):
332
+ return status[:2] != "??"
333
+ return False
334
+
335
+
336
+ def file_last_commit_unix(repo: Repo, file_path: Path) -> int | None:
337
+ rel_path = str(file_path.relative_to(repo.working_dir))
338
+ with suppress(GitCommandError):
339
+ ts = repo.git.log("-1", "--format=%ct", "--", rel_path).strip()
340
+ return int(ts) if ts else None
341
+ return None
@@ -23,3 +23,12 @@ def prompt_confirm(message: str, no_prompt: bool = False) -> bool:
23
23
  return response == "y"
24
24
  except (EOFError, KeyboardInterrupt):
25
25
  return False
26
+
27
+
28
+ def prompt_pull_confirm(message: str) -> bool:
29
+ if not sys.stdin.isatty():
30
+ return False
31
+ try:
32
+ return input(f"{message} [y/n]: ").strip().lower() == "y"
33
+ except (EOFError, KeyboardInterrupt):
34
+ return False
@@ -39,7 +39,7 @@ def run_command(cmd: str, cwd: Path, dry_run: bool = False) -> None:
39
39
  logger.info(f"[DRY RUN] Would run: {cmd} from {cwd}")
40
40
  return
41
41
  logger.info(f"Running: {cmd}")
42
- result = subprocess.run(cmd, shell=True, cwd=cwd, capture_output=True, text=True)
42
+ result = subprocess.run(cmd, shell=True, cwd=cwd, capture_output=True, text=True, check=False)
43
43
  prefix = cmd.split()[0]
44
44
  for line in result.stdout.strip().splitlines():
45
45
  logger.info(f"[{prefix}] {line}")
@@ -0,0 +1,6 @@
1
+ # Generated by pkg-ext
2
+ from path_sync._internal.cmd_pull import PullOptions as _PullOptions
3
+ from path_sync._internal.cmd_pull import pull as _pull
4
+
5
+ PullOptions = _PullOptions
6
+ pull = _pull
@@ -2,7 +2,7 @@
2
2
 
3
3
  [project]
4
4
  name = "path-sync"
5
- version = "0.7.7"
5
+ version = "0.8.0"
6
6
  description = "Sync files from a source repo to multiple destination repos"
7
7
  requires-python = ">=3.13"
8
8
  license = "MIT"
@@ -69,6 +69,13 @@ target-version = "py313"
69
69
  # UP047: use type parameters instead of `Generic[T]` subclass
70
70
  extend-ignore = ["E501", "UP006", "UP007", "UP035", "UP040", "UP046", "UP047"]
71
71
  extend-select = ["Q", "RUF100", "C90", "UP", "I", "T", "FURB"]
72
+
73
+ [tool.ruff.lint.flake8-bugbear]
74
+ extend-immutable-calls = ["typer.Option", "typer.Argument"]
75
+
76
+ [tool.ruff.format]
77
+ # pytest-examples print prefix is `#>`; ruff 0.16 markdown format turns that into `# >`
78
+ exclude = ["docs/examples/**"]
72
79
  # === OK_EDIT: path-sync ruff ===
73
80
 
74
81
  # === DO_NOT_EDIT: path-sync pytest ===
@@ -95,7 +102,7 @@ exclude = [".venv/", "*test.py"]
95
102
  # === DO_NOT_EDIT: path-sync dev-lint ===
96
103
  [dependency-groups]
97
104
  dev-lint = [
98
- "pyright>=1.1",
105
+ "ty==0.0.76",
99
106
  "ruff>=0.14",
100
107
  "vulture>=2.14",
101
108
  ]
@@ -106,6 +113,7 @@ dev-test = [
106
113
  "pytest>=9.0",
107
114
  "pytest-asyncio>=0.24",
108
115
  "pytest-cov>=7.0",
116
+ "pytest-examples>=0.0.18",
109
117
  "pytest-regressions>=2.6",
110
118
  ]
111
119
  # === OK_EDIT: path-sync dev-test ===
@@ -123,3 +131,12 @@ docs = [
123
131
  # === DO_NOT_EDIT: path-sync release ===
124
132
  release = ["pkg-ext"]
125
133
  # === OK_EDIT: path-sync release ===
134
+ # === DO_NOT_EDIT: path-sync wheel ===
135
+ [tool.hatch.build.targets.wheel]
136
+ exclude = [
137
+ "*_examples.py",
138
+ "*_test.py",
139
+ "conftest.py",
140
+ "*_test/",
141
+ ]
142
+ # === OK_EDIT: path-sync wheel ===
File without changes
File without changes
File without changes
File without changes