Architecture
@gfargo/git-scenarios is a layered library. Each layer has a single responsibility and only depends on the layers below it. This page explains how the pieces fit together — useful for contributors and for anyone considering vendoring or extracting parts of the codebase.
The layer cake
┌──────────────────────────────────────────────────────────────┐│ Layer 5: Framework adapters (jest.ts, vitest.ts) ││ describeWithScenario / describeEachScenario wrappers │├──────────────────────────────────────────────────────────────┤│ Layer 4: Public API (spinUpScenario, fromScenario, registry) ││ The one-line entry points consumers reach for first │├──────────────────────────────────────────────────────────────┤│ Layer 3: Scenarios (src/scenarios/) ││ Curated, named repo states built from atoms │├──────────────────────────────────────────────────────────────┤│ Layer 2: Atoms (src/atoms/) ││ Composable Step factories — addCommit, switchToBranch, ... │├──────────────────────────────────────────────────────────────┤│ Layer 1: TempGitRepo (tempGitRepo.ts) ││ The on-disk git repo handle: { path, git, writeFile, ... } │├──────────────────────────────────────────────────────────────┤│ Layer 0: simple-git + Node.js stdlib ││ External dependencies │└──────────────────────────────────────────────────────────────┘Dependency direction is strictly upward. Layer N can import from N-1 through 0, but never from N+1. This rule keeps the atom layer extractable, the scenario layer testable in isolation, and the public API thin.
Layer 1: TempGitRepo
createTempGitRepo() produces a fresh git repo on disk:
type TempGitRepo = { path: string // absolute fs path git: SimpleGit // simple-git instance writeFile: (path, content) => Promise<void> readFile: (path) => Promise<string> exists: (path) => Promise<boolean> commitAll: (message) => Promise<void> snapshot: () => Promise<RepoSnapshot> // structured state, read-only cleanup: () => Promise<void>}What it does:
mkdtempa unique dir undertmpdir()git init+ checkoutmain- Set
user.name/user.email/commit.gpgsign=falseso commits work on CI runners without global git config - Optionally register the dir for process-exit cleanup (autoCleanup)
What it doesn’t do:
- No remotes (consumers add them via atoms or CLI flags)
- No commits (the next layer handles content)
- No assumptions about branch shape, file structure, or workflow
The TempGitRepo type is the universal currency of every layer above. Atoms take one and produce side effects on it; scenarios compose atoms over one; the public API returns one.
repo.snapshot()
snapshot() captures a structured, read-only description of the repo’s current state — the programmatic counterpart to the git-scenarios inspect CLI command. Use it to assert against a materialized scenario without hand-rolling git calls:
const repo = await spinUpScenario('mid-merge-conflict')const snap = await repo.snapshot()
snap.head.branch // current branch, or null when detachedsnap.head.detached // true when HEAD points at a commitsnap.head.sha // short SHA, or null on an empty reposnap.branches // local branch namessnap.status.clean // false heresnap.status.staged // staged pathssnap.status.modified // unstaged tracked changessnap.status.untrackedsnap.status.ahead // vs upstream (0 when no upstream)snap.status.behindsnap.commitCount // commits reachable from HEADsnap.operation // 'merge' | 'rebase' | 'cherry-pick' | 'revert' | 'bisect' | nullsnap.conflicts // unmerged paths — non-empty mid-conflictsnap.stashes // stash entry countsnap.graph // git log --graph --oneline --all linesThe same logic is exposed standalone as snapshotRepo(git) for use against any simple-git instance, and is the shared foundation the test matchers and diff / doctor CLI commands build on. Because every scenario is deterministic, a scenario’s snapshot is byte-for-byte identical across runs.
Layer 2: Atoms
An atom is a Step: (repo: TempGitRepo) => Promise<void>. Atoms compose via chain(...):
const setup: Step = chain( addCommit({ message: 'init', files: { 'README.md': '#' } }), switchToBranch('feat/x'), addCommit({ message: 'feat', files: { 'src/x.ts': 'x' } }),)Design rules:
- Every atom is a factory:
addCommit({...})returns aStep. The factory captures parameters; the returned function does the work. - Atoms are pure side-effect functions. They mutate the repo and return nothing.
- Atoms must NOT import from
src/scenarios/orbin/. The arrow points down only. - Errors propagate.
chainshort-circuits on rejection.
Categories (see Atoms Overview for the full catalog):
- Control flow:
chain,repeat,conditionally - Working tree:
writeFiles,deleteFiles,renameFile,seededFiles - Staging + commits:
stageFiles,unstageFiles,commit,addCommit,emptyCommit,amendCommit,bulkCommits - Branches, tags, remotes, upstream tracking, stash
- Operations: merge, rebase, cherry-pick, revert, bisect, reset (with abort + continue lifecycle atoms)
- Submodules, worktrees, sparse checkout, shallow repo, notes, hooks
- Scoping:
onBranch,insideSubmodule,withAuthor,withRemoteTracking - Utility:
gitClean,writeGitignore,writeGitattributes
Layer 3: Scenarios
A scenario is a named, validated Setup:
type Scenario = { name: string // kebab-case summary: string // one-line description: string // multi-line kind: 'branch' | 'worktree' | 'operation' | 'history' | 'stash' | 'submodule' tags?: string[] // e.g. ['conflict', 'merge'] setup: Step // typically chain(...) contracts?: string[] // human-readable assertions}Scenarios are constructed via defineScenario(...) which validates the shape at module load time (kebab-case names, valid kinds, non-empty fields). Catches typos before they become runtime mysteries.
Design rules:
- One scenario per file:
src/scenarios/<kebab-name>.ts - Co-located test:
src/scenarios/<kebab-name>.test.ts - Setup uses
chain(...)of atoms — no inline simple-git calls - Deterministic: same setup → byte-identical state every run
- No remotes (consumers add their own; keeps scenarios offline-safe)
Categories (see Scenarios Overview):
- branch: feature-pr-ready, multi-commit-branch, branch-tracking-upstream, …
- worktree: dirty-many-files, single-staged-file, partial-stage, monorepo-multi-package, …
- operation: mid-merge-conflict, mid-bisect, mid-rebase-conflict, …
- history: rich-history-graph, large-repo, merge-no-conflict, …
- stash: stashed-changes, stash-with-untracked
- submodule: submodule-with-history
Layer 4: Public API
Three entry points, increasingly inline:
spinUpScenario(name, options?)
The one-line API. Creates a temp repo + runs the named scenario. ~95% of test consumers reach for this first.
const repo = await spinUpScenario('feature-pr-ready')fromScenario(name, ...extraSteps)
Scenario + custom atoms in one call. For when the registered scenario is almost what you need.
const repo = await fromScenario('feature-pr-ready', addCommit({ message: 'extra', files: { 'x.ts': 'x' } }),)createTempGitRepo() + atoms
The most flexible path. Build inline from the primitives. For the rare case no scenario fits and you don’t want to register a custom one.
const repo = await createTempGitRepo()await chain(addCommit({...}), switchToBranch('feat/x'))(repo)Registry surface
registerScenario(scenario), findRegistered(name), etc. The mutable registry is the source of truth for scenario lookup. Both built-in scenarios and custom-registered ones live here. The CLI reads from this registry too.
Implementation: Map<string, Scenario> for O(1) name lookup. Insertion order preserved for stable list output.
Error attribution
The internal resolveScenario(name, origin) helper produces consistent “Unknown scenario X. Available: …” errors across all entry points. The origin label (e.g., 'spinUpScenario', 'describeWithScenario') appears in the error so callers know where the lookup happened.
Layer 5: Framework adapters
Thin wrappers that bind a scenario lifecycle to a test framework’s setup/teardown hooks.
// jest:import { describeWithScenario } from '@gfargo/git-scenarios/jest'// vitest:import { describeWithScenario } from '@gfargo/git-scenarios/vitest'The two adapters are nearly identical — Vitest’s globals (describe, beforeAll, afterAll) are runtime-resolved through its own registry, so a separate file keeps the import shape clean.
Assertion layer
A read-only layer that sits on top of snapshotRepo() (Layer 1) and is consumed by tests in any runner:
assertRepo(source)— a fluent, runner-agnostic assertion chain. Checks queue synchronously and evaluate against a single snapshot when the chain is awaited (one chain → one snapshot), resolving to that snapshot. ThrowsRepoAssertionError(carryingassertion/expected/actual) on the first mismatch. Accepts aTempGitRepoor a rawsimple-gitinstance.@gfargo/git-scenarios/matchers—expect(...)matchers for Jest and Vitest, each a thin translation of anassertRepocheck into the{ pass, message }shape both runners accept. Consistent with the adapters, this takes no dependency on a test runner: Jest types ship via ajest.Matchersaugmentation; Vitest users augmentAssertionwith the exportedGitScenariosMatchersinterface.
Because both build on the deterministic snapshot, the same assertions hold byte-for-byte across runs.
CLI
bin/cli.ts is a thin wrapper around the same public API:
list→listRegistered()→ group by kind, optionally filter by--kind/--tag, optionally emit--jsondescribe→findRegistered(name)→ format human or JSONinspect→ materialize ephemerally → printrepo.snapshot()(graph, branches, status, contracts) → clean upcreate→createTempGitRepo()+scenario.setup(repo)+ optional--remote/--path/--run/--ephemeralcapture→ read a real repo’s shape → emit adefineScenario(...)module (or--json)clean→ scan tmpdir for stalegit-scenarios-*dirs, prune
The CLI uses the mutable registry, not the static built-in array, so custom-registered scenarios surface in list and describe too. This was a regression in v0.5 (the CLI had its own static lookup) and was fixed in v0.6.
Build pipeline
tsup produces dual CJS + ESM output for every entry point:
dist/index.{cjs,js}— public APIdist/atoms/index.{cjs,js}— atom catalogdist/scenarios/index.{cjs,js}— scenario registrydist/tempGitRepo.{cjs,js}— low-level primitivedist/jest.{cjs,js}— Jest adapterdist/vitest.{cjs,js}— Vitest adapterdist/bin/cli.cjs— CLI binary (CJS only)
Source maps and .d.ts declarations ship for everything.
Testing strategy
Tests live alongside source: every atom file has a corresponding .test.ts, every scenario has a co-located test verifying its contract assertions. The shared withRepo helper in atom tests handles createTempGitRepo + cleanup boilerplate:
async function withRepo(callback: (repo: TempGitRepo) => Promise<void>): Promise<void> { const repo = await createTempGitRepo() try { await callback(repo) } finally { await repo.cleanup() }}Coverage thresholds (in jest.config.cjs): 80% lines/statements, 70% functions/branches. Enforced by prepublishOnly.
Determinism
Every scenario produces byte-identical state every run. This is non-trivial — git records timestamps, the working dir is randomly named, content can drift if generated naively. Pieces that enforce determinism:
- Author + committer dates pinned via
GIT_AUTHOR_DATE/GIT_COMMITTER_DATEenv vars (per-commitdateoption) daysAgo(n)helper — relative date stamping at noon UTCseededFiles({ files, seed })— procedurally-generated file content seeded by an explicit number, not a clock- No use of
Math.randomanywhere in atom or scenario code commit.gpgsign=falseon every temp repo so commits don’t depend on a host GPG keyring
Tests asserting “this scenario produces N commits with subjects [a, b, c]” depend on this determinism — flaky output would surface as immediate test failures.
See also
ARCHITECTURE.mdin the repo — the source-of-truth markdown this page mirrors- Custom scenarios guide — how to extend the registry
- Atoms overview — the full atom catalog