npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@gfargo/git-scenarios

v1.3.0

Published

Composable atoms for spinning up real git repos in any state — merge conflicts, out-of-date submodules, multiple remotes, in-progress operations, multi-contributor history, and more. For tests, demos, and tool development.

Readme

@gfargo/git-scenarios

Spin up real git repositories in any state, deterministically. Composable atoms for merge conflicts, out-of-date submodules, multiple remotes, in-progress operations, multi-contributor histories, linked worktrees, and more — for tests, demos, and tool development.

npm license types CI

📖 Full documentation →

What this is

Real-world git tools — lazygit, gitui, IDEs, custom dev tools — behave differently against a feature-branch-ready-to-PR than against a mid-merge-conflict than against an out-of-date submodule. Testing those behaviors usually means hand-writing git init + writeFile + commitAll setups in every test, or worse, checking real repos into the test tree.

This package replaces both with:

  • A registry of curated scenarios (feature-pr-ready, mid-merge-conflict, submodule-with-history, …) — call spinUpScenario('name') and you get a real temp git repo in the named state, ready to drive your tool against.
  • A composable atom layer (chain, addCommit, startMerge, addSubmodule, withAuthor, …) — build your own scenarios inline in tests, or register custom ones for your project.
  • A tool-agnostic CLInpx git-scenarios create <name> --run <command> materializes a scenario and launches any tool against it. Tightest dev loop for "what does my tool do against state X?"

Every scenario is deterministic (same setup → byte-identical repo state every run), so the tests built on top are deterministic too.

Audiences

  1. You're writing an integration test. Use spinUpScenario() to start from a deterministic baseline instead of hand-building the same git init + writeFile + commitAll setup every time.
  2. You're hand-testing a git tool (your own, or someone else's). Use the CLI to materialize a scenario on disk and launch the tool against it in one command.
  3. You're building your own scenario library for a tool that doesn't fit the curated set. Use the atom layer to compose anything from "single staged file" to "three-way nested submodule mid-rebase."

Status: v1.2.0 — Stable release. 46 curated scenarios, 60+ composable atoms, 7 framework adapters, assertRepo() + expect matchers, MCP server, VS Code extension, CLI (list · describe · inspect · create · capture · diff · doctor · clean), dual CJS/ESM output.

Table of contents

Installation

npm install --save-dev @gfargo/git-scenarios simple-git
# or
yarn add --dev @gfargo/git-scenarios simple-git
# or
pnpm add --save-dev @gfargo/git-scenarios simple-git

simple-git is a peerDependency — installed alongside so your project picks the version compatible with both this package and any other simple-git consumer you have.

Node requirement: ^22.22.2 || ^24.15.0 || >=26.0.0. The package ships both ESM and CJS — use import or require, both work.

Prerequisites

  • Node.js: ^22.22.2 || ^24.15.0 || >=26.0.0
  • Git: ≥2.25.0 (for sparse-checkout, worktree improvements)

Compatibility Matrix

| OS | Git Version | Status | |---|---|---| | Ubuntu 24.04 | 2.43.x (system) | ✅ Tested in CI | | Ubuntu 24.04 | 2.25.0 (minimum) | ✅ Tested in CI | | Windows Server 2022 | 2.43.x (system) | ✅ Tested in CI | | macOS 14 (Sonoma) | 2.43.x (Xcode) | ✅ Tested in CI |

Quick start

Integration tests — start from a baseline

import { spinUpScenario, type TempGitRepo } from '@gfargo/git-scenarios'

describe('changelog flow against a PR-ready branch', () => {
  let repo: TempGitRepo

  beforeAll(async () => {
    repo = await spinUpScenario('feature-pr-ready')
  })

  afterAll(async () => {
    await repo.cleanup()
  })

  it('generates a changelog vs main', async () => {
    // repo is on feat/widget-v2, 4 commits ahead of main, clean.
    // Run the thing under test from here.
  })
})

Manual testing — drive any tool against a known state

# Spin up a feature branch ready to PR, launch lazygit against it
npx git-scenarios create feature-pr-ready --run "lazygit"

# Spin up an in-progress merge conflict, drop into your IDE
npx git-scenarios create mid-merge-conflict --run "code -n"

# Spin up a dirty worktree without launching anything — get the path
npx git-scenarios create dirty-many-files
# → /var/folders/.../git-scenarios-xR2qwz
# cd in and run whatever you want against it

Inline composition — build a scenario right in a test

import {
  addCommit,
  addRemote,
  chain,
  createTempGitRepo,
  startMerge,
  switchToBranch,
} from '@gfargo/git-scenarios'

const repo = await createTempGitRepo()
await chain(
  addCommit({ message: 'base', files: { 'src/widget.ts': 'export const widget = () => null\n' } }),
  switchToBranch('feat/theirs'),
  addCommit({ message: 'theirs', files: { 'src/widget.ts': 'theirs\n' } }),
  switchToBranch('main'),
  addCommit({ message: 'ours', files: { 'src/widget.ts': 'ours\n' } }),
  startMerge('feat/theirs'),
  addRemote('origin', '[email protected]:org/repo.git'),
)(repo)
// repo is now mid-merge with src/widget.ts conflicted, origin set

Framework adapters (Jest, Vitest, node:test, Mocha, AVA, Playwright, Cypress)

The library ships adapters for every major TypeScript test runner and E2E framework. Each provides zero-boilerplate scenario setup with automatic cleanup.

| Adapter | Import path | API style | |---|---|---| | Jest | @gfargo/git-scenarios/jest | describeWithScenario | | Vitest | @gfargo/git-scenarios/vitest | describeWithScenario | | node:test | @gfargo/git-scenarios/node-test | describeWithScenario | | Mocha | @gfargo/git-scenarios/mocha | describeWithScenario | | AVA | @gfargo/git-scenarios/ava | withScenario (no describe) | | Playwright | @gfargo/git-scenarios/playwright | test.extend fixture | | Cypress | @gfargo/git-scenarios/cypress | cy.task registration | | Mock Factories | @gfargo/git-scenarios/mocks | mockStatus, mockSimpleGit |

// Jest:
import { describeWithScenario } from '@gfargo/git-scenarios/jest'
// Vitest:
import { describeWithScenario } from '@gfargo/git-scenarios/vitest'
// node:test:
import { describeWithScenario } from '@gfargo/git-scenarios/node-test'
// Mocha:
import { describeWithScenario } from '@gfargo/git-scenarios/mocha'
// AVA (different shape — no describe blocks):
import { withScenario } from '@gfargo/git-scenarios/ava'
// Playwright (test.extend fixture):
import { createScenarioTest } from '@gfargo/git-scenarios/playwright'
// Cypress (cy.task registration):
import { registerScenarioTasks } from '@gfargo/git-scenarios/cypress'

The first four share one API — only the import path changes:

import { describeWithScenario } from '@gfargo/git-scenarios/jest'

describeWithScenario('feature-pr-ready', (getRepo) => {
  it('is on a feature branch', async () => {
    const repo = getRepo()
    const status = await repo.git.status()
    expect(status.current).not.toBe('main')
  })

  it('has a clean worktree', async () => {
    const repo = getRepo()
    const status = await repo.git.status()
    expect(status.isClean()).toBe(true)
  })
})

