Extending Claude Code Worktrees for True Database Isolation

9 min read

If you’re running multiple Claude Code sessions on a Rails app, you need isolation. Without it, agents edit the same files, collide on the same branch, and corrupt each other’s state.

Git worktrees solve the file side of this. Each agent gets its own working directory with its own branch. Anthropic recently shipped native worktree support in Claude Code, which handles the Git mechanics automatically. No external scripts to maintain.

But file isolation is only half the problem. A Rails app running in a worktree still points at the same development and test databases as your main checkout. Two agents running specs in parallel will insert conflicting test data, and you’ll get flaky tests with no obvious cause.

Native worktrees handle file isolation. The WorktreeCreate hook handles everything else.

What Native Worktree Support Does

Claude Code now manages worktrees directly. Start a session with the --worktree flag:

claude --worktree my-feature-branch

This creates a new branch and checks it out in a dedicated directory under .claude/worktrees/. The branch name argument is optional. Skip it and Claude generates a random name.

The --worktree flag isn’t the only entry point. You can also:

  • Ask Claude mid-session to work in a worktree
  • Spawn sub-agents that each get their own worktree automatically
  • Set isolation: "worktree" on custom agent definitions so they always run isolated

That last option is worth calling out. If you have an agent that runs specs or does anything destructive, setting isolation at the agent level means you never have to remember the flag. It’s just how that agent works.

One thing to note: .claude/worktrees/ is not added to .gitignore by default. Add it early so worktree directories don’t end up in your commit history.

The .worktreeinclude File

Git worktrees only duplicate tracked files. Anything in your .gitignore won’t carry over. For most Rails apps, that means your .env file, config/master.key, and any other credentials are missing in every new worktree.

Claude Code solves this with .worktreeinclude. It works like a reverse .gitignore. You list the gitignored files that should be copied into each new worktree:

# .worktreeinclude
.env
config/master.key
config/credentials/development.key

When Claude creates a worktree, it reads this file and copies each listed file from the main checkout into the new directory. Your agents stop failing silently because they’re running without credentials.

Where Native Worktrees Fall Short

File isolation is necessary, but it’s not sufficient. Consider this scenario: two agents running your test suite at the same time. Both point at the same test database. Agent A inserts test data. Agent B’s assertions fail because the data doesn’t match what it expected.

You get flaky tests, data collisions, and corrupted state. The root cause is invisible because each agent’s test run passes when run alone.

True isolation for a Rails app requires three things:

  1. A separate database per worktree so test runs don’t collide
  2. Environment config (credentials, .env files) available in each worktree
  3. Dependencies installed so the app can actually boot

The .worktreeinclude file handles environment config. Native worktrees handle the Git mechanics. But nothing handles the database. That’s the gap.

The WorktreeCreate Hook

Claude Code supports lifecycle hooks that run at specific moments during a session. The one we care about is WorktreeCreate. It fires every time a worktree is created, whether from the CLI flag, a mid-session request, or a sub-agent spawn.

This hook was originally designed for teams using version control systems other than Git, like SVN or Mercurial. It’s an extension point that lets you replace the default Git worktree behavior entirely with your own setup logic.

We can use that same extension point to bootstrap a full Rails environment.

Hook Configuration

Add the hook to your Claude Code settings at .claude/settings.json:

{
  "hooks": {
    "WorktreeCreate": [
      {
        "command": ".claude/hooks/worktree-create.sh",
        "timeout": 60000
      }
    ]
  }
}

The timeout is set to 60 seconds. bin/setup can take a while, especially if it’s running database migrations.

When the hook fires, Claude passes JSON to stdin with the session context, including the working directory and session ID. Your script reads that input, does the setup, and prints the path to the created worktree to stdout. That’s the contract: print the path, exit zero, and Claude uses that directory.

The Setup Script

Here’s the full script. I’ll walk through each section below.

#!/usr/bin/env bash
set -euo pipefail

# WorktreeCreate hook: creates a git worktree, symlinks shared files,
# configures worktree-specific databases, and runs bin/setup.
#
# Input (JSON on stdin): { "name": "<slug>", "cwd": "<project-root>", ... }
# Output (stdout): absolute path to the created worktree directory
# All other output goes to stderr so it doesn't interfere with the path.

