Skip to content
Open
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
118 changes: 118 additions & 0 deletions scripts/branch-guard.sh
Original file line number Diff line number Diff line change
@@ -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 <path>]
# --check-only Validate only; don't print worktree path on success
# --dir <path> Start search from <path> 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 <path>]"
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 <path> Start search from <path> 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
205 changes: 205 additions & 0 deletions scripts/worktree.sh
Original file line number Diff line number Diff line change
@@ -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 <repo> <name> [base-branch]
# worktree.sh list [repo]
# worktree.sh remove <repo> <name>
# worktree.sh clean <repo>
# worktree.sh path <repo> <name>
#
# Environment:
# GSTACK_WORKTREE_BASE Override worktree root (default: ~/.worktrees)

set -eo pipefail

WORKTREE_BASE="${GSTACK_WORKTREE_BASE:-$HOME/.worktrees}"

usage() {
echo "Usage: worktree.sh <command> [options]"
echo ""
echo "Manage isolated git worktrees for parallel Claude Code sessions."
echo ""
echo "Commands:"
echo " create <repo> <name> [base] Create new worktree + branch"
echo " list [repo] List worktrees (or all if no repo)"
echo " remove <repo> <name> Remove worktree and delete branch"
echo " clean <repo> Remove ALL worktrees for a repo"
echo " path <repo> <name> 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 <repo> <name> [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 <repo> <name>" >&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 <repo>" >&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