diff --git a/plugins/source-control/.claude-plugin/plugin.json b/plugins/source-control/.claude-plugin/plugin.json index 5ff5f9d3fb..a85b245e3d 100644 --- a/plugins/source-control/.claude-plugin/plugin.json +++ b/plugins/source-control/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "source-control", - "version": "0.15.1", + "version": "0.15.2", "description": "Git and GitHub delivery workflow: /commit (Conventional Commits + Co-Authored-By trailer via safe heredoc mechanics), /pull-request (prep, create, CI monitoring, review-comment triage, merge, CI-log fetch), /babysit-prs (self-pacing fleet loop — safe by default; opt-in worker/autopilot tiers add gate-checked merge and thread resolution behind a deterministic Python engine), /worktree (create, status, cleanup, audit for parallel-session isolation), /setup (check the effective commit-subject / PR-title convention merged across its config layers and the babysit-prs config, or apply — interview the repo and write the convention config to a chosen layer), and /resolve-conflicts (intent-first merge/rebase conflict resolution with a semantic-conflict sweep — never --abort). The commit-subject / PR-title convention is configurable via a source-control.md config written by a re-runnable setup skill, layered across a ~/.claude user-global file, the tracked team file, and a gitignored .claude/source-control.local.md personal overlay merged per key; Conventional Commits is the default when no convention is declared.", "author": { "name": "Melodic Software", diff --git a/plugins/source-control/CHANGELOG.md b/plugins/source-control/CHANGELOG.md index d71393ec19..6df0e2592e 100644 --- a/plugins/source-control/CHANGELOG.md +++ b/plugins/source-control/CHANGELOG.md @@ -3,6 +3,24 @@ All notable changes to the `source-control` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.15.2] + +### Fixed + +- **`babysit-prs` worktree pruner no longer hard-depends on `ghq` (#438).** The engine-backed + pruner (`prune_babysit_worktrees.py`) resolved a linked worktree's main checkout by shelling out + to `ghq` — the plugin author's personal repo-layout tool — and raised a hard `RuntimeError` + ("install ghq or set ghq.root") for any consumer without it, an undeclared prerequisite absent + from the README's "runs on `git`, `gh`, `jq`" contract. `repo_path` now resolves the main + checkout natively from the worktree's own gitdir/commondir pointer via + `git rev-parse --git-common-dir` (parent of the shared `.git` for a standard clone, the git + directory itself for a bare-clone hub), so cleanup works with only `git` present regardless of + repo layout. `ghq` is removed from the executable allowlist entirely — native resolution is + strictly more correct than ghq's guess from a configured root plus an assumed + `/github.com/owner/repo` layout, so no optional ghq path is retained. Adds a hermetic + regression test that exercises resolution and removal against a real linked worktree with no + `ghq` on `PATH`. + ## [0.15.1] ### Changed diff --git a/plugins/source-control/skills/babysit-prs/scripts/prune_babysit_worktrees.py b/plugins/source-control/skills/babysit-prs/scripts/prune_babysit_worktrees.py index 40b28b4f74..039474409f 100755 --- a/plugins/source-control/skills/babysit-prs/scripts/prune_babysit_worktrees.py +++ b/plugins/source-control/skills/babysit-prs/scripts/prune_babysit_worktrees.py @@ -18,7 +18,7 @@ from babysit_util import configure_stdio, run_command WORKTREE_RE = re.compile(r"^(?P.+?)__(?P.+?)__pr-(?P\d+)$") -ALLOWED_EXECUTABLES = ("git", "gh", "ghq") +ALLOWED_EXECUTABLES = ("git", "gh") @dataclass @@ -57,21 +57,22 @@ def resolve_root(value: str | None) -> Path: return path -def repo_path(owner: str, repo: str) -> Path: - # Prefer ghq so non-default roots (and prompt-configured reposRoot) resolve correctly. - proc = run(["ghq", "list", "-p", f"{owner}/{repo}"], check=False) - lines = [line.strip() for line in proc.stdout.splitlines() if line.strip()] - if lines: - return Path(lines[0]) +def repo_path(worktree_path: Path) -> Path: + """Resolve the main checkout a linked worktree belongs to, from git alone. - proc = run(["git", "config", "--get", "ghq.root"], check=False) - root = (proc.stdout or "").strip() - if root: - return Path(root) / "github.com" / owner / repo - - raise RuntimeError( - f"unable to resolve main checkout for {owner}/{repo}; install ghq or set ghq.root" - ) + A linked worktree records its repository through its gitdir/commondir + pointer, so `git rev-parse --git-common-dir` yields the shared git directory + with no external repo-layout tool. For a standard clone that directory is the + main working tree's `.git`, so its parent is the checkout git worktree + commands run from; a bare-clone hub has no working tree, so the git directory + itself is where those commands run. + """ + proc = run(["git", "-C", str(worktree_path), "rev-parse", "--git-common-dir"]) + common = Path(proc.stdout.strip()) + if not common.is_absolute(): + common = worktree_path / common + common = common.resolve() + return common.parent if common.name == ".git" else common def iter_worktrees(root: Path) -> list[Worktree]: @@ -132,7 +133,7 @@ def pr_state(worktree: Worktree) -> dict[str, str]: def remove_worktree(worktree: Worktree, root: Path) -> None: - main_repo = repo_path(worktree.owner, worktree.repo) + main_repo = repo_path(worktree.path) if not main_repo.exists(): raise RuntimeError(f"main repo missing for {worktree.key}: {main_repo}") resolved = worktree.path.resolve() diff --git a/plugins/source-control/skills/babysit-prs/scripts/tests/test_prune_babysit_worktrees.py b/plugins/source-control/skills/babysit-prs/scripts/tests/test_prune_babysit_worktrees.py new file mode 100644 index 0000000000..dba0a0206f --- /dev/null +++ b/plugins/source-control/skills/babysit-prs/scripts/tests/test_prune_babysit_worktrees.py @@ -0,0 +1,123 @@ +"""Native worktree-pruner resolution: no ghq, main checkout from git metadata. + +`repo_path` and `remove_worktree` are exercised against a real on-disk git +repository with a linked worktree, so the whole path is hermetic -- no `gh`, and +notably no `ghq`: it is absent from the module's executable allowlist, so any +lingering call would raise "not in the caller's allowlist" and fail these tests. +This is the regression guard for #438, where a consumer without ghq hit a hard +RuntimeError instead of resolving the checkout from the worktree's own gitdir +pointer. +""" + +from __future__ import annotations + +import pathlib +import subprocess +import sys +import tempfile +import unittest + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) + +import prune_babysit_worktrees as prune # noqa: E402 + + +def git(*args: str) -> str: + proc = subprocess.run( + ["git", *args], + check=True, + capture_output=True, + text=True, + encoding="utf-8", + ) + return proc.stdout.strip() + + +def make_repo(tmp: pathlib.Path) -> pathlib.Path: + """A one-commit repository with committer identity set locally.""" + main = tmp / "mainrepo" + main.mkdir() + git("init", "-q", str(main)) + git("-C", str(main), "config", "user.email", "t@t") + git("-C", str(main), "config", "user.name", "t") + git("-C", str(main), "commit", "-q", "--allow-empty", "-m", "init") + return main + + +def make_bare_hub(tmp: pathlib.Path) -> pathlib.Path: + """A bare-clone hub: no working tree, so `--git-common-dir` is the repo dir.""" + source = make_repo(tmp) + bare = tmp / "hub.git" + git("clone", "-q", "--bare", str(source), str(bare)) + return bare + + +def add_worktree(main: pathlib.Path, root: pathlib.Path, name: str) -> pathlib.Path: + root.mkdir(exist_ok=True) + wt = root / name + git("-C", str(main), "worktree", "add", "-q", str(wt), "-b", f"feat/{name}") + return wt + + +class RepoPathResolvesFromGitMetadata(unittest.TestCase): + def test_linked_worktree_resolves_its_main_checkout(self) -> None: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as td: + tmp = pathlib.Path(td) + main = make_repo(tmp) + wt = add_worktree(main, tmp / "root", "owner__repo__pr-1") + + resolved = prune.repo_path(wt) + + self.assertEqual(resolved, main.resolve()) + + def test_bare_hub_worktree_resolves_to_the_bare_repo_itself(self) -> None: + # A bare-clone hub has no working tree, so `--git-common-dir` is the bare + # repo directory (name != ".git"); the else-branch must return it as-is, + # not its parent, or `git -C worktree remove` would fail. + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as td: + tmp = pathlib.Path(td) + bare = make_bare_hub(tmp) + wt = add_worktree(bare, tmp / "root", "owner__repo__pr-1") + + resolved = prune.repo_path(wt) + + self.assertEqual(resolved, bare.resolve()) + + +class RemoveWorktreeIsHermetic(unittest.TestCase): + def test_removes_a_clean_worktree_under_root_without_ghq(self) -> None: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as td: + tmp = pathlib.Path(td) + main = make_repo(tmp) + root = tmp / "root" + wt = add_worktree(main, root, "owner__repo__pr-1") + worktree = prune.Worktree(path=wt, owner="owner", repo="repo", number=1) + + prune.remove_worktree(worktree, root) + + self.assertFalse(wt.exists()) + listed = git("-C", str(main), "worktree", "list", "--porcelain") + self.assertNotIn(str(wt), listed) + + def test_refuses_to_remove_a_worktree_outside_the_babysit_root(self) -> None: + # The path-containment guard must fire for a real worktree that lives + # outside the caller's declared root -- resolution succeeding never + # licenses removal beyond the sandbox. + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as td: + tmp = pathlib.Path(td) + main = make_repo(tmp) + actual_root = tmp / "root" + wt = add_worktree(main, actual_root, "owner__repo__pr-2") + worktree = prune.Worktree(path=wt, owner="owner", repo="repo", number=2) + unrelated_root = tmp / "elsewhere" + unrelated_root.mkdir() + + with self.assertRaises(RuntimeError) as ctx: + prune.remove_worktree(worktree, unrelated_root) + + self.assertIn("outside babysit root", str(ctx.exception)) + self.assertTrue(wt.exists()) + + +if __name__ == "__main__": + unittest.main()