Skip to content

Instantly share code, notes, and snippets.

@cabe56
Created January 13, 2026 22:09
Show Gist options
  • Select an option

  • Save cabe56/9c438220b3a002c17e4c11151a16cac9 to your computer and use it in GitHub Desktop.

Select an option

Save cabe56/9c438220b3a002c17e4c11151a16cac9 to your computer and use it in GitHub Desktop.
claude-code-worktrees: Isolated git worktrees for Claude Code sessions

claude-code-worktrees

Isolated git worktrees for Claude Code sessions. Prevents multiple Claude instances from stepping on each other's git state.

Problem

When running multiple Claude agents or sessions on the same repo, they can conflict:

  • Competing for staging area
  • Conflicting uncommitted changes
  • One agent's commit including another's work

Solution

Each Claude session runs in its own git worktree - a separate working directory with its own branch. Work is isolated, and auto-committed on exit so nothing is lost.

Installation

./install.sh
source ~/.zshrc

This adds the cc alias to your shell.

Usage

cc [description words]

Examples:

cc                        # → worktree/session-20260113-1400
cc fix auth bug           # → worktree/fix-auth-bug-20260113-1400
cc refactor email agent   # → worktree/refactor-email-agent-20260113-1401

Lifecycle

1. Start session

cc fix auth bug
  • Creates branch worktree/fix-auth-bug-20260113-1400
  • Creates directory <repo>-wt-fix-auth-bug-<pid>
  • Rsyncs .env and other dotfiles (excludes .venv, node_modules)
  • Launches Claude in the worktree

2. During session

  • Work happens in isolated branch
  • Main repo is untouched
  • Claude sees a SessionStart hook message about being in a worktree (if configured)

3. Exit session

On quit, Ctrl+C, or crash:

  1. If uncommitted changes exist:
    • Stages all changes (git add -A)
    • Uses Claude haiku to summarize the diff
    • Commits as WIP (fix-auth-bug): <summary>
  2. Worktree directory is deleted
  3. Branch is preserved

4. Review & cleanup

# List all worktree branches with age and summary
git for-each-ref --sort=-committerdate \
  --format='%(refname:short)  %(committerdate:relative)  %(subject)' \
  refs/heads/worktree/

# Example output:
# worktree/fix-auth-bug-20260113-1400  2 hours ago  WIP (fix-auth-bug): Add validation for sender field
# worktree/refactor-20260112-0930     1 day ago    WIP (refactor): Extract email parser to separate module

# Merge useful work
git merge worktree/fix-auth-bug-20260113-1400

# Delete stale branches
git branch -D worktree/fix-auth-bug-20260113-1400

# Bulk delete old worktree branches
git branch | grep 'worktree/' | xargs git branch -D

What gets synced to the worktree

The script rsyncs dotfiles from the main repo, excluding heavy directories:

Included:

  • .env, .envrc, and other dotfiles

Excluded:

  • .venv (run uv sync to recreate)
  • node_modules (run npm install to recreate)
  • .git
  • __pycache__, *.pyc
  • .mypy_cache, .pytest_cache

SessionStart Hook (optional)

To inform Claude it's in a worktree, add this hook to your repo's .claude/settings.json:

{
  "hooks": {
    "SessionStart": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/worktree-info.sh"
          }
        ]
      }
    ]
  }
}

And create .claude/hooks/worktree-info.sh:

#!/bin/bash
WORKTREE_INFO=$(git worktree list --porcelain 2>/dev/null | head -1)
MAIN_WORKTREE=$(echo "$WORKTREE_INFO" | sed 's/worktree //')
CURRENT_DIR=$(pwd)

if [ "$CURRENT_DIR" != "$MAIN_WORKTREE" ] && [ -n "$MAIN_WORKTREE" ]; then
    BRANCH=$(git branch --show-current)
    echo "You are working in a git worktree (isolated workspace)."
    echo "Path: $CURRENT_DIR"
    echo "Branch: $BRANCH"
    echo "Main repo: $MAIN_WORKTREE"
    echo ""
    echo "Note: .venv may not exist - run 'uv sync' if needed for Python modules."
fi

Key benefits

  1. Work is never lost - auto-commit + branch preserved
  2. Main repo stays clean - no stray uncommitted changes
  3. Multiple agents can work in parallel - each in their own worktree
  4. Meaningful commit messages - LLM-generated summaries of changes
  5. Easy cleanup - directory deleted, just branches to manage
