spin-worktree
Create an isolated Git worktree for issue, pull-request, branch, or parallel agent work without editing the control checkout. Use when starting task work from fresh remote state, avoiding a dirty or shared checkout, or preparing a dedicated worktree for another agent.
Install
npx skills add https://github.com/ConnorGriffin/skills/tree/main/skills/tools/spin-worktree
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install connorgriffin-skills@llmmart
git clone https://github.com/ConnorGriffin/skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole connorgriffin/skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Spin an isolated worktree
Keep the ordinary checkout as the control checkout and create one worktree per task. Dirty files in the control checkout are preserved and do not block the helper: it updates Git refs and shared worktree metadata, creates the destination, and never switches, stashes, or edits files in the control checkout.
By default worktrees live under ${AGENT_WORKTREE_ROOT:-~/worktrees}:
~/worktrees/<repository>/<task>
Workflow
- Resolve the ordinary checkout and pass it explicitly with
--repo. Never substitute another task's linked worktree just because it is clean. If a linked worktree is passed accidentally, the helper resolves Git's primary worktree before deriving<repository>or running commands. - Resolve this installed skill's directory.
- Run one of the commands below.
- Report the exact path and use it as the working directory.
- Do not remove the worktree automatically. Cleanup belongs to task closeout after merge.
New issue branch:
python3 <spin-worktree-skill-directory>/scripts/spin-worktree.py \
--repo /path/to/repository \
--issue 317 \
--slug rescue-context
Existing pull-request branch, discovered with GitHub CLI:
python3 <spin-worktree-skill-directory>/scripts/spin-worktree.py \
--repo /path/to/repository \
--pr 321
Existing branch:
python3 <spin-worktree-skill-directory>/scripts/spin-worktree.py \
--repo /path/to/repository \
--branch issue-316 \
--name pr321
Use --dry-run to inspect commands. Override defaults with
--worktree-root, --remote, --base, or --branch-prefix.
Branch prefix
New issue branches resolve their prefix in this order: an explicitly supplied
--branch-prefix flag, then the string branchPrefix in
~/.config/spin-worktree/config.json, then no prefix. For example:
{"branchPrefix": "my-prefix"}
With no resolved prefix, issue branches are issue-317 or
317-rescue-context; they never begin with /. Pass --branch-prefix '' to
request that bare form for one invocation. Missing, unreadable, malformed, or
otherwise unsuitable config files silently resolve to no prefix.
Guardrails
- Use one worktree path and branch per task.
- Never switch branches in another agent's active worktree.
- Do not reuse an existing target path unless inspection proves it belongs to the same task.
- New issue work updates the remote and starts from its current default branch
unless
--baseis supplied. - Existing local branches do not require a remote.
- Pull-request discovery requires authenticated
gh. A same-repository head is fetched when needed. A fork head fails with the exact fork and branch to configure, rather than pretending it exists underorigin. --nameaccepts one relative directory leaf, never a path.
Files (skills)
-
agents
-
openai.yaml 191 B
interface: display_name: "Spin Worktree" short_description: "Create isolated Git worktrees for tasks" default_prompt: "Use $spin-worktree to create an isolated worktree for this task."
-
-
scripts
-
spin-worktree.py 9.3 KB
#!/usr/bin/env python3 """Create an isolated Git worktree for issue or branch work.""" from __future__ import annotations import argparse import json import os import re import subprocess import sys from pathlib import Path from typing import Optional DEFAULT_ROOT = Path( os.environ.get("AGENT_WORKTREE_ROOT", str(Path.home() / "worktrees")) ).expanduser() DEFAULT_REMOTE = "origin" class SpinError(RuntimeError): """A safe, user-facing worktree setup failure.""" def run( command: list[str], *, cwd: Optional[Path] = None, capture: bool = False, dry_run: bool = False, ) -> str: print("$ " + " ".join(command), flush=True) if dry_run: return "" options: dict[str, object] = {"text": True} if cwd is not None: options["cwd"] = str(cwd) if capture: options["stdout"] = subprocess.PIPE options["stderr"] = subprocess.PIPE try: completed = subprocess.run(command, check=True, **options) except FileNotFoundError as error: raise SpinError(f"required command not found: {command[0]}") from error except subprocess.CalledProcessError as error: detail = error.stderr.strip() if capture and error.stderr else "" raise SpinError(detail or f"command exited {error.returncode}") from error return str(completed.stdout).strip() if capture else "" def git( repo: Path, *arguments: str, capture: bool = False, dry_run: bool = False ) -> str: return run( ["git", "-C", str(repo), *arguments], capture=capture, dry_run=dry_run, ) def slugify(value: str) -> str: return re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") or "task" def safe_leaf(value: str) -> str: if ( not value or value in {".", ".."} or Path(value).is_absolute() or "/" in value or "\\" in value ): raise SpinError("--name must be one safe relative directory name") return value def repository_root(path: Path) -> Path: try: value = run( ["git", "-C", str(path), "rev-parse", "--show-toplevel"], capture=True, ) except SpinError as error: raise SpinError(f"{path} is not a Git checkout") from error return Path(value).resolve() def primary_worktree(repo: Path) -> Path: value = git(repo, "worktree", "list", "--porcelain", capture=True) first_line = value.splitlines()[0] if value else "" if not first_line.startswith("worktree "): raise SpinError(f"cannot determine the primary worktree for {repo}") return Path(first_line.removeprefix("worktree ")).resolve() def local_branch_exists(repo: Path, branch: str) -> bool: result = subprocess.run( [ "git", "-C", str(repo), "show-ref", "--verify", "--quiet", f"refs/heads/{branch}", ], check=False, ) return result.returncode == 0 def remote_default_branch(repo: Path, remote: str) -> str: symbolic = subprocess.run( [ "git", "-C", str(repo), "symbolic-ref", "--quiet", "--short", f"refs/remotes/{remote}/HEAD", ], check=False, text=True, stdout=subprocess.PIPE, ) if symbolic.returncode == 0 and symbolic.stdout.strip(): return symbolic.stdout.strip().removeprefix(f"{remote}/") for candidate in ("main", "master"): result = subprocess.run( [ "git", "-C", str(repo), "show-ref", "--verify", "--quiet", f"refs/remotes/{remote}/{candidate}", ], check=False, ) if result.returncode == 0: return candidate raise SpinError(f"cannot determine the default branch for remote {remote}") def discover_pr_branch(repo: Path, pull_request: int) -> str: value = run( [ "gh", "pr", "view", str(pull_request), "--json", "headRefName,isCrossRepository,headRepository,headRepositoryOwner", ], cwd=repo, capture=True, ) try: metadata = json.loads(value) except json.JSONDecodeError as error: raise SpinError(f"could not resolve pull request #{pull_request}") from error branch = metadata.get("headRefName") if not branch: raise SpinError(f"could not resolve pull request #{pull_request}") if metadata.get("isCrossRepository"): owner = (metadata.get("headRepositoryOwner") or {}).get("login", "unknown") repository = (metadata.get("headRepository") or {}).get("name", "unknown") raise SpinError( f"pull request #{pull_request} comes from fork {owner}/{repository}; " "add that fork as a Git remote, fetch its head branch, then rerun with " f"--branch {branch}" ) return str(branch) def configured_branch_prefix() -> str: config_path = Path.home() / ".config" / "spin-worktree" / "config.json" try: config = json.loads(config_path.read_text(encoding="utf-8")) except (OSError, UnicodeDecodeError, json.JSONDecodeError): return "" prefix = config.get("branchPrefix") if isinstance(config, dict) else None return prefix if isinstance(prefix, str) else "" def issue_branch(issue: int, slug: Optional[str], prefix: str) -> str: branch_slug = slugify(slug or f"issue-{issue}") branch_name = f"{issue}-{branch_slug}" if slug else f"issue-{issue}" return f"{prefix}/{branch_name}" if prefix else branch_name def parse_arguments() -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--repo", default=".", help="control checkout") parser.add_argument("--worktree-root", default=str(DEFAULT_ROOT)) parser.add_argument("--remote", default=DEFAULT_REMOTE) parser.add_argument("--base", help="base branch for new issue work") parser.add_argument("--branch-prefix", default=None) parser.add_argument("--issue", type=int) parser.add_argument("--slug") parser.add_argument("--pr", type=int) parser.add_argument("--branch") parser.add_argument("--name", help="worktree directory name") parser.add_argument("--dry-run", action="store_true") arguments = parser.parse_args() modes = ( arguments.issue is not None, arguments.pr is not None, arguments.branch is not None, ) if sum(modes) != 1: parser.error("choose exactly one of --issue, --pr, or --branch") if arguments.slug and arguments.issue is None: parser.error("--slug requires --issue") return arguments def main() -> int: arguments = parse_arguments() try: if arguments.name is not None: safe_leaf(arguments.name) supplied_repo = repository_root(Path(arguments.repo).expanduser()) repo = primary_worktree(supplied_repo) root = Path(arguments.worktree_root).expanduser().resolve() if arguments.issue is not None: git(repo, "fetch", arguments.remote, dry_run=arguments.dry_run) base = arguments.base or remote_default_branch(repo, arguments.remote) prefix = ( arguments.branch_prefix if arguments.branch_prefix is not None else configured_branch_prefix() ) branch = issue_branch(arguments.issue, arguments.slug, prefix) task_name = safe_leaf(arguments.name or str(arguments.issue)) start_point = f"{arguments.remote}/{base}" new_branch = True else: branch = arguments.branch or discover_pr_branch(repo, arguments.pr) task_name = safe_leaf( arguments.name or (f"pr{arguments.pr}" if arguments.pr else slugify(branch)) ) start_point = branch new_branch = not local_branch_exists(repo, branch) if new_branch: git( repo, "fetch", arguments.remote, branch, dry_run=arguments.dry_run, ) git( repo, "branch", "--track", branch, f"{arguments.remote}/{branch}", dry_run=arguments.dry_run, ) new_branch = False destination = root / repo.name / task_name if not arguments.dry_run and destination.exists(): raise SpinError(f"target already exists: {destination}") command = ["worktree", "add", str(destination)] if new_branch: command.extend(["-b", branch, start_point]) else: command.append(branch) git(repo, *command, dry_run=arguments.dry_run) except SpinError as error: print(f"spin-worktree: {error}", file=sys.stderr) return 1 print( json.dumps( { "repo": str(repo), "worktree": str(destination), "branch": branch, "dry_run": arguments.dry_run, }, indent=2, ) ) return 0 if __name__ == "__main__": raise SystemExit(main())
-
-
SKILL.md 3.1 KB
--- name: spin-worktree description: Create an isolated Git worktree for issue, pull-request, branch, or parallel agent work without editing the control checkout. Use when starting task work from fresh remote state, avoiding a dirty or shared checkout, or preparing a dedicated worktree for another agent. --- # Spin an isolated worktree Keep the ordinary checkout as the control checkout and create one worktree per task. Dirty files in the control checkout are preserved and do not block the helper: it updates Git refs and shared worktree metadata, creates the destination, and never switches, stashes, or edits files in the control checkout. By default worktrees live under `${AGENT_WORKTREE_ROOT:-~/worktrees}`: ```text ~/worktrees/<repository>/<task> ``` ## Workflow 1. Resolve the ordinary checkout and pass it explicitly with `--repo`. Never substitute another task's linked worktree just because it is clean. If a linked worktree is passed accidentally, the helper resolves Git's primary worktree before deriving `<repository>` or running commands. 2. Resolve this installed skill's directory. 3. Run one of the commands below. 4. Report the exact path and use it as the working directory. 5. Do not remove the worktree automatically. Cleanup belongs to task closeout after merge. New issue branch: ```sh python3 <spin-worktree-skill-directory>/scripts/spin-worktree.py \ --repo /path/to/repository \ --issue 317 \ --slug rescue-context ``` Existing pull-request branch, discovered with GitHub CLI: ```sh python3 <spin-worktree-skill-directory>/scripts/spin-worktree.py \ --repo /path/to/repository \ --pr 321 ``` Existing branch: ```sh python3 <spin-worktree-skill-directory>/scripts/spin-worktree.py \ --repo /path/to/repository \ --branch issue-316 \ --name pr321 ``` Use `--dry-run` to inspect commands. Override defaults with `--worktree-root`, `--remote`, `--base`, or `--branch-prefix`. ## Branch prefix New issue branches resolve their prefix in this order: an explicitly supplied `--branch-prefix` flag, then the string `branchPrefix` in `~/.config/spin-worktree/config.json`, then no prefix. For example: ```json {"branchPrefix": "my-prefix"} ``` With no resolved prefix, issue branches are `issue-317` or `317-rescue-context`; they never begin with `/`. Pass `--branch-prefix ''` to request that bare form for one invocation. Missing, unreadable, malformed, or otherwise unsuitable config files silently resolve to no prefix. ## Guardrails - Use one worktree path and branch per task. - Never switch branches in another agent's active worktree. - Do not reuse an existing target path unless inspection proves it belongs to the same task. - New issue work updates the remote and starts from its current default branch unless `--base` is supplied. - Existing local branches do not require a remote. - Pull-request discovery requires authenticated `gh`. A same-repository head is fetched when needed. A fork head fails with the exact fork and branch to configure, rather than pretending it exists under `origin`. - `--name` accepts one relative directory leaf, never a path.
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.