Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Git & GitHub  Module 11 – Customising and scaling Git

Submodules and subtrees: code from other repos

Pin another repository inside yours with a Git submodule, or copy it in with git subtree: how each works, its traps, and when a package is better.

  • Advanced
  • 30 minutes
  • Examples run with git 2.54.0, Python 3.14.8 and quickjs 0.32.0
  • By MySmartCoPilot
Free lesson, Pro answers: 30 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Add a submodule, clone with --recurse-submodules and update it to a new commit
  • Explain that a submodule pins a commit through a gitlink entry
  • Compare submodules, subtrees and package managers for sharing code
  • Protect yourself from malicious submodules by keeping Git updated and not cloning untrusted repositories recursively

Before you start

What you’ll learn · Before you start

What you will learn

  • Add a submodule, clone with --recurse-submodules and update it to a new commit
  • Explain that a submodule pins a commit through a gitlink entry
  • Compare submodules, subtrees and package managers for sharing code
  • Protect yourself from malicious submodules by keeping Git updated and not cloning untrusted repositories recursively

Before you start

On this page

Projects often need code that lives in another repository: a theme several sites share, a library, a set of lint rules. Git offers two ways to bring it in. A submodule puts the other repository inside yours as a pointer: your commit records the exact commit of the other repository it uses, in an entry called a gitlink, and a file named .gitmodules records where to clone it from. A subtree copies the other repository’s files, and if you like its history, into a folder of your repository, where they are ordinary files. Submodules keep the two histories apart and pin an exact version, at the price of extra commands for everyone. Subtrees ask nothing of the people who clone, at the price of a heavier history. For a library that has releases, a package manager such as npm, pip or Maven is usually better than either.

The sessions below run real Git 2.54.0. A college fest site uses a shared theme, and bare repositories in folders stand in for the servers of both.

A submodule pins a commit

The theme has its own repository with a tag v1.0. Adding it to the fest site as a submodule takes one command, plus one setting this session needs because the theme’s “server” is a folder:

Adding the shared theme as a submodule Python · add_submodule.py
from session import run, write

# The shared theme, with a bare repository standing in for its server
write("theme/theme.css", "body { font-family: sans-serif; }\n")
write("fest-site/index.html", "<h1>College fest</h1>\n")
run("""
git init -q --bare theme.git
cd theme
git init -q
git add theme.css
git commit -q -m "Theme 1.0"
git tag -a v1.0 -m "Theme 1.0"
git push -q ../theme.git main v1.0
cd ../fest-site
git init -q
git add index.html
git commit -q -m "Fest home page"
""", quiet=True)
run("""
git submodule add ../theme.git theme
# Since Git 2.38.1, submodules may not be cloned from a folder unless you allow it for that command
git -c protocol.file.allow=always submodule add ../theme.git theme
cat .gitmodules
git status --short
git diff --cached
git commit -q -m "Add the shared theme as a submodule"
git ls-tree HEAD
git submodule status
""")

Output

$ git submodule add ../theme.git theme
Cloning into '~/fest-site/theme'...
fatal: transport 'file' not allowed
fatal: clone of '~/theme.git' into submodule path '~/fest-site/theme' failed
[exit status 128]
# Since Git 2.38.1, submodules may not be cloned from a folder unless you allow it for that command
$ git -c protocol.file.allow=always submodule add ../theme.git theme
Cloning into '~/fest-site/theme'...
done.
$ cat .gitmodules
[submodule "theme"]
	path = theme
	url = ../theme.git
$ git status --short
A  .gitmodules
A  theme
$ git diff --cached
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000..2bde6f8
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,3 @@
+[submodule "theme"]
+	path = theme
+	url = ../theme.git
diff --git a/theme b/theme
new file mode 160000
index 0000000..1d77779
--- /dev/null
+++ b/theme
@@ -0,0 +1 @@
+Subproject commit 1d77779008421ca001e81e800e5e9840cca4bf68
$ git commit -q -m "Add the shared theme as a submodule"
$ git ls-tree HEAD
100644 blob 2bde6f8e997ce40ac1c951fc163d16e1986f7ad4	.gitmodules
100644 blob 838575670c25fb1df90a60ccadfadc79695dfd76	index.html
160000 commit 1d77779008421ca001e81e800e5e9840cca4bf68	theme
$ git submodule status
 1d77779008421ca001e81e800e5e9840cca4bf68 theme (v1.0)

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 add_submodule.py

