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

v0.4.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

What this is

Real-world git tools — coco, lazygit, 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: v0.1.0. Recently extracted from gfargo/coco where it lived at src/lib/testUtils/ (coco v0.43.0–v0.51.x) and later packages/git-scenarios/ during the standalone-extraction spike. Now a real published npm package. API is at 0.x — minor breaking changes possible until v1.0.

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 ESM; CommonJS consumers should use await import(...).

Inside the coco monorepo today, no install is needed — the package is consumed via path mapping. See coco's CONTRIBUTING.md for the in-monorepo workflow.

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/.../coco-git-test-xR2qwz
# cd in and run whatever you want against it

Inside coco's monorepo, npm run scenario is wired as a shortcut:

npm run scenario list
npm run scenario create feature-pr-ready -- --run-ui  # launches coco ui

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

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

packages/git-scenarios/
├── README.md               (this file)
├── package.json
├── tsconfig.json
├── src/
│   ├── index.ts            (public API — `spinUpScenario`, `createTempGitRepo`, registry)
│   ├── tempGitRepo.ts      (low-level: init + user config + main branch)
│   ├── spinUpScenario.ts   (programmatic API for tests)
│   ├── spinUpScenario.test.ts
│   ├── __fixtures__/
│   │   └── generators.ts   (vendored deterministic content generator)
│   └── scenarios/
│       ├── types.ts        (Scenario type)
│       ├── index.ts        (registry + lookup)
│       ├── shared/
│       │   └── seededFiles.ts (wrapper around the generator)
│       ├── feature-pr-ready.ts
│       ├── feature-branch-one-commit.ts
│       ├── multi-commit-branch.ts
│       ├── two-commit-feature.ts
│       ├── single-staged-file.ts
│       ├── dirty-many-files.ts
│       ├── mid-bisect.ts
│       ├── mid-merge-conflict.ts
│       ├── stashed-changes.ts
│       ├── rich-history-graph.ts
│       └── submodule-with-history.ts
└── bin/
    └── cli.ts              (the `git-scenarios` CLI, also reachable as `npm run scenario` inside coco)

The CLI driver lives at bin/cli.ts and is wired via the scenario npm script inside the coco monorepo. When extracted, it becomes the binary at bin.git-scenarios in package.json.

Available scenarios

Run git-scenarios list (or npm run scenario list inside coco) for the live list. Current set (21 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. The "what does your tool do on a brand-new repo?" edge case. | | feature-pr-ready | branch | feat/widget-v2 4 commits ahead of main, clean worktree — for create-pr (C) and changelog (L) 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. For TUIs whose branch list shows mixed sync states at once. | | detached-head | branch | HEAD detached at main~2, main still at its original tip | | signed-commits-required | branch | commit.gpgsign=true + user.signingkey set — for testing signing-aware UI | | single-staged-file | worktree | baseline + 1 staged README — minimum "ready to commit" shape | | dirty-many-files | worktree | 12 staged + 6 unstaged + 3 untracked files across src/, tests/, docs/ — for the future split flow | | mid-bisect | operation | 20 commits + active git bisect, HEAD at midpoint — for the bisect view | | mid-merge-conflict | operation | in-progress merge with 1 unresolved conflict on src/widget.ts — for the conflicts view | | rich-history-graph | history | 20+ commits across 6 date buckets, 2 --no-ff merges, 1 live unmerged feat/wip — for compact + full-graph rendering (bucket dividers, type coloring, branch chips, lane topology) | | chip-rendering-showcase | history | 6 commits each carrying a different branch-tip-chip kind — HEAD, plain local (develop), slashy local (feat/widgets), origin/main, upstream/main, and tag v0.1.0 in trailing refs. For visual regression on TUIs that colour chips by kind. | | stashed-changes | stash | clean main + 3 stashes (LIFO ordered, each touching a distinct file) — for the stash view | | submodule-with-history | submodule | parent with 4 commits + vendor/lib submodule (clean pin, 4 commits, branch = main) — for recursive submodule navigation |

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

The CLI

# Outside coco (after `npm install --save-dev @gfargo/git-scenarios`):
npx git-scenarios list                                                  # show all scenarios grouped by kind
npx git-scenarios describe feature-pr-ready                             # one-scenario detail
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

# Inside coco's monorepo, `npm run scenario` is wired as a shortcut:
npm run scenario list
npm run scenario create feature-pr-ready -- --run-ui                    # `--run-ui` launches coco ui

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). | | --run-ui | Coco-monorepo back-compat alias — spawns coco's source-tree CLI (tsx <coco>/src/index.ts ui) against the scenario dir. External consumers use --run "coco ui" (or any other shell command) instead. | | --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. |