Run tests against multiple scenarios at once:

import { describeEachScenario } from '@gfargo/git-scenarios/jest'

describeEachScenario(
  ['feature-pr-ready', 'two-commit-feature', 'multi-commit-branch'],
  (getRepo, scenarioName) => {
    it(`has a clean worktree in ${scenarioName}`, async () => {
      const repo = getRepo()
      const status = await repo.git.status()
      expect(status.isClean()).toBe(true)
    })
  },
)

Extend a base scenario with extra steps:

describeWithScenario('single-staged-file', (getRepo) => {
  it('has the extra dirty file', async () => {
    const repo = getRepo()
    const status = await repo.git.status()
    expect(status.not_added).toContain('extra.ts')
  })
}, {
  timeout: 60_000,
  extraSteps: [writeFiles({ 'extra.ts': 'uncommitted\n' })],
})

Playwright (test.extend fixture)

// fixtures.ts
import { test as base } from '@playwright/test'
import { createScenarioTest } from '@gfargo/git-scenarios/playwright'

export const test = createScenarioTest(base)
export { expect } from '@playwright/test'
// my.spec.ts
import { test, expect } from './fixtures'

test.use({ scenarioName: 'feature-pr-ready' })

test('is on a feature branch', async ({ repo }) => {
  const status = await repo.git.status()
  expect(status.current).not.toBe('main')
})

Add extra atoms via scenarioOptions:

test.use({
  scenarioName: 'single-staged-file',
  scenarioOptions: { extraSteps: [writeFiles({ 'extra.ts': 'extra\n' })] },
})

Cypress (cy.task registration)

// cypress.config.ts
import { defineConfig } from 'cypress'
import { registerScenarioTasks } from '@gfargo/git-scenarios/cypress'

export default defineConfig({
  e2e: {
    setupNodeEvents(on, config) {
      registerScenarioTasks(on)
      return config
    },
  },
})
// my.cy.ts
describe('git tool E2E', () => {
  let repoPath: string

  beforeEach(() => {
    cy.task('gitScenario:spinUp', { name: 'feature-pr-ready' }).then((result) => {
      repoPath = (result as { path: string }).path
    })
  })

  afterEach(() => {
    cy.task('gitScenario:cleanup', { path: repoPath })
  })

  // Safety net — drain any repos that afterEach missed
  after(() => cy.task('gitScenario:cleanupAll'))

  it('launches the git tool against the scenario repo', () => {
    cy.exec(`my-git-tool status --repo ${repoPath}`)
      .its('stdout')
      .should('contain', 'feat/widget-v2')
  })
})

Common patterns (cookbook)

"I just need a repo with a few commits"

const repo = await spinUpScenario('two-commit-feature')

"I need a repo my tool can stage / commit against"

const repo = await spinUpScenario('single-staged-file')
// repo has 1 staged README ready to commit

"I need to test a merge-conflict flow"

const repo = await spinUpScenario('mid-merge-conflict')
// repo is mid-merge with `src/widget.ts` conflicted, MERGE_HEAD set

Or inline:

await chain(
  addCommit({ message: 'base', files: { 'x.ts': 'base\n' } }),
  switchToBranch('feat/theirs'),
  addCommit({ message: 'theirs', files: { 'x.ts': 'theirs\n' } }),
  switchToBranch('main'),
  addCommit({ message: 'ours', files: { 'x.ts': 'ours\n' } }),
  startMerge('feat/theirs'),
)(repo)

"I need an out-of-date submodule"

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' }),
  // Commits inside the submodule that DON'T update the parent's pin
  insideSubmodule('vendor/lib', chain(
    addCommit({ message: 'feat: post-pin', files: { 'a.ts': 'a' } }),
  )),
)(repo)
// `git submodule status` now reports `+` modified

"I need multi-contributor history for blame / triage tests"

await chain(
  addCommit({ message: 'init', files: { 'README.md': '# repo' } }),
  withAuthor({ name: 'Alice', email: 'alice@org', date: daysAgo(10) },
    addCommit({ message: 'feat: alice work', files: { 'a.ts': 'a' } }),
  ),
  withAuthor({ name: 'Bob', email: 'bob@org', date: daysAgo(5) },
    addCommit({ message: 'fix: bob work', files: { 'b.ts': 'b' } }),
  ),
)(repo)

"I need a fork topology with origin + upstream"

await chain(
  addCommit({ message: 'init', files: { 'README.md': '# fork' } }),
  addRemote('origin', '[email protected]:fork/repo.git'),
  addRemote('upstream', '[email protected]:source/repo.git'),
)(repo)

"My tool depends on ahead/behind counts — I need a tracked branch"

// Tracked, fully synced — "Your branch is up to date with 'origin/main'."
await chain(
  addCommit({ message: 'init', files: { 'README.md': '# repo' } }),
  addRemote('origin', '/fake/url'),
  setRemoteRef('origin', 'main', 'HEAD'),
  setUpstream('main', 'origin'),
)(repo)

"I need a branch that's N commits ahead of its upstream"

// 3 commits ahead of origin/main.
await chain(
  addCommit({ message: 'init', files: { 'README.md': '# repo' } }),
  addRemote('origin', '/fake/url'),
  // Pin the remote at the current commit ...
  setRemoteRef('origin', 'main', 'HEAD'),
  setUpstream('main', 'origin'),
  // ... then add 3 local-only commits.
  addCommit({ message: 'feat: a', files: { 'a.ts': 'a\n' } }),
  addCommit({ message: 'feat: b', files: { 'b.ts': 'b\n' } }),
  addCommit({ message: 'feat: c', files: { 'c.ts': 'c\n' } }),
)(repo)

"I need a branch that's N commits behind its upstream"

// `withRemoteTracking` runs a step against a temporary clone, then
// fetches the resulting branch tip back into the parent as
// refs/remotes/<remote>/<branch>. Any commit-producing atom works
// inside.
await chain(
  addCommit({ message: 'init', files: { 'README.md': '# repo' } }),
  addRemote('origin', '/fake/url'),
  withRemoteTracking('origin', 'main', chain(
    addCommit({ message: 'upstream B', files: { 'b.ts': 'b' } }),
    addCommit({ message: 'upstream C', files: { 'c.ts': 'c' } }),
  )),
  setUpstream('main', 'origin'),
)(repo)
// git status: "Your branch is behind 'origin/main' by 2 commits"

"I need a diverged branch (both ahead and behind)"

await chain(
  addCommit({ message: 'init', files: { 'README.md': '# repo' } }),
  addRemote('origin', '/fake/url'),
  // Two upstream-only commits ...
  withRemoteTracking('origin', 'main', chain(
    addCommit({ message: 'upstream X' }),
    addCommit({ message: 'upstream Y' }),
  )),
  // ... then two local-only commits that diverge.
  addCommit({ message: 'local M', files: { 'm.ts': 'm' } }),
  addCommit({ message: 'local N', files: { 'n.ts': 'n' } }),
  setUpstream('main', 'origin'),
)(repo)
// git status: "Your branch and 'origin/main' have diverged, 2 and 2"

