What was bound to happen has happened. Ana has spent months extracting reusable pieces out of task-manager: the button with a loading state, the confirmation dialog, the text field with validation. At first they lived in app.js; then in a separate components.js. And now there are two more projects in the company that want to use them.

The team has done the right thing: it has created a repository of its own, ui-components, at git.example.com/team/ui-components.git, with its own history, its own tagged versions and its own maintainers.

And now the uncomfortable question arises: how does task-manager use that library?

Copying the files and pasting them works up until the first fix, at which point there are three divergent copies. And it is not enough to "have the library somewhere": it needs to be recorded which exact version of ui-components each commit of task-manager uses, so that running git checkout v1.0.0 six months from now can rebuild exactly that application.

Git's native answer to that problem is submodules. They are powerful, they are the right solution for certain cases, and they have a reputation — deserved — for biting anyone who does not understand how they work. This lesson is about understanding them properly.

Contents

  1. What a submodule is exactly
  2. git submodule add: adding ui-components
  3. What exactly gets committed: the commit-type object
  4. Cloning a project with submodules
  5. Updating: the submodule versus the pointer
  6. Working inside a submodule and detached HEAD
  7. Inspection: status, foreach and diff --submodule
  8. submodule.recurse and other options that take the pain away
  9. The real problems
  10. Alternatives: submodules, subtree, packages and monorepo

  1. What a submodule is exactly

The complete definition, and everything else follows from it:

A submodule is an entry in the parent repository's tree pointing at a specific commit of another repository, plus a line in the .gitmodules file saying where to clone that other repository from.

Two pieces, no more:

  1. The pointer: an entry in the tree (lesson 01-04) whose type is neither blob nor tree, but commit. It stores a 40-character hash and nothing else.
  2. .gitmodules: a version-controlled text file, at the parent's root, associating each path with its URL.
flowchart TD
    subgraph P["task-manager repository"]
      C1["commit c8f2a1e"]
      T1["root tree"]
      B1["blob app.js"]
      B2["blob index.html"]
      B3["blob .gitmodules"]
      SM["commit 7f3a9d2<br/>('ui-components' entry)"]
    end
    subgraph S["ui-components repository"]
      X1["commit 4b8e1c5"]
      X2["commit 7f3a9d2"]
      X3["commit 9d2f6a8"]
    end
    C1 --> T1
    T1 --> B1
    T1 --> B2
    T1 --> B3
    T1 --> SM
    SM -.->|"points at"| X2

The crucial part: the parent repository does not contain the library's code. It contains a sticky note saying "ui-components goes here, at exactly commit 7f3a9d2". The code lives in the other repository, with its own .git, its own history and its own branches.

From that come the three properties that define the experience of working with submodules:

  • Exact reproducibility. Each commit of task-manager pins a specific version of the library. git checkout v1.0.0 + updating the submodules rebuilds the application exactly as it was.
  • Independent histories. A commit in the library does not show up in the application's history. They are two separate repositories with two separate git logs.
  • Explicit updating. The pointer does not move on its own. If the library moves forward, the parent carries on pointing at the same commit until somebody decides to move it and commits that.

That last property is simultaneously the greatest virtue and the greatest source of complaints.

  1. git submodule add: adding ui-components

Ana does it from the root of task-manager:

cd ~/projects/task-manager
git submodule add git@git.example.com:team/ui-components.git vendor/ui-components
Cloning into '/home/ana/projects/task-manager/vendor/ui-components'...
remote: Enumerating objects: 214, done.
remote: Total 214 (delta 89), reused 214 (delta 89)
Receiving objects: 100% (214/214), 48.32 KiB | 4.83 MiB/s, done.
Resolving deltas: 100% (89/89), done.

Syntax:

git submodule add [-b <branch>] <url> [<path>]
  • If you omit the path, the repository's name is used (ui-components/).
  • -b <branch> registers a tracking branch, useful for --remote (section 5).

What has happened, exactly:

git status
On branch main
Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
	new file:   .gitmodules
	new file:   vendor/ui-components

Two new files, not two hundred. The library's entire directory shows up as a single entry. That is the first visible sign that the content is not being stored.

The generated .gitmodules:

[submodule "vendor/ui-components"]
	path = vendor/ui-components
	url = git@git.example.com:team/ui-components.git

And the commit:

git commit -m "Add ui-components as a submodule

