Skip to main content

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

The monorepo's a, a stack of app commits, and k, a stack of the library's commits copied, both parents of i; f on top of i, with HEAD; below, the library as Josh's view: k prime, a stack, and f prime

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

The monorepo's a, a stack of app commits, c and f, with HEAD at f; below, the library's k, a stack of its commits, and f prime, with 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 monorepo&#39;s f, a stack, then d, r, e and v in a line, with HEAD at v; below, the library as Josh&#39;s view: f prime, a stack, then d prime, r prime, e prime and v prime

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

The monorepo&#39;s f, a stack, and x, with HEAD at x; below, the library&#39;s k, a stack, then f prime, d, r, e and v, with U(x) at v

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

The monorepo&#39;s v, a stack, n on top of it, and w beside n; both are parents of j, with HEAD; below, the library as Josh&#39;s view: v prime, a stack, n prime and w prime, merged into j prime

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:

The monorepo&#39;s x, a stack, n and y, with HEAD at y; below, the library&#39;s v, a stack, and w; U(x) points at v, U(y) at w

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
|/

The monorepo&#39;s j, a stack, b and g on main, and o, p and t on parse-options, merged into g, with HEAD; below, the library as Josh&#39;s view: j prime, a stack, then o prime and t prime, merged into g prime

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

The monorepo&#39;s y, a stack, b and g on main, and o, p and t on parse-options, merged into g, with HEAD; below, the library&#39;s y prime, a stack, and g prime, with main

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 blame and git log reach 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 the git-splice monorepo, they stop at the pull that brought the commits in, and going further takes a second bisect, git log, or git blame on 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-splice records that point, the synced commit, in .splice, so it needs no shared history. For repositories Josh didn't import, such as rust-lang/miri next to rust-lang/rust, its own design lists that as an open question; today, rustc-josh-sync keeps the last synced commit in a rust-version file.
  • 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 git to fetch and push. git-splice calls Git for every step, and walks history only since the boundary.
  • Links are unreleased, and go one way. josh link, experimental on Josh's master since 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 with git mv.

In short​

Joshgit-splice
Library history in the monorepowhole, as copies under the foldernone, one commit per sync
Commits a push sendsthe filtered history, the same as git subtree split for commits without a signature or extra headersthe first-parent commits since the boundary, otherwise the same as git subtree split
git bisect for a library bugfinds the monorepo's copy of the library's commitfinds the pull, then a second bisect between the synced commits does
A monorepo branch mergeda merge, with the branch's commitsone commit, with the merge's message
Where both sides meetcommits of the filtered view that the library hasthe synced commit in .splice
An upstream commit pulled inone monorepo commit for each, and a merge if both sides movedone pull commit for all
ImplementationRust, gitoxide, a cacheBash, the git command
Best fora monorepo that is the source of truth, served in partsa library with its own repository, changed in the monorepo