path-sync 0.7.7__tar.gz → 0.8.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.7.7 → path_sync-0.8.0}/PKG-INFO +78 -5
- {path_sync-0.7.7 → path_sync-0.8.0}/README.md +76 -3
- {path_sync-0.7.7 → path_sync-0.8.0}/path_sync/__init__.py +3 -1
- {path_sync-0.7.7 → path_sync-0.8.0}/path_sync/__main__.py +1 -1
- {path_sync-0.7.7 → path_sync-0.8.0}/path_sync/_internal/auto_merge.py +4 -4
- {path_sync-0.7.7 → path_sync-0.8.0}/path_sync/_internal/cmd_boot.py +6 -2
- path_sync-0.8.0/path_sync/_internal/cmd_pull.py +407 -0
- {path_sync-0.7.7 → path_sync-0.8.0}/path_sync/_internal/cmd_validate.py +9 -2
- {path_sync-0.7.7 → path_sync-0.8.0}/path_sync/_internal/git_ops.py +33 -8
- {path_sync-0.7.7 → path_sync-0.8.0}/path_sync/_internal/prompt_utils.py +9 -0
- {path_sync-0.7.7 → path_sync-0.8.0}/path_sync/_internal/verify.py +1 -1
- path_sync-0.8.0/path_sync/pull.py +6 -0
- {path_sync-0.7.7 → path_sync-0.8.0}/pyproject.toml +19 -2
- {path_sync-0.7.7 → path_sync-0.8.0}/.gitignore +0 -0
- {path_sync-0.7.7 → path_sync-0.8.0}/LICENSE +0 -0
- {path_sync-0.7.7 → path_sync-0.8.0}/path_sync/_internal/__init__.py +0 -0
- {path_sync-0.7.7 → path_sync-0.8.0}/path_sync/_internal/cmd_copy.py +0 -0
- {path_sync-0.7.7 → path_sync-0.8.0}/path_sync/_internal/cmd_dep_update.py +0 -0
- {path_sync-0.7.7 → path_sync-0.8.0}/path_sync/_internal/cmd_options.py +0 -0
- {path_sync-0.7.7 → path_sync-0.8.0}/path_sync/_internal/file_utils.py +0 -0
- {path_sync-0.7.7 → path_sync-0.8.0}/path_sync/_internal/header.py +0 -0
- {path_sync-0.7.7 → path_sync-0.8.0}/path_sync/_internal/log_capture.py +0 -0
- {path_sync-0.7.7 → path_sync-0.8.0}/path_sync/_internal/models.py +0 -0
- {path_sync-0.7.7 → path_sync-0.8.0}/path_sync/_internal/models_dep.py +0 -0
- {path_sync-0.7.7 → path_sync-0.8.0}/path_sync/_internal/repo_utils.py +0 -0
- {path_sync-0.7.7 → path_sync-0.8.0}/path_sync/_internal/typer_app.py +0 -0
- {path_sync-0.7.7 → path_sync-0.8.0}/path_sync/_internal/validation.py +0 -0
- {path_sync-0.7.7 → path_sync-0.8.0}/path_sync/_internal/yaml_utils.py +0 -0
- {path_sync-0.7.7 → path_sync-0.8.0}/path_sync/config.py +0 -0
- {path_sync-0.7.7 → path_sync-0.8.0}/path_sync/copy.py +0 -0
- {path_sync-0.7.7 → path_sync-0.8.0}/path_sync/dep_update.py +0 -0
- {path_sync-0.7.7 → path_sync-0.8.0}/path_sync/sections.py +0 -0
- {path_sync-0.7.7 → path_sync-0.8.0}/path_sync/validate_no_changes.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: path-sync
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.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 improvements can be harvested back to SRC with `pull` (local working tree only; you commit SRC yourself)
|
|
46
47
|
- Idempotent: PR body is left unchanged when already synced from an equal or newer source commit
|
|
47
48
|
- Stale PRs auto-close when source and destination are already in sync (zero file changes)
|
|
48
49
|
|
|
@@ -92,14 +93,81 @@ By default, prompts before each git operation. See [Usage Scenarios](#usage-scen
|
|
|
92
93
|
| `--pr-reviewers` | Comma-separated PR reviewers |
|
|
93
94
|
| `--pr-assignees` | Comma-separated PR assignees |
|
|
94
95
|
|
|
95
|
-
### 3.
|
|
96
|
+
### 3. Pull changes from a destination
|
|
97
|
+
|
|
98
|
+
Harvest newer dest content into SRC. Reads one dest working tree and writes into SRC. Does not commit, push, open a PR, or run `copy`. The dest repo must already exist at `dest_path_relative`.
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
path-sync pull -n myconfig -d dest1
|
|
102
|
+
path-sync pull -n myconfig -d dest1 --dest-only
|
|
103
|
+
path-sync pull -n myconfig -d dest1 --dest-only -i '.cursor/*'
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Unlike `copy`, `pull` never auto-applies. `--dry-run` and non-TTY list candidates and write nothing.
|
|
107
|
+
|
|
108
|
+
| | `copy` | `pull` |
|
|
109
|
+
|---|--------|--------|
|
|
110
|
+
| Direction | SRC → DEST | DEST → SRC |
|
|
111
|
+
| `-d` | Comma-separated filter (optional) | Exactly one dest (required) |
|
|
112
|
+
| Non-TTY / no-write flags | `-y` auto-confirms git ops | Never writes (`--dry-run` or non-TTY lists only) |
|
|
113
|
+
| Default | Overwrites dest managed content | Only newer mapped content (git timestamp after content diff) |
|
|
114
|
+
|
|
115
|
+
| Flag | Description |
|
|
116
|
+
|------|-------------|
|
|
117
|
+
| `-d dest` | Required. Exactly one destination name (not comma-separated). |
|
|
118
|
+
| `--dest-only` | Also harvest dest files with no SRC counterpart (additive on mapped pull). |
|
|
119
|
+
| `--dry-run` | Print candidates; no prompt; no write. Non-TTY behaves the same. |
|
|
120
|
+
| `-i`, `--include` | Allowlist on SRC-relative path ([fnmatch](https://docs.python.org/3/library/fnmatch.html); quote globs in zsh). |
|
|
121
|
+
| `-e`, `--exclude` | Denylist after include (same matcher). |
|
|
122
|
+
|
|
123
|
+
| Scenario | Command |
|
|
124
|
+
|----------|---------|
|
|
125
|
+
| Interactive harvest | `pull -n cfg -d dest1` |
|
|
126
|
+
| Preview candidates | `pull -n cfg -d dest1 --dry-run` |
|
|
127
|
+
| Scripted / CI list only | `pull -n cfg -d dest1` (non-TTY) |
|
|
128
|
+
|
|
129
|
+
**Pull behavior**:
|
|
130
|
+
- **Mapped files**: Content compare first; equal content skips git. Pull only when dest git time is newer than SRC. Dirty dest or SRC path: warn and skip.
|
|
131
|
+
- **Sectioned dest files**: Copies managed section bodies only; dest text after `OK_EDIT` stays in dest; `skip_sections` honored.
|
|
132
|
+
- **Whole-file dest**: Copies file with path-sync header stripped; no header added on SRC.
|
|
133
|
+
- **Dest-only**: Whole file to mapped SRC path; does not edit `.src.yaml`.
|
|
134
|
+
- **Path filters**: `-i` / `-e` apply after collect; match SRC-relative path; empty after filter prints `No pull candidates.`
|
|
135
|
+
- **Confirm**: One list, one `y/n`; yes applies all rows.
|
|
136
|
+
|
|
137
|
+
**Dest-only example** (dir mapping; config unchanged):
|
|
138
|
+
|
|
139
|
+
```yaml
|
|
140
|
+
# .github/cursor.src.yaml (excerpt)
|
|
141
|
+
paths:
|
|
142
|
+
- src_path: .cursor/**/*.mdc
|
|
143
|
+
destinations:
|
|
144
|
+
- name: lz
|
|
145
|
+
dest_path_relative: ../lz
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
# ../lz/.cursor/rules/bar.mdc exists; SRC has no bar.mdc yet
|
|
150
|
+
path-sync pull -n cursor -d lz --dest-only
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Creates `.cursor/rules/bar.mdc` in SRC. To limit a mixed dest-only list:
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
path-sync pull -n cursor -d lz --dest-only -i '.cursor/*'
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
See also: [pull command reference](docs/pull/index.md).
|
|
160
|
+
|
|
161
|
+
### 4. Validate (run in dest repo)
|
|
96
162
|
|
|
97
163
|
```bash
|
|
98
164
|
uvx path-sync validate-no-changes -b main
|
|
99
165
|
```
|
|
100
166
|
|
|
167
|
+
In CI, when the workflow runs on `pull_request`, the comparison branch is the PR base. On `push` or locally, pass `-b` with the branch to compare against (e.g. `-b SDLC` for a branch based on SDLC).
|
|
168
|
+
|
|
101
169
|
Options:
|
|
102
|
-
- `-b, --branch` -
|
|
170
|
+
- `-b, --branch` - Branch to compare against (default: main). When `GITHUB_BASE_REF` is set (e.g. in GitHub Actions), it overrides the default so CI can use the PR base without passing `-b`. If you set `GITHUB_BASE_REF`, use a non-empty branch name.
|
|
103
171
|
- `--skip-sections` - Comma-separated `path:section_id` pairs to skip (e.g., `justfile:coverage`)
|
|
104
172
|
|
|
105
173
|
## Usage Scenarios
|
|
@@ -159,6 +227,8 @@ def hello():
|
|
|
159
227
|
# === DO_NOT_EDIT: path-sync synced ===
|
|
160
228
|
def hello():
|
|
161
229
|
pass
|
|
230
|
+
|
|
231
|
+
|
|
162
232
|
# === OK_EDIT: path-sync synced ===
|
|
163
233
|
```
|
|
164
234
|
|
|
@@ -376,7 +446,10 @@ jobs:
|
|
|
376
446
|
with:
|
|
377
447
|
fetch-depth: 0
|
|
378
448
|
- uses: astral-sh/setup-uv@v5
|
|
379
|
-
-
|
|
449
|
+
- name: Validate no changes to synced files
|
|
450
|
+
env:
|
|
451
|
+
GITHUB_BASE_REF: ${{ github.base_ref || 'main' }}
|
|
452
|
+
run: uvx path-sync validate-no-changes
|
|
380
453
|
```
|
|
381
454
|
|
|
382
455
|
**Validation skips automatically when:**
|
|
@@ -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 improvements can be harvested back to SRC with `pull` (local working tree only; you commit SRC yourself)
|
|
28
29
|
- Idempotent: PR body is left unchanged when already synced from an equal or newer source commit
|
|
29
30
|
- Stale PRs auto-close when source and destination are already in sync (zero file changes)
|
|
30
31
|
|
|
@@ -74,14 +75,81 @@ By default, prompts before each git operation. See [Usage Scenarios](#usage-scen
|
|
|
74
75
|
| `--pr-reviewers` | Comma-separated PR reviewers |
|
|
75
76
|
| `--pr-assignees` | Comma-separated PR assignees |
|
|
76
77
|
|
|
77
|
-
### 3.
|
|
78
|
+
### 3. Pull changes from a destination
|
|
79
|
+
|
|
80
|
+
Harvest newer dest content into SRC. Reads one dest working tree and writes into SRC. Does not commit, push, open a PR, or run `copy`. The dest repo must already exist at `dest_path_relative`.
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
path-sync pull -n myconfig -d dest1
|
|
84
|
+
path-sync pull -n myconfig -d dest1 --dest-only
|
|
85
|
+
path-sync pull -n myconfig -d dest1 --dest-only -i '.cursor/*'
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Unlike `copy`, `pull` never auto-applies. `--dry-run` and non-TTY list candidates and write nothing.
|
|
89
|
+
|
|
90
|
+
| | `copy` | `pull` |
|
|
91
|
+
|---|--------|--------|
|
|
92
|
+
| Direction | SRC → DEST | DEST → SRC |
|
|
93
|
+
| `-d` | Comma-separated filter (optional) | Exactly one dest (required) |
|
|
94
|
+
| Non-TTY / no-write flags | `-y` auto-confirms git ops | Never writes (`--dry-run` or non-TTY lists only) |
|
|
95
|
+
| Default | Overwrites dest managed content | Only newer mapped content (git timestamp after content diff) |
|
|
96
|
+
|
|
97
|
+
| Flag | Description |
|
|
98
|
+
|------|-------------|
|
|
99
|
+
| `-d dest` | Required. Exactly one destination name (not comma-separated). |
|
|
100
|
+
| `--dest-only` | Also harvest dest files with no SRC counterpart (additive on mapped pull). |
|
|
101
|
+
| `--dry-run` | Print candidates; no prompt; no write. Non-TTY behaves the same. |
|
|
102
|
+
| `-i`, `--include` | Allowlist on SRC-relative path ([fnmatch](https://docs.python.org/3/library/fnmatch.html); quote globs in zsh). |
|
|
103
|
+
| `-e`, `--exclude` | Denylist after include (same matcher). |
|
|
104
|
+
|
|
105
|
+
| Scenario | Command |
|
|
106
|
+
|----------|---------|
|
|
107
|
+
| Interactive harvest | `pull -n cfg -d dest1` |
|
|
108
|
+
| Preview candidates | `pull -n cfg -d dest1 --dry-run` |
|
|
109
|
+
| Scripted / CI list only | `pull -n cfg -d dest1` (non-TTY) |
|
|
110
|
+
|
|
111
|
+
**Pull behavior**:
|
|
112
|
+
- **Mapped files**: Content compare first; equal content skips git. Pull only when dest git time is newer than SRC. Dirty dest or SRC path: warn and skip.
|
|
113
|
+
- **Sectioned dest files**: Copies managed section bodies only; dest text after `OK_EDIT` stays in dest; `skip_sections` honored.
|
|
114
|
+
- **Whole-file dest**: Copies file with path-sync header stripped; no header added on SRC.
|
|
115
|
+
- **Dest-only**: Whole file to mapped SRC path; does not edit `.src.yaml`.
|
|
116
|
+
- **Path filters**: `-i` / `-e` apply after collect; match SRC-relative path; empty after filter prints `No pull candidates.`
|
|
117
|
+
- **Confirm**: One list, one `y/n`; yes applies all rows.
|
|
118
|
+
|
|
119
|
+
**Dest-only example** (dir mapping; config unchanged):
|
|
120
|
+
|
|
121
|
+
```yaml
|
|
122
|
+
# .github/cursor.src.yaml (excerpt)
|
|
123
|
+
paths:
|
|
124
|
+
- src_path: .cursor/**/*.mdc
|
|
125
|
+
destinations:
|
|
126
|
+
- name: lz
|
|
127
|
+
dest_path_relative: ../lz
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
# ../lz/.cursor/rules/bar.mdc exists; SRC has no bar.mdc yet
|
|
132
|
+
path-sync pull -n cursor -d lz --dest-only
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Creates `.cursor/rules/bar.mdc` in SRC. To limit a mixed dest-only list:
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
path-sync pull -n cursor -d lz --dest-only -i '.cursor/*'
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
See also: [pull command reference](docs/pull/index.md).
|
|
142
|
+
|
|
143
|
+
### 4. Validate (run in dest repo)
|
|
78
144
|
|
|
79
145
|
```bash
|
|
80
146
|
uvx path-sync validate-no-changes -b main
|
|
81
147
|
```
|
|
82
148
|
|
|
149
|
+
In CI, when the workflow runs on `pull_request`, the comparison branch is the PR base. On `push` or locally, pass `-b` with the branch to compare against (e.g. `-b SDLC` for a branch based on SDLC).
|
|
150
|
+
|
|
83
151
|
Options:
|
|
84
|
-
- `-b, --branch` -
|
|
152
|
+
- `-b, --branch` - Branch to compare against (default: main). When `GITHUB_BASE_REF` is set (e.g. in GitHub Actions), it overrides the default so CI can use the PR base without passing `-b`. If you set `GITHUB_BASE_REF`, use a non-empty branch name.
|
|
85
153
|
- `--skip-sections` - Comma-separated `path:section_id` pairs to skip (e.g., `justfile:coverage`)
|
|
86
154
|
|
|
87
155
|
## Usage Scenarios
|
|
@@ -141,6 +209,8 @@ def hello():
|
|
|
141
209
|
# === DO_NOT_EDIT: path-sync synced ===
|
|
142
210
|
def hello():
|
|
143
211
|
pass
|
|
212
|
+
|
|
213
|
+
|
|
144
214
|
# === OK_EDIT: path-sync synced ===
|
|
145
215
|
```
|
|
146
216
|
|
|
@@ -358,7 +428,10 @@ jobs:
|
|
|
358
428
|
with:
|
|
359
429
|
fetch-depth: 0
|
|
360
430
|
- uses: astral-sh/setup-uv@v5
|
|
361
|
-
-
|
|
431
|
+
- name: Validate no changes to synced files
|
|
432
|
+
env:
|
|
433
|
+
GITHUB_BASE_REF: ${{ github.base_ref || 'main' }}
|
|
434
|
+
run: uvx path-sync validate-no-changes
|
|
362
435
|
```
|
|
363
436
|
|
|
364
437
|
**Validation skips automatically when:**
|
|
@@ -2,13 +2,15 @@
|
|
|
2
2
|
# flake8: noqa
|
|
3
3
|
from path_sync import copy
|
|
4
4
|
from path_sync import dep_update
|
|
5
|
+
from path_sync import pull
|
|
5
6
|
from path_sync import validate_no_changes
|
|
6
7
|
from path_sync import config
|
|
7
8
|
|
|
8
|
-
VERSION = "0.
|
|
9
|
+
VERSION = "0.8.0"
|
|
9
10
|
__all__ = [
|
|
10
11
|
"copy",
|
|
11
12
|
"dep_update",
|
|
13
|
+
"pull",
|
|
12
14
|
"validate_no_changes",
|
|
13
15
|
"config",
|
|
14
16
|
]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import logging
|
|
2
2
|
|
|
3
|
-
from path_sync._internal import cmd_boot, cmd_copy, cmd_dep_update, cmd_validate # noqa: F401
|
|
3
|
+
from path_sync._internal import cmd_boot, cmd_copy, cmd_dep_update, cmd_pull, cmd_validate # noqa: F401
|
|
4
4
|
from path_sync._internal.models import LOG_FORMAT
|
|
5
5
|
from path_sync._internal.typer_app import app
|
|
6
6
|
|
|
@@ -77,7 +77,7 @@ def enable_auto_merge(repo_path: Path, pr_ref: str, config: AutoMergeConfig, des
|
|
|
77
77
|
cmd = ["gh", "pr", "merge", "--auto", f"--{config.method}", pr_ref]
|
|
78
78
|
if config.delete_branch:
|
|
79
79
|
cmd.append("--delete-branch")
|
|
80
|
-
result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True)
|
|
80
|
+
result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True, check=False)
|
|
81
81
|
if result.returncode != 0:
|
|
82
82
|
logger.warning(f" {label}: auto-merge enable failed: {result.stderr.strip()}")
|
|
83
83
|
else:
|
|
@@ -86,7 +86,7 @@ def enable_auto_merge(repo_path: Path, pr_ref: str, config: AutoMergeConfig, des
|
|
|
86
86
|
|
|
87
87
|
def get_pr_checks(repo_path: Path, pr_ref: str) -> list[CheckRun]:
|
|
88
88
|
cmd = ["gh", "pr", "checks", pr_ref, "--json", "name,state,workflow,link"]
|
|
89
|
-
result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True)
|
|
89
|
+
result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True, check=False)
|
|
90
90
|
if result.returncode != 0:
|
|
91
91
|
logger.warning(f"Failed to get checks for {pr_ref}: {result.stderr.strip()}")
|
|
92
92
|
return []
|
|
@@ -95,7 +95,7 @@ def get_pr_checks(repo_path: Path, pr_ref: str) -> list[CheckRun]:
|
|
|
95
95
|
|
|
96
96
|
def get_pr_state(repo_path: Path, pr_ref: str) -> PRState:
|
|
97
97
|
cmd = ["gh", "pr", "view", pr_ref, "--json", "state", "-q", ".state"]
|
|
98
|
-
result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True)
|
|
98
|
+
result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True, check=False)
|
|
99
99
|
if result.returncode != 0:
|
|
100
100
|
logger.warning(f"Failed to get PR state for {pr_ref}: {result.stderr.strip()}")
|
|
101
101
|
return PRState.OPEN
|
|
@@ -104,7 +104,7 @@ def get_pr_state(repo_path: Path, pr_ref: str) -> PRState:
|
|
|
104
104
|
|
|
105
105
|
def get_pr_url(repo_path: Path, pr_ref: str) -> str:
|
|
106
106
|
cmd = ["gh", "pr", "view", pr_ref, "--json", "url", "-q", ".url"]
|
|
107
|
-
result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True)
|
|
107
|
+
result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True, check=False)
|
|
108
108
|
if result.returncode != 0:
|
|
109
109
|
logger.warning(f"Failed to get PR URL for {pr_ref}: {result.stderr.strip()}")
|
|
110
110
|
return pr_ref
|
|
@@ -24,8 +24,8 @@ logger = logging.getLogger(__name__)
|
|
|
24
24
|
@app.command()
|
|
25
25
|
def boot(
|
|
26
26
|
name: str = typer.Option(..., "-n", "--name", help="Config name"),
|
|
27
|
-
dest_paths: Annotated[list[str], typer.Option("-d", "--dest", help="Destination relative paths")] =
|
|
28
|
-
sync_paths: Annotated[list[str], typer.Option("-p", "--path", help="Paths to sync (glob patterns)")] =
|
|
27
|
+
dest_paths: Annotated[list[str] | None, typer.Option("-d", "--dest", help="Destination relative paths")] = None,
|
|
28
|
+
sync_paths: Annotated[list[str] | None, typer.Option("-p", "--path", help="Paths to sync (glob patterns)")] = None,
|
|
29
29
|
dry_run: bool = typer.Option(False, "--dry-run", help="Preview without writing"),
|
|
30
30
|
regen: bool = typer.Option(False, "--regen", help="Regenerate config"),
|
|
31
31
|
src_root_opt: str = typer.Option(
|
|
@@ -35,6 +35,10 @@ def boot(
|
|
|
35
35
|
),
|
|
36
36
|
) -> None:
|
|
37
37
|
"""Initialize or update SRC repo config."""
|
|
38
|
+
if sync_paths is None:
|
|
39
|
+
sync_paths = []
|
|
40
|
+
if dest_paths is None:
|
|
41
|
+
dest_paths = []
|
|
38
42
|
repo_root = Path(src_root_opt) if src_root_opt else find_repo_root(Path.cwd())
|
|
39
43
|
config_path = resolve_config_path(repo_root, name)
|
|
40
44
|
|
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import fnmatch
|
|
4
|
+
import logging
|
|
5
|
+
import sys
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from enum import StrEnum
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import NamedTuple
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
from git import Repo
|
|
14
|
+
from pydantic import BaseModel, Field
|
|
15
|
+
|
|
16
|
+
from path_sync import sections
|
|
17
|
+
from path_sync._internal import git_ops, header, prompt_utils
|
|
18
|
+
from path_sync._internal.cmd_copy import _iter_sync_files
|
|
19
|
+
from path_sync._internal.file_utils import ensure_parents_write_text
|
|
20
|
+
from path_sync._internal.models import (
|
|
21
|
+
Destination,
|
|
22
|
+
PathMapping,
|
|
23
|
+
SrcConfig,
|
|
24
|
+
SyncMode,
|
|
25
|
+
find_repo_root,
|
|
26
|
+
resolve_config_path,
|
|
27
|
+
)
|
|
28
|
+
from path_sync._internal.repo_utils import resolve_repo_path
|
|
29
|
+
from path_sync._internal.typer_app import app
|
|
30
|
+
from path_sync._internal.yaml_utils import load_yaml_model
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
_EMPTY_STR_LIST: list[str] = []
|
|
35
|
+
_INCLUDE_OPTION = typer.Option(_EMPTY_STR_LIST, "-i", "--include", help="Keep paths matching pattern")
|
|
36
|
+
_EXCLUDE_OPTION = typer.Option(_EMPTY_STR_LIST, "-e", "--exclude", help="Drop paths matching pattern")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class PullKind(StrEnum):
|
|
40
|
+
SECTIONS = "sections"
|
|
41
|
+
WHOLE = "whole"
|
|
42
|
+
BINARY = "binary"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class PullOptions(BaseModel):
|
|
46
|
+
dry_run: bool = False
|
|
47
|
+
dest_only: bool = False
|
|
48
|
+
include: list[str] = Field(default_factory=list)
|
|
49
|
+
exclude: list[str] = Field(default_factory=list)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class _DestFileMap(NamedTuple):
|
|
53
|
+
src_path: Path
|
|
54
|
+
dest_key: str
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class PullCandidate:
|
|
59
|
+
src_path: Path
|
|
60
|
+
dest_path: Path
|
|
61
|
+
dest_key: str
|
|
62
|
+
kind: PullKind
|
|
63
|
+
section_ids: list[str]
|
|
64
|
+
dest_ts: int | None
|
|
65
|
+
src_ts: int | None
|
|
66
|
+
skip_sections: list[str]
|
|
67
|
+
dest_only: bool = False
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _validate_dest_name(dest: str) -> str:
|
|
71
|
+
if not dest.strip():
|
|
72
|
+
raise ValueError("Missing destination: -d is required")
|
|
73
|
+
if "," in dest:
|
|
74
|
+
raise ValueError("Exactly one destination required; comma-separated list not supported")
|
|
75
|
+
return dest.strip()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _format_unix(ts: int) -> str:
|
|
79
|
+
return datetime.fromtimestamp(ts, UTC).strftime("%Y-%m-%d")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _managed_section_map(content: str, path: Path, skip: list[str]) -> dict[str, str]:
|
|
83
|
+
body = header.remove_header(content)
|
|
84
|
+
extracted = sections.extract_sections(body, path)
|
|
85
|
+
return {k: v for k, v in extracted.items() if k not in skip}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _diff_section_ids(dest_map: dict[str, str], src_map: dict[str, str]) -> list[str]:
|
|
89
|
+
ids = set(dest_map) | set(src_map)
|
|
90
|
+
return sorted(i for i in ids if dest_map.get(i) != src_map.get(i))
|
|
91
|
+
|
|
92
|
+
|
|
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
|
+
def _collect_mapped_candidates(
|
|
98
|
+
config: SrcConfig,
|
|
99
|
+
dest: Destination,
|
|
100
|
+
src_root: Path,
|
|
101
|
+
dest_root: Path,
|
|
102
|
+
src_repo: Repo,
|
|
103
|
+
dest_repo: Repo,
|
|
104
|
+
) -> list[PullCandidate]:
|
|
105
|
+
candidates: list[PullCandidate] = []
|
|
106
|
+
for mapping in config.resolve_paths(dest):
|
|
107
|
+
for src_path, dest_key, dest_path in _iter_sync_files(mapping, src_root, dest_root):
|
|
108
|
+
if dest.is_skipped(dest_key):
|
|
109
|
+
continue
|
|
110
|
+
if not dest_path.exists():
|
|
111
|
+
logger.warning(f"Dest not found: {dest_path}")
|
|
112
|
+
continue
|
|
113
|
+
if not src_path.exists():
|
|
114
|
+
continue
|
|
115
|
+
if mapping.sync_mode == SyncMode.SCAFFOLD:
|
|
116
|
+
continue
|
|
117
|
+
|
|
118
|
+
skip_list = dest.skip_sections.get(dest_key, [])
|
|
119
|
+
candidate = _candidate_for_path(
|
|
120
|
+
src_path,
|
|
121
|
+
dest_path,
|
|
122
|
+
dest_key,
|
|
123
|
+
mapping.sync_mode,
|
|
124
|
+
skip_list,
|
|
125
|
+
src_repo,
|
|
126
|
+
dest_repo,
|
|
127
|
+
)
|
|
128
|
+
if candidate:
|
|
129
|
+
candidates.append(candidate)
|
|
130
|
+
return candidates
|
|
131
|
+
|
|
132
|
+
|
|
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:
|
|
160
|
+
try:
|
|
161
|
+
dest_path.read_text()
|
|
162
|
+
except UnicodeDecodeError:
|
|
163
|
+
kind = PullKind.BINARY
|
|
164
|
+
else:
|
|
165
|
+
kind = PullKind.WHOLE
|
|
166
|
+
return PullCandidate(
|
|
167
|
+
src_path=src_path,
|
|
168
|
+
dest_path=dest_path,
|
|
169
|
+
dest_key=dest_key,
|
|
170
|
+
kind=kind,
|
|
171
|
+
section_ids=[],
|
|
172
|
+
dest_ts=None,
|
|
173
|
+
src_ts=None,
|
|
174
|
+
skip_sections=[],
|
|
175
|
+
dest_only=True,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _collect_dest_only_candidates(
|
|
180
|
+
config: SrcConfig,
|
|
181
|
+
dest: Destination,
|
|
182
|
+
src_root: Path,
|
|
183
|
+
dest_root: Path,
|
|
184
|
+
dest_repo: Repo,
|
|
185
|
+
skip_keys: set[str],
|
|
186
|
+
) -> 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
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _text_pull_kind(
|
|
209
|
+
src_path: Path,
|
|
210
|
+
dest_path: Path,
|
|
211
|
+
dest_text: str,
|
|
212
|
+
src_text: str,
|
|
213
|
+
sync_mode: SyncMode,
|
|
214
|
+
skip_list: list[str],
|
|
215
|
+
) -> tuple[PullKind, list[str]] | None:
|
|
216
|
+
if _is_opted_out(dest_text, sync_mode):
|
|
217
|
+
return None
|
|
218
|
+
|
|
219
|
+
dest_body = header.remove_header(dest_text)
|
|
220
|
+
if header.has_known_comment_prefix(dest_path) and sections.has_sections(dest_body, dest_path):
|
|
221
|
+
dest_map = _managed_section_map(dest_text, dest_path, skip_list)
|
|
222
|
+
src_map = _managed_section_map(src_text, src_path, skip_list)
|
|
223
|
+
if dest_map == src_map:
|
|
224
|
+
return None
|
|
225
|
+
return PullKind.SECTIONS, _diff_section_ids(dest_map, src_map)
|
|
226
|
+
|
|
227
|
+
if header.remove_header(dest_text) == src_text:
|
|
228
|
+
return None
|
|
229
|
+
return PullKind.WHOLE, []
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _candidate_for_path(
|
|
233
|
+
src_path: Path,
|
|
234
|
+
dest_path: Path,
|
|
235
|
+
dest_key: str,
|
|
236
|
+
sync_mode: SyncMode,
|
|
237
|
+
skip_list: list[str],
|
|
238
|
+
src_repo: Repo,
|
|
239
|
+
dest_repo: Repo,
|
|
240
|
+
) -> PullCandidate | None:
|
|
241
|
+
try:
|
|
242
|
+
dest_text = dest_path.read_text()
|
|
243
|
+
src_text = src_path.read_text()
|
|
244
|
+
except UnicodeDecodeError:
|
|
245
|
+
if dest_path.read_bytes() == src_path.read_bytes():
|
|
246
|
+
return None
|
|
247
|
+
kind, section_ids = PullKind.BINARY, []
|
|
248
|
+
else:
|
|
249
|
+
kind_info = _text_pull_kind(src_path, dest_path, dest_text, src_text, sync_mode, skip_list)
|
|
250
|
+
if kind_info is None:
|
|
251
|
+
return None
|
|
252
|
+
kind, section_ids = kind_info
|
|
253
|
+
|
|
254
|
+
if git_ops.path_is_dirty(dest_repo, dest_path):
|
|
255
|
+
logger.warning(f"Skipping dirty dest path: {dest_path}")
|
|
256
|
+
return None
|
|
257
|
+
if git_ops.path_is_dirty(src_repo, src_path):
|
|
258
|
+
logger.warning(f"Skipping dirty src path: {src_path}")
|
|
259
|
+
return None
|
|
260
|
+
|
|
261
|
+
dest_ts = git_ops.file_last_commit_unix(dest_repo, dest_path)
|
|
262
|
+
src_ts = git_ops.file_last_commit_unix(src_repo, src_path)
|
|
263
|
+
if dest_ts is None or src_ts is None or dest_ts <= src_ts:
|
|
264
|
+
return None
|
|
265
|
+
|
|
266
|
+
return PullCandidate(
|
|
267
|
+
src_path=src_path,
|
|
268
|
+
dest_path=dest_path,
|
|
269
|
+
dest_key=dest_key,
|
|
270
|
+
kind=kind,
|
|
271
|
+
section_ids=section_ids,
|
|
272
|
+
dest_ts=dest_ts,
|
|
273
|
+
src_ts=src_ts,
|
|
274
|
+
skip_sections=skip_list,
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _keep_pull_path(rel: str, include: list[str], exclude: list[str]) -> bool:
|
|
279
|
+
if include and not any(fnmatch.fnmatch(rel, pat) for pat in include):
|
|
280
|
+
return False
|
|
281
|
+
return not (exclude and any(fnmatch.fnmatch(rel, pat) for pat in exclude))
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _format_candidate_line(candidate: PullCandidate, src_root: Path) -> str:
|
|
285
|
+
rel = str(candidate.src_path.relative_to(src_root))
|
|
286
|
+
if candidate.dest_only:
|
|
287
|
+
return f" {rel:<30} dest-only"
|
|
288
|
+
if candidate.kind == PullKind.SECTIONS:
|
|
289
|
+
detail = f"sections: {', '.join(candidate.section_ids)}"
|
|
290
|
+
elif candidate.kind == PullKind.WHOLE:
|
|
291
|
+
detail = "whole file"
|
|
292
|
+
else:
|
|
293
|
+
detail = "binary"
|
|
294
|
+
dest_ts = candidate.dest_ts
|
|
295
|
+
src_ts = candidate.src_ts
|
|
296
|
+
assert dest_ts is not None and src_ts is not None
|
|
297
|
+
ts = f"dest {_format_unix(dest_ts)} > src {_format_unix(src_ts)}"
|
|
298
|
+
return f" {rel:<30} {detail:<30} {ts}"
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _print_candidates(dest_name: str, candidates: list[PullCandidate], src_root: Path) -> None:
|
|
302
|
+
typer.echo(f"\nPull from {dest_name} into src?\n", err=True)
|
|
303
|
+
for c in candidates:
|
|
304
|
+
typer.echo(_format_candidate_line(c, src_root), err=True)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _apply_candidate(candidate: PullCandidate) -> None:
|
|
308
|
+
if candidate.kind == PullKind.BINARY:
|
|
309
|
+
candidate.src_path.parent.mkdir(parents=True, exist_ok=True)
|
|
310
|
+
candidate.src_path.write_bytes(candidate.dest_path.read_bytes())
|
|
311
|
+
logger.info(f"Pulled binary: {candidate.src_path}")
|
|
312
|
+
return
|
|
313
|
+
|
|
314
|
+
if candidate.kind == PullKind.SECTIONS:
|
|
315
|
+
dest_body = header.remove_header(candidate.dest_path.read_text())
|
|
316
|
+
dest_sections = sections.parse_sections(dest_body, candidate.dest_path)
|
|
317
|
+
new_content = sections.replace_sections(
|
|
318
|
+
candidate.src_path.read_text(),
|
|
319
|
+
dest_sections,
|
|
320
|
+
candidate.src_path,
|
|
321
|
+
skip_sections=candidate.skip_sections,
|
|
322
|
+
keep_deleted_sections=True,
|
|
323
|
+
)
|
|
324
|
+
else:
|
|
325
|
+
new_content = header.remove_header(candidate.dest_path.read_text())
|
|
326
|
+
|
|
327
|
+
ensure_parents_write_text(candidate.src_path, new_content)
|
|
328
|
+
logger.info(f"Pulled: {candidate.src_path}")
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _run_pull(config: SrcConfig, dest: Destination, src_root: Path, opts: PullOptions) -> None:
|
|
332
|
+
dest_root = resolve_repo_path(dest, src_root, "")
|
|
333
|
+
if not git_ops.is_git_repo(dest_root):
|
|
334
|
+
raise ValueError(f"Not a git repository: {dest_root}")
|
|
335
|
+
|
|
336
|
+
src_repo = git_ops.get_repo(src_root)
|
|
337
|
+
dest_repo = git_ops.get_repo(dest_root)
|
|
338
|
+
candidates = _collect_mapped_candidates(config, dest, src_root, dest_root, src_repo, dest_repo)
|
|
339
|
+
if opts.dest_only:
|
|
340
|
+
skip_keys = {c.dest_key for c in candidates}
|
|
341
|
+
candidates.extend(_collect_dest_only_candidates(config, dest, src_root, dest_root, dest_repo, skip_keys))
|
|
342
|
+
|
|
343
|
+
candidates = [
|
|
344
|
+
c for c in candidates if _keep_pull_path(str(c.src_path.relative_to(src_root)), opts.include, opts.exclude)
|
|
345
|
+
]
|
|
346
|
+
|
|
347
|
+
if not candidates:
|
|
348
|
+
typer.echo("No pull candidates.", err=True)
|
|
349
|
+
return
|
|
350
|
+
|
|
351
|
+
_print_candidates(dest.name, candidates, src_root)
|
|
352
|
+
|
|
353
|
+
if opts.dry_run or not sys.stdin.isatty():
|
|
354
|
+
return
|
|
355
|
+
if prompt_utils.prompt_pull_confirm("Confirm?"):
|
|
356
|
+
for c in candidates:
|
|
357
|
+
_apply_candidate(c)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
@app.command()
|
|
361
|
+
def pull(
|
|
362
|
+
name: str = typer.Option("", "-n", "--name", help="Config name"),
|
|
363
|
+
config_path_opt: str = typer.Option("", "-c", "--config-path", help="Full path to config file"),
|
|
364
|
+
src_root_opt: str = typer.Option("", "--src-root", help="Source repo root"),
|
|
365
|
+
dest_name: str = typer.Option(..., "-d", "--dest", help="Destination name (exactly one)"),
|
|
366
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Print candidates without writing (same as non-TTY)"),
|
|
367
|
+
dest_only: bool = typer.Option(False, "--dest-only", help="Also harvest dest files with no src counterpart"),
|
|
368
|
+
include: list[str] = _INCLUDE_OPTION,
|
|
369
|
+
exclude: list[str] = _EXCLUDE_OPTION,
|
|
370
|
+
) -> None:
|
|
371
|
+
"""Harvest newer mapped dest files into src after one confirm.
|
|
372
|
+
|
|
373
|
+
--dest-only also copies dest-only files. Quote glob patterns (e.g. -i '.cursor/*').
|
|
374
|
+
"""
|
|
375
|
+
if name and config_path_opt:
|
|
376
|
+
logger.error("Cannot use both --name and --config-path")
|
|
377
|
+
raise typer.Exit(1)
|
|
378
|
+
if not name and not config_path_opt:
|
|
379
|
+
logger.error("Either --name or --config-path is required")
|
|
380
|
+
raise typer.Exit(1)
|
|
381
|
+
|
|
382
|
+
try:
|
|
383
|
+
dest_filter = _validate_dest_name(dest_name)
|
|
384
|
+
except ValueError as e:
|
|
385
|
+
logger.error(str(e))
|
|
386
|
+
raise typer.Exit(1)
|
|
387
|
+
|
|
388
|
+
src_root = Path(src_root_opt) if src_root_opt else find_repo_root(Path.cwd())
|
|
389
|
+
config_path = Path(config_path_opt) if config_path_opt else resolve_config_path(src_root, name)
|
|
390
|
+
if not config_path.exists():
|
|
391
|
+
logger.error(f"Config not found: {config_path}")
|
|
392
|
+
raise typer.Exit(1)
|
|
393
|
+
|
|
394
|
+
config = load_yaml_model(config_path, SrcConfig)
|
|
395
|
+
dest = config.find_destination(dest_filter)
|
|
396
|
+
opts = PullOptions(
|
|
397
|
+
dry_run=dry_run,
|
|
398
|
+
dest_only=dest_only,
|
|
399
|
+
include=include,
|
|
400
|
+
exclude=exclude,
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
try:
|
|
404
|
+
_run_pull(config, dest, src_root, opts)
|
|
405
|
+
except ValueError as e:
|
|
406
|
+
logger.error(f"Pull failed: {e}")
|
|
407
|
+
raise typer.Exit(1)
|
|
@@ -15,7 +15,13 @@ logger = logging.getLogger(__name__)
|
|
|
15
15
|
|
|
16
16
|
@app.command("validate-no-changes")
|
|
17
17
|
def validate_no_changes(
|
|
18
|
-
branch: str = typer.Option(
|
|
18
|
+
branch: str = typer.Option(
|
|
19
|
+
"main",
|
|
20
|
+
"-b",
|
|
21
|
+
"--branch",
|
|
22
|
+
help="Branch to compare against (default: main; uses GITHUB_BASE_REF when set and -b not passed)",
|
|
23
|
+
envvar="GITHUB_BASE_REF",
|
|
24
|
+
),
|
|
19
25
|
skip_sections_opt: str = typer.Option(
|
|
20
26
|
"",
|
|
21
27
|
"--skip-sections",
|
|
@@ -36,9 +42,10 @@ def validate_no_changes(
|
|
|
36
42
|
logger.info(f"On sync branch {current_branch}, validation skipped")
|
|
37
43
|
return
|
|
38
44
|
if current_branch == branch:
|
|
39
|
-
logger.info(f"On
|
|
45
|
+
logger.info(f"On base branch {branch}, validation skipped")
|
|
40
46
|
return
|
|
41
47
|
|
|
48
|
+
logger.info(f"Validating changes against branch {branch}")
|
|
42
49
|
skip_sections = parse_skip_sections(skip_sections_opt) if skip_sections_opt else None
|
|
43
50
|
unauthorized = validate_no_unauthorized_changes(repo_root, branch, skip_sections)
|
|
44
51
|
|
|
@@ -4,6 +4,7 @@ import logging
|
|
|
4
4
|
import os
|
|
5
5
|
import re
|
|
6
6
|
import subprocess
|
|
7
|
+
from configparser import NoOptionError, NoSectionError
|
|
7
8
|
from contextlib import suppress
|
|
8
9
|
from pathlib import Path
|
|
9
10
|
|
|
@@ -155,7 +156,7 @@ def _ensure_git_user(repo: Repo) -> None:
|
|
|
155
156
|
"""Configure git user if not already set."""
|
|
156
157
|
try:
|
|
157
158
|
repo.config_reader().get_value("user", "name")
|
|
158
|
-
except
|
|
159
|
+
except (NoOptionError, NoSectionError):
|
|
159
160
|
repo.config_writer().set_value("user", "name", "path-sync[bot]").release()
|
|
160
161
|
repo.config_writer().set_value("user", "email", "path-sync[bot]@users.noreply.github.com").release()
|
|
161
162
|
|
|
@@ -191,7 +192,7 @@ def _get_repo_full_name(repo_path: Path) -> str | None:
|
|
|
191
192
|
"-q",
|
|
192
193
|
'.owner.login + "/" + .name',
|
|
193
194
|
]
|
|
194
|
-
result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True)
|
|
195
|
+
result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True, check=False)
|
|
195
196
|
if result.returncode != 0:
|
|
196
197
|
logger.warning(f"Failed to get repo info: {result.stderr}")
|
|
197
198
|
return None
|
|
@@ -201,7 +202,7 @@ def _get_repo_full_name(repo_path: Path) -> str | None:
|
|
|
201
202
|
def _get_pr_number(repo_path: Path, branch: str) -> str | None:
|
|
202
203
|
"""Get PR number for a branch."""
|
|
203
204
|
cmd = ["gh", "pr", "view", branch, "--json", "number", "-q", ".number"]
|
|
204
|
-
result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True)
|
|
205
|
+
result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True, check=False)
|
|
205
206
|
if result.returncode != 0:
|
|
206
207
|
logger.warning(f"Failed to get PR number: {result.stderr}")
|
|
207
208
|
return None
|
|
@@ -228,7 +229,7 @@ def update_pr_body(repo_path: Path, branch: str, body: str) -> bool:
|
|
|
228
229
|
"-f",
|
|
229
230
|
f"body={body}",
|
|
230
231
|
]
|
|
231
|
-
result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True)
|
|
232
|
+
result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True, check=False)
|
|
232
233
|
if result.returncode != 0:
|
|
233
234
|
logger.warning(f"Failed to update PR body: {result.stderr}")
|
|
234
235
|
return False
|
|
@@ -239,7 +240,7 @@ def update_pr_body(repo_path: Path, branch: str, body: str) -> bool:
|
|
|
239
240
|
|
|
240
241
|
def get_pr_body(repo_path: Path, branch: str) -> str | None:
|
|
241
242
|
cmd = ["gh", "pr", "view", branch, "--json", "body,state", "-q", 'select(.state == "OPEN") | .body']
|
|
242
|
-
result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True)
|
|
243
|
+
result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True, check=False)
|
|
243
244
|
if result.returncode != 0:
|
|
244
245
|
return None
|
|
245
246
|
body = result.stdout.strip()
|
|
@@ -248,7 +249,7 @@ def get_pr_body(repo_path: Path, branch: str) -> str | None:
|
|
|
248
249
|
|
|
249
250
|
def has_open_pr(repo_path: Path, branch: str) -> bool:
|
|
250
251
|
cmd = ["gh", "pr", "view", branch, "--json", "state", "-q", ".state"]
|
|
251
|
-
result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True)
|
|
252
|
+
result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True, check=False)
|
|
252
253
|
if result.returncode != 0:
|
|
253
254
|
return False
|
|
254
255
|
return result.stdout.strip() == "OPEN"
|
|
@@ -256,7 +257,7 @@ def has_open_pr(repo_path: Path, branch: str) -> bool:
|
|
|
256
257
|
|
|
257
258
|
def close_pr(repo_path: Path, branch: str, comment: str) -> bool:
|
|
258
259
|
cmd = ["gh", "pr", "close", branch, "-c", comment]
|
|
259
|
-
result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True)
|
|
260
|
+
result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True, check=False)
|
|
260
261
|
if result.returncode != 0:
|
|
261
262
|
logger.warning(f"Failed to close PR for {branch}: {result.stderr}")
|
|
262
263
|
return False
|
|
@@ -283,7 +284,7 @@ def create_or_update_pr(
|
|
|
283
284
|
if assignees:
|
|
284
285
|
cmd.extend(["--assignee", ",".join(assignees)])
|
|
285
286
|
|
|
286
|
-
result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True)
|
|
287
|
+
result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True, check=False)
|
|
287
288
|
if result.returncode != 0:
|
|
288
289
|
if "already exists" in result.stderr:
|
|
289
290
|
logger.info("PR already exists, updating body")
|
|
@@ -314,3 +315,27 @@ def get_file_content_at_ref(repo: Repo, file_path: Path, ref: str) -> str | None
|
|
|
314
315
|
with suppress(GitCommandError):
|
|
315
316
|
return repo.git.show(f"{ref}:{rel_path}")
|
|
316
317
|
return None
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def path_porcelain_status(repo: Repo, file_path: Path) -> str | None:
|
|
321
|
+
rel_path = str(file_path.relative_to(repo.working_dir))
|
|
322
|
+
status = repo.git.status("--porcelain", "--", rel_path).strip()
|
|
323
|
+
return status or None
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def path_is_dirty(repo: Repo, file_path: Path) -> bool:
|
|
327
|
+
return path_porcelain_status(repo, file_path) is not None
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def path_is_tracked_dirty(repo: Repo, file_path: Path) -> bool:
|
|
331
|
+
if status := path_porcelain_status(repo, file_path):
|
|
332
|
+
return status[:2] != "??"
|
|
333
|
+
return False
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def file_last_commit_unix(repo: Repo, file_path: Path) -> int | None:
|
|
337
|
+
rel_path = str(file_path.relative_to(repo.working_dir))
|
|
338
|
+
with suppress(GitCommandError):
|
|
339
|
+
ts = repo.git.log("-1", "--format=%ct", "--", rel_path).strip()
|
|
340
|
+
return int(ts) if ts else None
|
|
341
|
+
return None
|
|
@@ -23,3 +23,12 @@ def prompt_confirm(message: str, no_prompt: bool = False) -> bool:
|
|
|
23
23
|
return response == "y"
|
|
24
24
|
except (EOFError, KeyboardInterrupt):
|
|
25
25
|
return False
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def prompt_pull_confirm(message: str) -> bool:
|
|
29
|
+
if not sys.stdin.isatty():
|
|
30
|
+
return False
|
|
31
|
+
try:
|
|
32
|
+
return input(f"{message} [y/n]: ").strip().lower() == "y"
|
|
33
|
+
except (EOFError, KeyboardInterrupt):
|
|
34
|
+
return False
|
|
@@ -39,7 +39,7 @@ def run_command(cmd: str, cwd: Path, dry_run: bool = False) -> None:
|
|
|
39
39
|
logger.info(f"[DRY RUN] Would run: {cmd} from {cwd}")
|
|
40
40
|
return
|
|
41
41
|
logger.info(f"Running: {cmd}")
|
|
42
|
-
result = subprocess.run(cmd, shell=True, cwd=cwd, capture_output=True, text=True)
|
|
42
|
+
result = subprocess.run(cmd, shell=True, cwd=cwd, capture_output=True, text=True, check=False)
|
|
43
43
|
prefix = cmd.split()[0]
|
|
44
44
|
for line in result.stdout.strip().splitlines():
|
|
45
45
|
logger.info(f"[{prefix}] {line}")
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[project]
|
|
4
4
|
name = "path-sync"
|
|
5
|
-
version = "0.
|
|
5
|
+
version = "0.8.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,6 +69,13 @@ 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/**"]
|
|
72
79
|
# === OK_EDIT: path-sync ruff ===
|
|
73
80
|
|
|
74
81
|
# === DO_NOT_EDIT: path-sync pytest ===
|
|
@@ -95,7 +102,7 @@ exclude = [".venv/", "*test.py"]
|
|
|
95
102
|
# === DO_NOT_EDIT: path-sync dev-lint ===
|
|
96
103
|
[dependency-groups]
|
|
97
104
|
dev-lint = [
|
|
98
|
-
"
|
|
105
|
+
"ty==0.0.76",
|
|
99
106
|
"ruff>=0.14",
|
|
100
107
|
"vulture>=2.14",
|
|
101
108
|
]
|
|
@@ -106,6 +113,7 @@ dev-test = [
|
|
|
106
113
|
"pytest>=9.0",
|
|
107
114
|
"pytest-asyncio>=0.24",
|
|
108
115
|
"pytest-cov>=7.0",
|
|
116
|
+
"pytest-examples>=0.0.18",
|
|
109
117
|
"pytest-regressions>=2.6",
|
|
110
118
|
]
|
|
111
119
|
# === OK_EDIT: path-sync dev-test ===
|
|
@@ -123,3 +131,12 @@ docs = [
|
|
|
123
131
|
# === DO_NOT_EDIT: path-sync release ===
|
|
124
132
|
release = ["pkg-ext"]
|
|
125
133
|
# === OK_EDIT: path-sync release ===
|
|
134
|
+
# === DO_NOT_EDIT: path-sync wheel ===
|
|
135
|
+
[tool.hatch.build.targets.wheel]
|
|
136
|
+
exclude = [
|
|
137
|
+
"*_examples.py",
|
|
138
|
+
"*_test.py",
|
|
139
|
+
"conftest.py",
|
|
140
|
+
"*_test/",
|
|
141
|
+
]
|
|
142
|
+
# === OK_EDIT: path-sync wheel ===
|
|
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
|