GitSubmodules

Git Submodules

A Git submodule is a full Git repository embedded inside another Git repository. The outer repo (the superproject) records a pointer to a specific commit in the inner repo (the submodule). This lets you track an external codebase at a precise version while keeping it physically separate from your own history.

Why submodules exist

Imagine you maintain a design-system library used by a dozen product repos. You want each product to pin to a tested version of the library, see its source during development, and be able to contribute changes back upstream. A submodule thread ties the two repos together at the Git level — no publishing to a package registry required.

  • Shared libraries: reusable component or utility repos that multiple projects depend on.

  • Vendored dependencies: third-party code you want to keep alongside your source (and possibly patch).

  • Monorepo escape hatch: keep a sub-team's repo independent while still nesting it in a larger project.

  • Documentation or asset repos: large binary assets tracked separately to keep the main repo lean.

Adding a submodule

Bash
# Add a submodule at the path "libs/ui"
git submodule add https://github.com/example/ui-components.git libs/ui

# Add and place it at a custom path
git submodule add git@github.com:example/auth-sdk.git vendor/auth

What happens on disk

Text
Cloning into '/your/project/libs/ui'...
remote: Enumerating objects: 412, done.
...
done.

Two new things in your repo:
  .gitmodules          (new file tracking submodule config)
  libs/ui              (the checked-out submodule directory)

After running git submodule add, stage and commit the changes to record the submodule in your superproject:

Bash
git status
# Changes to be committed:
#   new file: .gitmodules
#   new file: libs/ui

git commit -m "chore: add ui-components as submodule"
The .gitmodules file

.gitmodules is a plain-text INI file committed to the repo. It records the URL and local path for every submodule. Git reads this file to know where to clone each submodule.

.gitmodules

Text
[submodule "libs/ui"]
	path = libs/ui
	url = https://github.com/example/ui-components.git

[submodule "vendor/auth"]
	path = vendor/auth
	url = git@github.com:example/auth-sdk.git
.gitmodules is committed; .git/config is local
`.gitmodules` tracks submodule metadata for everyone. Each developer's `.git/config` gets a local copy after `git submodule init`. If you change a submodule URL, update `.gitmodules` AND re-run `git submodule sync` so everyone's local config follows.
Cloning a repo that has submodules

A plain git clone gives you the submodule directories but leaves them empty. You then need to initialise and populate them.

Bash
# Option A — all-in-one (recommended)
git clone --recurse-submodules https://github.com/example/myproject.git

# Option B — if you already cloned without the flag
git clone https://github.com/example/myproject.git
cd myproject
git submodule init      # registers submodules from .gitmodules into .git/config
git submodule update    # clones the submodule repos and checks out the pinned commit

# Shortcut for init + update in one step
git submodule update --init

# Also init/update nested submodules (submodules of submodules)
git submodule update --init --recursive

Terminal output after update --init

