git submodule compared
A submodule keeps the library in its own repository, nested in the
monorepo's worktree. The monorepo commits only the library's URL, in
.gitmodules, and one of its commits, as a "gitlink". This page shows
what that boundary means when you fix the library together with your app,
and in a second worktree. It ends with what submodules do better.
The setup: https://git.example.com/lib.git, a library with 30 commits,
and a monorepo with an app. How these pages run.
$ git submodule add -q https://git.example.com/lib.git vendor/lib && git commit -q -m "add lib as a submodule"
The monorepo doesn't see the library's files
You fix the library and use the fix in the app, in one go:
$ echo "fix" >>vendor/lib/src/parse.txt && echo "call parse()" >>app/main.txt
status names the changed app file, but only the submodule, not the file
in it:
$ git status --short
M app/main.txt
m vendor/lib
diff shows the app's change and, for the library, only that the
submodule's checkout is dirty:
$ git diff --stat
app/main.txt | 1 +
vendor/lib | 0
2 files changed, 1 insertion(+)
$ git diff -- vendor/lib
diff --git a/vendor/lib b/vendor/lib
--- a/vendor/lib
+++ b/vendor/lib
@@ -1 +1 @@
-Subproject commit bafd496b1f4512a95b69464604f3da428aa9e33b
+Subproject commit bafd496b1f4512a95b69464604f3da428aa9e33b-dirty
To be fair, Git can look inside when asked:
$ git diff --submodule=diff -- vendor/lib
Submodule vendor/lib contains modified content
diff --git a/vendor/lib/src/parse.txt b/vendor/lib/src/parse.txt
index ac9837c..66931ff 100644
--- a/vendor/lib/src/parse.txt
+++ b/vendor/lib/src/parse.txt
@@ -28,3 +28,4 @@ line 27
line 28
line 29
line 30
+fix
Committing takes two commits, in two repositories. The monorepo's commit records only that the gitlink moved:
$ git -C vendor/lib commit -q -a -m "fix parse()" && git commit -q -a -m "app: call parse()"
$ git show --stat --format=%s
app: call parse()
app/main.txt | 1 +
vendor/lib | 2 +-
2 files changed, 2 insertions(+), 1 deletion(-)
So the monorepo's history of a library file is empty, and its search doesn't find the fix unless asked to recurse:
$ git log --oneline -- vendor/lib/src/parse.txt
Only the patches of commits that moved the gitlink can show what changed
inside, with --submodule=diff again:
$ git log -1 -p --submodule=diff --format=%s -- vendor/lib
app: call parse()
Submodule vendor/lib bafd496..a6152f5:
diff --git a/vendor/lib/src/parse.txt b/vendor/lib/src/parse.txt
index ac9837c..66931ff 100644
--- a/vendor/lib/src/parse.txt
+++ b/vendor/lib/src/parse.txt
@@ -28,3 +28,4 @@ line 27
line 28
line 29
line 30
+fix
status has no such option. It never names a file inside a submodule,
as above, and rejects --recurse-submodules:
$ git status --recurse-submodules | head -1
error: unknown option `recurse-submodules'
$ git grep -c fix -- vendor/lib; git grep -c --recurse-submodules fix -- vendor/lib
vendor/lib/src/parse.txt:1
The same fix in a splice is one ordinary commit, and every command sees the file. Here in a copy of the monorepo from before the submodule:
$ cd ../splice-monorepo && git splice clone https://git.example.com/lib.git vendor/lib
=== vendor/lib: fetching https://git.example.com/lib.git
ok vendor/lib: cloned bafd496 from main
$ echo "fix" >>vendor/lib/src/parse.txt && echo "call parse()" >>app/main.txt && git status --short
M app/main.txt
M vendor/lib/src/parse.txt
$ git commit -q -a -m "fix parse() and call it" && git show --stat --format=%s
fix parse() and call it
app/main.txt | 1 +
vendor/lib/src/parse.txt | 1 +
2 files changed, 2 insertions(+)
$ git log --oneline -- vendor/lib/src/parse.txt
22698ef fix parse() and call it
3ab92de splice: clone vendor/lib from main at bafd496
$ cd ../monorepo
Two repositories, two pushes, in the right order
The monorepo's commit points at a library commit that only exists in this
clone. Push the monorepo first, and colleagues get a commit they can't
check out. (The grep drops what the server side prints itself, here
on the same terminal and in a random order with the rest, as the
"server" is a local folder.)
$ git clone -q --bare --no-local . ../origin.git && git clone -q --recurse-submodules ../origin.git ../colleague 2>&1 | grep -v '^fatal: git upload-pack'
fatal: remote error: upload-pack: not our ref a6152f5cee8b2bfd6b5dfc7bbb2bf5d8a7562325
fatal: Fetched in submodule path 'vendor/lib', but it did not contain a6152f5cee8b2bfd6b5dfc7bbb2bf5d8a7562325. Direct fetching of that commit failed.
The library's commit has to be pushed first:
$ git -C vendor/lib push -q && rm -rf ../colleague && git clone -q --recurse-submodules ../origin.git ../colleague && echo cloned
cloned
Here the submodule was on main, because git submodule add cloned it.
In the colleague's clone, git submodule update checked out the
monorepo's recorded commit, on no branch. A fix committed there needs a
branch first (git switch -c), or a push as HEAD:<branch>:
$ git -C ../colleague/vendor/lib status --short --branch
## HEAD (no branch)
A splice's files are part of the monorepo commit, so a colleague's clone
is complete whether or not the fix reached the library yet. Pushing it
there is a separate step, git splice push, for when the fix is ready.
A second worktree
git worktree add checks out another branch next to this one. The
submodule stays empty there:
$ git worktree add -q ../feature -b feature && ls -A ../feature/vendor/lib
$ git -C ../feature submodule status
-a6152f5cee8b2bfd6b5dfc7bbb2bf5d8a7562325 vendor/lib
Each worktree initializes its submodules on its own, with its own clone of
the library, under .git/worktrees/<name>/modules/:
$ git -C ../feature submodule update -q --init && cat ../feature/vendor/lib/.git
gitdir: ../../../monorepo/.git/worktrees/feature/modules/vendor/lib
The submodule's own repository, with its own .git/config, is separate
in each worktree. But the monorepo's settings for the submodule,
submodule.<name>.* like its URL, live in the monorepo's .git/config,
which all worktrees share. A change in one worktree changes the others:
$ git -C ../feature config submodule.vendor/lib.url https://git.example.com/lib-fork.git && git config submodule.vendor/lib.url
https://git.example.com/lib-fork.git
$ git config submodule.vendor/lib.url https://git.example.com/lib.git
And Git refuses to move a worktree that has submodules, and removes it
only with --force:
$ git worktree move ../feature ../feature-2
fatal: working trees containing submodules cannot be moved or removed
[128]
$ git worktree remove ../feature
fatal: working trees containing submodules cannot be moved or removed
[128]
$ git worktree remove --force ../feature && echo removed
removed
Git's documentation says so too: "Multiple checkout in general is still
experimental, and the support for submodules is incomplete. It is NOT
recommended to make multiple checkouts of a superproject."
(git worktree, BUGS)
A splice lives in the worktree. A new worktree has it, with nothing to
initialize. The fix from main isn't upstream yet, so a push from
feature would bring it along:
$ cd ../splice-monorepo && git worktree add -q ../splice-feature -b feature && cd ../splice-feature && git splice status
On branch feature
new, ahead 1 vendor/lib push creates the branch
What git-splice keeps outside the worktree, the fetched upstream
branches under refs/splices/, is shared by all worktrees, like
refs/remotes/: it describes the upstream, which is the same from every
worktree. A push from one worktree is seen by the others:
$ echo "fix 2" >>vendor/lib/src/parse.txt && git commit -q -a -m "fix 2" && git splice push vendor/lib
?? vendor/lib: upstream has no 'feature' branch yet -- this push creates it
ok vendor/lib: pushed 6b3df25 to feature
$ cd ../splice-monorepo && git for-each-ref --format='%(refname)' refs/splices
refs/splices/lib/feature
refs/splices/lib/main
$ git worktree remove ../splice-feature && echo removed
removed
One limit: two fetches at the same time, in two worktrees, may both try to
move the same ref. Then one fails with cannot lock ref, and running it
again fixes it, as with two git fetch at once.
Where submodules are better
- The library's files stay out of the monorepo's history. A clone
that doesn't need them,
git clonewithout--recurse-submodules, doesn't download them. A splice's files are monorepo content, in every clone. - The commit pins an exact upstream revision. A splice's commit is where it last matched upstream; the folder may have changed since.
- Access stays separate. The library can be private while the monorepo isn't, or the other way round.
- Settings can be overridden per clone. Pointing a submodule at a
mirror or fork takes a
git configchange, not a commit. A splice's URL is committed in.splice, buturl.<base>.insteadOfoverrides it the same way, without a commit, as these pages do to reachhttps://git.example.com/on this machine:
$ git config --get-regexp 'url\..*insteadof'
url.$COMPARISON/upstream/.insteadof https://git.example.com/