Skip to content
HARBAN AI LABS

Git & GitHub Mastery · Chapter 23

Submodules and Subtrees

Four reports from one week on a team that vendors a shared library: A CTO who hears these asks two things: why does this mechanism fail so often, and should we be using it at all?

28 min read · Volume 4 of 4: Scale, professional practice, production, and reference

Baseline. Git 2.55.0 on macOS; GitHub facts as of 1 October 2026. Transcripts are real output from labs/ch23/.

23.1 Why this matters

Four reports from one week on a team that vendors a shared library:

  1. "I cloned the service and the vendor/textsplit directory is empty. The imports fail."
  2. "git pull worked for everyone yesterday. Today it dies with not our ref."
  3. "I pulled, changed one line in ingest.py, committed, and the reviewer says my commit downgrades the library."
  4. "CI is green on my branch and red on main, and the only difference is a directory I never touched."

A CTO who hears these asks two things: why does this mechanism fail so often, and should we be using it at all? Both questions have precise answers, and both start from one fact that the Phase 0 report puts in a sentence: the superproject records a commit ID, not a branch, and Git's defaults do not keep the nested checkout in sync with that record. Every standard failure in this chapter is a gap between three things that should agree: the commit ID recorded in the superproject, the commit checked out in the submodule's working tree, and the commits that exist in the submodule's shared repository.

The projects: doc-qa, a document question-answering service owned by your team, and textsplit, a small text-chunking library that Asha maintains in a repository of its own. Ravi is your teammate on doc-qa. The second half of the chapter solves the same problem with a subtree and compares both with a package manager.

In one sentence. A submodule is another repository checked out in a subdirectory of yours, of which your repository records exactly one thing in its history: the ID of the commit that should be checked out there.

Analogy. A recipe that says "use the stock from page 212 of the other book, third printing". Your book does not contain the stock recipe; it contains a precise reference to one version of it. Anyone cooking from your book needs the other book as well, opened at that page. Where the analogy breaks: the other book keeps being reprinted, your reference does not update itself, and the reader can scribble in their copy of the other book without your book noticing until they compare.

Precisely. Three pieces, in three different places (gitsubmodules (external site)):

PieceWhereVersioned in the superproject?What it holds
The gitlinkA tree entry (and index entry) with mode 160000 at the submodule pathYesThe commit ID the superproject expects there
.gitmodulesA file at the top of the superproject's working treeYesFor each submodule: a name, the path, the URL to clone from, optional settings such as branch
The submodule's repository.git/modules/<name>/ inside the superproject's Git directory, plus a .git file in the submodule's working tree that points to itNoThe submodule's own objects, refs, HEAD, index, configuration

A fourth, local piece connects them: git submodule init copies the URL from .gitmodules into the superproject's .git/config as submodule.<name>.url. From then on Git uses the copy. That is why a changed URL does not reach existing clones by itself (section 23.11).

Chapter 3 listed mode 160000 among the five entry modes of a tree. This chapter is about what that entry does not contain.

See it. In your clone of doc-qa, add the library. The option before submodule is explained in section 23.4; the lab remotes are directories, and Git is cautious about those.

$ git -c protocol.file.allow=always submodule add ../textsplit.git vendor/textsplit
Cloning into '$LAB/ch23/submodule-add/doc-qa/vendor/textsplit'...
done.
$ git status
On branch main
Your branch is up to date with 'origin/main'.

Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
	new file:   .gitmodules
	new file:   vendor/textsplit

The command cloned the library into vendor/textsplit and staged two things. First, .gitmodules and the copy of the URL in the local configuration:

$ cat .gitmodules
[submodule "vendor/textsplit"]
	path = vendor/textsplit
	url = ../textsplit.git
$ git config list --local | grep ^submodule
submodule.vendor/textsplit.url=$LAB/ch23/submodule-add/remotes/textsplit.git
submodule.vendor/textsplit.active=true

The versioned file holds the relative URL ../textsplit.git. A URL that starts with ./ or ../ is resolved against the superproject's own remote, here remotes/doc-qa.git, which gives its sibling remotes/textsplit.git. The local configuration holds the resolved, absolute result. Relative URLs are worth using when both repositories live on the same host: a clone made over HTTPS fetches the submodule over HTTPS, a clone made over SSH uses SSH, and no protocol is written into a versioned file.

Second, the gitlink in the index:

$ git ls-files --stage
100644 9f740c3c709ae97192a6f851e624b01da96b7224 0	.gitmodules
100644 09f9084395224cf2f392b6a549a62c2cdc28d7dd 0	README.md
100644 b5c3d8b6ef195753d164433b23610b16e4fbdb62 0	answer.py
100644 eb72b3ec2e343a1cc0d789f693ab705990af7f35 0	ingest.py
160000 e2166657d4daa7b174a5fb83da9e2f8ecca2c9e4 0	vendor/textsplit
$ git -C vendor/textsplit log --oneline --decorate -1
e216665 (HEAD -> main, origin/main, origin/HEAD) Add overlap between neighbouring chunks

Four blobs with mode 100644 and one entry with mode 160000. Its ID, e216665, is the commit that the library's main pointed at when you ran the command. Nothing in the entry says "main".

$ cat vendor/textsplit/.git
gitdir: ../../.git/modules/vendor/textsplit
$ ls .git/modules/vendor/textsplit
config
description
HEAD
hooks
index
info
logs
objects
packed-refs
refs
$ git -C vendor/textsplit rev-parse --git-dir
$LAB/ch23/submodule-add/doc-qa/.git/modules/vendor/textsplit
$ git -C vendor/textsplit config get core.worktree
../../../../vendor/textsplit

The submodule's working tree has a .git file, the same device a linked worktree uses (Chapter 25), and the real Git directory sits under the superproject's .git/modules/. Keeping it there means the repository survives when the working tree directory disappears, for example when you switch to a commit of the superproject from before the submodule existed.

$ git diff --cached -- vendor/textsplit
diff --git a/vendor/textsplit b/vendor/textsplit
new file mode 160000
index 0000000..e216665
--- /dev/null
+++ b/vendor/textsplit
@@ -0,0 +1 @@
+Subproject commit e2166657d4daa7b174a5fb83da9e2f8ecca2c9e4

To the superproject a submodule is a one-line file whose content is Subproject commit <ID>. That is how every later change to it appears in diffs and in review.

$ git commit -m "Vendor textsplit as a submodule"
[main bf751ba] Vendor textsplit as a submodule
 2 files changed, 4 insertions(+)
 create mode 100644 .gitmodules
 create mode 160000 vendor/textsplit
$ git ls-tree HEAD
100644 blob 9f740c3c709ae97192a6f851e624b01da96b7224	.gitmodules
100644 blob 09f9084395224cf2f392b6a549a62c2cdc28d7dd	README.md
100644 blob b5c3d8b6ef195753d164433b23610b16e4fbdb62	answer.py
100644 blob eb72b3ec2e343a1cc0d789f693ab705990af7f35	ingest.py
040000 tree 341fc987ffe5b926508788b966cbd3b8e0f19927	vendor
$ git ls-tree HEAD vendor/
160000 commit e2166657d4daa7b174a5fb83da9e2f8ecca2c9e4	vendor/textsplit
$ git submodule status
 e2166657d4daa7b174a5fb83da9e2f8ecca2c9e4 vendor/textsplit (v0.1.0-1-ge216665)

git ls-tree prints the type commit for the entry. git submodule status prints the checked-out commit, the path, and the output of git describe for it. And the point that the rest of the chapter depends on:

# The tree entry names a commit, but the commit object is not in this repository:
$ git cat-file -t HEAD:vendor/textsplit
fatal: git cat-file: could not get object info
[exit status: 128]
# It lives in the object database of the submodule:
$ git -C vendor/textsplit cat-file -t HEAD
commit
$ git -C vendor/textsplit rev-parse HEAD
e2166657d4daa7b174a5fb83da9e2f8ecca2c9e4

The superproject's object database does not contain commit e216665. A tree entry of mode 160000 is the only place in Git where an object ID names something that the repository is not required to have. Git does not check that the ID exists anywhere when you commit it or when you push it. Everything that makes the entry useful (where to get the object, whether it has been fetched, whether it is checked out) is arranged outside the commit.

Picture.

  doc-qa  (superproject)                                   textsplit  (its own repository)
  +---------------------------------------------+          +----------------------------------+
  | commit bf751ba                              |          |  ceaafe1 --- e216665   main      |
  |   tree                                      |          |  (v0.1.0)                        |
  |     100644 blob  .gitmodules  ----------------- url -->|  remotes/textsplit.git           |
  |     100644 blob  ingest.py                  |          +----------------------------------+
  |     040000 tree  vendor                     |                         ^
  |       160000 commit e216665  textsplit  ------- "check this out" ------+
  |                                             |
  | .git/config        submodule.vendor/textsplit.url = <absolute URL>   (local, not versioned)
  | .git/modules/vendor/textsplit/              the clone of textsplit    (local, not versioned)
  | vendor/textsplit/.git   (file) -> ../../.git/modules/vendor/textsplit
  +---------------------------------------------+

In production. A team keeps prompt templates, a tokenizer vocabulary or protocol definitions in one repository that five services consume. A submodule gives each service an exact, reviewable pin: the pull request that moves the pin shows -Subproject commit and +Subproject commit, and a release of the service is reproducible from one commit ID of the superproject. The price is the set of failures below.

State table for git submodule add <url> <path>:

