Contents

Git Worktrees: how to work in multiple branches at the same time

gitgit-worktreeworkflowtmuxclaude-codedeveloper-tools

Branches are sequential. You are on one or you are on the other, and switching means your working directory changes underneath you. Most of the time that is what you want. Sometimes it very much isn’t: you need two states of the codebase on disk at once, so you can run both, compare both, or let two different things happen in parallel without either one noticing.

The usual workarounds are stashing, committing a wip you will have to clean up later, or cloning the repository a second time into another folder. Cloning does work. It is just clumsy — two directories with the same project name in different places, two remotes, two of everything, and no relationship between them beyond you remembering which is which.

git worktree is the built-in answer, and it has been in Git since 2.5, released in 2015. It became a lot more useful to me recently, because it turns out to be the cleanest way to run several coding agents at the same time.

What a worktree is

A worktree is a second (or third, or tenth) working directory attached to one repository. Different branch checked out, different folder on disk, sharing a single .git object store and a single set of remotes.

It is a normal Git subcommand, right next to git branch. The docs are here.

The mental model that works: each worktree behaves like a separate repository. Its own branch, its own files, its own git status. But there is exactly one object database underneath, so a commit you make in one is immediately visible from all of them.

Creating one

The raw command takes two arguments, where to put it and which branch:

git worktree add worktrees/fix-auth fix-auth

If the branch does not exist yet, use -b:

git worktree add worktrees/fix-auth -b fix-auth

And there is a third flag that matters a lot if you also use containers, which I will come back to:

git worktree add worktrees/fix-auth -b fix-auth --relative-paths

The layout convention

There are two ways to organise this and one of them is a trap.

You can use a bare clone, git clone --bare, where the top-level directory is essentially the innards of .git and you create a worktrees/ folder underneath to manage everything. You will not see your project files at the top level at all.

I would advise against it. Bare repos bring path complications, and they make the relative-paths problem below considerably harder to solve.

The convention I use, with a normal clone:

my-project/          ← the main worktree, checked out to main
├── .git/
├── src/
├── .gitignore       ← contains "worktrees/"
└── worktrees/
    ├── fix-auth/    ← a full checkout of the fix-auth branch
    └── new-feature/ ← a full checkout of the new-feature branch

Root is main. Everything else lives under worktrees/. And worktrees/ has to be gitignored — that is not a style preference, it is a requirement of putting them inside the main worktree.

Problem one: absolute paths

By default Git records worktree locations as absolute paths. Create a worktree at /home/you/Projects/thing/worktrees/feature and that exact string is baked into Git’s metadata.

That is fine until the repository shows up at a different absolute path. Mount the same repo into a container at /workspaces/thing and every worktree registration now points at a directory that does not exist from the inside. It is a miserable class of bug because nothing about the error message tells you what happened.

I used to solve it with a repair script that ran on container start, walked the worktree metadata, noticed the paths did not match the filesystem and rewrote them. It worked, and it was completely unnecessary, because Git added --relative-paths in version 2.48 (January 2025), along with a config option to make it the default:

git config worktree.useRelativePaths true

With relative paths the same repository works from the host and from inside a container with no repair logic at all. I deleted the whole script. It needs a recent Git, which I think is worth upgrading for on its own.

Problem two: everything Git does not track

This is the one that made me give up on worktrees years ago, at a previous job.

When Git creates a worktree it creates a clean checkout. It reconstructs the branch from the object store, which means nothing untracked comes with it. No .env files, no node_modules, no build output.

At that job npm install took about fifteen minutes. I did the arithmetic on running a fifteen-minute install every time I wanted to look at another branch and decided I was not going to work that way, so I did not use worktrees for years.

Then the obvious fix occurred to me: copy the untracked stuff over.

Git will tell you exactly what it is. You do not have to parse .gitignore, there is a direct question for it:

git status --porcelain --ignored

Show me everything in this directory that is not part of the repository. Take that list, rsync it into the new worktree, done. Copying a folder is dramatically faster than reinstalling dependencies, and there is no install step at all — you can cd in and start the server immediately.

Here is the whole creation script:

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

NAME=${1:-}
if [[ -z "$NAME" ]]; then
  echo "usage: $0 <branch-name>" >&2
  exit 1
fi

