diff --git a/scripts/branch-guard.sh b/scripts/branch-guard.sh new file mode 100755 index 0000000..707ea38 --- /dev/null +++ b/scripts/branch-guard.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +# Branch guard: Prevents git operations on the wrong branch in worktree sessions +# +# Walks up from $PWD (or --dir) to find a .claude-worktree marker file. +# Compares the expected branch (from the marker) with the actual git branch. +# Blocks if mismatched or if on a protected branch (main/master) in a worktree. +# +# Designed to be called from git hooks (pre-commit, pre-push) or manually. +# +# Usage: branch-guard.sh [--check-only] [--dir ] +# --check-only Validate only; don't print worktree path on success +# --dir Start search from instead of $PWD +# +# Exit codes: +# 0 = OK (branch matches, or no marker found — not in a worktree) +# 1 = BLOCKED (mismatch or on protected branch with marker) +# +# On success (without --check-only), prints the worktree path to stdout. + +set -eo pipefail + +CHECK_ONLY=false +SEARCH_DIR="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --check-only) CHECK_ONLY=true; shift ;; + --dir) SEARCH_DIR="$2"; shift 2 ;; + -h|--help) + echo "Usage: branch-guard.sh [--check-only] [--dir ]" + echo "" + echo "Walks up from \$PWD to find .claude-worktree marker." + echo "Blocks git operations if on the wrong branch." + echo "" + echo "Options:" + echo " --check-only Validate only; don't print worktree path" + echo " --dir Start search from instead of \$PWD" + echo "" + echo "Exit 0 = OK, Exit 1 = BLOCKED" + exit 0 + ;; + *) echo "Unknown option: $1" >&2; exit 1 ;; + esac +done + +SEARCH_DIR="${SEARCH_DIR:-$PWD}" + +# Walk up directory tree looking for .claude-worktree marker +find_marker() { + local dir="$1" + while [[ "$dir" != "/" ]]; do + if [[ -f "$dir/.claude-worktree" ]]; then + echo "$dir" + return 0 + fi + dir="$(dirname "$dir")" + done + return 1 +} + +# Find the marker file +MARKER_DIR="" +MARKER_DIR=$(find_marker "$SEARCH_DIR") || true + +if [[ -z "$MARKER_DIR" ]]; then + # No marker found — not in a worktree session, allow everything + exit 0 +fi + +MARKER_FILE="$MARKER_DIR/.claude-worktree" + +# Read expected branch from marker +EXPECTED_BRANCH="" +if command -v jq &>/dev/null; then + EXPECTED_BRANCH=$(jq -r '.branch // empty' "$MARKER_FILE" 2>/dev/null) +else + EXPECTED_BRANCH=$(grep -o '"branch"[[:space:]]*:[[:space:]]*"[^"]*"' "$MARKER_FILE" | head -1 | sed 's/.*"branch"[[:space:]]*:[[:space:]]*"//; s/"//') +fi + +if [[ -z "$EXPECTED_BRANCH" ]]; then + echo "WARNING: .claude-worktree marker found at $MARKER_DIR but has no branch field" >&2 + exit 0 +fi + +# Get actual branch +ACTUAL_BRANCH=$(git -C "$MARKER_DIR" branch --show-current 2>/dev/null || echo "") + +if [[ -z "$ACTUAL_BRANCH" ]]; then + echo "ERROR: Could not determine current git branch in $MARKER_DIR" >&2 + exit 1 +fi + +# On a protected branch with a worktree marker means something is wrong +if [[ "$ACTUAL_BRANCH" == "main" || "$ACTUAL_BRANCH" == "master" ]]; then + echo "BLOCKED: You are on '$ACTUAL_BRANCH' but .claude-worktree expects '$EXPECTED_BRANCH'" >&2 + echo "" >&2 + echo "Recovery:" >&2 + echo " cd $MARKER_DIR" >&2 + echo " git checkout $EXPECTED_BRANCH" >&2 + exit 1 +fi + +# Branch mismatch +if [[ "$ACTUAL_BRANCH" != "$EXPECTED_BRANCH" ]]; then + echo "BLOCKED: Branch mismatch — expected '$EXPECTED_BRANCH', currently on '$ACTUAL_BRANCH'" >&2 + echo "" >&2 + echo "Recovery:" >&2 + echo " cd $MARKER_DIR" >&2 + echo " git checkout $EXPECTED_BRANCH" >&2 + exit 1 +fi + +# All checks passed +if [[ "$CHECK_ONLY" == "false" ]]; then + echo "$MARKER_DIR" +fi + +exit 0 diff --git a/scripts/worktree.sh b/scripts/worktree.sh new file mode 100755 index 0000000..d41df95 --- /dev/null +++ b/scripts/worktree.sh @@ -0,0 +1,205 @@ +#!/usr/bin/env bash +# Worktree management for parallel Claude Code sessions +# +# Each worktree gets its own isolated copy of the repository with a dedicated +# branch, enabling multiple Claude sessions to work on different tasks without +# file conflicts. A .claude-worktree marker file tracks which branch each +# worktree expects, enabling branch-guard.sh to prevent accidental cross-branch +# commits. +# +# Usage: +# worktree.sh create [base-branch] +# worktree.sh list [repo] +# worktree.sh remove +# worktree.sh clean +# worktree.sh path +# +# Environment: +# GSTACK_WORKTREE_BASE Override worktree root (default: ~/.worktrees) + +set -eo pipefail + +WORKTREE_BASE="${GSTACK_WORKTREE_BASE:-$HOME/.worktrees}" + +usage() { + echo "Usage: worktree.sh [options]" + echo "" + echo "Manage isolated git worktrees for parallel Claude Code sessions." + echo "" + echo "Commands:" + echo " create [base] Create new worktree + branch" + echo " list [repo] List worktrees (or all if no repo)" + echo " remove Remove worktree and delete branch" + echo " clean Remove ALL worktrees for a repo" + echo " path Print worktree path (for cd/scripts)" + echo "" + echo "Examples:" + echo " worktree.sh create myapp auth-flow main" + echo " worktree.sh list myapp" + echo " worktree.sh remove myapp auth-flow" + echo " worktree.sh clean myapp" + echo "" + echo "Environment:" + echo " GSTACK_WORKTREE_BASE Root directory for worktrees (default: ~/.worktrees)" +} + +create_worktree() { + local repo="$1" name="$2" base="${3:-main}" + + if [[ -z "$repo" || -z "$name" ]]; then + echo "Error: repo and name are required" >&2 + echo "Usage: worktree.sh create [base-branch]" >&2 + return 1 + fi + + local branch="feature/$name" + local worktree_path="$WORKTREE_BASE/$repo/$name" + + if [[ -d "$worktree_path" ]]; then + echo "Error: Worktree already exists at $worktree_path" >&2 + return 1 + fi + + mkdir -p "$WORKTREE_BASE/$repo" + + echo "Creating worktree: $worktree_path" + echo "Branch: $branch (from $base)" + + git worktree add -b "$branch" "$worktree_path" "$base" + + # Write .claude-worktree marker for branch-guard.sh enforcement + cat > "$worktree_path/.claude-worktree" << EOF +{ + "branch": "$branch", + "worktree_path": "$worktree_path", + "repo": "$repo", + "session": "$name", + "created_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)" +} +EOF + + # Add marker to worktree's .gitignore (don't commit it) + if ! grep -qF '.claude-worktree' "$worktree_path/.gitignore" 2>/dev/null; then + echo '.claude-worktree' >> "$worktree_path/.gitignore" + fi + + echo "" + echo "Worktree created." + echo "" + echo "To start working:" + echo " cd $worktree_path && claude" +} + +list_worktrees() { + local repo="$1" + + if [[ -n "$repo" ]]; then + local repo_dir="$WORKTREE_BASE/$repo" + if [[ ! -d "$repo_dir" ]]; then + echo "No worktrees for $repo" + return 0 + fi + + echo "Worktrees for $repo:" + echo "" + + for wt_dir in "$repo_dir"/*/; do + [[ -d "$wt_dir" ]] || continue + local name + name=$(basename "$wt_dir") + local branch="(unknown)" + local created="(unknown)" + + if [[ -f "$wt_dir/.claude-worktree" ]]; then + if command -v jq &>/dev/null; then + branch=$(jq -r '.branch // "(unknown)"' "$wt_dir/.claude-worktree" 2>/dev/null) + created=$(jq -r '.created_at // "(unknown)"' "$wt_dir/.claude-worktree" 2>/dev/null) + else + branch=$(grep -o '"branch"[[:space:]]*:[[:space:]]*"[^"]*"' "$wt_dir/.claude-worktree" | head -1 | sed 's/.*"branch"[[:space:]]*:[[:space:]]*"//; s/"//') + fi + fi + + printf " %-20s %-30s %s\n" "$name" "$branch" "$created" + done + else + echo "All git worktrees:" + git worktree list + fi +} + +remove_worktree() { + local repo="$1" name="$2" + + if [[ -z "$repo" || -z "$name" ]]; then + echo "Error: repo and name are required" >&2 + echo "Usage: worktree.sh remove " >&2 + return 1 + fi + + local worktree_path="$WORKTREE_BASE/$repo/$name" + local branch="feature/$name" + + echo "Removing worktree: $worktree_path" + + git worktree remove "$worktree_path" --force 2>/dev/null || true + git branch -D "$branch" 2>/dev/null || true + rm -rf "$worktree_path" 2>/dev/null || true + + echo "Worktree removed: $name" +} + +clean_worktrees() { + local repo="$1" + + if [[ -z "$repo" ]]; then + echo "Error: repo is required" >&2 + echo "Usage: worktree.sh clean " >&2 + return 1 + fi + + local repo_dir="$WORKTREE_BASE/$repo" + if [[ ! -d "$repo_dir" ]]; then + echo "No worktrees to clean for $repo" + return 0 + fi + + echo "Removing all worktrees for $repo..." + + for wt_dir in "$repo_dir"/*/; do + [[ -d "$wt_dir" ]] || continue + local name + name=$(basename "$wt_dir") + remove_worktree "$repo" "$name" + done + + rmdir "$repo_dir" 2>/dev/null || true + echo "Clean complete." +} + +worktree_path() { + local repo="$1" name="$2" + + if [[ -z "$repo" || -z "$name" ]]; then + echo "Error: repo and name are required" >&2 + return 1 + fi + + echo "$WORKTREE_BASE/$repo/$name" +} + +# CLI dispatch +case "${1:-}" in + create) shift; create_worktree "$@" ;; + list) shift; list_worktrees "$@" ;; + remove) shift; remove_worktree "$@" ;; + clean) shift; clean_worktrees "$@" ;; + path) shift; worktree_path "$@" ;; + -h|--help|help) usage ;; + *) + if [[ -n "${1:-}" ]]; then + echo "Unknown command: $1" >&2 + fi + usage + exit 1 + ;; +esac