DEV Community

Andreas Herz
Andreas Herz

Posted on

Integrating Git Submodules the Easy Way

Git submodules have a reputation for being fiddly, but most of that pain comes down to a handful of missing commands and one config flag nobody mentions. Used well, they're a clean way to embed a shared library, a design-system repo, or a common docs folder inside another project - pinned to an exact commit so nothing shifts under your feet. This guide walks through the whole lifecycle, from adding a submodule to removing it, and calls out the gotchas that bite teams in real projects.

Understanding What a Submodule Actually Is

Before the commands, one mental model that clears up most confusion: a submodule embeds another git repo inside yours at a fixed path, pinned to a specific commit. Your repo doesn't track the submodule's files - it tracks which commit of the submodule to check out. That single idea explains almost every quirk that follows.

Adding a Submodule

Adding one is a single command:

git submodule add git@github.com:org/shared-lib.git vendor/shared-lib
Enter fullscreen mode Exit fullscreen mode

This clones the repo into vendor/shared-lib, creates a .gitmodules file describing the mapping, and stages the pinned commit (git calls this a "gitlink"). Commit both pieces:

git add .gitmodules vendor/shared-lib
git commit -m "chore: add shared-lib submodule"
Enter fullscreen mode Exit fullscreen mode

The resulting .gitmodules entry is plain text and lives in version control:

[submodule "vendor/shared-lib"]
    path = vendor/shared-lib
    url = git@github.com:org/shared-lib.git
    branch = main
Enter fullscreen mode Exit fullscreen mode

The branch line is optional: it's only used later when pulling the latest changes automatically.

Cloning Without the Empty-Folder Surprise

The most common submodule complaint is a teammate cloning the project and finding an empty folder where the submodule should be. The fix is knowing two commands:

# Clone everything in one shot
git clone --recurse-submodules <your-repo-url>

# Already cloned? Initialize after the fact
git submodule update --init --recursive
Enter fullscreen mode Exit fullscreen mode

Even better, run this once per machine so git pull and git checkout keep submodules in sync automatically - after this, submodules mostly disappear from your day-to-day:

git config --global submodule.recurse true
Enter fullscreen mode Exit fullscreen mode

Wiring Up Continuous Integration

Here's the step that quietly breaks builds: CI systems do not fetch submodules by default. On GitHub Actions, one line fixes it:

- uses: actions/checkout@v4
  with:
    submodules: recursive # or 'true' for a single level
Enter fullscreen mode Exit fullscreen mode

Every CI provider has an equivalent flag, and forgetting it is the number-one reason a project that works locally fails in the pipeline.

Updating to a Newer Commit

Since your repo pins an exact commit, updating is deliberate - nothing moves until you say so. To grab the latest from the submodule's tracked branch:

git submodule update --remote vendor/shared-lib
git add vendor/shared-lib
git commit -m "chore: bump shared-lib submodule"
Enter fullscreen mode Exit fullscreen mode

Prefer pinning to a specific tag or commit? Check it out inside the submodule and record the new pin in the parent:

cd vendor/shared-lib
git fetch && git checkout v2.1.0
cd -
git add vendor/shared-lib && git commit -m "chore: pin shared-lib to v2.1.0"
Enter fullscreen mode Exit fullscreen mode

Making Changes Inside a Submodule

A submodule is a full git repo, so you can edit it in place. Just remember it starts in a detached HEAD state, so check out a branch first:

cd vendor/shared-lib
git checkout main
# edit, commit, and push to the submodule's own remote
cd -
git add vendor/shared-lib && git commit -m "chore: bump submodule after change"
Enter fullscreen mode Exit fullscreen mode

The golden rule: always push inside the submodule before committing the parent. The parent only stores a commit hash, so an unpushed submodule commit leaves everyone else with a broken clone.

Removing a Submodule

When a submodule has served its purpose, three commands clean it up completely:

git submodule deinit -f vendor/shared-lib
git rm -f vendor/shared-lib
rm -rf .git/modules/vendor/shared-lib
git commit -m "chore: remove shared-lib submodule"
Enter fullscreen mode Exit fullscreen mode

The Gotchas Worth Bookmarking

Most submodule frustration traces back to the same short list:

  • Empty folder after cloning: someone forgot --recurse-submodules or update --init.
  • CI failures: the checkout step is missing submodules: recursive.
  • Detached HEAD: check out a branch before editing inside a submodule.
  • Broken pins for teammates: the parent commit was pushed before the submodule commit.
  • Private submodules in CI: the pipeline's token or deploy key needs read access to the submodule repo too.

Wrapping Up

Submodules aren't scary once the moving parts click into place: they pin a commit, they need an extra flag to clone and to build, and they reward pushing in the right order. Set submodule.recurse true, add submodules: recursive to your CI, and most of the friction disappears. For the full reference, the official Git Submodules documentation is excellent.

Happy coding! 🚀

Top comments (0)