Claude Skill

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.

LLM Mart · 0 points · 13 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download ConnorGriffin-skills-skills_tools_spin-worktree-afea794.zip · 4 KB
Part of connorgriffin/skills — 25 skills

Install

skills CLI npx skills add https://github.com/ConnorGriffin/skills/tree/main/skills/tools/spin-worktree
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install connorgriffin-skills@llmmart
Git 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

  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:

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 --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.
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.

No comments yet.

Reviews (0)

No reviews yet.

Related