INPUT=$(cat)
NAME=$(echo "$INPUT" | jq -r '.name')
PROJECT_DIR=$(echo "$INPUT" | jq -r '.cwd')

WORKTREE_DIR="$PROJECT_DIR/.claude/worktrees/$NAME"

# -------------------------------------------------------------------
# 1. Create the git worktree
# -------------------------------------------------------------------
if [ -d "$WORKTREE_DIR" ]; then
  echo "Worktree directory already exists: $WORKTREE_DIR" >&2
  exit 1
fi

echo "Creating git worktree at $WORKTREE_DIR ..." >&2
git -C "$PROJECT_DIR" worktree add -b "$NAME" "$WORKTREE_DIR" HEAD >&2

# -------------------------------------------------------------------
# 2. Symlink entries from .worktreeinclude
# -------------------------------------------------------------------
INCLUDE_FILE="$PROJECT_DIR/.worktreeinclude"
if [ -f "$INCLUDE_FILE" ]; then
  echo "Symlinking entries from .worktreeinclude ..." >&2
  while IFS= read -r entry || [ -n "$entry" ]; do
    # Skip blank lines and comments
    entry=$(echo "$entry" | sed 's/#.*//' | xargs)
    [ -z "$entry" ] && continue

    SOURCE="$PROJECT_DIR/$entry"
    TARGET="$WORKTREE_DIR/$entry"

    if [ ! -e "$SOURCE" ]; then
      echo "  SKIP (not found): $entry" >&2
      continue
    fi

    # Ensure parent directory exists in the worktree
    mkdir -p "$(dirname "$TARGET")"

    # Remove the file/dir that git checkout placed there (if any)
    rm -rf "$TARGET"

    ln -s "$SOURCE" "$TARGET"
    echo "  Linked: $entry" >&2
  done <"$INCLUDE_FILE"
else
  echo "No .worktreeinclude file found, skipping symlinks." >&2
fi

# -------------------------------------------------------------------
# 3. Configure worktree-specific databases via .env.local
# -------------------------------------------------------------------
# Sanitize the name for use in database names (replace dashes with underscores)
DB_SLUG=$(echo "$NAME" | tr '-' '_')

ENV_LOCAL="$WORKTREE_DIR/.env.local"
echo "Writing worktree-specific database config to .env.local ..." >&2
cat >"$ENV_LOCAL" <<EOF
# Auto-generated for worktree: $NAME
DB_DATABASE=tracewell_development_${DB_SLUG}
DB_TEST_DATABASE=tracewell_test_${DB_SLUG}
EOF
echo "  Development DB: tracewell_development_${DB_SLUG}" >&2
echo "  Test DB:        tracewell_test_${DB_SLUG}" >&2

# -------------------------------------------------------------------
# Output: print the worktree path for Claude Code (must happen before
# any step that could fail so Claude Code can track the session)
# -------------------------------------------------------------------
echo "$WORKTREE_DIR"

# -------------------------------------------------------------------
# 4. Run bin/setup (skip starting the dev server)
# -------------------------------------------------------------------
echo "Running bin/setup --skip-server ..." >&2
(cd "$WORKTREE_DIR" && bin/setup --skip-server) >&2

The script does four things:

Creates the Git worktree. This replicates what Claude does natively, giving us a new branch and working directory.

Copies .worktreeinclude files. We re-implement this behavior since we’re replacing the default worktree creation. Each listed file gets copied from the main checkout into the new worktree.

Writes a .env.local with unique database names. This is the key part. The branch name gets transformed into a Postgres-safe format (hyphens become underscores) and used as a prefix for both the development and test database names. Your database.yml needs to read from these environment variables, falling back to defaults when they’re not set:

# config/database.yml
development:
  url: <%= ENV.fetch("DATABASE_URL", "postgres://localhost/myapp_development") %>

test:
  url: <%= ENV.fetch("TEST_DATABASE_URL", "postgres://localhost/myapp_test") %>

