Josh compared
Josh, "Just One Single History", shows a folder of a monorepo as a repository of its own by filtering the monorepo's history. Its filters are reversible: filtering a commit always gives the same commit, and filtering the result back gives the original. So the library's repository and Josh's filtered view of the monorepo share commits, hash for hash, but only if the monorepo holds the library's whole history, as copies of its commits.
git-splice keeps the two histories apart. This page runs both on the same
library and monorepo, with josh-filter, Josh's command-line front end to
its filters, the way Josh's
import guide
uses it. It shows that the library gets the same commits from both tools,
and what the monorepo has to hold for that.
The setup: https://git.example.com/lib.git, a library with 30 commits,
and a monorepo with an app. How these pages run.
$ josh-filter --version
Version: r24.10.04
Import the library
Josh imports the library by rewriting its history as if it had always been
developed in vendor/lib/, and merging that into the monorepo:
$ git fetch -q https://git.example.com/lib.git main && josh-filter ':prefix=vendor/lib' FETCH_HEAD && git merge -q --allow-unrelated-histories -m "import vendor/lib" FILTERED_HEAD
The monorepo now has a copy of each of the library's 30 commits, with
other hashes, since their trees have the files under vendor/lib/:
$ git log --oneline -2 FILTERED_HEAD
aa49e17 lib commit 30
7934fd0 lib commit 29
Filtering vendor/lib/ out of the monorepo gives back the library's own
commits:
$ josh-filter ':/vendor/lib' && git rev-parse FILTERED_HEAD && git -C "$COMPARISON/upstream/lib.git" rev-parse main
bafd496b1f4512a95b69464604f3da428aa9e33b
bafd496b1f4512a95b69464604f3da428aa9e33b
Fix the library: the same commit
You fix the library and use the fix in the app, in one commit:
$ echo "fix" >>vendor/lib/src/parse.txt && echo "call parse()" >>app/main.txt && git commit -q -a -m "fix parse() and call it"
Filtered, it's a library commit on top of the library's main, which the
library could take as a fast-forward:
$ josh-filter ':/vendor/lib' && git log --oneline -2 FILTERED_HEAD
5069701 fix parse() and call it
bafd496 lib commit 30
The same fix with git-splice, which brings in no history:
$ 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 commit -q -a -m "fix parse() and call it" && git splice push vendor/lib
ok vendor/lib: pushed 5069701 to main
The library gets 5069701 from both. Josh and git-splice copy a commit's
author, committer, dates and message the way git subtree split does, so
the same change on top of the same library commit is the same commit, as
long as it has no signature or other extra header. Josh keeps such
headers by default, a signature then no longer verifies, while git-splice
copies only the fields above and never signs.
Neither needs to remember which commits it pushed: it computes them again.
A release breaks the app: git bisect
The app's test needs the fix in the library:
$ printf '%s\n' 'grep -q "call parse()" app/main.txt && grep -qx fix vendor/lib/src/parse.txt' >"$COMPARISON/test-app.sh" && sh "$COMPARISON/test-app.sh" && echo pass
pass
The library gets four commits, and one of them renames the line the app relies on:
$ git clone -q https://git.example.com/lib.git ../lib-release && cd ../lib-release && echo "parse(text)" >README.md && git add README.md && git commit -q -m "docs: explain parse()" && sed -i 's/^fix$/fixed/' src/parse.txt && git commit -q -a -m "parse: rename fix to fixed"
$ echo "example" >examples.txt && git add examples.txt && git commit -q -m "add examples" && echo "1.2" >VERSION && git add VERSION && git commit -q -m "release 1.2" && git push -q && cd ../splice-monorepo
Josh: the culprit is a monorepo commit
The Josh monorepo hasn't changed the library since the fix, so the
library's commits land on top of it. --reverse turns each into a
monorepo commit, here into a ref that main then fast-forwards to:
$ cd ../monorepo && git fetch -q https://git.example.com/lib.git main && git update-ref FILTERED_HEAD FETCH_HEAD && git update-ref refs/josh/main main && josh-filter --reverse ':/vendor/lib' refs/josh/main && git merge -q --ff-only refs/josh/main
$ git log --oneline -5
99943ff release 1.2
372e8a6 add examples
092b6b1 parse: rename fix to fixed
0ea8b10 docs: explain parse()
95dc6e0 fix parse() and call it
The test fails now, and git bisect finds the library's commit among the
monorepo's own:
$ git bisect start HEAD HEAD~4 >/dev/null && git bisect run sh "$COMPARISON/test-app.sh" >/dev/null && git bisect log | tail -1 && git bisect reset >/dev/null
# first bad commit: [092b6b1e37d8035965e55a4856c921988c941e39] parse: rename fix to fixed
git-splice: the culprit is the pull
$ cd ../splice-monorepo && git splice pull vendor/lib
ok vendor/lib fetched (main moved 5069701..612d462)
ok vendor/lib: pulled 612d462
git bisect stops at the pull, which brought all four commits at once:
$ git bisect start HEAD HEAD~1 >/dev/null && git bisect run sh "$COMPARISON/test-app.sh" >/dev/null && git bisect log | tail -1 && git bisect reset >/dev/null
# first bad commit: [f85ff0501eca7cb3482ec7e84b7da5fd966724d5] splice: pull vendor/lib from main at 612d462
To go deeper, bisect the library's history between the two synced
commits, U(X^) and U(X), which .splice records before and after the pull X. Its
commits are fetched into the monorepo already, so a worktree of the
monorepo can check them out. A second, throwaway worktree runs the app's
test, so your own checkout stays as it is:
$ git worktree add -q --detach ../app-bisect && git worktree add -q --detach ../lib-bisect && cd ../lib-bisect && git bisect start "$(git config --blob HEAD:vendor/lib/.splice splice.commit)" "$(git config --blob HEAD~1:vendor/lib/.splice splice.commit)" >/dev/null
The library alone can't run the app's test, so each step replaces the throwaway worktree's folder with the library's files at that step:
$ git bisect run sh -c 'rm -rf ../app-bisect/vendor/lib && mkdir ../app-bisect/vendor/lib && git archive HEAD | tar -x -C ../app-bisect/vendor/lib && cd ../app-bisect && sh "$COMPARISON/test-app.sh"' >/dev/null && git bisect log | tail -1
# first bad commit: [9c8481ced0e577e419cebdf1caa1d444e05944f7] parse: rename fix to fixed
$ git bisect reset >/dev/null && cd ../splice-monorepo && git worktree remove ../lib-bisect && git worktree remove --force ../app-bisect
That's the library's own commit, of which Josh's 092b6b1 is the
monorepo's copy. Replacing the folder works here because it had no
changes of its own since U(X^). With unpushed changes, each step would need
them merged in.
Both sides move on
The library gets a release:
$ git clone -q https://git.example.com/lib.git ../lib-dev && echo "release 1.3" >>../lib-dev/src/parse.txt && git -C ../lib-dev commit -q -a -m "release 1.3" && git -C ../lib-dev push -q
Meanwhile, each monorepo adds notes to the library:
$ echo "notes" >vendor/lib/NOTES && git add vendor/lib && git commit -q -m "add notes to lib"
$ cd ../monorepo && echo "notes" >vendor/lib/NOTES && git add vendor/lib && git commit -q -m "add notes to lib"
Josh: merge in the filtered history
The release and the notes now diverge, and Josh merges them in the library's history, where both sides share commits. A worktree of the filtered history stands in for a checkout of Josh's view:
$ git fetch -q https://git.example.com/lib.git main:refs/lib/main && josh-filter ':/vendor/lib' && git worktree add -q --detach ../lib-view FILTERED_HEAD && git -C ../lib-view merge -q -m "merge release 1.3" refs/lib/main
--reverse carries the merge back into the monorepo, here into a ref that
main then fast-forwards to:
$ git update-ref FILTERED_HEAD "$(git -C ../lib-view rev-parse HEAD)" && git update-ref refs/josh/main main && josh-filter --reverse ':/vendor/lib' refs/josh/main && git merge -q --ff-only refs/josh/main
The release reaches the monorepo as a commit of its own, joined by the merge:
$ git log --oneline --graph -4
* 151bbf6 merge release 1.3
|\
| * 148e97c release 1.3
* | 64fc20e add notes to lib
|/
* 99943ff release 1.2
git-splice: one pull commit
$ cd ../splice-monorepo && git splice pull vendor/lib
ok vendor/lib fetched (main moved 612d462..bc443d9)
ok vendor/lib: pulled bc443d9
$ git log --oneline --graph -3
* 49407e9 splice: pull vendor/lib from main at bc443d9
* 5b9db00 add notes to lib
* f85ff05 splice: pull vendor/lib from main at 612d462
The release stays in the library's history, in refs/splices/lib/main.
The monorepo's history counts its own commits only:
$ git rev-list --count HEAD && git -C ../monorepo rev-list --count HEAD
45
79
Whose history is it?
With Josh, the library's history is a view of the monorepo's: one
project, seen through a folder. With git-splice, the library has a history
of its own, and the monorepo integrates it. The difference shows when the
monorepo adopts a library change on a branch, glues it into the app, and
merges the branch.
In the Josh monorepo, the branch changes the library, adapts the app, and fixes a typo in the library change:
$ cd ../monorepo && git switch -q -c parse-options && echo "options" >vendor/lib/src/options.txt && git add vendor/lib && git commit -q -m "lib: add parse() options" && echo "parse(options)" >>app/main.txt && git commit -q -a -m "app: pass options to parse()" && echo "options, fixed" >vendor/lib/src/options.txt && git commit -q -a -m "lib: fix options typo"
$ git switch -q main && echo "deps" >app/deps.txt && git add app && git commit -q -m "app: bump dependencies" && git merge -q --no-ff -m "Merge branch 'parse-options'" parse-options
The library gets the monorepo's merge, with its branch name, and both of the branch's library commits, the typo fix included. Only the commits that didn't touch the library, the app's glue and the dependency bump, stay behind:
$ josh-filter ':/vendor/lib' && git log --oneline --graph -6 FILTERED_HEAD
* 2788671 Merge branch 'parse-options'
|\
| * 3b58a7e lib: fix options typo
| * 0833578 lib: add parse() options
|/
* 23ff56b merge release 1.3
|\
| * bc443d9 release 1.3
* | 15560d7 add notes to lib
|/
The same branch and merge in the git-splice monorepo:
$ cd ../splice-monorepo && git switch -q -c parse-options && echo "options" >vendor/lib/src/options.txt && git add vendor/lib && git commit -q -m "lib: add parse() options" && echo "parse(options)" >>app/main.txt && git commit -q -a -m "app: pass options to parse()" && echo "options, fixed" >vendor/lib/src/options.txt && git commit -q -a -m "lib: fix options typo"
$ git switch -q main && echo "deps" >app/deps.txt && git add app && git commit -q -m "app: bump dependencies" && git merge -q --no-ff -m "Merge branch 'parse-options'" parse-options
A push follows the monorepo's first parents, so the merge reaches the library as one commit, with the library's half of the branch:
$ git splice push vendor/lib
ok vendor/lib: pushed ea668f6 to main
$ git -C "$COMPARISON/upstream/lib.git" log --oneline --graph -6 main
* ea668f6 Merge branch 'parse-options'
* ef0b95a splice: pull vendor/lib from main at bc443d9
|\
| * bc443d9 release 1.3
* | 15560d7 add notes to lib
|/
* 612d462 release 1.2
* efa30d9 add examples
Below it, both libraries have the same merge of the earlier pull: the same
parents, 15560d7 and bc443d9, with Josh's message or git-splice's.
A merge that joins the library's own history with the monorepo's changes
belongs to the library, and both tools send it. A merge of the monorepo's
branches is integration, and only Josh sends it.
The message is still the monorepo's: Merge branch 'parse-options'. A
squash merge, or a merge message written for the library, reaches the
library as written.
Where Josh is better
git bisect,git blameandgit logreach the library's commits. In the Josh monorepo, they're ancestors, so a bisect lands on the library's commit, and blame names its author. In thegit-splicemonorepo, they stop at the pull that brought the commits in, and going further takes a second bisect,git log, orgit blameon the library's history, between the synced commits.- Nothing to conflict on a pull. Two monorepo branches that both pull a
splice conflict in
.splice, and you keep the newer synced commit. Josh stores no sync point, so there's nothing to resolve but the files. - Any slice of the monorepo can be a repository. A view can combine folders, change on demand and be served to people who can't see the rest of the monorepo. A splice is one folder with one upstream, set up beforehand.
- Built for large monorepos. Josh filters whole histories quickly, with a cache that can be shared between machines.
Where Josh is different
- History, not a sync point. Josh needs no sync point: a commit's
filtered hash says where the two sides meet.
git-splicerecords that meeting point, the synced commit, in.splice, so it needs no shared history. Josh's own design for repositories it didn't import, such asrust-lang/mirinext torust-lang/rust, lists how to relate them at their sync points as an open question. Today, the externalrustc-josh-synckeeps the last synced commit in arust-versionfile of the smaller repository. - Git does less of the work. Josh filters, walks history and merges
trees in Rust, on gitoxide, with a cache, so it can filter whole
histories quickly, and calls the
gitcommand for fetches and pushes.git-splicecalls Git for every step, and only walks history since the boundary. - One project, or two owners. With Josh, the library's history is the
monorepo's, filtered: a monorepo branch reaches the library with its
commits and merge, and pushing to a view lands in the monorepo. That
suits one team, or one owner, working in one project.
git-splicekeeps two histories that meet at the synced commit: the library's maintainers review and release in their own repository, the app's team integrates in the monorepo, the monorepo's merges and app-only commits stay out of the library's history, and the library's commits out of the monorepo's (Whose history is it?).rust-lang/mirihas its own repository and maintainers too, butrustc-josh-syncsyncs it withrust-lang/rustthrough Josh, so miri's history has copies of the Rust commits that changed it. - Links are unreleased, and go one way.
josh link, experimental and on Josh'smastersince September 2026 but in no release yet, publishes a folder to a repository of its own. A link's id and the one upstream branch it tracks live in a ref of the monorepo,refs/josh/links/<id>, not in the folder, and nothing comes back through a link. A splice's id and synced commit are committed in its folder, so they move along withgit mv, and each monorepo branch syncs with the upstream branch of its name, the default branch with the upstream's.
In short
| Josh | git-splice | |
|---|---|---|
| Library history in the monorepo | whole, as copies under the folder | none, one commit per sync |
| Commits a push sends | the filtered history, the same as git subtree split for commits without a signature or extra headers | the first-parent commits since the boundary, otherwise the same as git subtree split |
git bisect for a library bug | finds the monorepo's copy of the library's commit | finds the pull, then a second bisect between the synced commits does |
| A monorepo branch merged | a merge, with the branch's commits | one commit, with the merge's message |
| Where both sides meet | commits of the filtered view that the library has | the synced commit in .splice |
| An upstream commit pulled in | one monorepo commit for each, and a merge if both sides moved | one pull commit for all |
| Merges a sync adds to the monorepo | one when both sides moved | none |
| Implementation | Rust, gitoxide, a cache | Bash, the git command |
| Best for | a monorepo that is the source of truth, served in parts | a library with its own repository, changed in the monorepo |