The first attempt failed with transport 'file' not allowed. Since Git 2.38.1 and the matching fixes for older release lines, cloning over the file transport is allowed only when you run the clone yourself, not when Git starts it for you, as it does for a submodule; this closed a hole through which a crafted repository could make Git copy other files from your computer into the new clone. A real project uses an https:// or ssh address, which is always allowed. For the folders of this session, -c protocol.file.allow=always lifts the rule for one command only, which is safer than changing it everywhere.

The second attempt cloned the theme into theme/ and staged two things. .gitmodules is an ordinary file in the configuration format, mapping the path theme to the address ../theme.git; a relative address like this one is taken relative to the parent’s own remote, so the theme can move with it. The other is the entry theme itself, which git diff --cached shows as new file mode 160000 holding a single line, Subproject commit 1d77779….

After the commit, git ls-tree HEAD shows what the parent really stores: two blobs and a gitlink, mode 160000, type commit, with the ID of the theme’s commit. Not one file of the theme is in the parent’s history. The parent pins exactly that commit, and git submodule status names it, adding (v1.0) because a tag points to it.

The parent commit’s tree holds a gitlink pinning commit 1d77779 of the theme repository; .gitmodules says where to clone it.fest-site commit"Add the shared theme as a submodule"Its tree100644 blob .gitmodules100644 blob index.html160000 commit 1d77779… theme.gitmodules[submodule "theme"]path = themeurl = ../theme.gittheme repositorycommit 1d77779… "Theme 1.0", tag v1.0recordsthe blob .gitmodulesthe gitlink pins exactly this commitsays where to clone it from

A submodule is a gitlink plus a line in .gitmodules

Text description of the diagram

The diagram shows the fest site's commit "Add the shared theme as a submodule" from the add_submodule session.

  1. The commit records a tree with three entries: the file .gitmodules, the file index.html, and theme, an entry of mode 160000 and type commit, called a gitlink.
  2. The gitlink holds the ID 1d77779…, the commit "Theme 1.0" of the theme repository, which carries the tag v1.0. The parent stores no file of the theme at all.
  3. .gitmodules, an ordinary file of the parent, maps the path theme to the URL ../theme.git, which says where a clone fetches that commit from.

Moving the submodule to another theme commit therefore changes one line of the parent's tree, and commits nothing of the theme's own files.

Cloning a project that has submodules

A clone of the parent does not fetch its submodules unless you ask:

A plain clone, then the submodule filled in, and a recursive clone Python · clone_with_submodules.py
import setup_fest  # noqa: F401  (builds theme.git and fest-site.git)
from session import run

run("""
# A plain clone: the theme folder is there, but empty
git clone -q fest-site.git plain
ls -A plain/theme
git -C plain submodule status
git -C plain -c protocol.file.allow=always submodule update --init
git -C plain submodule status
# Or ask for the submodules while cloning
git -c protocol.file.allow=always clone -q --recurse-submodules fest-site.git full
git -C full/theme status
""")

Output

# A plain clone: the theme folder is there, but empty
$ git clone -q fest-site.git plain
$ ls -A plain/theme
$ git -C plain submodule status
-1d77779008421ca001e81e800e5e9840cca4bf68 theme
$ git -C plain -c protocol.file.allow=always submodule update --init
Submodule 'theme' (~/theme.git) registered for path 'theme'
Cloning into '~/plain/theme'...
done.
Submodule path 'theme': checked out '1d77779008421ca001e81e800e5e9840cca4bf68'
$ git -C plain submodule status
 1d77779008421ca001e81e800e5e9840cca4bf68 theme (v1.0)
# Or ask for the submodules while cloning
$ git -c protocol.file.allow=always clone -q --recurse-submodules fest-site.git full
$ git -C full/theme status
HEAD detached at 1d77779
nothing to commit, working tree clean

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 clone_with_submodules.py

The plain clone has an empty theme/ folder, and git submodule status marks the submodule with -, meaning it is not initialised. git submodule update --init reads .gitmodules, registers the submodule, clones it and checks out the pinned commit; add --recursive when submodules have submodules of their own. git clone --recurse-submodules does all of this during the clone. To make commands such as git switch and git pull update submodules as well, set submodule.recurse to true.

