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 popwhen 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 branchmarks 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 --forceoverrides 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.