When .env.local exists in the worktree, the app picks up the unique database names. In your main checkout, the defaults apply as usual.

Runs bin/setup. This creates the databases, runs migrations, and installs dependencies. The worktree is fully bootable when the hook finishes.

Each worktree now points at its own development and test database. Two agents can run specs simultaneously without interference.

Cleaning Up with WorktreeRemove

Worktrees accumulate, and so do the databases they create. Claude Code automatically cleans up the worktree directory when you exit a session with no changes, but the databases stick around.

The WorktreeRemove hook mirrors the creation logic in reverse:

{
  "hooks": {
    "WorktreeCreate": [
      {
        "command": ".claude/hooks/worktree-create.sh",
        "timeout": 60000
      }
    ],
    "WorktreeRemove": [
      {
        "command": ".claude/hooks/worktree-remove.sh",
        "timeout": 60000
      }
    ]
  }
}

The removal script reads the worktree path, derives the database names the same way the creation script did, and drops them:

#!/usr/bin/env bash
set -euo pipefail

# WorktreeRemove hook: drops worktree-specific databases and deletes the
# associated branch. Claude Code handles git worktree removal automatically.
#
# Input (JSON on stdin): { "name": "<slug>", "cwd": "<project-root>", ... }
# All output goes to stderr.

INPUT=$(cat)
NAME=$(echo "$INPUT" | jq -r '.name')
PROJECT_DIR=$(echo "$INPUT" | jq -r '.cwd')
WORKTREE_DIR=$(echo "$INPUT" | jq -r '.worktree_path')

if [ ! -d "$WORKTREE_DIR" ]; then
  echo "Worktree directory not found: $WORKTREE_DIR" >&2
  exit 0
fi

# Move to main repo before removing the worktree so the process cwd stays valid
cd "$PROJECT_DIR"

# -------------------------------------------------------------------
# 1. Drop worktree-specific databases via Rails
# -------------------------------------------------------------------
echo "Dropping databases for worktree: $NAME ..." >&2
(cd "$WORKTREE_DIR" && RAILS_ENV=development bin/rails db:drop DISABLE_DATABASE_ENVIRONMENT_CHECK=1) >&2
(cd "$WORKTREE_DIR" && RAILS_ENV=test bin/rails db:drop DISABLE_DATABASE_ENVIRONMENT_CHECK=1) >&2

# -------------------------------------------------------------------
# 2. Deregister the git worktree (required before branch can be deleted)
# -------------------------------------------------------------------
echo "Deregistering git worktree at $WORKTREE_DIR ..." >&2
git -C "$PROJECT_DIR" worktree remove --force "$WORKTREE_DIR" >&2

# -------------------------------------------------------------------
# 3. Delete the branch
# -------------------------------------------------------------------
if git -C "$PROJECT_DIR" rev-parse --verify "$NAME" >/dev/null 2>&1; then
  echo "Deleting branch: $NAME ..." >&2
  git -C "$PROJECT_DIR" branch -D "$NAME" >&2
else
  echo "Branch not found, skipping: $NAME" >&2
fi

echo "Worktree '$NAME' removed successfully." >&2

Alternatively, you could skip the hook and run a periodic cleanup script that finds orphaned databases and drops them. The hook approach is cleaner if you want everything automated.

Beyond Rails

These patterns aren’t Rails-specific. Rails conventions make the setup straightforward, but any application with external state has the same gap. Django, Phoenix, Laravel, if your app talks to a database, a cache, or a message queue, Git worktrees alone won’t isolate those resources.

The worktree gives you file isolation. The hook gives you everything else.

The mental model is simple: the WorktreeCreate hook is your opportunity to bootstrap whatever environment your application needs, and the WorktreeRemove hook is your opportunity to tear it down. What you put in those scripts depends entirely on your stack.


I covered the full setup process in a video walkthrough. If you want to see the hook in action, including the database creation and a parallel test run, watch it here.

If you’re looking to set up isolated agent workflows for your team or need help integrating Claude Code into your development process, let’s talk.

More on building real systems

I write about AI integration, architecture decisions, and what actually works in production.

Occasional emails, no fluff.

Powered by Buttondown