Mastering Git Submodules: Taming Dependencies Without Losing Your Sanity¶
Lessons from managing a multi-repo architecture where submodules are the glue that holds everything together.
The Problem That Led Me to Submodules¶
I was working on a microservices platform where each service lived in its own repository. We had shared libraries — configuration schemas, test utilities, deployment scripts — that every service needed. Copy-pasting was unsustainable. Package managers added release overhead for internal tooling that changed daily. I needed a way to compose repositories while keeping them independently versioned. Git submodules gave me exactly that.
A submodule is simply a pointer: a specific commit SHA in another repository, tracked as a file in the parent repo. The parent doesn't store the submodule's code — just a reference to where it lives and which commit to use.
Adding and Initializing Submodules¶
The basics are straightforward, but the mental model matters. When I add a submodule, I'm telling git: "At this path, check out that repository at that commit."
# Add a submodule
git submodule add https://github.com/org/shared-lib.git libs/shared
# This creates two things:
# 1. .gitmodules file (tracks URL + path mapping)
# 2. A special entry in the git tree (the commit pointer)
When a colleague clones my repo, they get an empty directory where the submodule should be. They need to initialize:
# After cloning, initialize and fetch submodules
git submodule update --init --recursive
# Or clone with submodules in one step
git clone --recurse-submodules https://github.com/org/parent-repo.git
I always recommend --recursive because submodules can themselves contain submodules. In my projects, they often do.
Updating Submodules: The Daily Workflow¶
This is where most people get confused. There are two different operations:
1. Pulling the latest pointer from the parent repo:
# Someone updated the submodule pointer in the parent — get that change
git pull
git submodule update --recursive
This checks out the exact commit the parent specifies. Not HEAD, not the latest — the pinned commit.
2. Updating to a newer version of the submodule:
# Go into the submodule and fetch new changes
cd libs/shared
git fetch origin
git checkout origin/main # or a specific tag/commit
# Go back to parent and commit the new pointer
cd ../..
git add libs/shared
git commit -m "Uplift shared-lib to v2.3.1"
The distinction is crucial: git submodule update makes the submodule match the parent's record. Going into the submodule and changing its HEAD is how you change what the parent records.
The Detached HEAD Trap¶
Every developer hits this at least once. Submodules always check out in detached HEAD state. They're pinned to a specific commit, not a branch. If you make changes inside a submodule without creating a branch first, those commits are dangling and easy to lose.
My protective workflow:
cd libs/shared
# Always create a branch before making changes
git checkout -b my-fix
# Make changes, commit, push
git add .
git commit -m "Fix parsing edge case"
git push origin my-fix
# Then update the parent to point here
cd ../..
git add libs/shared
git commit -m "shared-lib: fix parsing edge case"
Handling Submodule Conflicts¶
When two developers update the same submodule pointer to different commits, you get a conflict. It looks different from a normal text conflict:
Resolution requires deciding which commit wins, or finding a commit that includes both changes:
# See what commits each side points to
git diff --submodule
# Option A: Accept theirs
git checkout --theirs libs/shared
git add libs/shared
# Option B: Accept ours
git checkout --ours libs/shared
git add libs/shared
# Option C: Pick a specific commit that includes both
cd libs/shared
git log --oneline origin/main
git checkout <commit-that-has-both>
cd ..
git add libs/shared
Automating Submodule Pain Away¶
Over time, I've built habits that eliminate most submodule frustrations:
# Auto-update submodules on every pull
git config --global submodule.recurse true
# Show submodule changes in git status
git config --global status.submoduleSummary true
# Show submodule diffs in log
git config --global diff.submodule log
I also added a pre-commit hook that warns if a submodule is dirty but not staged — catching the "forgot to commit the submodule pointer" mistake before it reaches CI.
When Not to Use Submodules¶
Submodules are powerful but not universal. I avoid them when:
- The dependency is stable and versioned — use a package manager instead
- Every developer needs to modify the submodule frequently — consider a monorepo
- The team is git-inexperienced — the learning curve creates real friction
But when you need to compose independently-versioned repositories while maintaining exact reproducibility, submodules are the right tool.
Key Takeaway¶
Submodules are not magic and not cursed — they're pointers with a specific mental model. Once you internalize that the parent stores a commit SHA (not a branch, not "latest"), everything clicks. Pin deliberately, update explicitly, and always check git submodule status before pushing.
Tags: git, submodules, multi-repo, dependencies, architecture