When working with Git worktrees, environment files (.env, .env.local, etc.) are not automatically copied from the main repository to new worktrees. This causes issues because:
- β New worktrees can't run the project without
.envfiles - β You have to manually copy
.envfiles every time you create a worktree - β Easy to forget, leading to confusing errors when trying to test
- β Especially problematic with Claude Code which creates worktrees automatically
- β Monorepos and nested projects have
.envfiles in subdirectories too
Set up a global Git hook that automatically copies all .env* files from the main repository to any new worktree when it's created. It searches recursively through the project, preserving directory structure, while skipping gitignored directories like node_modules/, dist/, vendor/, etc.
git config --global core.hooksPathIf this returns a path:
- You already have a global hooks directory configured
- Use that path instead of
~/.git-hooksin the steps below - Skip to Step 3
If this returns nothing (or an error):
- No global hooks configured yet
- Proceed to Step 2
# Create the directory
mkdir -p ~/.git-hooks
# Configure Git to use it globally
git config --global core.hooksPath ~/.git-hooks
# Verify it's set
git config --global core.hooksPath
# Should output: /Users/yourname/.git-hooksRun this command to create the hook file:
cat > ~/.git-hooks/post-checkout << 'EOF'
#!/bin/bash
# Global Git hook: post-checkout
# Automatically copy .env files to new worktrees (including nested directories)
# Skips gitignored directories (node_modules, vendor, dist, etc.)
# Only run for worktree checkouts (not regular branch switches in main repo)
# $3 = 1 means it's a branch checkout (not file checkout)
if [ "$3" != "1" ]; then
exit 0
fi
# Get the common git directory (works for both main repo and worktrees)
GIT_COMMON_DIR=$(git rev-parse --git-common-dir)
MAIN_REPO=$(cd "$GIT_COMMON_DIR/.." && pwd)
CURRENT_DIR=$(pwd)
# If we're in a worktree (not the main repo), copy .env files
if [ "$CURRENT_DIR" != "$MAIN_REPO" ]; then
echo "π Worktree detected: $CURRENT_DIR"
# Build a list of gitignored directories to prune from traversal
# This prevents find from even entering node_modules/, dist/, etc.
prune_args=(-name ".git" -prune)
while IFS= read -r dir; do
[ -z "$dir" ] && continue
prune_args+=(-o -path "$MAIN_REPO/$dir" -prune)
done < <(
# List all directories, check which ones are gitignored
find "$MAIN_REPO" -mindepth 1 -maxdepth 3 -type d -not -path "*/.git/*" -not -name ".git" | while read -r d; do
reldir="${d#$MAIN_REPO/}"
if git -C "$MAIN_REPO" check-ignore -q "$reldir" 2>/dev/null; then
echo "$reldir"
fi
done
)
# Find .env* files, skipping pruned (gitignored) directories
find "$MAIN_REPO" \( "${prune_args[@]}" \) -o -name ".env*" -type f -print | while read -r envfile; do
relpath="${envfile#$MAIN_REPO/}"
# Copy preserving directory structure
target="$CURRENT_DIR/$relpath"
if [ ! -f "$target" ]; then
mkdir -p "$(dirname "$target")"
cp "$envfile" "$target"
echo " β Copied $relpath"
else
echo " β Skipped $relpath (already exists)"
fi
done
echo "β
Environment files synced!"
fi
EOFchmod +x ~/.git-hooks/post-checkout# Check the hook exists and is executable
ls -la ~/.git-hooks/post-checkout
# Should show something like:
# -rwxr-xr-x 1 yourname staff XXX Jan 14 XX:XX /Users/yourname/.git-hooks/post-checkoutCreate a test worktree to verify it works:
# Navigate to any repo with .env files
cd /path/to/your/repo
# Create a test worktree
git worktree add ~/.test-worktree -b test-branch
# You should see output like:
# Preparing worktree (new branch 'test-branch')
# HEAD is now at XXXXXXX <commit message>
# π Worktree detected: /Users/yourname/.test-worktree
# β Copied .env
# β Copied .env.local
# β Copied packages/api/.env
# β
Environment files synced!
# Verify files were copied
find ~/.test-worktree -name ".env*"
# Clean up
git worktree remove ~/.test-worktree
git branch -D test-branch- Trigger: When you run
git worktree add, Git executes thepost-checkouthook - Prune: The hook identifies gitignored directories (like
node_modules/) usinggit check-ignoreand excludes them from traversal - Search: It recursively finds all
.env*files in non-ignored directories - Copy: Copies them to the new worktree, preserving the directory structure (skips if they already exist)
All files matching .env* pattern, at any depth in the project:
.env.env.local.env.development.env.production.env.test.env.examplepackages/api/.envapps/web/.env.local- etc.
- Files inside gitignored directories (
node_modules/,dist/,vendor/,.venv/, etc.) - Files that already exist in the worktree
- β Won't overwrite: If a file already exists in the worktree, it's skipped
- β Only worktrees: Won't affect your main repository
- β Only on creation: Only runs when checking out a new worktree, not on branch switches
- β Global: Works for all repositories on your machine
- β
Respects .gitignore: Won't traverse into ignored directories like
node_modules/ - β
Preserves structure: Nested
.envfiles keep their relative paths
β Zero manual copying: Environment files are automatically synced to new worktrees
β
Works with monorepos: Recursively copies .env files from nested packages and apps
β
Works with Claude Code: Claude creates worktrees automatically, and this hook ensures they have the necessary .env files
β
Works with manual worktrees: Also works when you create worktrees manually with git worktree add
β Set once, use everywhere: Global hook applies to all repositories on your machine
β No maintenance: Set it up once and it works forever
Check if global hooks path is set:
git config --global core.hooksPathIf not set, configure it:
git config --global core.hooksPath ~/.git-hooksCheck if hook is executable:
ls -la ~/.git-hooks/post-checkout
# Should show -rwxr-xr-x (note the 'x' for executable)If not executable, fix it:
chmod +x ~/.git-hooks/post-checkoutVerify .env files exist in main repo:
cd /path/to/main/repo
find . -name ".env*" -not -path "./.git/*"Test hook manually in a worktree:
cd /path/to/worktree
bash -x ~/.git-hooks/post-checkout 0 0 1This will show debug output to help identify the issue.
In worktrees, git rev-parse --git-dir returns .git/worktrees/<worktree-name>, which is the worktree-specific git directory. If we use cd "$GIT_DIR/..", we end up in .git/worktrees/ instead of the main repo root.
Using --git-common-dir always points to the main .git directory, so cd "$GIT_COMMON_DIR/.." correctly resolves to the main repository root where .env files are stored.
Incorrect approach (buggy):
GIT_DIR=$(git rev-parse --git-dir)
MAIN_REPO=$(cd "$GIT_DIR/.." && pwd)
# Results in: /path/to/repo/.git/worktrees (WRONG!)Correct approach:
GIT_COMMON_DIR=$(git rev-parse --git-common-dir)
MAIN_REPO=$(cd "$GIT_COMMON_DIR/.." && pwd)
# Results in: /path/to/repo (CORRECT!)Instead of finding all .env* files and then filtering, the hook prunes gitignored directories upfront so find never enters them. This is both faster (no traversing node_modules/ with thousands of files) and correct (no .env files from dependencies get copied).
After setting this up, you can:
- Create worktrees with
git worktree addand environment files will be automatically copied - Use Claude Code without worrying about missing
.envfiles - Test features in isolated worktrees without manual setup
If you prefer, you can run this all-in-one script:
# Create global hooks directory if it doesn't exist
mkdir -p ~/.git-hooks
# Create the post-checkout hook
cat > ~/.git-hooks/post-checkout << 'HOOKEOF'
#!/bin/bash
if [ "$3" != "1" ]; then exit 0; fi
GIT_COMMON_DIR=$(git rev-parse --git-common-dir)
MAIN_REPO=$(cd "$GIT_COMMON_DIR/.." && pwd)
CURRENT_DIR=$(pwd)
if [ "$CURRENT_DIR" != "$MAIN_REPO" ]; then
echo "π Worktree detected: $CURRENT_DIR"
prune_args=(-name ".git" -prune)
while IFS= read -r dir; do
[ -z "$dir" ] && continue
prune_args+=(-o -path "$MAIN_REPO/$dir" -prune)
done < <(
find "$MAIN_REPO" -mindepth 1 -maxdepth 3 -type d -not -path "*/.git/*" -not -name ".git" | while read -r d; do
reldir="${d#$MAIN_REPO/}"
if git -C "$MAIN_REPO" check-ignore -q "$reldir" 2>/dev/null; then
echo "$reldir"
fi
done
)
find "$MAIN_REPO" \( "${prune_args[@]}" \) -o -name ".env*" -type f -print | while read -r envfile; do
relpath="${envfile#$MAIN_REPO/}"
target="$CURRENT_DIR/$relpath"
if [ ! -f "$target" ]; then
mkdir -p "$(dirname "$target")"
cp "$envfile" "$target"
echo " β Copied $relpath"
else
echo " β Skipped $relpath (already exists)"
fi
done
echo "β
Environment files synced!"
fi
HOOKEOF
# Make it executable
chmod +x ~/.git-hooks/post-checkout
# Configure Git to use global hooks
git config --global core.hooksPath ~/.git-hooks
echo "β
Global Git worktree .env auto-copy hook installed!"If you run into issues, check:
- Global hooks path is configured:
git config --global core.hooksPath - Hook file exists:
ls ~/.git-hooks/post-checkout - Hook is executable:
ls -la ~/.git-hooks/post-checkout .envfiles exist in main repo:find . -name ".env*" -not -path "./.git/*"