Text
Submodule 'libs/ui' (https://github.com/example/ui-components.git) registered for path 'libs/ui'
Cloning into '/your/project/libs/ui'...
remote: Enumerating objects: 412, done.
Submodule path 'libs/ui': checked out 'a3f7e91c...'
The detached HEAD in a submodule

After git submodule update, the submodule is in a detached HEAD state — it points to a specific commit, not a branch. This is intentional: the superproject pins an exact commit to guarantee reproducible builds.

Bash
cd libs/ui
git status
# HEAD detached at a3f7e91

# To work on the submodule (make commits to it), check out a branch first
git checkout main
# now edit, commit, push as normal inside libs/ui
Updating a submodule to the latest upstream commit

Bash
# Fetch and merge the remote tracking branch for all submodules
git submodule update --remote

# Update only one specific submodule
git submodule update --remote libs/ui

# --merge integrates the fetched commit rather than leaving a detached HEAD
git submodule update --remote --merge libs/ui

After --remote updates the submodule, the superproject sees a modified submodule pointer. You must commit that change to record the new pinned commit:

Bash
git diff --submodule
# Submodule libs/ui a3f7e91..b8c2d34:
#   > feat: new Button variant
#   > fix: tooltip z-index

git add libs/ui
git commit -m "chore: bump ui-components to b8c2d34"
Warning
Submodules pin to a specific commit. They do NOT automatically follow a branch. If someone pushes to the submodule repo, your superproject stays at the old commit until you explicitly run `git submodule update --remote` and commit the new pointer. This is a feature, not a bug — but forgetting it is the number-one source of submodule confusion.
Making changes inside a submodule and pushing

Bash
# 1. Enter the submodule and check out a branch
cd libs/ui
git checkout -b fix/button-focus

# 2. Make and commit your changes
vim src/Button.tsx
git add src/Button.tsx
git commit -m "fix: restore focus ring on Button"

# 3. Push the submodule changes to ITS remote
git push origin fix/button-focus

# 4. Go back to the superproject and record the new commit pointer
cd ../..
git add libs/ui
git commit -m "chore: point ui-components at button focus fix"
Warning
Always push the submodule's commits BEFORE you push the superproject. If you push the superproject first, it will point to a commit that other developers cannot fetch (because it has not been pushed to the submodule remote yet). Git will warn you: `warning: push.default is unset` — or worse, CI will break with a missing object error.
Removing a submodule

Git has no single git submodule remove command. You must manually clean up in four places:

  1. Delete the submodule entry from .gitmodules.

  2. Delete the submodule entry from .git/config.

  3. Remove the submodule directory from the working tree: git rm --cached libs/ui.

  4. Delete the actual submodule directory data: rm -rf libs/ui and rm -rf .git/modules/libs/ui.

Full removal sequence

Bash
# Step 1 — unstage and remove from index
git rm --cached libs/ui

# Step 2 — remove the working tree directory
rm -rf libs/ui

# Step 3 — remove residual Git data
rm -rf .git/modules/libs/ui

# Step 4 — remove the [submodule "libs/ui"] block from .gitmodules manually
#           (or use git config --file .gitmodules --remove-section submodule.libs/ui)
git config --file .gitmodules --remove-section submodule.libs/ui

# Step 5 — commit everything
git add .gitmodules
git commit -m "chore: remove libs/ui submodule"
Useful submodule commands reference

Command

What it does

git submodule add <url> <path>

Add a new submodule.

git submodule init

Register submodules from .gitmodules into .git/config.

git submodule update

Check out the commit recorded by the superproject.

git submodule update --init --recursive

Init + update all submodules, including nested ones.

git submodule update --remote

Fetch and check out the tip of the tracking branch.

git submodule foreach <cmd>

Run a shell command in every submodule directory.

git submodule status

Show the current SHA, path, and dirty state of each submodule.

git submodule sync

Update local submodule URLs from .gitmodules (after URL change).

git clone --recurse-submodules

Clone and initialise all submodules in one shot.

Common pitfalls
  • Forgetting to push the submodule before the superproject — causes others to fetch a missing object.

  • Committing a dirty submodule pointergit status in the superproject shows a "modified" submodule; always commit after update --remote.

  • Not using --recurse-submodules on clone — leaves empty submodule directories that look like they exist but contain nothing.

  • Editing inside a detached HEAD — commits made in a detached HEAD are lost if you git submodule update again without first checking out a branch and pushing.

  • Nested submodules — submodules inside submodules are supported but multiply the complexity; prefer flatter structures.

Submodules vs git subtree vs package managers

Feature

Submodules

git subtree

Package Manager (npm/pip/etc.)

Source visible in repo

Yes (nested repo)

Yes (inlined into history)

No (downloaded at build time)

Separate history

Yes, fully independent

Merged into parent history

N/A

Contribute changes back

Easy (push from submodule dir)

Possible but awkward (git subtree push)

Requires fork + PR to upstream

Pins to exact version

Yes (commit SHA)

Yes (squash commit)

Yes (lockfile)

Cloning complexity

Requires --recurse-submodules

None — plain clone

Requires install step

Offline builds

Yes (already cloned)

Yes

No (needs registry)

Binary dependencies

Poor fit

Poor fit

Excellent

Overhead in large monorepos

High

Medium

Low

Tip
Run `git submodule foreach 'git fetch && git log --oneline HEAD..origin/main'` to quickly see which submodules have upstream commits you have not yet pulled in — a handy weekly habit when you maintain multiple submodule-backed projects.