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.gitAfter 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-featureVerify 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