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.
- {path_sync-0.8.0 → path_sync-0.9.0}/PKG-INFO +37 -2
- {path_sync-0.8.0 → path_sync-0.9.0}/README.md +36 -1
- {path_sync-0.8.0 → path_sync-0.9.0}/path_sync/__init__.py +3 -1
- {path_sync-0.8.0 → path_sync-0.9.0}/path_sync/__main__.py +1 -1
- {path_sync-0.8.0 → path_sync-0.9.0}/path_sync/_internal/cmd_copy.py +18 -0
- path_sync-0.9.0/path_sync/_internal/cmd_prune.py +95 -0
- {path_sync-0.8.0 → path_sync-0.9.0}/path_sync/_internal/cmd_pull.py +11 -62
- path_sync-0.9.0/path_sync/_internal/dest_only.py +102 -0
- path_sync-0.9.0/path_sync/prune.py +4 -0
- {path_sync-0.8.0 → path_sync-0.9.0}/pyproject.toml +2 -9
- {path_sync-0.8.0 → path_sync-0.9.0}/.gitignore +0 -0
- {path_sync-0.8.0 → path_sync-0.9.0}/LICENSE +0 -0
- {path_sync-0.8.0 → path_sync-0.9.0}/path_sync/_internal/__init__.py +0 -0
- {path_sync-0.8.0 → path_sync-0.9.0}/path_sync/_internal/auto_merge.py +0 -0
- {path_sync-0.8.0 → path_sync-0.9.0}/path_sync/_internal/cmd_boot.py +0 -0
- {path_sync-0.8.0 → path_sync-0.9.0}/path_sync/_internal/cmd_dep_update.py +0 -0
- {path_sync-0.8.0 → path_sync-0.9.0}/path_sync/_internal/cmd_options.py +0 -0
- {path_sync-0.8.0 → path_sync-0.9.0}/path_sync/_internal/cmd_validate.py +0 -0
- {path_sync-0.8.0 → path_sync-0.9.0}/path_sync/_internal/file_utils.py +0 -0
- {path_sync-0.8.0 → path_sync-0.9.0}/path_sync/_internal/git_ops.py +0 -0
- {path_sync-0.8.0 → path_sync-0.9.0}/path_sync/_internal/header.py +0 -0
- {path_sync-0.8.0 → path_sync-0.9.0}/path_sync/_internal/log_capture.py +0 -0
- {path_sync-0.8.0 → path_sync-0.9.0}/path_sync/_internal/models.py +0 -0
- {path_sync-0.8.0 → path_sync-0.9.0}/path_sync/_internal/models_dep.py +0 -0
- {path_sync-0.8.0 → path_sync-0.9.0}/path_sync/_internal/prompt_utils.py +0 -0
- {path_sync-0.8.0 → path_sync-0.9.0}/path_sync/_internal/repo_utils.py +0 -0
- {path_sync-0.8.0 → path_sync-0.9.0}/path_sync/_internal/typer_app.py +0 -0
- {path_sync-0.8.0 → path_sync-0.9.0}/path_sync/_internal/validation.py +0 -0
- {path_sync-0.8.0 → path_sync-0.9.0}/path_sync/_internal/verify.py +0 -0
- {path_sync-0.8.0 → path_sync-0.9.0}/path_sync/_internal/yaml_utils.py +0 -0
- {path_sync-0.8.0 → path_sync-0.9.0}/path_sync/config.py +0 -0
- {path_sync-0.8.0 → path_sync-0.9.0}/path_sync/copy.py +0 -0
- {path_sync-0.8.0 → path_sync-0.9.0}/path_sync/dep_update.py +0 -0
- {path_sync-0.8.0 → path_sync-0.9.0}/path_sync/pull.py +0 -0
- {path_sync-0.8.0 → path_sync-0.9.0}/path_sync/sections.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
|
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
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[project]
|
|
4
4
|
name = "path-sync"
|
|
5
|
-
version = "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
|
-
"
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|