Give every parallel Claude Code session a worktree that already runs: a WorktreeCreate hook for env files, ports and dependencies
A fresh worktree has no .env, no node_modules and the same port as the session next to it. Two small hook scripts fix all three and clean up safely. About 15 minutes to set up.
Part of the hooks reading guide

Two agents are working in two worktrees and both start a dev server. One of them dies with EADDRINUSE, the other quietly talks to the wrong database, and a third session that was supposed to run the integration tests can’t, because its worktree has no .env. None of this is the agent’s fault. A worktree is a fresh checkout, so it has the tracked files and nothing else.
Claude Code has two built-in answers: .worktreeinclude for copying gitignored files, and “ask Claude to run the install”. Both stop short of ports, databases and anything you’d like to happen the same way every time. A WorktreeCreate hook covers the rest. It runs whenever Claude Code makes a worktree, whether you passed --worktree, a subagent has isolation: worktree, or a background session needs its own checkout.
By the end of this piece there are two scripts in .claude/hooks/. The first creates the worktree, copies your .env files, gives the worktree a stable port and database name, and installs dependencies before the agent sees the directory. The second refuses to delete a worktree that holds uncommitted or unmerged work, and deletes the branch when it’s safe. The parent post on running parallel sessions is here
; this one is the setup half it only gestures at.
What the hook replaces
Read this part before copying anything, because it’s the catch. According to the hooks reference
, a configured WorktreeCreate hook replaces Claude Code’s default git behaviour entirely. Three consequences follow:
- Your script has to create the worktree itself, normally with
git worktree add. .worktreeincludeis not processed. If you want.envcopied, the script copies it.- The default base-branch logic (
worktree.baseRef, the remote fetch) is gone. Your script picks the base.
The contract is small. The hook gets JSON on stdin with a name field (a slug, either what you typed after --worktree or something generated like bold-oak-a3f2) plus the common fields, including cwd. It prints the absolute path of the new directory as the last non-empty line of stdout. Anything else it prints has to go to stderr, because stdout is the return channel. Any non-zero exit fails creation. There’s no matcher: it fires every time.
That contract is why a one-line change in the wrong place breaks everything. A stray echo on stdout becomes the path. Keep that in mind when reading the script.
The create hook
Save this as .claude/hooks/worktree-create.sh and run chmod +x on it. It needs bash, git and jq.
#!/usr/bin/env bash
# WorktreeCreate hook: stdout's last line is the worktree path, so everything else goes to stderr.
set -euo pipefail
input=$(cat)
name=$(jq -r .name <<<"$input")
cwd=$(jq -r .cwd <<<"$input")
root=$(git -C "$cwd" rev-parse --show-toplevel)
dir="$root/.claude/worktrees/$name"
branch="wt/$name"
if [ ! -d "$dir" ]; then
base=HEAD
git -C "$root" rev-parse --verify -q origin/HEAD >/dev/null && base=origin/HEAD
git -C "$root" worktree add -b "$branch" "$dir" "$base" >&2
fi
# Replacing the default creation skips .worktreeinclude, so copy ignored files here.
if [ -f "$root/.worktreeinclude" ]; then
git -C "$root" ls-files -o -i --exclude-from=.worktreeinclude \
| { git -C "$root" check-ignore --stdin || true; } \
| while IFS= read -r f; do
mkdir -p "$dir/$(dirname "$f")"
cp "$root/$f" "$dir/$f"
echo "copied $f" >&2
done
fi
# A port derived from the name is stable across restarts and needs no probing.
port=$((3100 + $(printf '%s' "$name" | cksum | cut -d' ' -f1) % 900))
printf 'PORT=%s\nDB_NAME=app_%s\n' "$port" "${name//[^a-zA-Z0-9]/_}" > "$dir/.env.local"
echo "PORT=$port" >&2
if [ -f "$dir/package-lock.json" ] && [ ! -d "$dir/node_modules" ]; then
(cd "$dir" && npm ci --silent >&2)
fi
echo "$dir"
It reads like a lot, but it does five separate things.
Creation. If the directory doesn’t exist, git worktree add -b wt/<name> makes it on a new branch. The base is origin/HEAD when the repository has one and your current HEAD when it doesn’t, which roughly mirrors the built-in default of branching from the remote’s default branch. The >&2 matters: git prints “Preparing worktree” to stdout, and that would otherwise be read as the path. If the directory exists already, the script reuses it, so claude --worktree feature-auth run twice opens the same place twice, as it does without a hook.
Copying ignored files. git ls-files -o -i --exclude-from=.worktreeinclude lists untracked files that match the patterns in your existing .worktreeinclude, so the file you may already have keeps working. The check-ignore filter keeps only files that are also gitignored, which copies the documented behaviour: tracked files are never duplicated. The || true is there because check-ignore exits 1 when nothing matches, and under pipefail a repository without a .env would otherwise fail every worktree creation. A teammate’s fresh clone, with no .env yet, is exactly that case.
Ports and database names. The cksum of the name, taken modulo 900 and added to 3100, gives a port between 3100 and 3999 that never changes for a given name. No probing for a free port, no registry file, and the same worktree gets the same port when you come back to it tomorrow. The same name feeds a database name, with anything that isn’t a letter or digit turned into an underscore. Both go into .env.local, which is gitignored in most projects. Your dev server and test runner need to read PORT and DB_NAME from it, which is a one-line change in most frameworks.
Dependencies. npm ci runs in the new directory when there’s a lockfile and no node_modules. Swap in dotnet restore, uv sync, pnpm install --frozen-lockfile or whatever your project uses. This is the slow part, and it’s the reason to do it in the hook: the agent starts in a directory where npm test works, rather than spending its first few turns discovering that it doesn’t.
The path. The last line, echo "$dir", is the only thing on stdout.
The remove hook
Without a WorktreeRemove hook, Claude Code falls back to git worktree remove --force when you exit a worktree session, which removes the directory whatever it holds. The docs
say a remove hook that exits 0 counts as having removed the worktree, and one that exits non-zero fails the removal if the directory is still there. That makes it a last check you control.
Save as .claude/hooks/worktree-remove.sh, also executable:
#!/usr/bin/env bash
# WorktreeRemove hook: exit non-zero and leave the directory in place if work would be lost.
set -euo pipefail
path=$(jq -r .worktree_path)
[ -d "$path" ] || exit 0
branch=$(git -C "$path" branch --show-current)
if [ -n "$(git -C "$path" status --porcelain)" ]; then
echo "refusing to remove $path: uncommitted changes" >&2
exit 1
fi
if [ -n "$(git -C "$path" log --oneline origin/HEAD..HEAD 2>/dev/null || git -C "$path" log --oneline main..HEAD)" ]; then
echo "refusing to remove $path: commits not on the base branch" >&2
exit 1
fi
git -C "$path" worktree remove "$path"
git branch -d "$branch" >&2
Input is worktree_path. The script refuses on a dirty tree, refuses when the branch has commits the base doesn’t, and otherwise removes the worktree and deletes the branch with -d, which git itself refuses to do for an unmerged branch. That last line is not decoration. The docs are explicit that Claude Code never deletes the branch of a hook-created worktree, because it only knows the path your create hook returned. Skip the git branch -d and wt/* branches pile up.
The main..HEAD fallback assumes your default branch is called main. Change it if yours isn’t.
Wire them up
.claude/settings.json, committed so the whole team gets it:
{
"hooks": {
"WorktreeCreate": [
{ "hooks": [ { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/worktree-create.sh" } ] }
],
"WorktreeRemove": [
{ "hooks": [ { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/worktree-remove.sh" } ] }
]
}
}
Referencing the script through $CLAUDE_PROJECT_DIR is the pattern the docs use for hook scripts. It also matters here for a specific reason: once Claude enters a worktree, ${CLAUDE_PROJECT_DIR} still points at the main checkout, so the hooks run from one place no matter where the session is standing.
Make sure .claude/worktrees/ is in .gitignore, and that .env, .env.local and node_modules are too.
Show it working
Run the create hook by hand with the documented input shape, from the repository root:
echo "{\"cwd\":\"$PWD\",\"name\":\"feature-auth\"}" | .claude/hooks/worktree-create.sh
It prints this (the first two lines are git, on stderr; the last line is the path on stdout):
Preparing worktree (new branch 'wt/feature-auth')
HEAD is now at c9cde47 init
copied .env
PORT=3296
/tmp/wtdemo/app/.claude/worktrees/feature-auth
The worktree directory holds the tracked files, a copy of .env, and a .env.local containing PORT=3296 and DB_NAME=app_feature_auth. Names map to different ports: fix-date-parse gets 3621, docs-cli-flags gets 3561 and bright-running-fox gets 3551.
Now the real thing. With Claude Code 2.1.302, this one-shot run goes through the same hooks:
claude -p --worktree hook-demo --debug-file dbg.log \
--allowedTools "Bash(pwd)" --permission-mode dontAsk "Run pwd and tell me the path."
The answer comes back as:
The path is `/tmp/wtdemo/app/.claude/worktrees/hook-demo`.
and the debug log contains these lines (trimmed to the relevant ones):
WorktreeCreate ["$CLAUDE_PROJECT_DIR"/.claude/hooks/worktree-create.sh] completed with status 0
Created hook-based worktree at: /tmp/wtdemo/app/.claude/worktrees/hook-demo
The new directory has the .env and a .env.local with PORT=3220. The agent’s working directory is the worktree the hook returned.
The remove hook’s three outcomes, run directly against the documented worktree_path input:
refusing to remove /tmp/wtdemo/app/.claude/worktrees/feature-auth: uncommitted changes
refusing to remove /tmp/wtdemo/app/.claude/worktrees/feature-auth: commits not on the base branch
Deleted branch wt/no-env (was c9cde47).
Dirty tree refused, unmerged commit refused, clean worktree removed along with its branch. What wasn’t observed: Claude Code itself invoking the remove hook. A -p run has no exit prompt and doesn’t clean up, so that wiring rests on the documentation. Test it once in an interactive session: start claude --worktree scratch, exit, and watch for the removal.
Make it yours
Python or .NET projects. Replace the npm ci block. For .NET, dotnet restore in the worktree directory is enough, and a per-worktree ConnectionStrings__Default in .env.local replaces DB_NAME. For Python, uv sync --frozen.
Real databases. Deriving a name is the cheap half. If each worktree needs its own database actually created, add a createdb "app_$name" to the create hook and a matching dropdb --if-exists to the remove hook. Do the drop after the dirty-tree check, never before.
Shared caches instead of installs. If npm ci takes minutes on your monorepo, a package manager with a global store (pnpm’s, or a warm npm cache) makes the second and third worktree nearly free. Symlinking node_modules from the main checkout is tempting and a bad idea: every agent then shares one dependency tree, and one npm install in a worktree changes all of them. There’s a second problem specific to hooks. The docs say Claude Code refuses a returned path that passes through a symlink below the repository root, so keep the worktree directory itself a real directory.
HTTP hooks. A WorktreeCreate hook can also be an HTTP hook returning hookSpecificOutput.worktreePath. For one repository on one laptop a shell script is simpler.
What goes wrong
Hash collisions. Nine hundred ports is plenty for three or four live worktrees, but not collision-proof. Two names can land on the same port, roughly a 0.3 percent chance for three worktrees by the usual birthday arithmetic. If a clash matters, have the script check .env.local files in sibling worktrees and bump the number, or widen the range.
Subagent and workflow worktrees aren’t cleaned by your hook. When a subagent or a workflow agent finishes, the docs say Claude Code keeps the worktree the hook created and doesn’t run WorktreeRemove. The periodic sweep that clears Claude’s own subagent worktrees also skips any worktree without Claude Code’s marker, which includes hook-created ones. Those directories and wt/* branches stay until you remove them. Run git worktree list now and then, and git worktree prune after deleting directories by hand.
Cookies and shared services. Separate ports don’t separate browser cookies on localhost, and a per-worktree database name does nothing for a shared Redis or a third-party sandbox account. The hook can only isolate what your app lets it parameterise.
Environment files carry secrets. Copying .env into every worktree multiplies the places a secret sits on disk, and into directories an agent can read freely. Copy only what a worktree needs, and use a dev-only .env rather than one holding production credentials.
Slow hooks. Creation waits for the hook. A two-minute install in the create hook means two minutes before the session starts. That’s the right trade for an interactive session. For a fan-out of many worktrees at once, it adds up.
The hook doesn’t run when Claude shells out to git worktree add through the Bash tool. The docs say so, and suggest a PreToolUse hook on Bash if you want to catch that.
Don’t bother with any of this if you run one session at a time, or if your project starts with no environment at all. The payoff scales with how many worktrees you open per day and how much a fresh one is missing.
First 15 minutes
- Copy the two scripts into
.claude/hooks/,chmod +xboth, and add thehooksblock to.claude/settings.json. - Edit the dependency line in the create script to your install command, and make your dev server read
PORTfrom.env.local. - Run the create script by hand with the
echo ... |line above. Check the last line of stdout is only the path. - Start
claude --worktree try-hooks, ask it to run your test command, and confirm it passes without any setup from the agent. - Exit, and check the remove hook refuses when you’ve left an uncommitted file and cleans up when you haven’t.