#!/bin/zsh -i
# Usage: claude-worktree [description words...]
# Example: claude-worktree refactor email agent
# Creates a git worktree for isolated Claude sessions, auto-commits WIP on exit
REPO=$(git rev-parse --show-toplevel 2>/dev/null)
if [ -z "$REPO" ]; then
echo "Not in a git repository, running claude directly"
claude "$@"
exit
fi
LABEL="${*:-session}"
LABEL="${LABEL// /-}" # spaces → dashes
BRANCH="worktree/${LABEL}-$(date +%Y%m%d-%H%M)"
WORKTREE_PATH="${REPO}-wt-${LABEL}-$$"
echo "Creating worktree: $WORKTREE_PATH"
echo "Branch: $BRANCH"
git worktree add "$WORKTREE_PATH" -b "$BRANCH" HEAD
# Rsync gitignored files (excluding heavy dirs)
rsync -a \
--exclude='.venv' \
--exclude='node_modules' \
--exclude='.git' \
--exclude='__pycache__' \
--exclude='*.pyc' \
--exclude='.mypy_cache' \
--exclude='.pytest_cache' \
--include='.*' \
--exclude='*' \
"$REPO/" "$WORKTREE_PATH/"
echo "✓ Synced gitignored config files"
cd "$WORKTREE_PATH"
cleanup() {
cd "$WORKTREE_PATH" 2>/dev/null || return
# Auto-commit any uncommitted work
if ! git diff --quiet || ! git diff --cached --quiet || [ -n "$(git ls-files --others --exclude-standard)" ]; then
git add -A
DIFF=$(git diff --cached)
SUMMARY=$(echo "$DIFF" | claude -p --model haiku "Summarize these changes in one short sentence (max 72 chars). No prefix, just the summary." 2>/dev/null || echo "auto-save on session exit")
git commit -m "WIP ($LABEL): $SUMMARY"
echo "✓ Auto-committed WIP to branch: $BRANCH"
fi
# Always safe to cleanup now - work is committed
cd /
git -C "$REPO" worktree remove "$WORKTREE_PATH" --force
echo "✓ Worktree removed"
echo " Branch preserved: $BRANCH"
}
trap cleanup EXIT
claude
#!/bin/bash
# Install claude-code-worktrees - adds 'cc' alias and SessionStart hook
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
BIN_PATH="$SCRIPT_DIR/bin/claude-worktree"
HOOK_SRC="$SCRIPT_DIR/worktree-info.sh"
# Project-level Claude config (current git repo)
PROJECT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
if [ -z "$PROJECT_ROOT" ]; then
echo "⚠️ Not in a git repository. Hook will only be installed globally."
PROJECT_CLAUDE_DIR=""
else
PROJECT_CLAUDE_DIR="$PROJECT_ROOT/.claude"
fi
HOOK_DST="$PROJECT_CLAUDE_DIR/hooks/worktree-info.sh"
SETTINGS_FILE="$PROJECT_CLAUDE_DIR/settings.json"
# Detect user's login shell (not current shell)
if [[ "$SHELL" == *"zsh"* ]]; then
SHELL_RC="$HOME/.zshrc"
elif [[ "$SHELL" == *"bash"* ]]; then
SHELL_RC="$HOME/.bashrc"
else
SHELL_RC="$HOME/.zshrc" # default to zsh
fi
INSTALLED_ALIAS=false
INSTALLED_HOOK=false
# --- Install shell alias ---
if grep -q "claude-code-worktrees" "$SHELL_RC" 2>/dev/null; then
echo "✓ Shell alias already installed"
else
echo "" >> "$SHELL_RC"
echo "# claude-code-worktrees: isolated git worktrees for Claude sessions" >> "$SHELL_RC"
echo "alias cc=\"$BIN_PATH\"" >> "$SHELL_RC"
echo "✓ Installed 'cc' alias to $SHELL_RC"
INSTALLED_ALIAS=true
fi
# --- Install SessionStart hook (project-level) ---
if [ -n "$PROJECT_CLAUDE_DIR" ]; then
mkdir -p "$PROJECT_CLAUDE_DIR/hooks"
# Symlink hook script
ln -sf "$HOOK_SRC" "$HOOK_DST"
echo "✓ Linked hook to $HOOK_DST"
# Update settings.json
if [ ! -f "$SETTINGS_FILE" ]; then
echo '{}' > "$SETTINGS_FILE"
fi
# Check if hook already configured
if grep -q "worktree-info.sh" "$SETTINGS_FILE" 2>/dev/null; then
echo "✓ SessionStart hook already configured in project"
else
if command -v jq &> /dev/null; then
# Build the hook entry (use $CLAUDE_PROJECT_DIR for portability)
HOOK_ENTRY='[{"hooks":[{"type":"command","command":"$CLAUDE_PROJECT_DIR/.claude/hooks/worktree-info.sh"}]}]'
# Add hooks object if missing, then add SessionStart
jq --argjson entry "$HOOK_ENTRY" '
.hooks //= {} |
.hooks.SessionStart = (.hooks.SessionStart // []) + $entry
' "$SETTINGS_FILE" > "$SETTINGS_FILE.tmp" && mv "$SETTINGS_FILE.tmp" "$SETTINGS_FILE"
echo "✓ Added SessionStart hook to $SETTINGS_FILE"
INSTALLED_HOOK=true
else
echo "⚠️ jq not found. Add this to $SETTINGS_FILE manually:"
echo ' "hooks": {'
echo ' "SessionStart": [{"hooks": [{"type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/worktree-info.sh"}]}]'
echo ' }'
fi
fi
else
echo "⚠️ Skipping hook installation (not in a git repo)"
fi
echo ""
if [ "$INSTALLED_ALIAS" = true ]; then
echo "Run 'source $SHELL_RC' or restart your shell"
fi
echo ""
echo "Usage: cc [description]"
echo " cc fix auth bug → worktree/fix-auth-bug-20260113-1400"
#!/bin/bash
# Uninstall claude-code-worktrees
# Project-level Claude config (current git repo)
PROJECT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
if [ -z "$PROJECT_ROOT" ]; then
echo "⚠️ Not in a git repository."
PROJECT_CLAUDE_DIR=""
else
PROJECT_CLAUDE_DIR="$PROJECT_ROOT/.claude"
fi
HOOK_DST="$PROJECT_CLAUDE_DIR/hooks/worktree-info.sh"
SETTINGS_FILE="$PROJECT_CLAUDE_DIR/settings.json"
# Detect user's login shell
if [[ "$SHELL" == *"zsh"* ]]; then
SHELL_RC="$HOME/.zshrc"
elif [[ "$SHELL" == *"bash"* ]]; then
SHELL_RC="$HOME/.bashrc"
else
SHELL_RC="$HOME/.zshrc"
fi
# --- Remove shell alias ---
if grep -q "claude-code-worktrees" "$SHELL_RC" 2>/dev/null; then
# Remove the comment and alias lines
sed -i '' '/claude-code-worktrees/d' "$SHELL_RC"
sed -i '' '/alias cc=.*claude-worktree/d' "$SHELL_RC"
echo "✓ Removed alias from $SHELL_RC"
else
echo "✓ No alias found in $SHELL_RC"
fi
# --- Remove hook symlink ---
if [ -n "$PROJECT_CLAUDE_DIR" ]; then
if [ -L "$HOOK_DST" ] || [ -f "$HOOK_DST" ]; then
rm "$HOOK_DST"
echo "✓ Removed hook from $HOOK_DST"
else
echo "✓ No hook found at $HOOK_DST"
fi
# --- Remove from settings.json ---
if grep -q "worktree-info.sh" "$SETTINGS_FILE" 2>/dev/null; then
if command -v jq &> /dev/null; then
# Remove SessionStart entries containing worktree-info.sh
jq '
.hooks.SessionStart |= map(select(.hooks[0].command | contains("worktree-info.sh") | not)) |
if .hooks.SessionStart == [] then del(.hooks.SessionStart) else . end |
if .hooks == {} then del(.hooks) else . end
' "$SETTINGS_FILE" > "$SETTINGS_FILE.tmp" && mv "$SETTINGS_FILE.tmp" "$SETTINGS_FILE"
echo "✓ Removed SessionStart hook from $SETTINGS_FILE"
else
echo "⚠️ jq not found. Remove worktree-info.sh entry from $SETTINGS_FILE manually"
fi
else
echo "✓ No hook configured in $SETTINGS_FILE"
fi
else
echo "⚠️ Skipping hook removal (not in a git repo)"
fi
echo ""
echo "Uninstalled. Run 'source $SHELL_RC' to apply changes."
#!/bin/bash
# SessionStart hook: inform Claude when running in a git worktree
# Check if we're in a worktree (not the main working tree)
WORKTREE_INFO=$(git worktree list --porcelain 2>/dev/null | head -1)
MAIN_WORKTREE=$(echo "$WORKTREE_INFO" | sed 's/worktree //')
CURRENT_DIR=$(pwd)
if [ "$CURRENT_DIR" != "$MAIN_WORKTREE" ] && [ -n "$MAIN_WORKTREE" ]; then
BRANCH=$(git branch --show-current)
echo "You are working in a git worktree (isolated workspace)."
echo "Path: $CURRENT_DIR"
echo "Branch: $BRANCH"
echo "Main repo: $MAIN_WORKTREE"
echo ""
echo "Note: .venv may not exist - run 'uv sync' if needed for Python modules."
fi
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment