repo-control 0.1.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.
- repo_control-0.1.0/.github/workflows/publish.yaml +21 -0
- repo_control-0.1.0/.gitignore +13 -0
- repo_control-0.1.0/PKG-INFO +5 -0
- repo_control-0.1.0/README.md +87 -0
- repo_control-0.1.0/pyproject.toml +16 -0
- repo_control-0.1.0/src/repo_control/__init__.py +0 -0
- repo_control-0.1.0/src/repo_control/__main__.py +457 -0
- repo_control-0.1.0/src/repo_control/config.py +54 -0
- repo_control-0.1.0/src/repo_control/gh.py +88 -0
- repo_control-0.1.0/src/repo_control/git.py +181 -0
- repo_control-0.1.0/src/repo_control/ide.py +23 -0
- repo_control-0.1.0/src/repo_control/setup.py +18 -0
- repo_control-0.1.0/src/repo_control/skill/SKILL.md +91 -0
- repo_control-0.1.0/src/repo_control/state.py +67 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
|
|
7
|
+
permissions:
|
|
8
|
+
id-token: write
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
publish:
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
environment: pypi
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
|
16
|
+
|
|
17
|
+
- uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
|
|
18
|
+
|
|
19
|
+
- run: uv build
|
|
20
|
+
|
|
21
|
+
- uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # release/v1
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# repo-control
|
|
2
|
+
|
|
3
|
+
Mirror every open GitHub PR you've authored as a per-repo cluster of git worktrees under a single base path. One folder per tracked repo, named `<repo>-control/`, holding `main/` plus one worktree per open PR.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
<base_path>/
|
|
7
|
+
Backend-control/
|
|
8
|
+
main/ # always kept, fast-forwarded each sync
|
|
9
|
+
2851-fix_parser_foo/ # one worktree per open PR
|
|
10
|
+
2850-fix_data_ingestion_fmt/
|
|
11
|
+
metering-sdk-control/
|
|
12
|
+
main/
|
|
13
|
+
522-feat_android_mercado_libre_br/
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
A single daily `repo-control sync` clones missing repos, creates worktrees for new PRs, refreshes existing ones, and removes worktrees whose PRs were merged/closed (only if the worktree is clean). First creation runs `mise install` / `uv sync` / `npm install` automatically when those manifests exist.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
From PyPI:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
uv tool install repo-control
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Or with pipx / pip:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pipx install repo-control
|
|
30
|
+
# pip install --user repo-control
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
From a local checkout (editable):
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
uv tool install --editable "/path/to/repo-control"
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Then:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
repo-control setup # interactive config (also auto-triggered on first sync)
|
|
43
|
+
repo-control install-skill # symlinks the bundled Claude skill into ~/.claude/skills/
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Requires `gh` (authenticated) and `uv` on PATH. Python 3.12+.
|
|
47
|
+
|
|
48
|
+
## Usage
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
repo-control sync # daily refresh (auto-runs setup on first invocation)
|
|
52
|
+
repo-control list # table of repo / pr / branch / status / path
|
|
53
|
+
repo-control open <pr> # launch the configured IDE on that worktree
|
|
54
|
+
repo-control open <pr> --ide=code
|
|
55
|
+
repo-control clean # prune stale worktrees (clean only)
|
|
56
|
+
repo-control clean --force # confirm-then-drop dirty ones too
|
|
57
|
+
repo-control setup # re-run interactive config
|
|
58
|
+
repo-control install-skill # symlink the bundled skill (or --uninstall)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`<pr>` is the GitHub PR number. If the same number exists across multiple repos (rare), use `<owner>/<repo>#<n>`.
|
|
62
|
+
|
|
63
|
+
## Config
|
|
64
|
+
|
|
65
|
+
XDG-conformant paths:
|
|
66
|
+
|
|
67
|
+
- Config: `$XDG_CONFIG_HOME/repo-control/config.toml` (default `~/.config/repo-control/config.toml`).
|
|
68
|
+
- Default base path: `$XDG_DATA_HOME/repo-control/` (default `~/.local/share/repo-control/`).
|
|
69
|
+
|
|
70
|
+
```toml
|
|
71
|
+
base_path = "/home/<user>/.local/share/repo-control"
|
|
72
|
+
ide = "idea" # any binary on PATH; suggestions: idea, code, zed
|
|
73
|
+
skip_repos = [] # ["owner/repo", ...] to ignore
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`repo-control setup` is interactive — first sync triggers it automatically; re-run any time to change settings.
|
|
77
|
+
|
|
78
|
+
## Bundled Claude skill
|
|
79
|
+
|
|
80
|
+
The skill ships inside the Python package at `repo_control/skill/SKILL.md`. `repo-control install-skill` symlinks it into `~/.claude/skills/repo-control/` so Claude Code picks it up. Idempotent; `--uninstall` removes the symlink.
|
|
81
|
+
|
|
82
|
+
## Safety properties
|
|
83
|
+
|
|
84
|
+
- Idempotent. Re-running `sync` immediately is a no-op.
|
|
85
|
+
- A worktree with uncommitted work, stashes, or unpushed commits is never auto-removed; sync flags it and moves on.
|
|
86
|
+
- `gh` auth or network failure aborts before any filesystem mutation.
|
|
87
|
+
- If two different `<owner>/<repo>` pairs would collide on `<repo>-control/`, sync skips the second with a warning rather than overwriting.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "repo-control"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Mirror open GitHub PRs as per-repo git worktree clusters"
|
|
5
|
+
requires-python = ">=3.12"
|
|
6
|
+
dependencies = []
|
|
7
|
+
|
|
8
|
+
[project.scripts]
|
|
9
|
+
repo-control = "repo_control.__main__:main"
|
|
10
|
+
|
|
11
|
+
[build-system]
|
|
12
|
+
requires = ["hatchling"]
|
|
13
|
+
build-backend = "hatchling.build"
|
|
14
|
+
|
|
15
|
+
[tool.hatch.build.targets.wheel]
|
|
16
|
+
packages = ["src/repo_control"]
|
|
File without changes
|
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import os
|
|
3
|
+
import shlex
|
|
4
|
+
import shutil
|
|
5
|
+
import sys
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from repo_control import config, gh, git, ide, setup, state
|
|
10
|
+
|
|
11
|
+
SKILL_NAME = "repo-control"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class WorktreeRow:
|
|
16
|
+
repo_slug: str
|
|
17
|
+
pr_number: int | None
|
|
18
|
+
branch: str | None
|
|
19
|
+
path: Path
|
|
20
|
+
status: str
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def main() -> int:
|
|
24
|
+
parser = argparse.ArgumentParser(
|
|
25
|
+
prog="repo-control",
|
|
26
|
+
description="Mirror your open GitHub PRs as per-repo worktree folders under a base path.",
|
|
27
|
+
)
|
|
28
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
29
|
+
sub.add_parser("sync", help="Refresh worktrees from open PRs")
|
|
30
|
+
sub.add_parser("list", help="Show all tracked worktrees")
|
|
31
|
+
open_p = sub.add_parser("open", help="Open a PR's worktree in the IDE")
|
|
32
|
+
open_p.add_argument("pr", help="PR number (or owner/repo#N for disambiguation)")
|
|
33
|
+
open_p.add_argument(
|
|
34
|
+
"--ide",
|
|
35
|
+
default=None,
|
|
36
|
+
help=f"Editor command (e.g. {', '.join(ide.KNOWN)}, or any binary on PATH; quote to pass args)",
|
|
37
|
+
)
|
|
38
|
+
clean_p = sub.add_parser("clean", help="Remove stale worktrees")
|
|
39
|
+
clean_p.add_argument("--force", action="store_true", help="Also drop dirty worktrees (with confirmation)")
|
|
40
|
+
install_p = sub.add_parser("install-skill", help="Symlink the bundled Claude skill into ~/.claude/skills/")
|
|
41
|
+
install_p.add_argument("--uninstall", action="store_true", help="Remove the symlink")
|
|
42
|
+
install_p.add_argument("--force", action="store_true", help="Replace an existing non-symlink at the destination")
|
|
43
|
+
sub.add_parser("setup", help="Interactive first-run configuration (or re-configure later)")
|
|
44
|
+
|
|
45
|
+
args = parser.parse_args()
|
|
46
|
+
try:
|
|
47
|
+
if args.cmd == "sync":
|
|
48
|
+
return cmd_sync()
|
|
49
|
+
if args.cmd == "list":
|
|
50
|
+
return cmd_list()
|
|
51
|
+
if args.cmd == "open":
|
|
52
|
+
return cmd_open(reference=args.pr, ide_override=args.ide)
|
|
53
|
+
if args.cmd == "clean":
|
|
54
|
+
return cmd_clean(force=args.force)
|
|
55
|
+
if args.cmd == "install-skill":
|
|
56
|
+
return cmd_install_skill(uninstall=args.uninstall, force=args.force)
|
|
57
|
+
if args.cmd == "setup":
|
|
58
|
+
return cmd_setup()
|
|
59
|
+
except (gh.GhError, git.GitError, ValueError, RuntimeError) as error:
|
|
60
|
+
print(f"error: {error}", file=sys.stderr)
|
|
61
|
+
return 1
|
|
62
|
+
return 0
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def cmd_sync() -> int:
|
|
66
|
+
gh.check_auth()
|
|
67
|
+
if not config.exists():
|
|
68
|
+
print("No config found; running first-run setup.\n")
|
|
69
|
+
rc = cmd_setup()
|
|
70
|
+
if rc != 0:
|
|
71
|
+
return rc
|
|
72
|
+
print()
|
|
73
|
+
cfg = config.load()
|
|
74
|
+
skip = set(cfg["skip_repos"])
|
|
75
|
+
base = config.base_path(cfg=cfg)
|
|
76
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
|
|
78
|
+
prs = gh.list_open_prs()
|
|
79
|
+
desired: dict[tuple[str, str], dict[int, gh.OpenPR]] = {}
|
|
80
|
+
for pr in prs:
|
|
81
|
+
if pr.base_slug in skip:
|
|
82
|
+
continue
|
|
83
|
+
desired.setdefault((pr.base_owner, pr.base_repo), {})[pr.number] = pr
|
|
84
|
+
|
|
85
|
+
created: list[Path] = []
|
|
86
|
+
refreshed: list[Path] = []
|
|
87
|
+
removed: list[Path] = []
|
|
88
|
+
kept_dirty: list[Path] = []
|
|
89
|
+
setup_steps: dict[Path, list[str]] = {}
|
|
90
|
+
|
|
91
|
+
for (owner, name), prs_by_num in sorted(desired.items()):
|
|
92
|
+
repo_path = state.resolve_repo_dir(base_path=base, owner=owner, name=name)
|
|
93
|
+
main_path = repo_path / "main"
|
|
94
|
+
if repo_path.exists() and main_path.exists():
|
|
95
|
+
existing = git.remote_url(repo_path=main_path)
|
|
96
|
+
existing_parsed = git.parse_owner_repo(url=existing) if existing else None
|
|
97
|
+
if existing_parsed is not None and existing_parsed != (owner, name):
|
|
98
|
+
print(
|
|
99
|
+
f"skipping {owner}/{name}: {repo_path} already mirrors "
|
|
100
|
+
f"{existing_parsed[0]}/{existing_parsed[1]} (name collision)"
|
|
101
|
+
)
|
|
102
|
+
continue
|
|
103
|
+
if not main_path.exists():
|
|
104
|
+
print(f"cloning {owner}/{name} ...")
|
|
105
|
+
git.clone(owner=owner, name=name, target=main_path)
|
|
106
|
+
git.fetch(repo_path=main_path)
|
|
107
|
+
default = git.default_branch(repo_path=main_path)
|
|
108
|
+
git.fast_forward(repo_path=main_path, branch=default)
|
|
109
|
+
|
|
110
|
+
for pr_number, pr in sorted(prs_by_num.items()):
|
|
111
|
+
wt_name = state.worktree_dir_name(pr_number=pr_number, branch=pr.head_branch)
|
|
112
|
+
wt_path = repo_path / wt_name
|
|
113
|
+
local_branch = f"pr-{pr_number}" if pr.is_fork else pr.head_branch
|
|
114
|
+
if wt_path.exists():
|
|
115
|
+
if pr.is_fork and pr.fork_clone_url:
|
|
116
|
+
git.fetch_fork(
|
|
117
|
+
repo_path=main_path,
|
|
118
|
+
fork_url=pr.fork_clone_url,
|
|
119
|
+
head_branch=pr.head_branch,
|
|
120
|
+
local_branch=local_branch,
|
|
121
|
+
)
|
|
122
|
+
else:
|
|
123
|
+
git.fetch(repo_path=main_path, refspec=pr.head_branch)
|
|
124
|
+
refreshed.append(wt_path)
|
|
125
|
+
continue
|
|
126
|
+
if pr.is_fork and pr.fork_clone_url:
|
|
127
|
+
git.fetch_fork(
|
|
128
|
+
repo_path=main_path,
|
|
129
|
+
fork_url=pr.fork_clone_url,
|
|
130
|
+
head_branch=pr.head_branch,
|
|
131
|
+
local_branch=local_branch,
|
|
132
|
+
)
|
|
133
|
+
else:
|
|
134
|
+
git.fetch(repo_path=main_path, refspec=pr.head_branch)
|
|
135
|
+
git.worktree_add(repo_path=main_path, target=wt_path, branch=local_branch)
|
|
136
|
+
created.append(wt_path)
|
|
137
|
+
setup_steps[wt_path] = setup.run_init(worktree_path=wt_path)
|
|
138
|
+
|
|
139
|
+
for repo in state.discover_repos(base_path=base):
|
|
140
|
+
wanted = desired.get((repo.owner, repo.name), {})
|
|
141
|
+
wanted_paths = {repo.path / state.worktree_dir_name(pr_number=pr.number, branch=pr.head_branch) for pr in wanted.values()}
|
|
142
|
+
wanted_paths.add(repo.main_path)
|
|
143
|
+
default = git.default_branch(repo_path=repo.main_path)
|
|
144
|
+
for worktree in state.existing_worktrees(repo=repo):
|
|
145
|
+
wt_path = worktree.path.resolve()
|
|
146
|
+
if wt_path == repo.main_path.resolve():
|
|
147
|
+
continue
|
|
148
|
+
if wt_path in {p.resolve() for p in wanted_paths}:
|
|
149
|
+
continue
|
|
150
|
+
if not git.is_clean(worktree_path=wt_path):
|
|
151
|
+
kept_dirty.append(wt_path)
|
|
152
|
+
continue
|
|
153
|
+
git.worktree_remove(repo_path=repo.main_path, target=wt_path)
|
|
154
|
+
if worktree.branch and worktree.branch != default:
|
|
155
|
+
git.delete_branch(repo_path=repo.main_path, branch=worktree.branch)
|
|
156
|
+
removed.append(wt_path)
|
|
157
|
+
|
|
158
|
+
_print_sync_summary(
|
|
159
|
+
created=created,
|
|
160
|
+
refreshed=refreshed,
|
|
161
|
+
removed=removed,
|
|
162
|
+
kept_dirty=kept_dirty,
|
|
163
|
+
setup_steps=setup_steps,
|
|
164
|
+
)
|
|
165
|
+
return 0
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def cmd_list() -> int:
|
|
169
|
+
rows = _collect_rows()
|
|
170
|
+
if not rows:
|
|
171
|
+
print("no worktrees tracked yet — run `repo-control sync`")
|
|
172
|
+
return 0
|
|
173
|
+
_print_table(rows=rows)
|
|
174
|
+
return 0
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def cmd_open(*, reference: str, ide_override: str | None) -> int:
|
|
178
|
+
cfg = config.load()
|
|
179
|
+
chosen = ide_override or cfg["ide"]
|
|
180
|
+
path = _resolve_pr(reference=reference)
|
|
181
|
+
if path is None:
|
|
182
|
+
print(f"no worktree found for {reference!r}", file=sys.stderr)
|
|
183
|
+
return 1
|
|
184
|
+
ide.launch(ide=chosen, path=path)
|
|
185
|
+
print(f"launching {chosen} on {path}")
|
|
186
|
+
return 0
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def cmd_clean(*, force: bool) -> int:
|
|
190
|
+
gh.check_auth()
|
|
191
|
+
cfg = config.load()
|
|
192
|
+
base = config.base_path(cfg=cfg)
|
|
193
|
+
prs = gh.list_open_prs()
|
|
194
|
+
wanted_by_repo: dict[tuple[str, str], set[Path]] = {}
|
|
195
|
+
for pr in prs:
|
|
196
|
+
key = (pr.base_owner, pr.base_repo)
|
|
197
|
+
repo_path = state.resolve_repo_dir(base_path=base, owner=pr.base_owner, name=pr.base_repo)
|
|
198
|
+
wt = repo_path / state.worktree_dir_name(pr_number=pr.number, branch=pr.head_branch)
|
|
199
|
+
wanted_by_repo.setdefault(key, set()).add(wt.resolve())
|
|
200
|
+
|
|
201
|
+
stale_clean: list[tuple[Path, Path, str | None]] = []
|
|
202
|
+
stale_dirty: list[tuple[Path, Path]] = [] # (main_path, wt_path)
|
|
203
|
+
for repo in state.discover_repos(base_path=base):
|
|
204
|
+
wanted = wanted_by_repo.get((repo.owner, repo.name), set())
|
|
205
|
+
default = git.default_branch(repo_path=repo.main_path)
|
|
206
|
+
for worktree in state.existing_worktrees(repo=repo):
|
|
207
|
+
wt_path = worktree.path.resolve()
|
|
208
|
+
if wt_path == repo.main_path.resolve():
|
|
209
|
+
continue
|
|
210
|
+
if wt_path in wanted:
|
|
211
|
+
continue
|
|
212
|
+
if git.is_clean(worktree_path=wt_path):
|
|
213
|
+
stale_clean.append((repo.main_path, wt_path, worktree.branch if worktree.branch != default else None))
|
|
214
|
+
else:
|
|
215
|
+
stale_dirty.append((repo.main_path, wt_path))
|
|
216
|
+
|
|
217
|
+
for main_path, wt_path, branch in stale_clean:
|
|
218
|
+
git.worktree_remove(repo_path=main_path, target=wt_path)
|
|
219
|
+
if branch:
|
|
220
|
+
git.delete_branch(repo_path=main_path, branch=branch)
|
|
221
|
+
print(f"removed {wt_path}")
|
|
222
|
+
|
|
223
|
+
if not stale_dirty:
|
|
224
|
+
return 0
|
|
225
|
+
if not force:
|
|
226
|
+
print(f"\n{len(stale_dirty)} dirty stale worktree(s) preserved:")
|
|
227
|
+
for _, path in stale_dirty:
|
|
228
|
+
print(f" {path}")
|
|
229
|
+
print("re-run with --force to drop them anyway")
|
|
230
|
+
return 0
|
|
231
|
+
print(f"\nabout to remove {len(stale_dirty)} dirty worktree(s):")
|
|
232
|
+
for _, path in stale_dirty:
|
|
233
|
+
print(f" {path}")
|
|
234
|
+
answer = input("type 'yes' to confirm: ").strip().lower()
|
|
235
|
+
if answer != "yes":
|
|
236
|
+
print("aborted")
|
|
237
|
+
return 1
|
|
238
|
+
for main_path, wt_path in stale_dirty:
|
|
239
|
+
git.worktree_remove(repo_path=main_path, target=wt_path)
|
|
240
|
+
print(f"removed {wt_path}")
|
|
241
|
+
return 0
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _prompt(*, label: str, default: str) -> str:
|
|
245
|
+
try:
|
|
246
|
+
raw = input(f"{label} [{default}]: ").strip()
|
|
247
|
+
except EOFError:
|
|
248
|
+
return default
|
|
249
|
+
return raw or default
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def cmd_setup() -> int:
|
|
253
|
+
cfg = config.load()
|
|
254
|
+
print(f"Writing config to {config.config_path()}\n")
|
|
255
|
+
|
|
256
|
+
base = os.path.expanduser(_prompt(
|
|
257
|
+
label="Base path (where <repo>-control/ folders live)",
|
|
258
|
+
default=cfg["base_path"],
|
|
259
|
+
))
|
|
260
|
+
|
|
261
|
+
ide_choice = _prompt(
|
|
262
|
+
label=f"Default editor (suggestions: {', '.join(ide.KNOWN)}; any binary on PATH works, quote to pass args)",
|
|
263
|
+
default=cfg["ide"],
|
|
264
|
+
)
|
|
265
|
+
binary = shlex.split(ide_choice)[0] if ide_choice else ""
|
|
266
|
+
if binary and shutil.which(binary) is None:
|
|
267
|
+
print(f" warning: {binary!r} not on PATH — saving anyway; install or fix before `open`")
|
|
268
|
+
|
|
269
|
+
skip_raw = _prompt(
|
|
270
|
+
label="Repos to skip (comma-separated owner/repo)",
|
|
271
|
+
default=", ".join(cfg["skip_repos"]),
|
|
272
|
+
)
|
|
273
|
+
skip_repos = [item.strip() for item in skip_raw.split(",") if item.strip()]
|
|
274
|
+
|
|
275
|
+
Path(base).mkdir(parents=True, exist_ok=True)
|
|
276
|
+
written = config.write(base_path=base, ide=ide_choice, skip_repos=skip_repos)
|
|
277
|
+
print(f"\nWrote {written}")
|
|
278
|
+
return 0
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def cmd_install_skill(*, uninstall: bool, force: bool) -> int:
|
|
282
|
+
source = _bundled_skill_source()
|
|
283
|
+
target = Path.home() / ".claude" / "skills" / SKILL_NAME
|
|
284
|
+
|
|
285
|
+
if uninstall:
|
|
286
|
+
if not target.exists() and not target.is_symlink():
|
|
287
|
+
print(f"{target} does not exist; nothing to remove")
|
|
288
|
+
return 0
|
|
289
|
+
if target.is_symlink():
|
|
290
|
+
target.unlink()
|
|
291
|
+
print(f"unlinked {target}")
|
|
292
|
+
return 0
|
|
293
|
+
if not force:
|
|
294
|
+
print(
|
|
295
|
+
f"refusing to remove {target}: not a symlink. "
|
|
296
|
+
"Inspect manually or re-run with --force.",
|
|
297
|
+
file=sys.stderr,
|
|
298
|
+
)
|
|
299
|
+
return 1
|
|
300
|
+
if target.is_dir():
|
|
301
|
+
_rmtree(target)
|
|
302
|
+
else:
|
|
303
|
+
target.unlink()
|
|
304
|
+
print(f"removed {target}")
|
|
305
|
+
return 0
|
|
306
|
+
|
|
307
|
+
if not source.exists():
|
|
308
|
+
print(
|
|
309
|
+
f"bundled skill source not found at {source}; "
|
|
310
|
+
"is this an editable install?",
|
|
311
|
+
file=sys.stderr,
|
|
312
|
+
)
|
|
313
|
+
return 1
|
|
314
|
+
|
|
315
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
316
|
+
if target.is_symlink():
|
|
317
|
+
if target.resolve() == source.resolve():
|
|
318
|
+
print(f"{target} -> {source} already in place")
|
|
319
|
+
return 0
|
|
320
|
+
target.unlink()
|
|
321
|
+
elif target.exists():
|
|
322
|
+
if not force:
|
|
323
|
+
print(
|
|
324
|
+
f"refusing to overwrite {target}: not a symlink. "
|
|
325
|
+
"Back it up and re-run with --force.",
|
|
326
|
+
file=sys.stderr,
|
|
327
|
+
)
|
|
328
|
+
return 1
|
|
329
|
+
if target.is_dir():
|
|
330
|
+
_rmtree(target)
|
|
331
|
+
else:
|
|
332
|
+
target.unlink()
|
|
333
|
+
|
|
334
|
+
target.symlink_to(source, target_is_directory=True)
|
|
335
|
+
print(f"linked {target} -> {source}")
|
|
336
|
+
return 0
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _rmtree(path: Path) -> None:
|
|
340
|
+
import shutil
|
|
341
|
+
shutil.rmtree(path)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _bundled_skill_source() -> Path:
|
|
345
|
+
return Path(__file__).resolve().parent / "skill"
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _collect_rows() -> list[WorktreeRow]:
|
|
349
|
+
rows: list[WorktreeRow] = []
|
|
350
|
+
cfg = config.load()
|
|
351
|
+
base = config.base_path(cfg=cfg)
|
|
352
|
+
for repo in state.discover_repos(base_path=base):
|
|
353
|
+
default = git.default_branch(repo_path=repo.main_path)
|
|
354
|
+
for worktree in state.existing_worktrees(repo=repo):
|
|
355
|
+
wt_path = worktree.path
|
|
356
|
+
if wt_path.resolve() == repo.main_path.resolve():
|
|
357
|
+
rows.append(WorktreeRow(
|
|
358
|
+
repo_slug=repo.slug,
|
|
359
|
+
pr_number=None,
|
|
360
|
+
branch=default,
|
|
361
|
+
path=wt_path,
|
|
362
|
+
status="main",
|
|
363
|
+
))
|
|
364
|
+
continue
|
|
365
|
+
pr_number = _pr_number_from_dir(name=wt_path.name)
|
|
366
|
+
status = "clean" if git.is_clean(worktree_path=wt_path) else "dirty"
|
|
367
|
+
rows.append(WorktreeRow(
|
|
368
|
+
repo_slug=repo.slug,
|
|
369
|
+
pr_number=pr_number,
|
|
370
|
+
branch=worktree.branch,
|
|
371
|
+
path=wt_path,
|
|
372
|
+
status=status,
|
|
373
|
+
))
|
|
374
|
+
return rows
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _pr_number_from_dir(*, name: str) -> int | None:
|
|
378
|
+
head, _, _ = name.partition("-")
|
|
379
|
+
if head.isdigit():
|
|
380
|
+
return int(head)
|
|
381
|
+
return None
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _resolve_pr(*, reference: str) -> Path | None:
|
|
385
|
+
rows = _collect_rows()
|
|
386
|
+
if "#" in reference:
|
|
387
|
+
slug, _, num = reference.partition("#")
|
|
388
|
+
target = int(num)
|
|
389
|
+
for row in rows:
|
|
390
|
+
if row.repo_slug == slug and row.pr_number == target:
|
|
391
|
+
return row.path
|
|
392
|
+
return None
|
|
393
|
+
if not reference.isdigit():
|
|
394
|
+
return None
|
|
395
|
+
target = int(reference)
|
|
396
|
+
matches = [row for row in rows if row.pr_number == target]
|
|
397
|
+
if len(matches) == 0:
|
|
398
|
+
return None
|
|
399
|
+
if len(matches) > 1:
|
|
400
|
+
print("ambiguous PR number; matches:", file=sys.stderr)
|
|
401
|
+
for row in matches:
|
|
402
|
+
print(f" {row.repo_slug}#{row.pr_number} {row.path}", file=sys.stderr)
|
|
403
|
+
return None
|
|
404
|
+
return matches[0].path
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _print_table(*, rows: list[WorktreeRow]) -> None:
|
|
408
|
+
headers = ("repo", "pr", "branch", "status", "path")
|
|
409
|
+
data = [
|
|
410
|
+
(
|
|
411
|
+
row.repo_slug,
|
|
412
|
+
str(row.pr_number) if row.pr_number is not None else "-",
|
|
413
|
+
row.branch or "-",
|
|
414
|
+
row.status,
|
|
415
|
+
str(row.path),
|
|
416
|
+
)
|
|
417
|
+
for row in rows
|
|
418
|
+
]
|
|
419
|
+
widths = [len(h) for h in headers]
|
|
420
|
+
for record in data:
|
|
421
|
+
for i, cell in enumerate(record):
|
|
422
|
+
widths[i] = max(widths[i], len(cell))
|
|
423
|
+
fmt = " ".join(f"{{:<{w}}}" for w in widths)
|
|
424
|
+
print(fmt.format(*headers))
|
|
425
|
+
print(fmt.format(*["-" * w for w in widths]))
|
|
426
|
+
for record in data:
|
|
427
|
+
print(fmt.format(*record))
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _print_sync_summary(
|
|
431
|
+
*,
|
|
432
|
+
created: list[Path],
|
|
433
|
+
refreshed: list[Path],
|
|
434
|
+
removed: list[Path],
|
|
435
|
+
kept_dirty: list[Path],
|
|
436
|
+
setup_steps: dict[Path, list[str]],
|
|
437
|
+
) -> None:
|
|
438
|
+
print()
|
|
439
|
+
print(f"created: {len(created)}")
|
|
440
|
+
for path in created:
|
|
441
|
+
steps = setup_steps.get(path, [])
|
|
442
|
+
suffix = f" [{', '.join(steps)}]" if steps else ""
|
|
443
|
+
print(f" + {path}{suffix}")
|
|
444
|
+
print(f"refreshed: {len(refreshed)}")
|
|
445
|
+
for path in refreshed:
|
|
446
|
+
print(f" ~ {path}")
|
|
447
|
+
print(f"removed: {len(removed)}")
|
|
448
|
+
for path in removed:
|
|
449
|
+
print(f" - {path}")
|
|
450
|
+
if kept_dirty:
|
|
451
|
+
print(f"kept dirty: {len(kept_dirty)} (PR closed but worktree has uncommitted work)")
|
|
452
|
+
for path in kept_dirty:
|
|
453
|
+
print(f" ! {path}")
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
if __name__ == "__main__":
|
|
457
|
+
sys.exit(main())
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import tomllib
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def xdg_config_home() -> Path:
|
|
7
|
+
raw = os.environ.get("XDG_CONFIG_HOME")
|
|
8
|
+
return Path(raw) if raw else Path.home() / ".config"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def xdg_data_home() -> Path:
|
|
12
|
+
raw = os.environ.get("XDG_DATA_HOME")
|
|
13
|
+
return Path(raw) if raw else Path.home() / ".local" / "share"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
DEFAULTS: dict = {
|
|
17
|
+
"base_path": str(xdg_data_home() / "repo-control"),
|
|
18
|
+
"ide": "idea",
|
|
19
|
+
"skip_repos": [],
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def config_path() -> Path:
|
|
24
|
+
return xdg_config_home() / "repo-control" / "config.toml"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def exists() -> bool:
|
|
28
|
+
return config_path().exists()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def load() -> dict:
|
|
32
|
+
path = config_path()
|
|
33
|
+
if not path.exists():
|
|
34
|
+
return dict(DEFAULTS)
|
|
35
|
+
with path.open("rb") as handle:
|
|
36
|
+
data = tomllib.load(handle)
|
|
37
|
+
return {**DEFAULTS, **data}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def write(*, base_path: str, ide: str, skip_repos: list[str]) -> Path:
|
|
41
|
+
path = config_path()
|
|
42
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
skip_repr = ", ".join(f'"{r}"' for r in skip_repos)
|
|
44
|
+
path.write_text(
|
|
45
|
+
f'base_path = "{base_path}"\n'
|
|
46
|
+
f'ide = "{ide}"\n'
|
|
47
|
+
f"skip_repos = [{skip_repr}]\n"
|
|
48
|
+
)
|
|
49
|
+
return path
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def base_path(*, cfg: dict | None = None) -> Path:
|
|
53
|
+
settings = cfg or load()
|
|
54
|
+
return Path(os.path.expanduser(settings["base_path"]))
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import subprocess
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
QUERY = """
|
|
6
|
+
query {
|
|
7
|
+
search(query: "author:@me is:pr state:open archived:false", type: ISSUE, first: 100) {
|
|
8
|
+
nodes {
|
|
9
|
+
... on PullRequest {
|
|
10
|
+
number
|
|
11
|
+
title
|
|
12
|
+
url
|
|
13
|
+
isCrossRepository
|
|
14
|
+
headRefName
|
|
15
|
+
baseRepository { name owner { login } }
|
|
16
|
+
headRepository { name url }
|
|
17
|
+
headRepositoryOwner { login }
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class OpenPR:
|
|
27
|
+
number: int
|
|
28
|
+
title: str
|
|
29
|
+
url: str
|
|
30
|
+
base_owner: str
|
|
31
|
+
base_repo: str
|
|
32
|
+
head_branch: str
|
|
33
|
+
is_fork: bool
|
|
34
|
+
fork_clone_url: str | None
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def base_slug(self) -> str:
|
|
38
|
+
return f"{self.base_owner}/{self.base_repo}"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class GhError(RuntimeError):
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def check_auth() -> None:
|
|
46
|
+
result = subprocess.run(
|
|
47
|
+
["gh", "auth", "status"],
|
|
48
|
+
capture_output=True,
|
|
49
|
+
text=True,
|
|
50
|
+
)
|
|
51
|
+
if result.returncode != 0:
|
|
52
|
+
raise GhError(
|
|
53
|
+
"gh is not authenticated. Run `gh auth login` and re-try.\n" + result.stderr.strip()
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def list_open_prs() -> list[OpenPR]:
|
|
58
|
+
try:
|
|
59
|
+
result = subprocess.run(
|
|
60
|
+
["gh", "api", "graphql", "-f", f"query={QUERY}"],
|
|
61
|
+
check=True,
|
|
62
|
+
capture_output=True,
|
|
63
|
+
text=True,
|
|
64
|
+
)
|
|
65
|
+
except subprocess.CalledProcessError as error:
|
|
66
|
+
raise GhError(f"gh api graphql failed: {error.stderr.strip()}") from error
|
|
67
|
+
payload = json.loads(result.stdout)
|
|
68
|
+
if "errors" in payload:
|
|
69
|
+
raise GhError(f"graphql errors: {payload['errors']}")
|
|
70
|
+
nodes = payload["data"]["search"]["nodes"]
|
|
71
|
+
out: list[OpenPR] = []
|
|
72
|
+
for node in nodes:
|
|
73
|
+
if not node:
|
|
74
|
+
continue
|
|
75
|
+
head_repo = node.get("headRepository") or {}
|
|
76
|
+
out.append(
|
|
77
|
+
OpenPR(
|
|
78
|
+
number=node["number"],
|
|
79
|
+
title=node["title"],
|
|
80
|
+
url=node["url"],
|
|
81
|
+
base_owner=node["baseRepository"]["owner"]["login"],
|
|
82
|
+
base_repo=node["baseRepository"]["name"],
|
|
83
|
+
head_branch=node["headRefName"],
|
|
84
|
+
is_fork=bool(node["isCrossRepository"]),
|
|
85
|
+
fork_clone_url=(f"{head_repo['url']}.git" if node["isCrossRepository"] and head_repo else None),
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
return out
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class GitError(RuntimeError):
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class Worktree:
|
|
12
|
+
path: Path
|
|
13
|
+
branch: str | None # None for detached HEAD
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _run(args: list[str], *, cwd: Path | None = None, check: bool = True) -> subprocess.CompletedProcess:
|
|
17
|
+
try:
|
|
18
|
+
return subprocess.run(
|
|
19
|
+
args,
|
|
20
|
+
cwd=cwd,
|
|
21
|
+
check=check,
|
|
22
|
+
capture_output=True,
|
|
23
|
+
text=True,
|
|
24
|
+
)
|
|
25
|
+
except subprocess.CalledProcessError as error:
|
|
26
|
+
raise GitError(f"{' '.join(args)} failed: {error.stderr.strip()}") from error
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def clone(*, owner: str, name: str, target: Path) -> None:
|
|
30
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
31
|
+
_run(["gh", "repo", "clone", f"{owner}/{name}", str(target)])
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def default_branch(*, repo_path: Path) -> str:
|
|
35
|
+
result = _run(
|
|
36
|
+
["git", "symbolic-ref", "--short", "refs/remotes/origin/HEAD"],
|
|
37
|
+
cwd=repo_path,
|
|
38
|
+
check=False,
|
|
39
|
+
)
|
|
40
|
+
if result.returncode == 0:
|
|
41
|
+
return result.stdout.strip().removeprefix("origin/")
|
|
42
|
+
for candidate in ("main", "master"):
|
|
43
|
+
check = _run(
|
|
44
|
+
["git", "rev-parse", "--verify", f"refs/heads/{candidate}"],
|
|
45
|
+
cwd=repo_path,
|
|
46
|
+
check=False,
|
|
47
|
+
)
|
|
48
|
+
if check.returncode == 0:
|
|
49
|
+
return candidate
|
|
50
|
+
raise GitError(f"could not determine default branch for {repo_path}")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def remote_url(*, repo_path: Path) -> str | None:
|
|
54
|
+
result = _run(
|
|
55
|
+
["git", "config", "--get", "remote.origin.url"],
|
|
56
|
+
cwd=repo_path,
|
|
57
|
+
check=False,
|
|
58
|
+
)
|
|
59
|
+
if result.returncode != 0:
|
|
60
|
+
return None
|
|
61
|
+
return result.stdout.strip() or None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def parse_owner_repo(*, url: str) -> tuple[str, str] | None:
|
|
65
|
+
cleaned = url.removesuffix(".git").rstrip("/")
|
|
66
|
+
for marker in ("github.com:", "github.com/"):
|
|
67
|
+
idx = cleaned.find(marker)
|
|
68
|
+
if idx == -1:
|
|
69
|
+
continue
|
|
70
|
+
tail = cleaned[idx + len(marker):]
|
|
71
|
+
parts = tail.split("/")
|
|
72
|
+
if len(parts) < 2:
|
|
73
|
+
return None
|
|
74
|
+
return parts[0], parts[1]
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def fetch(*, repo_path: Path, refspec: str | None = None) -> None:
|
|
79
|
+
args = ["git", "fetch", "origin", "--prune"]
|
|
80
|
+
if refspec is not None:
|
|
81
|
+
args.append(refspec)
|
|
82
|
+
_run(args, cwd=repo_path)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def fetch_fork(*, repo_path: Path, fork_url: str, head_branch: str, local_branch: str) -> None:
|
|
86
|
+
_run(
|
|
87
|
+
[
|
|
88
|
+
"git",
|
|
89
|
+
"fetch",
|
|
90
|
+
fork_url,
|
|
91
|
+
f"+{head_branch}:refs/heads/{local_branch}",
|
|
92
|
+
],
|
|
93
|
+
cwd=repo_path,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def fast_forward(*, repo_path: Path, branch: str) -> bool:
|
|
98
|
+
head = _run(["git", "symbolic-ref", "--short", "HEAD"], cwd=repo_path, check=False)
|
|
99
|
+
on_branch = head.returncode == 0 and head.stdout.strip() == branch
|
|
100
|
+
if not on_branch:
|
|
101
|
+
return False
|
|
102
|
+
if not is_clean(worktree_path=repo_path):
|
|
103
|
+
return False
|
|
104
|
+
result = _run(
|
|
105
|
+
["git", "merge", "--ff-only", f"origin/{branch}"],
|
|
106
|
+
cwd=repo_path,
|
|
107
|
+
check=False,
|
|
108
|
+
)
|
|
109
|
+
return result.returncode == 0
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def list_worktrees(*, repo_path: Path) -> list[Worktree]:
|
|
113
|
+
result = _run(["git", "worktree", "list", "--porcelain"], cwd=repo_path)
|
|
114
|
+
out: list[Worktree] = []
|
|
115
|
+
current_path: Path | None = None
|
|
116
|
+
current_branch: str | None = None
|
|
117
|
+
detached = False
|
|
118
|
+
for line in result.stdout.splitlines():
|
|
119
|
+
if line.startswith("worktree "):
|
|
120
|
+
if current_path is not None:
|
|
121
|
+
out.append(Worktree(path=current_path, branch=None if detached else current_branch))
|
|
122
|
+
current_path = Path(line.removeprefix("worktree ").strip())
|
|
123
|
+
current_branch = None
|
|
124
|
+
detached = False
|
|
125
|
+
continue
|
|
126
|
+
if line.startswith("branch "):
|
|
127
|
+
current_branch = line.removeprefix("branch ").strip().removeprefix("refs/heads/")
|
|
128
|
+
continue
|
|
129
|
+
if line.startswith("detached"):
|
|
130
|
+
detached = True
|
|
131
|
+
continue
|
|
132
|
+
if current_path is not None:
|
|
133
|
+
out.append(Worktree(path=current_path, branch=None if detached else current_branch))
|
|
134
|
+
return out
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def worktree_add(*, repo_path: Path, target: Path, branch: str) -> None:
|
|
138
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
139
|
+
_run(
|
|
140
|
+
["git", "worktree", "add", str(target), branch],
|
|
141
|
+
cwd=repo_path,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def worktree_remove(*, repo_path: Path, target: Path) -> None:
|
|
146
|
+
_run(
|
|
147
|
+
["git", "worktree", "remove", str(target)],
|
|
148
|
+
cwd=repo_path,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def delete_branch(*, repo_path: Path, branch: str) -> None:
|
|
153
|
+
_run(["git", "branch", "-D", branch], cwd=repo_path, check=False)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def is_clean(*, worktree_path: Path) -> bool:
|
|
157
|
+
status = _run(["git", "status", "--porcelain"], cwd=worktree_path)
|
|
158
|
+
if status.stdout.strip():
|
|
159
|
+
return False
|
|
160
|
+
stash = _run(["git", "stash", "list"], cwd=worktree_path)
|
|
161
|
+
if stash.stdout.strip():
|
|
162
|
+
return False
|
|
163
|
+
head = _run(["git", "symbolic-ref", "--short", "HEAD"], cwd=worktree_path, check=False)
|
|
164
|
+
if head.returncode != 0:
|
|
165
|
+
return True
|
|
166
|
+
branch = head.stdout.strip()
|
|
167
|
+
upstream = _run(
|
|
168
|
+
["git", "rev-parse", "--abbrev-ref", f"{branch}@{{upstream}}"],
|
|
169
|
+
cwd=worktree_path,
|
|
170
|
+
check=False,
|
|
171
|
+
)
|
|
172
|
+
if upstream.returncode != 0:
|
|
173
|
+
return False
|
|
174
|
+
ahead = _run(
|
|
175
|
+
["git", "rev-list", "--count", f"{upstream.stdout.strip()}..{branch}"],
|
|
176
|
+
cwd=worktree_path,
|
|
177
|
+
check=False,
|
|
178
|
+
)
|
|
179
|
+
if ahead.returncode != 0:
|
|
180
|
+
return True
|
|
181
|
+
return ahead.stdout.strip() == "0"
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import shlex
|
|
2
|
+
import shutil
|
|
3
|
+
import subprocess
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
KNOWN = ("idea", "code", "zed")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def launch(*, ide: str, path: Path) -> None:
|
|
10
|
+
parts = shlex.split(ide)
|
|
11
|
+
if not parts:
|
|
12
|
+
raise ValueError("ide is empty")
|
|
13
|
+
binary, *extra = parts
|
|
14
|
+
if shutil.which(binary) is None:
|
|
15
|
+
raise RuntimeError(
|
|
16
|
+
f"{binary!r} not on PATH; install its CLI launcher or pick a different --ide"
|
|
17
|
+
)
|
|
18
|
+
subprocess.Popen(
|
|
19
|
+
[binary, *extra, str(path)],
|
|
20
|
+
start_new_session=True,
|
|
21
|
+
stdout=subprocess.DEVNULL,
|
|
22
|
+
stderr=subprocess.DEVNULL,
|
|
23
|
+
)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import shutil
|
|
2
|
+
import subprocess
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def run_init(*, worktree_path: Path) -> list[str]:
|
|
7
|
+
"""Run mise/uv/npm init for a freshly-created worktree. Returns list of steps run."""
|
|
8
|
+
steps: list[str] = []
|
|
9
|
+
if (worktree_path / "mise.toml").exists() and shutil.which("mise"):
|
|
10
|
+
subprocess.run(["mise", "install"], cwd=worktree_path, check=False)
|
|
11
|
+
steps.append("mise install")
|
|
12
|
+
if (worktree_path / "pyproject.toml").exists() and shutil.which("uv"):
|
|
13
|
+
subprocess.run(["uv", "sync"], cwd=worktree_path, check=False)
|
|
14
|
+
steps.append("uv sync")
|
|
15
|
+
if (worktree_path / "package.json").exists() and shutil.which("npm"):
|
|
16
|
+
subprocess.run(["npm", "install"], cwd=worktree_path, check=False)
|
|
17
|
+
steps.append("npm install")
|
|
18
|
+
return steps
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: repo-control
|
|
3
|
+
description: Use when the user says "sync my PRs", "sync repo-control", "refresh my worktrees", "open PR <n>", "what am I working on", or otherwise wants to inspect or refresh the local per-repo worktree mirrors created by the `repo-control` CLI. Wraps the `repo-control` command.
|
|
4
|
+
allowed-tools: Bash
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# repo-control
|
|
8
|
+
|
|
9
|
+
For every open PR the user has authored on GitHub, the `repo-control` CLI scaffolds a sibling `<repo>-control/` folder under one configurable base path (default `~/.local/share/repo-control/`, XDG_DATA_HOME). Each folder holds the repo's `main/` worktree plus one worktree per open PR.
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
<base_path>/
|
|
13
|
+
Backend-control/
|
|
14
|
+
main/ # always kept
|
|
15
|
+
2851-fix_parser_foo/ # one worktree per open PR
|
|
16
|
+
2850-fix_data_ingestion_fmt/
|
|
17
|
+
metering-sdk-control/
|
|
18
|
+
main/
|
|
19
|
+
522-feat_android_mercado_libre_br/
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Configuration lives at `~/.config/repo-control/config.toml` (XDG_CONFIG_HOME). Created interactively by `repo-control setup` or on first `sync`.
|
|
23
|
+
|
|
24
|
+
```toml
|
|
25
|
+
base_path = "/home/<user>/.local/share/repo-control"
|
|
26
|
+
ide = "idea" # any binary on PATH; suggestions: idea, code, zed
|
|
27
|
+
skip_repos = [] # ["owner/repo", ...]
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Preflight (once per session)
|
|
31
|
+
|
|
32
|
+
Before the first `repo-control` invocation, confirm the environment:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
command -v repo-control && gh auth status
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
If either fails, halt and report the exact remediation:
|
|
39
|
+
|
|
40
|
+
- `gh auth status` failure → `gh auth login` (user runs themselves).
|
|
41
|
+
- `repo-control` missing → install via `uv tool install --editable <repo-path>` or `uv tool install repo-control` (once published).
|
|
42
|
+
|
|
43
|
+
Do not retry blindly. Do not bootstrap silently.
|
|
44
|
+
|
|
45
|
+
## Mode dispatch
|
|
46
|
+
|
|
47
|
+
| User says | Run |
|
|
48
|
+
|---|---|
|
|
49
|
+
| "sync my PRs", "sync repo-control", "refresh worktrees" | `repo-control sync` |
|
|
50
|
+
| "list my worktrees", "what am I working on" | `repo-control list` |
|
|
51
|
+
| "open PR 5432", "open the parser fix" (after resolving) | `repo-control open <pr>` |
|
|
52
|
+
| "clean stale worktrees", "prune merged ones" | `repo-control clean` |
|
|
53
|
+
| "set up repo-control", "reconfigure repo-control" | `repo-control setup` |
|
|
54
|
+
| "install the skill", "link the skill" | `repo-control install-skill` |
|
|
55
|
+
|
|
56
|
+
## sync
|
|
57
|
+
|
|
58
|
+
Run `repo-control sync` and show the summary verbatim. If no config exists yet, sync will auto-launch `setup` which is interactive — let it complete. After sync finishes, surface anything that needs the user's attention:
|
|
59
|
+
|
|
60
|
+
- Worktrees kept dirty (PR closed but uncommitted work present) — call them out by path.
|
|
61
|
+
- Fork PRs that failed to fetch — print the error.
|
|
62
|
+
|
|
63
|
+
Never auto-rerun on failure. Never run `clean` as a follow-up unless the user asks.
|
|
64
|
+
|
|
65
|
+
## open
|
|
66
|
+
|
|
67
|
+
If the user's reference is ambiguous (e.g. "open the parser fix"), run `repo-control list` first and show matches, then ask which one. Once the PR is unambiguous, invoke `repo-control open <pr>`. Pass `--ide=<binary>` (e.g. `code`, `idea`, `zed`, or any command on PATH; quote to include args) to override the configured default for one call.
|
|
68
|
+
|
|
69
|
+
## clean
|
|
70
|
+
|
|
71
|
+
Never run `repo-control clean --force` without explicit user authorisation in this turn. The plain `repo-control clean` removes only worktrees that are clean and whose PR no longer exists — that's safe and you can run it when asked.
|
|
72
|
+
|
|
73
|
+
## setup
|
|
74
|
+
|
|
75
|
+
Interactive. Prompts for base_path, ide, skip_repos. Re-running it overwrites the existing config. Use when the user explicitly wants to change settings.
|
|
76
|
+
|
|
77
|
+
## install-skill
|
|
78
|
+
|
|
79
|
+
Symlinks the skill (bundled inside the installed Python package) to `~/.claude/skills/repo-control/`. Idempotent. Run when:
|
|
80
|
+
|
|
81
|
+
- The user just installed the package on a new machine.
|
|
82
|
+
- The user moved or reinstalled the package.
|
|
83
|
+
- `--uninstall` removes the symlink.
|
|
84
|
+
|
|
85
|
+
## Out of scope
|
|
86
|
+
|
|
87
|
+
This skill never:
|
|
88
|
+
|
|
89
|
+
- Commits, pushes, or opens PRs (that's `graphite-cli` / `parley`).
|
|
90
|
+
- Auto-syncs on session start.
|
|
91
|
+
- Modifies the user's working tree in any worktree other than via `git worktree add` / `remove`.
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from repo_control import git
|
|
5
|
+
|
|
6
|
+
REPO_DIR_SUFFIX = "-control"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class RepoDir:
|
|
11
|
+
owner: str
|
|
12
|
+
name: str
|
|
13
|
+
path: Path
|
|
14
|
+
|
|
15
|
+
@property
|
|
16
|
+
def main_path(self) -> Path:
|
|
17
|
+
return self.path / "main"
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def slug(self) -> str:
|
|
21
|
+
return f"{self.owner}/{self.name}"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def slugify_branch(*, branch: str) -> str:
|
|
25
|
+
return branch.replace("/", "-").replace(" ", "-")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def repo_dir_name(*, name: str) -> str:
|
|
29
|
+
return f"{name}{REPO_DIR_SUFFIX}"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def worktree_dir_name(*, pr_number: int, branch: str) -> str:
|
|
33
|
+
return f"{pr_number}-{slugify_branch(branch=branch)}"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def resolve_repo_dir(*, base_path: Path, owner: str, name: str) -> Path:
|
|
37
|
+
return base_path / repo_dir_name(name=name)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def discover_repos(*, base_path: Path) -> list[RepoDir]:
|
|
41
|
+
"""Walk <base>/*-control/ dirs and recover (owner, name) from each main/'s remote."""
|
|
42
|
+
if not base_path.exists():
|
|
43
|
+
return []
|
|
44
|
+
out: list[RepoDir] = []
|
|
45
|
+
for child in sorted(base_path.iterdir()):
|
|
46
|
+
if not child.is_dir() or child.name.startswith("."):
|
|
47
|
+
continue
|
|
48
|
+
if not child.name.endswith(REPO_DIR_SUFFIX):
|
|
49
|
+
continue
|
|
50
|
+
main = child / "main"
|
|
51
|
+
if not main.exists():
|
|
52
|
+
continue
|
|
53
|
+
url = git.remote_url(repo_path=main)
|
|
54
|
+
if url is None:
|
|
55
|
+
continue
|
|
56
|
+
parsed = git.parse_owner_repo(url=url)
|
|
57
|
+
if parsed is None:
|
|
58
|
+
continue
|
|
59
|
+
owner, name = parsed
|
|
60
|
+
out.append(RepoDir(owner=owner, name=name, path=child))
|
|
61
|
+
return out
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def existing_worktrees(*, repo: RepoDir) -> list[git.Worktree]:
|
|
65
|
+
if not repo.main_path.exists():
|
|
66
|
+
return []
|
|
67
|
+
return git.list_worktrees(repo_path=repo.main_path)
|