The component library now lives in its own repository.
It is pinned at version v2.1.0 so that the build is reproducible."
[main 3d8f1a6] Add ui-components as a submodule
 2 files changed, 4 insertions(+)
 create mode 100644 .gitmodules
 create mode 160000 vendor/ui-components

Note the last line: create mode 160000. The modes we knew from lesson 01-04 were 100644 (ordinary file), 100755 (executable) and 040000 (directory). 160000 is the special mode for a submodule, and that number is literally how Git marks "there is a pointer to an external commit here".

Ana can now use it from index.html:

<script src="vendor/ui-components/dist/components.js"></script>
// app.js
const dialog = UIComponents.createDialog({
  title: 'Confirm deletion',
  message: 'Are you sure you want to delete this task?',
});

And she pushes:

git push

  1. What exactly gets committed: the commit-type object

It is worth looking at it from the inside, because understanding it here avoids all the later confusion. We pick up the tools from lesson 01-04:

git cat-file -p HEAD^{tree}
100644 blob 8f2a1c9e...	.gitmodules
100644 blob 4b7d9c3e...	README.md
100644 blob 2e5f8a1c...	app.js
100644 blob 9c4e7b2f...	styles.css
100644 blob 1d6a8f3c...	index.html
040000 tree 7b3d5c9a...	vendor
git cat-file -p HEAD:vendor
160000 commit 7f3a9d2c4e8b1f6a3d5c9e2b7f4a8d1c6e3b5a9f	ui-components

There it is. The vendor tree contains an entry of type commit with mode 160000. It is not a tree, it is not a blob: it is a reference to a commit object that is not even in task-manager's object database.

git cat-file -t 7f3a9d2
fatal: git cat-file: could not get object info

Indeed: that object lives in the ui-components repository, not here. The parent only stores its name.

And the total size that reference takes up in the parent repository:

git cat-file -s $(git rev-parse HEAD:vendor)

A tree with one entry: a few dozen bytes. The task-manager repository has not grown at all from taking on a library of 200 commits.

From this you can see why the diff of a submodule change looks so odd:

diff --git a/vendor/ui-components b/vendor/ui-components
index 7f3a9d2..9d2f6a8 160000
--- a/vendor/ui-components
+++ b/vendor/ui-components
@@ -1 +1 @@
-Subproject commit 7f3a9d2c4e8b1f6a3d5c9e2b7f4a8d1c6e3b5a9f
+Subproject commit 9d2f6a8b3e7c1d5f9a2b6c4e8d1f3a7c5b9e2d6f

One line changing. The library's entire content may have changed, but for the parent this is a pointer moving.

  1. Cloning a project with submodules

Bruno clones the project for the first time:

git clone git@git.example.com:team/task-manager.git
cd task-manager
ls vendor/ui-components/
(empty)

The directory exists but it is empty. This is the first classic stumble, and it happens to everyone: git clone does not download the submodules by default.

There are two ways of fixing it.

The right one: clone with --recurse-submodules

git clone --recurse-submodules git@git.example.com:team/task-manager.git
Cloning into 'task-manager'...
...
Submodule 'vendor/ui-components' (git@git.example.com:team/ui-components.git) registered for path 'vendor/ui-components'
Cloning into '/home/bruno/task-manager/vendor/ui-components'...
Submodule path 'vendor/ui-components': checked out '7f3a9d2c4e8b1f6a3d5c9e2b7f4a8d1c6e3b5a9f'

It clones the parent and, straight afterwards, initialises and clones each submodule at the pinned commit.

If you have already cloned without it

git submodule init          # reads .gitmodules and registers the submodules in .git/config
git submodule update        # clones and checks out the pinned commit

Or in a single command:

git submodule update --init
git submodule update --init --recursive     # if the submodules have submodules

The split into two steps makes sense once you understand it:

Command What it does Where it writes
git submodule init Copies the configuration from .gitmodules into .git/config Local .git/config
git submodule update Clones (if needed) and checks out the pinned commit The submodule's directory

The init step exists because it lets you change the URL locally before cloning (for example, to use an internal mirror or HTTPS instead of SSH) without touching the .gitmodules shared by the whole team:

git submodule init
git config submodule.vendor/ui-components.url https://git.example.com/team/ui-components.git
git submodule update

You can also initialise only some of them, in projects with many:

git submodule update --init vendor/ui-components

  1. Updating: the submodule versus the pointer

