Version Control

Git Submodule Management and Its Common Traps

A field guide to when a submodule actually makes sense, how to add, clone, and update it, and the standard escape hatches for empty dirs after clone, branch drift, and nesting.

By LaoHand Team·7 min read·Updated 2026-09-06

Decide First: a Submodule Is Not Glue for Everything

A submodule pins another Git repository onto a commit of the parent; parent and child version independently, and the parent stores only a pointer to a commit. The appeal is exact dependency reproduction; the cost is daily sync, branching, auth, and collaboration overhead.

It suits strongly coupled independent repos that release separately, or third-party product repos (a shared docs theme, a shared config repo) you integrate.

If the goal is merely "reuse shared code", a monorepo or copying the code in is often simpler. Test it with one question: would you develop and push that referenced repo on its own? If yes, a submodule earns its keep.

# Add a submodule (records a pointer to a specific commit)
git submodule add https://github.com/you/shared-lib.git lib/shared

# Commit the pointer together with the parent repo
git commit -m "add shared-lib submodule"

cat .gitmodules
# [submodule "lib/shared"]
# 	path = lib/shared
# 	url = https://github.com/you/shared-lib.git

After Clone the Submodule Is Empty: The Two Standard Steps

The most common newbie trap: the project you clone has lib/shared as an empty directory, because the parent does not contain submodule content, only pointers.

The standard fix is two steps: git submodule init registers the urls from .gitmodules into your local .git/config, and git submodule update checks out each recorded commit. The one-liner git submodule update --init is equivalent to both.

If nested submodules exist and you want everything at once, add --recursive.

# Standard activation
cd parent

git submodule init
git submodule update

# Or in one step
git submodule update --init

# Pull nested submodules in one go
git submodule update --init --recursive

# The least fussy full clone command
# git clone --recurse-submodules <url>

Mistake: git pull Inside a Submodule Appears to Do Nothing

Some devs see a changed submodule, cd into lib/shared and run git pull. That does fetch a newer commit, but the parent pointer stays put; the next person to init/update snaps back to the old pointer, so "I changed it and nothing happened".

The right move is, from the parent, git submodule update --remote (with a branch configured) to pull the latest, or git submodule update --remote --merge to merge the remote branch into the working tree, then git add that pointer and commit the parent.

If you only want a pinned revision, you should not use update --remote at all; keep the pointer at a commit you verified, and let the bump cadence be a deliberate choice.

# Wrong: pulls the submodule but does not update the parent pointer
git -C lib/shared pull

# Fix: update the submodule to the remote latest from the parent repo and commit
git submodule update --remote lib/shared

git status        # lib/shared shows as modified (the pointer changed)
git add lib/shared
git commit -m "bump shared-lib to latest main"

Branch Drift and Detached HEAD: How to Stop Feeling Lost

By default git submodule update leaves the submodule in detached HEAD — pinned to a bare commit with no active branch. That is fine by design, but editing while detached feels confusing because the destination of commits is unclear.

To live on a named branch, configure the submodule to track a remote branch: add branch = main (or develop) under the submodule's section in .gitmodules, then git submodule update --remote checks out the branch's tip. Or just git checkout main inside the submodule.

Remember work inside a submodule does not bubble up to the parent automatically; the parent only records the pointer, and uncommitted changes in the submodule need their own commit and push or they remain local dirt.

# Make the submodule track the remote develop branch
git config -f .gitmodules submodule.lib/shared.branch develop

git submodule update --remote lib/shared

# With the default pinned pointer (detached HEAD), create a separate dev branch:
git -C lib/shared checkout -b my-feature main

# Push the submodule's own commits
git -C lib/shared push origin my-feature

Verify Submodule State So Bad Pointers Never Sneak In

After clone, CI and colleagues should first run a full submodule init so the environment reproduces exactly.

git submodule status tells you whether each submodule points at the recorded commit and whether its worktree is clean; add --recursive to cover nesting. If a URL, path, or directory is off, status flags a -/+ difference.

Before committing the parent, run git diff --submodule to see the exact pointer movement (from which commit to which), so a wrong version never slips in.

git submodule status --recursive
#  <sha> lib/shared   Normal
# -<sha> lib/tools    Not initialized (leading -)
# +<sha> lib/docs     Local changes or points to a different commit (+)

# See how the pointer will change
git diff --submodule

git submodule update --recursive   # Re-align all submodules

Official References

Each command links to its official documentation below, so you can verify the latest usage and read deeper.