
May 02, 20256 min read

How an internal test helper inside Coco earned its way onto npm, and why composable atoms beat hand-rolled git fixtures.
I have written the same 80 lines of git test setup more times than I want to count. tmp.dirSync(), git init, write a file, commitAll, switch branch, write another file, commit again. Every test that needed a non-trivial git state hand-rolled it from scratch. Each test drifted a little: different identity config, different commit message style, different timing on the dates. By the time I was building views into Coco that needed real submodule state or a mid-bisect repo, the test boilerplate was longer than the assertions.
So I extracted the helper layer. @gfargo/git-scenarios v0.1.0 is live on npm as of today. It spins up real git repositories in any state you want, deterministically, with a tiny composable atom API. Merge conflicts, out-of-date submodules, multi-contributor histories, mid-bisect, linked worktrees, all one line. No mocks, no Docker, no checked-in .git fixtures. Real git on real disk, then rm -rf when the test is done.
There is a whole class of testing where the setup is the hard part. TUIs, IDEs, linters, formatters, CLIs, anything that reads or modifies repo state. The actual assertion is “I press this key” or “I call this function,” but getting the repo into the right shape beforehand takes more code than the test.
The patterns I have seen (and written) all have trade-offs:
.git directories in the test tree. Bloats the repo, breaks on every git version bump, no programmatic tweaking.None of these gave me what I actually wanted: a real repo on disk, in a known state, that I could drive any tool against. Including my own tool. Including someone else’s tool. Including code -n . when I just wanted to poke around.
The library settled into three layers, each built on the one below.
createTempGitRepo() gives you a fresh repo: a tempdir with git init run, the main branch created, user identity set to Coco Test <coco@example.com>, and commit.gpgsign=false so no tests prompt for a signing key. You get back an object with path, a simple-git instance bound to it, helpers for writeFile and commitAll, and a cleanup() that rm -rf‘s the directory.
That’s the whole foundation. Everything else composes on top of it.
An atom is just a function that returns a Step:
type Step = (repo: TempGitRepo) => Promise<void>Every atom in the package returns a Step. So does a scenario’s setup field. So do any custom helpers you write. They compose with chain(...steps), which is just function composition with awaits.
import { createTempGitRepo, chain, addCommit, switchToBranch } from '@gfargo/git-scenarios'
const repo = await createTempGitRepo()
await chain(
addCommit({ message: 'init', files: { 'README.md': '# my-tool' } }),
switchToBranch('feat/x'),
addCommit({ message: 'feat: x', files: { 'src/x.ts': 'export const x = 1\n' } }),
)(repo)
// repo is on feat/x, 1 commit ahead of main. Run your tool against it.
await repo.cleanup()There are 38 atoms in v0.1.0, covering control flow (chain, repeat), working tree (writeFiles, seededFiles), staging and commits (stageFiles, commit, addCommit, emptyCommit, amendCommit), branches, tags, remotes, stash, merge / cherry-pick / revert / bisect / reset, submodules, linked worktrees, config, and scoping utilities. The full catalog is in the README.
The piece I keep reaching for is the scoping utilities. They turn the atom layer from “list of useful functions” into a real composition language.
onBranch(name, step) runs a Step on a named branch and restores the previous one on exit, even on throw.insideSubmodule(path, step) runs any Step against a submodule’s working tree. Every atom in the package works inside.withAuthor({ name, email, date? }, step) pins author and committer identity for everything inside.Because each scope takes a Step and returns a Step, they nest. Multi-contributor history inside a submodule’s source history is a few lines:
withAuthor({ name: 'Alice', email: 'alice@x' },
insideSubmodule('vendor/lib',
onBranch('feat/x',
chain(addCommit({ message: 'feat: a', files: { 'src/a.ts': 'a' } })),
),
),
)On top of the atoms sit 11 curated scenarios, each a named, contract-asserted setup. spinUpScenario('mid-merge-conflict') hands you back a repo mid-merge, with MERGE_HEAD set and one unresolved conflict on src/widget.ts. spinUpScenario('submodule-with-history') gives you a parent repo with a clean submodule pinned at HEAD. spinUpScenario('rich-history-graph') drops you into 20+ commits across 6 date buckets with two --no-ff merges and an unmerged tip.
Every scenario carries a contracts array: human-readable assertions like “main has 3 commits” or “feat/widget-v2 is 4 commits ahead of main.” The matching *.test.ts files validate each line, so the registry doubles as documentation that can’t drift from reality.
Tests are half the value. The other half is the manual loop. The package ships a git-scenarios binary with a tool-agnostic --run flag:
npx git-scenarios list
npx git-scenarios describe mid-merge-conflict
npx git-scenarios create mid-merge-conflict --run "lazygit"
npx git-scenarios create feature-pr-ready --run "code -n"
npx git-scenarios create submodule-with-history --run "coco ui"The CLI knows nothing about my tool. It materializes a scenario, then spawns whatever shell command you give it against the scenario directory. lazygit, gitui, code -n, your own CLI in dev mode, anything. The boundary discipline (no consumer-tool-specific imports inside the package) is what made the extraction clean enough to publish.
The clearest proof that this approach paid off came when I shipped recursive submodule navigation in Coco v0.51.0. The feature: press Enter on a submodule row in the workstation TUI, and every view re-scopes to the submodule’s working directory, as if you’d run coco ui from inside it. Esc walks back out. Frames stack.
That feature shipped across 8 PRs. The first one added the submodule-with-history scenario to the testing layer. Everything after it consumed that scenario.
Without the scenario, every test that wanted to exercise the navigation would have hand-rolled a parent-plus-submodule setup. Manual testing would have meant a bash script every time I wanted to see the chrome render. The Enter-drills-in change would have been validated against synthetic mocks.
With the scenario, one command spun up the right state and dropped me into the TUI. Integration tests called spinUpScenario('submodule-with-history') and tested the push / pop reducer actions against real git state. When I added a cache-aware-pop optimization on the final PR in the series, I had a deterministic fixture to verify “popping back to a cached parent is instant” against, instead of a probabilistic observation.
The pattern repeated throughout: iterate the feature, run the scenario, see the result, adjust. Sub-second loop. Same pattern applies to anyone building a git tool. Write a scenario for the state you’re targeting, develop against it, ship when it works.
Most DSLs for building thing X take one of two shapes: chainable builders (.foo().bar().build()) or recipe arrays ({ steps: [...] }). Atoms-as-functions-returning-functions is a third shape that composes cleaner than either. The user can write their own atoms by returning Steps, with no boilerplate, no inheritance, no plugin API. Custom helpers drop into chain(...) next to the built-ins.
addSubmodule takes a setup: Step and uses it to build the source repo’s history inside a fresh TempGitRepo, then clones it in via git submodule add. So the parent and submodule histories use the same atom library. Combine with insideSubmodule to produce out-of-date-submodule states (parent’s pin lags the submodule’s HEAD) entirely declaratively. No special-case API.
await chain(
addCommit({ message: 'init', files: { 'README.md': '# parent' } }),
addSubmodule({
path: 'vendor/lib',
branch: 'main',
setup: chain(
addCommit({ message: 'init lib', files: { 'README.md': '# lib' } }),
),
}),
addCommit({ message: 'chore: pin submodule' }),
insideSubmodule('vendor/lib', chain(
addCommit({ message: 'feat: post-pin', files: { 'a.ts': 'a' } }),
)),
)(repo)
// git submodule status now reports '+' modifiedA scenario with a name that isn’t kebab-case throws when its module loads. A kind outside the enum throws. Empty summary throws. The point is to catch typos that would otherwise produce “why is my CI failing on something completely unrelated” stack traces six tests deep.
The deterministic content generators (procedural file content seeded for byte-stability) are vendored inside the package, not declared as a peer dependency. 9 of 11 scenarios use them. Making it a peer dep would have forced every consumer to install something they almost certainly want by default. The trade-off: there’s a duplicate copy inside Coco’s parser fixtures that has to stay in sync, guarded by a parity test that compares output across both copies on every CI run. Pragmatic for now; might extract to its own package later.
This isn’t the first attempt at this problem. simple-git (which this package is built on) is a thin wrapper around the git binary, useful as a programmatic interface but not a fixture builder. isomorphic-git and nodegit are alternate git implementations with a different goal entirely. There are a few small git-fixture-style packages on npm, most stale or scoped to one specific use case.
What I couldn’t find anywhere was the combination: a curated scenario registry with named contract-asserted shapes, a composable atom layer for the cases the registry doesn’t cover, a tool-agnostic CLI for the manual testing loop, and full TypeScript types throughout. So I built it. Honest positioning: everyone who has tested a git tool has written some version of this; here’s a maintained, typed, composable version with the boundary discipline already validated through multi-month consumption inside Coco.
npm install --save-dev @gfargo/git-scenarios simple-git
npx git-scenarios list
npx git-scenarios create mid-merge-conflict --run "lazygit"The README is comprehensive (1000+ lines, more cookbook than reference) and covers everything I didn’t fit here: defining your own scenarios, TypeScript types you’ll reach for, the debugging story when a test fails and you want to inspect the temp dir, and how to use the library outside of tests as a benchmark / eval input source.
If you give it a spin (especially the CLI against your own git tool of choice) I’d be curious what scenarios you wish were in the registry. A few states I haven’t covered yet: partial clone, and a “rerere-resolved” state. Atoms welcome as PRs.
This post documents v0.1.0. The package has moved quickly since: as of v0.5.0 it ships 27 curated scenarios and 50+ atoms, dual CJS and ESM output (so import and require both work), a Jest adapter (@gfargo/git-scenarios/jest), and programmatic scenario registration via registerScenario. Mid-rebase, mid-cherry-pick, and mid-revert conflict states are in the registry now, along with upstream-tracking atoms and sparse-checkout, shallow-repo, git-notes, and git-hooks atoms. Full docs live at git-scenarios.griffen.codes.
The Jest adapter is also where cleanup stops being your problem. Every scenario materializes a real temp directory, so each layer has its own teardown story. describeWithScenario wraps Jest’s describe with a beforeAll that spins the scenario up and an afterAll that removes it; describeEachScenario does the same per scenario, each block with its own independent teardown. You reach the live repo inside tests through a getRepo() accessor and never write a cleanup line.
Outside the adapter, teardown is yours. spinUpScenario, fromScenario, and raw createTempGitRepo all hand back a repo.cleanup() to call in an afterAll / afterEach, or in a try/finally so it still runs when an assertion throws. It’s idempotent, so a double call is harmless. The CLI is the deliberate exception: it leaves the temp dir on disk by default so you can poke around after your tool exits, and prints the path plus an rm -rf hint; pass --ephemeral when you’d rather it clean up on exit. The short version: the Jest adapter cleans up for you, everything else expects a cleanup() call or --ephemeral.
Discussion
Comments are powered by Disqus. Sign in once, comment anywhere.