# NOT `git rev-parse --show-toplevel`: from inside a linked worktree that
# returns the worktree's own path, so new worktrees would nest under
# worktrees/<other>/worktrees/. `--git-common-dir` always points at the shared
# .git dir; its parent is the main worktree root.
REPO_ROOT=$(dirname "$(cd "$(git rev-parse --git-common-dir)" && pwd -P)")
WORKTREE_PATH="$REPO_ROOT/worktrees/$NAME"

# stdout is reserved for the final path so the shell wrapper can cd into it
if git show-ref --verify --quiet "refs/heads/$NAME"; then
  git -C "$REPO_ROOT" worktree add "$WORKTREE_PATH" "$NAME" --relative-paths >&2
else
  git -C "$REPO_ROOT" worktree add "$WORKTREE_PATH" -b "$NAME" --relative-paths >&2
fi

# copy everything git doesn't track (envs, node_modules, build output, etc.)
git -C "$REPO_ROOT" status --porcelain --ignored | while IFS= read -r line; do
  path="${line:3}"
  [[ "$path" == worktrees/* ]] && continue
  mkdir -p "$WORKTREE_PATH/$(dirname "$path")"
  rsync -a "$REPO_ROOT/$path" "$WORKTREE_PATH/$path"
done

echo "$WORKTREE_PATH"

Two details in there are worth knowing about. The worktrees/* skip prevents copying the worktrees directory into a new worktree, which is a recursion you do not want. And using --git-common-dir instead of --show-toplevel is the difference between this working from inside an existing worktree and it nesting worktrees inside worktrees.

This step matters more than it looks, especially if you are going to point agents at these directories. An agent that lands in a worktree with no .env and no dependencies cannot run the tests, cannot start the app, and will cheerfully spend your tokens working that out.

The Node special case

Worth knowing even though I do not rely on it: Node’s module resolution walks upward. It looks for node_modules in the current directory, then the parent, then the parent’s parent, all the way up.

Since worktrees live at main/worktrees/<name>/, walking up eventually reaches main/node_modules. So in principle you can skip copying node_modules and let resolution find the root’s copy.

In practice I copy it anyway. I work with a coding agent, and agents install packages when they need them. The premise that every branch has identical dependencies stops being true the moment something adds one. So: copy.

The three aliases

The raw commands are verbose. Three shell functions cover everything I actually do.

Create and jump in:

gwc() {
  local dir
  dir=$(git-worktree-create "$@") && [[ -n "$dir" ]] && cd "$dir"
}

Switch between worktrees, via fzf:

gws() {
  local dir
  dir=$(git-worktree-select) && [[ -n "$dir" ]] && cd "$dir"
}

The selector is worth showing, because it deals with the absolute-path issue for worktrees created before you had --relative-paths:

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

REPO_ROOT=$(dirname "$(cd "$(git rev-parse --git-common-dir)" && pwd -P)")

# drop registrations for worktrees whose directory no longer exists
git -C "$REPO_ROOT" worktree prune

SELECTED_LINE=$(git -C "$REPO_ROOT" worktree list | fzf)
[[ -n "$SELECTED_LINE" ]] || exit 0

RAW_PATH=$(awk '{print $1}' <<<"$SELECTED_LINE")

# git bakes in the absolute path from when the worktree was added, which goes
# stale if the repo is later mounted at a different absolute path (e.g. a
# devcontainer). Rebuild the path relative to the current repo root instead
# of trusting git's copy verbatim.
if [[ "$RAW_PATH" == */worktrees/* ]]; then
  echo "$REPO_ROOT/worktrees/${RAW_PATH#*/worktrees/}"
else
  echo "$REPO_ROOT"
fi

Switching is just cd. Worktrees are paths, which is what makes them pleasant to use. No checkout, no stash, no waiting.

Delete, carefully:

gwd() {
  local dir unpushed
  dir=$(git-worktree-select)
  [[ -n "$dir" ]] || return
  if [[ "$dir" != */worktrees/* ]]; then
    echo "refusing to remove the main worktree: $dir"
    return 1
  fi

  # commits reachable from HEAD but on no remote-tracking branch
  unpushed=$(git -C "$dir" rev-list --count HEAD --not --remotes)
  if [[ "$unpushed" -gt 0 ]]; then
    echo "refusing to remove $dir: $unpushed commit(s) not pushed to any remote"
    return 1
  fi

  read -q "REPLY?Remove worktree $dir? [y/N] "
  echo
  [[ "$REPLY" == [Yy] ]] || return
  git worktree remove --force "$dir"
  rm -rf "$dir"
}

Three safety properties in there, all learned the hard way:

  1. Never delete the main worktree. Obvious in retrospect.
  2. Refuse if anything is unpushed. git rev-list --count HEAD --not --remotes counts commits reachable from HEAD that exist on no remote-tracking branch. Above zero means you are about to lose work. This one is doubly important with agents around, since the commits in a worktree are often not commits you wrote or remember.
  3. --force, then rm -rf. The force flag is needed because we deliberately copied untracked files in, and git worktree remove refuses to delete a worktree that contains them. The explicit rm -rf is there because git worktree remove cleans up Git’s registration and the directory can survive it. Remove both.

Running several Claude Code sessions at once

This is where worktrees stopped being a nice-to-have for me.

A coding agent occupies a working directory. It edits files, runs the test suite, starts a dev server, commits. Two agents in one directory is not a workflow, it is a merge conflict happening in real time — one of them rewrites a file the other is halfway through reasoning about, and both end up wrong. Branches do not help, because branches do not give you two directories.

Worktrees do. One task, one branch, one directory, one Claude Code session. Nothing shared except the object store, which is exactly the thing you do want shared.

I am usually running three tasks at a time, so three worktrees. Each one gets its own tmux window with an editor, a shell and a Claude session in it. Three agents, three branches, three directories, running concurrently and not interfering with each other in any way. gwc feature-x creates the worktree with all the untracked files already in place, and the agent can run the tests in its first minute rather than its fifteenth.

The statusline in each session shows which branch and which worktree it is in, which sounds minor until you are looking at five panes trying to work out which is which.

There are two ways to point an agent at a worktree.

Start it inside one. cd into the worktree, then launch. The agent treats that directory as the project root, and since agents do not wander up out of their working directory by default, it may not even register that it is in a worktree unless it looks.

Or start it in main and tell it. “For this task, work in worktree X.” It will, and it will create files there rather than in main. Claude Code understands git worktree with no special configuration or MCP server involved; you just have to say so. It will sometimes offer to create one itself. I generally do not delegate worktree management to the agent, because I want to be certain which directory holds which task, but it is capable of it.

Alongside each agent I keep a second pane in the same worktree, purely to watch what is going on. git status, and:

git diff main

Diff against main rather than against HEAD, because the agent commits as it goes and a plain git diff will often show you nothing at all. Diffing against main shows the whole shape of the work regardless of how many commits it has made in the meantime. lazygit is good for this too, and for anything larger a draft PR is better than either.

Then I merge into main, delete the worktree with gwd, and move on.

On harnesses

If someone tells you they are using a special harness to run agents on multiple tasks in parallel, some wrapper tool whose selling point is orchestrating agents across branches, I would push back. tmux plus git worktree does this, has done this for a decade, and does not require you to adopt anybody’s abstraction or wait for it to catch up with the underlying tools.

Claude Code is itself a harness, and a good one, but it works fine on its own. Tell it to use a Git worktree and it will.

Things worth knowing before you start

Worktrees are per-repository

git worktree operates within a single Git repository. If your backend and your ML service are two separate repos, worktrees do not span them. There is no cross-repo worktree.

That is one of several reasons I like monorepos. My position is that everything a company builds should live in one repository — frontend, backend, mobile, all of it, in subfolders. I will take the subfolders. It removes a whole category of version-skew problems, the “I pulled the backend but not the frontend and now nothing works” genre. At minimum, main always means one coherent thing.

Editors can be worktree-aware

Neovim, configured well, notices when it is running inside a worktree and scopes its Git integration to that worktree rather than the repository root. It is not automatic, it depends on your plugins, but it is the behaviour you want and it is worth checking that yours does it.

Where this meets containers

I wrote a separate post about dev containers, and I want to be precise about the relationship, because it confused people when I presented this.

Worktrees and dev containers are unrelated. Everything above works with no container anywhere in sight. They are two independent topics that happen to combine into something quite good.

The connection is that a worktree exists inside the repository, so it exists both on the host and inside the container, because the container has the repo mounted, worktrees and all. One rule makes this painless:

Always start your dev container from main.

Launch it from inside a worktree and you get a different dev container. Start it from main and every worktree is reachable from the one container. When I want to work on a feature I open a shell in the running container, gws into the worktree I want, and start the agent there. Two terminal panes into the same container are two sessions, not two containers.

That, plus --relative-paths so Git’s metadata survives the host-to-container path change, is the whole integration. Two independent tools, one convention, and three agents working three branches inside one reproducible environment.

Comments