Git Worktrees
how to run multiple branches simultaneously, wire up hooks to copy .env, and use the bare-repo pattern for a cleaner checkout workflow.
- DATE:
- APR.30.2026
- READ:
- 20 MIN
The problem: you’re mid-refactor on a feature branch when a production hotfix lands. You need to switch contexts immediately. The options most people reach for — git stash, git stash pop, deal with the fallout — work, but they’re friction. You lose your editor state, half your terminals are pointed at the wrong branch, and stash pop occasionally introduces conflicts into code you never touched.
Git worktrees solve this cleanly. One repository. Multiple checked-out branches. Each in its own directory.
What a worktree is
Git was designed around a single “working tree” — the directory where your files live. Since Git 2.5 (2015), you can attach additional working trees to the same repository. Each linked worktree:
- Checks out a different branch (you cannot check out the same branch in two worktrees simultaneously)
- Has its own HEAD and index (staging area)
- Shares everything else: the object database, all refs, all config, all hooks
The mental model is simple: one .git directory, multiple directories of checked-out files.
The pointer chain: ../feature-x/.git is a plain text file (not a directory) containing one line — gitdir: /path/to/repo/.git/worktrees/feature-x. That path is an admin directory inside your main .git that holds the per-worktree HEAD and index. The commondir file inside the admin directory points back to the main .git, giving every worktree access to the shared object store and refs.
Inside any linked worktree, two git commands expose this structure:
git rev-parse --git-dir # → .git/worktrees/feature-x (per-worktree)
git rev-parse --git-common-dir # → /path/to/repo/.git (shared)Core commands
# Add a linked worktree (new branch from HEAD)
git worktree add -b feature/auth ../auth
# Add for an existing branch
git worktree add ../hotfix hotfix/cve-2026-001
# Add at an existing branch, creating/resetting it from main
git worktree add -B hotfix ../hotfix main
# Detached HEAD (useful for reviewing a commit or PR)
git worktree add --detach ../scratch
# List all worktrees
git worktree list
git worktree list --porcelain # machine-readable, stable format
# Remove a worktree (must be clean)
git worktree remove ../feature/auth
git worktree remove --force ../feature/auth # unclean
git worktree remove --force --force ../auth # also locked
# Prune stale entries (after manually deleting a worktree directory)
git worktree prune
git worktree prune --dry-run
# Lock/unlock (prevents prune — useful for NFS mounts or external drives)
git worktree lock --reason "on USB drive" ../auth
git worktree unlock ../auth
# Move a worktree
git worktree move ../auth ../new/path/auth
# Repair broken links after manually moving a worktree directory
git worktree repair ../moved-pathThe file structure in detail
When you run git worktree add ../feature-x -b feature/x, git creates two things:
1. The linked worktree directory (../feature-x/):
../feature-x/
.git ← plain FILE: "gitdir: /abs/path/to/repo/.git/worktrees/feature-x"
src/ ← your checked-out files
package.json
(no .env, no node_modules — gitignored files don't appear)2. An admin directory (.git/worktrees/feature-x/):
.git/worktrees/feature-x/
HEAD ← "ref: refs/heads/feature-x" (per-worktree)
index ← binary staging area (per-worktree)
gitdir ← "/abs/path/to/feature-x/.git" (absolute path to the .git FILE)
commondir ← "../.." (relative path back to main .git/)
logs/ ← per-worktree reflogs
refs/worktree/ ← per-worktree refs (bisect state, rebase-merge, etc.)
locked ← only present if locked; contains the lock reasonPer-worktree refs under refs/worktree/ are isolated. Everything else — refs/heads/, refs/remotes/, refs/tags/ — is shared. This is why you can’t check out the same branch in two worktrees: refs/heads/feature-x can only point to one commit, and having two HEADs pointing to it would make commits from one worktree invisible to the other’s working tree.
What doesn’t carry over
Any file that’s gitignored exists only in the checkout where it was created. This is intentional — .gitignore marks things that aren’t part of the tracked project:
.env,.env.local,.env.development,.env.testnode_modules/- Build output (
dist/,build/,.next/,.svelte-kit/) - IDE settings (
.idea/,.vscode/if gitignored) - Any tool cache (
.turbo/,.cache/) - Database files (
dev.db,*.sqlite) - Secrets and credentials
This is the #1 source of “why doesn’t anything work in my new worktree” confusion. The fix is either copying these files explicitly or symlinking them — covered next.
Hooks are shared
Hooks live in .git/hooks/. Since every linked worktree’s commondir points back to the main .git, all worktrees share the same hooks. There is no per-worktree hooks directory.
When a hook fires in a linked worktree, the hook script is read from .git/hooks/post-checkout (via the commondir lookup), but GIT_DIR inside the hook environment points to .git/worktrees/<name>. This distinction is what lets you detect which kind of worktree is running the hook.
Copying .env on worktree creation
git worktree add fires the post-checkout hook with the new worktree as PWD. This is the right place to copy .env files and install dependencies.
post-checkout hook
#!/bin/sh
# .git/hooks/post-checkout
# Args: $1=prev-HEAD $2=new-HEAD $3=1(branch-checkout)|0(file-checkout)
# Only run on branch checkouts, not individual file checkouts
[ "$3" = "1" ] || exit 0
GIT_DIR_VAL=$(git rev-parse --git-dir 2>/dev/null) || exit 0
GIT_COMMON=$(git rev-parse --git-common-dir 2>/dev/null) || exit 0
WORKTREE_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0
# Detect linked worktree: git-dir ends in worktrees/<name>
case "$GIT_DIR_VAL" in
*/worktrees/*)
MAIN_ROOT=$(dirname "$GIT_COMMON")
# Copy env files — never overwrite an existing one
for envfile in .env .env.local .env.development .env.test; do
src="$MAIN_ROOT/$envfile"
dst="$WORKTREE_ROOT/$envfile"
if [ -f "$src" ] && [ ! -f "$dst" ]; then
cp "$src" "$dst"
echo "[worktree] Copied $envfile"
fi
done
# Install dependencies
if [ -f "$WORKTREE_ROOT/package.json" ]; then
echo "[worktree] Installing dependencies..."
(cd "$WORKTREE_ROOT" && bun install --frozen-lockfile 2>&1) || true
fi
;;
esacMake it executable: chmod +x .git/hooks/post-checkout.
The case match on */worktrees/* is the reliable detection. In the main worktree, git rev-parse --git-dir returns .git (not an absolute path with worktrees/ in it).
Shell script wrapper (more explicit)
If you want full control without relying on hook timing, wrap git worktree add in a script:
#!/usr/bin/env bash
# scripts/new-worktree.sh
set -euo pipefail
BRANCH="${1:?Usage: $0 <branch-name>}"
BASENAME=$(basename "$BRANCH")
DEST="../$BASENAME"
MAIN_ROOT=$(git rev-parse --show-toplevel)
# Create the worktree (create branch if it doesn't exist)
git worktree add -b "$BRANCH" "$DEST" 2>/dev/null
|| git worktree add "$DEST" "$BRANCH"
# Copy env files
for envfile in .env .env.local .env.development .env.test; do
src="$MAIN_ROOT/$envfile"
dst="$DEST/$envfile"
if [[ -f "$src" && ! -f "$dst" ]]; then
cp "$src" "$dst"
echo "Copied $envfile → $DEST"
fi
done
# Install deps
echo "Installing dependencies in $DEST..."
(cd "$DEST" && bun install)
echo ""
echo "Worktree ready: $DEST (branch: $BRANCH)"./scripts/new-worktree.sh feature/payment-redesign
# → ../payment-redesign/ with .env copied and deps installedSymlink strategy (always in sync)
If your .env is the same across all branches, a symlink is simpler than copying:
MAIN_ROOT=$(git rev-parse --show-toplevel)
git worktree add -b feature/x ../feature-x
ln -s "$MAIN_ROOT/.env" ../feature-x/.env
ln -s "$MAIN_ROOT/.env.local" ../feature-x/.env.local 2>/dev/null || trueChanges to .env in either directory affect both. Only do this if your env values are truly branch-agnostic.
direnv approach (.envrc committed to the repo)
If your team uses direnv, this is the most ergonomic solution — it works automatically in every worktree without any setup:
# .envrc (committed to the repo)
# Works from any worktree — resolves the main worktree's .env
MAIN_GIT=$(git rev-parse --git-common-dir)
MAIN_ROOT=$(dirname "$MAIN_GIT")
dotenv "$MAIN_ROOT/.env"
dotenv_if_exists "$MAIN_ROOT/.env.local"direnv re-evaluates .envrc on every cd. Since .envrc is committed, every worktree picks it up automatically. The actual .env stays only in the main worktree.
node_modules in worktrees
+--------------------+------------------+--------+--------------------+ | Strategy | Speed | Safety | Notes | +--------------------+------------------+--------+--------------------+ | Separate install | slow (first run) | safe | fully isolated; | | per worktree | | | best for | | | | | conflicting dep | | | | | versions | +--------------------+------------------+--------+--------------------+ | Symlink to main | instant | risky | breaks on new | | node_modules | | | installs; | | | | | hardcodes main | | | | | tree path | +--------------------+------------------+--------+--------------------+ | pnpm (shared | fast | safe | each worktree | | content store) | | | installs but | | | | | hardlinks to | | | | | shared store | +--------------------+------------------+--------+--------------------+ | bun install | fast | safe | ~1-3s warm cache; | | | | | just run it per | | | | | worktree | +--------------------+------------------+--------+--------------------+
For most projects using bun or pnpm, just run the install command in each new worktree. It’s fast enough that the symlink shortcut isn’t worth the footgun.
Typical workflows
Hotfix while mid-feature
# You're deep in feature/auth, mid-refactor
git worktree add -b hotfix/cve-2026-001 ../hotfix main
# In a second terminal:
cd ../hotfix
cp ../your-repo/.env ./.env # or let the post-checkout hook handle this
bun install
# fix the bug, commit, push, open PR
# Back in your main terminal — feature/auth is untouched
git worktree remove ../hotfixNo stash. No context switch. Your feature branch editor state is exactly where you left it.
Parallel test runs
# Run the full test suite on main while you develop on feature
git worktree add ../main-check main
cp .env ../main-check/.env
(cd ../main-check && bun install && bun test > /tmp/main-tests.log 2>&1) &
# Your tests run simultaneously
bun test
wait
echo "Main branch tests:" && tail -5 /tmp/main-tests.logReviewing a PR without touching your current branch
git worktree add --detach ../review-pr-1234
cd ../review-pr-1234
git fetch origin pull/1234/head:pr-1234
git checkout pr-1234
# Read, run, comment — then:
cd ../your-repo
git worktree remove ../review-pr-1234The bare repo pattern
Instead of git clone (which creates a working tree you’ll immediately ignore most of the time), clone with --bare and add worktrees for every branch you care about.
git clone --bare https://github.com/org/repo.git repo.git
cd repo.git
# Fix fetch configuration (bare clones don't set this by default)
git config remote.origin.fetch "+refs/heads/*:refs/remotes/origin/*"
git fetch origin
# Add worktrees for branches you work on
git worktree add ../main main
git worktree add ../feature-x -b feature/my-feature
git worktree add ../review-pr42 -b review/pr-42
git worktree list
# /path/to/repo.git (bare)
# /path/to/main abc1234 [main]
# /path/to/feature-x def5678 [feature/my-feature]
# /path/to/review-pr42 ghi9012 [review/pr-42]Directory layout:
repo.git/ ← bare repo (hooks/, objects/, refs/ — no working files)
main/ ← worktree for main branch
feature-x/ ← worktree for your feature
review-pr42/ ← worktree for PR reviewEvery branch is a directory at the same level. There is no “default” checkout taking up space. cd ../main always gives you a clean main with no stashing required.
Pros of the bare pattern
- Clean directory structure — each branch is a directory, nothing implicit
git fetchinrepo.git/updates all worktrees simultaneously (shared object store)- No “main” working tree accidentally checked out on a detached HEAD or wrong branch
- Preferred by contributors managing many concurrent PRs
Cons
- No
git pullwithout explicit remote setup (the fetch config fix above) - Hooks in
repo.git/hooks/aren’t auto-populated — you need to add them manually or use a hook manager - Some tools expect
.gitto be a directory inside the working tree, not a file pointing elsewhere (most modern tooling handles this correctly; older scripts that docat .git/HEADdirectly will fail) .envandnode_modulesstill need per-worktree setup regardless
post-merge hook for dependency changes
When you pull or merge and the lockfile changes, you want deps to reinstall automatically. Add this hook alongside post-checkout:
#!/bin/sh
# .git/hooks/post-merge
# $1 = 1 if squash merge, 0 otherwise
changed=$(git diff-tree -r --name-only --no-commit-id ORIG_HEAD HEAD 2>/dev/null)
if echo "$changed" | grep -qE '(package.json|bun.lockb|package-lock.json|pnpm-lock.yaml)'; then
echo "[post-merge] Lockfile changed — running install..."
bun install 2>&1 || npm install
fiGotchas
You can’t check out the same branch in two worktrees. This is fundamental — refs/heads/main is a single pointer.
git worktree add ../second main
# fatal: 'main' is already used by worktree at '/path/to/repo'Detached HEAD from remote branches. If you pass a remote tracking ref to git worktree add, git creates a detached HEAD instead of a local branch:
git worktree add ../feature-x origin/feature-x # detached HEAD!
# Fix:
cd ../feature-x && git switch -c feature-xManually deleting a worktree directory leaves stale admin files. Run git worktree prune afterward:
rm -rf ../feature-x # without git worktree remove
git worktree list # shows "prunable: gitdir file points to non-existent location"
git worktree prune # cleans up .git/worktrees/feature-x/Submodules. Multiple worktrees of a superproject with submodules is explicitly flagged as unsupported in the git man page. Tread carefully.
gc.worktreePruneExpire defaults to 3 months — git gc will auto-clean stale admin dirs after that window.
git config gc.worktreePruneExpire "never" # disable auto-prune if neededDetecting your worktree context in scripts
The reliable pattern — used in all the hook scripts above:
GIT_DIR=$(git rev-parse --git-dir)
GIT_COMMON=$(git rev-parse --git-common-dir)
if [ "$GIT_DIR" = "$GIT_COMMON" ]; then
WORKTREE_TYPE="main"
else
WORKTREE_TYPE="linked"
MAIN_ROOT=$(dirname "$GIT_COMMON")
WORKTREE_NAME=$(basename "$GIT_DIR") # e.g., "feature-x"
figit-dir equals git-common-dir only in the main worktree. In any linked worktree they differ — git-dir is the per-worktree admin path, git-common-dir is the shared .git/.
Resources
Git official documentation —
git-worktree(1)man page. https://git-scm.com/docs/git-worktree
The Primeagen — bare repo workflow video (widely cited starting point for the bare clone pattern).
Stack Overflow — How to make git worktree work with submodules. https://stackoverflow.com/questions/41208660/how-to-make-git-worktree-work-with-submodules
direnv documentation. https://direnv.net/