Git Worktree Lab

You already know how to clone, branch and merge. This lab adds one more tool: git worktree , which lets a single repository have several working directories checked out at the same time.

Why it matters: one working directory can hold only one branch at a time. That is fine for one person doing one task. It breaks the moment you want two things going at once - a feature and an urgent hotfix, a long build and a code review, a refactor you want to compare against the branch it came from. Each task needs its own files to edit, but they should all share one history. git worktree is how you get there.

Time: about 40 minutes. Type every command. Requires git 2.5 or newer.

The commit SHA codes in the outputs below (7be8b30, 93f5a62, ...) will be different on your machine. Everything else should match.

1. Setup - build a toy repo

Create a small repository with three commits on main. Nothing here is new, it is just material to work with.

 1$> mkdir ~/worktree-lab
 2$> cd ~/worktree-lab
 3$> git init myproject
 4Initialized empty Git repository in /home/student/worktree-lab/myproject/.git/
 5$> cd myproject
 6$> git checkout -b main
 7Switched to a new branch 'main'
 8$> echo "toy project for the worktree lab" > README.txt
 9$> git add README.txt
10$> git commit -m "initial commit"
11[main (root-commit) b86acb5] initial commit
12 1 file changed, 1 insertion(+)
13 create mode 100644 README.txt
14$> echo "v1" > version.txt
15$> git add version.txt
16$> git commit -m "add version file"
17[main 0f224de] add version file
18 1 file changed, 1 insertion(+)
19 create mode 100644 version.txt
20$> echo "step one" > plan.txt
21$> git add plan.txt
22$> git commit -m "add deployment plan"
23[main 7be8b30] add deployment plan
24 1 file changed, 1 insertion(+)
25 create mode 100644 plan.txt

Verify you have three commits:

1$> git log --oneline
27be8b30 (HEAD -> main) add deployment plan
30f224de add version file
4b86acb5 initial commit

2. The motivating problem

You are in the middle of a task on feature-a: one change committed, the next one half-written - exactly the state you are in when someone interrupts you.

1$> git checkout -b feature-a
2Switched to a new branch 'feature-a'
3$> echo "step two (feature-a)" >> plan.txt
4$> git add plan.txt
5$> git commit -m "plan: add step two"
6[feature-a d39a9f5] plan: add step two
7 1 file changed, 1 insertion(+)
8$> echo "step three (WIP, half-written)" >> plan.txt

Now the interruption arrives: a colleague needs you to check something on main, right now. Switch over:

1$> git checkout main
2error: Your local changes to the following files would be overwritten by checkout:
3	plan.txt
4Please commit your changes or stash them before you switch branches.
5Aborting

Git refuses. main and feature-a disagree about plan.txt, and there is only one plan.txt on disk. You have two ways out, and both cost you something:

  • Commit the half-written change - and put a step you called "WIP, half-written" into your permanent history.
  • Stash it - and the files change underneath your open editor tabs, your feature's build output stops matching the source, and you have to remember to git stash pop when you return.

Either way, you must put the first task down in order to pick the second one up. One working directory holds one task at a time. That is the friction, and it is not a setting you can configure away - it follows from a working directory being a single set of files.

Take the first option so the lab can continue, and notice that you are committing work you just said was not ready:

1$> git add plan.txt
2$> git commit -m "plan: add step three"
3[feature-a 93f5a62] plan: add step three
4 1 file changed, 1 insertion(+)
5$> git checkout main
6Switched to branch 'main'

3. Your first worktree

git worktree add <path> <branch> checks out a branch into a second directory, backed by the same repository. Create one as a sibling of the main clone:

1$> git worktree add ../wt-feature-a feature-a
2Preparing worktree (checking out 'feature-a')
3HEAD is now at 93f5a62 plan: add step three

Look at what you got:

1$> ls ../wt-feature-a
2README.txt  plan.txt  version.txt
3$> ls -a ../wt-feature-a
4.  ..  .git  README.txt  plan.txt  version.txt

A complete checkout of feature-a, with a .git entry - it looks like an ordinary clone. It is not. Compare the two .git entries:

1$> file .git ../wt-feature-a/.git
2.git:                 directory
3../wt-feature-a/.git: ASCII text

In the main clone .git is a directory. In the worktree it is a file:

