Replacing My Custom Git Worktree Skill with Claude Code Hooks
9 min read
If you’ve been following along with my recent posts, you’ll know that git worktrees have become a fundamental part of how I work. One worktree per feature, one AI session per worktree, everything isolated. It’s the setup that makes parallel AI-assisted development actually work.
For months, I’ve been managing this through a custom Claude Code skill — /git-worktree — that handled creating worktrees, naming branches, and crucially, copying local development files into each new worktree. It worked well. But recently, Claude Code shipped native worktree support, and I found myself in that slightly awkward position of having a custom tool that overlaps with an official feature.
The question was whether I could retire my skill and adopt the built-in approach — without losing the bits that made my workflow actually work.
What My Custom Skill Did
The /git-worktree skill was straightforward. When I started work on a feature, it would:
- Create a git worktree in a
.worktreesdirectory - Name the branch using gitflow conventions —
feature/add-streaming,feature/fix-rate-limiting, etc. - Copy files listed in a
.worktreeincludefile from the main repo into the new worktree
That third point is the important one. A git worktree is a fresh checkout — it only contains committed files. But in any real project, there are local files that git quite rightly ignores but that you absolutely need to actually run the thing. Config files like appsettings.Development.json and .env with your API keys and connection strings. Visual Studio .user settings. And in my case, an entire demo site instance — a fully configured Umbraco installation with a database, media files, and sample content that gets generated by a setup script and lives in a gitignored demo/ directory. None of that is committed, but without it, a new worktree can’t build, can’t run, and can’t be tested.
The .worktreeinclude file solved this. It’s a simple file in the repo root that lists patterns for the non-committed files you want carried across into each new worktree:
# .NET config
appsettings.Development.json
appsettings.Local.json
*.user
# Typescript config
.env*
!.env.template
# Demo site
demo/
*.local.sln
It’s a small thing, but it’s one of those details that makes the difference between “worktrees work in theory” and “worktrees work in practice.”
Claude Code Gets Native Worktree Support
With the introduction of the --worktree flag, Claude Code now handles worktree creation natively. You just run:
claude -w feature-auth
It creates a worktree, starts a session inside it, and even handles cleanup when you’re done — removing the worktree automatically if there are no changes, or prompting you to keep it if there are. Session management, resume support, and subagent isolation all work out of the box.
It’s genuinely well thought out. But it didn’t quite line up with what I needed.
The built-in behaviour creates branches named worktree-<name> and doesn’t know anything about .worktreeinclude. Which meant adopting it as-is would break two things I cared about: my gitflow branch naming, and the local file copying that keeps worktrees functional from the moment they’re created.
WorktreeCreate Hooks: The Best of Both Worlds
What I hadn’t initially noticed was that Claude Code also ships WorktreeCreate and WorktreeRemove hooks. These fire when a worktree is being created or removed, and — this is the key part — they replace the default git behaviour entirely.
The hook receives a JSON payload on stdin with the worktree name, and it’s expected to print the absolute path to the created worktree on stdout. Everything else is up to you. Claude Code doesn’t care how you create the worktree — it just needs a path back.
This meant I could use the official claude -w syntax while completely controlling what happens under the hood. No custom skill needed.
The Implementation
The hook itself lives in .claude/hooks/worktree-create.sh and does three things: creates the worktree with a gitflow branch name (feature/<name> instead of worktree-<name>), copies files matching .worktreeinclude patterns, and prints the worktree path to stdout for Claude Code to use.
The key insight was the file copying. Rather than reimplementing gitignore pattern matching in bash — which is what my original skill did, complete with hand-rolled glob expansion and directory pruning — I realised I could just use git itself. The .worktreeinclude file uses gitignore syntax, and git ls-files has an --exclude-from flag that accepts a gitignore-format file. Combined with --others --ignored, it returns exactly the untracked files matching those patterns. That single command replaced about 80 lines of find/prune/glob logic. Git handles all the pattern matching natively — globs, ** for recursive matches, ! for negation, trailing / for directories. All of it, correctly. The matched files then get bulk-copied via tar piping, which handles thousands of files efficiently.
The corresponding WorktreeRemove hook handles cleanup — running git worktree remove with a fallback for locked files on Windows.
Here are the full files:
.claude/hooks/worktree-create.sh
#!/bin/bash
# WorktreeCreate hook for Claude Code
#
# Replaces the default git worktree creation to:
# 1. Use feature/<name> branch naming (gitflow convention)
# 2. Copy files specified in .worktreeinclude to the new worktree
#
# Input (JSON on stdin): { "name": "<slug>", "cwd": "<project-root>", ... }
# Output (stdout): Absolute path to the created worktree directory
#
# All informational output goes to stderr to keep stdout clean for the path.
# Cross-platform: handles Windows (Git Bash) and Unix path conversions.
set -e
# --- Read input ---
INPUT=$(cat)
if ! command -v jq &>/dev/null; then
echo "Error: jq is required for worktree hooks. Install it: https://jqlang.github.io/jq/download/" >&2
echo " Windows (winget): winget install jqlang.jq" >&2
echo " Windows (scoop): scoop install jq" >&2
exit 1
fi
NAME=$(echo "$INPUT" | jq -r '.name')
CWD=$(echo "$INPUT" | jq -r '.cwd')
# --- Cross-platform path handling ---
# Claude Code sends Windows paths (D:\Work\...) in JSON on Windows,
# but bash/find need Unix-style paths (/d/Work/...).
# cygpath is available in Git Bash on Windows.
to_unix_path() {
if command -v cygpath &>/dev/null; then
cygpath -u "$1"
else
echo "$1"
fi
}
to_native_path() {
if command -v cygpath &>/dev/null; then
cygpath -w "$1"
else
echo "$1"
fi
}
# --- Paths ---
# Convert CWD to Unix-style for internal use, fallback to git root
if [[ -n "$CWD" && "$CWD" != "null" ]]; then
GIT_ROOT=$(to_unix_path "$CWD")
else
GIT_ROOT=$(git rev-parse --show-toplevel)
fi
WORKTREE_DIR="$GIT_ROOT/.claude/worktrees"
WORKTREE_PATH="$WORKTREE_DIR/$NAME"
BRANCH_NAME="feature/$NAME"
# --- Ensure .claude/worktrees is in .gitignore ---
if ! grep -qF '.claude/worktrees' "$GIT_ROOT/.gitignore" 2>/dev/null; then
# Add a newline if file doesn't end with one
if [[ -f "$GIT_ROOT/.gitignore" ]] && [[ -n "$(tail -c 1 "$GIT_ROOT/.gitignore")" ]]; then
echo "" >> "$GIT_ROOT/.gitignore"
fi
echo ".claude/worktrees" >> "$GIT_ROOT/.gitignore"
echo "Added .claude/worktrees to .gitignore" >&2
fi
# --- Determine base branch ---
# Use the default remote branch (usually dev or main)
DEFAULT_BRANCH=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's@^refs/remotes/origin/@@') || true
if [[ -z "$DEFAULT_BRANCH" ]]; then
# Fallback: check common branch names
for candidate in dev main master; do
if git show-ref --verify --quiet "refs/remotes/origin/$candidate" 2>/dev/null; then
DEFAULT_BRANCH="$candidate"
break
fi
done
fi
DEFAULT_BRANCH="${DEFAULT_BRANCH:-dev}"
# --- Create worktree ---
mkdir -p "$WORKTREE_DIR"
if git show-ref --verify --quiet "refs/heads/$BRANCH_NAME" 2>/dev/null; then
echo "Using existing branch: $BRANCH_NAME" >&2
git worktree add "$WORKTREE_PATH" "$BRANCH_NAME" >&2
else
echo "Creating branch: $BRANCH_NAME (from origin/$DEFAULT_BRANCH)" >&2
git worktree add -b "$BRANCH_NAME" "$WORKTREE_PATH" "origin/$DEFAULT_BRANCH" >&2
fi
# --- Copy .worktreeinclude files ---
# .worktreeinclude uses gitignore syntax (globs, negation, directory patterns).
# We pass it directly to git's pattern matching engine via --exclude-from,
# so all gitignore rules work natively: *, **, !, trailing /, etc.
INCLUDE_FILE="$GIT_ROOT/.worktreeinclude"
if [[ -f "$INCLUDE_FILE" ]]; then
file_list=$(git -C "$GIT_ROOT" ls-files --others --ignored --exclude-from="$INCLUDE_FILE" 2>/dev/null)
if [[ -z "$file_list" ]]; then
echo "No files matched .worktreeinclude patterns" >&2
else
count=$(echo "$file_list" | wc -l | tr -d ' ')
echo "Copying $count file(s) from .worktreeinclude..." >&2
# Bulk copy via tar (fast even for thousands of files, handles paths with spaces)
git -C "$GIT_ROOT" ls-files -z --others --ignored --exclude-from="$INCLUDE_FILE" 2>/dev/null | \
tar -C "$GIT_ROOT" --null -T - -cf - 2>/dev/null | \
tar -C "$WORKTREE_PATH" -xf - 2>/dev/null
# Summary: group by top-level directory, show root files individually
echo "$file_list" | awk -F/ '{print ($2 ? $1"/" : $0)}' | sort | uniq -c | \
while read -r cnt path; do
if [[ "$cnt" -eq 1 && "$path" != */ ]]; then
echo " + $path" >&2
else
echo " + $path ($cnt files)" >&2
fi
done
fi
else
echo "No .worktreeinclude file found - skipping file copy" >&2
fi
# --- Output the worktree path (this is what Claude Code reads) ---
# Convert to native path format so Claude Code (Node.js) can use it.
# On Windows: /d/Work/... -> D:\Work\...
# On Unix: passes through unchanged.
ABSOLUTE_PATH=$(cd "$WORKTREE_PATH" && pwd)
echo "$(to_native_path "$ABSOLUTE_PATH")"
.claude/hooks/worktree-remove.sh
#!/bin/bash
# WorktreeRemove hook for Claude Code
#
# Handles cleanup when a worktree session ends.
# Since WorktreeCreate replaces the default git behavior,
# we need this hook to properly run git worktree remove.
#
# Cross-platform: handles Windows (Git Bash) and Unix path conversions.
#
# Input (JSON on stdin): { "worktree_path": "<absolute-path>", ... }
set -e
INPUT=$(cat)
if ! command -v jq &>/dev/null; then
echo "Error: jq is required for worktree hooks" >&2
exit 1
fi
WORKTREE_PATH=$(echo "$INPUT" | jq -r '.worktree_path')
if [[ -z "$WORKTREE_PATH" || "$WORKTREE_PATH" == "null" ]]; then
echo "No worktree_path provided" >&2
exit 0
fi
# Convert Windows path to Unix-style for Git Bash if needed
if command -v cygpath &>/dev/null; then
WORKTREE_PATH=$(cygpath -u "$WORKTREE_PATH")
fi
if [[ ! -d "$WORKTREE_PATH" ]]; then
# Already removed, nothing to do
exit 0
fi
# Try git worktree remove first (cleanest approach)
if git worktree remove "$WORKTREE_PATH" --force 2>/dev/null; then
echo "Removed worktree: $WORKTREE_PATH" >&2
else
# Fallback: prune and force-remove directory
# Common on Windows when files are locked by IDE/build processes
echo "git worktree remove failed, attempting manual cleanup..." >&2
git worktree prune 2>/dev/null || true
rm -rf "$WORKTREE_PATH" 2>/dev/null || true
fi
exit 0
.claude/settings.json (hooks section)
{
"hooks": {
"WorktreeCreate": [
{
"hooks": [
{
"type": "command",
"command": "bash \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/worktree-create.sh",
"timeout": 60
}
]
}
],
"WorktreeRemove": [
{
"hooks": [
{
"type": "command",
"command": "bash \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/worktree-remove.sh",
"timeout": 30
}
]
}
]
}
}
What I Gained
The migration from a custom skill to hooks was a net simplification across the board:
Less code to maintain. The skill had a 760-line bash script handling creation, listing, switching, copying, removal, and cleanup. The hooks are about 80 lines each, and most of that is the worktree creation and path handling.
Official syntax, custom behaviour. claude -w auth-refactor just works — but it creates feature/auth-refactor and copies my local config files. Anyone on the team can use the standard flag without knowing about the hooks.
Better session management. Claude Code’s built-in session handling — resume, cleanup prompts, subagent isolation — all comes for free. My custom skill had none of that.
Cross-platform path handling. One thing I had to get right was Windows compatibility. Claude Code sends Windows-style paths (D:\Work\...) in the hook input, but Git Bash needs Unix-style paths (/d/Work/...). A pair of cygpath wrapper functions at the top of the script handles the conversion at the boundaries — Unix internally, native format for the output path.
The Pattern
What I like about this approach is the general pattern it demonstrates. Claude Code’s hook system isn’t just for blocking dangerous commands or running linters — it’s an extension mechanism. When a built-in feature gets you 80% of the way there, hooks let you bridge the remaining 20% without maintaining a parallel implementation.
The WorktreeCreate hook in particular is interesting because it fully replaces the default behaviour rather than augmenting it. That’s a powerful escape hatch. It means Claude Code can ship opinionated defaults that work for most people, while teams with specific conventions can slot in their own logic and still benefit from all the surrounding infrastructure.
I suspect more of my custom skills will follow this path as Claude Code’s feature set continues to evolve — each one replaced not by a single feature, but by the right combination of a feature and a hook.
Until next time 👋