The last command shows something that surprises people: inside the submodule, HEAD is detached at the pinned commit. That is by design, because the parent names a commit, not a branch. It matters as soon as you work inside the submodule, as the next sections show.

Moving a submodule to a new commit

The theme released v1.1. Using it in the fest site means changing the pinned commit and committing that change in the parent:

Moving the theme to v1.1 and committing the new pointer Python · update_pointer.py
import setup_fest  # noqa: F401  (builds theme.git and fest-site.git)
from session import run

run("""
git -c protocol.file.allow=always clone -q --recurse-submodules fest-site.git fest-site
cd fest-site/theme
# Inside the submodule: an ordinary repository, on a detached HEAD
git fetch -q --tags
git switch -q --detach v1.1
cd ..
git status
git diff
git diff --submodule=log
git add theme
git commit -q -m "Use theme 1.1"
git submodule status
""")

Output

$ git -c protocol.file.allow=always clone -q --recurse-submodules fest-site.git fest-site
$ cd fest-site/theme
# Inside the submodule: an ordinary repository, on a detached HEAD
$ git fetch -q --tags
$ git switch -q --detach v1.1
$ cd ..
$ 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:   theme (new commits)

no changes added to commit (use "git add" and/or "git commit -a")
$ git diff
diff --git a/theme b/theme
index 1d77779..cc56ac7 160000
--- a/theme
+++ b/theme
@@ -1 +1 @@
-Subproject commit 1d77779008421ca001e81e800e5e9840cca4bf68
+Subproject commit cc56ac7a8bfca8c18b20122dd60d66f1a1f9e12d
$ git diff --submodule=log
Submodule theme 1d77779..cc56ac7:
  > Fest colours
$ git add theme
$ git commit -q -m "Use theme 1.1"
$ git submodule status
 cc56ac7a8bfca8c18b20122dd60d66f1a1f9e12d theme (v1.1)

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 update_pointer.py

Inside theme/, git fetch and git switch --detach v1.1 move the submodule’s own checkout. The parent sees that as a change of one entry: git status reports modified: theme (new commits), and git diff replaces one Subproject commit line with another. git diff --submodule=log is easier to read: it lists the theme’s commits between the two, here Fest colours. git add theme and a commit record the new pointer, and from then on everyone who updates their submodules gets v1.1.

git submodule update --remote is a shortcut for the first two steps: it fetches and checks out the newest commit of the submodule’s remote branch (the one the remote’s HEAD names, unless .gitmodules sets another with branch). Either way, a teammate who pulls the parent’s new commit has an old theme checked out until they run git submodule update or have set submodule.recurse.

Three mistakes to avoid

Committing on a detached HEAD inside the submodule. Commits made there belong to no branch and are easy to lose. Switch to a branch first, as the next session does with git switch -c footer.

Forgetting the parent’s commit. Changing the submodule’s checkout is not enough: until you commit the new pointer in the parent, nobody else sees the change.

Pushing the parent before the submodule. The parent can name a submodule commit that exists only on your computer; pushed like that, it breaks every teammate’s git submodule update. Git can check for this:

A push that names a submodule commit nobody else has Python · push_check.py
import setup_fest  # noqa: F401  (builds theme.git and fest-site.git)
from session import run

run("""
git -c protocol.file.allow=always clone -q --recurse-submodules fest-site.git fest-site
cd fest-site/theme
git switch -q -c footer
printf 'footer { padding: 1rem; }\\n' >> theme.css
git commit -q -am "Add a footer style"
cd ..
git add theme
git commit -q -m "Use the footer style"
# The parent now names a theme commit that exists only on this computer
git push --recurse-submodules=check origin main
# Push the theme commit first, then the parent
git -C theme push -q origin footer
git push -q --recurse-submodules=check origin main
git -C ../theme.git branch --list footer
""")

Output

$ git -c protocol.file.allow=always clone -q --recurse-submodules fest-site.git fest-site
$ cd fest-site/theme
$ git switch -q -c footer
$ printf 'footer { padding: 1rem; }\n' >> theme.css
$ git commit -q -am "Add a footer style"
$ cd ..
$ git add theme
$ git commit -q -m "Use the footer style"
# The parent now names a theme commit that exists only on this computer
$ git push --recurse-submodules=check origin main
The following submodule paths contain changes that can
not be found on any remote:
  theme

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.
[exit status 128]
# Push the theme commit first, then the parent
$ git -C theme push -q origin footer
$ git push -q --recurse-submodules=check origin main
$ git -C ../theme.git branch --list footer
  footer

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 push_check.py