Working treeIndexHEADCurrent branch refOther refs and files in .gitRemoteGitHub
<path>/ created and populated; .gitmodules created or extended.gitmodules and a gitlink stagedunchangedunchanged.git/modules/<name>/ created (a clone); submodule.<name>.url and .active in .git/configunchanged (a fetch from the submodule's remote)unchanged

23.3 What a teammate receives: clone, init, update

A plain clone transfers the superproject's objects. The gitlink arrives; the commit it names does not.

$ git clone remotes/doc-qa.git ravi-doc-qa
Cloning into 'ravi-doc-qa'...
done.
$ cd ravi-doc-qa
$ ls -A vendor/textsplit
$ git status
On branch main
Your branch is up to date with 'origin/main'.

nothing to commit, working tree clean
$ git submodule status
-e2166657d4daa7b174a5fb83da9e2f8ecca2c9e4 vendor/textsplit

Read the three outputs together. The directory exists and is empty. git status says "nothing to commit, working tree clean": an uninitialized submodule is not a modification, so nothing warns Ravi. Only git submodule status tells him, with the leading -, that the submodule is not initialized. This is report 1 from section 23.1.

Two steps fill the directory. 🟢 git submodule init registers the submodule: it copies the URL into .git/config.

$ git config get submodule.vendor/textsplit.url
[exit status: 1]
$ git submodule init
Submodule 'vendor/textsplit' ($LAB/ch23/submodule-clone/remotes/textsplit.git) registered for path 'vendor/textsplit'
$ git config list --local | grep ^submodule
submodule.vendor/textsplit.active=true
submodule.vendor/textsplit.url=$LAB/ch23/submodule-clone/remotes/textsplit.git

🟡 git submodule update then clones the repository into .git/modules/, and checks out the recorded commit:

$ git -c protocol.file.allow=always submodule update
Cloning into '$LAB/ch23/submodule-clone/ravi-doc-qa/vendor/textsplit'...
done.
Submodule path 'vendor/textsplit': checked out 'e2166657d4daa7b174a5fb83da9e2f8ecca2c9e4'
$ ls -A vendor/textsplit
.git
README.md
splitter.py
$ git submodule status
 e2166657d4daa7b174a5fb83da9e2f8ecca2c9e4 vendor/textsplit (v0.1.0-1-ge216665)

git submodule update --init does both steps, and --recursive repeats them for submodules inside submodules. For a fresh clone, one command does everything:

$ git -c protocol.file.allow=always clone --recurse-submodules remotes/doc-qa.git asha-doc-qa
Cloning into 'asha-doc-qa'...
done.
Submodule 'vendor/textsplit' ($LAB/ch23/submodule-clone/remotes/textsplit.git) registered for path 'vendor/textsplit'
Cloning into '$LAB/ch23/submodule-clone/asha-doc-qa/vendor/textsplit'...
done.
Submodule path 'vendor/textsplit': checked out 'e2166657d4daa7b174a5fb83da9e2f8ecca2c9e4'
$ git -C asha-doc-qa submodule status
 e2166657d4daa7b174a5fb83da9e2f8ecca2c9e4 vendor/textsplit (v0.1.0-1-ge216665)

The table of prefixes that git submodule status prints is worth memorising, because it is the fastest diagnosis in this chapter:

PrefixMeaning
(space)Initialized, and the checked-out commit equals the recorded one
-Not initialized
+The checked-out commit differs from the one recorded in the superproject's index
UThe submodule path has a merge conflict in the superproject

23.4 Why the lab needs protocol.file.allow, and what that says about security

Without the extra option, the very first command of this chapter fails:

$ git remote -v
origin	$LAB/ch23/submodule-add/remotes/doc-qa.git (fetch)
origin	$LAB/ch23/submodule-add/remotes/doc-qa.git (push)
$ git submodule add ../textsplit.git vendor/textsplit
Cloning into '$LAB/ch23/submodule-add/doc-qa/vendor/textsplit'...
fatal: transport 'file' not allowed
fatal: clone of '$LAB/ch23/submodule-add/remotes/textsplit.git' into submodule path '$LAB/ch23/submodule-add/doc-qa/vendor/textsplit' failed
[exit status: 128]

So does a recursive clone, at the moment it starts cloning the submodule by itself:

$ cd ..
$ git clone --recurse-submodules remotes/doc-qa.git asha-doc-qa
Cloning into 'asha-doc-qa'...
done.
Submodule 'vendor/textsplit' ($LAB/ch23/submodule-clone/remotes/textsplit.git) registered for path 'vendor/textsplit'
Cloning into '$LAB/ch23/submodule-clone/asha-doc-qa/vendor/textsplit'...
fatal: transport 'file' not allowed
fatal: clone of '$LAB/ch23/submodule-clone/remotes/textsplit.git' into submodule path '$LAB/ch23/submodule-clone/asha-doc-qa/vendor/textsplit' failed
Failed to clone 'vendor/textsplit'. Retry scheduled
Cloning into '$LAB/ch23/submodule-clone/asha-doc-qa/vendor/textsplit'...
fatal: transport 'file' not allowed
fatal: clone of '$LAB/ch23/submodule-clone/remotes/textsplit.git' into submodule path '$LAB/ch23/submodule-clone/asha-doc-qa/vendor/textsplit' failed
Failed to clone 'vendor/textsplit' a second time, aborting
[exit status: 1]
# The clone of the superproject worked; only the clone that Git started by itself was refused.
$ git -C asha-doc-qa submodule status
-e2166657d4daa7b174a5fb83da9e2f8ecca2c9e4 vendor/textsplit
# The default policy for the file transport is "user". This variable is how Git marks
# a transfer that the user did not type:
$ GIT_PROTOCOL_FROM_USER=0 git clone remotes/textsplit.git probe
Cloning into 'probe'...
fatal: transport 'file' not allowed
[exit status: 128]

Mechanism. Git has a per-transport policy, protocol.<name>.allow, with three values: always, never and user. The local manual gives the defaults: http, https, git and ssh are always; ext is never; everything else, the file transport included, is user. Under user a transport may be used only when the environment variable GIT_PROTOCOL_FROM_USER is unset or 1, that is, when a person typed the clone, fetch or push. The git submodule command sets the variable to 0 before it does anything (it is the second statement of the git-submodule script in Git's exec path), because the URLs it will use come from .gitmodules, a file written by whoever authored the repository. A recursive clone runs the same code. The second half of the last transcript reproduces the refusal with an ordinary git clone and that variable.

Why the default is what it is. The value user for file dates from the fix for CVE-2022-39253, shipped in Git 2.38.1 and the maintenance releases of the same day. The release notes describe two changes: local clones no longer dereference symbolic links, and "the value of protocol.file.allow is changed to be "user" by default" (release notes 2.38.1 (external site), protocol configuration (external site)). The idea behind the policy is in the manual's wording: a protocol that is fine when you name it yourself should not be usable "by commands which execute clone/fetch/push commands without user input, e.g. recursive submodule initialization".

What the lab does. The lab's "servers" are bare repositories in a directory, so the submodule URL is a file path. Each command that makes Git clone or fetch a submodule on its own initiative is therefore run with -c protocol.file.allow=always, for that one command. Nothing is written to any configuration file. With a real remote over HTTPS or SSH you never need the option.

Outdated advice. "Run git config --global protocol.file.allow always to fix submodule errors." That switches a security default off for every repository you will ever clone. Use -c for a single command on repositories you created yourself, and leave the setting alone otherwise. The Phase 0 report's rule set says the same.

The wider security note. A recursive clone is the one common Git operation in which a repository you have not inspected decides what else gets cloned and where it is written. That is where the recent client vulnerabilities have been:

CVEFixed inWhat a recursive clone of a hostile repository could do
CVE-2024-32002 (critical)2.45.1 and backports, May 2024On a case-insensitive filesystem with symbolic links (the default on macOS), write a hook into .git/ and run it during the clone (advisory (external site))
CVE-2025-48384 (high)2.50.1 and backports, July 2025Through a submodule path ending in a carriage return, check content out to an unintended location where a hook could run (advisory (external site))

Both are fixed in the Git 2.55.0 this book uses and in Apple's 2.50.1. The second advisory's own workaround is the durable rule: do not recursively clone submodules of repositories you do not trust. Clone without recursion, read .gitmodules, and then decide. git submodule init also refuses, for the same reason, to copy a submodule.<name>.update setting that is a custom command from .gitmodules into your configuration.

23.5 The detached HEAD after update, and why it is correct

$ git -C vendor/textsplit status
HEAD detached at e216665
nothing to commit, working tree clean
$ git -C vendor/textsplit branch --all
* (HEAD detached at e216665)
  main
  remotes/origin/HEAD -> origin/main
  remotes/origin/main

Why. The superproject asked for commit e216665. It did not ask for a branch, and it cannot: the gitlink has no room for one. git submodule update therefore does what git switch --detach <commit> does. A local main exists in the submodule because the clone created it; HEAD is not on it. This is the detached HEAD of Chapter 7, reached by one more route, and it is the correct state for a dependency you only consume.

It becomes a trap the moment you edit the library in place. Ravi fixes something inside vendor/textsplit and commits:

$ cd vendor/textsplit
$ git status --short --branch
## HEAD (no branch)
$ git commit -am "Add line splitter"
[detached HEAD 679aa20] Add line splitter
 1 file changed, 5 insertions(+)
$ git log --oneline --decorate -2
679aa20 (HEAD) Add line splitter
e216665 (origin/main, origin/HEAD, main) Add overlap between neighbouring chunks
$ cd ../..

The commit 679aa20 is on no branch. Meanwhile you have moved the pointer to the library's 0.2.0 release and pushed. Ravi pulls and updates:

$ git pull
From $LAB/ch23/submodule-detached/remotes/doc-qa
   907dbd3..3719d28  main       -> origin/main
Fetching submodule vendor/textsplit
From $LAB/ch23/submodule-detached/remotes/textsplit
   e216665..b3c86ce  main       -> origin/main
 * [new tag]         v0.2.0     -> v0.2.0
Updating 907dbd3..3719d28
Fast-forward
 vendor/textsplit | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
$ git submodule update
Submodule path 'vendor/textsplit': checked out 'b3c86ceee1901d9cfff1a21920bd863a64ffb738'
$ git -C vendor/textsplit log --oneline --decorate -2
b3c86ce (HEAD, tag: v0.2.0, origin/main, origin/HEAD) Reject an overlap that is not smaller than the chunk size
e216665 (main) Add overlap between neighbouring chunks
# Which branch, local or remote-tracking, contains the commit Ravi made? None:
$ git -C vendor/textsplit branch --all --contains 679aa20

git submodule update checked out the commit that the superproject now records and said nothing about the commit it left behind. No branch contains it. It is not lost: the submodule is a repository with its own reflog (Chapter 13).

# No branch ever pointed at the commit. The submodule has a reflog of its own:
$ git -C vendor/textsplit reflog -3
b3c86ce HEAD@{0}: checkout: moving from 679aa200d3e14163fc2fc5764cdd69de4c189dbd to b3c86ceee1901d9cfff1a21920bd863a64ffb738
679aa20 HEAD@{1}: commit: Add line splitter
e216665 HEAD@{2}: checkout: moving from main to e2166657d4daa7b174a5fb83da9e2f8ecca2c9e4
$ git -C vendor/textsplit branch line-splitter 'HEAD@{1}'
$ git -C vendor/textsplit log --oneline --decorate -1 line-splitter
679aa20 (line-splitter) Add line splitter

The prevention is a habit: before you change anything inside a submodule, get on a branch there.

# The way to work in a submodule: get on a branch first.
$ git -C vendor/textsplit switch line-splitter
Previous HEAD position was b3c86ce Reject an overlap that is not smaller than the chunk size
Switched to branch 'line-splitter'
$ git -C vendor/textsplit status --short --branch
## line-splitter
$ git status --short
 M vendor/textsplit
$ git submodule status
+679aa200d3e14163fc2fc5764cdd69de4c189dbd vendor/textsplit (v0.1.0-2-g679aa20)

With the submodule on a branch, git submodule update --merge or --rebase integrates the recorded commit into that branch and leaves HEAD attached; submodule.<name>.update makes either the default for one submodule. They are for people who develop the library inside the superproject. For consumers, the default checkout is right.

23.6 Moving the pointer: update --remote, status, diff, commit

Asha has released textsplit 0.2.0. Nothing changes in doc-qa until someone moves the gitlink. 🟡 git submodule update --remote fetches in the submodule and checks out the tip of its remote-tracking branch instead of the recorded commit:

$ git submodule status
 e2166657d4daa7b174a5fb83da9e2f8ecca2c9e4 vendor/textsplit (v0.1.0-1-ge216665)
$ git -c protocol.file.allow=always submodule update --remote
From $LAB/ch23/submodule-update/remotes/textsplit
   e216665..b3c86ce  main       -> origin/main
 * [new tag]         v0.2.0     -> v0.2.0
Submodule path 'vendor/textsplit': checked out 'b3c86ceee1901d9cfff1a21920bd863a64ffb738'
$ git submodule status
+b3c86ceee1901d9cfff1a21920bd863a64ffb738 vendor/textsplit (v0.2.0)

Which branch? By default the one the remote's HEAD points at. git submodule set-branch --branch <name> <path> records another in .gitmodules as submodule.<name>.branch, and git submodule add -b <name> does so at creation. That setting is read by update --remote and by nothing else: it does not make the submodule "follow" a branch, and the gitlink is still a commit ID.

The submodule's working tree is now at b3c86ce, the superproject still records e216665, and git submodule status marks the difference with +. The superproject sees a modified one-line file:

$ git status
On branch main
Your branch is up to date with 'origin/main'.

Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
	modified:   vendor/textsplit (new commits)

no changes added to commit (use "git add" and/or "git commit -a")
$ git diff
diff --git a/vendor/textsplit b/vendor/textsplit
index e216665..b3c86ce 160000
--- a/vendor/textsplit
+++ b/vendor/textsplit
@@ -1 +1 @@
-Subproject commit e2166657d4daa7b174a5fb83da9e2f8ecca2c9e4
+Subproject commit b3c86ceee1901d9cfff1a21920bd863a64ffb738
$ git diff --submodule=log
Submodule vendor/textsplit e216665..b3c86ce:
  > Reject an overlap that is not smaller than the chunk size

(new commits) in git status means "the submodule's HEAD differs from the gitlink in the index". git diff --submodule=log replaces the two IDs by the list of commits between them, which is what a reviewer wants to read; git config set diff.submodule log makes it the default.

Recording the new pointer is an ordinary add and commit:

$ git add vendor/textsplit
$ git commit -m "Update textsplit to 0.2.0"
[main 0d51dbc] Update textsplit to 0.2.0
 1 file changed, 1 insertion(+), 1 deletion(-)
$ git push origin main
To $LAB/ch23/submodule-update/remotes/doc-qa.git
   907dbd3..0d51dbc  main -> main

State table for the two commands of this section:

CommandWorking treeIndexHEADCurrent branch refOther refs and files in .gitRemoteGitHub
git submodule update (with or without --remote)Files inside the submodule path change; the superproject's own files do notunchangedunchanged; the submodule's HEAD is detached at the target commitunchanged.git/modules/<name>/: new objects after a fetch, its HEAD and HEAD reflogunchanged (fetch only)unchanged
git add <path> then git commitunchangedgitlink updatednew commitadvancedreflogsunchanged until pushedunchanged until pushed

Treat the resulting commit like any dependency bump: one purpose, its own pull request, CI on the result.

23.7 Keeping clones in step: the stale submodule and the silent rollback

You pushed the pointer change. Ravi pulls:

$ cd ../ravi-doc-qa
$ git pull
From $LAB/ch23/submodule-update/remotes/doc-qa
   907dbd3..0d51dbc  main       -> origin/main
Fetching submodule vendor/textsplit
From $LAB/ch23/submodule-update/remotes/textsplit
   e216665..b3c86ce  main       -> origin/main
 * [new tag]         v0.2.0     -> v0.2.0
Updating 907dbd3..0d51dbc
Fast-forward
 vendor/textsplit | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
$ git status
On branch main
Your branch is up to date with 'origin/main'.

Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
	modified:   vendor/textsplit (new commits)

no changes added to commit (use "git add" and/or "git commit -a")

git pull fetched the superproject, noticed that the new commits change a gitlink, and fetched inside the submodule as well ("Fetching submodule vendor/textsplit"; this on-demand fetch is the default of fetch.recurseSubmodules). Then it fast-forwarded the superproject. It did not check out the new commit in the submodule. Ravi made no change, and git status reports one:

$ git submodule status
+e2166657d4daa7b174a5fb83da9e2f8ecca2c9e4 vendor/textsplit (v0.1.0-1-ge216665)
$ git diff --submodule=log
Submodule vendor/textsplit b3c86ce..e216665 (rewind):
  < Reject an overlap that is not smaller than the chunk size

The + and the word (rewind) are the evidence. The superproject now records b3c86ce; the submodule's working tree is still at e216665. Seen from the superproject, the working tree differs from the index by going one library commit backwards.

Observed behavior : After a pull that changed nothing of his own, git status shows
                    "modified: vendor/textsplit (new commits)".
Git state         : HEAD and index of the superproject record gitlink b3c86ce. The submodule's HEAD is
                    still e216665. The library commit b3c86ce has been fetched into .git/modules/.
Mechanism         : pull = fetch + merge. The merge updates the gitlink in the index. Checking out a
                    superproject commit does not move the HEAD of a submodule unless recursion is on.
Root cause        : Two repositories, two HEADs, and no default that ties the second to the first.
Why Git does this : The submodule may hold work in progress; Git will not move it without being asked.
Correct fix       : git submodule update      (then git status is clean)
Prevention        : git config set submodule.recurse true, or git pull --recurse-submodules.

If Ravi does not look closely, the stale state turns into a commit. He edits one file and uses git commit -a, which stages everything that is modified:

# Ravi does not look closely. He edits a file and commits everything that is modified:
$ git commit -am "Use larger chunks"
[main b98f829] Use larger chunks
 2 files changed, 2 insertions(+), 2 deletions(-)
$ git show --stat --format="%h %an: %s"
b98f829 Ravi Menon: Use larger chunks

 ingest.py        | 2 +-
 vendor/textsplit | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)
$ git ls-tree HEAD vendor/
160000 commit e2166657d4daa7b174a5fb83da9e2f8ecca2c9e4	vendor/textsplit

Two files changed. His commit moved the gitlink back to e216665. This is report 3 from section 23.1, and after a push it is report 4 for everyone else: the service silently runs the old library again. A reviewer sees it only as one changed line, vendor/textsplit | 2 +-, in a commit that claims to be about chunk sizes.

The commit has not been pushed, so it can be repaired in place: check out the right library commit, stage the gitlink, amend.

# Not pushed yet, so repair the commit: put the submodule on the commit that main records
# upstream, stage it, amend.
$ git -C vendor/textsplit checkout --quiet $(git rev-parse origin/main:vendor/textsplit)
$ git add vendor/textsplit
$ git commit --amend --no-edit
[main 2b86667] Use larger chunks
 Date: Mon Sep 7 10:35:00 2026 +0530
 1 file changed, 1 insertion(+), 1 deletion(-)
$ git show --stat --format="%h %an: %s"
2b86667 Ravi Menon: Use larger chunks

 ingest.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
$ git submodule status
 b3c86ceee1901d9cfff1a21920bd863a64ffb738 vendor/textsplit (v0.2.0)

origin/main:vendor/textsplit is the gitlink as the remote-tracking branch records it; git rev-parse returns the ID even though the object is not in the superproject. If the bad commit had already been pushed, the fix is a new commit that moves the pointer forward again.

The cure for the whole section is to make the second step automatic. After a pull, the manual sequence is two commands:

$ git pull
From $LAB/ch23/submodule-recurse/remotes/doc-qa
   907dbd3..3719d28  main       -> origin/main
Fetching submodule vendor/textsplit
From $LAB/ch23/submodule-recurse/remotes/textsplit
   e216665..b3c86ce  main       -> origin/main
 * [new tag]         v0.2.0     -> v0.2.0
Updating 907dbd3..3719d28
Fast-forward
 vendor/textsplit | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
$ git submodule status
+e2166657d4daa7b174a5fb83da9e2f8ecca2c9e4 vendor/textsplit (v0.1.0-1-ge216665)
$ git submodule update
Submodule path 'vendor/textsplit': checked out 'b3c86ceee1901d9cfff1a21920bd863a64ffb738'
$ git submodule status
 b3c86ceee1901d9cfff1a21920bd863a64ffb738 vendor/textsplit (v0.2.0)
$ git status --short --branch
## main...origin/main

The same staleness appears with every command that moves the superproject's HEAD:

# An older commit of doc-qa records the older library commit. Without recursion:
$ git switch --detach HEAD~1
HEAD is now at 907dbd3 Vendor textsplit as a submodule and chunk documents
M	vendor/textsplit
$ git submodule status
+b3c86ceee1901d9cfff1a21920bd863a64ffb738 vendor/textsplit (v0.2.0)
$ git switch -
Previous HEAD position was 907dbd3 Vendor textsplit as a submodule and chunk documents
Switched to branch 'main'
Your branch is up to date with 'origin/main'.
$ git status --short

With submodule.recurse set, checkout, fetch, grep, pull, push, read-tree, reset, restore and switch behave as if --recurse-submodules were given (the list is from git help -m config), and the submodule follows:

$ git config set submodule.recurse true
$ git switch --detach HEAD~1
HEAD is now at 907dbd3 Vendor textsplit as a submodule and chunk documents
$ git submodule status
 e2166657d4daa7b174a5fb83da9e2f8ecca2c9e4 vendor/textsplit (v0.1.0-1-ge216665)
$ git switch -
Previous HEAD position was 907dbd3 Vendor textsplit as a submodule and chunk documents
Switched to branch 'main'
Your branch is up to date with 'origin/main'.
$ git submodule status
 b3c86ceee1901d9cfff1a21920bd863a64ffb738 vendor/textsplit (v0.2.0)
$ git config get submodule.recurse
true

git clone is the exception: it needs its own --recurse-submodules, and so does git ls-files. The manual's workflow section recommends submodule.recurse true for repositories that are split into submodules (gitsubmodules (external site)). The setting is local or global configuration, so it cannot be shipped with the repository: every clone, and every CI job, has to set it or pass the option.

23.8 Push order: the commit your teammate cannot fetch

You improve the library from inside the service. You are on the submodule's main, as section 23.5 advised:

$ cd vendor/textsplit
$ git switch main
Already on 'main'
Your branch is up to date with 'origin/main'.
$ git commit -am "Add line splitter"
[main 17d7857] Add line splitter
 1 file changed, 5 insertions(+)
$ cd ../..
$ git status --short
 M vendor/textsplit
$ git commit -am "Use the line splitter from textsplit"
[main ee30f63] Use the line splitter from textsplit
 1 file changed, 1 insertion(+), 1 deletion(-)

There are now two unpushed commits in two repositories: 17d7857 in the library and ee30f63 in the superproject, whose tree names 17d7857. Pushing the superproject alone is accepted, because a gitlink is only an ID and the server does not check it:

# Without the guard, the push of the superproject succeeds:
$ git push origin main
To $LAB/ch23/submodule-push-order/remotes/doc-qa.git
   907dbd3..ee30f63  main -> main

Ravi pulls:

$ cd ../ravi-doc-qa
$ git pull
From $LAB/ch23/submodule-push-order/remotes/doc-qa
   907dbd3..ee30f63  main       -> origin/main
Fetching submodule vendor/textsplit
fatal: git upload-pack: not our ref 17d78577459b6c2e681d54d4cfac2b350aaf7b2c
fatal: remote error: upload-pack: not our ref 17d78577459b6c2e681d54d4cfac2b350aaf7b2c
Errors during submodule fetch:
	vendor/textsplit
[exit status: 1]

Root cause. The superproject on the server records library commit 17d7857. That commit exists in exactly one place: .git/modules/vendor/textsplit on your machine. The server-side git upload-pack of the library was asked for it by ID and answered not our ref.

In this transcript two processes report the same refusal: the client (remote error: upload-pack: not our ref) and git upload-pack itself, which runs on your own machine when the remote is a path. Their two lines can appear in either order, which is why labs/verify-all.sh treats this demo as volatile. Over HTTPS or SSH you see only the remote error line.

Note what state the failure left. The fetch of the superproject succeeded and the merge did not run:

$ git status --short --branch
## main...origin/main [behind 1]
$ git ls-tree origin/main vendor/
160000 commit 17d78577459b6c2e681d54d4cfac2b350aaf7b2c	vendor/textsplit
$ git ls-remote ../remotes/textsplit.git
e2166657d4daa7b174a5fb83da9e2f8ecca2c9e4	HEAD
e2166657d4daa7b174a5fb83da9e2f8ecca2c9e4	refs/heads/main
f8fdba471fcee4fa0c9a10d59be41c10c86f79ec	refs/tags/v0.1.0
ceaafe1cb2284284c7aa91a8ac843268d1dad25e	refs/tags/v0.1.0^{}
$ git -C vendor/textsplit cat-file -t 17d7857
fatal: Not a valid object name 17d7857
[exit status: 128]
$ git -C ../doc-qa/vendor/textsplit branch --all --contains 17d7857
* main

The diagnosis in three questions. Which library commit does the superproject want? git ls-tree origin/main vendor/. Does the library's shared repository have it? git ls-remote shows main still at e216665, and no ref leads to 17d7857. Who has it? The author of the superproject commit, on a local branch.

The fix is on the author's side: push the library commit. Then the teammate's commands work:

$ cd ../doc-qa
$ git -C vendor/textsplit push origin main
To $LAB/ch23/submodule-push-order/remotes/textsplit.git
   e216665..17d7857  main -> main
$ cd ../ravi-doc-qa
$ git pull
Updating 907dbd3..ee30f63
Fast-forward
 vendor/textsplit | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
$ git -c protocol.file.allow=always submodule update
From $LAB/ch23/submodule-push-order/remotes/textsplit
   e216665..17d7857  main       -> origin/main
Submodule path 'vendor/textsplit': checked out '17d78577459b6c2e681d54d4cfac2b350aaf7b2c'
$ git submodule status
 17d78577459b6c2e681d54d4cfac2b350aaf7b2c vendor/textsplit (v0.1.0-2-g17d7857)

Git has a guard for this, and it is off by default. 🟢 git push --recurse-submodules=check verifies, before anything is sent, that every submodule commit named by the commits being pushed is reachable from a remote-tracking branch of that submodule:

$ git push --recurse-submodules=check origin main
The following submodule paths contain changes that can
not be found on any remote:
  vendor/textsplit

Please try

	git push --recurse-submodules=on-demand

or cd to the path and use

	git push

to push them to a remote.

fatal: Aborting.
fatal: the remote end hung up unexpectedly
[exit status: 128]

--recurse-submodules=on-demand goes one step further and pushes the submodules first:

$ cd ../doc-qa
$ git -C vendor/textsplit commit -am "Add sentence splitter"
[main 0dd831a] Add sentence splitter
 1 file changed, 4 insertions(+)
$ git commit -am "Use the sentence splitter from textsplit"
[main 9b2d81f] Use the sentence splitter from textsplit
 1 file changed, 1 insertion(+), 1 deletion(-)
$ git push --recurse-submodules=on-demand origin main
Pushing submodule 'vendor/textsplit'
To $LAB/ch23/submodule-push-order/remotes/textsplit.git
   17d7857..0dd831a  main -> main
To $LAB/ch23/submodule-push-order/remotes/doc-qa.git
   ee30f63..9b2d81f  main -> main
$ git config set push.recurseSubmodules check
$ git config get push.recurseSubmodules
check

push.recurseSubmodules=check is the setting to put in every developer's configuration for a repository with submodules. Its limits: check trusts your remote-tracking branches, so it is satisfied by a commit that sits on a library branch you pushed and that someone later deletes; and on-demand needs push permission on the library, which consumers often do not have. In that case the library change goes through the library's own review first, and the superproject bumps the pointer afterwards.

23.9 Dirty submodules and switching branches

A submodule's working tree can have uncommitted changes of its own. The superproject reports them and cannot record them:

$ git status
On branch main
Your branch is up to date with 'origin/main'.

Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
  (commit or discard the untracked or modified content in submodules)
	modified:   vendor/textsplit (modified content, untracked content)

no changes added to commit (use "git add" and/or "git commit -a")
$ git submodule status
 e2166657d4daa7b174a5fb83da9e2f8ecca2c9e4 vendor/textsplit (v0.1.0-1-ge216665)
$ git diff
diff --git a/vendor/textsplit b/vendor/textsplit
--- a/vendor/textsplit
+++ b/vendor/textsplit
@@ -1 +1 @@
-Subproject commit e2166657d4daa7b174a5fb83da9e2f8ecca2c9e4
+Subproject commit e2166657d4daa7b174a5fb83da9e2f8ecca2c9e4-dirty
$ git diff --submodule=diff
Submodule vendor/textsplit contains modified content
diff --git a/vendor/textsplit/splitter.py b/vendor/textsplit/splitter.py
index c0dea76..6f4648f 100644
--- a/vendor/textsplit/splitter.py
+++ b/vendor/textsplit/splitter.py
@@ -2,3 +2,4 @@ def split(text, size=200, overlap=0):
     """Cut text into pieces of at most `size` characters; neighbours share `overlap` characters."""
     step = size - overlap
     return [text[i:i + size] for i in range(0, len(text), step)]
+# debug: print every chunk

(modified content, untracked content) means the submodule's HEAD is the recorded commit and its working tree is not clean; git submodule status therefore shows no +. The diff appends -dirty to the ID. A gitlink can only name a commit, so there is nothing for the superproject to stage:

$ git commit -am "Save my work"
On branch main
Your branch is up to date with 'origin/main'.

Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
  (commit or discard the untracked or modified content in submodules)
	modified:   vendor/textsplit (modified content, untracked content)

no changes added to commit (use "git add" and/or "git commit -a")
[exit status: 1]

The work has to be committed inside the submodule first (on a branch), and then the new commit ID can be staged in the superproject. To look into all submodules at once, git submodule foreach runs a shell command in each. The lower-case m and ? in short status are the superproject's markers for modified and untracked content inside a submodule, and --ignore-submodules chooses how much of that is shown:

$ git submodule foreach "git status --short"
Entering 'vendor/textsplit'
 M splitter.py
?? notes.txt
$ git status --short
 m vendor/textsplit
$ git status --short --ignore-submodules=untracked
 m vendor/textsplit
$ git status --short --ignore-submodules=dirty

To get back to exactly the recorded state, 🔴 git submodule update --force checks the files out again and discards edits to tracked files; untracked files need git clean inside the submodule (Chapter 4), previewed with -n:

# update does nothing: the checked-out commit already is the recorded one.
$ git submodule update
$ git status --short
 m vendor/textsplit
# --force checks the files out again, which discards the edit to the tracked file:
$ git submodule update --force
Submodule path 'vendor/textsplit': checked out 'e2166657d4daa7b174a5fb83da9e2f8ecca2c9e4'
$ git status --short
 ? vendor/textsplit
$ git -C vendor/textsplit clean -n
Would remove notes.txt
$ git -C vendor/textsplit clean -f
Removing notes.txt
$ git status --short

Switching to a commit without the submodule leaves its directory behind, because Git will not delete a directory that contains a repository's working tree:

$ git log --oneline
907dbd3 Vendor textsplit as a submodule and chunk documents
bf78eb9 Add keyword answerer
1d93b09 Add document loader
$ git switch --detach HEAD~1
warning: unable to rmdir 'vendor/textsplit': Directory not empty
HEAD is now at bf78eb9 Add keyword answerer
$ git status
HEAD detached at bf78eb9
Untracked files:
  (use "git add <file>..." to include in what will be committed)
	vendor/

nothing added to commit but untracked files present (use "git add" to track)
$ ls -A vendor/textsplit
.git
README.md
splitter.py

The stray vendor/ is untracked on that commit. A careless git add -A there stages it as a new gitlink, with the warning adding embedded git repository: vendor/textsplit, and git clean -f -d answers Skipping repository vendor/textsplit (both run for this chapter). With recursion, the checkout removes and restores the submodule's working tree together with the superproject's files; the repository itself stays in .git/modules/ either way:

$ git switch --recurse-submodules --detach HEAD~1
HEAD is now at bf78eb9 Add keyword answerer
$ git status --short
$ ls vendor 2>&1
ls: vendor: No such file or directory
$ git switch --recurse-submodules main
Previous HEAD position was bf78eb9 Add keyword answerer
Switched to branch 'main'
Your branch is up to date with 'origin/main'.
$ git submodule status
 e2166657d4daa7b174a5fb83da9e2f8ecca2c9e4 vendor/textsplit (v0.1.0-1-ge216665)

23.10 Pointer conflicts

Two branches of doc-qa move the gitlink. What a merge does depends on how the two library commits relate, and Git can look that up only in the submodule's repository.

$ git log --graph --oneline --decorate --all
* 7a1b409 (HEAD -> use-tokens) Use the token splitter of textsplit
| * e5a77ed (use-release) Use textsplit 0.2.0
|/  
* 907dbd3 (origin/main, main) Vendor textsplit as a submodule and chunk documents
* bf78eb9 Add keyword answerer
* 1d93b09 Add document loader
$ git ls-tree use-release vendor/
160000 commit de689e20c2a64f6ebe2a0f3ec7c98efc09459cb9	vendor/textsplit
$ git ls-tree use-tokens vendor/
160000 commit cd2a8a45f5da8ab7bfedfe413228a50b91874c72	vendor/textsplit
$ git -C vendor/textsplit log --graph --oneline --decorate v0.2.0 origin/feature/tokens
* de689e2 (tag: v0.2.0, origin/main, origin/HEAD) Reject an overlap that is not smaller than the chunk size
| * cd2a8a4 (HEAD, origin/feature/tokens) Add token splitter
|/  
* e216665 (main) Add overlap between neighbouring chunks
* ceaafe1 (tag: v0.1.0) Add fixed-size splitter

use-release records de689e2 (the 0.2.0 release) and use-tokens records cd2a8a4 (a feature branch of the library). The two library commits have diverged: neither is an ancestor of the other.

$ git merge use-release
hint: Recursive merging with submodules currently only supports trivial cases.
hint: Please manually handle the merging of each conflicted submodule.
hint: This can be accomplished with the following steps:
hint:  - go to submodule (vendor/textsplit), and either merge commit de689e2
hint:    or update to an existing commit which has merged those changes
hint:  - come back to superproject and run:
hint:
hint:       git add vendor/textsplit
hint:
hint:    to record the above merge or update
hint:  - resolve any other conflicts in the superproject
hint:  - commit the resulting index in the superproject
hint:
hint: Disable this message with "git config set advice.submoduleMergeConflict false"
Failed to merge submodule vendor/textsplit
CONFLICT (submodule): Merge conflict in vendor/textsplit
Automatic merge failed; fix conflicts and then commit the result.
[exit status: 1]
$ git status --short
UU vendor/textsplit
$ git ls-files --unmerged
160000 e2166657d4daa7b174a5fb83da9e2f8ecca2c9e4 1	vendor/textsplit
160000 cd2a8a45f5da8ab7bfedfe413228a50b91874c72 2	vendor/textsplit
160000 de689e20c2a64f6ebe2a0f3ec7c98efc09459cb9 3	vendor/textsplit
$ git submodule status
U0000000000000000000000000000000000000000 vendor/textsplit
$ git diff
diff --cc vendor/textsplit
index cd2a8a4,de689e2..0000000
--- a/vendor/textsplit
+++ b/vendor/textsplit

The index has the three stages of Chapter 8, each a gitlink: stage 1 the merge base's pointer, stage 2 ours, stage 3 theirs. There are no conflict markers, because there is no file to put them in. The superproject cannot produce the answer: the correct pointer is a library commit that contains both lines of development, and only the library can create it. Git's hint lists the two ways. One is to merge inside the submodule yourself; that creates a merge commit which exists only in your clone and must be pushed to the library before anyone else can use it (section 23.8). The other, used here, is to wait for the library to merge and then point at the result:

$ git -C vendor/textsplit fetch origin
From $LAB/ch23/submodule-conflict/remotes/textsplit
   de689e2..5a1935a  main       -> origin/main
$ git -C vendor/textsplit log --graph --oneline --decorate -4 origin/main
*   5a1935a (origin/main, origin/HEAD) Merge feature/tokens
|\  
| * cd2a8a4 (HEAD, origin/feature/tokens) Add token splitter
* | de689e2 (tag: v0.2.0) Reject an overlap that is not smaller than the chunk size
|/  
* e216665 (main) Add overlap between neighbouring chunks
$ git -C vendor/textsplit checkout --quiet origin/main
$ git add vendor/textsplit
$ git status --short
M  vendor/textsplit
$ git commit --no-edit
[use-tokens c927444] Merge branch 'use-release' into use-tokens
$ git ls-tree HEAD vendor/
160000 commit 5a1935ae4e615fa75f14284cab1eb1906c8cdf33	vendor/textsplit
$ git submodule status
 5a1935ae4e615fa75f14284cab1eb1906c8cdf33 vendor/textsplit (v0.2.0-2-g5a1935a)

git add vendor/textsplit stages whatever commit the submodule has checked out, which resolves the path; git commit concludes the merge.

When one of the two library commits is an ancestor of the other, Git resolves the pointer by itself and takes the descendant. It says so in one line:

# A new branch from main that records the library merge commit. use-release records v0.2.0,
# an ancestor of that merge. Both branches moved the pointer away from the one main has.
$ git ls-tree main vendor/
160000 commit e2166657d4daa7b174a5fb83da9e2f8ecca2c9e4	vendor/textsplit
$ git ls-tree use-merged vendor/
160000 commit 5a1935ae4e615fa75f14284cab1eb1906c8cdf33	vendor/textsplit
$ git ls-tree use-release vendor/
160000 commit de689e20c2a64f6ebe2a0f3ec7c98efc09459cb9	vendor/textsplit
$ git merge use-release
Note: Fast-forwarding submodule vendor/textsplit to 5a1935ae4e615fa75f14284cab1eb1906c8cdf33
Merge made by the 'ort' strategy.
[exit status: 0]
$ git ls-tree HEAD vendor/
160000 commit 5a1935ae4e615fa75f14284cab1eb1906c8cdf33	vendor/textsplit

That automatic resolution needs both commits to be present in the submodule's local repository. In a clone where the submodule is not checked out, Git cannot determine ancestry and reports Failed to merge submodule vendor/textsplit (not checked out) and a conflict instead. -X theirs does not help either: the merge stops with the same submodule conflict. (Both were run for this chapter; no transcript is printed.)

23.11 Removing a submodule, and a submodule whose URL changes

There are two different "removals". 🟡 git submodule deinit <path> is local: it empties the working directory and removes the submodule.<name> section from your .git/config. The superproject's history is untouched, and git submodule update --init undoes it.

$ git submodule deinit vendor/textsplit
Cleared directory 'vendor/textsplit'
Submodule 'vendor/textsplit' (../textsplit.git) unregistered for path 'vendor/textsplit'
$ git submodule status
-e2166657d4daa7b174a5fb83da9e2f8ecca2c9e4 vendor/textsplit
$ ls -A vendor/textsplit
$ git status --short
$ git config list --local | grep ^submodule
$ ls .git/modules/vendor
textsplit

🟡 git rm <path> removes the submodule from the project: it deletes the gitlink and the section in .gitmodules, and you commit that.

$ git rm vendor/textsplit
rm 'vendor/textsplit'
$ git status
On branch main
Your branch is up to date with 'origin/main'.

Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
	modified:   .gitmodules
	deleted:    vendor/textsplit

$ git diff --cached
diff --git a/.gitmodules b/.gitmodules
index 9f740c3..e69de29 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -1,3 +0,0 @@
-[submodule "vendor/textsplit"]
-	path = vendor/textsplit
-	url = ../textsplit.git
diff --git a/vendor/textsplit b/vendor/textsplit
deleted file mode 160000
index e216665..0000000
--- a/vendor/textsplit
+++ /dev/null
@@ -1 +0,0 @@
-Subproject commit e2166657d4daa7b174a5fb83da9e2f8ecca2c9e4
$ git commit -m "Stop vendoring textsplit"
[main 4adef58] Stop vendoring textsplit
 2 files changed, 4 deletions(-)
 delete mode 160000 vendor/textsplit
$ ls -A
.git
.gitmodules
answer.py
ingest.py
README.md
vendor

Three leftovers remain, and each causes a question later. An empty .gitmodules is still tracked (delete it with git rm .gitmodules if no submodule is left). The section in .git/config and the repository under .git/modules/ are still on your disk:

$ git config list --local | grep ^submodule
submodule.vendor/textsplit.active=true
submodule.vendor/textsplit.url=$LAB/ch23/submodule-remove/remotes/textsplit.git
$ ls .git/modules/vendor
textsplit

Git keeps the repository on purpose, so that old commits which still contain the gitlink can be checked out without a new clone. The cost shows when someone adds a submodule under the same name again:

$ git -c protocol.file.allow=always submodule add ../textsplit.git vendor/textsplit
fatal: A git directory for 'vendor/textsplit' is found locally with remote(s):
  origin	$LAB/ch23/submodule-remove/remotes/textsplit.git
If you want to reuse this local git directory instead of cloning again from
  $LAB/ch23/submodule-remove/remotes/textsplit.git
use the '--force' option. If the local git directory is not the correct repo
or you are unsure what this means choose another name with the '--name' option.
[exit status: 128]

To remove a submodule completely from your clone, delete the leftovers. 🔴 The first command deletes a repository; if the submodule held unpushed commits, they are gone.

$ rm -rf .git/modules/vendor/textsplit
$ git config remove-section submodule.vendor/textsplit
$ git -c protocol.file.allow=always submodule add ../textsplit.git vendor/textsplit
Cloning into '$LAB/ch23/submodule-remove/doc-qa/vendor/textsplit'...
done.
$ git status --short
M  .gitmodules
A  vendor/textsplit

History is not rewritten by any of this. A checkout of a commit from before the removal has the gitlink again, and the directory is empty until the next git submodule update --init.

A changed URL shows the role of the local copy in .git/config. The library's repository has moved. You record the new URL with git submodule set-url, which edits .gitmodules and synchronizes your own configuration:

$ git submodule set-url vendor/textsplit ../chunking.git
Synchronizing submodule url for 'vendor/textsplit'
$ git diff
diff --git a/.gitmodules b/.gitmodules
index 9f740c3..7902bc0 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -1,3 +1,3 @@
 [submodule "vendor/textsplit"]
 	path = vendor/textsplit
-	url = ../textsplit.git
+	url = ../chunking.git
$ git config get submodule.vendor/textsplit.url
$LAB/ch23/submodule-url/remotes/chunking.git
$ git -c protocol.file.allow=always submodule update --remote
From $LAB/ch23/submodule-url/remotes/chunking
   e216665..4f00a5b  main       -> origin/main
 * [new tag]         v0.2.0     -> v0.2.0
Submodule path 'vendor/textsplit': checked out '4f00a5b8bb1f8ba700d273d9ec2f453be23efc75'
$ git commit -am "textsplit moved to chunking.git; update to 0.2.0"
[main 109e5d9] textsplit moved to chunking.git; update to 0.2.0
 2 files changed, 2 insertions(+), 2 deletions(-)
$ git push --recurse-submodules=check origin main
To $LAB/ch23/submodule-url/remotes/doc-qa.git
   907dbd3..109e5d9  main -> main

Ravi pulls the new .gitmodules. His configuration still has the URL that init copied months ago:

$ cd ../ravi-doc-qa
$ git pull --no-recurse-submodules
From $LAB/ch23/submodule-url/remotes/doc-qa
   907dbd3..109e5d9  main       -> origin/main
Updating 907dbd3..109e5d9
Fast-forward
 .gitmodules      | 2 +-
 vendor/textsplit | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)
