Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion plugins/source-control/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
18 changes: 18 additions & 0 deletions plugins/source-control/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
`<root>/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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
from babysit_util import configure_stdio, run_command

WORKTREE_RE = re.compile(r"^(?P<owner>.+?)__(?P<repo>.+?)__pr-(?P<number>\d+)$")
ALLOWED_EXECUTABLES = ("git", "gh", "ghq")
ALLOWED_EXECUTABLES = ("git", "gh")


@dataclass
Expand Down Expand Up @@ -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]:
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <parent> 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()
Loading