With --recurse-submodules=check, Git refused the push because the theme commit that the parent names is on no remote of the submodule. Pushing the submodule’s branch first fixed it. Set push.recurseSubmodules to check to make this the default, or to on-demand to have Git try to push the submodules first; on-demand pushes what the submodule’s own push settings say, so a branch with no upstream is still left behind.

Git 2.54 adds a safety for submodules you never want to commit by accident: when .gitmodules sets ignore = all for a submodule, git add <path> skips it and says so, and git add -f adds it anyway.

Subtrees: the other repository’s files in a folder

git subtree takes the other approach. It merges another repository’s files into a folder of yours, so the parent holds real files and anyone who clones it gets them without extra steps. It lives in the contrib part of Git’s source, so not every installation has it: the Git that ran these sessions does, while some Linux distributions ship it as a separate package, and git subtree -h tells you whether yours has it.

The same theme as a subtree, added at v1.0 and pulled to v1.1 Python · subtree.py
import setup_fest  # noqa: F401  (builds theme.git and fest-site.git)
from session import run, write

write("fest-subtree/index.html", "<h1>College fest</h1>\n")
run("""
git init -q fest-subtree
cd fest-subtree
git add index.html
git commit -q -m "Fest home page"
""", quiet=True)
run("""
git subtree add --prefix=theme ../theme.git v1.0 --squash
git log --oneline --graph
git ls-tree HEAD
git subtree pull --prefix=theme ../theme.git v1.1 --squash -m "Update the theme to 1.1"
git log --oneline --graph
cat theme/theme.css
""")

Output

$ git subtree add --prefix=theme ../theme.git v1.0 --squash
git fetch ../theme.git v1.0
From ../theme
 * tag               v1.0       -> FETCH_HEAD
Added dir 'theme'
$ git log --oneline --graph
*   00f34d8 Merge commit '74d1499596f8933bec48c254e1a5015b6401d3b7' as 'theme'
|\  
| * 74d1499 Squashed 'theme/' content from commit 1d77779
* aacdb99 Fest home page
$ git ls-tree HEAD
100644 blob 838575670c25fb1df90a60ccadfadc79695dfd76	index.html
040000 tree 46fa6eb0081fec70638d827e3e61d0e630dee756	theme
$ git subtree pull --prefix=theme ../theme.git v1.1 --squash -m "Update the theme to 1.1"
From ../theme
 * tag               v1.1       -> FETCH_HEAD
Merge made by the 'ort' strategy.
 theme/theme.css | 1 +
 1 file changed, 1 insertion(+)
$ git log --oneline --graph
*   85795a7 Update the theme to 1.1
|\  
| * 5832deb Squashed 'theme/' changes from 1d77779..cc56ac7
* | 00f34d8 Merge commit '74d1499596f8933bec48c254e1a5015b6401d3b7' as 'theme'
|\| 
| * 74d1499 Squashed 'theme/' content from commit 1d77779
* aacdb99 Fest home page
$ cat theme/theme.css
body { font-family: sans-serif; }
h1 { color: maroon; }

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 subtree.py

git subtree add --prefix=theme ../theme.git v1.0 --squash fetched the tag and committed its files under theme/. With --squash, the theme’s history arrives as one commit (“Squashed ‘theme/’ content from commit 1d77779”), merged into the parent’s history; without it, every commit of the theme becomes part of yours. git ls-tree HEAD now shows theme as an ordinary tree, mode 040000, where the submodule had a gitlink. git subtree pull brings v1.1 in the same way, as one squashed commit and a merge.

The cost is in the history and in the way back. Every update adds merge commits to your log, and every byte of the theme is stored in your repository. To send a fix to the theme’s own repository, you commit it in theme/ like any other change and then use git subtree push (or git subtree split) to extract those commits into a branch for the theme.

