SkillsHooksPromptsAgentsPersonasModelsPoliciesToolsTemplatesBundlesCategoriesStart here
← Hooks
Hk hookgovernanceblockingstable

Worktree Guard

Enforces worktree-based feature development. Rule 1: blocks ‘git checkout -b’ and ‘git switch -c’ in the primary worktree — feature work must live in a linked worktree so the primary repo stays on main. Rule 2: blocks ‘git worktree add’ to non-canonical paths. Canonical paths: <repo>/.worktrees/<name>/ for single-repo work, <repo>/.claude/worktrees/<name>/ for Claude Code's native EnterWorktree tool, ~/.ai/worktrees/<name>/ for cross-repo or persistent worktrees.

id hook/worktree-guardv1.3.0by convergent-systems-key
Event
PreToolUse
Trigger
tool-nameBash
Language
python
Side effects
  • blocks new-branch creation (checkout -b / switch -c) in the primary worktree with worktree-add guidance
  • blocks non-canonical worktree placement with path guidance
Platforms
linuxmacoswindows
Notes
Logic is cross-platform. Wiring: use ‘ai hooks run worktree-guard’ in settings.json — the ai binary discovers Python on each OS. Inspects git command strings via Python. Path separators handled by pathlib. Works on all platforms.
Depends on
hook/lib

Script · worktree-guard.py

#!/usr/bin/env python3
"""hooks/worktree-guard.py — enforce worktree-based feature development.

Two rules, both implementing Common.md §U17:

1. BRANCH-IN-PRIMARY GUARD
   Block `git checkout -b <branch>` and `git switch -c <branch>` when
   running in the primary worktree. Feature work must live in a linked
   worktree so the primary repo stays on main. Redirect to
   `git worktree add`.

2. PLACEMENT GUARD
   When `git worktree add <path>` IS used, enforce canonical placement:
     <repo>/.worktrees/<name>/         (single-repo, dies with the repo)
     <repo>/.claude/worktrees/<name>/  (Claude Code native EnterWorktree)
     ~/.ai/worktrees/<name>/           (cross-repo, persistent)
   Ad-hoc placement (../<branch>/, /tmp/worktree-X/, etc.) is forbidden.

Known limitations (PreToolUse static parser): a `git checkout -b` or
`git worktree add` hidden inside a subshell (`(...)`), command
substitution (`$(...)` / backticks), or a delegated interpreter
(`bash -c "..."`) is not caught by static tokenization. The
client-agnostic wrapper mode (`ai wrap git`) is the backstop for those
— it inspects the real git argv.

Two invocation modes:
  PreToolUse  — reads a JSON event from stdin; on violation emits a
                JSON permissionDecision deny on stdout and exits 0.
                (Claude Code path via hooks.PreToolUse in settings.json)
  wrapper     — invoked as `python <hook> --mode=wrapper` by `ai wrap git`;
                reads git argv from $WRAPPED_ARGV; on violation writes to
                stderr and exits non-zero to block the real git binary.

Self-check:
  --self-check
"""
from __future__ import annotations

import json
import os
import shlex
import subprocess
import sys
from pathlib import Path
from typing import List, Optional

sys.path.insert(0, str(Path(__file__).resolve().parent))
import _lib  # noqa: E402

# Flags on `git worktree add` that consume the next token as a value.
VALUE_FLAGS = {"-b", "-B", "--reason"}

# Shell operators that separate one command from the next.
SHELL_SEPARATORS = {";", "&&", "||", "|", "&"}

# `git` global options (before the subcommand) that consume the next token.
GIT_GLOBAL_VALUE_OPTS = {
    "-C", "-c", "--git-dir", "--work-tree",
    "--namespace", "--exec-path", "--super-prefix",
}


def deny_pretooluse(reason: str) -> None:
    """Emit a PreToolUse permission-deny decision (stdout) and exit 0."""
    print(json.dumps({
        "hookSpecificOutput": {
            "hookEventName": "PreToolUse",
            "permissionDecision": "deny",
            "permissionDecisionReason": reason,
        }
    }))
    sys.exit(0)


def ai_worktrees_root() -> Path:
    """Return the cross-repo canonical worktree root."""
    ai_root = os.environ.get("AI_ROOT", str(Path.home() / ".ai"))
    return Path(ai_root) / "worktrees"


