Git Worktrees and Submodules: Two Features Worth Knowing

Git Worktrees and Submodules: Two Features Worth Knowing

Two git features that people avoid because they sound advanced. One of them removes a daily annoyance and is genuinely simple. The other is simple too, and behaves in a way that surprises people until the model clicks.

Worktrees

The problem they solve: you are halfway through a change, and something urgent needs fixing on another branch.

# the usual dance
git stash
git checkout main
git checkout -b hotfix
# fix, commit, push
git checkout feature-branch
git stash pop
# and hope nothing was forgotten

Instead:

git worktree add ../myrepo-hotfix -b hotfix main
cd ../myrepo-hotfix
# fix, commit, push
cd -

Your feature branch is untouched. Its files, its build output, its running dev server, all still there.

# an existing branch
git worktree add ../myrepo-review existing-branch

# a new branch from a specific base
git worktree add ../myrepo-hotfix -b hotfix origin/main

# detached, just to look at something
git worktree add --detach ../myrepo-old v1.2.0

# what exists
git worktree list
/home/you/myrepo          a1b2c3d [main]
/home/you/myrepo-hotfix   e4f5g6h [hotfix]
/home/you/myrepo-review   i7j8k9l [feature-x]

Why it is cheap

Objects and history are shared. A worktree is not a clone; it costs only the checked out files. On a repository with long history, that is a large difference, and there is one copy of the objects to garbage collect.

cat ../myrepo-hotfix/.git
# gitdir: /home/you/myrepo/.git/worktrees/myrepo-hotfix

.git in a worktree is a file pointing back to the main repository, not a directory.

Commits, fetches and branch operations in any worktree are visible from all of them, because there is one object store and one set of refs.

Cleaning up

git worktree remove ../myrepo-hotfix

# if the directory was deleted by hand
git worktree prune
git worktree list

Deleting the directory with rm -rf leaves a stale registration, which then blocks re-adding the same path. git worktree prune clears those, and git worktree remove avoids the problem.

The limits

A branch can be checked out in only one worktree at a time. Trying twice gives an error, which is the feature working: it stops two directories fighting over one branch.

# to look at the same commit twice
git worktree add --detach ../second HEAD

Untracked files are per-worktree, which includes .env, node_modules and build output. A new worktree needs its dependencies installed, and tooling that expects a single directory occasionally needs pointing at the right one.

Where it earns its place

# long-running builds on two branches
git worktree add ../myrepo-main main
cd ../myrepo-main && make -j8

# reviewing a colleague's branch without disturbing your work
git fetch origin
git worktree add ../review origin/their-branch

# bisecting while continuing to work
git worktree add ../bisect --detach
cd ../bisect && git bisect start HEAD v1.0

# comparing behaviour between two versions side by side
git worktree add ../v1 v1.0.0
git worktree add ../v2 v2.0.0

Bisecting in a worktree is the strongest case. git bisect moves HEAD repeatedly, which makes your main directory unusable for the duration, and a bisect can take a while.

Our git branching guide covers the branch model this sits on top of.

Submodules

A submodule records another repository pinned at a specific commit.

git submodule add https://github.com/other/lib.git vendor/lib
git commit -m "add lib as submodule"

That creates two things:

cat .gitmodules
[submodule "vendor/lib"]
	path = vendor/lib
	url = https://github.com/other/lib.git

And an entry in the tree at vendor/lib that is not a directory but a commit reference:

git ls-tree HEAD vendor/
# 160000 commit a1b2c3d...    vendor/lib

Mode 160000 is the giveaway. The parent repository stores one commit hash, nothing else.

The pointer model explains every surprise

Once you hold that idea, submodule behaviour stops being mysterious.

Cloning gives you an empty directory:

git clone https://github.com/you/parent.git
ls parent/vendor/lib/        # empty

The pointer was cloned. The contents were not.

# the fix, at clone time
git clone --recurse-submodules https://github.com/you/parent.git

# or afterwards
git submodule update --init --recursive

This catches every new contributor once. Put the recursive clone command in your README.

The submodule shows as modified when you did nothing:

git status
#	modified:   vendor/lib (new commits)

Something moved the submodule’s HEAD away from the recorded commit. The parent is reporting that the pointer no longer matches, which is accurate.

# what does the parent expect, versus what is checked out
git submodule status
# +a1b2c3d vendor/lib (v1.2.0-3-ga1b2c3d)

+ means checked out differs from recorded. - means not initialised. A space means they match.

# put it back
git submodule update

# or accept the new commit
git add vendor/lib && git commit -m "bump lib"