$ cat .gitmodules
[submodule "vendor/textsplit"]
	path = vendor/textsplit
	url = ../chunking.git
$ git config get submodule.vendor/textsplit.url
$LAB/ch23/submodule-url/remotes/textsplit.git
$ git -c protocol.file.allow=always submodule update
fatal: '$LAB/ch23/submodule-url/remotes/textsplit.git' does not appear to be a git repository
fatal: Could not read from remote repository.

Please make sure you have the correct access rights
and the repository exists.
Unable to fetch in submodule path 'vendor/textsplit'; trying to directly fetch 4f00a5b8bb1f8ba700d273d9ec2f453be23efc75:
fatal: '$LAB/ch23/submodule-url/remotes/textsplit.git' does not appear to be a git repository
fatal: Could not read from remote repository.

Please make sure you have the correct access rights
and the repository exists.
fatal: Fetched in submodule path 'vendor/textsplit', but it did not contain 4f00a5b8bb1f8ba700d273d9ec2f453be23efc75. Direct fetching of that commit failed.
[exit status: 128]

🟢 git submodule sync copies the URLs from .gitmodules into .git/config and into the origin remote of each initialized submodule:

$ git submodule sync
Synchronizing submodule url for 'vendor/textsplit'
$ git config get submodule.vendor/textsplit.url
$LAB/ch23/submodule-url/remotes/chunking.git
$ git -C vendor/textsplit remote get-url origin
$LAB/ch23/submodule-url/remotes/chunking.git
$ git -c protocol.file.allow=always submodule update
From $LAB/ch23/submodule-url/remotes/chunking
   e216665..4f00a5b  main       -> origin/main
 * [new tag]         v0.2.0     -> v0.2.0