"I need detached HEAD"

await chain(
  addCommit({ message: 'init' }),
  addCommit({ message: 'feat: one' }),
  addCommit({ message: 'feat: two' }),
  // No dedicated atom — use simple-git directly inside an inline step:
  (async (repo) => { await repo.git.checkout(['--detach', 'main~1']) }),
)(repo)

"I need linked worktrees"

await chain(
  addCommit({ message: 'init', files: { 'README.md': '# repo' } }),
  addWorktree('/tmp/feat-x', { branch: 'feat/x' }),
  // Second worktree on its own branch
)(repo)

"I need a specific git config for my tool to detect"

await chain(
  addCommit({ message: 'init', files: { 'README.md': '# repo' } }),
  setConfig('commit.template', '.gitmessage'),
  setConfig('user.signingkey', 'ABC123'),
)(repo)

"I need a mid-rebase conflict"

await chain(
  addCommit({ message: 'base', files: { 'x.ts': 'base\n' } }),
  switchToBranch('feat/theirs'),
  addCommit({ message: 'theirs', files: { 'x.ts': 'theirs\n' } }),
  checkoutBranch('main'),
  addCommit({ message: 'ours', files: { 'x.ts': 'ours\n' } }),
  checkoutBranch('feat/theirs'),
  startRebase('main'),
  // repo is now mid-rebase with x.ts conflicted, REBASE_HEAD set
)(repo)

"I need to test rename detection"

await chain(
  addCommit({ message: 'init', files: { 'src/old-name.ts': 'export const x = 1\n' } }),
  renameFile('src/old-name.ts', 'src/new-name.ts'),
  commit('refactor: rename old-name → new-name'),
)(repo)

Layout

git-scenarios/
├── README.md
├── CONTRIBUTING.md
├── CHANGELOG.md
├── package.json
├── tsconfig.json
├── tsup.config.ts          (dual CJS/ESM build config)
├── jest.config.cjs
├── src/
│   ├── index.ts            (public API entry point)
│   ├── tempGitRepo.ts      (low-level: init + user config + main branch)
│   ├── spinUpScenario.ts   (one-shot scenario API)
│   ├── fromScenario.ts     (scenario + extra steps helper)
│   ├── registry.ts         (mutable scenario registry)
│   ├── jest.ts             (Jest framework adapter)
│   ├── __fixtures__/
│   │   └── generators.ts   (vendored deterministic content generator)
│   ├── atoms/              (composable building blocks)
│   │   ├── index.ts        (barrel export)
│   │   ├── chain.ts, addCommit.ts, branches.ts, ...
│   │   ├── sparseCheckout.ts, shallowClone.ts, notes.ts, hooks.ts
│   │   └── *.test.ts
│   └── scenarios/          (curated repo states)
│       ├── index.ts        (registry + lookup)
│       ├── types.ts        (Scenario type)
│       ├── shared/
│       └── *.ts / *.test.ts
└── bin/
    └── cli.ts              (the `git-scenarios` CLI)

Available scenarios

Run git-scenarios list for the live list. Current set (46 scenarios across 6 kinds):

| Name | Kind | What you get | |---|---|---| | empty-repo | branch | freshly-initialized repo: no commits, no files, no remotes. HEAD on main but unborn. | | feature-pr-ready | branch | feat/widget-v2 4 commits ahead of main, clean worktree — for create-pr and changelog flows | | feature-branch-one-commit | branch | main + feat/x (1 commit ahead, src/feature.ts) — minimal branch-vs-base shape | | multi-commit-branch | branch | feat/dashboard with 8 varied commits — baseline for navigation / filter / yank | | two-commit-feature | branch | baseline + a feat commit on main, clean worktree — for changelog / log / review smoke tests | | branch-tracking-upstream | branch | main tracks origin/main, both at the same commit, clean worktree — baseline "synced" state | | branch-ahead-of-upstream | branch | main is 3 commits ahead of origin/main — classic "unpushed" state | | branch-behind-upstream | branch | main is 3 commits behind origin/main — fast-forwardable | | branch-diverged | branch | main is 2 ahead AND 2 behind origin/main — diverged history | | multi-remote-with-tracking | branch | fork-workflow: origin + upstream remotes, main tracks upstream/main, feat/fork-work tracks origin/feat/fork-work | | branch-sync-showcase | branch | five local branches in five different upstream sync states (behind, ahead, diverged, synced, no-upstream); HEAD on the behind branch. | | detached-head | branch | HEAD detached at main~2, main still at its original tip | | dangling-commit | branch | experimental commit dropped from main; object still in store, reachable only via HEAD@{1} reflog | | reset-recoverable-head | branch | main hard-reset 2 commits back; former tip recoverable via main@{1} in the reflog | | signed-commits-required | branch | commit.gpgsign=true + user.signingkey set — for testing signing-aware UI | | orphan-branch | branch | main + gh-pages orphan branch with no shared history | | single-staged-file | worktree | baseline + 1 staged README — minimum "ready to commit" shape | | partial-stage | worktree | 2 staged + 2 unstaged + 1 untracked — the "mixed worktree" shape | | monorepo-multi-package | worktree | workspaces monorepo: app (clean), lib (staged), cli (unstaged) | | dirty-many-files | worktree | 12 staged + 6 unstaged + 3 untracked files across src/, tests/, docs/ | | multiple-worktrees | worktree | primary worktree on main + 3 linked worktrees on feat/alpha, feat/beta, hotfix/urgent | | locked-worktree | worktree | one linked worktree locked via git worktree lock — appears in git worktree list with a locked annotation and resists removal without --force | | mid-bisect | operation | 20 commits + active git bisect, HEAD at midpoint | | mid-merge-conflict | operation | in-progress merge with 1 unresolved conflict on src/widget.ts | | mid-rebase-conflict | operation | in-progress rebase with 1 unresolved conflict on src/config.ts | | mid-cherry-pick-conflict | operation | in-progress cherry-pick with 1 unresolved conflict on src/utils.ts | | mid-revert-conflict | operation | in-progress revert with 1 unresolved conflict on src/service.ts | | merge-conflict-rename-rename | operation | in-progress merge with a rename/rename conflict: orig.txt renamed to two different names; both renamed files exist in the worktree with no conflict markers | | merge-conflict-delete-modify | operation | in-progress merge with a delete/modify conflict: src/component.ts deleted on main, modified on feat/x; modified version left in worktree with no conflict markers | | merge-conflict-add-add | operation | in-progress merge with an add/add conflict: src/config.ts independently added on both branches with different content; has unresolved conflict markers | | interactive-rebase-mid-edit | operation | mid-interactive-rebase stopped at the first commit marked edit; two picks remain in .git/rebase-merge/git-rebase-todo | | merge-no-conflict | history | a successful --no-ff merge of feat/x into main, fully committed (2-parent commit at HEAD) | | rich-history-graph | history | 20+ commits across 6 date buckets, 2 --no-ff merges, 1 live unmerged feat/wip | | chip-rendering-showcase | history | 6 commits each carrying a different branch-tip-chip kind (HEAD, local, slashy, remote, upstream, tag) | | shallow-clone | history | 10 commits but only 4 reachable from HEAD (.git/shallow set) — for testing shallow-repo detection | | large-repo | history | 115 commits across 3 branches with 3 tags (v0.1.0, v0.5.0, v1.0.0) — for pagination/performance testing | | stashed-changes | stash | clean main + 3 stashes (LIFO ordered, each touching a distinct file) | | stash-with-untracked | stash | one stash containing both modified tracked + untracked new files | | submodule-with-history | submodule | parent with 4 commits + vendor/lib submodule (clean pin, 4 commits, branch = main) | | out-of-date-submodule | submodule | parent repo whose pinned submodule SHA (vendor/lib) is one commit behind the submodule's HEAD — git submodule status shows + for vendor/lib | | git-lfs-pointer | worktree | repo with a Git LFS pointer file committed for a binary asset — no git-lfs binary required; pointer format and .gitattributes rules are testable everywhere | | crlf-normalization | worktree | .gitattributes with * text=auto eol=lf normalising all text files; documents Windows core.autocrlf override and LF-in-object-store behaviour | | case-collision | worktree | git history holds src/File.ts and src/file.ts — a case-only collision that silently loses data when checked out on macOS or Windows (case-insensitive FS) | | installed-hooks | worktree | repo with pre-commit and commit-msg hooks installed and marked executable — for testing hook detection and --no-verify bypass flows | | commits-with-notes | history | 2 commits each annotated with git notes; first commit also has a note in a second namespace (refs/notes/ci) — for testing note-aware log and annotation tools | | mixed-tags | history | lightweight tag (v0.1.0), annotated tag object (v1.0.0), and a tag pointing at a tree (tree-snapshot) — for testing tag-type detection and non-commit tag handling |