def _clean_path_env() -> dict:
    """Return os.environ with AI shim bin dirs stripped from PATH.

    resolve_repo_root and is_primary_worktree must reach the real git,
    not the ~/.ai/bin/git governance shim, to avoid re-entering the
    `ai wrap` pipeline which can deadlock or timeout.
    """
    canonical_ai_bin = Path.home() / ".ai" / "bin"
    env_ai_root = os.environ.get("AI_ROOT", "")
    override_ai_bin = Path(env_ai_root) / "bin" if env_ai_root else None

    strip = {str(canonical_ai_bin.resolve())}
    if override_ai_bin is not None:
        try:
            strip.add(str(override_ai_bin.resolve()))
        except Exception:
            strip.add(str(override_ai_bin))

    raw_path = os.environ.get("PATH", "")
    cleaned_parts = []
    for p in raw_path.split(os.pathsep):
        if not p:
            continue
        try:
            resolved = str(Path(p).resolve())
        except Exception:
            resolved = p
        if resolved not in strip and p not in strip:
            cleaned_parts.append(p)
    env = dict(os.environ)
    if cleaned_parts:
        env["PATH"] = os.pathsep.join(cleaned_parts)
    return env


def resolve_repo_root(cwd: str) -> Optional[Path]:
    """Return the repo root for the given working directory, or None."""
    try:
        out = subprocess.run(
            ["git", "-C", cwd, "rev-parse", "--show-toplevel"],
            capture_output=True, text=True, timeout=5,
            env=_clean_path_env(),
        )
        if out.returncode == 0 and out.stdout.strip():
            return Path(out.stdout.strip()).resolve()
    except Exception:
        pass
    return None


def is_primary_worktree(cwd: str) -> bool:
    """Return True if cwd is inside the primary (main) worktree.

    In a linked worktree, --git-dir and --git-common-dir differ. In the
    primary worktree they resolve to the same path.
    """
    try:
        git_dir = subprocess.run(
            ["git", "-C", cwd, "rev-parse", "--git-dir"],
            capture_output=True, text=True, timeout=5,
            env=_clean_path_env(),
        )
        common_dir = subprocess.run(
            ["git", "-C", cwd, "rev-parse", "--git-common-dir"],
            capture_output=True, text=True, timeout=5,
            env=_clean_path_env(),
        )
        if git_dir.returncode != 0 or common_dir.returncode != 0:
            return False
        gd = Path(git_dir.stdout.strip()).resolve()
        cd = Path(common_dir.stdout.strip()).resolve()
        return gd == cd
    except Exception:
        return False


def _normalize_tokens(tokens: List[str]) -> List[str]:
    """Expand tokens that have trailing semicolons glued on."""
    result: List[str] = []
    for tok in tokens:
        if tok.endswith(";") and tok != ";":
            result.append(tok[:-1])
            result.append(";")
        else:
            result.append(tok)
    return result


def _split_segments(tokens: List[str]) -> List[List[str]]:
    """Split a flat token list into command segments on shell operators."""
    segments: List[List[str]] = []
    current: List[str] = []
    for tok in _normalize_tokens(tokens):
        if tok in SHELL_SEPARATORS:
            if current:
                segments.append(current)
                current = []
        else:
            current.append(tok)
    if current:
        segments.append(current)
    return segments


def _is_env_assignment(tok: str) -> bool:
    """True for a leading `VAR=value` shell environment assignment."""
    if tok.startswith("-") or "=" not in tok:
        return False
    return tok.split("=", 1)[0].isidentifier()


def _skip_git_preamble(seg: List[str], start: int) -> int:
    """Skip env assignments and git global options; return index of subcommand."""
    i, n = start, len(seg)
    while i < n and _is_env_assignment(seg[i]):
        i += 1
    if i >= n or seg[i] != "git":
        return n  # not a git command
    i += 1
    while i < n and seg[i].startswith("-"):
        opt = seg[i]
        if "=" in opt:
            i += 1
        elif opt in GIT_GLOBAL_VALUE_OPTS and i + 1 < n:
            i += 2
        else:
            i += 1
    return i