Submodules are on a detached HEAD by default:

cd vendor/lib
git status
# HEAD detached at a1b2c3d

Correct, because the parent pinned a commit, not a branch. Commit there and it is unreachable from any branch until you check one out.

# if you intend to work in the submodule
cd vendor/lib
git checkout main
git pull

Updating

# pin to each submodule's upstream default branch
git submodule update --remote
git add vendor/lib
git commit -m "update lib to latest"

# one submodule only
git submodule update --remote vendor/lib

# a specific version
cd vendor/lib
git fetch --tags
git checkout v2.0.0
cd -
git add vendor/lib && git commit -m "lib v2.0.0"

The two-step nature is the point. Updating the submodule checkout and recording it in the parent are separate actions, so an update is an explicit, reviewable commit rather than something that happens implicitly.

Making them less painful

# recurse by default on most commands
git config --global submodule.recurse true

# see submodule changes in diffs and logs
git config --global diff.submodule log
git config --global status.submoduleSummary true

# fetch submodules when fetching the parent
git config --global fetch.recurseSubmodules on-demand

submodule.recurse true is the setting to apply immediately. It makes git pull, git checkout and git switch update submodules automatically, which eliminates most of the confusion.

# run a command in every submodule
git submodule foreach 'git checkout main && git pull'
git submodule foreach --recursive 'git status --short'

Removing one

git submodule deinit -f vendor/lib
git rm -f vendor/lib
rm -rf .git/modules/vendor/lib
git commit -m "remove lib submodule"

All four steps. git rm alone leaves state in .git/modules, which then interferes if you ever add a submodule at the same path.

Submodules versus the alternatives

SubmoduleSubtreePackage manager
Extra clone stepYesNoNo
History separateYesMerged inN/A
Contribute upstreamNaturalAwkwardN/A
Pin a versionCommit hashMerge commitLock file
Learning curveRealModerateAlready known
# subtree, for comparison
git subtree add --prefix=vendor/lib https://github.com/other/lib.git main --squash
git subtree pull --prefix=vendor/lib https://github.com/other/lib.git main --squash

A subtree merges the other repository’s contents into yours, so a plain clone gets everything and nobody needs to know it was ever separate. The cost is a messier history and a harder path for sending changes back upstream.

Use a package manager where one exists. Cargo, npm, Go modules and pip all solve dependency pinning better than git does, with a lock file and a resolver. Submodules are for cases where the dependency is not a package: shared configuration, a vendored fork you patch, content you keep separately, or an internal library with no registry.

Use submodules when the other repository is genuinely independent, has its own release cycle, and you want it pinned at a reviewable commit.

Do not use them when the two repositories always change together. The pinning is then pure overhead, and every change becomes two commits in two places. Most teams that regret submodules were using them for code that was never really separate.

Both at once

Worth knowing, because it works and the paths are not obvious:

git worktree add ../myrepo-feature feature-branch
cd ../myrepo-feature
git submodule update --init --recursive

Submodules are not populated in a new worktree automatically, even with submodule.recurse set, so that second command is needed. The submodule’s git data stays in the main repository’s .git/modules, shared between worktrees.

Frequently Asked Questions

What is a git worktree?

An additional working directory attached to the same repository, with its own checked out branch. The history and objects are shared, so it costs only the files rather than a full clone, and you can have several branches checked out at once in different directories.

When should I use a worktree instead of stashing?

Whenever the interruption will take more than a moment. Stashing forces you to unwind your in-progress state and remember to restore it. A worktree leaves the work untouched in its own directory, including build artifacts and running processes, and you simply change directory.

Why does my submodule show as modified when I have not touched it?

Because the parent repository records a specific commit, and your submodule checkout is on a different one. Running commands inside the submodule moves it, and the parent notices the pointer no longer matches. Committing in the parent updates the recorded commit to whatever is checked out.

Why is my submodule directory empty after cloning?

Because a plain clone records the submodule pointer without fetching the contents. Clone with the recurse submodules option, or run submodule update with init after cloning. This is the most common submodule complaint and it catches every new contributor once.

Should I use submodules or a monorepo?

Submodules suit a genuinely separate repository with its own release cycle that you want pinned at a known commit. If the two always change together, the pinning is pure overhead and a single repository is simpler. Most teams that regret submodules were using them for code that was never really independent.

What is the difference between submodules and subtrees?

A submodule keeps the other repository separate and records a pointer, so history stays distinct and everyone needs the extra clone step. A subtree merges the contents into your repository, so a clone gets everything with no special commands, at the cost of a messier history and a harder path back upstream.