git-splice: design
git-splice keeps folders of a monorepo in sync with their own upstream
repositories, in both directions. Switching branches in the monorepo
switches the branch every folder syncs with.
Design goals
Keep configuration with the folder
Cloning the monorepo should be enough to use every splice. Its URL and sync
state live in a committed .splice file, not in .git/config. Fetching and
pushing by URL also prevents a plain git push from sending the whole
monorepo to a splice's upstream.
Make sync state explicit
status should not scan the monorepo's entire history or depend on a merge
commit surviving a rebase or squash merge. Each splice records the upstream
commit it last matched, so commands only inspect history since that point.
Keep the histories separate
Working in a monorepo should not import upstream's history. A pull creates one ordinary monorepo commit. A push rebuilds the commits that changed the folder as upstream commits. Changes cross the splice; commits do not.
Principles
- Let Git do the work. Bash and Git, nothing to compile. Wherever Git
can do a job, the tool calls Git:
git configparses the state file,cherry-pickmerges,commit-treerebuilds,log --left-rightcompares. The tool's own code connects these, and stays small. - Changes cross the splice, commits don't. The monorepo and each upstream keep separate histories. Upstream commits never become ancestors of the monorepo's commits, and the monorepo's merges and side branches never reach upstream.
- The folder carries its own state. A splice is a folder with a
committed
.splicefile. Nothing about it lives in.git/config, so a clone of the monorepo has every splice, andgit mvmoves one. - Store as little as possible, derive the rest.
.spliceholds the URL and the upstream commit the folder last matched, never a monorepo SHA. Where the last sync happened in the monorepo, and whether a push is needed, are worked out from history each time. Nothing a rebase or squash merge does can make them stale. - Deterministic rebuilds. The same history always rebuilds into the same upstream commits, byte for byte. That makes status computable without storing anything about earlier pushes, and lets a second push fast-forward over the first.
- Branches follow the monorepo. On
feature-x, every splice syncs with its upstream'sfeature-x. No branch is fixed per folder. - Name the target when writing, show everything when looking. Commands
that change the monorepo or an upstream need a path or
--all. Commands that only look cover every splice. - Refuse rather than guess. With unrelated histories, or a path that can't be a ref name, the tool stops and says what to do.
- Weigh every fix against its complexity. An easy, safe fix goes in.
A rare case gets documented, or an issue labelled
edge case. An extreme one is ignored.
The sync point
A splice is an ordinary folder in the monorepo with a committed .splice
file. Like a subtree, its files live directly in the monorepo, where they can
be changed and tested with everything else. Like a submodule, the monorepo
records an upstream repository and one of its commits.
The commit has a different meaning than a submodule's gitlink. A submodule commit says which revision should be checked out. A splice commit is a sync point: the upstream revision the folder matched when changes were last spliced in. It does not determine the folder's current content; later monorepo commits can change it, and a push does not update the sync point (why).
From that point, git-splice lets Git merge upstream changes and resolve
conflicts, or rebuild the folder's monorepo commits for a push. Changes cross
the splice, but commits do not: both repositories keep their own histories.
How it compares
Comparisons runs git submodule and
git subtree side by side with git-splice, and
third-party tools compares
git-subrepo, splitsh-lite, Josh and Copybara.
Vocabulary
| Term | Meaning |
|---|---|
| splice | A folder of the monorepo that has its own upstream repository: "vendor/a/ is spliced in from github.com/x/a", "the vendor/a/ splice". Strictly, the noun means the joint, not the inserted piece (why "splice"). |
| path | Where the splice lives in the monorepo, e.g. vendor/a/. (git subtree calls it --prefix.) |
| upstream | The splice's own repository, named and given by URL in .splice, like a Git remote. |
| state file | <path>/.splice. Its presence in HEAD makes the folder a splice. |
| synced commit (U(H)) | The upstream commit .splice records at HEAD: the one last spliced in. U(X) is the one a monorepo commit X's .splice records. |
| last sync | The newest first-parent commit in the monorepo that changed <path>/.splice: the last pull, usually. Its .splice records U(H). |
| boundary (B) | Where the rebuild starts: the newest last sync, or one before it, whose folder is the synced commit it recorded. Usually the last sync. At the mount, where the splice starts, it's the mount, which the rebuild copies too where its folder isn't the synced commit, e.g. after init; for a nested splice, it can be the last sync of the splice above. Derived, never stored. |
| start (U(B)) | The synced commit the boundary records, and the boundary's folder. R starts on it. Usually U(H). |
| rebuild (R) | The splice's changes in the monorepo, rebuilt as upstream commits. What push sends, and ours when compared with T, in the upstream's history; in the monorepo's history, ours is HEAD. |
| upstream branch (T, for theirs) | refs/splices/<key>/<branch>: the upstream branch as the monorepo last saw it, under the upstream's key. |
| key | An upstream's short name in one repository, e.g. lib, mapped to its URL in the repository's config. Names its refs. |
| splice in | Bring upstream content into the monorepo: clone, merge, pull. |
| splice out | Publish the monorepo's changes upstream: push. |
| sync state | How R and T relate: up to date, push, pull, diverged and so on. |
| default branch | A repository's main branch (HEAD on the remote). The monorepo's default branch corresponds to upstream's. |
| nested splice | A splice below another one's folder. Commands that change things go top-down (merge, pull: the splice above first) or bottom-up (push: the splice below first). |
"Default branch" belongs to a repository and is stored in .splice when
needed. It isn't called "fallback", which would describe only one of its
uses.
Architecture
monorepo upstream (by URL)
┌──────────────────────────────┐
│ HEAD │ fetch ┌───────────────────────────┐
│ └ vendor/a/ │ <─────── │ refs/heads/* │
│ ├ .splice (url, U(H)) │ └───────────────────────────┘
│ └ … │ ^
│ │ │ push R
│ refs/splices/<key>/* (T) │ │
└──────────────────────────────┘ │
│ merge: cherry-pick of re-rooted trees │
│ push: rebuild of B..HEAD on top of U(B) ─┘
The state lives in two places, and both are plain Git data:
- Committed:
<path>/.splice, with the upstream's URL and the synced commit U(H). - Local, rebuilt by
fetch:refs/splices/<key>/<branch>, a copy of every upstream branch, under a key the repository's config maps the upstream's URL to.
Everything else, B, R and the sync state, is derived from those two and the monorepo's history each time a command runs.
Code map
git-splice checks the Bash and Git versions, sources lib/*.sh, and
dispatches git splice <command> to cmd_<command>. The libraries are
layered, each using only the ones above it:
| File | Responsibility |
|---|---|
lib/common.sh | Discovery (discover_splices), argument parsing and path selection, reading and writing .splice (splice_config, state_blob), upstream keys (upstream_key, create_upstream_key), branch mapping. |
lib/rebuild.sh | The push rebuild: splice_last_sync, rebuild_splice, splice_start (B) and the commit loop rebuild_walk. |
lib/state.sh | classify_splice, the sync state every command acts on, and splice_in, the re-rooted cherry-pick behind merge and clone --merge. |
lib/pager.sh | Paging for diff and log. |
lib/<command>.sh | One command each: usage text, cmd_<command>, and its per-splice step. |
Each command follows the same shape: select_paths picks the splices,
then a per-splice function (merge_one, push_one, …) calls
classify_splice and acts on its state.
The .splice file
The .splice file shows
what it holds and which commands write it. Why it looks like that:
- Git's config format, read and written by Git. There's no parser of our own, and the file is never sourced, since that would run whatever someone commits into it.
- No monorepo SHAs. The boundary is derived from history instead, so rebases and squash merges can't break it.
- No path. The folder that contains it is the splice, so
git mvmoves the splice with nothing else to update. - Its name clashes with no known configuration file, so build or package globs that see it in the monorepo don't mistake it for theirs.
- Its
idis derived, not random, from the path,HEAD, the upstream's URL and, forclone, the commit it splices in. So a command gives the same result each time it runs on the same commit, while two splices of different upstreams mounted at the same path on branches from the same commit still get different ids. The rebuild uses it to tell this splice from another one that had the same path before (see the push section). - Without
default-branch, an upstream's default branch is named like that of what the splice lives in: the splice above, or the monorepo. So a nested.splicemeans the same in the monorepo as in the upstream above, where that repository stands in for the splice above. Where the splice above leaves it open, commands refuse rather than fall back to the monorepo's. default-branchis recorded once, bycloneandinit, and only when it differs. That keeps it stable if the upstream later renames its default branch, and visible to anyone wondering whymainsyncs withmaster. It belongs to the upstream, like its URL: a company fork may name its default branch differently from the original.- One
commitfor several upstreams: a sync point is a commit, whichever upstream it came from. - A
.splicethat can't be read stops every command, instead of being half read. - No merge driver: two branches that both pulled conflict in the file, and keeping the newer synced commit resolves it. A merge driver can follow if that turns out to be common.
Refs instead of remotes
Upstream branches are fetched by URL into private refs:
git fetch --prune <url> '+refs/heads/*:refs/splices/<key>/*'
The key is the upstream's short name in this repository. The first command that fetches an upstream records it in the repository's config, which all its worktrees share, as they share the refs:
[splice "lib"]
url = https://github.com/x/lib.git
It comes from the end of the URL, without .git, in lower case: lib
for https://github.com/x/lib.git. If that's taken, by another URL or by
refs left under it, more of the URL goes in front, x-lib, then
github.com-x-lib, and only then a number. Lower case only, so no two
keys' refs share files on a case-insensitive file system, and short, so
they fit any file system's name limit. Commands record keys one at a
time, under a lock, and refuse a config edited by hand into keys that
aren't valid, or into two URLs for a key or two keys for a URL, naming
the git config command that fixes it. A key has no /, so branch names, which may, start right
after it. git splice key <path> prints a splice's key, and
git splice key <path> <new-key> renames it, refs included. Commands
that only read, like status, never record a key: an upstream without
one hasn't been fetched.
The URL is the one written in .splice, before insteadOf. Spellings
that look alike can name different repositories, e.g. /srv/lib and
/srv/lib.git, or host:lib (relative to the home folder) and
ssh://host/lib, so only equal URLs share a key. Keys are local, like the
refs: two clones may give one upstream different keys.
Keyed by URL, not by path, the refs describe the upstream rather than the folder:
-
git mvkeeps the URL, so a moved splice still finds its refs. -
Splices at the same path with different upstreams, e.g. on two branches, don't overwrite each other's refs, and
initon a path that had another upstream doesn't see that one's refs. -
Splices with equal URLs share refs, which is fine, since refs only describe the upstream.
fetchfetches each upstream once. -
No Git remote exists, so nothing can push the monorepo there by mistake. Why that matters.
-
url.<base>.insteadOfandpushInsteadOfstill apply, since they work on URLs. -
pushgoes to the URL, and then updatesrefs/splices/<key>/<branch>itself. If it can't, the push counts as failed. -
--prunedrops branches deleted upstream, so noprunecommand is needed. -
Every Git command can read an upstream as
splices/<key>/<branch>, as of the last fetch. No separate clone is needed:git log --oneline splices/a/maingit show splices/a/main:README.mdgit worktree add --detach ../a-upstream splices/a/maingit log --allandgitk --allshow those histories too; add--exclude='refs/splices/*'before--allto leave them out.
Two rules follow from the layout:
- A splice nested in another is a splice on both sides. Each push
sends its folder without its own
.splice, so the push of the splice above sends the.spliceof the one below along with its files. In the upstream of the splice above, the folder below is then a splice too, whichgit-splicecan pull and push there. The.spliceholds nothing specific to the monorepo, and fetched refs are keyed by URL, so both repositories find the same synced commit under the same refs. An upstream with a.spliceat its root is refused: it would replace the splice's own. - A splice's path must work in a ref name (
git check-ref-format), which applies to the whole path: no spaces, no component ending in.lock, and so on.
How the commands work
Which splices a command acts on depends on what it does:
| Kind | Commands | Without a path |
|---|---|---|
| Starts a splice | clone, init | Take one URL and one path, no --all. clone defaults the path to the repository's name, as git clone does. |
| Splices in or out: changes the monorepo or an upstream | merge, pull, push | Refuses. Takes one or more paths, or --all. |
| Looks or prepares: changes neither | status, diff, log, fetch | Every splice; paths narrow it down. |
Splicing in writes a commit into the monorepo, and splicing out publishes
commits upstream. Both should name their target, as git push <remote>
does. An overview such as status is only useful if it's complete.
Starting a splice: clone and init
| Situation | Command |
|---|---|
| Upstream exists, the folder doesn't | git splice clone <url> [<path>] |
| The folder exists, upstream is new or empty | git splice init <path> <url> |
| Both exist with the same content | git splice clone <url> <path>: only writes .splice, since nothing can be lost |
| Both exist and differ | git splice clone --merge <url> <path> |
clonerefuses a folder that exists and differs from upstream, likegit clonerefuses a non-empty directory. The message names--merge.--mergeruns the same mechanism asmerge(below), with an empty folder as the merge base. Every file that differs becomes an add/add conflict, and nothing is lost. Deleting the folder on a branch, re-cloning it there and mergingmainback isn't a substitute: the merge base still has the folder, so Git takes upstream's side wholesale and drops local-only files.- To replace the folder with upstream's version instead,
git rmit in one commit, thencloneinto the empty spot. initrefuses an upstream that already has commits, and points toclone --merge. It writes.splicewithout a synced commit, and the first push creates the upstream branch starting at the init commit, followed by any commits after it, also on a feature branch where the folder didn't change. The folder's history before it isn't published: it was written for the monorepo, and may hold what was removed before anyone decided to publish the folder (example).- The name
addis avoided on purpose: it suggestsgit add.
Splicing in: merge, and pull = fetch + merge
merge turns already fetched upstream changes into one ordinary
commit in the monorepo, and never touches the network. The problem it
solves: upstream's commits have the splice's content at their root, while
the monorepo has it at <path>/, and they share no history, so Git can't
merge them directly. git subtree solves that by putting upstream's
history into the monorepo's. merge instead supplies the merge base
explicitly:
- Build two throwaway commits whose trees have the monorepo's layout
(
reroot_tree, using a temporary index):- base: HEAD's tree, with
<path>/replaced by the merge base's tree and HEAD's.splice. - theirs: HEAD's tree, with
<path>/replaced by the upstream branch's tree and a.splicethat records its commit. Its parent is base.
- base: HEAD's tree, with
- Run
git cherry-pick theirs. That's a three-way merge of HEAD (ours) and theirs, with base as the merge base. Only<path>/differs between base and theirs, so only the splice can conflict. Local edits are kept, and the.spliceupdate comes along in the same commit. Git's ort merge does the work, including rename detection.
The merge base is git merge-base R T: the newest upstream commit both
sides contain. Usually that's U(H). After a push it's the pushed commit, which
spares the merge from replaying changes upstream already has.
The same files, another history: if the folder already has T's
files, there's nothing to merge. But if T isn't part of R's history,
merge still records T as U(H), in a commit that only changes .splice.
That happens when the upstream rewrote its history to the same files
(example),
squash-merged what was pushed, or got R edited before the push
(example).
Without the record, R would keep the monorepo's original commits, and
the next push would publish them. Right after an ordinary push, R is T,
and nothing is recorded. Once the folder changes again, it's too late:
the splice reads as diverged, or as unrelated history.
- On a conflict: resolve it, then run
git commit, which finishes the cherry-pick and keeps the prepared message. Abort withgit cherry-pick --abort.git statussays "cherry-picking" although you ranmerge, somergenames both commands when it stops. - base and theirs are never referenced and get garbage-collected. Upstream
commits stay in
refs/splices/and never become ancestors of HEAD. - Nested splices: a pull of a splice brings in the
.splicefiles below it as its upstream has them, and becomes the last sync of those splices. If its upstream moved the synced commit of a splice below, the rebuild joins that splice's unpushed commits with the new one, as after a pull of a divergence.pullfetches the splices below too, so their new synced commit is there.- Top-down:
mergeandpulltake a splice before the ones below it. Pulling the one below first, to a commit newer than the one the upstream above records, would make the pull above conflict. A pull that removed a.splicebelow ends that splice, and its pull is skipped. A failed fetch stops the pull of the splices above and below it, and a failed merge the merges below it. - Bottom-up:
pushtakes a splice before the ones above it, whose push publishes its.splice, and a failed push stops them. Otherwise the upstream above would record content the upstream below doesn't have.
- Top-down:
Splicing out: push and the rebuild
A push sends the splice's history since the last sync, rebuilt as upstream
commits. rebuild_splice is a function of HEAD alone:
- Find the last sync. L is
git log --first-parent -1 -- <path>/.splice. Everyclone,initand pull writes such a commit, and no rebase or squash merge can remove all of them. U(L) is the synced commit recorded at L. - Start at U(L). If L's folder (without
.splice) equals U(L)'s tree, the rebuild continues from U(L), and L is the boundary B. If it differs, a pull merged a divergence, or a squash merge mixed local edits into the commit that changed.splice. Then L is rebuilt as a commit with L's folder, whose parents are the rebuild of L's first parent (recursively) and U(L). Local commits that were never pushed keep their identity, and upstream gets them joined with its own by a merge, as a plaingit pullwould have made. Where that recursive rebuild is a parent, its boundary is B, so B is the first last sync, going back, whose folder equals its synced commit, U(B) (sync model), and the recursion only costs something after pulls of divergences. If L is the splice's mount, its first parent isn't rebuilt at all, and L goes on top of U(L) (see below). Without U(L), e.g. afterinit, the rebuild starts at the mount instead, as a root commit, and the mount is B. - Copy each commit. For each commit in
git rev-list --reverse --first-parent L..HEAD -- <path>whose folder changed,git commit-treewith the folder's tree minus.splice. Author and committer are copied, including their dates, asgit subtree split'scopy_commitdoes. The message is read with--pretty=format:%B, not--format=, which would add a newline. Commits are never signed: a signature would differ from run to run.
Deterministic: commits pushed earlier are rebuilt identically, so the
next push fast-forwards, and status can compare R with T without
remembering anything.
First parent only: a merge in the monorepo, e.g. git merge main on a
feature branch, becomes one ordinary commit upstream, with the merge's
message. Upstream doesn't know the monorepo's side branches, so a faithful
merge shape would add nothing, and leaving it out avoids the
parent-mapping problems split spent years fixing.
Only this splice's history: a splice's history starts at its
mount, the newest first-parent commit whose first parent had no
.splice at the path, or one with another id. Before the
mount, the folder was no splice, or another one, so nothing from there is
published, with or without a synced commit. That covers a folder's
history from before init
(example),
two splices that swap paths
(example), and a splice
removed and spliced in again
(example).
A new upstream URL keeps the history: the id stays
(example).
A nested splice's history up to the splice above's last sync comes
from that splice's upstream. A clone or pull of the splice above squashes
the upstream's history into one commit, so the monorepo's own history
can't tell which of the folder's changes the splice below already
pushed, or who made them. The upstream's history can, and the monorepo
has it: the synced commit of the splice above, fetched. So the rebuild of
the splice below first rebuilds it in that upstream, at that synced
commit, and continues from there with the monorepo's commits
(example).
Of the splices above, it takes the one with the newest last sync, and on
a tie the outermost: a push writes nothing to the monorepo, so the
.splice of the splice in between may name a commit from before the
change, while the outermost upstream has it
(three levels).
Only if the upstream above has the same splice there, by its id
(replaced above).
And the monorepo's own commits before the last sync are joined in even if
the folder's files equal that rebuild, so a commit pushed straight to
the splice's upstream stays in its history
(pushed and made above).
Costs O(commits since B), not O(all history). The commit loop
reads all commit metadata with one git log and all folder trees with one
git cat-file --batch-check, so it spawns about one process per commit.
Never writes to the monorepo. Recording a push as a sync point would be incorrect: merging a branch that pushed would bring that record onto another upstream branch (sync model).
A new upstream branch is created only if R has commits that
upstream's default branch lacks, which a PR from the new branch would
show. So starting a feature branch doesn't create empty branches on every
upstream, while commits the default branch lacks always count: the ones
the feature branch got from the branch it was cut from, and the first
push of a splice made by init, which has no upstream branch at all.
Whether the folder changed on this branch doesn't matter. A branch
stacked on one upstream has already gets its own branch upstream too,
with nothing new
(scenario).
status: the sync state
classify_splice works the state out each time, from R and T, as the
sync model
describes. It compares the files first: when they're equal, nothing
needs rebuilding. Before that, it checks there's something to compare:
without refs/splices/<key>/*, and with U(H) recorded but not available
locally, as in a fresh clone of the monorepo, the splice was never
fetched.
diff and log
-
diffshows the file changes a push would send:git diff T R, or, for a missing upstream branch, from the merge base of R and upstream's default branch, as a PR from the new branch would show them. -
logshows commits in both directions:git log --left-right --cherry-mark R...refs/splices/<key>/<branch><is on the left (local) side only, so a push sends it;>is on the upstream side only, so a pull brings it;=is a commit upstream already applied by cherry-pick or rebase. Ours on the left, theirs on the right, as in Git's conflict markers. The format shows the author, because a push publishes it and it may be a private identity.
Branches
- Each splice syncs with the upstream branch named like the monorepo's current branch, for every command. Switching branches switches every splice.
- The monorepo's default branch maps to upstream's default branch
(the upstream's
default-branchin.spliceif the names differ). Every other branch keeps its name on both sides. - The monorepo's default branch is
origin/HEAD, elseinit.defaultBranch. - If upstream has no branch named like the current one, every command
compares with upstream's default branch instead, as a PR from that
branch would;
pushcreates the branch only if the default branch lacks commits, so a feature branch doesn't spawn empty branches upstream. Which branch it was cut from doesn't matter: Git doesn't record it, and commits from there that upstream lacks have to get there somehow, e.g. through a PR.
Limits
These are known and accepted, each to keep the design simple:
- A nested splice pulled on both sides: if the monorepo and the
upstream of the splice above each pull the splice below, the next pull
of the splice above conflicts in the folder below, since each side
brought in a different upstream commit. If the monorepo has no unpushed
changes there, keep the upstream's side:
git checkout --theirs -- <above>/<below>, thengit addandgit commit. Pushing the splice above right after pulling the one below avoids the conflict. - Paths that aren't valid in ref names, e.g. with spaces, are refused.
- Moving a splice with unpushed changes: the
git mvcommit changes.splice, and the splice's history starts there, so the unpushed commits before it reach upstream folded into the move commit. Push before moving (#4). - Upstream URLs that differ only in letter case, e.g.
ssh://host/Org/libandssh://host/org/lib, share their fetched refs on case-insensitive file systems, like macOS's default one: fetching one overwrites the other's. And a URL component that is too long once escaped, e.g. many spaces, fails with "File name too long". Until #51, avoid upstreams whose URLs differ only in case, and long path components. - A splice made by
inithas no synced commit until its first pull, so until then every rebuild walks the folder's history sinceinit. - Rare edge cases each have an issue labelled
edge case.
Testing
Tests are executable documentation: a test's name states one behaviour,
and a scenario's README shows the tool's real output. Run them in
nix develop:
just test # bats tests
just docs-check # concepts, scenario READMEs and walkthroughs, via scrut
just docs-check --write # update them after an intended output change
just ci # lint, fmt-check, test, docs-check
CI also runs the whole suite on the oldest supported versions, Bash 4.4 and Git 2.40, and their newer counterparts.
The layers
| Layer | Where | What it checks |
|---|---|---|
| Scenarios | test/scenarios/**/setup.bash | Nothing on its own: one function that builds a monorepo and an upstream in one state, nested under the scenario it continues from. Shared by everything below. |
| Unit and command tests | test/*.bats | Functions and commands, called directly after load_lib, against a scenario. |
| Oracle tests | test/rebuild.bats | The rebuild against git subtree split. |
| Scenario READMEs | test/scenarios/**/*.md | What the tool prints in that state, checked by scrut. |
| Walkthroughs | test/walkthrough/*.md | Every command once, in order, then a page per command, each on a fresh sandbox, checked by scrut. |
| Benchmark | bench/, test/bench.bats | status timings on a synthetic monorepo, reported on PRs out of draft. |
Writing a bats test
setup() {
load 'helpers/fixtures'
load_lib # source lib/*.sh, to call functions directly
hermetic_git_config # ignore the developer's git config
load 'scenarios/up-to-date/push-ahead/setup'
monorepo="$BATS_TEST_TMPDIR/monorepo"
upstream="$BATS_TEST_TMPDIR/upstream.git"
}
@test "push: writes nothing to the monorepo and creates no Git remote" {
scenario_push_ahead "$monorepo" "$upstream"
cd "$monorepo"
…
}
- Name the behaviour, not the steps:
<command or function>: <what it does>, e.g."status: a quiet splice with uncommitted changes moves up". The list of test names should read as a specification. - Start from a scenario if one fits, so the test and the README show
the same state. Otherwise build the state inline from the helpers in
test/helpers/fixtures.bash:make_bare_repo,seed_bare_repo,init_monorepo,add_splice,commit_local,fetch_splice. - Fixtures don't use the code under test.
add_splicesplices a folder in with raw plumbing, notcmd_clone, so a bug inclonedoesn't fail every other test. Usesplice <command>(the real entrypoint) only where a state needs a command's own result, e.g. an earlier push. - Assert on Git's data, not only on output: SHAs, trees,
rev-listcounts, whether a ref or a remote exists. - Make sure the test can fail. A test that passes because both sides are empty, or because a step never ran, proves nothing. Check the precondition too, e.g. that the rebuild has three commits before comparing it.
- A bug fix starts with a failing test that reproduces it.
Writing a scenario
A scenario is one state a splice can be in, or one situation a command has to handle. Adding a scenario has the steps.
git subtree split as the test oracle
split has years of fixes behind it, and the rebuild copies metadata the
same way. So on histories where both apply, the rebuild must produce
identical SHAs. That reuses split's maturity in the tests without
running it in production.
To keep the comparison fair:
- Oracle fixtures are built with
git subtree add --squash, whichsplitunderstands, and the test passes B and U(B) torebuild_walkdirectly. .splicestays out of oracle fixtures.splitwould include it in every tree, so its removal is tested separately.
Approved divergences. Where the rebuild deliberately differs from
split, the test asserts exactly how, and its name says
approved divergence:
-
A merge in the monorepo becomes one commit. Everything before the merge is identical; from the merge on, the SHAs differ:
--- git subtree split (oracle): --- first-parent rebuild:* 2098669 feature: after merge * 4f27765 feature: after merge* 7584029 Merge branch 'main' into feature * 0f2628d Merge branch 'main' into feature|\ * 875a02b feature: g| * e6d52a2 main: h * e6d54f9 f2* | 875a02b feature: g * 9e9685e f1|/ * 153c9a7 up 1* e6d54f9 f2 -
A squash-merged pull.
splitrebuilds a duplicate of the upstream commit; the rebuild starts at the boundary and doesn't. Here the oracle is wrong. A rebased pull behaves the same way, and has a test of its own.
A new divergence needs a reason it's right, and a test like these.