Submodule path 'vendor/textsplit': checked out '4f00a5b8bb1f8ba700d273d9ec2f453be23efc75'

23.12 CI consequences

A CI job is a fresh clone made by a script, so every default of this chapter applies to it, and nobody is there to read git status.

GitHub Actions, not Git. actions/checkout does not check out submodules unless told to: its submodules input defaults to false, and the README describes the values as "true to checkout submodules or recursive to recursively checkout submodules" (action.yml (external site), README (external site)). The job's github.token is scoped to the repository that triggered the run, so a private submodule in another repository needs a token or an SSH key with access to it, passed through the action's token or ssh-key input. The README also notes that, without ssh-key, URLs beginning with git@github.com: are converted to HTTPS. None of this was run for this book.

What follows from the mechanics:

  • An empty directory is not an error to Git. A job without submodule checkout fails later and elsewhere: an import error, a missing file in a Docker build context, a test that is silently skipped. Add an explicit step such as git submodule status and fail if any line starts with - or +.
  • The job tests the recorded commit, not the library's latest. A library fix reaches the service only through a pointer commit.
  • The push-order failure of section 23.8 appears in CI first, as "not our ref" or "did not contain" during checkout, on the commit of the person who forgot to push the library.
  • Trust. A workflow that recursively checks out submodules of a pull request from a fork lets the fork's .gitmodules choose what is cloned onto the runner. Combine that with section 23.4 before enabling recursion for untrusted code (Chapter 21A).
  • Docker and archives. git archive does not include submodule contents: the archive of this chapter's superproject contains the entry vendor/textsplit/ and nothing below it (run for this chapter, no transcript printed). A build that starts from an archive needs the submodules fetched separately.

