You are halfway through a refactor. Nothing compiles. Someone reports production is broken and needs a one-line fix on main.

So you git stash, switch branch, fix, switch back, git stash pop, and spend the next ten minutes working out which of your changes came back and which were already there. Or you keep a second clone of the repo, and now you maintain two sets of hooks, two .env files, two IDE indexes, and 4 GB of duplicated history.

git worktree solves this properly: several branches checked out at once, in separate directories, sharing one object database. It shipped in Git 2.5 in 2015 and a lot of people still have not touched it.

I picked up the layout below from Git Worktree Like a Boss by Metal3d, then spent an evening finding the parts that bite. Three parts as usual: how we got here, how it actually works, then a recipe that I have actually run.


Part 1 — How We Got Here

The switching problem

Git’s default model gives you one working directory per clone. HEAD points at one branch, and the files on disk are that branch. Switching means rewriting those files in place.

That is fine until your working directory has state you cannot cheaply reproduce: uncommitted work, a running dev server, a warm build cache, node_modules for a different dependency set, an IDE that will reindex the moment the files change underneath it.

git stash was the historical answer. It is a stack of diffs, and it works, but it is a stack — anonymous entries, no branch association, and a pop that can conflict. Stashes are also easy to lose track of; most long-lived repos have a stash@{7} that nobody dares drop.

A second clone was the other answer, and it is the one most teams actually use. It works too, but each clone is an island: its own object database, its own hooks, its own config, its own remotes. Nothing you fix in one shows up in the other, and a large repo costs you its full history every time.

What worktree changed

Git 2.5 added git worktree, which splits a concept that had always been welded together: the repository (objects, refs, config) and the working tree (files on disk at some commit).

One repository, many working trees. Each has its own directory, its own HEAD, its own index. All of them share one object store, one set of remotes, one config, one stash list, one set of hooks.

Git also adds a safety property you do not get from multiple clones: the same branch cannot be checked out in two worktrees at once. It refuses. Two clones will happily let you commit to main in both and then work out the mess at push time.


Part 2 — How It Works

Shared history, separate files

Two clones — duplicated everythingrepo-a/.git objectshooks, configrepo-b/.git objectshooks, configno shared stateBare repo + worktrees — one database.bare/objects · refs · config · hooksmain/hotfix/review/Each worktree has its own HEAD, index and files. Everything else is shared.Fetch once and every worktree sees it. Fix a hook once and every worktree uses it.The safety property clones do not give youA branch can be checked out in only one worktree at a time — git refuses the second.

Why a bare clone, and what the .git file does

You can use worktrees on an ordinary clone. git worktree add ../hotfix works today in any repo you have. But then one branch is privileged: it lives in the original directory, with .git inside it, and the others hang off it as siblings. Delete or move that directory and you have broken the others.

The bare layout removes the privilege. The repository lives in .bare/, and every branch is an equal sibling directory next to it.

The one-line trick that makes it ergonomic:

echo "gitdir: ./.bare" > .git

A .git file — rather than a directory — containing a gitdir: pointer is a documented Git mechanism (it is how submodules and worktrees themselves work internally). It means git commands run from the root directory find the repository, so git fetch and git worktree list work from the top rather than only inside a worktree.

myapp/├── .bare/the actual repository — objects, refs, config, hooks├── .gita file, one line: gitdir: ./.bare├── main/worktree — branch main├── hotfix-tls/worktree — branch hotfix/tls└── release-1.0/worktree — branch release/1.0Every branch is a peer. Nothing is the "real" checkout.

Part 3 — The Recipe

Everything below was run on Git 2.50.1 against a scratch repository. Where the widely-shared version of this setup falls short, I have said so.

Yes — make the directory first

The commonly posted recipe starts with git clone --bare … .bare, which quietly assumes you are already inside a directory dedicated to this one repository. If you run it in ~/projects, you get ~/projects/.bare, ~/projects/.git, and your worktrees as siblings of every unrelated project you own.

That is not just untidy. It changes what git thinks your other directories are:

$ cd ~/projects/some-other-project
$ git rev-parse --git-dir
/Users/you/projects/.bare
$ git status
fatal: this operation must be run in a work tree

some-other-project is not a git repository, so git walked up the tree, found ~/projects/.git, and resolved it to the wrong repository. Every git command you run in any non-repo folder under ~/projects now points at that bare repo.

So: create and enter the folder first. One mkdir that most write-ups omit.

The setup

mkdir myapp && cd myapp

git init --bare .bare
echo "gitdir: ./.bare" > .git

git remote add origin git@github.com:user/repo.git
git config remote.origin.fetch "+refs/heads/*:refs/remotes/origin/*"
git config worktree.guessRemote true

git fetch origin
git worktree add main

That is deliberately git init --bare plus a remote, not git clone --bare. The reason is the next section.

A script that does it for you

Seven commands is few enough to type, but not few enough to type correctly at 2am. I put the whole thing in a script — it works out the directory name from the URL, detects the remote’s actual default branch instead of assuming main, and refuses to touch a directory that already has anything in it.

Download git-worktree-init.sh, or straight from the shell:

curl -fsSL https://vzav.eu/files/git-worktree-init.sh -o git-worktree-init.sh
chmod +x git-worktree-init.sh

Using it:

$ ./git-worktree-init.sh git@github.com:user/repo.git
==> creating bare repository in .bare
==> configuring origin
==> fetching
==> adding worktree for 'main'
==> ready — /Users/you/repo

