open atlas
↑ Back to track
Git, from zero to senior GIT · 09 · 02

Submodules and subtrees

A submodule stores a pinned pointer (gitlink) to one external commit via .gitmodules; a subtree merges the external code into your tree. Submodules stay light but track a commit, not a branch; subtrees are simpler for consumers but bulk up history.

GIT Middle ◷ 18 min
Level
FoundationsJuniorMiddleSenior

Your app depends on an internal design-system repo, and you want its source inside your project — not as a published package, but as code you can read and patch in place. You could copy the files in and lose all link to upstream, or you could let Git track the relationship for you. Git offers two built-in ways to nest one repository inside another, and they make opposite tradeoffs.

By the end of this lesson you will be able to tell a submodule from a subtree, run the core commands for each, and explain why a submodule’s “track a commit, not a branch” model is the gotcha that bites teams most.

Goal

After this lesson you can vendor an external repository into your own using either a submodule or a subtree, explain what each stores in your history, and choose between them based on who carries the complexity — your developers or your consumers.

1

A submodule is a pinned pointer to one exact commit of an external repo — not a copy of its files. When you add a submodule, your repository records a special entry called a gitlink: a single line in the tree that says “at this path lives external repo X, at commit abc123.” The external repo is cloned into a subdirectory, but your repo stores only the pointer plus a .gitmodules file mapping the path to the upstream URL.

git submodule add https://example.com/design-system.git vendor/design-system
git commit -m "Add design-system submodule"

Your commit now contains the gitlink (the pinned SHA) and .gitmodules — not the design-system’s actual files. That is what keeps the parent repo small.

2

Cloning a repo with submodules does not fetch them by default — you must initialise them. A fresh clone leaves submodule directories empty; the parent only knows the pointer, not the contents. You pull the pinned commits explicitly:

# In one step, at clone time:
git clone --recursive https://example.com/app.git

# Or after a normal clone:
git submodule update --init --recursive

--init reads .gitmodules, update checks out each submodule at the exact commit the parent pins (not the tip of its branch). This is the heart of the model: the parent controls precisely which version of the dependency every checkout gets.

3

The gotcha: a submodule tracks a commit, and updating it is a two-step commit you can forget. To move the dependency forward, you go into the submodule, check out a newer commit, then come back and commit the moved pointer in the parent:

cd vendor/design-system
git checkout main
git pull            # submodule now at a newer commit
cd ../..
git add vendor/design-system   # stage the new pointer
git commit -m "Bump design-system to latest main"

If you skip that last git add + commit, your local submodule is updated but the parent still pins the old SHA — and teammates who submodule update get the old version. The reverse trap: pull a parent change that moved the pointer and forget to run submodule update, and your working copy silently runs a stale dependency. Submodules are precise but sharp.

4

A subtree merges the external repo’s content directly into your tree — no pointer, no extra step for consumers. Instead of a link, git subtree copies the external history into a subdirectory of your repo as real files and real commits:

# Add the external repo under vendor/design-system
git subtree add --prefix=vendor/design-system \
  https://example.com/design-system.git main --squash

# Later, pull upstream changes in
git subtree pull --prefix=vendor/design-system \
  https://example.com/design-system.git main --squash

Now anyone who clones your repo gets the design-system files immediately — no --recursive, no submodule update, no .gitmodules. The cost is in your history: the subtree’s commits (or a squashed snapshot) are merged into your repo, making it bulkier, and pushing changes back upstream (git subtree push) is fiddlier than a submodule’s clean separation.

Common mistake

The single most common submodule failure is the “detached pointer” confusion. After submodule update, the submodule is in detached HEAD at the pinned commit — not on a branch. Developers cd in, make edits, commit, and later find the work “gone” because they committed onto no branch and then a later update moved HEAD away. Inside a submodule, always git checkout <branch> before editing, and remember that any change there is a change in a separate repository with its own push.

Worked example

Same dependency, two teams, two choices.

Platform team, submodule. They maintain design-system actively and need their twelve apps to pin an exact, auditable version. They add it as a submodule in each app. Bumping the version is a deliberate act: someone updates the submodule commit and commits the new pointer in a PR, so the bump is reviewable and every app states precisely which SHA it ships. The price they accept: every developer must remember git submodule update --init after pulling, and CI must clone --recursive or builds break with an empty directory.

Tooling team, subtree. They publish a CLI that bundles a small templates repo. Their users are not Git experts and must never run a second command after cloning. They vendor templates as a subtree, so git clone alone yields a working checkout — no .gitmodules, no surprises. When upstream templates change, a maintainer runs git subtree pull --squash and commits the merged result. Their history is bulkier, but their consumers carry zero submodule complexity. Same problem, opposite distribution of pain — submodule pushes effort onto consumers, subtree onto maintainers.

Why this works

Why does either exist when package managers (npm, pip, Go modules) solve dependency vendoring? Because some dependencies are not published packages: an internal shared library, a fork you patch locally, a config repo, or vendored source you must build from inside your tree. Submodules and subtrees let Git itself version the relationship, with no registry and no release step — you pin or embed a specific commit of any reachable repo. When a real package registry fits, prefer it; these are for the cases where it does not.

Check yourself
Quiz

A developer clones a repo that uses submodules with a plain `git clone` (no --recursive) and finds the vendor/design-system directory empty. Why?

Recap

To nest one repo inside another, a submodule records a gitlink — a pointer to one exact external commit — plus a .gitmodules file, keeping your repo light but tracking a commit, not a branch; updating it means moving into the submodule and then committing the new pointer in the parent, and a fresh clone needs --recursive or git submodule update --init or the directory is empty. A subtree instead merges the external code into your own tree as real files, so consumers get everything from one git clone with no extra step — at the cost of a bulkier history and trickier pushes upstream. Submodule pushes complexity onto consumers; subtree onto maintainers. Next you will tackle the heaviest history surgery of all: rewriting an entire repository’s past to purge a leaked secret.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 4 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.