May 24, 202612 min read

git-scenarios hits 1.0: what stable actually took
May 29, 2026 · 9 min read
How a focused side-project came together fast, because the project it serves had very clear needs, and why getting to a stable v1 was less about features than about trust.
I published @gfargo/git-scenarios v0.1.0 a couple of weeks ago: 11 scenarios, 38 atoms, 159 tests, a tool I’d extracted from Coco after writing the same git-fixture setup one too many times. It’s now at v1.0.0: 32 scenarios, 60+ atoms, five framework adapters, 527 tests, verified across three operating systems and two git versions.
It came together quickly, and I think the reason is worth more than the speed itself. git-scenarios serves a project with extremely well-defined needs. Coco started life as a small utility that generated commit messages and changelogs for me, but where I’ve been pushing it lately is a full TUI workstation for managing git repositories: an overhead view of all your repos plus a complete terminal UI for working inside any one of them. If you’ve used something like lazygit or GitKraken, that’s the territory. I wanted to roll my own git toolbelt, and building that surface means every view has to render correctly against every state a repository can land in.
That’s exactly the kind of requirement that makes a supporting library fall out cleanly. I wasn’t guessing at what states to support or what the API should feel like; Coco’s workstation told me. A merge in progress, an out-of-date submodule, a detached bisect, a dirty worktree with a dozen staged files: each of those is a view I had to test, so each became a scenario. When the direction is that clear and the needs are that concrete, the pieces come together fast. The slow part, it turned out, wasn’t building features at all.
The jump to v1.0.0 wasn’t about features. The API was already stable by v0.6.0. v1 was about something harder to put in a changelog: earning the right to tell people they can depend on this.
What a 1.0 actually means
It’s easy to slap a 1.0 on something the moment the feature list looks full. I didn’t want to do that. To me, putting a 1 in front of the version is a promise, and a reader evaluating whether to add a dependency is really asking a handful of concrete questions:
- Can I catch errors programmatically, or am I parsing strings?
- Will it block my event loop in a high-concurrency suite?
- Does it work on my OS and my git version?
- Are the results actually deterministic, or just usually?
- When I pass bad input, do I get a clear error or a cryptic one from deep inside git?
v1.0.0 is the release where I could answer all of those with “yes” and mean it. Here’s the work that got me there.
Typed errors instead of parseable strings
This is the change I’m happiest about. Until v1, the library threw plain Error instances with human-readable messages, which meant any consumer that wanted to handle a specific failure had to match on the message text. That’s the kind of code that breaks the moment you reword a string.
// v0.x: hope the message stays parseable
try {
await spinUpScenario('typo-name')
} catch (err) {
if (err.message.includes('not found')) { /* fingers crossed */ }
}
// v1.0.0: typed, structured, actionable
import { ScenarioNotFoundError } from '@gfargo/git-scenarios'
try {
await spinUpScenario('typo-name')
} catch (err) {
if (err instanceof ScenarioNotFoundError) {
err.scenarioName // 'typo-name'
err.availableScenarios // ['feature-pr-ready', ...]
err.code // 'SCENARIO_NOT_FOUND'
}
}There’s now a real hierarchy: GitScenariosError as the base (catch any library error), with ScenarioNotFoundError, GitCommandError, and InvalidArgumentError underneath. Every error carries a discriminating code field so you can branch on it without instanceof if you’d rather. And GitCommandError and InvalidArgumentError both carry an atomName, so when something fails you know exactly which atom produced it instead of reverse-engineering a stack trace.
Guards that fail early, in your language
Five atoms need at least one commit to make sense: switchToBranch, startMerge, cherryPick, startRebase, and createBranch. Call any of them on an empty repo before v1 and you’d get a confusing failure from git itself, several layers down.
// v0.x: cryptic git error, deep in the stack
await switchToBranch('feature')(emptyRepo)
// Error: fatal: not a valid object name: 'main'
// v1.0.0: immediate, clear, traceable
await switchToBranch('feature')(emptyRepo)
// InvalidArgumentError: [switchToBranch] Invalid argument "repo":
// Repository must have at least one commitThe guard catches the mistake at the boundary and tells you what’s wrong in terms of the atom you called, not the git plumbing underneath it. A related helper, withGitError, wraps simple-git failures in a GitCommandError with the command, exit code, stderr, and originating atom all attached. It’s applied to addSubmodule first, since that’s historically been the richest source of baffling git output, and any atom can opt into it.
Proving determinism instead of assuming it
Determinism was always the pitch: same setup, same repo, every time. v1 is where I stopped asserting that and started proving it. Every built-in scenario now runs twice in the test suite and asserts that the two runs produce identical structure. seededFiles is verified to generate byte-identical content for a given seed. Thirty of the 32 scenarios are documented as structurally deterministic (same commit messages, same graph shape), and two are verified hash-deterministic because they pin their timestamps.
I also added property-based testing with fast-check: 11 correctness invariants that ordinary unit tests don’t cover well, things like “the error class hierarchy holds across the whole instanceof chain,” “commit-requiring atoms always throw on an empty repo,” and “paths normalize correctly regardless of platform.” Unit tests check the cases you thought of; property tests go looking for the ones you didn’t.
Three operating systems, two git versions
A git fixture library has no excuse for being platform-flaky, so the CI matrix now runs Ubuntu, Windows, and macOS across two Node versions with fail-fast: false so one red cell doesn’t hide the others. macOS earns its slot specifically because its case-insensitive filesystem surfaces a class of path bugs that Linux and Windows quietly tolerate.
On top of that there’s a dedicated compatibility job that runs the suite against git 2.25.0 (the documented floor) and the latest release, plus an audit that replaced the last raw path-string concatenation with path.join and added @since git 2.25.0 annotations to the atoms that need a newer git. If it’s green, it’s green on your machine too.
The adapter family is complete
The Jest adapter shipped back in v0.4/v0.5; v0.6 added Vitest; the v1 push rounded out the set with Node’s native test runner, Mocha, and AVA. So whatever runner you’ve standardized on, the zero-boilerplate setup is a one-line import away:
import { describeWithScenario } from '@gfargo/git-scenarios/jest'
import { describeWithScenario } from '@gfargo/git-scenarios/vitest'
import { describeWithScenario } from '@gfargo/git-scenarios/node-test'
import { describeWithScenario } from '@gfargo/git-scenarios/mocha'
import { withScenario } from '@gfargo/git-scenarios/ava'The adapters that wrap a describe still own the full lifecycle: they spin the scenario up before the block and tear the temp repo down after it, so you never write a cleanup line. Outside an adapter, spinUpScenario and friends still hand you a repo.cleanup() to call yourself; that contract hasn’t changed.
Losing the coco fingerprints
The package was born inside Coco, and until v1 it still wore a few of those clothes: temp directories were prefixed coco-git-test-, and the default commit identity was Coco Test <coco@example.com>. Harmless, but odd to inherit if you’ve never heard of Coco. v1 renames the prefix to git-scenarios- and the identity to Git Scenarios Test <test@git-scenarios.dev>. The CLI’s clean command scans for both the new and the legacy prefix, so old temp dirs from a prior version still get swept up and are labeled as legacy when it lists them.
Upgrading from v0.x
There are four breaking changes, and most projects won’t notice any of them: the temp-directory prefix rename, the default-identity rename, the new typed error classes (additive, but they change what your catch blocks see), and the now-genuinely-async exists() (the signature was always async, so no call-site change). If you were asserting against the old prefix or identity in your own tests, or matching on error message strings, those are the spots to check. Every change is documented with before/after examples in MIGRATION.md.
Still this simple
For all the work underneath, the thing you actually type hasn’t changed since day one:
import { spinUpScenario } from '@gfargo/git-scenarios'
const repo = await spinUpScenario('mid-merge-conflict')
// Real git repo, real merge conflict, real .git directory.
// Run your tool against repo.path.
await repo.cleanup()- npm: @gfargo/git-scenarios
- v1.0.0 release notes: github.com/gfargo/git-scenarios
- Migration guide: MIGRATION.md
- Docs: git-scenarios.griffen.codes
- Coco: coco.griffen.codes – the TUI workstation this came out of
Getting to a v1 I’m willing to stand behind went quickly, but not because I rushed it. The hard design questions had already been answered inside Coco’s workstation, so by the time I extracted the library I knew what it needed to do and what good looked like. That’s the real lesson for me: when the parent project has clear needs and you have a clear direction, the supporting pieces come together with surprisingly little friction. The slow part was never deciding what to build; it was the unglamorous work of making it trustworthy. If you’re testing a git tool of your own and you upgrade, I’d genuinely like to know what broke, what didn’t, and which scenario you reached for first.
Discussion
Have thoughts? Drop them in.
Comments are powered by Disqus. Sign in once, comment anywhere.