git-scenarios describe <name> prints the full description and the contract assertions for a single scenario.

large-repo is also the primary target of the built-in benchmark harness — run npm run bench to measure scenario spin-up times and detect performance regressions.

GitHub Action

Use the gfargo/git-scenarios composite action to spin up a scenario inside any GitHub Actions workflow:

- name: Materialize scenario
  id: scenario
  uses: gfargo/git-scenarios@v1
  with:
    scenario: mid-merge-conflict       # any scenario name
    path: test-repos/conflict          # workspace-relative path (optional)
    # remote: [email protected]:org/repo.git  # optional origin URL

- name: Run tool against it
  run: my-git-tool status --repo "${{ steps.scenario.outputs.path }}"

Inputs: scenario (required), path (default: git-scenario), remote, version. Output: path — absolute path to the materialized repository.

Supported on Linux and macOS runners. See the GitHub Action guide →

MCP Server

The library ships a Model Context Protocol server for AI coding agents. It exposes the scenario catalogue and materialization as MCP tools: list_scenarios, describe_scenario, inspect_scenario, materialize_scenario, capture_repo, cleanup_scenario.

{
  "mcpServers": {
    "git-scenarios": {
      "command": "npx",
      "args": ["-y", "@gfargo/git-scenarios", "git-scenarios-mcp"]
    }
  }
}

See the MCP Server guide →

VS Code Extension

Spin up scenarios from the command palette — "git-scenarios: Create Scenario…" opens a quick-pick, materializes the selected scenario, and opens it in a new VS Code window. A companion "Clean Up Scenario Directories" command removes accumulated temp dirs.

The extension lives in vscode-extension/ as a self-contained sub-package. To try it, build the root package and run the Extension Development Host (F5).

See the VS Code Extension guide →

The CLI

npx git-scenarios list                                                  # show all scenarios grouped by kind
npx git-scenarios list --kind operation                                 # filter by kind
npx git-scenarios list --tag conflict                                   # filter by tag
npx git-scenarios list --json                                           # machine-readable
npx git-scenarios describe feature-pr-ready                             # one-scenario detail
npx git-scenarios describe feature-pr-ready --json                      # machine-readable
npx git-scenarios inspect feature-pr-ready                              # graph + branches + status, no files kept
npx git-scenarios inspect feature-pr-ready --json                      # machine-readable
npx git-scenarios diff feature-pr-ready mid-merge-conflict              # compare two scenarios' shapes
npx git-scenarios diff feature-pr-ready mid-merge-conflict --json       # machine-readable comparison
npx git-scenarios create feature-pr-ready                               # materialize in /tmp
npx git-scenarios create feature-pr-ready --path ~/sandbox/widget       # custom location
npx git-scenarios create feature-pr-ready --run "lazygit"               # launch any tool against it
npx git-scenarios create feature-pr-ready --ephemeral                   # auto-clean on exit
npx git-scenarios create rich-history-graph \
  --run "lazygit" --remote [email protected]:org/repo.git                  # add an origin first
npx git-scenarios capture . --name my-bug-repro > my-bug.ts             # snapshot a real repo's shape into a scenario
npx git-scenarios capture . --json                                      # structured capture data
npx git-scenarios clean                                                 # remove stale scenario dirs in /tmp
npx git-scenarios clean --dry-run                                       # preview without removing
npx git-scenarios clean --older-than 24                                 # only dirs older than 24 hours
npx git-scenarios completions bash                                      # print bash completion script
npx git-scenarios completions zsh                                       # print zsh completion script
npx git-scenarios completions fish                                      # print fish completion script

Shell completions

Enable TAB-completion for scenario names and flags. Scenario names are resolved dynamically at TAB time so newly added scenarios complete without regenerating.

Requires a global install: npm install -g @gfargo/git-scenarios

# bash — add to ~/.bashrc
eval "$(git-scenarios completions bash)"

# zsh — add to ~/.zshrc
eval "$(git-scenarios completions zsh)"

# fish — save to fish completions
git-scenarios completions fish > ~/.config/fish/completions/git-scenarios.fish

Flags

| Flag | Behavior | |---|---| | --path <dir> | Materialize at <dir> instead of /tmp. Useful when you want to cd into it later and poke around. | | --run <cmd> | After materializing, spawn <cmd> against the scenario dir (cwd = scenario dir). Examples: --run "lazygit", --run "gitui", --run "code -n" (open in VS Code). | | --remote <url> | Add origin pointing at <url> so gh-aware tools detect a remote on launch. Pass any gh-shaped URL. Use a real one to render the tool's views with live data; use a fake one to render against an empty / unauthenticated remote (no risk of accidental destructive actions). Without this flag the scenario repo is a bare git init with no remote. | | --ephemeral | Auto-clean the temp dir on CLI exit. Skip for normal use — without --ephemeral, the dir persists so you can re-inspect after the launched tool quits. | | --tag <t> | (list only) Filter by tag inclusion. Combine with --kind (AND semantics). Example: git-scenarios list --kind stash --tag untracked. | | --names | (list only) Print scenario names only, one per line — used by shell completion scripts to resolve names dynamically. Respects --kind and --tag. | | --name <name> | (capture only) Scenario name (kebab-cased). Defaults to the captured repo's directory name. | | --summary <s> | (capture only) One-line summary for the generated scenario. | | --out <file> | (capture only) Write the generated module to <file> instead of stdout. | | --kind <k> | (list and capture) list: filter by scenario kind (branch, worktree, operation, history, stash, submodule). capture: override the inferred kind. | | --json | (list, describe, inspect, diff, capture) Emit machine-readable JSON. List → name/summary/kind/tags/contracts; inspectgraph/branches/status; diff{ a, b, same, differences }; capture → the structured shape (file contents omitted). |