Submodule, subtree or package manager?

  • Submodule. The parent stores a gitlink to one commit and a .gitmodules entry. People who clone add --recurse-submodules or run git submodule update --init. You update by moving the submodule’s checkout and committing the pointer, and send changes back by committing and pushing inside the submodule, as in any repository. It fits code you develop alongside the parent and pin exactly: a shared theme, firmware, a fork you patch.
  • Subtree. The parent stores the files themselves, plus merge commits. People who clone do nothing extra. You update with git subtree pull and send changes back with git subtree push or split. It fits small code you rarely change and want every clone to have.
  • Package manager. The parent stores a version number in a manifest and a lock file. People who clone install the dependencies. You update by changing the version, and changes go back as a new release of the package. It fits libraries with releases.

A rule of thumb: if the other code has releases and a registry, depend on a version. If you change both repositories together, a submodule keeps them separate but exact. If the people who use your repository should never need to know there is another one, use a subtree.

Submodules and security

Cloning with --recurse-submodules lets the repository you clone tell Git to clone others and check them out into places it chooses, and that has been the route of serious bugs in Git itself:

  • CVE-2024-32002 (critical): on file systems that ignore case and support symbolic links, as macOS’s usually does, a crafted repository with submodules could make a recursive clone write files into .git/ and run a hook before the clone finished. It was fixed in Git 2.45.1, 2.44.1, 2.43.4, 2.42.2, 2.41.1, 2.40.2 and 2.39.4.
  • CVE-2025-48384 (high): a submodule path ending in a carriage return could be written to Git’s configuration without quotes, lose that character when read back, and lead a recursive clone to run a hook. It was fixed in Git 2.50.1 and in 2.49.1, 2.48.2, 2.47.3, 2.46.4, 2.45.4, 2.44.4 and 2.43.7.

Two habits protect you. Keep Git updated, and check with git version, because the Git that comes with an operating system or a set of developer tools can lag behind the latest fixes. And do not clone a repository you do not trust with --recurse-submodules: clone it plainly, read its .gitmodules, and initialise submodules only if you trust where they come from.

Practise: list the commit each submodule pins

Exercise · Medium · JavaScript

List the commit each submodule pins

A submodule is two things: a gitlink in the parent's tree, which pins one commit of the other repository, and an entry in .gitmodules, which says where to clone it from. Write pinnedSubmodules(gitmodules, lsTree) in submodules.mjs. It gets the text of .gitmodules and what git ls-tree -r HEAD printed for the same commit, and returns one { path, url, commit } for every gitlink, in the order git ls-tree lists them.

  • In git ls-tree output, each line is a mode, a type and an object ID separated by spaces, then a tab and the path. Gitlinks have the mode 160000 and the type commit; blobs and trees are not submodules.
  • .gitmodules uses Git's configuration syntax: a section [submodule "<name>"], then key = value lines. The name need not equal the path, so match a gitlink to the section whose path names it. Key names are not case-sensitive, spaces around = do not matter, a line or the end of a value may hold a comment starting with # or ;, and a value may be written in double quotes.
  • A gitlink with no .gitmodules entry gets url: null: nobody who clones the project can fetch that commit, and git submodule status stops with "no submodule mapping found". A section whose path is no longer in the tree is left out.

The sample tests use two repositories built with Git 2.54.0: the fest site of this lesson, and a campus portal with three gitlinks and an outdated .gitmodules. Their expected answers are what git config -f .gitmodules read from the same files.

Starter code · submodules.mjs

// Lists the submodules of a commit: [{ path, url, commit }], in the order git ls-tree prints them.
export function pinnedSubmodules(gitmodules, lsTree) {
  return [];
}
The sample tests · submodules.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { pinnedSubmodules } from './submodules.mjs';

// Both repositories were built with Git 2.54.0. The expected URLs are what `git config -f .gitmodules --get-regexp`
// printed for the same .gitmodules, and the commits are what `git ls-tree -r HEAD` printed.

const FEST_GITMODULES = '[submodule "theme"]\n\tpath = theme\n\turl = ../theme.git\n';
const FEST_TREE = [
  '100644 blob 2bde6f8e997ce40ac1c951fc163d16e1986f7ad4\t.gitmodules',
  '100644 blob 838575670c25fb1df90a60ccadfadc79695dfd76\tindex.html',
  '160000 commit 1d77779008421ca001e81e800e5e9840cca4bf68\ttheme',
  '',
].join('\n');