# ── Rule 1: new-branch-in-primary detection ──────────────────────────────────

def _is_new_branch_in_segment(seg: List[str]) -> bool:
    """True if this segment creates a new git branch."""
    i = _skip_git_preamble(seg, 0)
    if i >= len(seg):
        return False
    subcmd = seg[i]
    flags = set(seg[i + 1:])
    if subcmd == "checkout" and (flags & {"-b", "-B"}):
        return True
    if subcmd == "switch" and (flags & {"-c", "-C"}):
        return True
    return False


def _has_new_branch_command(tokens: List[str]) -> bool:
    """True if any segment in tokens creates a new git branch."""
    return any(_is_new_branch_in_segment(seg) for seg in _split_segments(tokens))


def new_branch_deny_message(cwd: str) -> str:
    """Build the Rule 1 deny message with worktree-add guidance."""
    repo_root = resolve_repo_root(cwd)
    ai_root = ai_worktrees_root()
    hints = []
    if repo_root:
        hints.append(
            f"  git worktree add {repo_root}/.worktrees/<name> -b <branch>"
            "  (single-repo)"
        )
    hints.append(
        f"  git worktree add {ai_root}/<name> -b <branch>"
        "  (cross-repo / persistent)"
    )
    return (
        "Creating a branch in the primary worktree violates Common.md §U17.\n"
        "Feature work must live in a linked worktree so the primary repo "
        "stays on main.\n"
        "\nUse a worktree instead:\n"
        + "\n".join(hints) + "\n"
        "\nOr: `ai worktree add <name>` (with `--global` for cross-repo)."
    )


# ── Rule 2: placement detection ───────────────────────────────────────────────

def _path_after_add(seg: List[str], i: int) -> Optional[str]:
    """Return the positional path argument following `worktree add`."""
    n = len(seg)
    while i < n:
        tok = seg[i]
        if tok == "--":
            i += 1
            break
        if not tok.startswith("-"):
            return tok
        if tok in VALUE_FLAGS and i + 1 < n:
            i += 2
            continue
        i += 1
    if i < n:
        return seg[i]
    return None


def _worktree_add_path_in_segment(seg: List[str]) -> Optional[str]:
    """Return the target path if this segment is `git worktree add`."""
    i = _skip_git_preamble(seg, 0)
    n = len(seg)
    if i + 1 >= n or seg[i] != "worktree" or seg[i + 1] != "add":
        return None
    return _path_after_add(seg, i + 2)


def _worktree_add_paths(tokens: List[str]) -> List[str]:
    """Every `git worktree add` target path across the token segments."""
    paths: List[str] = []
    for seg in _split_segments(tokens):
        path = _worktree_add_path_in_segment(seg)
        if path is not None:
            paths.append(path)
    return paths


def _worktree_add_paths_in_command(command: str) -> List[str]:
    """All `git worktree add` paths in a raw shell command string."""
    paths: List[str] = []
    for line in command.splitlines():
        line_tokens = shlex.split(line, comments=False, posix=True)
        paths.extend(_worktree_add_paths(line_tokens))
    return paths


def is_canonical(raw_path: str, cwd: str) -> bool:
    """Return True if raw_path resolves to a canonical worktree location."""
    target = Path(raw_path)
    if not target.is_absolute():
        target = Path(cwd) / target
    try:
        resolved = target.resolve()
    except Exception:
        resolved = Path(os.path.normpath(str(target)))

    ai_root = ai_worktrees_root()
    try:
        resolved.relative_to(ai_root.resolve())
        return True
    except (ValueError, FileNotFoundError):
        pass

    repo_root = resolve_repo_root(cwd)
    if repo_root is not None:
        try:
            resolved.relative_to(repo_root / ".worktrees")
            return True
        except ValueError:
            pass
        try:
            # Claude Code's native EnterWorktree tool places worktrees here;
            # it can't be redirected to `ai worktree add`, so this harness-
            # managed root is canonical too.
            resolved.relative_to(repo_root / ".claude" / "worktrees")
            return True
        except ValueError:
            pass

    return False