23.13 Subtrees: the dependency's files inside your own history

In one sentence. 🟡 git subtree add copies another project's files into a subdirectory of your repository as ordinary tracked files, and records in commit messages where they came from, so that later commands can merge newer upstream versions in or extract your changes back out.

Analogy. Instead of a reference to page 212 of the other book, you photocopy the page and bind the copy into yours, with a note in the margin saying which printing it was copied from. Every reader of your book has the recipe. The margin note is the only link to the original: if somebody copies a newer printing over it without updating the note, the next update has nothing to go by.

Precisely. git subtree is a script from Git's contrib/ directory. The Homebrew Git on this Mac installs it. It has no page in git help; its documentation is in the Git source tree, and git subtree -h prints the usage. That documentation describes the contrast with submodules itself: subtrees "do not need any special constructions (like .gitmodules files or gitlinks) be present in your repository, and do not force end-users of your repository to do anything special or to understand how subtrees work" (git-subtree (external site)). Do not confuse the command with the subtree merge strategy of git merge -s subtree, which it builds on.

See it. Start again from doc-qa without any dependency and add the library under the same path:

$ git subtree add --prefix=vendor/textsplit ../remotes/textsplit.git main --squash
git fetch ../remotes/textsplit.git main
From ../remotes/textsplit
 * branch            main       -> FETCH_HEAD
