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
- What a submodule is exactly
git submodule add: addingui-components- What exactly gets committed: the commit-type object
- Cloning a project with submodules
- Updating: the submodule versus the pointer
- Working inside a submodule and detached HEAD
- Inspection:
status,foreachanddiff --submodule submodule.recurseand other options that take the pain away- The real problems
- Alternatives: submodules, subtree, packages and monorepo
- 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
.gitmodulesfile saying where to clone that other repository from.
Two pieces, no more:
- The pointer: an entry in the tree (lesson 01-04) whose type is neither
blobnortree, butcommit. It stores a 40-character hash and nothing else. .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-managerpins 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.
git submodule add: adding ui-components
git submodule add: adding ui-componentsAna 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-componentsCloning 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:
- 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:
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.gitAnd 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:
// app.js
const dialog = UIComponents.createDialog({
title: 'Confirm deletion',
message: 'Are you sure you want to delete this task?',
});And she pushes:
- 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:
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
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.
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:
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 9d2f6a8b3e7c1d5f9a2b6c4e8d1f3a7c5b9e2d6fOne line changing. The library's entire content may have changed, but for the parent this is a pointer moving.
- Cloning a project with submodules
Bruno clones the project for the first time:
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
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 commitOr in a single command:
git submodule update --init
git submodule update --init --recursive # if the submodules have submodulesThe 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 updateYou can also initialise only some of them, in projects with many:
- 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:
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 has done a fetch in the submodule and checked out the tip of its tracking branch. Which branch? In this order:
- The one given in
.gitmoduleswithbranch = <branch>(orgit submodule add -b). - If there is none, the remote's
HEAD, which is usuallymain.
[submodule "vendor/ui-components"]
path = vendor/ui-components
url = git@git.example.com:team/ui-components.git
branch = stableAnd now the important part: that still has not changed anything in the parent's history. It is an uncommitted change:
(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 pushsequenceDiagram
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.
- 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:
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 pushStep 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:
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 beforehandcheck is the recommended option: it warns you and forces you to decide, rather than publishing things off its own bat.
- Inspection:
status, foreach and diff --submodule
status, foreach and diff --submodulegit submodule status
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:
→ Bruno has just cloned without --recurse-submodules.
→ 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 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:
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:
With status.submoduleSummary, git status also summarises the outstanding commits instead of just saying (new commits).
submodule.recurse and other options that take the pain away
submodule.recurse and other options that take the pain awayMost of the suffering with submodules comes from forgetting --recurse-submodules on some command. This option enables it by default for nearly all of them:
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.recursedoes not affectgit 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 4And 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).
- 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"
fiProblem 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.
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 dirtyThe 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:
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 commitOften 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.
- 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):
- Create a
libraryrepository with three commits and av1.0tag. - Create an
applicationrepository with two commits. - Add
libraryas a submodule ofapplicationatvendor/libraryand commit it. - Inspect the tree with
git cat-file -p HEAD^{tree}and locate the entry with mode160000. - 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:
- Add two new commits to
library. - In
application, check thatgit statussays nothing (the pointer is unchanged). - Run
git submodule update --remoteand observe what changes ingit status. - Commit the new pointer with a message that explains why.
- Check the pointer's diff with
--submodule=short,--submodule=logand--submodule=diff.
Exercise 3: simulating the problems and solving them
- Clone
applicationwithout--recurse-submodulesand check the empty directory and the output ofgit submodule status. - Fix it with
git submodule update --init. - 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.
- Recover that orphaned commit by putting it on a branch.
- 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"The
-c protocol.file.allow=alwaysoption is needed as of Git 2.38 in order to use submodules with local paths; withhttps://orgit@URLs it is not necessary.
100644 blob 3f8a1c9e... .gitmodules 100644 blob 7d2e5b4c... app.js 100644 blob 9a4f1c6b... index.html 040000 tree 2c8d5f9a... vendor
There it is: mode 160000 and type commit.
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 --shortThe 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.
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
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.
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 statusSpace 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/The leading - is the diagnosis: not initialised.
echo "export function menu() {}" > menu.js
git add . && git commit -q -m "Add the menu component"
git log --oneline -1
cd ../..
git submodule statusThe + is the warning: the submodule is at a different commit from the recorded one. And that commit is not on any branch:
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)"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
160000and typecommit, 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, orgit submodule update --init --recursiveafterwards. It is the most frequent mistake. - There are two opposite updates:
submodule updateputs the submodule where the parent says;submodule update --remotebrings 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 statuswith its prefixes (,-,+,U),foreachanddiff --submodule=logare the inspection tools.submodule.recurse true,diff.submodule logandstatus.submoduleSummary trueremove 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
- What Is Git?
- Installing Git
- Basic Git Terminology
- The Git Data Model
- Configuring Git
- Initial Configuration
Module 2: Basic Git Operations
- Creating a Repository
- Cloning a Repository
- The Basic Git Workflow
- Staging and Committing Changes
- Inspecting Changes with git diff
- Viewing Commit History
Module 3: Branching and Merging
- Understanding Branches
- Creating and Switching Branches
- Merging Branches
- Merge Strategies
- Resolving Merge Conflicts
- Branch Management
Module 4: Working with Remote Repositories
- Understanding Remote Repositories
- Adding a Remote Repository
- Authenticating with Remote Repositories
- Fetching and Pulling Changes
- Pushing Changes
- Tracking Branches
Module 5: Advanced Git Operations
Module 6: Git Tools and Techniques
- Using Git Hooks
- Git Bisect
- Git Blame
- Git Log and Aliases
- Git Submodules
- Multiple Working Copies with git worktree
Module 7: Collaboration and Workflow Strategies
- Forks and Pull Requests
- Code Reviews with Git
- The Git Flow Workflow
- GitHub Flow
- Trunk Based Development
- Continuous Integration with Git
Module 8: Git Best Practices and Tips
- Writing Good Commit Messages
- Keeping a Clean History
- Ignoring Files with .gitignore
- File Attributes with .gitattributes
- Security Best Practices
- Performance Tips
Module 9: Troubleshooting and Debugging
- Common Git Problems
- Undoing Changes
- Resolving Divergence with the Remote
- Recovering Lost Commits
- Dealing with Corrupted Repositories
- Advanced Debugging Techniques