1$> cat ../wt-feature-a/.git
2gitdir: /home/student/worktree-lab/myproject/.git/worktrees/wt-feature-a

That one line is the whole trick. The worktree has no repository of its own - the file points back into the main repo's .git/worktrees/. There is exactly one copy of your history on disk.

4. The directory map

Ask git what it is tracking:

1$> git worktree list
2/home/student/worktree-lab/myproject     7be8b30 [main]
3/home/student/worktree-lab/wt-feature-a  93f5a62 [feature-a]

Now look at the bookkeeping git created inside the main repo:

1$> ls .git/worktrees/
2wt-feature-a
3$> ls .git/worktrees/wt-feature-a/
4HEAD  ORIG_HEAD  commondir  gitdir  index  logs  refs
5$> cat .git/worktrees/wt-feature-a/HEAD
6ref: refs/heads/feature-a
7$> cat .git/worktrees/wt-feature-a/commondir
8../..

Each worktree gets its own HEAD (which branch am I on) and its own index (the staging area). commondir points back up to the shared .git, which is where objects/ and refs/ - the actual history - live. Note that .git/worktrees/ holds no objects directory at all:

1$> ls .git/
2COMMIT_EDITMSG  config       hooks  info  objects  worktrees
3HEAD            description  index  logs  refs

The layout on disk:

 1  ~/worktree-lab/
 2  |
 3  +-- myproject/            <- main clone, on branch main
 4  |   |-- README.txt            per-worktree: working files
 5  |   |-- plan.txt
 6  |   |-- version.txt
 7  |   `-- .git/             <- a DIRECTORY: the one real repository
 8  |       |-- objects/          SHARED: every commit, tree and blob
 9  |       |-- refs/             SHARED: all branches and tags
10  |       |-- config            SHARED: remotes, user, settings
11  |       |-- HEAD              per-worktree (this one's)
12  |       |-- index             per-worktree (this one's)
13  |       `-- worktrees/
14  |           `-- wt-feature-a/
15  |               |-- HEAD      per-worktree: on feature-a
16  |               |-- index     per-worktree: its own staging area
17  |               `-- commondir -> ../..
18  |
19  `-- wt-feature-a/         <- linked worktree, on branch feature-a
20      |-- README.txt            per-worktree: its own working files
21      |-- plan.txt
22      |-- version.txt
23      `-- .git              <- a FILE: "gitdir: .../worktrees/wt-feature-a"

Shared: objects, refs, config, remotes, stash. Per-worktree: HEAD, index, and the working files themselves.

Where the directory can go

Anywhere you can write. A worktree is an ordinary directory - it may be a sibling of the project (as above), somewhere else entirely, or on another disk. A relative path is resolved against your current directory, not the repo root, which is the only reason every command in this lab uses ../. These are all valid:

1$> git worktree add ../wt-feature-a feature-a
2$> git worktree add /home/student/scratch/review feature-a
3$> git worktree add /mnt/fast-ssd/build-check feature-a

Can it go inside the project? Yes - and it is a bad idea. Try it, so you can see why. Both main and feature-a are taken, so make a throwaway branch to put in it:

1$> git branch tmp-nested
2$> git worktree add ./wt-inside tmp-nested
3Preparing worktree (checking out 'tmp-nested')
4HEAD is now at 7be8b30 add deployment plan

Git allowed it without a word of warning. Now look at what it did to the project you were working in - the new directory is sitting among the main worktree's own files, so git reports it as untracked content:

1$> git status --short
2?? wt-inside/

That is the trap. The next routine git add . tries to swallow it:

 1$> git add .
 2warning: adding embedded git repository: wt-inside
 3hint: You've added another git repository inside your current repository.
 4hint: Clones of the outer repository will not contain the contents of
 5hint: the embedded repository and will not know how to obtain it.
 6hint: If you meant to add a submodule, use:
 7hint:
 8...
 9$> git status --short
10A  wt-inside

You have just staged a second checkout of your own repository as a broken pseudo-submodule. Commit that and you hand everyone who clones the project a path that can never be filled in.

Undo it, and note that a .gitignore entry is what makes nesting survivable:

1$> git reset
2$> echo "wt-inside/" >> .gitignore
3$> git status --short
4?? .gitignore

