path-sync 0.2.1__py3-none-any.whl → 0.3.1__py3-none-any.whl
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/__init__.py +10 -1
- path_sync/__main__.py +3 -3
- path_sync/_internal/__init__.py +1 -0
- path_sync/{cmd_boot.py → _internal/cmd_boot.py} +7 -11
- path_sync/{cmd_copy.py → _internal/cmd_copy.py} +96 -73
- path_sync/{cmd_validate.py → _internal/cmd_validate.py} +7 -13
- path_sync/{git_ops.py → _internal/git_ops.py} +3 -9
- path_sync/_internal/header.py +83 -0
- path_sync/{models.py → _internal/models.py} +15 -24
- path_sync/{validation.py → _internal/validation.py} +5 -23
- path_sync/config.py +14 -0
- path_sync/copy.py +4 -0
- path_sync/sections.py +61 -104
- {path_sync-0.2.1.dist-info → path_sync-0.3.1.dist-info}/METADATA +11 -5
- path_sync-0.3.1.dist-info/RECORD +21 -0
- path_sync-0.3.1.dist-info/licenses/LICENSE +21 -0
- path_sync/cmd_copy_test.py +0 -180
- path_sync/conftest.py +0 -15
- path_sync/header.py +0 -80
- path_sync/header_test.py +0 -41
- path_sync/models_test.py +0 -69
- path_sync/sections_test.py +0 -128
- path_sync/validation_test.py +0 -114
- path_sync-0.2.1.dist-info/RECORD +0 -23
- /path_sync/{file_utils.py → _internal/file_utils.py} +0 -0
- /path_sync/{typer_app.py → _internal/typer_app.py} +0 -0
- /path_sync/{yaml_utils.py → _internal/yaml_utils.py} +0 -0
- {path_sync-0.2.1.dist-info → path_sync-0.3.1.dist-info}/WHEEL +0 -0
- {path_sync-0.2.1.dist-info → path_sync-0.3.1.dist-info}/entry_points.txt +0 -0
|
@@ -3,7 +3,8 @@ from __future__ import annotations
|
|
|
3
3
|
import logging
|
|
4
4
|
from pathlib import Path
|
|
5
5
|
|
|
6
|
-
from path_sync import
|
|
6
|
+
from path_sync import sections
|
|
7
|
+
from path_sync._internal import git_ops, header
|
|
7
8
|
|
|
8
9
|
logger = logging.getLogger(__name__)
|
|
9
10
|
|
|
@@ -22,25 +23,6 @@ def parse_skip_sections(value: str) -> dict[str, set[str]]:
|
|
|
22
23
|
return result
|
|
23
24
|
|
|
24
25
|
|
|
25
|
-
def compare_sections(
|
|
26
|
-
baseline_content: str,
|
|
27
|
-
current_content: str,
|
|
28
|
-
skip: set[str],
|
|
29
|
-
) -> list[str]:
|
|
30
|
-
"""Return section IDs with unauthorized changes (modified or removed)."""
|
|
31
|
-
baseline_secs = sections.extract_sections(baseline_content)
|
|
32
|
-
current_secs = sections.extract_sections(current_content)
|
|
33
|
-
|
|
34
|
-
changed: list[str] = []
|
|
35
|
-
for sec_id, baseline_text in baseline_secs.items():
|
|
36
|
-
if sec_id in skip:
|
|
37
|
-
continue
|
|
38
|
-
current_text = current_secs.get(sec_id, "")
|
|
39
|
-
if baseline_text != current_text:
|
|
40
|
-
changed.append(sec_id)
|
|
41
|
-
return changed
|
|
42
|
-
|
|
43
|
-
|
|
44
26
|
def validate_no_unauthorized_changes(
|
|
45
27
|
repo_root: Path,
|
|
46
28
|
default_branch: str = "main",
|
|
@@ -68,12 +50,12 @@ def validate_no_unauthorized_changes(
|
|
|
68
50
|
if baseline_content is None:
|
|
69
51
|
continue
|
|
70
52
|
|
|
71
|
-
baseline_has_sections = sections.has_sections(baseline_content)
|
|
72
|
-
current_has_sections = sections.has_sections(current_content)
|
|
53
|
+
baseline_has_sections = sections.has_sections(baseline_content, path)
|
|
54
|
+
current_has_sections = sections.has_sections(current_content, path)
|
|
73
55
|
|
|
74
56
|
if baseline_has_sections:
|
|
75
57
|
file_skip = skip.get(rel_path, set())
|
|
76
|
-
changed_ids = compare_sections(baseline_content, current_content, file_skip)
|
|
58
|
+
changed_ids = sections.compare_sections(baseline_content, current_content, path, file_skip)
|
|
77
59
|
unauthorized.extend(f"{rel_path}:{sid}" for sid in changed_ids)
|
|
78
60
|
elif current_has_sections:
|
|
79
61
|
unauthorized.append(rel_path)
|
path_sync/config.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Generated by pkg-ext
|
|
2
|
+
from path_sync._internal.models import Destination as _Destination
|
|
3
|
+
from path_sync._internal.models import HeaderConfig as _HeaderConfig
|
|
4
|
+
from path_sync._internal.models import PathMapping as _PathMapping
|
|
5
|
+
from path_sync._internal.models import PRDefaults as _PRDefaults
|
|
6
|
+
from path_sync._internal.models import SrcConfig as _SrcConfig
|
|
7
|
+
from path_sync._internal.models import SyncMode as _SyncMode
|
|
8
|
+
|
|
9
|
+
Destination = _Destination
|
|
10
|
+
HeaderConfig = _HeaderConfig
|
|
11
|
+
PRDefaults = _PRDefaults
|
|
12
|
+
PathMapping = _PathMapping
|
|
13
|
+
SrcConfig = _SrcConfig
|
|
14
|
+
SyncMode = _SyncMode
|
path_sync/copy.py
ADDED
path_sync/sections.py
CHANGED
|
@@ -1,115 +1,72 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
import
|
|
4
|
-
from dataclasses import dataclass
|
|
3
|
+
from pathlib import Path
|
|
5
4
|
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
from zero_3rdparty.sections import (
|
|
6
|
+
Section,
|
|
7
|
+
get_comment_config,
|
|
8
8
|
)
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
content_lines.append(line)
|
|
56
|
-
|
|
57
|
-
if current_id is not None:
|
|
58
|
-
raise ValueError(
|
|
59
|
-
f"Unclosed section '{current_id}' starting at line {current_start}"
|
|
60
|
-
)
|
|
61
|
-
|
|
62
|
-
return sections
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
def wrap_in_default_section(content: str) -> str:
|
|
66
|
-
return f"# === DO_NOT_EDIT: path-sync default ===\n{content}\n# === OK_EDIT ==="
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
def extract_sections(content: str) -> dict[str, str]:
|
|
70
|
-
return {s.id: s.content for s in parse_sections(content)}
|
|
9
|
+
from zero_3rdparty.sections import (
|
|
10
|
+
compare_sections as _compare_sections,
|
|
11
|
+
)
|
|
12
|
+
from zero_3rdparty.sections import (
|
|
13
|
+
extract_sections as _extract_sections,
|
|
14
|
+
)
|
|
15
|
+
from zero_3rdparty.sections import (
|
|
16
|
+
has_sections as _has_sections,
|
|
17
|
+
)
|
|
18
|
+
from zero_3rdparty.sections import (
|
|
19
|
+
parse_sections as _parse_sections,
|
|
20
|
+
)
|
|
21
|
+
from zero_3rdparty.sections import (
|
|
22
|
+
replace_sections as _replace_sections,
|
|
23
|
+
)
|
|
24
|
+
from zero_3rdparty.sections import (
|
|
25
|
+
wrap_in_default_section as _wrap_in_default_section,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"Section",
|
|
30
|
+
"compare_sections",
|
|
31
|
+
"extract_sections",
|
|
32
|
+
"has_sections",
|
|
33
|
+
"parse_sections",
|
|
34
|
+
"replace_sections",
|
|
35
|
+
"wrap_in_default_section",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
TOOL_NAME = "path-sync"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def has_sections(content: str, path: Path) -> bool:
|
|
42
|
+
return _has_sections(content, TOOL_NAME, get_comment_config(path))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def parse_sections(content: str, path: Path) -> list[Section]:
|
|
46
|
+
return _parse_sections(content, TOOL_NAME, get_comment_config(path))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def wrap_in_default_section(content: str, path: Path) -> str:
|
|
50
|
+
return _wrap_in_default_section(content, TOOL_NAME, get_comment_config(path))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def extract_sections(content: str, path: Path) -> dict[str, str]:
|
|
54
|
+
return _extract_sections(content, TOOL_NAME, get_comment_config(path))
|
|
71
55
|
|
|
72
56
|
|
|
73
57
|
def replace_sections(
|
|
74
58
|
dest_content: str,
|
|
75
59
|
src_sections: dict[str, str],
|
|
60
|
+
path: Path,
|
|
76
61
|
skip_sections: list[str] | None = None,
|
|
77
62
|
) -> str:
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
current_section_id = start_match.group("id")
|
|
89
|
-
result.append(line)
|
|
90
|
-
elif SECTION_END_PATTERN.match(line):
|
|
91
|
-
if current_section_id:
|
|
92
|
-
should_replace = (
|
|
93
|
-
current_section_id in src_sections
|
|
94
|
-
and current_section_id not in skip
|
|
95
|
-
)
|
|
96
|
-
content = (
|
|
97
|
-
src_sections[current_section_id]
|
|
98
|
-
if should_replace
|
|
99
|
-
else dest_sections.get(current_section_id, "")
|
|
100
|
-
)
|
|
101
|
-
if content:
|
|
102
|
-
result.append(content)
|
|
103
|
-
result.append(line)
|
|
104
|
-
current_section_id = None
|
|
105
|
-
elif current_section_id is None:
|
|
106
|
-
result.append(line)
|
|
107
|
-
|
|
108
|
-
# Append new sections from source not in dest
|
|
109
|
-
for sid in src_sections:
|
|
110
|
-
if sid not in dest_ids and sid not in skip:
|
|
111
|
-
result.append(f"# === DO_NOT_EDIT: path-sync {sid} ===")
|
|
112
|
-
result.append(src_sections[sid])
|
|
113
|
-
result.append("# === OK_EDIT ===")
|
|
114
|
-
|
|
115
|
-
return "\n".join(result)
|
|
63
|
+
return _replace_sections(dest_content, src_sections, TOOL_NAME, get_comment_config(path), skip_sections)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def compare_sections(
|
|
67
|
+
baseline_content: str,
|
|
68
|
+
current_content: str,
|
|
69
|
+
path: Path,
|
|
70
|
+
skip: set[str] | None = None,
|
|
71
|
+
) -> list[str]:
|
|
72
|
+
return _compare_sections(baseline_content, current_content, TOOL_NAME, get_comment_config(path), skip)
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: path-sync
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
4
|
+
Summary: Sync files from a source repo to multiple destination repos
|
|
4
5
|
Author-email: EspenAlbert <espen.albert1@gmail.com>
|
|
5
6
|
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: cli,repository,sync,template
|
|
6
9
|
Classifier: Development Status :: 4 - Beta
|
|
7
10
|
Classifier: Programming Language :: Python :: 3.13
|
|
8
11
|
Requires-Python: >=3.13
|
|
@@ -10,6 +13,7 @@ Requires-Dist: gitpython>=3.1.0
|
|
|
10
13
|
Requires-Dist: pydantic>=2.0
|
|
11
14
|
Requires-Dist: pyyaml>=6.0
|
|
12
15
|
Requires-Dist: typer>=0.16.0
|
|
16
|
+
Requires-Dist: zero-3rdparty>=0.100.0
|
|
13
17
|
Description-Content-Type: text/markdown
|
|
14
18
|
|
|
15
19
|
# path-sync
|
|
@@ -69,7 +73,7 @@ By default, prompts before each git operation. See [Usage Scenarios](#usage-scen
|
|
|
69
73
|
| `--dry-run` | Preview without writing (requires existing repos) |
|
|
70
74
|
| `-y, --no-prompt` | Skip confirmations (for CI) |
|
|
71
75
|
| `--local` | No git ops after sync (no commit/push/PR) |
|
|
72
|
-
| `--no-checkout` | Skip branch switching
|
|
76
|
+
| `--no-checkout` | Skip branch switching (assumes already on correct branch) |
|
|
73
77
|
| `--checkout-from-default` | Reset to origin/default before sync |
|
|
74
78
|
| `--no-pr` | Push but skip PR creation |
|
|
75
79
|
| `--force-overwrite` | Overwrite files even if header removed (opted out) |
|
|
@@ -98,10 +102,12 @@ Options:
|
|
|
98
102
|
| CI fresh sync | `copy -n cfg --checkout-from-default -y` |
|
|
99
103
|
| Local preview | `copy -n cfg --dry-run` |
|
|
100
104
|
| Local test files | `copy -n cfg --local` |
|
|
101
|
-
| Already on branch | `copy -n cfg --no-checkout
|
|
105
|
+
| Already on branch | `copy -n cfg --no-checkout` |
|
|
102
106
|
| Push, manual PR | `copy -n cfg --no-pr -y` |
|
|
103
107
|
| Force opted-out | `copy -n cfg --force-overwrite` |
|
|
104
108
|
|
|
109
|
+
**Interactive prompt behavior**: Declining the checkout prompt syncs files but skips commit/push/PR (same as `--local`). Use `--no-checkout` when you're already on the correct branch and want to proceed with git operations.
|
|
110
|
+
|
|
105
111
|
## Section Markers
|
|
106
112
|
|
|
107
113
|
For partial file syncing (e.g., `justfile`, `pyproject.toml`), wrap sections with markers:
|
|
@@ -143,7 +149,7 @@ destinations:
|
|
|
143
149
|
- name: dest1
|
|
144
150
|
repo_url: https://github.com/user/dest1
|
|
145
151
|
dest_path_relative: ../dest1
|
|
146
|
-
copy_branch: sync/
|
|
152
|
+
# copy_branch: sync/cursor # defaults to sync/{config_name}
|
|
147
153
|
default_branch: main
|
|
148
154
|
skip_sections:
|
|
149
155
|
justfile: [coverage]
|
|
@@ -228,7 +234,7 @@ jobs:
|
|
|
228
234
|
```
|
|
229
235
|
|
|
230
236
|
**Validation skips automatically when:**
|
|
231
|
-
- On a `sync/*` branch (path-sync uses `sync/
|
|
237
|
+
- On a `sync/*` branch (path-sync uses `sync/{config_name}` by default)
|
|
232
238
|
- On the default branch (comparing against itself)
|
|
233
239
|
|
|
234
240
|
The workflow triggers exclude these branches too, reducing unnecessary CI runs.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
path_sync/__init__.py,sha256=S6tn88icmaqW7h_DeyDXb_Koaq9rIeUUuIyQVf31VF4,153
|
|
2
|
+
path_sync/__main__.py,sha256=HDj3qgijDcK8k976qsAvKMvUq9fZEJTK-dZldUc5-no,326
|
|
3
|
+
path_sync/config.py,sha256=XYuEK_bjSpAk_nZN0oxpEA-S3t9F6Qn0yznYLoQHIw8,568
|
|
4
|
+
path_sync/copy.py,sha256=BpflW4086XJFSHHK4taYPgXtF07xHB8qrgm8TYqdM4E,120
|
|
5
|
+
path_sync/sections.py,sha256=dMEux8wT6nu9fcPgqgoWCc7QFNOxGVx5RVwcHhEK_lw,1894
|
|
6
|
+
path_sync/_internal/__init__.py,sha256=iPkMhrpiyXBijo2Hp-y_2zEYxAXnHLnStKM0X0HHd4U,56
|
|
7
|
+
path_sync/_internal/cmd_boot.py,sha256=44IUnemVzYO_qcZsHnCG1oeQHoGIavLy-9BNKIV8Tlo,2918
|
|
8
|
+
path_sync/_internal/cmd_copy.py,sha256=Cl81112qBKRWU-99a8aaVHjl3RD4GCBYlHRNj39wems,16896
|
|
9
|
+
path_sync/_internal/cmd_validate.py,sha256=L84W5iCUxCbM04muiOvDOTdvN3HA0Ubxy8Fk_XMQf1E,1612
|
|
10
|
+
path_sync/_internal/file_utils.py,sha256=5C33qzKFQdwChi5YwUWBujj126t0P6dbGSU_5hWExpE,194
|
|
11
|
+
path_sync/_internal/git_ops.py,sha256=rpG_r7VNH1KlBgqM9mz7xop0mpdy76Vs3rzCoxE1dIQ,5895
|
|
12
|
+
path_sync/_internal/header.py,sha256=evgY2q_gfDdEytEt_jyJ7M_KdGzCpfdKBUnoh3v-0Go,2593
|
|
13
|
+
path_sync/_internal/models.py,sha256=D4qGXh4NnT01xf2odeLYULtgr245TkdjaqW26BPnxP4,3767
|
|
14
|
+
path_sync/_internal/typer_app.py,sha256=lEGMRXql3Se3VbmwAohvpUaL2cbY-RwhPUq8kL7bPbc,177
|
|
15
|
+
path_sync/_internal/validation.py,sha256=qhEha-pJiM5zkZlr4sj2I4ZqvqcWMEfL4IZu_LGatLI,2226
|
|
16
|
+
path_sync/_internal/yaml_utils.py,sha256=yj6Bl54EltjLEcVKaiA5Ahb9byT6OUMh0xIEzTsrvnQ,498
|
|
17
|
+
path_sync-0.3.1.dist-info/METADATA,sha256=dkhwUJMoG5BDWpZkZpY1rBm1qm4hBzo_rQwkqS1um_8,8231
|
|
18
|
+
path_sync-0.3.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
19
|
+
path_sync-0.3.1.dist-info/entry_points.txt,sha256=jTsL0c-9gP-4_Jt3EPgihtpLcwQR0AFAf1AUpD50AlI,54
|
|
20
|
+
path_sync-0.3.1.dist-info/licenses/LICENSE,sha256=OphKV48tcMv6ep-7j-8T6nycykPT0g8ZlMJ9zbGvdPs,1066
|
|
21
|
+
path_sync-0.3.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Your Name
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
path_sync/cmd_copy_test.py
DELETED
|
@@ -1,180 +0,0 @@
|
|
|
1
|
-
import pytest
|
|
2
|
-
|
|
3
|
-
from path_sync.cmd_copy import (
|
|
4
|
-
CopyOptions,
|
|
5
|
-
_cleanup_orphans,
|
|
6
|
-
_ensure_dest_repo,
|
|
7
|
-
_sync_path,
|
|
8
|
-
)
|
|
9
|
-
from path_sync.header import add_header, has_header
|
|
10
|
-
from path_sync.models import Destination, PathMapping
|
|
11
|
-
|
|
12
|
-
CONFIG_NAME = "test-config"
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
def _make_dest(**kwargs) -> Destination:
|
|
16
|
-
defaults = {"name": "test", "dest_path_relative": "."}
|
|
17
|
-
return Destination(**(defaults | kwargs)) # pyright: ignore[reportArgumentType]
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def test_sync_single_file(tmp_path):
|
|
21
|
-
src_root = tmp_path / "src"
|
|
22
|
-
dest_root = tmp_path / "dest"
|
|
23
|
-
src_root.mkdir()
|
|
24
|
-
dest_root.mkdir()
|
|
25
|
-
|
|
26
|
-
(src_root / "file.py").write_text("content")
|
|
27
|
-
|
|
28
|
-
mapping = PathMapping(src_path="file.py", dest_path="out.py")
|
|
29
|
-
changes, synced = _sync_path(
|
|
30
|
-
mapping, src_root, dest_root, _make_dest(), CONFIG_NAME, False, False
|
|
31
|
-
)
|
|
32
|
-
|
|
33
|
-
assert changes == 1
|
|
34
|
-
assert dest_root / "out.py" in synced
|
|
35
|
-
result = (dest_root / "out.py").read_text()
|
|
36
|
-
assert has_header(result)
|
|
37
|
-
assert f"path-sync copy -n {CONFIG_NAME}" in result
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
def test_sync_skips_opted_out_file(tmp_path):
|
|
41
|
-
src_root = tmp_path / "src"
|
|
42
|
-
dest_root = tmp_path / "dest"
|
|
43
|
-
src_root.mkdir()
|
|
44
|
-
dest_root.mkdir()
|
|
45
|
-
|
|
46
|
-
(src_root / "file.py").write_text("new content")
|
|
47
|
-
(dest_root / "file.py").write_text("local content without header")
|
|
48
|
-
|
|
49
|
-
mapping = PathMapping(src_path="file.py")
|
|
50
|
-
changes, _ = _sync_path(
|
|
51
|
-
mapping, src_root, dest_root, _make_dest(), CONFIG_NAME, False, False
|
|
52
|
-
)
|
|
53
|
-
|
|
54
|
-
assert changes == 0
|
|
55
|
-
assert (dest_root / "file.py").read_text() == "local content without header"
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
def test_force_overwrite_adds_header_when_content_matches(tmp_path):
|
|
59
|
-
src_root = tmp_path / "src"
|
|
60
|
-
dest_root = tmp_path / "dest"
|
|
61
|
-
src_root.mkdir()
|
|
62
|
-
dest_root.mkdir()
|
|
63
|
-
|
|
64
|
-
content = "same content"
|
|
65
|
-
(src_root / "file.py").write_text(content)
|
|
66
|
-
(dest_root / "file.py").write_text(content) # No header, same content
|
|
67
|
-
|
|
68
|
-
mapping = PathMapping(src_path="file.py")
|
|
69
|
-
changes, _ = _sync_path(
|
|
70
|
-
mapping, src_root, dest_root, _make_dest(), CONFIG_NAME, False, True
|
|
71
|
-
)
|
|
72
|
-
|
|
73
|
-
assert changes == 1
|
|
74
|
-
result = (dest_root / "file.py").read_text()
|
|
75
|
-
assert has_header(result)
|
|
76
|
-
assert content in result
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
def test_cleanup_orphans(tmp_path):
|
|
80
|
-
dest_root = tmp_path / "dest"
|
|
81
|
-
dest_root.mkdir()
|
|
82
|
-
|
|
83
|
-
# File with matching config header - will be orphaned
|
|
84
|
-
orphan = dest_root / "orphan.py"
|
|
85
|
-
orphan.write_text(add_header("orphan content", ".py", CONFIG_NAME))
|
|
86
|
-
|
|
87
|
-
# File with different config - should not be deleted
|
|
88
|
-
other = dest_root / "other.py"
|
|
89
|
-
other.write_text(add_header("other content", ".py", "other-config"))
|
|
90
|
-
|
|
91
|
-
synced: set = set() # No files synced
|
|
92
|
-
deleted = _cleanup_orphans(dest_root, CONFIG_NAME, synced, dry_run=False)
|
|
93
|
-
|
|
94
|
-
assert deleted == 1
|
|
95
|
-
assert not orphan.exists()
|
|
96
|
-
assert other.exists()
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
def test_sync_with_sections_replaces_managed(tmp_path):
|
|
100
|
-
src_root = tmp_path / "src"
|
|
101
|
-
dest_root = tmp_path / "dest"
|
|
102
|
-
src_root.mkdir()
|
|
103
|
-
dest_root.mkdir()
|
|
104
|
-
|
|
105
|
-
src_content = """\
|
|
106
|
-
# === DO_NOT_EDIT: path-sync standard ===
|
|
107
|
-
new recipe
|
|
108
|
-
# === OK_EDIT ==="""
|
|
109
|
-
(src_root / "file.sh").write_text(src_content)
|
|
110
|
-
|
|
111
|
-
dest_content = add_header(
|
|
112
|
-
"""\
|
|
113
|
-
# === DO_NOT_EDIT: path-sync standard ===
|
|
114
|
-
old recipe
|
|
115
|
-
# === OK_EDIT ===
|
|
116
|
-
# my custom stuff""",
|
|
117
|
-
".sh",
|
|
118
|
-
CONFIG_NAME,
|
|
119
|
-
)
|
|
120
|
-
(dest_root / "file.sh").write_text(dest_content)
|
|
121
|
-
|
|
122
|
-
mapping = PathMapping(src_path="file.sh")
|
|
123
|
-
changes, _ = _sync_path(
|
|
124
|
-
mapping, src_root, dest_root, _make_dest(), CONFIG_NAME, False, False
|
|
125
|
-
)
|
|
126
|
-
|
|
127
|
-
assert changes == 1
|
|
128
|
-
result = (dest_root / "file.sh").read_text()
|
|
129
|
-
assert "new recipe" in result
|
|
130
|
-
assert "old recipe" not in result
|
|
131
|
-
assert "# my custom stuff" in result
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
def test_sync_with_sections_skip(tmp_path):
|
|
135
|
-
src_root = tmp_path / "src"
|
|
136
|
-
dest_root = tmp_path / "dest"
|
|
137
|
-
src_root.mkdir()
|
|
138
|
-
dest_root.mkdir()
|
|
139
|
-
|
|
140
|
-
src_content = """\
|
|
141
|
-
# === DO_NOT_EDIT: path-sync standard ===
|
|
142
|
-
source
|
|
143
|
-
# === OK_EDIT ==="""
|
|
144
|
-
(src_root / "file.sh").write_text(src_content)
|
|
145
|
-
|
|
146
|
-
dest_content = add_header(
|
|
147
|
-
"""\
|
|
148
|
-
# === DO_NOT_EDIT: path-sync standard ===
|
|
149
|
-
keep this
|
|
150
|
-
# === OK_EDIT ===""",
|
|
151
|
-
".sh",
|
|
152
|
-
CONFIG_NAME,
|
|
153
|
-
)
|
|
154
|
-
(dest_root / "file.sh").write_text(dest_content)
|
|
155
|
-
|
|
156
|
-
dest = _make_dest(skip_sections={"file.sh": ["standard"]})
|
|
157
|
-
mapping = PathMapping(src_path="file.sh")
|
|
158
|
-
changes, _ = _sync_path(
|
|
159
|
-
mapping, src_root, dest_root, dest, CONFIG_NAME, False, False
|
|
160
|
-
)
|
|
161
|
-
|
|
162
|
-
assert changes == 0
|
|
163
|
-
assert "keep this" in (dest_root / "file.sh").read_text()
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
def test_ensure_dest_repo_dry_run_errors_if_missing(tmp_path):
|
|
167
|
-
dest = _make_dest()
|
|
168
|
-
dest_root = tmp_path / "missing_repo"
|
|
169
|
-
with pytest.raises(ValueError, match="Destination repo not found"):
|
|
170
|
-
_ensure_dest_repo(dest, dest_root, dry_run=True)
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
def test_copy_options_defaults():
|
|
174
|
-
opts = CopyOptions()
|
|
175
|
-
assert not opts.dry_run
|
|
176
|
-
assert not opts.force_overwrite
|
|
177
|
-
assert not opts.no_checkout
|
|
178
|
-
assert not opts.local
|
|
179
|
-
assert not opts.no_prompt
|
|
180
|
-
assert not opts.no_pr
|
path_sync/conftest.py
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
import pytest
|
|
2
|
-
from git import Repo
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
@pytest.fixture
|
|
6
|
-
def tmp_repo(tmp_path):
|
|
7
|
-
"""Create a temporary git repo."""
|
|
8
|
-
|
|
9
|
-
repo_path = tmp_path / "repo"
|
|
10
|
-
repo_path.mkdir()
|
|
11
|
-
repo = Repo.init(repo_path)
|
|
12
|
-
(repo_path / ".gitkeep").write_text("")
|
|
13
|
-
repo.index.add([".gitkeep"])
|
|
14
|
-
repo.index.commit("Initial commit")
|
|
15
|
-
return repo_path
|
path_sync/header.py
DELETED
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import re
|
|
4
|
-
from pathlib import Path
|
|
5
|
-
|
|
6
|
-
from path_sync.models import (
|
|
7
|
-
DEFAULT_COMMENT_PREFIXES,
|
|
8
|
-
DEFAULT_COMMENT_SUFFIXES,
|
|
9
|
-
HEADER_TEMPLATE,
|
|
10
|
-
HeaderConfig,
|
|
11
|
-
)
|
|
12
|
-
|
|
13
|
-
COMMENT_PREFIXES = DEFAULT_COMMENT_PREFIXES
|
|
14
|
-
|
|
15
|
-
HEADER_PATTERN = re.compile(r"path-sync copy -n (?P<config_name>[\w-]+)")
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def get_header_line(
|
|
19
|
-
extension: str,
|
|
20
|
-
config_name: str,
|
|
21
|
-
config: HeaderConfig | None = None,
|
|
22
|
-
) -> str:
|
|
23
|
-
if config:
|
|
24
|
-
prefix = config.comment_prefixes.get(extension, "")
|
|
25
|
-
suffix = config.comment_suffixes.get(extension, "")
|
|
26
|
-
else:
|
|
27
|
-
prefix = DEFAULT_COMMENT_PREFIXES.get(extension, "")
|
|
28
|
-
suffix = DEFAULT_COMMENT_SUFFIXES.get(extension, "")
|
|
29
|
-
|
|
30
|
-
if not prefix:
|
|
31
|
-
raise ValueError(f"No comment prefix found for extension: {extension}")
|
|
32
|
-
header_text = HEADER_TEMPLATE.format(config_name=config_name)
|
|
33
|
-
return f"{prefix} {header_text}{suffix}"
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
def has_header(content: str) -> bool:
|
|
37
|
-
first_line = content.split("\n", 1)[0] if content else ""
|
|
38
|
-
return bool(HEADER_PATTERN.search(first_line))
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
def get_config_name(content: str) -> str | None:
|
|
42
|
-
first_line = content.split("\n", 1)[0] if content else ""
|
|
43
|
-
if match := HEADER_PATTERN.search(first_line):
|
|
44
|
-
return match.group("config_name")
|
|
45
|
-
return None
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
def add_header(
|
|
49
|
-
content: str,
|
|
50
|
-
extension: str,
|
|
51
|
-
config_name: str,
|
|
52
|
-
config: HeaderConfig | None = None,
|
|
53
|
-
) -> str:
|
|
54
|
-
header = get_header_line(extension, config_name, config)
|
|
55
|
-
return f"{header}\n{content}"
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
def remove_header(content: str) -> str:
|
|
59
|
-
if not has_header(content):
|
|
60
|
-
return content
|
|
61
|
-
lines = content.split("\n", 1)
|
|
62
|
-
return lines[1] if len(lines) > 1 else ""
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
def file_get_config_name(path: Path) -> str | None:
|
|
66
|
-
"""Read first line and extract config name if present."""
|
|
67
|
-
if not path.exists() or path.suffix not in DEFAULT_COMMENT_PREFIXES:
|
|
68
|
-
return None
|
|
69
|
-
try:
|
|
70
|
-
with path.open() as f:
|
|
71
|
-
first_line = f.readline()
|
|
72
|
-
except (UnicodeDecodeError, OSError):
|
|
73
|
-
return None
|
|
74
|
-
return get_config_name(first_line)
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
def file_has_header(path: Path, config: HeaderConfig | None = None) -> bool:
|
|
78
|
-
if config and path.suffix not in config.comment_prefixes:
|
|
79
|
-
return False
|
|
80
|
-
return file_get_config_name(path) is not None
|
path_sync/header_test.py
DELETED
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
from path_sync import header
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
def test_header_generation():
|
|
5
|
-
assert header.get_header_line(".py", "my-config") == "# path-sync copy -n my-config"
|
|
6
|
-
assert (
|
|
7
|
-
header.get_header_line(".go", "my-config") == "// path-sync copy -n my-config"
|
|
8
|
-
)
|
|
9
|
-
assert (
|
|
10
|
-
header.get_header_line(".md", "my-config")
|
|
11
|
-
== "<!-- path-sync copy -n my-config -->"
|
|
12
|
-
)
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
def test_has_header_matches_any_config_name():
|
|
16
|
-
assert header.has_header("# path-sync copy -n my-config\ncode")
|
|
17
|
-
assert header.has_header("# path-sync copy -n other_name\ncode")
|
|
18
|
-
assert not header.has_header("# DO NOT EDIT: path-sync destination file\ncode")
|
|
19
|
-
assert not header.has_header("print('hello')")
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
def test_add_remove_header():
|
|
23
|
-
content = "print('hello')"
|
|
24
|
-
with_header = header.add_header(content, ".py", "test-config")
|
|
25
|
-
assert header.has_header(with_header)
|
|
26
|
-
without = header.remove_header(with_header)
|
|
27
|
-
assert without == content
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
def test_file_has_header(tmp_path):
|
|
31
|
-
py_file = tmp_path / "test.py"
|
|
32
|
-
py_file.write_text(header.add_header("content", ".py", "my-config"))
|
|
33
|
-
assert header.file_has_header(py_file)
|
|
34
|
-
|
|
35
|
-
no_header = tmp_path / "plain.py"
|
|
36
|
-
no_header.write_text("content")
|
|
37
|
-
assert not header.file_has_header(no_header)
|
|
38
|
-
|
|
39
|
-
unsupported = tmp_path / "data.whl"
|
|
40
|
-
unsupported.write_bytes(b"\x00\x01\x02\x03")
|
|
41
|
-
assert not header.file_has_header(unsupported)
|