/Users/you/repo/.bare  (bare)
/Users/you/repo/main   09356e2 [main]

  cd repo/main

The directory name comes from the URL, so git@github.com:user/repo.git gives you repo/. Pass a second argument to override it:

./git-worktree-init.sh https://github.com/cli/cli.git ~/code/gh-cli

Three things it does that are easy to forget by hand: it reads the remote’s default branch from origin/HEAD rather than assuming main (verified against a master repo), it converts slashes in that branch name into dashes for the directory, and if the fetch fails it removes the half-built directory instead of leaving a broken layout behind.

The rest of this section is what the script is protecting you from.

Four things the short recipe leaves out

1. A bare clone gives you local branches with no upstream.

git clone --bare copies the remote’s refs/heads/* into your local refs/heads/*. You end up with local branches that look right and are not wired to anything:

$ git worktree add main
Preparing worktree (checking out 'main')

$ cd main && git status -sb
## main

$ git pull
There is no tracking information for the current branch.

git init --bare plus an explicit remote add never creates those local heads, so the first git worktree add main creates the branch fresh and tracks origin/main properly:

$ git worktree add main
Preparing worktree (new branch 'main')
branch 'main' set up to track 'origin/main'.

$ cd main && git status -sb
## main...origin/main

2. Setting the fetch refspec does nothing until you fetch.

The remote.origin.fetch line is what fixes the “blind clone” problem — a bare repository has no refspec mapping remote branches into refs/remotes/origin/*, so you cannot see what exists on the server. But setting config does not populate anything:

$ git config remote.origin.fetch "+refs/heads/*:refs/remotes/origin/*"
$ git branch -r
                                  # nothing

$ git fetch origin
$ git branch -r
  origin/HEAD -> origin/main
  origin/feature/login
  origin/main
  origin/release/1.0

3. worktree.guessRemote is off by default.

Without it, git worktree add somebranch for a branch that only exists on the remote does not do what you want. It does this:

$ git worktree add main
No possible source branch, inferring '--orphan'
Preparing worktree (new branch 'main')

An orphan branch with no history. git config worktree.guessRemote true makes git worktree add <name> find origin/<name> and set up tracking. Without it you have to be explicit every time:

git worktree add --track -b main main origin/main

4. Slashes in branch names are read as directory paths.

This is the one that will catch you, because it fails silently and looks like it worked:

$ git worktree add release/1.0
Preparing worktree (new branch '1.0')

You asked for release/1.0 and got a branch called 1.0, in a nested directory release/1.0/, tracking nothing. Git took the argument as a path and named the branch after its basename.

Name the branch explicitly and flatten the directory:

$ git worktree add --track -b release/1.0 release-1.0 origin/release/1.0
Preparing worktree (new branch 'release/1.0')
branch 'release/1.0' set up to track 'origin/release/1.0'.

$ cd release-1.0 && git status -sb
## release/1.0...origin/release/1.0

Day-to-day

# start a new branch off main, in its own directory
git worktree add -b feature/rate-limit feature-rate-limit origin/main

# check out an existing remote branch to review it
git worktree add review origin/feature/login

# what do I have open?
git worktree list

# done — remove the directory and deregister it
git worktree remove feature-rate-limit

# clean up entries whose directories you deleted by hand
git worktree prune

git worktree list is the one to remember:

$ git worktree list
/Users/you/myapp/.bare        (bare)
/Users/you/myapp/main         09356e2 [main]
/Users/you/myapp/release-1.0  5285487 [release/1.0]

Two habits worth forming. Use git worktree remove rather than rm -rf — it deregisters the worktree as well as deleting the files; rm -rf leaves a stale entry until you prune. And fetch from anywhere: there is one object database, so a fetch in any worktree updates the refs every other worktree sees.

Where it earns its keep

  • Reviewing a colleague’s branch while your own work stays untouched. No stash, no rebuild, no IDE reindex. This is the case that converts people.
  • A hotfix during a long refactor. The exact scenario at the top of this post, without touching the refactor’s state.
  • Comparing two branches with real tooling — a diff tool, a test run, two dev servers on different ports — rather than flipping back and forth.
  • Long builds. If a clean build costs you fifteen minutes, keeping build caches per branch pays for the setup almost immediately.
  • Release maintenance. A permanent release-1.0/ worktree alongside main/ for backporting.

Where it does not

  • A repo you touch once a month. The setup cost is small but not zero, and this layout is unusual enough that you will re-learn it each time.
  • Tooling that assumes .git is a directory. Most things handle the gitdir: file fine — it is a standard mechanism — but occasionally something homegrown will not, and the failure is confusing.
  • Anything hardcoding absolute paths. Config files, IDE settings and .env files with absolute paths need attention per worktree, since each is a real directory. Files that are gitignored do not come along either — a new worktree starts without your .env.
  • Disk-constrained machines with huge working trees. Worktrees share history but not checked-out files. Five worktrees of a large repo is five copies of the files, even though it is one copy of the history.

Closing

The idea is older than most people’s git habits: the repository and the working tree were always separable, and Git 2.5 exposed that. The bare-clone layout takes it one step further and stops treating any single branch as the real one.

The setup is seven commands, and the one people leave out is the first — mkdir. Then worktree.guessRemote, an actual fetch, and remembering that a slash in a branch name means something different to git worktree add than it does to git checkout.

Credit where due: the layout comes from Metal3d’s write-up. The corrections above are what I hit running it.

References