git subtree compared
git subtree
puts the library's files into a folder of the monorepo, like a splice.
add and pull merge the library's history into the monorepo's, either
whole or squashed into one commit. split and push turn the monorepo's
commits that touched the folder back into library commits. This page shows
what each costs, and where git subtree is the better tool.
The setup: https://git.example.com/lib.git, a library with 30 commits by
three developers, and a monorepo with an app and 40 commits.
How these pages run.
$ git branch before-lib && git rev-list --count HEAD
40
The whole history
Without --squash, add merges all of the library's commits into the
monorepo:
$ git subtree add -q --prefix=vendor/lib https://git.example.com/lib.git main
git fetch https://git.example.com/lib.git main
From $COMPARISON/upstream/lib
* branch main -> FETCH_HEAD
$ git rev-list --count HEAD
71
$ git log --graph --format=%s -4
* Add 'vendor/lib/' from commit 'bafd496b1f4512a95b69464604f3da428aa9e33b'
|\
| * lib commit 30
| * lib commit 29
| * lib commit 28
The library's commits keep their authors, and git blame follows each
line into them, to the path the file had in the library:
$ git blame -L 1,3 vendor/lib/src/parse.txt
^136dd00 src/parse.txt (Lib Dev 2 2026-01-01 00:00:00 +0000 1) line 1
b7c06ff8 src/parse.txt (Lib Dev 3 2026-01-01 00:00:00 +0000 2) line 2
0089dc84 src/parse.txt (Lib Dev 1 2026-01-01 00:00:00 +0000 3) line 3
git log limited to the folder doesn't cross into them, though. The
library's commits had no vendor/lib/, so the folder's log starts at the
merge:
$ git log --oneline -- vendor/lib
83c95bf Add 'vendor/lib/' from commit 'bafd496b1f4512a95b69464604f3da428aa9e33b'
This is where git subtree is the right tool: moving a repository into
the monorepo for good, and retiring its old home. The history, and git blame with it, comes along.
What split walks
Two fixes to the library, between app commits:
$ for change in "app commit 41" "lib fix 1" "app commit 42" "lib fix 2"; do case $change in app*) echo "$change" >>app/main.txt ;; lib*) echo "$change" >>vendor/lib/src/parse.txt ;; esac; git commit -q -a -m "$change"; done
split rebuilds library commits from the monorepo's commits. It starts
from the newest commit it already knows, and here those are the library
commits that add imported. So it walks only the few commits since:
$ subtree_split_walks vendor/lib
7
Squashed
--squash keeps the library's history out: add merges a single commit
with the library's files:
$ git switch -q -c squashed before-lib && git subtree add -q --squash --prefix=vendor/lib https://git.example.com/lib.git main
git fetch https://git.example.com/lib.git main
From $COMPARISON/upstream/lib
* branch main -> FETCH_HEAD
$ git rev-list --count HEAD
42
It's still a merge, so the history isn't linear, and git blame stops at
the squashed commit:
$ git log --graph --format=%s -3
* Merge commit '8e0bc3b1b1c045b4dc98c2fc7d471a64fae6a2b7' as 'vendor/lib'
|\
| * Squashed 'vendor/lib/' content from commit bafd496
* app commit 40
$ git blame -s -L 1,3 vendor/lib/src/parse.txt
^8e0bc3b src/parse.txt 1) line 1
^8e0bc3b src/parse.txt 2) line 2
^8e0bc3b src/parse.txt 3) line 3
The same two fixes:
$ for change in "app commit 41" "lib fix 1" "app commit 42" "lib fix 2"; do case $change in app*) echo "$change" >>app/main.txt ;; lib*) echo "$change" >>vendor/lib/src/parse.txt ;; esac; git commit -q -a -m "$change"; done
Now split knows no library commit in the monorepo's history to start
from, so it walks all of it, every time, though only two commits touched
the library:
$ subtree_split_walks vendor/lib; git rev-list --count HEAD
46
46
split --rejoin avoids that: it merges the split result back into the
monorepo, as a sync point for the next split. That's one more merge
commit for every push, and a pull request that's squash-merged drops it,
with the sync point.
The same with git-splice
In a copy of the monorepo from before the library, clone adds one
ordinary commit, and the history stays linear:
$ 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
$ git rev-list --count HEAD; git log --graph --format=%s -3
41
* splice: clone vendor/lib from main at bafd496
* app commit 40
* app commit 39
The same two fixes:
$ for change in "app commit 41" "lib fix 1" "app commit 42" "lib fix 2"; do case $change in app*) echo "$change" >>app/main.txt ;; lib*) echo "$change" >>vendor/lib/src/parse.txt ;; esac; git commit -q -a -m "$change"; done
push only looks at the commits since the boundary, usually the newest
commit that changed .splice: the last clone or pull. It doesn't need the
library's history in the monorepo, nor the monorepo's history before that
commit. These are the commits it walks, following first parents:
$ git log --first-parent --format=%s "$(git log -1 --format=%H -- vendor/lib/.splice)"..HEAD
lib fix 2
app commit 42
lib fix 1
app commit 41
It rebuilds the two that changed the library on top of the library's history:
$ git splice push vendor/lib
ok vendor/lib: pushed 48108da to main
$ git -C "$COMPARISON/upstream/lib.git" log --format=%s -4 main
lib fix 2
lib fix 1
lib commit 30
lib commit 29
Like --squash, a splice keeps the library's history out of the
monorepo, so git blame stops at the clone:
$ git blame -s -L 1,3 vendor/lib/src/parse.txt
3ab92de9 1) line 1
3ab92de9 2) line 2
3ab92de9 3) line 3
The library's history is still at hand, fetched under
refs/splices/lib/, but not part of the monorepo's.