const PORTAL_GITMODULES = [
  '# Shared code the portal pins to exact commits',
  '[submodule "theme"]',
  '\tpath = vendor/theme',
  '\turl = https://git.example.org/fest/theme.git',
  '[submodule "ui-kit"]',
  '\tpath=vendor/ui',
  '\tURL = ../ui-kit.git ; moved next to the portal',
  '[submodule "old-charts"]',
  '\tpath = vendor/charts',
  '\turl = https://git.example.org/fest/charts.git',
  '',
].join('\n');
const PORTAL_TREE = [
  '100644 blob 3f56b2be00335c1d03d45a5b0319e6cd06898398\t.gitmodules',
  '100644 blob cd852aaf883511af85c14f718ed46b2777b8ca42\tindex.html',
  '160000 commit a8f618f31390e6abccba26b8c14372a72e786905\ttools/lint-rules',
  '160000 commit 1d77779008421ca001e81e800e5e9840cca4bf68\tvendor/theme',
  '160000 commit cc56ac7a8bfca8c18b20122dd60d66f1a1f9e12d\tvendor/ui',
  '',
].join('\n');

test('the fest site pins one commit of its theme', () => {
  assert.deepEqual(pinnedSubmodules(FEST_GITMODULES, FEST_TREE), [
    { path: 'theme', url: '../theme.git', commit: '1d77779008421ca001e81e800e5e9840cca4bf68' },
  ]);
});

test('blobs and trees are not submodules', () => {
  const tree = '100644 blob 838575670c25fb1df90a60ccadfadc79695dfd76\tindex.html\n040000 tree 46fa6eb0081fec70638d827e3e61d0e630dee756\ttheme\n';
  assert.deepEqual(pinnedSubmodules('', tree), [], 'a subtree keeps its files in an ordinary tree (mode 040000), so nothing is pinned');
});

test('the portal: every gitlink, in ls-tree order', () => {
  const got = pinnedSubmodules(PORTAL_GITMODULES, PORTAL_TREE);
  assert.deepEqual(got.map((s) => s.path), ['tools/lint-rules', 'vendor/theme', 'vendor/ui'], 'one entry per 160000 line, in the order git ls-tree printed them; vendor/charts is no longer in the tree');
  assert.deepEqual(got.map((s) => s.commit), ['a8f618f31390e6abccba26b8c14372a72e786905', '1d77779008421ca001e81e800e5e9840cca4bf68', 'cc56ac7a8bfca8c18b20122dd60d66f1a1f9e12d']);
});

test('the portal: a section is matched by its path, whatever its name, key case or spacing', () => {
  const ui = pinnedSubmodules(PORTAL_GITMODULES, PORTAL_TREE).find((s) => s.path === 'vendor/ui');
  assert.ok(ui, 'vendor/ui is a gitlink of the portal');
  assert.equal(ui.url, '../ui-kit.git', 'the section "ui-kit" has path=vendor/ui; "URL" is the url key, and "; moved next to the portal" is a comment');
});

test('the portal: a gitlink without a .gitmodules entry has no URL', () => {
  const lint = pinnedSubmodules(PORTAL_GITMODULES, PORTAL_TREE).find((s) => s.path === 'tools/lint-rules');
  assert.ok(lint, 'tools/lint-rules is a gitlink of the portal');
  assert.equal(lint.url, null, 'no section of .gitmodules has path = tools/lint-rules');
});

test('a value in double quotes', () => {
  const modules = '[submodule "docs"]\n\tpath = "handbook"\n\turl = "https://git.example.org/fest/handbook.git" # the club handbook\n';
  const tree = '160000 commit 1d77779008421ca001e81e800e5e9840cca4bf68\thandbook\n';
  assert.deepEqual(pinnedSubmodules(modules, tree), [{ path: 'handbook', url: 'https://git.example.org/fest/handbook.git', commit: '1d77779008421ca001e81e800e5e9840cca4bf68' }]);
});
A hint

Read .gitmodules first into a map from path to URL: remember the section you are in, and for its path and url lines keep the text after =, trimmed. Then go through the git ls-tree lines, keep only those whose mode is 160000, and look up each path in the map.

The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Check yourself