def placement_deny_message(raw_path: str, cwd: str) -> str:
    """Build the Rule 2 deny message."""
    p = Path(raw_path)
    if not p.is_absolute():
        p = Path(cwd) / p
    try:
        shown = p.resolve()
    except Exception:
        shown = p

    repo_root = resolve_repo_root(cwd)
    ai_root = ai_worktrees_root()
    hints = []
    if repo_root:
        hints.append(f"  {repo_root}/.worktrees/<name>/  — single-repo")
        hints.append(f"  {repo_root}/.claude/worktrees/<name>/  — Claude Code native (EnterWorktree)")
    hints.append(f"  {ai_root}/<name>/  — cross-repo or persistent")
    return (
        "Worktree placement violates Common.md §U17.\n"
        f"  Target: {shown}\n"
        "Canonical roots:\n"
        + "\n".join(hints) + "\n"
        "Choose by lifecycle (§U17.1) and re-run.\n"
        "Preferred surface: `ai worktree add <name>` "
        "(with `--global` for cross-repo)."
    )


# ── Mode runners ──────────────────────────────────────────────────────────────

def run_pretooluse_mode() -> int:
    """Claude Code PreToolUse path: parse a JSON event from stdin."""
    raw = sys.stdin.read()
    if not raw.strip():
        return 0
    try:
        event = json.loads(raw)
    except json.JSONDecodeError:
        return 0

    hook_event = event.get("hookEventName") or event.get("hook_event_name") or ""
    if hook_event != "PreToolUse":
        return 0
    tool_name = event.get("tool_name") or event.get("toolName") or ""
    if tool_name not in ("Bash", "shell", "execute"):
        return 0
    tool_input = event.get("tool_input") or event.get("toolInput") or {}
    if not isinstance(tool_input, dict):
        return 0
    command = tool_input.get("command") or ""
    if not isinstance(command, str):
        return 0

    has_branch_op = "checkout" in command or "switch" in command
    has_worktree = "worktree" in command
    if not has_branch_op and not has_worktree:
        return 0

    cwd = event.get("cwd") or os.getcwd()

    # Rule 1: block new-branch creation in the primary worktree.
    if has_branch_op and is_primary_worktree(cwd):
        for line in command.splitlines():
            try:
                line_tokens = shlex.split(line, comments=False, posix=True)
            except ValueError:
                continue
            if _has_new_branch_command(line_tokens):
                deny_pretooluse(new_branch_deny_message(cwd))

    # Rule 2: enforce canonical placement when `git worktree add` is used.
    if has_worktree:
        try:
            targets = _worktree_add_paths_in_command(command)
        except ValueError:
            deny_pretooluse(
                "worktree-guard: could not parse a command containing "
                "'worktree'; denying per fail-closed policy (Common.md §U17)."
            )
            return 0
        for target in targets:
            if not is_canonical(target, cwd):
                deny_pretooluse(placement_deny_message(target, cwd))

    return 0


def run_wrapper_mode() -> int:
    """Client-agnostic `ai wrap git` path: git argv arrives in $WRAPPED_ARGV."""
    argv_json = os.environ.get("WRAPPED_ARGV", "")
    if not argv_json:
        return 0
    try:
        argv = json.loads(argv_json)
    except json.JSONDecodeError:
        return 0
    if not isinstance(argv, list):
        return 0

    tokens = ["git"] + [str(a) for a in argv]
    cwd = os.getcwd()

    # Rule 1: block new-branch creation in the primary worktree.
    if ("checkout" in tokens or "switch" in tokens):
        if _has_new_branch_command(tokens) and is_primary_worktree(cwd):
            sys.stderr.write(new_branch_deny_message(cwd) + "\n")
            return 1

    # Rule 2: enforce canonical placement.
    if "worktree" in tokens:
        for target in _worktree_add_paths(tokens):
            if not is_canonical(target, cwd):
                sys.stderr.write(placement_deny_message(target, cwd) + "\n")
                return 1

    return 0


def main() -> int:
    if "--self-check" in sys.argv:
        return _lib.self_check_ok()
    if "--mode=wrapper" in sys.argv or os.environ.get("WRAPPED_CMD"):
        return run_wrapper_mode()
    return run_pretooluse_mode()


if __name__ == "__main__":
    sys.exit(main())
gitworktreegovernanceclaude-code
Author convergent-systems-key. Catalog data license CC-BY-4.0.