path-sync 0.8.0__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.8.0 → path_sync-0.9.0}/PKG-INFO +37 -2
  2. {path_sync-0.8.0 → path_sync-0.9.0}/README.md +36 -1
  3. {path_sync-0.8.0 → path_sync-0.9.0}/path_sync/__init__.py +3 -1
  4. {path_sync-0.8.0 → path_sync-0.9.0}/path_sync/__main__.py +1 -1
  5. {path_sync-0.8.0 → path_sync-0.9.0}/path_sync/_internal/cmd_copy.py +18 -0
  6. path_sync-0.9.0/path_sync/_internal/cmd_prune.py +95 -0
  7. {path_sync-0.8.0 → path_sync-0.9.0}/path_sync/_internal/cmd_pull.py +11 -62
  8. path_sync-0.9.0/path_sync/_internal/dest_only.py +102 -0
  9. path_sync-0.9.0/path_sync/prune.py +4 -0
  10. {path_sync-0.8.0 → path_sync-0.9.0}/pyproject.toml +2 -9
  11. {path_sync-0.8.0 → path_sync-0.9.0}/.gitignore +0 -0
  12. {path_sync-0.8.0 → path_sync-0.9.0}/LICENSE +0 -0
  13. {path_sync-0.8.0 → path_sync-0.9.0}/path_sync/_internal/__init__.py +0 -0
  14. {path_sync-0.8.0 → path_sync-0.9.0}/path_sync/_internal/auto_merge.py +0 -0
  15. {path_sync-0.8.0 → path_sync-0.9.0}/path_sync/_internal/cmd_boot.py +0 -0
  16. {path_sync-0.8.0 → path_sync-0.9.0}/path_sync/_internal/cmd_dep_update.py +0 -0
  17. {path_sync-0.8.0 → path_sync-0.9.0}/path_sync/_internal/cmd_options.py +0 -0
  18. {path_sync-0.8.0 → path_sync-0.9.0}/path_sync/_internal/cmd_validate.py +0 -0
  19. {path_sync-0.8.0 → path_sync-0.9.0}/path_sync/_internal/file_utils.py +0 -0
  20. {path_sync-0.8.0 → path_sync-0.9.0}/path_sync/_internal/git_ops.py +0 -0
  21. {path_sync-0.8.0 → path_sync-0.9.0}/path_sync/_internal/header.py +0 -0
  22. {path_sync-0.8.0 → path_sync-0.9.0}/path_sync/_internal/log_capture.py +0 -0
  23. {path_sync-0.8.0 → path_sync-0.9.0}/path_sync/_internal/models.py +0 -0
  24. {path_sync-0.8.0 → path_sync-0.9.0}/path_sync/_internal/models_dep.py +0 -0
  25. {path_sync-0.8.0 → path_sync-0.9.0}/path_sync/_internal/prompt_utils.py +0 -0
  26. {path_sync-0.8.0 → path_sync-0.9.0}/path_sync/_internal/repo_utils.py +0 -0
  27. {path_sync-0.8.0 → path_sync-0.9.0}/path_sync/_internal/typer_app.py +0 -0
  28. {path_sync-0.8.0 → path_sync-0.9.0}/path_sync/_internal/validation.py +0 -0
  29. {path_sync-0.8.0 → path_sync-0.9.0}/path_sync/_internal/verify.py +0 -0
  30. {path_sync-0.8.0 → path_sync-0.9.0}/path_sync/_internal/yaml_utils.py +0 -0
  31. {path_sync-0.8.0 → path_sync-0.9.0}/path_sync/config.py +0 -0
  32. {path_sync-0.8.0 → path_sync-0.9.0}/path_sync/copy.py +0 -0
  33. {path_sync-0.8.0 → path_sync-0.9.0}/path_sync/dep_update.py +0 -0
  34. {path_sync-0.8.0 → path_sync-0.9.0}/path_sync/pull.py +0 -0
  35. {path_sync-0.8.0 → path_sync-0.9.0}/path_sync/sections.py +0 -0
  36. {path_sync-0.8.0 → path_sync-0.9.0}/path_sync/validate_no_changes.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: path-sync
3
- Version: 0.8.0
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,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-only extras under dir or glob mappings survive `copy`; `pull --dest-only` harvests them; `prune` deletes them
46
47
  - Dest improvements can be harvested back to SRC with `pull` (local working tree only; you commit SRC yourself)
47
48
  - Idempotent: PR body is left unchanged when already synced from an equal or newer source commit
48
49
  - Stale PRs auto-close when source and destination are already in sync (zero file changes)
@@ -158,7 +159,41 @@ path-sync pull -n cursor -d lz --dest-only -i '.cursor/*'
158
159
 
159
160
  See also: [pull command reference](docs/pull/index.md).
160
161
 
161
- ### 4. Validate (run in dest repo)
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)
162
197
 