5 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.

  1. Question 2 of 5 After git clone, the theme/ folder of a project is empty and git submodule status starts its line with -. What do you run?

    Choose one answer.

    Show the answer to question 2

    Answer: git submodule update --init (with --recursive for nested submodules)

    A plain clone does not fetch submodules. The - means not initialised; git submodule update --init clones the submodule and checks out the pinned commit. git clone --recurse-submodules does it during the clone.

  2. Question 3 of 5 Put the steps in order to move the theme submodule to its tag v1.1:

    Give each item its position, from 1 (first).

    Show the answer to question 3

    Answer:

    1. Fetch inside the submodule
    2. Check out v1.1 inside the submodule
    3. git add theme in the parent
    4. Commit and push the parent

    The submodule's checkout decides which commit the parent sees; git add theme records that commit as the new gitlink, and only the parent's commit shares the change with everyone else.

  3. Question 4 of 5 You committed in the submodule and then committed the new pointer in the parent. git push --recurse-submodules=check refuses. Why?

    Choose one answer.

    Show the answer to question 4

    Answer: The parent names a submodule commit that is on no remote yet, so others could not fetch it

    Push the submodule's commit first, from a branch, and then the parent. The check exists so that nobody pushes a parent whose pinned commit others cannot clone.

  4. Question 5 of 5 Five services use the same logging library, which has numbered releases. What is usually the best way to share it?

    Choose one answer.

    Show the answer to question 5

    Answer: Publish it as a package and depend on a version

    A library with releases fits a package manager: each service names a version and updates it on purpose. Submodules suit code developed alongside the parent and pinned exactly; subtrees suit small code every clone should simply have.

Replay the sessions

setup_fest.py builds the theme and fest-site repositories that every session after the first one starts from, and session.py runs the commands. Keep both beside the session you want and start it with Python 3, for example python3 push_check.py; Git, its subtree command and Bash must be installed. Your own configuration never comes into it, because the helper treats the folder as its home and reads a Git configuration file it writes itself, and fixed commit times make the IDs match.

setup_fest.py, which builds the repositories Python · setup_fest.py
"""Builds the repositories the submodule sessions start from (it prints nothing):

  theme.git      the shared theme's server copy, with the tags v1.0 and v1.1
  fest-site.git  the fest site's server copy, which uses the theme as a submodule at v1.0
  theme          a working copy of the theme

Each session imports this file first.
"""

from session import run, write

write("theme/theme.css", "body { font-family: sans-serif; }\n")
write("fest-site/index.html", "<h1>College fest</h1>\n")
run("""
git init -q --bare theme.git
git init -q --bare fest-site.git
cd theme
git init -q
git add theme.css
git commit -q -m "Theme 1.0"
git tag -a v1.0 -m "Theme 1.0"
git push -q ../theme.git main v1.0
cd ../fest-site
git init -q
git add index.html
git commit -q -m "Fest home page"
git -c protocol.file.allow=always submodule add -q ../theme.git theme
git commit -q -m "Add the shared theme as a submodule"
git push -q ../fest-site.git main
cd ../theme
printf 'h1 { color: maroon; }\\n' >> theme.css
git commit -q -am "Fest colours"
git tag -a v1.1 -m "Theme 1.1"
git push -q ../theme.git main v1.1
cd ..
rm -rf fest-site
""", quiet=True)
session.py, which runs the sessions Python · session.py
"""Runs shell and Git commands for real and prints them the way a terminal shows them.

run(script) takes one command per line. Each command is printed after "$ ", run by bash in the session's current
folder, and followed by what it printed (standard output and standard error together). A command that fails adds a
line "[exit status N]" and the session carries on. "cd <folder>" changes the folder for the commands after it, and a
line that starts with "#" is printed as a comment. run(script, quiet=True) runs the commands and prints nothing: the
setup a session then works on.

write(path, data) creates a file with exactly the bytes given, so a "\r\n" in the text really is a carriage return and
a line feed. That is how the sessions make the files a Windows editor and a Unix editor would save.

Every computer prints the same output, commit IDs included, because the session fixes everything Git would otherwise
take from its surroundings:
  * one author and committer, and one timestamp for every commit;
  * no system configuration, and a configuration file of the session's own instead of your ~/.gitconfig, which sets
    only the name, the email, "main" as the first branch's name and no automatic garbage collection;
  * the folder the session runs in as its home folder, shown as ~ in the output;
  * no pager and no colours.
"""

import os
import shlex
import subprocess

NAME, EMAIL = "Meera Iyer", "meera.iyer@example.com"
WHEN = "1767225600 +0530"