Now clean up completely - remove the nested worktree, its throwaway branch, and the .gitignore you just made:

1$> rm .gitignore
2$> git worktree remove ./wt-inside
3$> git branch -d tmp-nested
4Deleted branch tmp-nested (was 7be8b30).
5$> git status --short
6$> git worktree list
7/home/student/worktree-lab/myproject     7be8b30 [main]
8/home/student/worktree-lab/wt-feature-a  93f5a62 [feature-a]

The verdict: nest a worktree only when something forces you to - an editor or language toolchain that refuses to look outside one project root - and gitignore it the moment you do. Otherwise, keep worktrees outside the project, as siblings, named after the branch they hold. Nothing is nested, git status stays honest, and one ls of the parent directory shows you every task in flight. That is the layout in the diagram above, and the rest of this lab assumes it.

5. Shared history, no fetch

Because the object database is shared, a commit made in one worktree is visible everywhere immediately. Commit from inside the worktree:

1$> cd ../wt-feature-a
2$> echo "feature-a: rollback procedure" >> plan.txt
3$> git add plan.txt
4$> git commit -m "plan: rollback procedure"
5[feature-a a7b40d2] plan: rollback procedure
6 1 file changed, 1 insertion(+)

Go back to the main clone and look - with no fetch, no pull, no remote of any kind:

1$> cd ../myproject
2$> git log --oneline feature-a -3
3a7b40d2 plan: rollback procedure
493f5a62 plan: add step three
5d39a9f5 plan: add step two

The commit is already there. Meanwhile main in this directory has not moved and its files are untouched:

1$> cat plan.txt
2step one
3$> git log --oneline -1
47be8b30 (HEAD -> main) add deployment plan

Two directories, two branches, one history. Nothing was stashed and nothing was interrupted - the work you left in wt-feature-a is still sitting there exactly as you left it. This is the property that makes working on two things at once practical.

6. The lock - one branch, one worktree

Try to check out main a second time:

1$> git worktree add ../wt-main main
2Preparing worktree (checking out 'main')
3fatal: 'main' is already used by worktree at '/home/student/worktree-lab/myproject'

The same protection applies to feature-a:

1$> git worktree add ../wt-a2 feature-a
2Preparing worktree (checking out 'feature-a')
3fatal: 'feature-a' is already used by worktree at '/home/student/worktree-lab/wt-feature-a'

Why git refuses: a branch is a single pointer to a single commit. If two directories both had main checked out, both would move that one pointer as they committed, and each would find its working files disagreeing with a HEAD that something else had advanced. Rather than let two writers fight over one ref, git makes the rule simple: a branch may be checked out in only one worktree at a time.

When you only need to read another branch's files, detach from the branch instead - you get the files without claiming the ref:

1$> git worktree add --detach ../wt-main-ro main
2Preparing worktree (detached HEAD 7be8b30)
3HEAD is now at 7be8b30 add deployment plan
4$> git worktree list
5/home/student/worktree-lab/myproject     8958e7c [main]
6/home/student/worktree-lab/wt-feature-a  a7b40d2 [feature-a]
7/home/student/worktree-lab/wt-main-ro    7be8b30 (detached HEAD)
1$> cd ../wt-main-ro
2$> git status
3Not currently on any branch.
4nothing to commit, working tree clean
5$> cat plan.txt
6step one
7$> cd ../myproject

(detached HEAD) means no branch name is claimed, so main stays available to the main clone. Use this when you only need to read a branch - run its test suite, diff against it, check how it used to work - without taking it away from whoever has it checked out.

What each directory may hold

The main clone counts as a worktree for every rule below - it is simply the first one. So, in any one directory you may check out:

  • Any branch that no other worktree has checked out. This is the normal case, one branch per directory. git branch marks the ones already taken with +.
  • A brand new branch, with git worktree add -b <new-branch> <path> <start-point> - it cannot collide, because it did not exist a moment ago. You will use this in step 7.
  • Any commit at all, detached, with --detach. Detached worktrees claim no branch name, so there is no limit on them: two directories may sit detached on the same commit, and that commit may be the tip of a branch some other worktree currently has checked out.

The single thing you may not do is check out one branch in two worktrees at once. Note what the rule is really about: the branch name, not the commit. Sharing a commit is free - what cannot be shared is the ref that moves when you commit.