Capturing a real repo

Hit a bug against a repo in some peculiar state? Instead of hand-writing a scenario to reproduce it, point capture at the repo and get a ready-to-edit defineScenario(...) module:

npx git-scenarios capture . --name my-bug-repro > scenarios/my-bug.ts

capture is read-only against the target repo. What it reproduces faithfully:

  • the current branch (and whether HEAD is detached)
  • the commit-graph shape — base-branch commits plus the commits your branch is ahead by (e.g. "4 commits ahead of main"), with each commit's message and author date preserved
  • the working tree's dirty state — which paths are staged, modified, or untracked (with their current content, size-capped)

What it deliberately does not reproduce: exact commit hashes (the library's whole point is fresh deterministic hashes), historical file contents (placeholders are emitted), or merge topology and non-current branches (surfaced as a comment to fill in). The output is a starting point you edit — register it with registerScenario(...) and it works everywhere the built-ins do.

Use --json for the structured shape instead of a module; note that JSON output omits captured file contents.

Cleanup

Without --ephemeral, scenarios persist. The CLI prints the path and a cleanup hint at exit:

✓ Scenario "feature-pr-ready" ready at:
    /var/folders/.../git-scenarios-xR2qwz

When you're done, clean up with:
    rm -rf /var/folders/.../git-scenarios-xR2qwz

Over time, /tmp accumulates these dirs. Periodically clean them with:

rm -rf $(ls -d /var/folders/**/git-scenarios-* 2>/dev/null)

Programmatic API (integration tests)

spinUpScenario(name, options?)

The single import point for tests. Returns a TempGitRepo already brought into the named state:

import { spinUpScenario } from '@gfargo/git-scenarios'

const repo = await spinUpScenario('feature-pr-ready')
// repo is on feat/widget-v2, 4 commits ahead of main, clean worktree

Optional options bag (all fields optional):

const repo = await spinUpScenario('feature-pr-ready', {
  // Auto-clean on process exit (safety net for tests that crash):
  autoCleanup: true,
  // Add an origin remote after setup (useful for gh-aware tooling):
  remote: '[email protected]:org/repo.git',
})

Throws if the name doesn't match a registered scenario — typos fail at setup time, not buried in an assertion.

The TempGitRepo shape

type TempGitRepo = {
  path: string                                          // absolute filesystem path
  git: SimpleGit                                        // simple-git instance bound to path
  writeFile: (path: string, content: string) => Promise<void>
  readFile: (path: string) => Promise<string>
  exists: (path: string) => Promise<boolean>
  commitAll: (message: string) => Promise<void>
  snapshot: () => Promise<RepoSnapshot>  // structured, read-only state
  cleanup: () => Promise<void>
}
  • path — absolute path to the temp dir. Use for shell-out operations or anywhere a string path is needed.
  • git — pre-configured simple-git instance. User identity (Git Scenarios Test <[email protected]>) and commit.gpgsign=false are already set. Use for any git command in your test.
  • writeFile(rel, content) — write to a path relative to the repo root. Parent directories created automatically.
  • readFile(rel) — read utf-8 content from a repo-relative path. Throws on missing file.
  • exists(rel) — check whether a file or directory exists at the given repo-relative path.
  • commitAll(message)git add . && git commit -m <message> in one call. Convenience for the common case.
  • snapshot() — a structured, read-only description of the repo's current state (HEAD, branches, staged/modified/untracked split, ahead/behind, commit count, in-progress operation, conflicts, stashes, graph). The programmatic counterpart to git-scenarios inspect. See Asserting against a scenario.
  • cleanup()rm -rf the temp dir. Call in afterAll / afterEach. Idempotent (safe to call twice).

Extending a scenario in your test

A scenario sets up the baseline. From there, do whatever your test needs:

const repo = await spinUpScenario('feature-pr-ready')

// Add an extra commit on top of the 4 the scenario gave you
await repo.writeFile('src/widget-v3.ts', 'export const v3 = true\n')
await repo.commitAll('feat: widget v3 stub')

// Make the worktree dirty
await repo.writeFile('src/extra.ts', 'console.log("dirty")\n')

// Now exercise the thing under test against this state
const log = await getLogRows(repo.git, { branch: 'main' })
expect(log).toHaveLength(5)

Reading state after the action

After exercising the code under test, inspect the repo with the provided git instance:

// Inspect commits
const log = await repo.git.log()
expect(log.latest?.message).toBe('feat: my new feature')

// Inspect refs
const branches = await repo.git.branchLocal()
expect(branches.all).toContain('feat/added-by-test')

// Inspect file content
const content = await fs.promises.readFile(`${repo.path}/src/foo.ts`, 'utf8')
expect(content).toContain('updated')

// Inspect status
const status = await repo.git.status()
expect(status.staged).toEqual(['src/foo.ts'])

Asserting against a scenario

For the common checks you don't have to hand-roll git calls. Three options, all built on repo.snapshot():

repo.snapshot() — read the whole state as one structured object:

const snap = await repo.snapshot()
snap.head.branch     // current branch, or null when detached
snap.status.clean    // boolean
snap.status.staged   // string[]  (also .modified, .untracked)
snap.status.ahead    // number    (vs upstream; also .behind)
snap.commitCount     // commits reachable from HEAD
snap.operation       // 'merge' | 'rebase' | 'cherry-pick' | 'revert' | 'bisect' | null
snap.conflicts       // string[]  unmerged paths
snap.stashes         // number

snapshotRepo(git) is exported too, for use against any simple-git instance.

assertRepo(...) — a fluent, runner-agnostic assertion chain that throws RepoAssertionError on the first mismatch (works in every test runner):

import { assertRepo } from '@gfargo/git-scenarios'

await assertRepo(repo)
  .onBranch('feat/widget-v2')
  .cleanWorktree()
  .commitCount(7)

expect(...) matchers — for Jest and Vitest, register once then assert directly:

import { matchers } from '@gfargo/git-scenarios/matchers'
expect.extend(matchers)

await expect(repo).toBeMidMerge()
await expect(repo).toHaveConflictIn('src/widget.ts')
await expect(repo).not.toHaveCleanWorktree()

See the Testing Recipes guide for the full matcher list and the 4-line Vitest type augmentation.

Raw createTempGitRepo() — when scenarios don't fit

spinUpScenario is the right entry point for ~95% of tests. The underlying createTempGitRepo() is exported too, for the rare case where none of the named scenarios fit and you really do want to build from git init:

import { createTempGitRepo } from '@gfargo/git-scenarios'

const repo = await createTempGitRepo()
// fresh git repo with main branch + user config + commit.gpgsign=false
// no commits, no files — you build everything from here

fromScenario(name, ...extraSteps) — scenario + extras in one call

When you need a scenario plus a few extra atoms on top:

import { fromScenario, addCommit, writeFiles } from '@gfargo/git-scenarios'

const repo = await fromScenario('feature-pr-ready',
  addCommit({ message: 'extra commit', files: { 'extra.ts': 'x\n' } }),
  writeFiles({ 'dirty.ts': 'uncommitted\n' }),
)
// repo is feature-pr-ready + one extra commit + one dirty file

Custom scenario registration

Register custom scenarios so they're available via spinUpScenario, fromScenario, and the CLI:

import {
  registerScenario,
  defineScenario,
  chain,
  addCommit,
  writeFiles,
} from '@gfargo/git-scenarios'

registerScenario(defineScenario({
  name: 'my-monorepo-dirty',
  summary: 'monorepo with uncommitted changes in packages/lib',
  description: '...',
  kind: 'worktree',
  tags: ['monorepo', 'dirty'],
  setup: chain(
    addCommit({ message: 'init', files: { 'packages/lib/index.ts': 'v1\n' } }),
    writeFiles({ 'packages/lib/index.ts': 'v2 (dirty)\n' }),
  ),
}))

// Now works everywhere:
const repo = await spinUpScenario('my-monorepo-dirty')

Registry API

| Function | What it does | |---|---| | registerScenario(scenario) | Add a custom scenario. Throws on duplicate names. | | registerScenarios(scenarios) | Batch registration. | | unregisterScenario(name) | Remove by name (returns true/false). | | listRegistered() | All scenarios (built-in + custom). | | findRegistered(name) | Lookup by name. O(1). | | findRegisteredByTag(tags, match?) | Filter by tag ('any' or 'all' matching). Searches built-in + custom. | | resetRegistry() | Restore to built-in-only (useful in test teardown). | | findScenariosByTag(tags, match?) | Like findRegisteredByTag but only searches built-ins. |

Atoms — compose any repo state from building blocks

Every registered scenario is built from small, single-purpose atoms: functions that take a TempGitRepo and apply one side-effect. Atoms are exported flat from the package, so you can compose your own setups inline in tests — no registration needed — or use them to write new registered scenarios.

import {
  addCommit,
  addRemote,
  chain,
  createTempGitRepo,
  seededFiles,
  startMerge,
  switchToBranch,
} from '@gfargo/git-scenarios'

const repo = await createTempGitRepo()
await chain(
  addCommit({ message: 'init', files: { 'README.md': '# repo' } }),
  addRemote('origin', '[email protected]:org/repo.git'),
  seededFiles({ files: [{ path: 'src/widget.ts', tokens: 120 }], seed: 0xabc }),
  addCommit({ message: 'feat: widget' }),
  switchToBranch('feat/conflict'),
  addCommit({ message: 'theirs', files: { 'src/widget.ts': 'theirs\n' } }),
  // … flip back to main with a conflicting change, then attempt merge
)(repo)

The atom signature is uniform: every atom returns a Step, (repo: TempGitRepo) => Promise<void>. That's the same type Scenario.setup accepts, so setup: chain(…) works directly in defineScenario({…}).

Atom catalog

Control flow

| Atom | What it does | |---|---| | chain(...steps) | Sequence atoms; awaits each before the next. Short-circuits on rejection. | | repeat(n, factory) | chain(...Array.from({ length: n }, factory)) — readable "do this N times." | | conditionally(condition, step) | Run step only when condition is true. Accepts a static boolean or an async predicate (repo) => boolean. |

Working tree

| Atom | What it does | |---|---| | writeFiles({ 'path': content }) | Write literal content. Parent dirs created. Does NOT stage. | | deleteFiles(...paths) | Remove files from the working directory. Does NOT stage the deletion. | | renameFile(from, to) | git mv — rename a tracked file. Stages the rename for rename-detection. | | seededFiles({ files, seed }) | Write procedurally-generated content (seeded, byte-stable across runs). |

Staging + commits

| Atom | What it does | |---|---| | stageFiles(...paths) | git add . (no args) or git add <paths>. | | unstageFiles(...paths) | Inverse of stageFiles. With no args resets the index (git reset); with paths uses git restore --staged. Enables partial-stage scenarios. | | commit(message, { date? }) | Commit the staged set. Doesn't stage. | | addCommit({ message, files?, date? }) | Workhorse: write + stage all + commit. | | emptyCommit(message, { date? }) | --allow-empty commit. | | amendCommit({ message? }) | --amend the last commit. | | bulkCommits(specs) | Fast-path for N commits. ~30% faster than chain(...specs.map(addCommit)) for 50+ commit scenarios. Specs without files are committed --allow-empty. |

Every commit-producing atom accepts an optional date (any GIT_AUTHOR_DATE-compatible ISO string). Pair with daysAgo(n) for relative-time scenarios.

Branches

| Atom | What it does | |---|---| | switchToBranch(name, { from? }) | git checkout -b <name> (optionally from a specific ref). | | checkoutBranch(name) | git checkout <name> (existing). | | createBranch(name, { from? }) | git branch <name> (no checkout). | | deleteBranch(name, { force? }) | git branch -d / -D. |

Tags

| Atom | What it does | |---|---| | createTag(name, { message?, sha? }) | Annotated when message is set, otherwise lightweight. | | deleteTag(name) | git tag -d. |

Remotes

| Atom | What it does | |---|---| | addRemote(name, url) | Register a remote. URL stored as-is — no fetch. | | removeRemote(name) | Drop a remote. | | renameRemote(from, to) | Rename a remote (URL unchanged). |

Upstream tracking

| Atom | What it does | |---|---| | setUpstream(localBranch, remote, remoteBranch?) | Write branch.<X>.remote + branch.<X>.merge config (git branch --set-upstream-to). remoteBranch defaults to localBranch. | | setRemoteRef(remote, branch, sha) | Direct git update-ref refs/remotes/<remote>/<branch> — fabricate a remote-tracking ref without a fetch. |

Stash

| Atom | What it does | |---|---| | stashChanges({ message?, includeUntracked?, keepIndex? }) | git stash push with the matching flags. | | applyStash({ ref? }) | git stash apply. | | popStash({ ref? }) | git stash pop. | | dropStash({ ref? }) | git stash drop. |

Operations (merge / cherry-pick / revert / rebase / bisect / reset)

| Atom | What it does | |---|---| | startMerge(branch, { allowConflict?, noFastForward?, squash?, message?, date? }) | Merge — conflicts leave the repo mid-merge by default. squash: true produces a staged squash without a merge commit (caller commits to finalize). | | abortMerge() | git merge --abort. | | cherryPick(ref, { allowConflict?, date? }) | Cherry-pick — conflicts leave mid-cherry-pick by default. | | abortCherryPick() | git cherry-pick --abort. | | continueCherryPick() | git cherry-pick --continue (after resolving conflicts). | | revert(ref, { mainline?, allowConflict?, date? }) | Revert a commit (use mainline for merge commits). | | abortRevert() | git revert --abort. | | continueRevert() | git revert --continue (after resolving conflicts). | | startRebase(onto, { allowConflict? }) | Rebase current branch onto a ref — conflicts leave mid-rebase by default. | | abortRebase() | git rebase --abort. | | continueRebase() | git rebase --continue (after resolving conflicts). | | startBisect({ bad, good }) | Begin a bisect at HEAD's midpoint. | | bisectStep(verdict) | 'good' / 'bad' / 'skip'. | | resetBisect() | git bisect reset. | | resetTo({ target, mode? }) | git reset --soft/mixed/hard <target>. |

Submodules

| Atom | What it does | |---|---| | addSubmodule({ path, branch?, setup }) | Builds a source repo from setup (a Step — any atom composes), clones it in as a submodule. | | pinSubmodule(path, sha) | Move the parent's recorded pin for the submodule. |

Linked worktrees

| Atom | What it does | |---|---| | addWorktree(path, { branch? \| checkout?, detach?, from? }) | git worktree add. | | removeWorktree(path, { force? }) | git worktree remove. |

Config

| Atom | What it does | |---|---| | setConfig(key, value, { unset? }) | Local git config <key> <value>, or --unset when unset: true. |

Scoping (apply atoms to a different context)

| Atom | What it does | |---|---| | onBranch(name, step) | Switch to name, run step, restore the previous branch (even on throw). | | insideSubmodule(path, step) | Run step against the submodule's working tree. Any atom composes inside. | | withAuthor({ name, email, date? }, step) | Run step with GIT_AUTHOR_* / GIT_COMMITTER_* pinned. | | withRemoteTracking(remote, branch, step) | Run step against a temporary clone, then fetch the resulting branch tip back into the parent as refs/remotes/<remote>/<branch>. Generates "upstream-only commits" without manual ref plumbing. |

Scenario definition

| Atom | What it does | |---|---| | defineScenario({…}) | Validating wrapper for Scenario (kebab-case name, kind enum, non-empty fields). | | daysAgo(n) | ISO timestamp at noon UTC N days before now. Pairs with the date option on commit atoms. |

Sparse checkout

| Atom | What it does | |---|---| | enableSparseCheckout(paths, { cone? }) | Enable sparse checkout — only specified paths are checked out. | | disableSparseCheckout() | Disable sparse checkout, restoring the full working tree. |

Shallow repo simulation

| Atom | What it does | |---|---| | shallowAt(depth) | Write .git/shallow to simulate a shallow clone at the given depth. | | unshallow() | Remove the shallow boundary, restoring full history. |

Git notes

| Atom | What it does | |---|---| | addNote(message, { ref?, namespace? }) | Add a note to a commit (overwrites existing). | | appendNote(message, { ref?, namespace? }) | Append to an existing note (or create). | | removeNote({ ref?, namespace? }) | Remove a note from a commit. |

Git hooks

| Atom | What it does | |---|---| | installHook(name, script) | Write an executable hook script to .git/hooks/<name>. | | removeHook(name) | Remove a hook script. |

Working tree cleanup

| Atom | What it does | |---|---| | gitClean({ directories?, force?, ignored? }) | git clean -f (force defaults to true). With directories: true adds -d; with ignored: true adds -x. |

Git meta files

| Atom | What it does | |---|---| | writeGitignore(patterns) | Convenience over writeFiles. Accepts a string array or pre-formatted string. Does NOT stage. | | writeGitattributes(rules) | Same shape as writeGitignore but writes to .gitattributes. |

Worked example: "out-of-date submodule"

A scenario shape that's hard with the imperative API but reads declaratively with atoms — the parent's pinned commit is older than the submodule's HEAD:

import { addCommit, addSubmodule, chain, defineScenario, insideSubmodule } from '@gfargo/git-scenarios'

export const outOfDateSubmoduleScenario = defineScenario({
  name: 'out-of-date-submodule',
  summary: 'parent pinned at submodule HEAD~2, three post-pin commits inside',
  description: '…',
  kind: 'submodule',
  setup: 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' }),

    // Make commits INSIDE the submodule without updating the parent's pin.
    insideSubmodule('vendor/lib', chain(
      addCommit({ message: 'feat: post-pin A', files: { 'src/a.ts': 'a' } }),
      addCommit({ message: 'feat: post-pin B', files: { 'src/b.ts': 'b' } }),
      addCommit({ message: 'feat: post-pin C', files: { 'src/c.ts': 'c' } }),
    )),
    // Parent's `.gitmodules` pin is unchanged; `git submodule status`
    // reports `+` modified.
  ),
})