Added dir 'vendor/textsplit'
$ git log --graph --format="%h %an: %s"
*   fb692e1 Lab User: Merge commit 'c77d389e2c498a974e85fe327f712348bbe6f573' as 'vendor/textsplit'
|\  
| * c77d389 Lab User: Squashed 'vendor/textsplit/' content from commit e216665
* bf78eb9 Lab User: Add keyword answerer
* 1d93b09 Lab User: Add document loader

--squash produced two commits. c77d389 is a commit with no parent in your history whose tree is the library's tree at upstream commit e216665; fb692e1 merges it into main and places that tree at vendor/textsplit. Without --squash the merge would bring the library's entire history into yours.

$ git ls-tree HEAD vendor/
040000 tree bb3d20df131c55a48b035cace5cd1812d87d5332	vendor/textsplit
$ git ls-files vendor
vendor/textsplit/README.md
vendor/textsplit/splitter.py
$ git log -1 --format=%B HEAD^2
Squashed 'vendor/textsplit/' content from commit e216665

git-subtree-dir: vendor/textsplit
git-subtree-split: e2166657d4daa7b174a5fb83da9e2f8ecca2c9e4

$ git status --short
$ ls -A
.git
answer.py
ingest.py
README.md
vendor

Inside .git. Nothing special at all. The entry at vendor/textsplit has mode 040000: a tree like any other directory. The files are blobs in your object database. There is no .gitmodules, no .git/modules, no configuration. The whole mechanism is the two trailer lines in the squash commit's message: git-subtree-dir and git-subtree-split, the upstream commit that the content corresponds to.