git worktree add --force overrides the lock and gives two directories the same branch. Do not use it while you are learning. The moment one of them commits, the shared branch pointer moves and the other directory - whose files and index are still at the old commit - starts reporting a staged modification that nobody made. Committing that would quietly revert the other directory's work. The lock is not bureaucracy; it is what stops this.

7. Worktrees are full checkouts

A worktree is not a read-only view. Create a second one on a new branch - -b creates the branch and checks it out in one step:

1$> git worktree add -b feature-b ../wt-feature-b main
2Preparing worktree (new branch 'feature-b')
3HEAD is now at 7be8b30 add deployment plan
4$> cd ../wt-feature-b
5$> echo "v2" > version.txt
6$> git add version.txt
7$> git commit -m "bump version to v2"
8[feature-b ee0af2b] bump version to v2
9 1 file changed, 1 insertion(+), 1 deletion(-)

You now have three branches being worked on in three directories at once. From the main clone, list them:

1$> cd ../myproject
2$> git branch
3+ feature-a
4+ feature-b
5* main

* is the branch checked out here; + marks branches checked out in another worktree.

Now merge both features into main, from the main clone, without visiting either worktree:

1$> git merge feature-a -m "merge feature-a"
2Updating 7be8b30..a7b40d2
3Fast-forward (no commit created; -m option ignored)
4 plan.txt | 3 +++
5 1 file changed, 3 insertions(+)
6$> git merge feature-b -m "merge feature-b"
7Merge made by the 'ort' strategy.
8 version.txt | 2 +-
9 1 file changed, 1 insertion(+), 1 deletion(-)

(feature-a was a straight line ahead of main, so git fast-forwarded and had no merge commit to write the message into. feature-b had diverged, so it produced a real merge commit.)

Check the result:

 1$> git log --oneline --graph -6
 2*   8958e7c merge feature-b
 3|\
 4| * ee0af2b bump version to v2
 5* | a7b40d2 plan: rollback procedure
 6* | 93f5a62 plan: add step three
 7* | d39a9f5 plan: add step two
 8|/
 9* 7be8b30 add deployment plan
10$> cat version.txt
11v2

Ordinary commits, ordinary branches, ordinary merge. Work done in a worktree is in no way second class.

8. Cleanup - and cleaning up wrong

Remove a worktree properly with git worktree remove. It deletes the directory and unregisters it in one step:

1$> git worktree remove ../wt-feature-b
2$> git worktree remove ../wt-main-ro
3$> git worktree list
4/home/student/worktree-lab/myproject     8958e7c [main]
5/home/student/worktree-lab/wt-feature-a  a7b40d2 [feature-a]

Now do it the wrong way on purpose - just delete the directory:

1$> rm -rf ../wt-feature-a
2$> git worktree list
3/home/student/worktree-lab/myproject     8958e7c [main]
4/home/student/worktree-lab/wt-feature-a  a7b40d2 [feature-a] prunable

The files are gone but the registration in .git/worktrees/ survives. Git flags it prunable. A stale entry is not harmless - it keeps holding the branch, so you cannot reuse that path:

1$> git worktree add ../wt-feature-a feature-a
2Preparing worktree (checking out 'feature-a')
3fatal: '../wt-feature-a' is a missing but already registered worktree;
4use 'add -f' to override, or 'prune' or 'remove' to clear

git worktree prune clears every registration whose directory no longer exists:

1$> git worktree prune
2$> git worktree list
3/home/student/worktree-lab/myproject  8958e7c [main]

Removing a worktree never touches the branch - only the checkout:

1$> git branch
2  feature-a
3  feature-b
4* main

Rule of thumb: use git worktree remove. When you or a script deletes a directory by hand, follow it with git worktree prune.

The analogy

A team scales by giving every developer their own clone on their own computer, and they synchronise through a remote - push, fetch, pull, with network latency and merge ceremony between every exchange. One developer juggling several tasks on one machine needs none of that. You give each task its own worktree instead: one repository, one directory per task, and the shared .git playing the role the remote used to play. Every commit is visible from every other directory the instant it is written, with nothing to push and nothing to fetch.

So: reach for a clone when the copies must live on different machines. Reach for a worktree when they live on yours, and the only thing you actually need is a second set of files.