<< BACK

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.

+ ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- +
worktree layoutrepo/main worktree.git/HEADref: refs/heads/mainSHARED across all worktrees:confighooks/objects/ · refs/ · packed-refsworktrees/feature-x/HEADref: refs/heads/feature-xindexper-worktree staging areagitdir→ ../feature-x/.gitcommondir../.. (back to .git/)../feature-x/linked worktree.git← plain FILE, not a directorygitdir: repo/.git/worktrees/feature-xsrc/package.jsonNOT auto-copied (gitignored):.env / .env.localnode_modules/build/ · dist/ · .cache/git rev-parse --git-dir→ .git/worktrees/feature-xgit rev-parse --git-common-dir→ repo/.gitshared= hooks/, objects/, refs/ — same for every worktree, read from main .gitper-worktree= HEAD, index — separate files under .git/worktrees/<name>/lavender →= gitdir pointer: linked .git file → admin dir → commondir → main .git
+ ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- +

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-path
++

The 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 reason
++

Per-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.test
  • node_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
    ;;
esac
++

Make 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 installed
++

Symlink 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 || true
++

Changes 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 ../hotfix
++

No 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.log
++

Reviewing 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-1234
++

The 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 review
++

Every 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 fetch in repo.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 pull without 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 .git to be a directory inside the working tree, not a file pointing elsewhere (most modern tooling handles this correctly; older scripts that do cat .git/HEAD directly will fail)
  • .env and node_modules still 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
fi
++

Gotchas

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-x
++

Manually 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 needed
++

Detecting 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"
fi
++

git-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/