Here is the distinction that generates the most confusion, and it deserves a heading of its own.

There are two different updates and they do opposite things:

Command What it does Direction
git submodule update Puts the submodule at the commit the parent says The parent is in charge
git submodule update --remote Fetches the latest from the submodule's remote and moves the pointer The submodule is in charge

git submodule update: obeying the parent

This is the normal case, and the one you run after a git pull:

git pull
git submodule update --init --recursive

Ana has pushed a commit that moves the library's pointer. Bruno does a git pull of the parent, and submodule update leaves his copy of ui-components at exactly the commit Ana pinned. Synchronisation, not updating.

git submodule update --remote: bringing in what is new

git submodule update --remote vendor/ui-components
Submodule path 'vendor/ui-components': checked out '9d2f6a8b3e7c1d5f9a2b6c4e8d1f3a7c5b9e2d6f'

Git has done a fetch in the submodule and checked out the tip of its tracking branch. Which branch? In this order:

  1. The one given in .gitmodules with branch = <branch> (or git submodule add -b).
  2. If there is none, the remote's HEAD, which is usually main.
[submodule "vendor/ui-components"]
	path = vendor/ui-components
	url = git@git.example.com:team/ui-components.git
	branch = stable

And now the important part: that still has not changed anything in the parent's history. It is an uncommitted change:

git status
On branch main
Changes not staged for commit:
	modified:   vendor/ui-components (new commits)

(new commits) means: "the submodule is at a different commit from the one I have recorded". For the update to be real and to reach the rest of the team, it has to be committed in the parent:

git add vendor/ui-components
git commit -m "Update ui-components to v2.2.0