For a teammate there is nothing to learn and nothing to forget:

$ git push origin main
To $LAB/ch23/subtree/remotes/doc-qa.git
   bf78eb9..fb692e1  main -> main
$ cd ..
$ git clone remotes/doc-qa.git ravi-doc-qa
Cloning into 'ravi-doc-qa'...
done.
$ ls ravi-doc-qa/vendor/textsplit
README.md
splitter.py
$ cd doc-qa

A plain clone, by a person or a CI job, has the library. No init, no update, no second fetch, no second set of credentials.

Updating. 🟡 git subtree pull fetches the upstream branch, builds a squash commit for the difference since the recorded git-subtree-split, and merges it:

# Asha has published textsplit 0.2.0.
$ git subtree pull --prefix=vendor/textsplit ../remotes/textsplit.git main --squash
From ../remotes/textsplit
 * branch            main       -> FETCH_HEAD
Merge made by the 'ort' strategy.
 vendor/textsplit/splitter.py | 2 ++
 1 file changed, 2 insertions(+)
$ git log --graph --format="%h %an: %s"
*   7241fca Lab User: Merge commit 'c2dc1b7050d0f35c26e98632a0a8916c9f9b61e3'
|\  
| * c2dc1b7 Lab User: Squashed 'vendor/textsplit/' changes from e216665..4980f0b
* | fb692e1 Lab User: Merge commit 'c77d389e2c498a974e85fe327f712348bbe6f573' as 'vendor/textsplit'
|\| 
| * c77d389 Lab User: Squashed 'vendor/textsplit/' content from commit e216665
* bf78eb9 Lab User: Add keyword answerer
* 1d93b09 Lab User: Add document loader
$ git log -1 --format=%B HEAD^2
Squashed 'vendor/textsplit/' changes from e216665..4980f0b

4980f0b Reject an overlap that is not smaller than the chunk size

git-subtree-dir: vendor/textsplit
git-subtree-split: 4980f0b1f99fa205fd4882634ce93e661ede41fd

The new squash commit's message lists the upstream commits it covers and records the new split point. It is an ordinary merge: if you changed the vendored files yourself and upstream changed the same lines, you resolve a normal content conflict in normal files.

The option has to be used consistently. A repository whose subtree was added with --squash has none of the upstream commits in its history, so a pull without --squash tries to merge two histories with no common ancestor:

# Update the vendored copy, and forget --squash:
$ git subtree pull --prefix=vendor/textsplit ../remotes/textsplit.git main
From ../remotes/textsplit
 * branch            main       -> FETCH_HEAD
fatal: refusing to merge unrelated histories
[exit status: 128]
$ git status --short --branch
## main...origin/main

Contributing back. Because the files are yours, one commit can change the library and the application together, which a submodule cannot do:

# A change of your own that touches the library and the application in one commit:
$ git commit -am "Split documents by paragraph"
[main e3827e6] Split documents by paragraph
 2 files changed, 9 insertions(+)
$ git show --stat --format="%h %s"
e3827e6 Split documents by paragraph

 ingest.py                    | 4 ++++
 vendor/textsplit/splitter.py | 5 +++++
 2 files changed, 9 insertions(+)

🟢 git subtree split walks your history and synthesizes a history that contains only what happened under the prefix, with the prefix removed from the paths:

$ git subtree split --quiet --prefix=vendor/textsplit -b textsplit-export
ee9c8f1aeeb272a682e93f0137db86efd7c142e8
$ git log --graph --format="%h %an: %s" textsplit-export
* ee9c8f1 Lab User: Split documents by paragraph
* 4980f0b Asha Rao: Reject an overlap that is not smaller than the chunk size
* e216665 Asha Rao: Add overlap between neighbouring chunks
* ceaafe1 Asha Rao: Add fixed-size splitter
$ git ls-tree --name-only textsplit-export
README.md
splitter.py
$ git show --stat --format="%h %s" textsplit-export
ee9c8f1 Split documents by paragraph

 splitter.py | 5 +++++
 1 file changed, 5 insertions(+)

Look at the IDs. The synthetic branch ends in ee9c8f1, your commit reduced to its library part (ingest.py is gone from it), and below it are 4980f0b, e216665 and ceaafe1: the upstream commits themselves, with their original IDs and Asha as author. The split found them through the git-subtree-split trailers, so the result is a branch that upstream can merge or review as a normal contribution. git subtree push is a split followed by a push of the result:

$ git subtree push --quiet --prefix=vendor/textsplit ../remotes/textsplit.git docqa/paragraphs
git push using:  ../remotes/textsplit.git docqa/paragraphs
To ../remotes/textsplit.git
 * [new branch]      ee9c8f1aeeb272a682e93f0137db86efd7c142e8 -> docqa/paragraphs
$ git ls-remote --heads ../remotes/textsplit.git
ee9c8f1aeeb272a682e93f0137db86efd7c142e8	refs/heads/docqa/paragraphs
4980f0b1f99fa205fd4882634ce93e661ede41fd	refs/heads/main

--rejoin merges the synthetic history back into yours so that, in the words of its documentation, "future splits can search only the part of history that has been added since the most recent --rejoin".

In production. Vendoring a small, slowly changing dependency into a service where every CI job and every Docker build must work from a plain clone. The price: the repository grows with every vendored version, the link to upstream lives in commit messages that a rebase or a squash-merge of the pull request can destroy, and everyone who updates the copy must use the same command with the same options.

State table:

CommandWorking treeIndexHEADCurrent branch refOther refs and files in .gitRemoteGitHub
git subtree add --squash, git subtree pull --squashFiles under the prefix created or updatedupdated to matchtwo new commits (a squash commit and a merge)advancednew objects; FETCH_HEADunchanged (a fetch)unchanged
git subtree split -b <branch>unchangedunchangedunchangedunchangeda new branch with synthetic commitsunchangedunchanged
git subtree pushunchangedunchangedunchangedunchangednew synthetic commitsa branch created or updated in the library's repositoryas for any push

23.14 Submodule, subtree, or a package manager

QuestionSubmoduleSubtreePackage manager (pip, npm, Maven, Go modules)
What the consuming repository recordsA commit ID (gitlink) and a URL in .gitmodulesThe files themselves, plus trailers in commit messagesA name and a version, in a manifest and a lock file
Where the dependency's content livesIn the dependency's repository; cloned beside yoursIn your repository's own objectsIn a registry or artifact store; installed at build time
After a plain git cloneAn empty directoryCompleteManifest only; an install step is required
Updatingupdate --remote, then commit the pointergit subtree pull --squashChange the version, regenerate the lock file
Changing the dependency from inside the consumerCommit in the submodule, push it, then commit the pointer: two repositories, in orderEdit and commit normally; split and push to offer it upstreamNot possible in place; publish a new version or use the tool's local-override mechanism
Access control and licensing separationKept: the dependency keeps its own repository and permissionsLost: whoever can read your repository reads the copyKept, through the registry
Typical failureThe states of this chapter: uninitialized, stale, unpushed, detachedLost trailers; mixed --squash usage; local edits that conflict on every updateVersion conflicts; a registry that is unavailable; unpinned versions
FitsA dependency developed in step with the consumer by people who have access to both; large or access-restricted contentA small dependency that must be present in every clone and changes rarelyAnything that is published with versions, which is most libraries