Worked example: multi-contributor history

import { addCommit, chain, daysAgo, withAuthor } from '@gfargo/git-scenarios'

await chain(
  addCommit({ message: 'init', files: { 'README.md': '# repo' } }),
  withAuthor({ name: 'Alice', email: '[email protected]', date: daysAgo(10) },
    addCommit({ message: 'feat: alice work', files: { 'a.ts': 'x' } }),
  ),
  withAuthor({ name: 'Bob', email: '[email protected]', date: daysAgo(5) },
    addCommit({ message: 'fix: bob work', files: { 'b.ts': 'y' } }),
  ),
)(repo)

git log now shows commits by Alice (10 days ago) and Bob (5 days ago) — useful for testing blame, PR-triage-by-author, contributor stats.

Worked example: multi-remote fork topology

import { addCommit, addRemote, chain } from '@gfargo/git-scenarios'

await chain(
  addCommit({ message: 'init', files: { 'README.md': '# fork' } }),
  addRemote('origin', '[email protected]:fork/repo.git'),
  addRemote('upstream', '[email protected]:source/repo.git'),
)(repo)

Defining your own scenarios

Most projects want a few custom scenarios alongside the built-in ones — repo shapes specific to your tool's domain (e.g. "monorepo with two workspaces, one dirty"). Define them with defineScenario and compose the setup from atoms:

// my-test-utils/scenarios/two-workspace-dirty.ts
import {
  addCommit,
  chain,
  defineScenario,
  stageFiles,
  switchToBranch,
  writeFiles,
} from '@gfargo/git-scenarios'

export const twoWorkspaceDirtyScenario = defineScenario({
  name: 'two-workspace-dirty',
  summary: 'monorepo w/ packages/app + packages/lib; lib is dirty',
  description: 'Two workspace packages on `main`; uncommitted edits in `packages/lib/src/foo.ts`.',
  kind: 'worktree',
  contracts: [
    'main has 2 commits',
    'packages/lib/src/foo.ts is unstaged',
  ],
  setup: chain(
    addCommit({
      message: 'chore: scaffold workspaces',
      files: {
        'package.json': JSON.stringify({ name: 'mono', workspaces: ['packages/*'] }, null, 2),
        'packages/app/package.json': '{ "name": "app" }',
        'packages/lib/package.json': '{ "name": "lib" }',
      },
    }),
    addCommit({
      message: 'feat: lib baseline',
      files: { 'packages/lib/src/foo.ts': 'export const foo = 1\n' },
    }),
    // Now make a worktree change without staging.
    writeFiles({ 'packages/lib/src/foo.ts': 'export const foo = 2\n' }),
  ),
})

