Development
With Nix and flakes, run in the clone:
nix develop # shell with the dev tools and this checkout on PATH
just --list # lint, fmt, test, ci, bench, sandbox, ...
just sandbox # try commands by hand in a throwaway monorepo
just site-serve # this documentation site, reloading on changes
Writing the docs has the markup the documentation uses for what, and the design where the state lives.
Releases come from release-please:
it keeps a release PR open with the next version and changelog, built from
the Conventional Commits on main. Merging it tags and publishes the
release.
Code map
src/main.rs checks the Git version and dispatches git splice <command> to
the command's run. The tool only runs the git CLI, so
GIT_TRACE=<absolute file> git splice <command> lists every git command it runs
(how).
| File | Responsibility |
|---|---|
src/util.rs | Logging, failures (Fail) and running git. |
src/args.rs | Argument parsing. |
src/repo.rs | Repo: discovery, path selection, reading .splice, upstreams, keys and what a splice syncs with. |
src/rebuild.rs | The push rebuild: rebuild_splice, splice_start (B) and the commit loop rebuild_walk. |
src/state.rs | classify_splice, the sync state every command acts on, and splice_in, the re-rooted cherry-pick behind merge and clone. |
src/pager.rs | Paging for diff and log. |
src/<command>.rs | One command each: its run and its per-splice step. |
src/usage/<command>.txt | Each command's help. |
A failure is Fail::Soft, which ends one splice's step, or Fail::Hard, which
ends the command.
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.
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 # Rust unit tests, then 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 bats tests on the oldest supported Git, 2.40.
Which test to write
| What changed | Test |
|---|---|
| What a user sees: output, exit code, the repositories afterwards | A bats test, or a scenario if the output belongs in the docs |
| Logic with many edge cases that needs no repository: parsing, paths, quoting, messages | A unit test next to the function |
| A bug | A bats test for the symptom the user saw, plus unit tests for the edge cases behind it |
The bats tests run the built binary and know nothing about its code, so they kept checking the behaviour while the tool moved from shell to Rust. Keep it that way: don't port them to Rust. They aren't slow because of bats but because each builds repositories and runs Git, which a Rust integration test would do too. A unit test needs neither and runs in microseconds, so an edge case that fits one should not cost a repository.
The layers
| Layer | Where | What it checks |
|---|---|---|
| Unit tests | src/**/*.rs, check-*/src/*.rs | Functions that need no repository, run by cargo test. |
| Scenarios | docs/09-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, or at the top of its chapter when that scenario is in an earlier chapter. Shared by everything below. |
| Command tests | docs/tests/*.bats | The git-splice CLI, run through the splice helper, against a scenario. |
| Oracle tests | docs/tests/rebuild.bats | The rebuild against git subtree split. |
| Scenario READMEs | docs/09-scenarios/**/*.md | What the tool prints in that state, checked by scrut. |
| Walkthroughs | docs/01-getting-started/02-walkthrough.md, docs/06-command-reference/*.md | Every command once, in order, then a page per command, each on a fresh sandbox, checked by scrut. |
| Benchmark | bench/, docs/tests/bench.bats | status timings on a synthetic monorepo, reported on PRs out of draft. |
Writing a bats test
setup() {
load 'helpers/fixtures'
hermetic_git_config # ignore the developer's git config
load 'scenarios/01-everyday-sync/02-up-to-date/02-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
docs/tests/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, notgit splice 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 unit test
A unit test lives in a #[cfg(test)] mod tests at the end of the module
it tests, as in src/util.rs:
#[test]
fn shell_quote_closes_and_reopens_the_quotes_around_a_single_quote() {
assert_eq!(shell_quote("it's"), r"'it'\''s'");
}
- Name the behaviour as for bats, in snake case: the list of names should read as a specification.
- Test functions that need no repository. A function that runs Git is tested through the binary, by a bats test.
- Make sure the test can fail: break the function on purpose once and watch the test fail.
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.