Includes the confirmation dialog focus fix
(ui-components#48) that we need for multiple deletion."
git push
sequenceDiagram
    participant B as ui-components (remote)
    participant L as vendor/ui-components (local)
    participant P as task-manager (parent)
    L->>B: git submodule update --remote → fetch + checkout
    Note over L: the submodule moves on to 9d2f6a8
    Note over P: git status → "modified: (new commits)"
    P->>P: git add vendor/ui-components + commit
    Note over P: the recorded pointer is now 9d2f6a8

The rule that sums up the whole section: updating the submodule is a change on your disk; updating the pointer is a commit in the parent. If you do not commit, nobody else sees the update, and the next git submodule update undoes it for you.

  1. Working inside a submodule and detached HEAD

Carla needs to fix the confirmation dialog, which is in the library. She goes into the submodule's directory:

cd vendor/ui-components
git status
HEAD detached at 7f3a9d2
nothing to commit, working tree clean

Detached HEAD, the state from lesson 03-02. And it makes complete sense: the parent does not say "use the library's main branch", it says "use commit 7f3a9d2". Git does exactly that, and checking out a loose commit leaves HEAD detached.

Dangerous consequence: if Carla edits and commits here without further thought, she creates a commit that belongs to no branch. As soon as somebody runs git submodule update, that commit is orphaned and can only be recovered through the reflog (lesson 09-04).

The correct procedure has four steps:

# 1. Get onto a real branch, inside the submodule
cd vendor/ui-components
git switch main
git pull

# 2. Work as in any repository
git switch -c fix/dialog-focus
# ... edit dialog.js ...
git commit -am "Return focus to the trigger when closing the dialog"

# 3. PUBLISH the change on the SUBMODULE's remote
git push -u origin fix/dialog-focus
# (after reviewing it and integrating it into the submodule's main)

# 4. Go back to the parent and commit the new pointer
cd ../..
git add vendor/ui-components
git commit -m "Update ui-components: dialog focus fix"
git push

Step 3 is the one that gets forgotten, and leaving it out is the most serious failure of all with submodules. If Carla commits the pointer in the parent without having published the submodule's commit, the result is:

# Bruno, on his machine
git pull
git submodule update
fatal: remote error: upload-pack: not our ref 5c9e2b7f4a8d1c6e3b5a9f7f3a9d2c4e8b1f6a3d
Fetched in submodule path 'vendor/ui-components', but it did not contain
5c9e2b7f4a8d1c6e3b5a9f7f3a9d2c4e8b1f6a3d. Direct fetching of that commit failed.

The parent points at a commit that does not exist anywhere except on Carla's disk. The project is broken for the whole team until she publishes.

Git has a safety net for this, and it needs enabling:

git push --recurse-submodules=check      # aborts the push if there are unpublished commits
git push --recurse-submodules=on-demand  # publishes them automatically beforehand
# Make it the repository's default behaviour
git config push.recurseSubmodules check

check is the recommended option: it warns you and forces you to decide, rather than publishing things off its own bat.

  1. Inspection: status, foreach and diff --submodule

git submodule status

git submodule status
 7f3a9d2c4e8b1f6a3d5c9e2b7f4a8d1c6e3b5a9f vendor/ui-components (v2.1.0)

The first character is a status indicator, and you need to know how to read it:

Prefix Meaning
(space) The submodule is at the right commit
- Not initialised: git submodule update --init is missing
+ It is at another commit, different from the recorded one
U It has unresolved merge conflicts

Examples of the problematic situations:

-7f3a9d2c... vendor/ui-components

→ Bruno has just cloned without --recurse-submodules.

+9d2f6a8b... vendor/ui-components (v2.2.0)

→ Somebody has moved the submodule and has not committed it in the parent.

With --recursive it descends through nested submodules.

git submodule foreach

Runs a command in each submodule:

git submodule foreach 'git status --short'
git submodule foreach 'git fetch'
git submodule foreach --recursive 'git switch main && git pull'

Inside the command, Git defines useful variables:

Variable Content
$name The submodule's name in .gitmodules
$path The path relative to the parent
$sha1 The commit the parent has recorded
$toplevel The absolute path of the parent repository
git submodule foreach 'echo "$name is at $(git describe --tags --always)"'
Entering 'vendor/ui-components'
vendor/ui-components is at v2.1.0

git diff --submodule

By default, a submodule change shows up as that Subproject commit line. --submodule=log shows the commits that make up the difference, which is infinitely more informative:

git diff --submodule=log
Submodule vendor/ui-components 7f3a9d2..9d2f6a8:
  > Return focus to the trigger when closing the dialog
  > Add the compact button variant
  > Fix the contrast of disabled text

Three commits of difference, with their subjects. The available modes:

Mode Output
--submodule=short The Subproject commit line (the default)
--submodule=log The list of commits between the two points
--submodule=diff The full diff of the changes inside the submodule

And since that is what you want almost always, it is worth pinning it:

git config --global diff.submodule log
git config --global status.submoduleSummary true

With status.submoduleSummary, git status also summarises the outstanding commits instead of just saying (new commits).

  1. submodule.recurse and other options that take the pain away

Most of the suffering with submodules comes from forgetting --recurse-submodules on some command. This option enables it by default for nearly all of them:

git config --global submodule.recurse true

From then on, git pull, git switch, git checkout, git reset and others update the submodules automatically. It is the first setting anybody working with submodules should put in place.

Note: submodule.recurse does not affect git clone, which still needs its explicit --recurse-submodules.

The complete recommended set of configuration:

# Recurse into submodules automatically on pull, switch, checkout, reset…
git config --global submodule.recurse true

# See commits instead of hashes when comparing
git config --global diff.submodule log

# Submodule summary in git status
git config --global status.submoduleSummary true

# Warn me if I am about to push a pointer to unpublished commits
git config --global push.recurseSubmodules check

# Speed up fetching several submodules in parallel
git config --global submodule.fetchJobs 4

And a handful of commands that solve specific situations:

# The URL changed in .gitmodules and needs propagating to .git/config
git submodule sync --recursive

# Discard ALL local changes in the submodules and go back to the pointer
git submodule update --init --recursive --force

# Remove a submodule completely
git submodule deinit -f vendor/ui-components
git rm vendor/ui-components
rm -rf .git/modules/vendor/ui-components
git commit -m "Remove the ui-components submodule"

That last one deserves an explanation: deinit deregisters it, git rm removes the tree entry and the .gitmodules line, and the rm -rf of .git/modules/ deletes the internal repository Git keeps (since version 1.7.8, the submodule's .git is not inside its directory but in the parent's .git/modules/<name>, and in the submodule's directory there is a .git file pointing there — the same technique we shall see with worktree in lesson 06-06).

  1. The real problems

Everything above works. These are the stumbles that occur in practice and how to solve them.

Problem 1: "the directory is empty"

Symptom. Somebody clones and vendor/ui-components/ has nothing in it. The application does not start.

Cause. git clone without --recurse-submodules.

Solution. git submodule update --init --recursive. And prevention: document it in the README.md, and — better still — a post-checkout hook from lesson 06-01 that warns:

#!/usr/bin/env bash
# .git/hooks/post-checkout
if [ -f .gitmodules ] && git submodule status | grep -q '^-'; then
  echo "⚠  There are uninitialised submodules. Run:"
  echo "   git submodule update --init --recursive"
fi

Problem 2: the pointer points at a commit that does not exist

Already seen in section 6: somebody committed the pointer without publishing the submodule's commit.

Solution. That person does a git push in the submodule. Prevention: push.recurseSubmodules check.

Problem 3: uncommitted changes inside the submodule

Symptom. git status in the parent says modified: vendor/ui-components (modified content) and there is no way of making it go away.

Cause. Somebody has edited files inside the submodule without committing them there.

git submodule status
+7f3a9d2c... vendor/ui-components (v2.1.0-3-g5c9e2b7)

Solution, depending on what you want:

# A) The changes are good: commit them INSIDE the submodule and publish
cd vendor/ui-components && git switch main && git commit -am "..." && git push

# B) The changes are surplus: discard them
git submodule update --force

# C) I just want the parent's git status to ignore them (careful!)
git config submodule.vendor/ui-components.ignore dirty

The ignore option accepts none (the default), untracked (ignores untracked files), dirty (also ignores modifications) and all (ignores even a different pointer). all is dangerous: it hides exactly the information you need to see.

Problem 4: switching branches in the parent

Symptom. Carla moves from a branch that has the submodule to another that does not (or that has it at a different version), and odd files appear or the directory is left with old content.

Cause. The parent's checkout moves the pointer, but the content of the submodule's directory does not update on its own unless you ask for it.

Solution. submodule.recurse true, or remembering git checkout --recurse-submodules <branch>.

And a particularly annoying case: on going back to a branch from before the submodule was added, the directory sits there with content and untracked. It is not a bug: it is that Git does not delete directories containing a repository, so as not to destroy work.

Problem 5: merge conflicts on the pointer

Two branches update the submodule to different commits:

CONFLICT (submodule): Merge conflict in vendor/ui-components

There are no conflict markers to edit: the conflict is over which hash should win. Resolving it consists of deciding the right commit:

# See the two candidates
git diff --submodule=log

# Choose one of the sides
cd vendor/ui-components
git log --oneline --all -10
git checkout <the-right-commit>     # normally the one that includes both changes
cd ../..
git add vendor/ui-components
git commit

Often the right answer is neither of the two, but a later commit of the submodule containing the changes from both branches. Merge them first inside the submodule, and then point there.

Problem 6: the daily friction

And the problem least talked about: submodules add a step to everything. Every pull may need a submodule update; every change in the library is two commits and two pushes; every new person gets the first clone wrong. None of that is serious on its own, but it adds up.

That is why the important question is not "how do you use submodules?", but "is this the right tool for my case?". On to that.

  1. Alternatives: submodules, subtree, packages and monorepo

There are four reasonable ways of composing a project out of several pieces.

Submodules git subtree Package manager Monorepo
What the parent stores A pointer to a commit The real code, merged into its history A declared version (package.json) Everything, it is a single repository
Size of the parent repository Minimal Grows with the library Minimal Large
Does the person cloning need extra steps? Yes (--recurse-submodules) No Yes (npm install) No
The library's history Separate and intact Mixed in (or squashed) Invisible Unified
Updating submodule update --remote + commit git subtree pull Change the version + install Does not apply: it is the same commit
Contributing to the library Natural: it is an ordinary repository Clumsy: git subtree push Requires cloning separately Trivial
Exact reproducibility Total (a hash) Total Good (with a lock file) Total
Learning curve Steep Medium Gentle Gentle
Atomic application+library changes No (two commits) Yes No Yes
External tooling None None Yes (npm, pip, Maven…) Usually needed

A paragraph on each:

git subtree does the opposite of submodules: it copies the content of the library into the parent and keeps it synchronised with merges. Whoever clones has nothing special to do, because the code is right there. In exchange, the repository grows, the history is mixed in and contributing back to the library is awkward. It is a good option when you consume a dependency and rarely modify it, and when the friction of --recurse-submodules is unacceptable (for example, if the project is cloned by people outside the team). The basic commands are git subtree add, pull and push; we do not develop it here because it would deserve a lesson of its own.

A package manager (npm, pip, Maven, Cargo…) is, in most modern projects, the right answer. If ui-components can be published as a package — even in a private company registry — the team declares "@team/ui-components": "^2.1.0" in its package.json, the lock file guarantees reproducibility, and the whole semantic versioning machinery (lesson 05-05) works in its favour. The fact that Git can do this does not mean it should. Before choosing submodules, always ask yourself whether a package would solve the problem.

The monorepo — a single repository with the application and the library inside it — removes the problem at the root: one commit can change both at once, and they are always consistent. It is what many large companies do. The price is a repository that grows a lot and needs its own tooling for permissions, partial builds and performance. That scenario, with its techniques — partial clones, sparse-checkout, shallow clones — is the subject of lesson 10-04: Scaling Git for Large Projects.

Submodules win when several of these conditions hold at once:

  • The dependency is source code you build alongside your own, not a publishable artefact.
  • You need to pin an exact commit, not a range of versions.
  • You modify the library fairly often and you want it to be a first-class repository.
  • There is no internal package registry (or you do not want to set one up).
  • The team is small and can be trained in the mechanics.

Typical real-world cases: CMS themes and plugins, C/C++ libraries built from source, configurations shared between projects, firmware with third-party components.

And a mention that has to be made here because it sometimes gets confused: if your problem is not shared code but large, binary files — high-resolution images, videos, models, executables — the answer is none of the four, but Git LFS, which replaces those files with text pointers and keeps the content in a separate store. It is the subject of lesson 10-03: Git LFS for Large Files.

For task-manager, the team ends up taking this decision: a submodule for now, because ui-components is in full development, is modified every week and it is not worth publishing a package on every change. When the library settles down and other teams start consuming it, they will move to publishing it in the internal npm registry. It is a sensible decision: the way a project is composed can change with its maturity.

Common Mistakes and Tips

Mistake 1: cloning without --recurse-submodules. Mistake number one. Empty directory and broken application. Solution: git submodule update --init --recursive.

Mistake 2: committing the pointer without publishing the submodule's commit. It breaks the project for everybody else. Prevention: git config push.recurseSubmodules check.

Mistake 3: working in the submodule without leaving detached HEAD. The commits end up orphaned. Do git switch <branch> before touching anything.

Mistake 4: confusing submodule update with submodule update --remote. The first obeys the parent; the second brings in what is new from the remote. They are opposites.

Mistake 5: forgetting to commit in the parent after updating. Without a commit in the parent, the update exists only on your disk.

Mistake 6: using submodule.<n>.ignore = all. It hides precisely the information you need to see. At most, dirty.

Mistake 7: trying to delete a submodule with rm -rf. It leaves remnants in .git/config, .gitmodules and .git/modules/. Use the deinit + git rm sequence.

Mistake 8: choosing submodules by default. There is nearly always a simpler alternative. Justify the choice.

Tip 1: git config --global submodule.recurse true, always. It is the setting that avoids the most pain.

Tip 2: diff.submodule log and status.submoduleSummary true. They turn illegible hashes into lists of commits.

Tip 3: point at stable commits, preferably tagged ones. A submodule pointing at the tip of another team's main is an inexhaustible source of surprises. git describe --tags inside the submodule tells you where you are.

Tip 4: document the mechanics in the README.md. Five lines with the clone --recurse-submodules and the submodule update --init save every new person an hour.

Tip 5: a post-checkout hook that warns about uninitialised submodules. It is the perfect use of what you learned in lesson 06-01.

Tip 6: when updating the pointer, explain why in the message. "Update ui-components" says nothing; "Update ui-components to v2.2.0 for the focus fix" turns that commit into useful information.

Exercises

Exercise 1: creating and exploring a submodule

Working locally (with no remote server):

  1. Create a library repository with three commits and a v1.0 tag.
  2. Create an application repository with two commits.
  3. Add library as a submodule of application at vendor/library and commit it.
  4. Inspect the tree with git cat-file -p HEAD^{tree} and locate the entry with mode 160000.
  5. Check that the commit object it points at does not exist in the parent's object database.

Exercise 2: the complete update cycle

On the previous exercise:

  1. Add two new commits to library.
  2. In application, check that git status says nothing (the pointer is unchanged).
  3. Run git submodule update --remote and observe what changes in git status.
  4. Commit the new pointer with a message that explains why.
  5. Check the pointer's diff with --submodule=short, --submodule=log and --submodule=diff.

Exercise 3: simulating the problems and solving them

  1. Clone application without --recurse-submodules and check the empty directory and the output of git submodule status.
  2. Fix it with git submodule update --init.
  3. In the clone, go into the submodule and check that you are in detached HEAD. Make a commit there without switching branch and observe Git's warning.
  4. Recover that orphaned commit by putting it on a branch.
  5. Remove the submodule from the parent completely (deinit, git rm, .git/modules/) and verify that no remnants are left.

Solutions

Solution 1:

mkdir -p /tmp/practice-sub && cd /tmp/practice-sub

# The library
git init -q -b main library
cd library
echo "export function button() {}" > button.js
git add . && git commit -q -m "Add the button component"
echo "export function dialog() {}" > dialog.js
git add . && git commit -q -m "Add the dialog component"
echo "export function field() {}" > field.js
git add . && git commit -q -m "Add the text field component"
git tag v1.0
cd ..

# The application
git init -q -b main application
cd application
echo "<html><body></body></html>" > index.html
git add . && git commit -q -m "Add the HTML skeleton"
echo "console.info('start-up');" > app.js
git add . && git commit -q -m "Add the application start-up"
git -c protocol.file.allow=always submodule add ../library vendor/library
git status --short
A  .gitmodules
A  vendor/library

The -c protocol.file.allow=always option is needed as of Git 2.38 in order to use submodules with local paths; with https:// or git@ URLs it is not necessary.

git commit -q -m "Add library as a submodule at v1.0"
git cat-file -p HEAD^{tree}
100644 blob 3f8a1c9e...	.gitmodules
100644 blob 7d2e5b4c...	app.js
100644 blob 9a4f1c6b...	index.html
040000 tree 2c8d5f9a...	vendor
git cat-file -p HEAD:vendor
160000 commit 8b3d6f2a9c4e7b1d5f8a2c6e9b4d7f1a3c5e8b2d	library

There it is: mode 160000 and type commit.

git cat-file -t 8b3d6f2 2>&1 | head -1
fatal: git cat-file: could not get object info

Confirmed: the object is not in the parent's database. Only its name is stored.

Solution 2:

cd /tmp/practice-sub/library
echo "export function table() {}" > table.js
git add . && git commit -q -m "Add the table component"
echo "// focus fixed" >> dialog.js
git commit -qam "Return focus to the trigger when closing the dialog"
git tag v1.1

cd /tmp/practice-sub/application
git status --short
(no output)

The parent is still pointing at the pinned commit: the library's new commits are of no concern to it. That is the property, not a bug.

git submodule update --remote
git status
Submodule path 'vendor/library': checked out 'd5a9c2f...'
On branch main
Changes not staged for commit:
	modified:   vendor/library (new commits)
git diff --submodule=short
diff --git a/vendor/library b/vendor/library
index 8b3d6f2..d5a9c2f 160000
--- a/vendor/library
+++ b/vendor/library
@@ -1 +1 @@
-Subproject commit 8b3d6f2a9c4e7b1d5f8a2c6e9b4d7f1a3c5e8b2d
+Subproject commit d5a9c2f7b1e4a8d3c6f9b2e5a8d1c4f7b3e6a9d2
git diff --submodule=log
Submodule vendor/library 8b3d6f2..d5a9c2f:
  > Return focus to the trigger when closing the dialog
  > Add the table component

Far more useful: two commits, with their subjects.

git diff --submodule=diff | head -20

It shows the real content of the changes inside the submodule.

git add vendor/library
git commit -q -m "Update library to v1.1

Includes the dialog focus fix, needed for the deletion flow
with confirmation."
git submodule status
 d5a9c2f7b1e4a8d3c6f9b2e5a8d1c4f7b3e6a9d2 vendor/library (v1.1)

Space prefix: all in order.

Solution 3:

cd /tmp/practice-sub
git -c protocol.file.allow=always clone -q application app-clone
cd app-clone
ls vendor/library/
(empty)
git submodule status
-d5a9c2f7b1e4a8d3c6f9b2e5a8d1c4f7b3e6a9d2 vendor/library

The leading - is the diagnosis: not initialised.

git -c protocol.file.allow=always submodule update --init
ls vendor/library/
git submodule status
button.js  dialog.js  field.js  table.js
 d5a9c2f7b1e4a8d3c6f9b2e5a8d1c4f7b3e6a9d2 vendor/library (v1.1)
# 3. The detached HEAD
cd vendor/library
git status | head -2
HEAD detached at d5a9c2f
echo "export function menu() {}" > menu.js
git add . && git commit -q -m "Add the menu component"
git log --oneline -1
cd ../..
git submodule status
a7c3e9f Add the menu component
+a7c3e9f1d5b8c2e6a9f4d7b1c3e8a5f2d6b9c4e7 vendor/library (v1.1-1-ga7c3e9f)

The + is the warning: the submodule is at a different commit from the recorded one. And that commit is not on any branch:

cd vendor/library
git branch --contains HEAD
(no output: it belongs to no branch)
# 4. Rescuing it
git switch -c fix/menu
git branch --contains HEAD
* fix/menu

Now it is safe. Without that step, a git submodule update would have orphaned it.

# 5. Removing the submodule completely
cd /tmp/practice-sub/app-clone
git submodule deinit -f vendor/library
git rm -q vendor/library
rm -rf .git/modules/vendor/library
git commit -q -m "Remove the library submodule"

cat .gitmodules 2>/dev/null || echo "(.gitmodules no longer exists)"
git config --get-regexp '^submodule\.' || echo "(no submodule configuration)"
ls .git/modules 2>/dev/null || echo "(no internal repositories)"
(.gitmodules no longer exists)
(no submodule configuration)
(no internal repositories)

The three places a submodule lives, all clean. A plain rm -rf would have left remnants in all three.

Conclusion

Submodules are Git's native answer to composing a project out of several repositories, and their entire behaviour follows from one single idea. The essentials:

  • A submodule is a pointer to a specific commit of another repository, stored as a tree entry of mode 160000 and type commit, plus a line in .gitmodules. The parent does not contain the library's code.
  • That gives exact reproducibility (each commit of the parent pins a version of the dependency), independent histories and explicit updating: the pointer does not move on its own.
  • git submodule add <url> <path> brings it in; the resulting commit touches two files, not two hundred.
  • Cloning requires --recurse-submodules, or git submodule update --init --recursive afterwards. It is the most frequent mistake.
  • There are two opposite updates: submodule update puts the submodule where the parent says; submodule update --remote brings in what is new from the remote and the pointer has to be committed in the parent for it to really exist.
  • Inside the submodule you are in detached HEAD: switch to a branch before working, and publish the submodule's commit before committing the pointer (push.recurseSubmodules check).
  • git submodule status with its prefixes ( , -, +, U), foreach and diff --submodule=log are the inspection tools. submodule.recurse true, diff.submodule log and status.submoduleSummary true remove most of the friction.
  • The real problems are always the same: empty directories, pointers to unpublished commits, uncommitted changes inside the submodule, branch switches in the parent and pointer conflicts. They all have a known solution and they all have a prevention.
  • And above all: they are not the default option. Compare them with git subtree, with a package manager (often the best answer) and with the monorepo (lesson 10-04), and choose with judgement. For large binaries, the tool is Git LFS (lesson 10-03).

task-manager is now composed of two repositories, and the team has the traceability it needed. But a problem is still outstanding from the end of module 5, and it is Carla's.

Carla is working on feature/multiple-delete, a long, half-finished branch. Every time an urgent alert arrives — a production fault, a code review to attend to, a query about another branch — she has to run git stash, switch branch, sort it out, come back and git stash pop. Ten times a day. And with the submodule just added it gets worse, because every branch switch also drags its update along.

Her first idea is to clone the repository twice. It would work, but it doubles the space, doubles the fetches and leaves two repositories with histories that have to be kept synchronised by hand. There is a much better solution, built into Git and surprisingly little known: several working directories sharing a single object database. That is lesson 06-06: Multiple Working Copies with git worktree.

Mastering Git: From Beginner to Advanced

Module 1: Introduction to Git

Module 2: Basic Git Operations

Module 3: Branching and Merging

Module 4: Working with Remote Repositories

Module 5: Advanced Git Operations

Module 6: Git Tools and Techniques

Module 7: Collaboration and Workflow Strategies

Module 8: Git Best Practices and Tips

Module 9: Troubleshooting and Debugging

Module 10: Git in the Real World

© Copyright 2026. All rights reserved