ROOT = os.getcwd()
CONFIG = os.path.join(ROOT, "session.gitconfig")
with open(CONFIG, "w", encoding="utf-8") as f:
    f.write(
        f"[user]\n\tname = {NAME}\n\temail = {EMAIL}\n"
        "[init]\n\tdefaultBranch = main\n"
        "[gc]\n\tauto = 0\n"
        "[maintenance]\n\tauto = false\n"
    )

ENV = dict(os.environ)
ENV.update(
    HOME=ROOT,
    XDG_CONFIG_HOME=os.path.join(ROOT, ".config"),
    GIT_CONFIG_NOSYSTEM="1",
    GIT_CONFIG_GLOBAL=CONFIG,
    GIT_AUTHOR_DATE=WHEN,
    GIT_COMMITTER_DATE=WHEN,
    GIT_PAGER="cat",
    GIT_EDITOR="false",
)

cwd = ROOT


def write(path: str, data: str | bytes) -> None:
    """Writes `data` to `path` (relative to the session's home folder) byte for byte."""
    full = os.path.join(ROOT, path)
    os.makedirs(os.path.dirname(full), exist_ok=True)
    with open(full, "wb") as f:
        f.write(data.encode("utf-8") if isinstance(data, str) else data)


def shown(text: str) -> str:
    """The output with the session's folder written as ~, as a terminal at home would show it."""
    return text.replace(ROOT + "/", "~/").replace(ROOT, "~")


def run(script: str, quiet: bool = False) -> None:
    """Runs each line of `script` and prints the session (nothing with quiet=True)."""
    global cwd
    for line in script.strip().splitlines():
        command = line.strip()
        if not command:
            continue
        if command.startswith("#"):
            if not quiet:
                print(command)
            continue
        if not quiet:
            print(f"$ {command}")
        words = shlex.split(command)
        if words[0] == "cd" and len(words) == 2:
            target = os.path.normpath(os.path.join(cwd, words[1]))
            if not os.path.isdir(target):
                raise SystemExit(f"cd: {words[1]}: no such folder in the session")
            cwd = target
            continue
        result = subprocess.run(
            command,
            shell=True,
            executable="bash",
            cwd=cwd,
            env=ENV,
            stdin=subprocess.DEVNULL,
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
        )
        output = shown(result.stdout.decode("utf-8", errors="replace"))
        if quiet:
            if result.returncode != 0:
                raise SystemExit(f"setup command failed: {command}\n{output}")
            continue
        print(output, end="" if output.endswith("\n") or not output else "\n")
        if result.returncode != 0:
            print(f"[exit status {result.returncode}]")

Interview questions

What does a submodule store in the parent repository? (warm-up)

Two things. The parent’s tree holds a gitlink: an entry with the mode 160000 and the type commit, whose value is the ID of one commit of the other repository. None of the other repository’s files are stored. And the parent has a .gitmodules file, which maps the submodule’s path to the address to clone it from, plus optional settings such as a branch to follow. A clone therefore knows exactly which commit to check out and where to fetch it, but it fetches nothing until you run git submodule update --init or clone with --recurse-submodules. Updating a submodule means checking out another commit inside it and committing the parent’s changed gitlink.

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Fresher to mid

    After pulling main, Asha's build fails because the theme submodule is still the old version, and Ravi, who cloned the project yesterday, has an empty theme folder. Explain both, fix them, and change the team's setup so it stops happening.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to staff

    A UI component library must be shared by five web apps in separate repositories, each releasing on its own schedule. Choose between submodules, git subtree and a private package, defend the choice, and say how a bug fix found in one app reaches the library and the other four apps.

    Model answer, Pro. First step and follow-ups

Key takeaways

  • A submodule is a gitlink (mode 160000) that pins one commit of another repository, plus a .gitmodules entry that says where to clone it from.
  • Clone with --recurse-submodules, or run git submodule update --init --recursive after a plain clone; inside a submodule, HEAD is detached at the pinned commit.
  • To update, move the submodule’s checkout and commit the new pointer in the parent; push the submodule first, and let --recurse-submodules=check catch it when you forget.
  • git subtree stores the other repository’s files in a folder of yours: nothing extra for people who clone, a heavier history for you.
  • Prefer a package manager for libraries with releases.
  • Keep Git updated and never clone an untrusted repository with --recurse-submodules.

References

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.