Use it in a test directly (no registration needed):

import { createTempGitRepo } from '@gfargo/git-scenarios'
import { twoWorkspaceDirtyScenario } from './my-test-utils/scenarios/two-workspace-dirty'

describe('my-tool against dirty workspace', () => {
  it('detects the unstaged lib change', async () => {
    const repo = await createTempGitRepo()
    try {
      await twoWorkspaceDirtyScenario.setup(repo)
      // … exercise your tool against repo …
    } finally {
      await repo.cleanup()
    }
  })
})

Or register it so it works with spinUpScenario and the CLI:

import { registerScenario } from '@gfargo/git-scenarios'
import { twoWorkspaceDirtyScenario } from './my-test-utils/scenarios/two-workspace-dirty'

registerScenario(twoWorkspaceDirtyScenario)
// Now: spinUpScenario('two-workspace-dirty') works
// And: git-scenarios create two-workspace-dirty works

The Scenario shape

type Scenario = {
  /** Stable identifier — kebab-case. */
  name: string
  /** One-line summary shown in CLI list output. */
  summary: string
  /** Multi-line description shown in CLI describe output. */
  description: string
  /** Filtering category. */
  kind: 'branch' | 'worktree' | 'operation' | 'history' | 'stash' | 'submodule'
  /** Optional tags for finer-grained filtering (e.g. ['conflict', 'merge']). */
  tags?: string[]
  /** Git-state factory — typically `chain(...)` of atoms. */
  setup: Step  // (repo: TempGitRepo) => Promise<void>
  /** Optional human-readable contract assertions. */
  contracts?: string[]
}

defineScenario validates the shape at module load time (kebab-case name, kind enum, non-empty fields). Catches typos that would otherwise blow up mid-test.

Contributing a scenario to this package

If your custom scenario is generally useful (e.g. "stashed-with-untracked", "rebase-mid-conflict"), open a PR against gfargo/git-scenarios adding:

  1. src/scenarios/<kebab-name>.ts exporting the scenario.
  2. <kebab-name>.test.ts next to it, asserting each contract line.
  3. Register in src/scenarios/index.ts.

See CONTRIBUTING.md for the full checklist.

Mock Factories

The library also ships in-memory mock factories for simple-git response types (StatusResult, LogResult, BranchSummary, DiffResult) — enabling fast unit tests that validate git-state-dependent logic without touching disk. Mock factories complement the real-repo approach: use real repos for integration tests, mocks for unit tests that need sub-millisecond speed.

Import from the dedicated subpath export at @gfargo/git-scenarios/mocks:

import { mockStatus, mockSimpleGit } from '@gfargo/git-scenarios/mocks'

Fluent builder

const status = mockStatus()
  .staged('auth.ts')
  .modified('utils.ts')
  .build()
// → A complete StatusResult with correct files[], bucket arrays, and isClean() = false

Wiring into a Jest test

import { mockSimpleGit, mockStatus } from '@gfargo/git-scenarios/mocks'

const git = mockSimpleGit({
  createMockFn: jest.fn,
  overrides: {
    status: mockStatus().staged('src/auth.ts').modified('src/utils.ts').build(),
  },
})

it('detects dirty worktree', async () => {
  const status = await git.status()
  expect(status.isClean()).toBe(false)
  expect(status.staged).toContain('src/auth.ts')
})

📖 Full mock factories guide →

TypeScript support

The package is TypeScript-first — all public APIs ship with full type declarations and source maps. Types you'll commonly reach for:

import type {
  AuthorIdentity,     // { name, email, date? } for withAuthor
  FileMap,            // { 'path': content } for writeFiles
  GitHookName,        // 'pre-commit' | 'commit-msg' | ... for installHook
  Scenario,           // the registered-scenario shape
  ScenarioKind,       // 'branch' | 'worktree' | 'operation' | 'history' | 'stash' | 'submodule'
  SeededFileSpec,     // { path, tokens, seedOffset? } for seededFiles
  Step,               // (repo: TempGitRepo) => Promise<void> — the atom contract
  TempGitRepo,        // { path, git, writeFile, commitAll, cleanup }
} from '@gfargo/git-scenarios'

Every atom returns a Step, so writing your own helpers feels identical to using the built-in ones:

import { addCommit, chain, type Step } from '@gfargo/git-scenarios'

// Custom helper composed from atoms — still a Step
export function scaffoldMonorepo(workspaces: string[]): Step {
  return chain(
    addCommit({
      message: 'chore: scaffold workspaces',
      files: {
        'package.json': JSON.stringify({ workspaces }, null, 2),
        ...Object.fromEntries(
          workspaces.map((w) => [`${w}/package.json`, `{ "name": "${w.split('/').pop()}" }`]),
        ),
      },
    }),
  )
}

// Use it like any built-in atom
await chain(
  scaffoldMonorepo(['packages/app', 'packages/lib']),
  addCommit({ message: 'feat: first feature', files: { 'packages/app/src/index.ts': '…' } }),
)(repo)

The atom factory pattern (returning a Step) means custom helpers compose cleanly into chain(...) alongside the built-ins.

Debugging

"What state did the scenario leave the repo in?"

# Spin up without --ephemeral (default) so the dir persists
npx git-scenarios create feature-pr-ready

# CLI prints the path; cd in and look around
cd /var/folders/.../git-scenarios-XXXXXX
git log --oneline
git status
git branch

"My test fails — what does the repo look like at that point?"

Comment out repo.cleanup() temporarily, then re-run the test. The temp dir survives the run; the failure message includes repo.path when you log it:

afterAll(async () => {
  // await repo.cleanup()   // ← comment out to inspect
})

it('does the thing', async () => {
  // ...
  console.log('repo path:', repo.path)   // log so you can cd in
  // assertion that fails
})

After inspecting, restore cleanup() so subsequent runs don't accumulate dirs.

"How do I run just one scenario's test?"

# All scenario tests
npm test -- --testPathPatterns scenarios

# A specific scenario
npm test -- --testPathPatterns feature-pr-ready

Mocking external services (LLM / network / hooks) in scenario-based tests

Scenarios set up the git state; mocks set up everything else. The standard pattern is to use your test framework's mocking primitives to replace the network / LLM / hook layer your tool calls into:

// jest example: mock a workflow handler the tool routes through
jest.mock('../commands/changelog/handler')
const mockedHandler = jest.mocked(changelogHandler)
mockedHandler.mockImplementation(async () => {
  process.stdout.write('feat: my deterministic title\n\nbody here.')
})

const repo = await spinUpScenario('feature-pr-ready')
const result = await runChangelogTextWorkflow({ branch: 'main' })
expect(result.text).toContain('feat: my deterministic title')

Together (scenario + mock) the test becomes deterministic top to bottom — same git state every run, same external response every run.

Contributing

See CONTRIBUTING.md for the full guide on adding atoms, scenarios, and tests. PRs welcome — open an issue at gfargo/git-scenarios if you're unsure about the shape.