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. Filtering a commit always gives the same commit, and filtering a change back onto the monorepo's history, which has what the view leaves out, gives the monorepo's commit. So the library's repository and Josh's view of the folder share commits, hash for hash, as long as the monorepo holds copies of the library's whole history.
git-splice keeps the two histories apart. This page runs both, Josh
with josh-filter as its
import guide
does: the library gets the same commits from both, and the difference is
what the monorepo holds.
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: r26.05.08
Import the library
Josh rewrites the library's history as if it had always lived in
vendor/lib/, and merges it 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
aa49e1798265c0235945ab8c9597d4c5de166969
The monorepo now has a copy of each of the library's 30 commits, with
other hashes, since their files are under vendor/lib/:
$ git log --oneline -2 FILTERED_HEAD
aa49e17 lib commit 30
7934fd0 lib commit 29
Filtering vendor/lib/ out again gives back the library's own commits:
$ josh-filter ':/vendor/lib' && git rev-parse FILTERED_HEAD
bafd496b1f4512a95b69464604f3da428aa9e33b
bafd496b1f4512a95b69464604f3da428aa9e33b
$ git -C "$COMPARISON/upstream/lib.git" rev-parse main
bafd496b1f4512a95b69464604f3da428aa9e33b
With git-splice: one commit
The clone, c, brings in the library's files, but none of its 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
Fix the library: the same commit
You fix the library and use the fix in the app, in one commit, f. It sits on i, the import, which merged k, the copy of the library's newest commit, with a, the app's:
$ cd ../monorepo &&
> 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 commit on top of the library's main, a fast-forward:
$ josh-filter ':/vendor/lib' && git log --oneline -2 FILTERED_HEAD
50697019d17eb5ce5e06b61545e3947bb0c08f55
5069701 fix parse() and call it
bafd496 lib commit 30
With git-splice: the same commit
The same fix, pushed:
$ cd ../splice-monorepo &&
> 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
Josh's view has the commit git-splice pushed, 5069701, so a push from
Josh would send the same. Like git subtree split, both keep the
author, committer, dates and message, so the same change on the same
library commit is the same commit (the example fixes the dates). A
signature or other extra header breaks that: Josh keeps it, so a
signature no longer verifies, and git-splice drops it.
A release breaks the app: git bisect
The app's test needs the fix in the library:
$ cat >"$COMPARISON/test-app.sh" <<'EOF'
> grep -q "call parse()" app/main.txt && grep -qx fix vendor/lib/src/parse.txt
> EOF
$ sh "$COMPARISON/test-app.sh" && echo pass
pass
The library gets four commits, d, r, e and v, and r 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
The Josh monorepo hasn't changed the library since the fix, so
--reverse turns each of the library's commits into a monorepo commit,
on a ref that main 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
99943ffa60f0e666d7c9d14a1bcb1c556f6d9b98
$ 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 r among the monorepo's own
commits:
$ 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
With git-splice: the culprit is the pull
The pull x brings all four commits at once. They stay in the library's history, after f', the fix as the monorepo pushed it, up to U(x), the synced commit after 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:
$ 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 from f', where the app
last passed, to U(x). git splice rebuild at f prints f'; U(f), the
synced commit before the pull, won't do: it's k, from before the fix.
The library's commits are fetched, so one worktree checks them out, and
a second, throwaway one runs the app's test:
$ git worktree add -q --detach ../app-bisect &&
> git worktree add -q --detach ../lib-bisect HEAD~1 &&
> cd ../lib-bisect &&
> good="$(git splice rebuild vendor/lib)" &&
> bad="$(git config --blob main:vendor/lib/.splice splice.commit)" &&
> git bisect start "$bad" "$good" >/dev/null
The library alone can't run the app's test, so each step copies the library's files into the throwaway worktree's folder:
$ cat >"$COMPARISON/bisect-step.sh" <<'EOF'
> set -e
> 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"
> EOF
$ git bisect run sh "$COMPARISON/bisect-step.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 r itself, which Josh's 092b6b1 copies. Replacing the folder
works because it had no changes of its own since f' was pushed;
unpushed changes would need to be merged in at each step.
Both sides move on
The library gets a release, w:
$ 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, n:
$ 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 merges the two in the library's history, where both sides share commits. A worktree of the filtered history stands in for 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
15560d75aab425a7d5cd9e9c4263dbd4c39412f3
--reverse carries the merge back into the monorepo:
$ 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
151bbf6389edb01576588e515121f0c4beeb2698
The release reaches the monorepo as a commit of its own, w, joined by the merge j. Josh's view, below, has the merge it started from, j', and the library's own release, w':
$ git log --oneline --graph -4
* 151bbf6 merge release 1.3
|\
| * 148e97c release 1.3
* | 64fc20e add notes to lib
|/
* 99943ff release 1.2
With 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 pull y records w, which stays in the library's history:
So the monorepo's history counts its own commits only:
$ git rev-list --count HEAD
45
$ git -C ../monorepo rev-list --count HEAD
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, which the monorepo integrates. It shows when a
branch changes the library, o, adapts the app, p, and fixes a typo
in the library change, t, while main bumps the app's dependencies,
b, and then merges the branch, g.
In the Josh monorepo:
$ 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
Josh's view of the library gets the merge, g', with the branch's name, and both library commits, o' and t', the typo fix included; only p and b stay behind:
$ josh-filter ':/vendor/lib' && git log --oneline --graph -6 FILTERED_HEAD
27886715d65462c101ec16b7762e92b5efe4a121
* 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
|/
With git-splice: the merge as one commit
The same branch and merge:
$ 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, g', 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, y' has the parents of j' in Josh's view, with another
message: a merge that joins the library's history with the monorepo's
changes belongs to the library, and a push from either tool sends it. A
merge of the monorepo's branches is integration, and only Josh's would
send it. g' keeps the monorepo's message,
Merge branch 'parse-options'; a squash merge, or a message written
for the library, arrives 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. A commit's filtered hash says where the
two sides meet;
git-splicerecords that point, the synced commit, in.splice, so it needs no shared history. For repositories Josh didn't import, such asrust-lang/mirinext torust-lang/rust, its own design lists that as an open question; today,rustc-josh-synckeeps the last synced commit in arust-versionfile. - One project, or two owners. With Josh, a monorepo branch reaches
the library with its commits and merge, which suits one team working in
one project. With
git-splice, the library's maintainers review and release in their own repository, and each history keeps the other's commits out (Whose history is it?). miri has its own maintainers too, but synced through Josh, its history has copies of the Rust commits that changed it. - Git does less of the work. Josh filters, walks and merges in Rust,
on gitoxide, with a cache, and calls
gitto fetch and push.git-splicecalls Git for every step, and walks history only since the boundary. - Links are unreleased, and go one way.
josh link, experimental on Josh'smastersince September 2026, publishes a folder to a repository of its own. Its id and upstream branch live in a ref,refs/josh/links/<id>, not in the folder, and nothing comes back through it. A splice's id and synced commit are committed in its folder, so they move withgit mv.
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 |
| 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 |