path-sync 0.7.8__tar.gz → 0.9.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 (36) hide show
  1. {path_sync-0.7.8 → path_sync-0.9.0}/PKG-INFO +106 -3
  2. {path_sync-0.7.8 → path_sync-0.9.0}/README.md +104 -1
  3. {path_sync-0.7.8 → path_sync-0.9.0}/path_sync/__init__.py +5 -1
  4. {path_sync-0.7.8 → path_sync-0.9.0}/path_sync/__main__.py +1 -1
  5. {path_sync-0.7.8 → path_sync-0.9.0}/path_sync/_internal/auto_merge.py +4 -4
  6. {path_sync-0.7.8 → path_sync-0.9.0}/path_sync/_internal/cmd_boot.py +6 -2
  7. {path_sync-0.7.8 → path_sync-0.9.0}/path_sync/_internal/cmd_copy.py +18 -0
  8. path_sync-0.9.0/path_sync/_internal/cmd_prune.py +95 -0
  9. path_sync-0.9.0/path_sync/_internal/cmd_pull.py +356 -0
  10. path_sync-0.9.0/path_sync/_internal/dest_only.py +102 -0
  11. {path_sync-0.7.8 → path_sync-0.9.0}/path_sync/_internal/git_ops.py +33 -8
  12. {path_sync-0.7.8 → path_sync-0.9.0}/path_sync/_internal/prompt_utils.py +9 -0
  13. {path_sync-0.7.8 → path_sync-0.9.0}/path_sync/_internal/verify.py +1 -1
  14. path_sync-0.9.0/path_sync/prune.py +4 -0
  15. path_sync-0.9.0/path_sync/pull.py +6 -0
  16. {path_sync-0.7.8 → path_sync-0.9.0}/pyproject.toml +10 -1
  17. {path_sync-0.7.8 → path_sync-0.9.0}/.gitignore +0 -0
  18. {path_sync-0.7.8 → path_sync-0.9.0}/LICENSE +0 -0
  19. {path_sync-0.7.8 → path_sync-0.9.0}/path_sync/_internal/__init__.py +0 -0
  20. {path_sync-0.7.8 → path_sync-0.9.0}/path_sync/_internal/cmd_dep_update.py +0 -0
  21. {path_sync-0.7.8 → path_sync-0.9.0}/path_sync/_internal/cmd_options.py +0 -0
  22. {path_sync-0.7.8 → path_sync-0.9.0}/path_sync/_internal/cmd_validate.py +0 -0
  23. {path_sync-0.7.8 → path_sync-0.9.0}/path_sync/_internal/file_utils.py +0 -0
  24. {path_sync-0.7.8 → path_sync-0.9.0}/path_sync/_internal/header.py +0 -0
  25. {path_sync-0.7.8 → path_sync-0.9.0}/path_sync/_internal/log_capture.py +0 -0
  26. {path_sync-0.7.8 → path_sync-0.9.0}/path_sync/_internal/models.py +0 -0
  27. {path_sync-0.7.8 → path_sync-0.9.0}/path_sync/_internal/models_dep.py +0 -0
  28. {path_sync-0.7.8 → path_sync-0.9.0}/path_sync/_internal/repo_utils.py +0 -0
  29. {path_sync-0.7.8 → path_sync-0.9.0}/path_sync/_internal/typer_app.py +0 -0
  30. {path_sync-0.7.8 → path_sync-0.9.0}/path_sync/_internal/validation.py +0 -0
  31. {path_sync-0.7.8 → path_sync-0.9.0}/path_sync/_internal/yaml_utils.py +0 -0
  32. {path_sync-0.7.8 → path_sync-0.9.0}/path_sync/config.py +0 -0
  33. {path_sync-0.7.8 → path_sync-0.9.0}/path_sync/copy.py +0 -0
  34. {path_sync-0.7.8 → path_sync-0.9.0}/path_sync/dep_update.py +0 -0
  35. {path_sync-0.7.8 → path_sync-0.9.0}/path_sync/sections.py +0 -0
  36. {path_sync-0.7.8 → path_sync-0.9.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.8
3
+ Version: 0.9.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,8 @@ 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-only extras under dir or glob mappings survive `copy`; `pull --dest-only` harvests them; `prune` deletes them
47
+ - Dest improvements can be harvested back to SRC with `pull` (local working tree only; you commit SRC yourself)
46
48
  - Idempotent: PR body is left unchanged when already synced from an equal or newer source commit
47
49
  - Stale PRs auto-close when source and destination are already in sync (zero file changes)
48
50
 
@@ -92,7 +94,106 @@ By default, prompts before each git operation. See [Usage Scenarios](#usage-scen
92
94
  | `--pr-reviewers` | Comma-separated PR reviewers |
93
95
  | `--pr-assignees` | Comma-separated PR assignees |
94
96
 
95
- ### 3. Validate (run in dest repo)
97
+ ### 3. Pull changes from a destination
98
+
99
+ 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`.
100
+
101
+ ```bash
102
+ path-sync pull -n myconfig -d dest1
103
+ path-sync pull -n myconfig -d dest1 --dest-only
104
+ path-sync pull -n myconfig -d dest1 --dest-only -i '.cursor/*'
105
+ ```
106
+
107
+ Unlike `copy`, `pull` never auto-applies. `--dry-run` and non-TTY list candidates and write nothing.
108
+
109
+ | | `copy` | `pull` |
110
+ |---|--------|--------|
111
+ | Direction | SRC → DEST | DEST → SRC |
112
+ | `-d` | Comma-separated filter (optional) | Exactly one dest (required) |
113
+ | Non-TTY / no-write flags | `-y` auto-confirms git ops | Never writes (`--dry-run` or non-TTY lists only) |
114
+ | Default | Overwrites dest managed content | Only newer mapped content (git timestamp after content diff) |
115
+
116
+ | Flag | Description |
117
+ |------|-------------|
118
+ | `-d dest` | Required. Exactly one destination name (not comma-separated). |
119
+ | `--dest-only` | Also harvest dest files with no SRC counterpart (additive on mapped pull). |
120
+ | `--dry-run` | Print candidates; no prompt; no write. Non-TTY behaves the same. |
121
+ | `-i`, `--include` | Allowlist on SRC-relative path ([fnmatch](https://docs.python.org/3/library/fnmatch.html); quote globs in zsh). |
122
+ | `-e`, `--exclude` | Denylist after include (same matcher). |
123
+
124
+ | Scenario | Command |
125
+ |----------|---------|
126
+ | Interactive harvest | `pull -n cfg -d dest1` |
127
+ | Preview candidates | `pull -n cfg -d dest1 --dry-run` |
128
+ | Scripted / CI list only | `pull -n cfg -d dest1` (non-TTY) |
129
+
130
+ **Pull behavior**:
131
+ - **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.
132
+ - **Sectioned dest files**: Copies managed section bodies only; dest text after `OK_EDIT` stays in dest; `skip_sections` honored.
133
+ - **Whole-file dest**: Copies file with path-sync header stripped; no header added on SRC.
134
+ - **Dest-only**: Whole file to mapped SRC path; does not edit `.src.yaml`.
135
+ - **Path filters**: `-i` / `-e` apply after collect; match SRC-relative path; empty after filter prints `No pull candidates.`
136
+ - **Confirm**: One list, one `y/n`; yes applies all rows.
137
+
138
+ **Dest-only example** (dir mapping; config unchanged):
139
+
140
+ ```yaml
141
+ # .github/cursor.src.yaml (excerpt)
142
+ paths:
143
+ - src_path: .cursor/**/*.mdc
144
+ destinations:
145
+ - name: lz
146
+ dest_path_relative: ../lz
147
+ ```
148
+
149
+ ```bash
150
+ # ../lz/.cursor/rules/bar.mdc exists; SRC has no bar.mdc yet
151
+ path-sync pull -n cursor -d lz --dest-only
152
+ ```
153
+
154
+ Creates `.cursor/rules/bar.mdc` in SRC. To limit a mixed dest-only list:
155
+
156
+ ```bash
157
+ path-sync pull -n cursor -d lz --dest-only -i '.cursor/*'
158
+ ```
159
+
160
+ See also: [pull command reference](docs/pull/index.md).
161
+
162
+ ### 4. Prune dest-only files
163
+
164
+ Delete dest files whose SRC counterpart is missing. Uses the same collect as `pull --dest-only`, then skips opted-out dest files before listing.
165
+
166
+ The following dest extras differ:
167
+
168
+ - **copy orphans:** Dest files that still have a path-sync header for this config and are no longer in this copy run. `_cleanup_orphans` deletes them unless `--skip-orphan-cleanup`.
169
+ - **prune dest-only:** Dest files under a dir, glob, or single-file mapping whose SRC counterpart is missing. No header required (`sync_mode: replace` extras such as `.cursor/`). `path-sync prune` unlinks after confirm.
170
+ - **pull dest-only:** Same collect as prune, then copy dest → SRC. Opted-out dest files (`sync_mode: sync`, header removed) stay on dest for prune; pull `--dest-only` can still harvest them.
171
+
172
+ ```bash
173
+ path-sync prune -n myconfig -d dest1
174
+ path-sync prune -n myconfig -d dest1 --dry-run
175
+ path-sync prune -n myconfig -d dest1 -i '.cursor/*'
176
+ ```
177
+
178
+ Unlike `copy`, `prune` never auto-deletes. `--dry-run` and non-TTY list candidates and write nothing.
179
+
180
+ - **`-d dest`:** Required. Exactly one destination name (not comma-separated).
181
+ - **`--dry-run`:** Print candidates; no prompt; no delete. Non-TTY behaves the same.
182
+ - **`-i`, `--include`:** Allowlist on dest-relative path ([fnmatch](https://docs.python.org/3/library/fnmatch.html); quote globs in zsh).
183
+ - **`-e`, `--exclude`:** Denylist after include (same matcher).
184
+
185
+ **Dest-only example** (same cursor config as pull):
186
+
187
+ ```bash
188
+ # ../lz/.cursor/rules/bar.mdc exists; SRC has no bar.mdc yet
189
+ path-sync prune -n cursor -d lz
190
+ ```
191
+
192
+ After `copy`, when prune-eligible dest-only files remain, path-sync prints the count plus the pull and prune commands. Copy does not delete them.
193
+
194
+ See also: [prune command reference](docs/prune/index.md).
195
+
196
+ ### 5. Validate (run in dest repo)
96
197
 
97
198
  ```bash
98
199
  uvx path-sync validate-no-changes -b main
@@ -161,6 +262,8 @@ def hello():
161
262
  # === DO_NOT_EDIT: path-sync synced ===
162
263
  def hello():
163
264
  pass
265
+
266
+
164
267
  # === OK_EDIT: path-sync synced ===
165
268
  ```
166
269
 
@@ -25,6 +25,8 @@ 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-only extras under dir or glob mappings survive `copy`; `pull --dest-only` harvests them; `prune` deletes them
29
+ - Dest improvements can be harvested back to SRC with `pull` (local working tree only; you commit SRC yourself)
28
30
  - Idempotent: PR body is left unchanged when already synced from an equal or newer source commit
29
31
  - Stale PRs auto-close when source and destination are already in sync (zero file changes)
30
32
 
@@ -74,7 +76,106 @@ By default, prompts before each git operation. See [Usage Scenarios](#usage-scen
74
76
  | `--pr-reviewers` | Comma-separated PR reviewers |
75
77
  | `--pr-assignees` | Comma-separated PR assignees |
76
78
 
77
- ### 3. Validate (run in dest repo)
79
+ ### 3. Pull changes from a destination
80
+
81
+ 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`.
82
+
83
+ ```bash
84
+ path-sync pull -n myconfig -d dest1
85
+ path-sync pull -n myconfig -d dest1 --dest-only
86
+ path-sync pull -n myconfig -d dest1 --dest-only -i '.cursor/*'
87
+ ```
88
+
89
+ Unlike `copy`, `pull` never auto-applies. `--dry-run` and non-TTY list candidates and write nothing.
90
+
91
+ | | `copy` | `pull` |
92
+ |---|--------|--------|
93
+ | Direction | SRC → DEST | DEST → SRC |
94
+ | `-d` | Comma-separated filter (optional) | Exactly one dest (required) |
95
+ | Non-TTY / no-write flags | `-y` auto-confirms git ops | Never writes (`--dry-run` or non-TTY lists only) |
96
+ | Default | Overwrites dest managed content | Only newer mapped content (git timestamp after content diff) |
97
+
98
+ | Flag | Description |
99
+ |------|-------------|
100
+ | `-d dest` | Required. Exactly one destination name (not comma-separated). |
101
+ | `--dest-only` | Also harvest dest files with no SRC counterpart (additive on mapped pull). |
102
+ | `--dry-run` | Print candidates; no prompt; no write. Non-TTY behaves the same. |
103
+ | `-i`, `--include` | Allowlist on SRC-relative path ([fnmatch](https://docs.python.org/3/library/fnmatch.html); quote globs in zsh). |
104
+ | `-e`, `--exclude` | Denylist after include (same matcher). |
105
+
106
+ | Scenario | Command |
107
+ |----------|---------|
108
+ | Interactive harvest | `pull -n cfg -d dest1` |
109
+ | Preview candidates | `pull -n cfg -d dest1 --dry-run` |
110
+ | Scripted / CI list only | `pull -n cfg -d dest1` (non-TTY) |
111
+
112
+ **Pull behavior**:
113
+ - **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.
114
+ - **Sectioned dest files**: Copies managed section bodies only; dest text after `OK_EDIT` stays in dest; `skip_sections` honored.
115
+ - **Whole-file dest**: Copies file with path-sync header stripped; no header added on SRC.
116
+ - **Dest-only**: Whole file to mapped SRC path; does not edit `.src.yaml`.
117
+ - **Path filters**: `-i` / `-e` apply after collect; match SRC-relative path; empty after filter prints `No pull candidates.`
118
+ - **Confirm**: One list, one `y/n`; yes applies all rows.
119
+
120
+ **Dest-only example** (dir mapping; config unchanged):
121
+
122
+ ```yaml
123
+ # .github/cursor.src.yaml (excerpt)
124
+ paths:
125
+ - src_path: .cursor/**/*.mdc
126
+ destinations:
127
+ - name: lz
128
+ dest_path_relative: ../lz
129
+ ```
130
+
131
+ ```bash
132
+ # ../lz/.cursor/rules/bar.mdc exists; SRC has no bar.mdc yet
133
+ path-sync pull -n cursor -d lz --dest-only
134
+ ```
135
+
136
+ Creates `.cursor/rules/bar.mdc` in SRC. To limit a mixed dest-only list:
137
+
138
+ ```bash
139
+ path-sync pull -n cursor -d lz --dest-only -i '.cursor/*'
140
+ ```
141
+
142
+ See also: [pull command reference](docs/pull/index.md).
143
+
144
+ ### 4. Prune dest-only files
145
+
146
+ Delete dest files whose SRC counterpart is missing. Uses the same collect as `pull --dest-only`, then skips opted-out dest files before listing.
147
+
148
+ The following dest extras differ:
149
+
150
+ - **copy orphans:** Dest files that still have a path-sync header for this config and are no longer in this copy run. `_cleanup_orphans` deletes them unless `--skip-orphan-cleanup`.
151
+ - **prune dest-only:** Dest files under a dir, glob, or single-file mapping whose SRC counterpart is missing. No header required (`sync_mode: replace` extras such as `.cursor/`). `path-sync prune` unlinks after confirm.
152
+ - **pull dest-only:** Same collect as prune, then copy dest → SRC. Opted-out dest files (`sync_mode: sync`, header removed) stay on dest for prune; pull `--dest-only` can still harvest them.
153
+
154
+ ```bash
155
+ path-sync prune -n myconfig -d dest1
156
+ path-sync prune -n myconfig -d dest1 --dry-run
157
+ path-sync prune -n myconfig -d dest1 -i '.cursor/*'
158
+ ```
159
+
160
+ Unlike `copy`, `prune` never auto-deletes. `--dry-run` and non-TTY list candidates and write nothing.
161
+
162
+ - **`-d dest`:** Required. Exactly one destination name (not comma-separated).
163
+ - **`--dry-run`:** Print candidates; no prompt; no delete. Non-TTY behaves the same.
164
+ - **`-i`, `--include`:** Allowlist on dest-relative path ([fnmatch](https://docs.python.org/3/library/fnmatch.html); quote globs in zsh).
165
+ - **`-e`, `--exclude`:** Denylist after include (same matcher).
166
+
167
+ **Dest-only example** (same cursor config as pull):
168
+
169
+ ```bash
170
+ # ../lz/.cursor/rules/bar.mdc exists; SRC has no bar.mdc yet
171
+ path-sync prune -n cursor -d lz
172
+ ```
173
+
174
+ After `copy`, when prune-eligible dest-only files remain, path-sync prints the count plus the pull and prune commands. Copy does not delete them.
175
+
176
+ See also: [prune command reference](docs/prune/index.md).
177
+
178
+ ### 5. Validate (run in dest repo)
78
179
 
79
180
  ```bash
80
181
  uvx path-sync validate-no-changes -b main
@@ -143,6 +244,8 @@ def hello():
143
244
  # === DO_NOT_EDIT: path-sync synced ===
144
245
  def hello():
145
246
  pass
247
+
248
+
146
249
  # === OK_EDIT: path-sync synced ===
147
250
  ```
148
251
 
@@ -2,13 +2,17 @@
2
2
  # flake8: noqa
3
3
  from path_sync import copy
4
4
  from path_sync import dep_update
5
+ from path_sync import prune
6
+ from path_sync import pull
5
7
  from path_sync import validate_no_changes
6
8
  from path_sync import config
7
9
 
8
- VERSION = "0.7.8"
10
+ VERSION = "0.9.0"
9
11
  __all__ = [
10
12
  "copy",
11
13
  "dep_update",
14
+ "prune",
15
+ "pull",
12
16
  "validate_no_changes",
13
17
  "config",
14
18
  ]
@@ -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_prune, 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
 
@@ -12,6 +12,7 @@ from pydantic import BaseModel
12
12
  from path_sync import sections
13
13
  from path_sync._internal import cmd_options, git_ops, header, prompt_utils, verify
14
14
  from path_sync._internal.auto_merge import PRRef, handle_auto_merge
15
+ from path_sync._internal.dest_only import collect_dest_only_files, prune_eligible
15
16
  from path_sync._internal.file_utils import ensure_parents_write_text
16
17
  from path_sync._internal.log_capture import capture_log
17
18
  from path_sync._internal.models import (
@@ -273,6 +274,7 @@ def _sync_destination(
273
274
  )
274
275
  result = _sync_paths(config, dest, src_root, dest_root, opts)
275
276
  _print_sync_summary(result)
277
+ _print_dest_only_hint(config, dest, src_root, dest_root)
276
278
 
277
279
  if result.total == 0:
278
280
  typer.echo(" No changes", err=True)
@@ -316,6 +318,22 @@ def _print_dest_header(dest: Destination) -> None:
316
318
  typer.echo(line, err=True)
317
319
 
318
320
 
321
+ def _print_dest_only_hint(config: SrcConfig, dest: Destination, src_root: Path, dest_root: Path) -> None:
322
+ if not dest_root.exists() or not git_ops.is_git_repo(dest_root):
323
+ return
324
+ dest_repo = git_ops.get_repo(dest_root)
325
+ files = prune_eligible(collect_dest_only_files(config, dest, src_root, dest_root, dest_repo, set()))
326
+ if not files:
327
+ return
328
+ label = "file" if len(files) == 1 else "files"
329
+ typer.echo(
330
+ f" {len(files)} dest-only {label}. "
331
+ f"Harvest: path-sync pull -n {config.name} -d {dest.name} --dest-only. "
332
+ f"Delete: path-sync prune -n {config.name} -d {dest.name}.",
333
+ err=True,
334
+ )
335
+
336
+
319
337
  def _print_sync_summary(result: SyncResult) -> None:
320
338
  if result.content_changes > 0:
321
339
  typer.echo(f" [{result.content_changes} files synced]", err=True)
@@ -0,0 +1,95 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ import typer
8
+ from pydantic import BaseModel, Field
9
+
10
+ from path_sync._internal import git_ops, prompt_utils
11
+ from path_sync._internal.cmd_pull import _EXCLUDE_OPTION, _INCLUDE_OPTION, _keep_pull_path, _validate_dest_name
12
+ from path_sync._internal.dest_only import collect_dest_only_files, prune_eligible
13
+ from path_sync._internal.models import Destination, SrcConfig, find_repo_root, resolve_config_path
14
+ from path_sync._internal.repo_utils import resolve_repo_path
15
+ from path_sync._internal.typer_app import app
16
+ from path_sync._internal.yaml_utils import load_yaml_model
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class PruneOptions(BaseModel):
22
+ dry_run: bool = False
23
+ include: list[str] = Field(default_factory=list)
24
+ exclude: list[str] = Field(default_factory=list)
25
+
26
+
27
+ def _run_prune(config: SrcConfig, dest: Destination, src_root: Path, opts: PruneOptions) -> None:
28
+ dest_root = resolve_repo_path(dest, src_root, "")
29
+ if not git_ops.is_git_repo(dest_root):
30
+ raise ValueError(f"Not a git repository: {dest_root}")
31
+
32
+ dest_repo = git_ops.get_repo(dest_root)
33
+ files = prune_eligible(collect_dest_only_files(config, dest, src_root, dest_root, dest_repo, set()))
34
+ files = [
35
+ row for row in files if _keep_pull_path(str(row.dest_path.relative_to(dest_root)), opts.include, opts.exclude)
36
+ ]
37
+
38
+ if not files:
39
+ typer.echo("No prune candidates.", err=True)
40
+ return
41
+
42
+ typer.echo(f"\nPrune dest-only files in {dest.name}?\n", err=True)
43
+ for row in files:
44
+ typer.echo(str(row.dest_path.relative_to(dest_root)), err=True)
45
+
46
+ if opts.dry_run or not sys.stdin.isatty():
47
+ return
48
+ if prompt_utils.prompt_pull_confirm("Confirm?"):
49
+ for row in files:
50
+ row.dest_path.unlink()
51
+ logger.info(f"Pruned: {row.dest_path}")
52
+
53
+
54
+ @app.command()
55
+ def prune(
56
+ name: str = typer.Option("", "-n", "--name", help="Config name"),
57
+ config_path_opt: str = typer.Option("", "-c", "--config-path", help="Full path to config file"),
58
+ src_root_opt: str = typer.Option("", "--src-root", help="Source repo root"),
59
+ dest_name: str = typer.Option(..., "-d", "--dest", help="Destination name (exactly one)"),
60
+ dry_run: bool = typer.Option(False, "--dry-run", help="Print candidates without deleting"),
61
+ include: list[str] = _INCLUDE_OPTION,
62
+ exclude: list[str] = _EXCLUDE_OPTION,
63
+ ) -> None:
64
+ """Delete dest-only files after one confirm.
65
+
66
+ Quote glob patterns (e.g. -i '.cursor/*').
67
+ """
68
+ if name and config_path_opt:
69
+ logger.error("Cannot use both --name and --config-path")
70
+ raise typer.Exit(1)
71
+ if not name and not config_path_opt:
72
+ logger.error("Either --name or --config-path is required")
73
+ raise typer.Exit(1)
74
+
75
+ try:
76
+ dest_filter = _validate_dest_name(dest_name)
77
+ except ValueError as e:
78
+ logger.error(str(e))
79
+ raise typer.Exit(1)
80
+
81
+ src_root = Path(src_root_opt) if src_root_opt else find_repo_root(Path.cwd())
82
+ config_path = Path(config_path_opt) if config_path_opt else resolve_config_path(src_root, name)
83
+ if not config_path.exists():
84
+ logger.error(f"Config not found: {config_path}")
85
+ raise typer.Exit(1)
86
+
87
+ config = load_yaml_model(config_path, SrcConfig)
88
+ dest = config.find_destination(dest_filter)
89
+ opts = PruneOptions(dry_run=dry_run, include=include, exclude=exclude)
90
+
91
+ try:
92
+ _run_prune(config, dest, src_root, opts)
93
+ except ValueError as e:
94
+ logger.error(f"Prune failed: {e}")
95
+ raise typer.Exit(1)
@@ -0,0 +1,356 @@
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
+
11
+ import typer
12
+ from git import Repo
13
+ from pydantic import BaseModel, Field
14
+
15
+ from path_sync import sections
16
+ from path_sync._internal import git_ops, header, prompt_utils
17
+ from path_sync._internal.cmd_copy import _iter_sync_files
18
+ from path_sync._internal.dest_only import DestOnlyFile, collect_dest_only_files, is_opted_out
19
+ from path_sync._internal.file_utils import ensure_parents_write_text
20
+ from path_sync._internal.models import (
21
+ Destination,
22
+ SrcConfig,
23
+ SyncMode,
24
+ find_repo_root,
25
+ resolve_config_path,
26
+ )
27
+ from path_sync._internal.repo_utils import resolve_repo_path
28
+ from path_sync._internal.typer_app import app
29
+ from path_sync._internal.yaml_utils import load_yaml_model
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ _EMPTY_STR_LIST: list[str] = []
34
+ _INCLUDE_OPTION = typer.Option(_EMPTY_STR_LIST, "-i", "--include", help="Keep paths matching pattern")
35
+ _EXCLUDE_OPTION = typer.Option(_EMPTY_STR_LIST, "-e", "--exclude", help="Drop paths matching pattern")
36
+
37
+
38
+ class PullKind(StrEnum):
39
+ SECTIONS = "sections"
40
+ WHOLE = "whole"
41
+ BINARY = "binary"
42
+
43
+
44
+ class PullOptions(BaseModel):
45
+ dry_run: bool = False
46
+ dest_only: bool = False
47
+ include: list[str] = Field(default_factory=list)
48
+ exclude: list[str] = Field(default_factory=list)
49
+
50
+
51
+ @dataclass
52
+ class PullCandidate:
53
+ src_path: Path
54
+ dest_path: Path
55
+ dest_key: str
56
+ kind: PullKind
57
+ section_ids: list[str]
58
+ dest_ts: int | None
59
+ src_ts: int | None
60
+ skip_sections: list[str]
61
+ dest_only: bool = False
62
+
63
+
64
+ def _validate_dest_name(dest: str) -> str:
65
+ if not dest.strip():
66
+ raise ValueError("Missing destination: -d is required")
67
+ if "," in dest:
68
+ raise ValueError("Exactly one destination required; comma-separated list not supported")
69
+ return dest.strip()
70
+
71
+
72
+ def _format_unix(ts: int) -> str:
73
+ return datetime.fromtimestamp(ts, UTC).strftime("%Y-%m-%d")
74
+
75
+
76
+ def _managed_section_map(content: str, path: Path, skip: list[str]) -> dict[str, str]:
77
+ body = header.remove_header(content)
78
+ extracted = sections.extract_sections(body, path)
79
+ return {k: v for k, v in extracted.items() if k not in skip}
80
+
81
+
82
+ def _diff_section_ids(dest_map: dict[str, str], src_map: dict[str, str]) -> list[str]:
83
+ ids = set(dest_map) | set(src_map)
84
+ return sorted(i for i in ids if dest_map.get(i) != src_map.get(i))
85
+
86
+
87
+ def _collect_mapped_candidates(
88
+ config: SrcConfig,
89
+ dest: Destination,
90
+ src_root: Path,
91
+ dest_root: Path,
92
+ src_repo: Repo,
93
+ dest_repo: Repo,
94
+ ) -> list[PullCandidate]:
95
+ candidates: list[PullCandidate] = []
96
+ for mapping in config.resolve_paths(dest):
97
+ for src_path, dest_key, dest_path in _iter_sync_files(mapping, src_root, dest_root):
98
+ if dest.is_skipped(dest_key):
99
+ continue
100
+ if not dest_path.exists():
101
+ logger.warning(f"Dest not found: {dest_path}")
102
+ continue
103
+ if not src_path.exists():
104
+ continue
105
+ if mapping.sync_mode == SyncMode.SCAFFOLD:
106
+ continue
107
+
108
+ skip_list = dest.skip_sections.get(dest_key, [])
109
+ candidate = _candidate_for_path(
110
+ src_path,
111
+ dest_path,
112
+ dest_key,
113
+ mapping.sync_mode,
114
+ skip_list,
115
+ src_repo,
116
+ dest_repo,
117
+ )
118
+ if candidate:
119
+ candidates.append(candidate)
120
+ return candidates
121
+
122
+
123
+ def _dest_only_candidate(row: DestOnlyFile) -> PullCandidate:
124
+ try:
125
+ row.dest_path.read_text()
126
+ except UnicodeDecodeError:
127
+ kind = PullKind.BINARY
128
+ else:
129
+ kind = PullKind.WHOLE
130
+ return PullCandidate(
131
+ src_path=row.src_path,
132
+ dest_path=row.dest_path,
133
+ dest_key=row.dest_key,
134
+ kind=kind,
135
+ section_ids=[],
136
+ dest_ts=None,
137
+ src_ts=None,
138
+ skip_sections=[],
139
+ dest_only=True,
140
+ )
141
+
142
+
143
+ def _collect_dest_only_candidates(
144
+ config: SrcConfig,
145
+ dest: Destination,
146
+ src_root: Path,
147
+ dest_root: Path,
148
+ dest_repo: Repo,
149
+ skip_keys: set[str],
150
+ ) -> list[PullCandidate]:
151
+ return [
152
+ _dest_only_candidate(row)
153
+ for row in collect_dest_only_files(config, dest, src_root, dest_root, dest_repo, skip_keys)
154
+ ]
155
+
156
+
157
+ def _text_pull_kind(
158
+ src_path: Path,
159
+ dest_path: Path,
160
+ dest_text: str,
161
+ src_text: str,
162
+ sync_mode: SyncMode,
163
+ skip_list: list[str],
164
+ ) -> tuple[PullKind, list[str]] | None:
165
+ if is_opted_out(dest_text, sync_mode):
166
+ return None
167
+
168
+ dest_body = header.remove_header(dest_text)
169
+ if header.has_known_comment_prefix(dest_path) and sections.has_sections(dest_body, dest_path):
170
+ dest_map = _managed_section_map(dest_text, dest_path, skip_list)
171
+ src_map = _managed_section_map(src_text, src_path, skip_list)
172
+ if dest_map == src_map:
173
+ return None
174
+ return PullKind.SECTIONS, _diff_section_ids(dest_map, src_map)
175
+
176
+ if header.remove_header(dest_text) == src_text:
177
+ return None
178
+ return PullKind.WHOLE, []
179
+
180
+
181
+ def _candidate_for_path(
182
+ src_path: Path,
183
+ dest_path: Path,
184
+ dest_key: str,
185
+ sync_mode: SyncMode,
186
+ skip_list: list[str],
187
+ src_repo: Repo,
188
+ dest_repo: Repo,
189
+ ) -> PullCandidate | None:
190
+ try:
191
+ dest_text = dest_path.read_text()
192
+ src_text = src_path.read_text()
193
+ except UnicodeDecodeError:
194
+ if dest_path.read_bytes() == src_path.read_bytes():
195
+ return None
196
+ kind, section_ids = PullKind.BINARY, []
197
+ else:
198
+ kind_info = _text_pull_kind(src_path, dest_path, dest_text, src_text, sync_mode, skip_list)
199
+ if kind_info is None:
200
+ return None
201
+ kind, section_ids = kind_info
202
+
203
+ if git_ops.path_is_dirty(dest_repo, dest_path):
204
+ logger.warning(f"Skipping dirty dest path: {dest_path}")
205
+ return None
206
+ if git_ops.path_is_dirty(src_repo, src_path):
207
+ logger.warning(f"Skipping dirty src path: {src_path}")
208
+ return None
209
+
210
+ dest_ts = git_ops.file_last_commit_unix(dest_repo, dest_path)
211
+ src_ts = git_ops.file_last_commit_unix(src_repo, src_path)
212
+ if dest_ts is None or src_ts is None or dest_ts <= src_ts:
213
+ return None
214
+
215
+ return PullCandidate(
216
+ src_path=src_path,
217
+ dest_path=dest_path,
218
+ dest_key=dest_key,
219
+ kind=kind,
220
+ section_ids=section_ids,
221
+ dest_ts=dest_ts,
222
+ src_ts=src_ts,
223
+ skip_sections=skip_list,
224
+ )
225
+
226
+
227
+ def _keep_pull_path(rel: str, include: list[str], exclude: list[str]) -> bool:
228
+ if include and not any(fnmatch.fnmatch(rel, pat) for pat in include):
229
+ return False
230
+ return not (exclude and any(fnmatch.fnmatch(rel, pat) for pat in exclude))
231
+
232
+
233
+ def _format_candidate_line(candidate: PullCandidate, src_root: Path) -> str:
234
+ rel = str(candidate.src_path.relative_to(src_root))
235
+ if candidate.dest_only:
236
+ return f" {rel:<30} dest-only"
237
+ if candidate.kind == PullKind.SECTIONS:
238
+ detail = f"sections: {', '.join(candidate.section_ids)}"
239
+ elif candidate.kind == PullKind.WHOLE:
240
+ detail = "whole file"
241
+ else:
242
+ detail = "binary"
243
+ dest_ts = candidate.dest_ts
244
+ src_ts = candidate.src_ts
245
+ assert dest_ts is not None and src_ts is not None
246
+ ts = f"dest {_format_unix(dest_ts)} > src {_format_unix(src_ts)}"
247
+ return f" {rel:<30} {detail:<30} {ts}"
248
+
249
+
250
+ def _print_candidates(dest_name: str, candidates: list[PullCandidate], src_root: Path) -> None:
251
+ typer.echo(f"\nPull from {dest_name} into src?\n", err=True)
252
+ for c in candidates:
253
+ typer.echo(_format_candidate_line(c, src_root), err=True)
254
+
255
+
256
+ def _apply_candidate(candidate: PullCandidate) -> None:
257
+ if candidate.kind == PullKind.BINARY:
258
+ candidate.src_path.parent.mkdir(parents=True, exist_ok=True)
259
+ candidate.src_path.write_bytes(candidate.dest_path.read_bytes())
260
+ logger.info(f"Pulled binary: {candidate.src_path}")
261
+ return
262
+
263
+ if candidate.kind == PullKind.SECTIONS:
264
+ dest_body = header.remove_header(candidate.dest_path.read_text())
265
+ dest_sections = sections.parse_sections(dest_body, candidate.dest_path)
266
+ new_content = sections.replace_sections(
267
+ candidate.src_path.read_text(),
268
+ dest_sections,
269
+ candidate.src_path,
270
+ skip_sections=candidate.skip_sections,
271
+ keep_deleted_sections=True,
272
+ )
273
+ else:
274
+ new_content = header.remove_header(candidate.dest_path.read_text())
275
+
276
+ ensure_parents_write_text(candidate.src_path, new_content)
277
+ logger.info(f"Pulled: {candidate.src_path}")
278
+
279
+
280
+ def _run_pull(config: SrcConfig, dest: Destination, src_root: Path, opts: PullOptions) -> None:
281
+ dest_root = resolve_repo_path(dest, src_root, "")
282
+ if not git_ops.is_git_repo(dest_root):
283
+ raise ValueError(f"Not a git repository: {dest_root}")
284
+
285
+ src_repo = git_ops.get_repo(src_root)
286
+ dest_repo = git_ops.get_repo(dest_root)
287
+ candidates = _collect_mapped_candidates(config, dest, src_root, dest_root, src_repo, dest_repo)
288
+ if opts.dest_only:
289
+ skip_keys = {c.dest_key for c in candidates}
290
+ candidates.extend(_collect_dest_only_candidates(config, dest, src_root, dest_root, dest_repo, skip_keys))
291
+
292
+ candidates = [
293
+ c for c in candidates if _keep_pull_path(str(c.src_path.relative_to(src_root)), opts.include, opts.exclude)
294
+ ]
295
+
296
+ if not candidates:
297
+ typer.echo("No pull candidates.", err=True)
298
+ return
299
+
300
+ _print_candidates(dest.name, candidates, src_root)
301
+
302
+ if opts.dry_run or not sys.stdin.isatty():
303
+ return
304
+ if prompt_utils.prompt_pull_confirm("Confirm?"):
305
+ for c in candidates:
306
+ _apply_candidate(c)
307
+
308
+
309
+ @app.command()
310
+ def pull(
311
+ name: str = typer.Option("", "-n", "--name", help="Config name"),
312
+ config_path_opt: str = typer.Option("", "-c", "--config-path", help="Full path to config file"),
313
+ src_root_opt: str = typer.Option("", "--src-root", help="Source repo root"),
314
+ dest_name: str = typer.Option(..., "-d", "--dest", help="Destination name (exactly one)"),
315
+ dry_run: bool = typer.Option(False, "--dry-run", help="Print candidates without writing (same as non-TTY)"),
316
+ dest_only: bool = typer.Option(False, "--dest-only", help="Also harvest dest files with no src counterpart"),
317
+ include: list[str] = _INCLUDE_OPTION,
318
+ exclude: list[str] = _EXCLUDE_OPTION,
319
+ ) -> None:
320
+ """Harvest newer mapped dest files into src after one confirm.
321
+
322
+ --dest-only also copies dest-only files. Quote glob patterns (e.g. -i '.cursor/*').
323
+ """
324
+ if name and config_path_opt:
325
+ logger.error("Cannot use both --name and --config-path")
326
+ raise typer.Exit(1)
327
+ if not name and not config_path_opt:
328
+ logger.error("Either --name or --config-path is required")
329
+ raise typer.Exit(1)
330
+
331
+ try:
332
+ dest_filter = _validate_dest_name(dest_name)
333
+ except ValueError as e:
334
+ logger.error(str(e))
335
+ raise typer.Exit(1)
336
+
337
+ src_root = Path(src_root_opt) if src_root_opt else find_repo_root(Path.cwd())
338
+ config_path = Path(config_path_opt) if config_path_opt else resolve_config_path(src_root, name)
339
+ if not config_path.exists():
340
+ logger.error(f"Config not found: {config_path}")
341
+ raise typer.Exit(1)
342
+
343
+ config = load_yaml_model(config_path, SrcConfig)
344
+ dest = config.find_destination(dest_filter)
345
+ opts = PullOptions(
346
+ dry_run=dry_run,
347
+ dest_only=dest_only,
348
+ include=include,
349
+ exclude=exclude,
350
+ )
351
+
352
+ try:
353
+ _run_pull(config, dest, src_root, opts)
354
+ except ValueError as e:
355
+ logger.error(f"Pull failed: {e}")
356
+ raise typer.Exit(1)
@@ -0,0 +1,102 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from pathlib import Path
5
+ from typing import NamedTuple
6
+
7
+ from git import Repo
8
+
9
+ from path_sync._internal import git_ops, header
10
+ from path_sync._internal.models import Destination, PathMapping, SrcConfig, SyncMode
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class DestOnlyFile(NamedTuple):
16
+ src_path: Path
17
+ dest_path: Path
18
+ dest_key: str
19
+ sync_mode: SyncMode
20
+
21
+
22
+ class _DestFileMap(NamedTuple):
23
+ src_path: Path
24
+ dest_key: str
25
+
26
+
27
+ def is_opted_out(dest_text: str, sync_mode: SyncMode) -> bool:
28
+ return sync_mode == SyncMode.SYNC and not header.has_header(dest_text)
29
+
30
+
31
+ def prune_eligible(files: list[DestOnlyFile]) -> list[DestOnlyFile]:
32
+ eligible: list[DestOnlyFile] = []
33
+ for row in files:
34
+ try:
35
+ dest_text = row.dest_path.read_text()
36
+ except UnicodeDecodeError:
37
+ continue
38
+ if not is_opted_out(dest_text, row.sync_mode):
39
+ eligible.append(row)
40
+ return eligible
41
+
42
+
43
+ def _src_for_dest_file(
44
+ mapping: PathMapping,
45
+ dest_path: Path,
46
+ src_root: Path,
47
+ dest_root: Path,
48
+ ) -> _DestFileMap | None:
49
+ if "*" in mapping.src_path:
50
+ glob_prefix = mapping.src_path.split("*")[0].rstrip("/")
51
+ dest_base = mapping.dest_path or glob_prefix
52
+ src_base = glob_prefix
53
+ elif (dest_root / mapping.resolved_dest_path()).is_dir():
54
+ dest_base = mapping.resolved_dest_path()
55
+ src_base = mapping.src_path
56
+ elif dest_path == dest_root / mapping.resolved_dest_path():
57
+ dest_base = mapping.resolved_dest_path()
58
+ return _DestFileMap(src_root / mapping.src_path, dest_base)
59
+ else:
60
+ return None
61
+
62
+ dest_anchor = dest_root / dest_base
63
+ if dest_path.is_relative_to(dest_anchor):
64
+ rel = dest_path.relative_to(dest_anchor)
65
+ return _DestFileMap(src_root / src_base / rel, str(Path(dest_base) / rel))
66
+ return None
67
+
68
+
69
+ def collect_dest_only_files(
70
+ config: SrcConfig,
71
+ dest: Destination,
72
+ src_root: Path,
73
+ dest_root: Path,
74
+ dest_repo: Repo,
75
+ skip_keys: set[str],
76
+ ) -> list[DestOnlyFile]:
77
+ files: list[DestOnlyFile] = []
78
+ seen = set(skip_keys)
79
+ for mapping in config.resolve_paths(dest):
80
+ if mapping.sync_mode == SyncMode.SCAFFOLD:
81
+ continue
82
+ for dest_path in mapping.expand_dest_paths(dest_root):
83
+ if dest_path.is_dir() or mapping.is_excluded(dest_path):
84
+ continue
85
+ mapped = _src_for_dest_file(mapping, dest_path, src_root, dest_root)
86
+ if mapped is None:
87
+ continue
88
+ if dest.is_skipped(mapped.dest_key) or mapped.src_path.exists() or mapped.dest_key in seen:
89
+ continue
90
+ if git_ops.path_is_tracked_dirty(dest_repo, dest_path):
91
+ logger.warning(f"Skipping dirty dest path: {dest_path}")
92
+ continue
93
+ seen.add(mapped.dest_key)
94
+ files.append(
95
+ DestOnlyFile(
96
+ src_path=mapped.src_path,
97
+ dest_path=dest_path,
98
+ dest_key=mapped.dest_key,
99
+ sync_mode=mapping.sync_mode,
100
+ )
101
+ )
102
+ return files
@@ -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,4 @@
1
+ # Generated by pkg-ext
2
+ from path_sync._internal.cmd_prune import prune as _prune
3
+
4
+ prune = _prune
@@ -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.8"
5
+ version = "0.9.0"
6
6
  description = "Sync files from a source repo to multiple destination repos"
7
7
  requires-python = ">=3.13"
8
8
  license = "MIT"
@@ -124,3 +124,12 @@ docs = [
124
124
  # === DO_NOT_EDIT: path-sync release ===
125
125
  release = ["pkg-ext"]
126
126
  # === OK_EDIT: path-sync release ===
127
+ # === DO_NOT_EDIT: path-sync wheel ===
128
+ [tool.hatch.build.targets.wheel]
129
+ exclude = [
130
+ "*_examples.py",
131
+ "*_test.py",
132
+ "conftest.py",
133
+ "*_test/",
134
+ ]
135
+ # === OK_EDIT: path-sync wheel ===
File without changes
File without changes
File without changes
File without changes