path-sync 0.3.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.3.0/.gitignore +39 -0
- path_sync-0.3.0/LICENSE +21 -0
- path_sync-0.3.0/PKG-INFO +277 -0
- path_sync-0.3.0/README.md +259 -0
- path_sync-0.3.0/path_sync/__init__.py +10 -0
- path_sync-0.3.0/path_sync/__main__.py +14 -0
- path_sync-0.3.0/path_sync/config.py +14 -0
- path_sync-0.3.0/path_sync/copy.py +4 -0
- path_sync-0.3.0/path_sync/sections.py +72 -0
- path_sync-0.3.0/pyproject.toml +100 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# path-sync copy -n python-template
|
|
2
|
+
|
|
3
|
+
# === DO_NOT_EDIT: path-sync gitignore ===
|
|
4
|
+
# Python bytecode
|
|
5
|
+
__pycache__/
|
|
6
|
+
*.pyc
|
|
7
|
+
*.pyo
|
|
8
|
+
|
|
9
|
+
# Virtual environments
|
|
10
|
+
.venv/
|
|
11
|
+
venv/
|
|
12
|
+
|
|
13
|
+
# Build artifacts
|
|
14
|
+
dist/
|
|
15
|
+
build/
|
|
16
|
+
*.egg-info/
|
|
17
|
+
|
|
18
|
+
# Coverage
|
|
19
|
+
.coverage
|
|
20
|
+
htmlcov/
|
|
21
|
+
coverage.xml
|
|
22
|
+
|
|
23
|
+
# Cache directories
|
|
24
|
+
.ruff_cache/
|
|
25
|
+
.pytest_cache/
|
|
26
|
+
.mypy_cache/
|
|
27
|
+
|
|
28
|
+
# IDE
|
|
29
|
+
.idea/
|
|
30
|
+
*.iml
|
|
31
|
+
.vscode/
|
|
32
|
+
|
|
33
|
+
# pkg-ext dev mode files
|
|
34
|
+
.groups-dev.yaml
|
|
35
|
+
CHANGELOG-dev.md
|
|
36
|
+
|
|
37
|
+
# MkDocs build output
|
|
38
|
+
site/
|
|
39
|
+
# === OK_EDIT: path-sync gitignore ===
|
path_sync-0.3.0/LICENSE
ADDED
|
@@ -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-0.3.0/PKG-INFO
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: path-sync
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Sync files from a source repo to multiple destination repos
|
|
5
|
+
Author-email: EspenAlbert <espen.albert1@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: cli,repository,sync,template
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
11
|
+
Requires-Python: >=3.13
|
|
12
|
+
Requires-Dist: gitpython>=3.1.0
|
|
13
|
+
Requires-Dist: pydantic>=2.0
|
|
14
|
+
Requires-Dist: pyyaml>=6.0
|
|
15
|
+
Requires-Dist: typer>=0.16.0
|
|
16
|
+
Requires-Dist: zero-3rdparty>=0.100.0
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# path-sync
|
|
20
|
+
|
|
21
|
+
Sync files from a source repo to multiple destination repos.
|
|
22
|
+
|
|
23
|
+
## Overview
|
|
24
|
+
|
|
25
|
+
**Problem**: You have shared config files (linter rules, CI templates, editor settings) that should be consistent across multiple repositories. Manual copying leads to drift.
|
|
26
|
+
|
|
27
|
+
**Solution**: path-sync provides one-way file syncing with clear ownership:
|
|
28
|
+
|
|
29
|
+
| Term | Definition |
|
|
30
|
+
|------|------------|
|
|
31
|
+
| **SRC** | Source repository containing the canonical files |
|
|
32
|
+
| **DEST** | Destination repository receiving synced files |
|
|
33
|
+
| **Header** | Comment added to synced files marking them as managed |
|
|
34
|
+
| **Section** | Marked region within a file for partial syncing |
|
|
35
|
+
|
|
36
|
+
**Key behaviors**:
|
|
37
|
+
- SRC owns synced content; DEST should not edit it
|
|
38
|
+
- Files with headers are updated on each sync
|
|
39
|
+
- Remove a header to opt-out (file becomes DEST-owned)
|
|
40
|
+
- Orphaned files (removed from SRC) are deleted in DEST
|
|
41
|
+
|
|
42
|
+
## Installation
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
# From PyPI
|
|
46
|
+
uvx path-sync --help
|
|
47
|
+
|
|
48
|
+
# Or install in project
|
|
49
|
+
uv pip install path-sync
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Quick Start
|
|
53
|
+
|
|
54
|
+
### 1. Bootstrap a source config
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
path-sync boot -n myconfig -d ../dest-repo1 -d ../dest-repo2 -p '.cursor/**/*.mdc'
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Creates `.github/myconfig.src.yaml` with auto-detected git remote and destinations.
|
|
61
|
+
|
|
62
|
+
### 2. Copy files to destinations
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
path-sync copy -n myconfig
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
By default, prompts before each git operation. See [Usage Scenarios](#usage-scenarios) for common patterns.
|
|
69
|
+
|
|
70
|
+
| Flag | Description |
|
|
71
|
+
|------|-------------|
|
|
72
|
+
| `-d dest1,dest2` | Filter specific destinations |
|
|
73
|
+
| `--dry-run` | Preview without writing (requires existing repos) |
|
|
74
|
+
| `-y, --no-prompt` | Skip confirmations (for CI) |
|
|
75
|
+
| `--local` | No git ops after sync (no commit/push/PR) |
|
|
76
|
+
| `--no-checkout` | Skip branch switching (assumes already on correct branch) |
|
|
77
|
+
| `--checkout-from-default` | Reset to origin/default before sync |
|
|
78
|
+
| `--no-pr` | Push but skip PR creation |
|
|
79
|
+
| `--force-overwrite` | Overwrite files even if header removed (opted out) |
|
|
80
|
+
| `--detailed-exit-code` | Exit 0=no changes, 1=changes, 2=error |
|
|
81
|
+
| `--skip-orphan-cleanup` | Skip deletion of orphaned synced files |
|
|
82
|
+
| `--pr-title` | Override PR title (supports `{name}`, `{dest_name}`) |
|
|
83
|
+
| `--pr-labels` | Comma-separated PR labels |
|
|
84
|
+
| `--pr-reviewers` | Comma-separated PR reviewers |
|
|
85
|
+
| `--pr-assignees` | Comma-separated PR assignees |
|
|
86
|
+
|
|
87
|
+
### 3. Validate (run in dest repo)
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
uvx path-sync validate-no-changes -b main
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Options:
|
|
94
|
+
- `-b, --branch` - Default branch to compare against (default: main)
|
|
95
|
+
- `--skip-sections` - Comma-separated `path:section_id` pairs to skip (e.g., `justfile:coverage`)
|
|
96
|
+
|
|
97
|
+
## Usage Scenarios
|
|
98
|
+
|
|
99
|
+
| Scenario | Command |
|
|
100
|
+
|----------|---------|
|
|
101
|
+
| Interactive sync | `copy -n cfg` |
|
|
102
|
+
| CI fresh sync | `copy -n cfg --checkout-from-default -y` |
|
|
103
|
+
| Local preview | `copy -n cfg --dry-run` |
|
|
104
|
+
| Local test files | `copy -n cfg --local` |
|
|
105
|
+
| Already on branch | `copy -n cfg --no-checkout` |
|
|
106
|
+
| Push, manual PR | `copy -n cfg --no-pr -y` |
|
|
107
|
+
| Force opted-out | `copy -n cfg --force-overwrite` |
|
|
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
|
+
|
|
111
|
+
## Section Markers
|
|
112
|
+
|
|
113
|
+
For partial file syncing (e.g., `justfile`, `pyproject.toml`), wrap sections with markers:
|
|
114
|
+
|
|
115
|
+
```makefile
|
|
116
|
+
# === DO_NOT_EDIT: path-sync default ===
|
|
117
|
+
lint:
|
|
118
|
+
ruff check .
|
|
119
|
+
# === OK_EDIT ===
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
- **`DO_NOT_EDIT: path-sync {id}`** - Start of managed section with identifier
|
|
123
|
+
- **`OK_EDIT`** - End marker (content below is editable)
|
|
124
|
+
|
|
125
|
+
During sync, only content within markers is replaced. Destination can have extra sections.
|
|
126
|
+
|
|
127
|
+
Use `skip_sections` in destination config to exclude specific sections from sync:
|
|
128
|
+
|
|
129
|
+
```yaml
|
|
130
|
+
destinations:
|
|
131
|
+
- name: dest1
|
|
132
|
+
dest_path_relative: ../dest1
|
|
133
|
+
skip_sections:
|
|
134
|
+
justfile: [coverage] # keep local coverage recipe
|
|
135
|
+
```
|
|
136
|
+
## Config Reference
|
|
137
|
+
|
|
138
|
+
**Source config** (`.github/{name}.src.yaml`):
|
|
139
|
+
|
|
140
|
+
```yaml
|
|
141
|
+
name: cursor
|
|
142
|
+
src_repo_url: https://github.com/user/src-repo
|
|
143
|
+
schedule: "0 6 * * *"
|
|
144
|
+
paths:
|
|
145
|
+
- src_path: .cursor/**/*.mdc
|
|
146
|
+
- src_path: templates/justfile
|
|
147
|
+
dest_path: justfile
|
|
148
|
+
destinations:
|
|
149
|
+
- name: dest1
|
|
150
|
+
repo_url: https://github.com/user/dest1
|
|
151
|
+
dest_path_relative: ../dest1
|
|
152
|
+
# copy_branch: sync/cursor # defaults to sync/{config_name}
|
|
153
|
+
default_branch: main
|
|
154
|
+
skip_sections:
|
|
155
|
+
justfile: [coverage]
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
| Field | Description |
|
|
159
|
+
|-------|-------------|
|
|
160
|
+
| `name` | Config identifier |
|
|
161
|
+
| `src_repo_url` | Source repo URL (auto-detected from git remote) |
|
|
162
|
+
| `schedule` | Cron for scheduled sync workflow |
|
|
163
|
+
| `paths` | Files/globs to sync (`src_path` required, `dest_path` optional) |
|
|
164
|
+
| `destinations` | Target repos with sync settings |
|
|
165
|
+
| `header_config` | Comment style per extension (has defaults) |
|
|
166
|
+
| `pr_defaults` | PR title, labels, reviewers, assignees |
|
|
167
|
+
|
|
168
|
+
## Header Format
|
|
169
|
+
|
|
170
|
+
Synced files have a header comment identifying the source config:
|
|
171
|
+
|
|
172
|
+
```python
|
|
173
|
+
# path-sync copy -n myconfig
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Comment style is extension-aware:
|
|
177
|
+
|
|
178
|
+
| Extension | Format |
|
|
179
|
+
|-----------|--------|
|
|
180
|
+
| `.py`, `.sh`, `.yaml` | `# path-sync copy -n {name}` |
|
|
181
|
+
| `.go`, `.js`, `.ts` | `// path-sync copy -n {name}` |
|
|
182
|
+
| `.md`, `.mdc`, `.html` | `<!-- path-sync copy -n {name} -->` |
|
|
183
|
+
|
|
184
|
+
Remove this header to opt-out of future syncs for that file.
|
|
185
|
+
|
|
186
|
+
## GitHub Actions
|
|
187
|
+
|
|
188
|
+
### Source repo workflow
|
|
189
|
+
|
|
190
|
+
Create `.github/workflows/path_sync_copy.yaml`:
|
|
191
|
+
|
|
192
|
+
```yaml
|
|
193
|
+
name: path-sync copy
|
|
194
|
+
on:
|
|
195
|
+
schedule:
|
|
196
|
+
- cron: "0 6 * * *"
|
|
197
|
+
workflow_dispatch:
|
|
198
|
+
|
|
199
|
+
jobs:
|
|
200
|
+
sync:
|
|
201
|
+
runs-on: ubuntu-latest
|
|
202
|
+
steps:
|
|
203
|
+
- uses: actions/checkout@v4
|
|
204
|
+
- uses: astral-sh/setup-uv@v5
|
|
205
|
+
- run: uvx path-sync copy -n myconfig --checkout-from-default -y
|
|
206
|
+
env:
|
|
207
|
+
GH_TOKEN: ${{ secrets.GH_PAT }}
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
### Destination repo validation
|
|
211
|
+
|
|
212
|
+
Create `.github/workflows/path_sync_validate.yaml`:
|
|
213
|
+
|
|
214
|
+
```yaml
|
|
215
|
+
name: path-sync validate
|
|
216
|
+
on:
|
|
217
|
+
push:
|
|
218
|
+
branches-ignore:
|
|
219
|
+
- main
|
|
220
|
+
- sync/**
|
|
221
|
+
pull_request:
|
|
222
|
+
branches:
|
|
223
|
+
- main
|
|
224
|
+
|
|
225
|
+
jobs:
|
|
226
|
+
validate:
|
|
227
|
+
runs-on: ubuntu-latest
|
|
228
|
+
steps:
|
|
229
|
+
- uses: actions/checkout@v4
|
|
230
|
+
with:
|
|
231
|
+
fetch-depth: 0
|
|
232
|
+
- uses: astral-sh/setup-uv@v5
|
|
233
|
+
- run: uvx path-sync validate-no-changes -b main
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
**Validation skips automatically when:**
|
|
237
|
+
- On a `sync/*` branch (path-sync uses `sync/{config_name}` by default)
|
|
238
|
+
- On the default branch (comparing against itself)
|
|
239
|
+
|
|
240
|
+
The workflow triggers exclude these branches too, reducing unnecessary CI runs.
|
|
241
|
+
|
|
242
|
+
### PAT Requirements
|
|
243
|
+
|
|
244
|
+
Create a **Fine-grained PAT** at <https://github.com/settings/tokens?type=beta>
|
|
245
|
+
|
|
246
|
+
| Permission | Scope |
|
|
247
|
+
|------------|-------|
|
|
248
|
+
| Contents | Read/write (push branches) |
|
|
249
|
+
| Pull requests | Read/write (create PRs) |
|
|
250
|
+
| Workflows | Read/write (if syncing `.github/workflows/`) |
|
|
251
|
+
| Metadata | Read (always required) |
|
|
252
|
+
|
|
253
|
+
Add as repository secret: `GH_PAT`
|
|
254
|
+
|
|
255
|
+
### Common Errors
|
|
256
|
+
|
|
257
|
+
| Error | Fix |
|
|
258
|
+
|-------|-----|
|
|
259
|
+
| `HTTP 404: Not Found` | Add repo to PAT's repository access |
|
|
260
|
+
| `HTTP 403: Resource not accessible` | Add Contents + Pull requests permissions |
|
|
261
|
+
| `GraphQL: Resource not accessible` | Use GH_PAT, not GITHUB_TOKEN |
|
|
262
|
+
| `HTTP 422: Required status check` | Exclude `sync/*` from branch protection |
|
|
263
|
+
|
|
264
|
+
## Alternatives Considered
|
|
265
|
+
|
|
266
|
+
| Tool | Why Not |
|
|
267
|
+
|------|---------|
|
|
268
|
+
| [repo-file-sync-action](https://github.com/BetaHuhn/repo-file-sync-action) | No local CLI, no validation |
|
|
269
|
+
| [Copier](https://copier.readthedocs.io/) | Merge-based (conflicts), no multi-dest |
|
|
270
|
+
| [Cruft](https://cruft.github.io/cruft/) | Patch-based, single dest |
|
|
271
|
+
|
|
272
|
+
**Why path-sync:**
|
|
273
|
+
- One SRC to many DEST repos
|
|
274
|
+
- Local CLI + CI support
|
|
275
|
+
- Section-level sync for shared files
|
|
276
|
+
- Validation enforced across repos
|
|
277
|
+
- Clear ownership (no merge conflicts)
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
# path-sync
|
|
2
|
+
|
|
3
|
+
Sync files from a source repo to multiple destination repos.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
**Problem**: You have shared config files (linter rules, CI templates, editor settings) that should be consistent across multiple repositories. Manual copying leads to drift.
|
|
8
|
+
|
|
9
|
+
**Solution**: path-sync provides one-way file syncing with clear ownership:
|
|
10
|
+
|
|
11
|
+
| Term | Definition |
|
|
12
|
+
|------|------------|
|
|
13
|
+
| **SRC** | Source repository containing the canonical files |
|
|
14
|
+
| **DEST** | Destination repository receiving synced files |
|
|
15
|
+
| **Header** | Comment added to synced files marking them as managed |
|
|
16
|
+
| **Section** | Marked region within a file for partial syncing |
|
|
17
|
+
|
|
18
|
+
**Key behaviors**:
|
|
19
|
+
- SRC owns synced content; DEST should not edit it
|
|
20
|
+
- Files with headers are updated on each sync
|
|
21
|
+
- Remove a header to opt-out (file becomes DEST-owned)
|
|
22
|
+
- Orphaned files (removed from SRC) are deleted in DEST
|
|
23
|
+
|
|
24
|
+
## Installation
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
# From PyPI
|
|
28
|
+
uvx path-sync --help
|
|
29
|
+
|
|
30
|
+
# Or install in project
|
|
31
|
+
uv pip install path-sync
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Quick Start
|
|
35
|
+
|
|
36
|
+
### 1. Bootstrap a source config
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
path-sync boot -n myconfig -d ../dest-repo1 -d ../dest-repo2 -p '.cursor/**/*.mdc'
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Creates `.github/myconfig.src.yaml` with auto-detected git remote and destinations.
|
|
43
|
+
|
|
44
|
+
### 2. Copy files to destinations
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
path-sync copy -n myconfig
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
By default, prompts before each git operation. See [Usage Scenarios](#usage-scenarios) for common patterns.
|
|
51
|
+
|
|
52
|
+
| Flag | Description |
|
|
53
|
+
|------|-------------|
|
|
54
|
+
| `-d dest1,dest2` | Filter specific destinations |
|
|
55
|
+
| `--dry-run` | Preview without writing (requires existing repos) |
|
|
56
|
+
| `-y, --no-prompt` | Skip confirmations (for CI) |
|
|
57
|
+
| `--local` | No git ops after sync (no commit/push/PR) |
|
|
58
|
+
| `--no-checkout` | Skip branch switching (assumes already on correct branch) |
|
|
59
|
+
| `--checkout-from-default` | Reset to origin/default before sync |
|
|
60
|
+
| `--no-pr` | Push but skip PR creation |
|
|
61
|
+
| `--force-overwrite` | Overwrite files even if header removed (opted out) |
|
|
62
|
+
| `--detailed-exit-code` | Exit 0=no changes, 1=changes, 2=error |
|
|
63
|
+
| `--skip-orphan-cleanup` | Skip deletion of orphaned synced files |
|
|
64
|
+
| `--pr-title` | Override PR title (supports `{name}`, `{dest_name}`) |
|
|
65
|
+
| `--pr-labels` | Comma-separated PR labels |
|
|
66
|
+
| `--pr-reviewers` | Comma-separated PR reviewers |
|
|
67
|
+
| `--pr-assignees` | Comma-separated PR assignees |
|
|
68
|
+
|
|
69
|
+
### 3. Validate (run in dest repo)
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
uvx path-sync validate-no-changes -b main
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Options:
|
|
76
|
+
- `-b, --branch` - Default branch to compare against (default: main)
|
|
77
|
+
- `--skip-sections` - Comma-separated `path:section_id` pairs to skip (e.g., `justfile:coverage`)
|
|
78
|
+
|
|
79
|
+
## Usage Scenarios
|
|
80
|
+
|
|
81
|
+
| Scenario | Command |
|
|
82
|
+
|----------|---------|
|
|
83
|
+
| Interactive sync | `copy -n cfg` |
|
|
84
|
+
| CI fresh sync | `copy -n cfg --checkout-from-default -y` |
|
|
85
|
+
| Local preview | `copy -n cfg --dry-run` |
|
|
86
|
+
| Local test files | `copy -n cfg --local` |
|
|
87
|
+
| Already on branch | `copy -n cfg --no-checkout` |
|
|
88
|
+
| Push, manual PR | `copy -n cfg --no-pr -y` |
|
|
89
|
+
| Force opted-out | `copy -n cfg --force-overwrite` |
|
|
90
|
+
|
|
91
|
+
**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.
|
|
92
|
+
|
|
93
|
+
## Section Markers
|
|
94
|
+
|
|
95
|
+
For partial file syncing (e.g., `justfile`, `pyproject.toml`), wrap sections with markers:
|
|
96
|
+
|
|
97
|
+
```makefile
|
|
98
|
+
# === DO_NOT_EDIT: path-sync default ===
|
|
99
|
+
lint:
|
|
100
|
+
ruff check .
|
|
101
|
+
# === OK_EDIT ===
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
- **`DO_NOT_EDIT: path-sync {id}`** - Start of managed section with identifier
|
|
105
|
+
- **`OK_EDIT`** - End marker (content below is editable)
|
|
106
|
+
|
|
107
|
+
During sync, only content within markers is replaced. Destination can have extra sections.
|
|
108
|
+
|
|
109
|
+
Use `skip_sections` in destination config to exclude specific sections from sync:
|
|
110
|
+
|
|
111
|
+
```yaml
|
|
112
|
+
destinations:
|
|
113
|
+
- name: dest1
|
|
114
|
+
dest_path_relative: ../dest1
|
|
115
|
+
skip_sections:
|
|
116
|
+
justfile: [coverage] # keep local coverage recipe
|
|
117
|
+
```
|
|
118
|
+
## Config Reference
|
|
119
|
+
|
|
120
|
+
**Source config** (`.github/{name}.src.yaml`):
|
|
121
|
+
|
|
122
|
+
```yaml
|
|
123
|
+
name: cursor
|
|
124
|
+
src_repo_url: https://github.com/user/src-repo
|
|
125
|
+
schedule: "0 6 * * *"
|
|
126
|
+
paths:
|
|
127
|
+
- src_path: .cursor/**/*.mdc
|
|
128
|
+
- src_path: templates/justfile
|
|
129
|
+
dest_path: justfile
|
|
130
|
+
destinations:
|
|
131
|
+
- name: dest1
|
|
132
|
+
repo_url: https://github.com/user/dest1
|
|
133
|
+
dest_path_relative: ../dest1
|
|
134
|
+
# copy_branch: sync/cursor # defaults to sync/{config_name}
|
|
135
|
+
default_branch: main
|
|
136
|
+
skip_sections:
|
|
137
|
+
justfile: [coverage]
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
| Field | Description |
|
|
141
|
+
|-------|-------------|
|
|
142
|
+
| `name` | Config identifier |
|
|
143
|
+
| `src_repo_url` | Source repo URL (auto-detected from git remote) |
|
|
144
|
+
| `schedule` | Cron for scheduled sync workflow |
|
|
145
|
+
| `paths` | Files/globs to sync (`src_path` required, `dest_path` optional) |
|
|
146
|
+
| `destinations` | Target repos with sync settings |
|
|
147
|
+
| `header_config` | Comment style per extension (has defaults) |
|
|
148
|
+
| `pr_defaults` | PR title, labels, reviewers, assignees |
|
|
149
|
+
|
|
150
|
+
## Header Format
|
|
151
|
+
|
|
152
|
+
Synced files have a header comment identifying the source config:
|
|
153
|
+
|
|
154
|
+
```python
|
|
155
|
+
# path-sync copy -n myconfig
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Comment style is extension-aware:
|
|
159
|
+
|
|
160
|
+
| Extension | Format |
|
|
161
|
+
|-----------|--------|
|
|
162
|
+
| `.py`, `.sh`, `.yaml` | `# path-sync copy -n {name}` |
|
|
163
|
+
| `.go`, `.js`, `.ts` | `// path-sync copy -n {name}` |
|
|
164
|
+
| `.md`, `.mdc`, `.html` | `<!-- path-sync copy -n {name} -->` |
|
|
165
|
+
|
|
166
|
+
Remove this header to opt-out of future syncs for that file.
|
|
167
|
+
|
|
168
|
+
## GitHub Actions
|
|
169
|
+
|
|
170
|
+
### Source repo workflow
|
|
171
|
+
|
|
172
|
+
Create `.github/workflows/path_sync_copy.yaml`:
|
|
173
|
+
|
|
174
|
+
```yaml
|
|
175
|
+
name: path-sync copy
|
|
176
|
+
on:
|
|
177
|
+
schedule:
|
|
178
|
+
- cron: "0 6 * * *"
|
|
179
|
+
workflow_dispatch:
|
|
180
|
+
|
|
181
|
+
jobs:
|
|
182
|
+
sync:
|
|
183
|
+
runs-on: ubuntu-latest
|
|
184
|
+
steps:
|
|
185
|
+
- uses: actions/checkout@v4
|
|
186
|
+
- uses: astral-sh/setup-uv@v5
|
|
187
|
+
- run: uvx path-sync copy -n myconfig --checkout-from-default -y
|
|
188
|
+
env:
|
|
189
|
+
GH_TOKEN: ${{ secrets.GH_PAT }}
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
### Destination repo validation
|
|
193
|
+
|
|
194
|
+
Create `.github/workflows/path_sync_validate.yaml`:
|
|
195
|
+
|
|
196
|
+
```yaml
|
|
197
|
+
name: path-sync validate
|
|
198
|
+
on:
|
|
199
|
+
push:
|
|
200
|
+
branches-ignore:
|
|
201
|
+
- main
|
|
202
|
+
- sync/**
|
|
203
|
+
pull_request:
|
|
204
|
+
branches:
|
|
205
|
+
- main
|
|
206
|
+
|
|
207
|
+
jobs:
|
|
208
|
+
validate:
|
|
209
|
+
runs-on: ubuntu-latest
|
|
210
|
+
steps:
|
|
211
|
+
- uses: actions/checkout@v4
|
|
212
|
+
with:
|
|
213
|
+
fetch-depth: 0
|
|
214
|
+
- uses: astral-sh/setup-uv@v5
|
|
215
|
+
- run: uvx path-sync validate-no-changes -b main
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
**Validation skips automatically when:**
|
|
219
|
+
- On a `sync/*` branch (path-sync uses `sync/{config_name}` by default)
|
|
220
|
+
- On the default branch (comparing against itself)
|
|
221
|
+
|
|
222
|
+
The workflow triggers exclude these branches too, reducing unnecessary CI runs.
|
|
223
|
+
|
|
224
|
+
### PAT Requirements
|
|
225
|
+
|
|
226
|
+
Create a **Fine-grained PAT** at <https://github.com/settings/tokens?type=beta>
|
|
227
|
+
|
|
228
|
+
| Permission | Scope |
|
|
229
|
+
|------------|-------|
|
|
230
|
+
| Contents | Read/write (push branches) |
|
|
231
|
+
| Pull requests | Read/write (create PRs) |
|
|
232
|
+
| Workflows | Read/write (if syncing `.github/workflows/`) |
|
|
233
|
+
| Metadata | Read (always required) |
|
|
234
|
+
|
|
235
|
+
Add as repository secret: `GH_PAT`
|
|
236
|
+
|
|
237
|
+
### Common Errors
|
|
238
|
+
|
|
239
|
+
| Error | Fix |
|
|
240
|
+
|-------|-----|
|
|
241
|
+
| `HTTP 404: Not Found` | Add repo to PAT's repository access |
|
|
242
|
+
| `HTTP 403: Resource not accessible` | Add Contents + Pull requests permissions |
|
|
243
|
+
| `GraphQL: Resource not accessible` | Use GH_PAT, not GITHUB_TOKEN |
|
|
244
|
+
| `HTTP 422: Required status check` | Exclude `sync/*` from branch protection |
|
|
245
|
+
|
|
246
|
+
## Alternatives Considered
|
|
247
|
+
|
|
248
|
+
| Tool | Why Not |
|
|
249
|
+
|------|---------|
|
|
250
|
+
| [repo-file-sync-action](https://github.com/BetaHuhn/repo-file-sync-action) | No local CLI, no validation |
|
|
251
|
+
| [Copier](https://copier.readthedocs.io/) | Merge-based (conflicts), no multi-dest |
|
|
252
|
+
| [Cruft](https://cruft.github.io/cruft/) | Patch-based, single dest |
|
|
253
|
+
|
|
254
|
+
**Why path-sync:**
|
|
255
|
+
- One SRC to many DEST repos
|
|
256
|
+
- Local CLI + CI support
|
|
257
|
+
- Section-level sync for shared files
|
|
258
|
+
- Validation enforced across repos
|
|
259
|
+
- Clear ownership (no merge conflicts)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
from path_sync._internal import cmd_boot, cmd_copy, cmd_validate # noqa: F401
|
|
4
|
+
from path_sync._internal.models import LOG_FORMAT
|
|
5
|
+
from path_sync._internal.typer_app import app
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def main() -> None:
|
|
9
|
+
logging.basicConfig(level=logging.INFO, format=LOG_FORMAT)
|
|
10
|
+
app()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
if __name__ == "__main__":
|
|
14
|
+
main()
|
|
@@ -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
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from zero_3rdparty.sections import (
|
|
6
|
+
Section,
|
|
7
|
+
get_comment_config,
|
|
8
|
+
)
|
|
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))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def replace_sections(
|
|
58
|
+
dest_content: str,
|
|
59
|
+
src_sections: dict[str, str],
|
|
60
|
+
path: Path,
|
|
61
|
+
skip_sections: list[str] | None = None,
|
|
62
|
+
) -> str:
|
|
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)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# path-sync copy -n python-template
|
|
2
|
+
|
|
3
|
+
# === OK_EDIT: path-sync header ===
|
|
4
|
+
[project]
|
|
5
|
+
name = "path-sync"
|
|
6
|
+
version = "0.3.0"
|
|
7
|
+
description = "Sync files from a source repo to multiple destination repos"
|
|
8
|
+
requires-python = ">=3.13"
|
|
9
|
+
license = "MIT"
|
|
10
|
+
license-files = [
|
|
11
|
+
"LICENSE",
|
|
12
|
+
]
|
|
13
|
+
readme = "README.md"
|
|
14
|
+
keywords = [
|
|
15
|
+
"sync",
|
|
16
|
+
"template",
|
|
17
|
+
"repository",
|
|
18
|
+
"cli",
|
|
19
|
+
]
|
|
20
|
+
classifiers = [
|
|
21
|
+
"Development Status :: 4 - Beta",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
]
|
|
24
|
+
authors = [
|
|
25
|
+
{ name = "EspenAlbert", email = "espen.albert1@gmail.com" },
|
|
26
|
+
]
|
|
27
|
+
dependencies = [
|
|
28
|
+
"pydantic>=2.0",
|
|
29
|
+
"pyyaml>=6.0",
|
|
30
|
+
"typer>=0.16.0",
|
|
31
|
+
"gitpython>=3.1.0",
|
|
32
|
+
"zero-3rdparty>=0.100.0",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[project.scripts]
|
|
36
|
+
path-sync = "path_sync.__main__:main"
|
|
37
|
+
|
|
38
|
+
[build-system]
|
|
39
|
+
requires = [
|
|
40
|
+
"hatchling",
|
|
41
|
+
]
|
|
42
|
+
build-backend = "hatchling.build"
|
|
43
|
+
|
|
44
|
+
[tool.hatch.build.targets.sdist]
|
|
45
|
+
include = [
|
|
46
|
+
"path_sync/*.py",
|
|
47
|
+
]
|
|
48
|
+
exclude = [
|
|
49
|
+
"*_test.py",
|
|
50
|
+
"conftest.py",
|
|
51
|
+
"test_*.json",
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
[tool.pkg-ext]
|
|
55
|
+
tag_prefix = "v"
|
|
56
|
+
|
|
57
|
+
# === DO_NOT_EDIT: path-sync ruff ===
|
|
58
|
+
[tool.ruff]
|
|
59
|
+
line-length = 120
|
|
60
|
+
target-version = "py313"
|
|
61
|
+
|
|
62
|
+
[tool.ruff.lint]
|
|
63
|
+
# E501: line too long (handled by formatter)
|
|
64
|
+
# UP006: use `type` instead of `Type` from typing module
|
|
65
|
+
# UP007: use `X | Y` instead of `Union[X, Y]`
|
|
66
|
+
# UP035: use `typing` instead of `typing_extensions`
|
|
67
|
+
# UP040: use `type` keyword instead of `TypeAlias`
|
|
68
|
+
# UP046: use type parameters on functions instead of TypeVar
|
|
69
|
+
# UP047: use type parameters instead of `Generic[T]` subclass
|
|
70
|
+
extend-ignore = ["E501", "UP006", "UP007", "UP035", "UP040", "UP046", "UP047"]
|
|
71
|
+
extend-select = ["Q", "RUF100", "C90", "UP", "I", "T"]
|
|
72
|
+
# === OK_EDIT: path-sync ruff ===
|
|
73
|
+
|
|
74
|
+
# === DO_NOT_EDIT: path-sync pytest ===
|
|
75
|
+
[tool.pytest.ini_options]
|
|
76
|
+
addopts = "-s -vv --log-cli-level=INFO"
|
|
77
|
+
python_files = ["*_test.py", "test_*.py"]
|
|
78
|
+
# === OK_EDIT: path-sync pytest ===
|
|
79
|
+
|
|
80
|
+
# === DO_NOT_EDIT: path-sync coverage ===
|
|
81
|
+
[tool.coverage.report]
|
|
82
|
+
omit = ["*_test.py"]
|
|
83
|
+
exclude_lines = ["no cov", "if __name__ == .__main__.:"]
|
|
84
|
+
# === OK_EDIT: path-sync coverage ===
|
|
85
|
+
|
|
86
|
+
# === DO_NOT_EDIT: path-sync dev ===
|
|
87
|
+
[dependency-groups]
|
|
88
|
+
dev = [
|
|
89
|
+
"pyright>=1.1",
|
|
90
|
+
"pytest>=9.0",
|
|
91
|
+
"pytest-cov>=7.0",
|
|
92
|
+
"ruff>=0.14",
|
|
93
|
+
]
|
|
94
|
+
# === OK_EDIT: path-sync dev ===
|
|
95
|
+
|
|
96
|
+
# === DO_NOT_EDIT: path-sync docs ===
|
|
97
|
+
docs = [
|
|
98
|
+
"mkdocs-material>=9.5",
|
|
99
|
+
]
|
|
100
|
+
# === OK_EDIT: path-sync docs ===
|