Cleanup

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

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

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

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

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

Programmatic API (integration tests)

spinUpScenario(name)

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

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>
  commitAll: (message: string) => Promise<void>
  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 (Coco 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.
  • commitAll(message)git add . && git commit -m <message> in one call. Convenience for the common case.
  • 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'])

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 'packages/git-scenarios/src/tempGitRepo'

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

If you find yourself reaching for createTempGitRepo() to build something a future test will also want, add a scenario instead (see "Adding a new scenario" below), or compose one inline from the atom layer (see the next section).

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>. | | 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. |

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?, message?, date? }) | Merge — conflicts leave the repo mid-merge by default. | | abortMerge() | git merge --abort. | | cherryPick(ref, { allowConflict?, date? }) | Cherry-pick — conflicts leave mid-cherry-pick by default. | | abortCherryPick() | git cherry-pick --abort. | | revert(ref, { mainline?, allowConflict?, date? }) | Revert a commit (use mainline for merge commits). | | 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. |

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 build a local registry + helper that mirrors spinUpScenario:

// my-test-utils/scenarios/index.ts
import { createTempGitRepo, type Scenario, type TempGitRepo } from '@gfargo/git-scenarios'
import { twoWorkspaceDirtyScenario } from './two-workspace-dirty'
import { releaseReadyScenario } from './release-ready'

const localScenarios: Scenario[] = [twoWorkspaceDirtyScenario, releaseReadyScenario]

export async function spinUpLocalScenario(name: string): Promise<TempGitRepo> {
  const scenario = localScenarios.find((s) => s.name === name)
  if (!scenario) throw new Error(`Unknown local scenario "${name}"`)
  const repo = await createTempGitRepo()
  await scenario.setup(repo)
  return repo
}

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'
  /** 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/coco adding:

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

The CLI picks it up automatically.

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
  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
npm run scenario create feature-pr-ready

# CLI prints the path; cd in and look around
cd /var/folders/.../coco-git-test-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?"

Inside the coco monorepo:

# All scenario tests
npm run test:jest -- --testPathPatterns scenarios

# A specific scenario
npm run test:jest -- --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.

Consumers outside of tests

The scenario library doubles as a benchmark / eval input source inside the coco monorepo — each scenario's commits are walked into per-file diffs and fed through the parser pipeline as a deterministic golden set:

npm run eval:structural-extract                 # all scenarios + fixtures
npm run eval:structural-extract -- --scenario feature-pr-ready
npm run eval:structural-extract -- --fixtures-only

The adapter lives at src/lib/parsers/default/__evals__/scenarioInputs.ts and the extraction-boundary rule still holds: it imports from src/scenarios and the public findScenario helper, not from any individual scenario module. When the testUtils layer moves out to its own package, the eval depends on the published package the same way any other consumer would.

Boundary discipline

This package is git-tool-agnostic by design. Its public surface is the named exports from index.ts; everything inside knows nothing about which downstream tool is consuming it.

Rules contributors should keep

  • Scenario signatures are pure git-state factories. (repo: TempGitRepo) => Promise<void>. No knowledge of which tool is testing them. A scenario named mid-bisect produces a mid-bisect repo — full stop.
  • Public surface = index.ts. Tests import named symbols from the package root; nothing else should reach into individual files directly.
  • CLI (bin/cli.ts) uses the generalized --run <cmd> flag to launch any tool. The --run-ui legacy alias exists for backward compatibility with the in-coco-monorepo workflow; external consumers should use --run "coco ui" (or any other shell command).
  • Imports stay minimal: simple-git, Node stdlib (fs, path, os, child_process, util), and sibling files inside the package. No deps on consumer tools.

Contributing

Open an issue at gfargo/git-scenarios with what you're trying to test and what shape the scenario should take. PRs welcome — see Defining your own scenarios above for the shape, plus add a paired .test.ts asserting each contract line.