How to decide. If the dependency is published as a package, use the package manager; it solves versioning, transitive dependencies and security advisories, which neither Git mechanism attempts. If it is not a package and must be present after a plain clone, use a subtree. If it must remain a separate repository (its own access rules, its own release cadence, a size you do not want in your history) and the team will adopt the configuration of section 23.16, use a submodule. If the real wish is "one atomic commit across both", the two projects may belong in one repository (Chapter 24).

23.15 What can go wrong

SymptomDiagnosisFixPrevention
The submodule directory is empty after a clonegit submodule status shows -git submodule update --init --recursivegit clone --recurse-submodules; document it in the README; check it in CI
modified: <path> (new commits) after a pull, with no edits of your owngit submodule status shows +; git diff --submodule=log shows (rewind)git submodule updatesubmodule.recurse=true; git pull --recurse-submodules
A commit about something else changes <path> | 2 +-git show --stat <commit>; the gitlink moved backwardsUnpushed: check out the right commit in the submodule, git add, amend. Pushed: a new commit that restores the pointerUpdate before committing; avoid git commit -a in superprojects; review gitlink changes
not our ref <id>, or "did not contain . Direct fetching of that commit failed"git ls-tree origin/main <path> against git ls-remote <library URL>: the recorded commit is on no branch of the library's remoteThe author pushes the library commit; or the pointer is moved to a published commitpush.recurseSubmodules=check; CI on a fresh recursive clone
Work done inside the submodule vanished after git submodule updategit -C <path> reflog: a commit made on a detached HEADgit -C <path> branch <name> <id>Switch to a branch inside the submodule before editing
modified: <path> (modified content) and git commit -a commits nothinggit submodule foreach 'git status --short'Commit inside the submodule, or discard thereKeep consumed submodules clean
CONFLICT (submodule)git ls-files -u; compare the two commits inside the submoduleCheck out a library commit that contains both, git add <path>, commitMove pointers in dedicated, short-lived branches
does not appear to be a git repository on update after the library movedgit config get submodule.<name>.url shows the old URLgit submodule sync, then updateAnnounce URL changes; relative URLs
git subtree pull ends in refusing to merge unrelated historiesThe subtree was added with --squash and pulled withoutRepeat the pull with --squashA script or alias for the update command

23.16 When not to use it, and dangerous edge cases

Do not use a submodule:

  • For a library that is available as a package. You would rebuild a worse package manager.
  • When contributors cannot be expected to learn it, for example an open-source project with many occasional contributors.
  • For content from sources you do not trust, combined with recursive clones (23.4).

Do not use a subtree when the vendored content is large or changes often (every version stays in your history, Chapter 22 explains why that matters for binaries), or when its access rules must differ from those of the consuming repository.

A configuration that removes most of the submodule failures. It is local configuration, so each clone and each CI job needs it:

git config set submodule.recurse true            # switch, pull, checkout, reset follow the gitlink
git config set push.recurseSubmodules check      # refuse to push a pointer to an unpublished commit
git config set diff.submodule log                # diffs list the library commits
git config set status.submoduleSummary true      # status shows them too

Edge cases:

  • 🔴 git submodule update --force and git submodule deinit --force discard uncommitted changes inside the submodule. Preview with git submodule foreach 'git status --short'.
  • 🔴 Deleting .git/modules/<name> deletes a repository, including commits that were never pushed.
  • A pointer to a commit on a library branch that is later rebased or deleted. The commit becomes unreachable on the server and will eventually be unobtainable. Record only commits that are on a protected branch or a tag of the library.
  • update --remote in CI. A job that runs it tests a library commit that no superproject commit records, so the run cannot be reproduced. Use it in a scheduled job that opens a pull request with the pointer change.
  • Linked worktrees of a superproject have incomplete submodule support according to the worktree manual (Chapter 25, section 25.7).
  • Rewriting a subtree's history. Squash-merging or rebasing the commits that git subtree created discards or rewrites the trailers that the next pull depends on.

23.17 Command safety

CommandLabelWhat it changesPreviewRecovery
git submodule status, summary, foreach '<read-only command>', git diff --submodule=log, git push --recurse-submodules=check, git subtree split🟢 SAFENothing in your branches, index or files (split adds synthetic commits and, with -b, a branch)not needednot needed
git submodule add🟢 SAFEClones; stages .gitmodules and a gitlink; writes .git/config and .git/modules/git ls-remote <url>git rm -f <path> before committing, and remove the leftovers (23.11)
git submodule init, sync, set-url, set-branch🟢 SAFE.git/config; set-url and set-branch also edit .gitmodulescat .gitmodules; git config list --localEdit the values back
git submodule update (default checkout)🟡 CAUTIONMoves the submodule's HEAD (detached) and its files; refuses to overwrite local edits to files it must changegit submodule status; git -C <path> statusThe submodule's reflog: git -C <path> reflog
git submodule update --remote🟡 CAUTIONAs above, to the tip of the remote-tracking branch; the superproject then shows a modified gitlinkgit -C <path> fetch && git -C <path> log --oneline HEAD..origin/HEADgit submodule update returns to the recorded commit
git submodule update --force🔴 DANGEROUSOverwrites modified tracked files in the submodulegit submodule foreach 'git status --short'None for uncommitted edits
git submodule deinit <path>🟡 CAUTIONEmpties the working directory; removes the section from .git/config; refuses if there are local modifications unless --forcegit -C <path> statusgit submodule update --init
git rm <submodule path>🟡 CAUTIONRemoves the gitlink, the .gitmodules section and the working directorygit statusgit restore --staged --worktree .gitmodules <path>, then git submodule update --init
git push --recurse-submodules=on-demand🟡 CAUTIONPushes in the submodules, then the superprojectgit submodule foreach 'git status --short --branch'As for any push
git subtree add, git subtree pull🟡 CAUTIONNew commits on the current branch (a merge); needs a clean working treegit ls-remote <repository> <ref>git reset --hard ORIG_HEAD if not pushed (🔴 for uncommitted work, Chapter 11); git revert -m 1 if pushed
git subtree push🟡 CAUTIONCreates or updates a branch in the library's repositorygit subtree split first and inspect the resultDelete or reset that branch in the library's repository

23.18 Version notes

Version note. Older behavior: submodule commands could clone from local paths without restriction. Current behavior: transfers that Git starts by itself over the file transport are refused unless protocol.file.allow permits them. Since: Git 2.38.1 and the maintenance releases issued with it (release notes (external site)). Recommended: leave the default; use -c protocol.file.allow=always on single commands in local test setups.

  • The local manual of Git 2.55.0 documents git submodule add --ref-format, set-branch, set-url and absorbgitdirs. The last one moves a .git directory that is embedded in a submodule's working tree into the superproject's .git/modules/.

Unverified. The release in which git submodule update began to fetch a missing commit directly by ID ("trying to directly fetch"), and the release that introduced submodule.recurse, were not looked up for this chapter. Both behaviors were run on Git 2.55.0 and are shown above.

Outdated advice. "After cloning, run git submodule init and then git submodule update." It still works. git clone --recurse-submodules, or git submodule update --init --recursive in an existing clone, does the same in one step and also handles nested submodules.

23.19 Practice

  • Lab 15.1 (a submodule that breaks for a teammate) and Lab 15.2 (the same dependency as a subtree) in the Module 15 lab manual.
  • Replay any transcript with labs/run ch23/<demo>, for example labs/run ch23/submodule-update.
  • Two drills. In the sandbox of ch23/submodule-conflict, resolve the conflict the other way: merge inside the submodule yourself, and then list everything that must be pushed, and in which order, before a teammate can use your merge. In the sandbox of ch23/subtree, find with git log --grep the commit that records the current upstream version of the vendored library, and say what would happen to the next git subtree pull if that commit's message were edited.

23.20 Interview questions

  1. What exactly does a superproject store about a submodule, and what does it not store? Where is each piece?
  2. A colleague says "our submodule tracks the library's main". Correct the statement and explain what submodule.<name>.branch does.
  3. Why is HEAD detached in a submodule after git submodule update? When does that lose work, and how do you get it back?
  4. After git pull, git status shows a modified submodule that you never touched. Explain the state of the three relevant commit IDs, the risk, and the fix.
  5. A teammate's pull fails with not our ref. Give the root cause, the three commands that prove it, the fix, and the setting that prevents it.
  6. Why does Git refuse file URLs for submodules by default, and why is a recursive clone of an untrusted repository a security decision?
  7. Two branches moved the same gitlink and the merge conflicts. Why can Git not resolve it, when does it resolve it by itself, and what does a correct resolution look like?
  8. Explain how git subtree split can reproduce the original upstream commit IDs. What would break that?
  9. Submodule, subtree, package manager: decide for (a) an internal protocol-definition repository used by eight services, (b) a 300-line utility copied from an open-source project, (c) a tokenizer library published on a package index.
  10. You are asked to introduce submodules for a team of thirty. Which four configuration settings and which CI check do you require, and why?

23.21 Sources

Primary sources

Secondary sources

  • Pro Git, Git Tools: Submodules (external site), which documents the failure modes of sections 23.5 to 23.10 from the user's side.
  • The Phase 0 report of this course, section 12 (the misconception table), section 13 ("Scale, submodules and large files") and section 14 ("The Git client").

Videos (optional; assessments in the Phase 0 report rest on captions, not on full viewing)

Further reading

Written against Git 2.55.0 on macOS; GitHub facts as of 1 October 2026. Git transcripts are output from the book’s lab scripts; GitHub behaviour is described from GitHub’s documentation. Found a mistake? Tell us through the enquiry form.

Model registry/models
Benchmarks/benchmarks
Paper breakdowns/research
AI tools directory/tools
Work with us/work-with-us