Testing Scenarios
Coco ships with a scenario library for spinning up real git repos in deterministic, named states — useful for trying out the TUI against tricky shapes (merge conflicts, diverged branches, mid-bisect, multi-remote forks, shallow clones, multiple worktrees) without manually building those states by hand.
Scenarios live in @gfargo/git-scenarios, a small package extracted from coco's internal test utilities. Coco depends on it and re-exposes the CLI as npm run scenario.
Three ways to use scenarios:
- Manual testing — materialize a scenario in a tmp dir and launch
coco ui(or any other git tool) against it. Tightest dev loop for trying TUI changes. - Integration tests via the Jest adapter —
describeWithScenario('name', (getRepo) => { ... })for zero-boilerplate setup and teardown. - Integration tests via the raw API —
spinUpScenario('feature-pr-ready')in your tests for a fully-builtTempGitRepoif you need finer control than the adapter gives you.
All three paths share the same library, so scenarios you try manually are the exact same shapes the test suite asserts against.
Quick start
1# List every scenario, grouped by kind:
2npm run scenario list
3
4# Describe a single scenario:
5npm run scenario describe feature-pr-ready
6
7# Materialize a scenario in /tmp:
8npm run scenario create feature-pr-ready
9
10# Materialize and immediately launch coco ui against it:
11npm run scenario create feature-pr-ready -- --run-ui
12
13# Materialize at a custom path:
14npm run scenario create feature-pr-ready -- --path ~/sandbox/widget
15
16# Auto-clean the tmp dir when the launched tool exits:
17npm run scenario create feature-pr-ready -- --run-ui --ephemeralThe -- separator is required because npm run scenario already accepts subcommands; flags after -- are passed through to the underlying git-scenarios binary.
Available scenarios
Current set: 27 scenarios across 6 kinds. The live list is npm run scenario list — the table below is a quick reference.
Branch shapes
| Name | What you get |
|---|---|
empty-repo | Freshly-git init'd 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 | feat/widget-v2 4 commits ahead of main, clean worktree. The "ready to open a PR" baseline — drives the C create-pr flow and the L changelog flow. |
feature-branch-one-commit | main + feat/x (1 commit ahead with src/feature.ts). Minimal branch-vs-base shape. |
multi-commit-branch | feat/dashboard with 8 varied commits. Baseline for history navigation, filter, yank, etc. |
two-commit-feature | Baseline + a feat: commit on main, clean worktree. Smoke-test fixture. |
detached-head | HEAD detached at main~2, main still at its original tip. |
signed-commits-required | commit.gpgsign=true + user.signingkey set. Commits in the scenario itself are unsigned (CI lacks GPG); tools can read the config to decide whether to nudge the user. |
Upstream tracking
Prereq fixtures for testing pull / push UI, ahead-behind indicators, and the branch-tip chip rendering.
| Name | What you get |
|---|---|
branch-tracking-upstream | main tracks origin/main, both at the same commit. Baseline "synced" state. |
branch-ahead-of-upstream | main is 3 commits ahead of origin/main. Classic "unpushed changes" state. |
branch-behind-upstream | main is 3 commits behind origin/main. Fast-forwardable. |
branch-diverged | main is 2 ahead AND 2 behind origin/main. The "pull --rebase" case. |
branch-sync-showcase | Five local branches in five different upstream sync states (behind / ahead / diverged / synced / no-upstream) in a single repo. The fixture for the branches sidebar redesign. |
multi-remote-with-tracking | Fork workflow: origin + upstream remotes, main tracks upstream/main, feat/fork-work tracks origin/feat/fork-work. |
In-progress operations
The conflict matrix is complete — every .git/ state file that can hold a half-applied operation has a dedicated scenario.
| Name | Marker file | What you get |
|---|---|---|
mid-bisect | BISECT_* | 20 commits + active git bisect, HEAD at midpoint. Drives the bisect view. |
mid-merge-conflict | MERGE_HEAD | In-progress merge, 1 unresolved conflict in src/widget.ts. |
mid-rebase-conflict | REBASE_HEAD, .git/rebase-merge/ | In-progress rebase, 1 unresolved conflict in src/config.ts. |
mid-cherry-pick-conflict | CHERRY_PICK_HEAD | In-progress cherry-pick, 1 unresolved conflict in src/utils.ts. |
mid-revert-conflict | REVERT_HEAD | In-progress revert, 1 unresolved conflict in src/service.ts. |
Worktree shapes
| Name | What you get |
|---|---|
single-staged-file | Baseline + 1 staged README. Minimum "ready to commit" shape. |
dirty-many-files | 12 staged + 6 unstaged + 3 untracked files across src/, tests/, docs/. The fixture for the split flow. |
multiple-worktrees | Primary worktree on main + 3 linked worktrees on feature/hotfix branches. The fixture for the worktrees view (gz). |
History shapes
| Name | What you get |
|---|---|
rich-history-graph | 20+ commits across 6 date buckets, 2 --no-ff merges, 1 live unmerged feat/wip. Exercises compact + full-graph rendering, bucket dividers, type colouring, lane topology. |
chip-rendering-showcase | 6-commit history with every branch-tip-chip variant visible at once (HEAD, plain local, slashy local, origin/main, upstream/main, tag in trailing decoration). |
shallow-clone | Shallow repo with only 3 of 10 commits reachable from HEAD. Edge case for tools that walk history past the shallow boundary. |
large-repo | 115 commits across 3 branches with tags. For pagination + density-tier perf testing. |
Stash & submodule shapes
| Name | What you get |
|---|---|
stashed-changes | Clean main + 3 stashes (LIFO ordered, each touching a distinct file). Drives the stash view. |
submodule-with-history | Parent with 4 commits + vendor/lib submodule (clean pin, 4 commits, branch = main). Drives recursive submodule navigation. |
Common dev workflows
"I'm working on the branches view"
npm run scenario create branch-sync-showcase -- --run-uiHEAD lands on main which is 2 behind origin/main. The history view should show the "↓ 2 commits behind origin/main" banner. The branches sidebar (gb) should render five branches with five distinct sync glyphs:
| Branch | Marker | Color |
|---|---|---|
main (current) | * | green |
feat/ahead-only | ↑ | blue |
feat/diverged | ⇅ | yellow |
feat/synced | ≡ | muted |
local-only | ◌ | muted |
"I'm working on the chip rendering"
npm run scenario create chip-rendering-showcase -- --run-uiSix commits, each carrying a different chip kind. Confirm:
- Row 0 (HEAD): green chip for
main - Row 1: yellow chip for
origin/main - Row 2: blue chip for
feat/widgets(slash, but still local!) - Row 3: yellow chip for
upstream/main - Row 4: blue chip for
develop - Row 5: no chip, but
[tag: v0.1.0]in trailing decoration
"I'm working on the conflicts view"
The conflict matrix has four scenarios. Each populates a different .git/ state file and triggers a different resolution flow:
1npm run scenario create mid-merge-conflict -- --run-ui
2npm run scenario create mid-rebase-conflict -- --run-ui
3npm run scenario create mid-cherry-pick-conflict -- --run-ui
4npm run scenario create mid-revert-conflict -- --run-uiCycle through all four to make sure your changes hold across every flow.
"I'm working on the worktrees view"
npm run scenario create multiple-worktrees -- --run-uiLands you in a repo with 3 linked worktrees alongside the primary. Drives the gz view's listing, switching, and per-worktree status surfaces.
"I want to stress-test history rendering"
npm run scenario create large-repo -- --run-ui115 commits across 3 branches. Useful for testing pagination, density tiers, lane topology, and per-row hydration performance.
"I want a real remote"
npm run scenario create feature-pr-ready -- --remote git@github.com:org/repo.git --run-uiAdds origin pointing at the given URL. Useful for testing gh-aware flows (PR creation, issue triage). Use a fake URL if you don't want any risk of accidental push.
"I want to try something fully ephemeral"
npm run scenario create feature-pr-ready -- --run-ui --ephemeralTmp dir gets removed when coco ui exits.
Using scenarios in jest tests
There are two API surfaces. Pick the one that fits the test you're writing.
Path A — the Jest framework adapter (recommended for new tests)
Zero-boilerplate setup and teardown. Lives under the @gfargo/git-scenarios/jest subpath:
1import { describeWithScenario } from '@gfargo/git-scenarios/jest'
2
3describeWithScenario('feature-pr-ready', (getRepo) => {
4 it('is on a feature branch', async () => {
5 const repo = getRepo()
6 const status = await repo.git.status()
7 expect(status.current).not.toBe('main')
8 })
9
10 it('has a clean worktree', async () => {
11 const repo = getRepo()
12 const status = await repo.git.status()
13 expect(status.isClean()).toBe(true)
14 })
15})describeWithScenario handles the beforeAll / afterAll lifecycle automatically. getRepo() returns the same TempGitRepo instance for every test in the block.
Run a test against multiple scenarios at once:
1import { describeEachScenario } from '@gfargo/git-scenarios/jest'
2
3describeEachScenario(
4 ['mid-merge-conflict', 'mid-rebase-conflict', 'mid-cherry-pick-conflict', 'mid-revert-conflict'],
5 (getRepo, scenarioName) => {
6 it(`renders the conflicts view in ${scenarioName}`, async () => {
7 const repo = getRepo()
8 // … exercise the conflicts view against each conflict variant
9 })
10 },
11)This is exactly the pattern for auditing the conflicts view across all four .git/ state files in one block.
Extend a base scenario with extra steps:
1import { describeWithScenarioExtended } from '@gfargo/git-scenarios/jest'
2import { addCommit, chain } from '@gfargo/git-scenarios'
3
4describeWithScenarioExtended(
5 'single-staged-file',
6 chain(
7 addCommit({ message: 'extra setup', files: { 'extra.ts': '// …' } }),
8 ),
9 (getRepo) => {
10 it('has the extra commit on top', async () => {
11 const repo = getRepo()
12 const log = await repo.git.log()
13 expect(log.latest?.message).toBe('extra setup')
14 })
15 },
16)Path B — the raw API (for tests that need finer control)
src/commands/commands.integration.test.ts is the worked example for the manual spinUpScenario pattern, useful when a test needs to teardown mid-suite or compose multiple repos:
1import { spinUpScenario } from '@gfargo/git-scenarios'
2
3describe('coco commit', () => {
4 it('writes a message for a feature-PR-ready repo', async () => {
5 const repo = await spinUpScenario('feature-pr-ready')
6 try {
7 // run your command against repo.path …
8 } finally {
9 await repo.cleanup()
10 }
11 })
12})spinUpScenario(name) returns a fully-built TempGitRepo (path + simple-git instance + writeFile/commitAll helpers + cleanup). Replaces the inline "git init + a stack of repo.git.commit(...) calls" pattern.
For the rare case where no named scenario fits, createTempGitRepo() exposes the raw primitive — a fresh repo with user identity + a main branch — and you compose what you need from the atom layer (see next section).
Composing your own scenarios
The scenarios listed above cover common cases. If you need something different, two paths.
Path A — inline in a test
The package exposes the atom layer that scenarios are built from. Compose what you need inline:
1import {
2 createTempGitRepo,
3 chain,
4 addCommit,
5 addRemote,
6 setUpstream,
7 withRemoteTracking,
8} from '@gfargo/git-scenarios'
9
10const repo = await createTempGitRepo()
11await chain(
12 addCommit({ message: 'init', files: { 'README.md': '# repo' } }),
13 addRemote('origin', '/fake/url'),
14 withRemoteTracking('origin', 'main', chain(
15 addCommit({ message: 'upstream B', files: { 'b.ts': 'b' } }),
16 addCommit({ message: 'upstream C', files: { 'c.ts': 'c' } }),
17 )),
18 setUpstream('main', 'origin'),
19)(repo)
20// repo is now 2 commits behind origin/main, ready for assertionsSee the git-scenarios cookbook for many more recipes.
Path B — fromScenario (extend a named scenario with extra atoms)
When you want a stock scenario plus a handful of extra steps, fromScenario(name, ...extraSteps) saves the manual spinUpScenario + chain dance:
1import { fromScenario, addCommit } from '@gfargo/git-scenarios'
2
3const repo = await fromScenario(
4 'feature-pr-ready',
5 addCommit({ message: 'extra setup', files: { 'extra.ts': '// …' } }),
6)Path C — register a coco-specific scenario at runtime
Programmatic scenario registration (added in v0.5.0) lets coco define and use a one-off scenario without contributing it upstream:
1import { defineScenario, registerScenario, spinUpScenario, addCommit, chain } from '@gfargo/git-scenarios'
2
3registerScenario(defineScenario({
4 name: 'coco-commitlint-strict',
5 summary: 'repo with strict commitlint config + a staged change that violates it',
6 kind: 'worktree',
7 setup: chain(
8 addCommit({ message: 'chore: init', files: { 'commitlint.config.js': '…' } }),
9 // … extra setup
10 ),
11 contracts: ['commitlint config present', 'staged change violates header-max-length'],
12}))
13
14const repo = await spinUpScenario('coco-commitlint-strict')The scenario participates in the registry for the duration of the process — findScenario('coco-commitlint-strict') resolves it, and tests can reference it by name like any built-in.
Path D — contribute a named scenario back to the package
If the shape is reusable beyond coco, contribute it to @gfargo/git-scenarios:
- Add
src/scenarios/your-scenario.tsusingdefineScenario(...)(see existing scenarios for the pattern — every one is a single self-contained file). - Add a
.test.tsasserting your scenario's contracts. - Wire it into
src/scenarios/index.ts(allScenariosarray + the re-export). - Run
npm testto verify; submit a PR togfargo/git-scenarios.
A scenario is roughly 30–60 lines including JSDoc, and defineScenario validates the shape at module-load time so typos surface immediately.
CLI flag reference
| Flag | Behavior |
|---|---|
--path <dir> | Materialize at <dir> instead of /tmp. Useful when you want to cd into it later. |
--run <cmd> | After materializing, spawn <cmd> against the scenario dir (cwd = scenario dir). Examples: --run "lazygit", --run "gitui", --run "code -n". |
--run-ui | Coco-specific shortcut — spawns the source-tree CLI (tsx <coco>/src/index.ts ui) against the scenario dir. External consumers use --run "coco ui" instead. Implemented via bin/scenarioRunner.ts. |
--remote <url> | Add origin pointing at <url> so gh-aware tools detect a remote on launch. |
--ephemeral | Auto-clean the temp dir when the spawned tool exits. Skip for normal use — without --ephemeral, the dir persists so you can re-inspect after closing the tool. |
Where things live
- Package source:
@gfargo/git-scenarios— atoms, scenarios, CLI binary, Jest adapter - Package on npm:
@gfargo/git-scenarios - Jest adapter:
@gfargo/git-scenarios/jestsubpath —describeWithScenario,describeEachScenario,describeWithScenarioExtended - Coco-side wiring:
package.jsondeclares the dep +bin/scenarioRunner.tswraps the CLI so the documented--run-uishortcut actually launches the workstation - Integration test consumer:
src/commands/commands.integration.test.tsusesspinUpScenarioto set up fixtures - Eval harness consumer:
src/lib/parsers/default/__evals__/scenarioInputs.tsusesfindScenario+createTempGitRepoto build the structural-extract eval corpus
Updating to a new release
When @gfargo/git-scenarios ships a new version (new scenarios, new atoms, bug fixes), bump the coco dep:
yarn add --dev @gfargo/git-scenarios@^X.Y.ZThen run the integration test to catch any breaking changes:
npm run test:jest -- src/commands/commands.integration.test.tsAnd verify the new scenarios show up:
npm run scenario listSee also
- Coco UI — the workstation TUI that scenarios are most often used to drive
- TUI Navigation — the keymap + chord set
- git-scenarios README — full atom catalog, cookbook, Jest adapter docs, and contribution rules