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.
@@ -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,13 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *$py.class
4
+
5
+ .venv/
6
+ .python-version
7
+ dist/
8
+ build/
9
+ *.egg-info/
10
+
11
+ .ruff_cache/
12
+ .pytest_cache/
13
+ .mypy_cache/
@@ -0,0 +1,5 @@
1
+ Metadata-Version: 2.4
2
+ Name: repo-control
3
+ Version: 0.1.0
4
+ Summary: Mirror open GitHub PRs as per-repo git worktree clusters
5
+ Requires-Python: >=3.12
@@ -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)