163
198
  ```bash
164
199
  uvx path-sync validate-no-changes -b main
@@ -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-only extras under dir or glob mappings survive `copy`; `pull --dest-only` harvests them; `prune` deletes them
28
29
  - Dest improvements can be harvested back to SRC with `pull` (local working tree only; you commit SRC yourself)
29
30
  - Idempotent: PR body is left unchanged when already synced from an equal or newer source commit
30
31
  - Stale PRs auto-close when source and destination are already in sync (zero file changes)
@@ -140,7 +141,41 @@ path-sync pull -n cursor -d lz --dest-only -i '.cursor/*'
140
141
 
141
142
  See also: [pull command reference](docs/pull/index.md).
142
143
 
143
- ### 4. Validate (run in dest repo)
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)
144
179
 
145
180
  ```bash
146
181
  uvx path-sync validate-no-changes -b main
@@ -2,14 +2,16 @@
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
5
6
  from path_sync import pull
6
7
  from path_sync import validate_no_changes
7
8
  from path_sync import config
8
9
 
9
- VERSION = "0.8.0"
10
+ VERSION = "0.9.0"
10
11
  __all__ = [
11
12
  "copy",
12
13
  "dep_update",
14
+ "prune",
13
15
  "pull",
14
16
  "validate_no_changes",
15
17
  "config",
@@ -1,6 +1,6 @@
1
1
  import logging
2
2
 
3
- from path_sync._internal import cmd_boot, cmd_copy, cmd_dep_update, cmd_pull, 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
 
@@ -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)
@@ -7,7 +7,6 @@ from dataclasses import dataclass
7
7
  from datetime import UTC, datetime
8
8
  from enum import StrEnum
9
9
  from pathlib import Path
10
- from typing import NamedTuple
11
10
 
12
11
  import typer
13
12
  from git import Repo
@@ -16,10 +15,10 @@ from pydantic import BaseModel, Field
16
15
  from path_sync import sections
17
16
  from path_sync._internal import git_ops, header, prompt_utils
18
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
19
  from path_sync._internal.file_utils import ensure_parents_write_text
20
20
  from path_sync._internal.models import (
21
21
  Destination,
22
- PathMapping,
23
22
  SrcConfig,
24
23
  SyncMode,
25
24
  find_repo_root,
@@ -49,11 +48,6 @@ class PullOptions(BaseModel):
49
48
  exclude: list[str] = Field(default_factory=list)
50
49
 
51
50
 
52
- class _DestFileMap(NamedTuple):
53
- src_path: Path
54
- dest_key: str
55
-
56
-
57
51
  @dataclass
58
52
  class PullCandidate:
59
53
  src_path: Path
@@ -90,10 +84,6 @@ def _diff_section_ids(dest_map: dict[str, str], src_map: dict[str, str]) -> list
90
84
  return sorted(i for i in ids if dest_map.get(i) != src_map.get(i))
91
85
 
92
86
 
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
87
  def _collect_mapped_candidates(
98
88
  config: SrcConfig,
99
89
  dest: Destination,
@@ -130,43 +120,17 @@ def _collect_mapped_candidates(
130
120
  return candidates
131
121
 
132
122
 
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:
123
+ def _dest_only_candidate(row: DestOnlyFile) -> PullCandidate:
160
124
  try:
161
- dest_path.read_text()
125
+ row.dest_path.read_text()
162
126
  except UnicodeDecodeError:
163
127
  kind = PullKind.BINARY
164
128
  else:
165
129
  kind = PullKind.WHOLE
166
130
  return PullCandidate(
167
- src_path=src_path,
168
- dest_path=dest_path,
169
- dest_key=dest_key,
131
+ src_path=row.src_path,
132
+ dest_path=row.dest_path,
133
+ dest_key=row.dest_key,
170
134
  kind=kind,
171
135
  section_ids=[],
172
136
  dest_ts=None,
@@ -184,25 +148,10 @@ def _collect_dest_only_candidates(
184
148
  dest_repo: Repo,
185
149
  skip_keys: set[str],
186
150
  ) -> 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
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
+ ]
206
155
 
207
156
 
208
157
  def _text_pull_kind(
@@ -213,7 +162,7 @@ def _text_pull_kind(
213
162
  sync_mode: SyncMode,
214
163
  skip_list: list[str],
215
164
  ) -> tuple[PullKind, list[str]] | None:
216
- if _is_opted_out(dest_text, sync_mode):
165
+ if is_opted_out(dest_text, sync_mode):
217
166
  return None
218
167
 
219
168
  dest_body = header.remove_header(dest_text)
@@ -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
@@ -0,0 +1,4 @@
1
+ # Generated by pkg-ext
2
+ from path_sync._internal.cmd_prune import prune as _prune
3
+
4
+ prune = _prune
@@ -2,7 +2,7 @@
2
2
 
3
3
  [project]
4
4
  name = "path-sync"
5
- version = "0.8.0"
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"
@@ -69,13 +69,6 @@ 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/**"]
79
72
  # === OK_EDIT: path-sync ruff ===
80
73
 
81
74
  # === DO_NOT_EDIT: path-sync pytest ===
@@ -102,7 +95,7 @@ exclude = [".venv/", "*test.py"]
102
95
  # === DO_NOT_EDIT: path-sync dev-lint ===
103
96
  [dependency-groups]
104
97
  dev-lint = [
105
- "ty==0.0.76",
98
+ "pyright>=1.1",
106
99
  "ruff>=0.14",
107
100
  "vulture>=2.14",
108
101
  ]
File without changes
File without changes
File without changes
File without changes
File without changes