tsgit replicates canonical git's observable behaviour byte-for-byte — object
SHAs, ref & reflog contents, on-disk state files, refusal conditions, and message
formats. Match git by default. When in doubt, verify against real git
(scrubbed GIT_*, isolated HOME, GIT_CONFIG_NOSYSTEM, signing off) rather than
guessing its behaviour, and pin the
result with a cross-tool interop test (see Write-surface interop
audit). A deliberate divergence is
permitted only when it carries its own ADR recording what diverges and why. This is
the project's prime directive — see ADR-226.
- Create a branch from
main - Write tests first (TDD: Red → Green → Refactor)
- Implement the feature
- Run
npm run validateto verify all quality gates - Commit using conventional commits
- Open a PR —
pkg-pr-newwill comment with an install command (npm install https://pkg.pr.new/scolladon/tsgit@<pr-number>) that lets reviewers smoke-test the build without waiting for a release
All tests in this project must follow these conventions.
Tests are nested so the describe tree carries the context and the action, and
the it carries only the expectation:
describe('<module or symbol>', () => {
describe('Given <context>', () => {
describe('When <action>', () => {
it('Then <expected result>', () => {
// ...
});
});
});
});Cluster every test sharing the same Given and the same When under one describe
"zone". The 2-level shortcut describe('Given <context>, When <action>') >
it('Then <expected>') is allowed only when a single expectation lives under that
When. A flat it('Given …, When …, Then …') is not allowed.
This is enforced by check:test-pyramid (the gwtTitle heuristic) on all test
tiers — unit, integration, parity, runtime-parity, and perf (see ADR-506).
Every test body is structured in three sections with comments (all three markers;
the gate floor is Arrange + Assert, and a genuinely empty section uses a
compound marker such as // Arrange & Act):
describe('Cart', () => {
describe('Given an empty cart', () => {
describe('When an item is added', () => {
it('Then the cart contains one item', () => {
// Arrange
const sut = new Cart();
const item = createItem('widget');
// Act
sut.add(item);
// Assert
expect(sut.count).toBe(1);
});
});
});
});sut names the unit under test — never a test input, never the outcome. The
outcome binds to result:
- Object under test →
sutis the object; exercise it and bind the outcome toresult:const sut = new NodeHashService('sha256'); const result = sut.digest(bytes). - Free function (or
this-free static factory) → dropsut; the callee self-names the unit, so bind the outcome directly toresult.
describe('serializeCommit', () => {
describe('Given a commit object', () => {
describe('When serializing', () => {
it('Then output matches git format', () => {
// Arrange
const commit = createCommit({ tree: treeId, parents: [parentId], message: 'initial' });
// Act
const result = serializeCommit(commit);
// Assert
expect(result).toEqual(expectedBytes);
});
});
});
});Enforced on all tiers by sutNaming (banned aliases subject/objectUnderTest/…
are rejected) and sutBindsResult (a const sut = <call>(…) binding a call result
is flagged — allowlisted object/operator factories aside). See ADR-506.
test/
├── unit/ # Isolated tests, memory adapter, fast — cross-platform
│ ├── domain/ # Per-module domain tests
│ ├── ports/ # Contract test suites (*.contract.ts — imported by adapter tests)
│ └── adapters/ # Per-adapter tests (Memory, Node) that invoke the contract suites
├── integration/
│ ├── network/ # Real repos, cross-adapter, git-http-backend interop (Linux-only)
│ ├── posix-only/ # Real POSIX filesystem semantics (symlinks, chmod, EACCES)
│ └── win-only/ # Real Windows filesystem semantics (8.3 short names, drive letters)
├── browser/ # Playwright × Chromium/Firefox/WebKit — OPFS round-trip, SubtleCrypto, DecompressionStream, command surface (log/branch/checkout/tag), and the cross-adapter parity driver (parity.spec.ts)
├── parity/ # Cross-adapter parity scenarios (Phase 19.5) — one Scenario<TResult> per file, asserted byte-identically by node.test.ts, memory.test.ts, and browser/parity.spec.ts
├── runtime-parity/ # Cross-runtime parity drivers (Phase 19.8) — runs the same SCENARIOS registry on Deno (deno/), Bun (bun/), and Cloudflare Workers (workers/) against the dist/ artifact
└── bench/ # vitest bench scenarios comparing tsgit vs isomorphic-git
When a new Repository flow needs the Node × Memory × Browser parity
guarantee, add a single file under test/parity/scenarios/<name>.scenario.ts:
- Declare a
<Name>Resultinterface — only fields exact-equality-safe across all three adapters (use the existingChangedPathshape forstatus.changes). - Export
scenario: Scenario<<Name>Result>withname,inputs(drawn fromtest/parity/fixtures.tsconstants — no inline literals; thecheck:parity-fixturesaudit gates determinism),expected(with 40-hexcommit.idliterals — see ADR-128), andrun. - Append to
SCENARIOSintest/parity/scenarios/index.ts. All three drivers and the browser bundle pick it up automatically. - Run
npm run test:parity— it fails with the real Node-side SHA-1; copy that intoexpected.commit.id. Re-run; thennpm run test:e2eto verify the browser side. SeeRUNBOOK.md→ "Cross-adapter parity" for the full recipe.
Tests are gated by folder, not by describe.skipIf(process.platform !== '…'):
test/unit/— cross-platform. Platform-aware behaviour is exercised via thePathPolicy(ADR-046)FsOperations(ADR-047) injection seam onNodeFileSystem. A simulated-Windows test runs on every host because the platform is data, not aprocess.platformread.
test/integration/posix-only/— real POSIX filesystem semantics (real symlinks, real mode bits, realEACCES). CI: theposix-integrationjob (ubuntu-latest+macos-latest).test/integration/win-only/— real Windows filesystem semantics (real 8.3 reconciliation, real drive-letter casing). CI: thewin-integrationjob (windows-latest).
See ADR-048 for
the rationale. Do not use describe.skipIf(process.platform !== '…')
or it.skipIf(process.platform !== '…') to gate platform-specific
behaviour — put the test in the folder that matches the platform it
needs.
# Vitest filters
npx vitest run test/unit/domain/objects/blob.test.ts # one file
npx vitest run -t "OPFS round-trip" # by test title
npx vitest run --project unit # whole project
npx vitest run --project integration # cross-platform shim
# Browser E2E (build is automatic via wireit)
npm run test:e2e # all 3 browsers
npx playwright test --project=chromium # one browser
npx playwright install --with-deps chromium # first-time setup
# Benchmarks
npm run test:bench # raw JSON in reports/benchmarks/
npm run bench:summary # markdown summary
npm run bench:fixture -- medium # pre-warm the scaled-bench fixture
npm run bench:fixture -- --prune # reclaim stale fixture caches
# Per-command CPU profiling (names-preserved build; commits docs/perf/baseline.{json,md})
npm run profile # profile every registered command
npm run profile status # profile a single command
# Runtime-parity matrix (Phase 19.8) — CI-gated; local-optional
npm run test:parity:deno # requires Deno on PATH
npm run test:parity:bun # requires Bun on PATH
npm run test:parity:workers # uses @cloudflare/vitest-pool-workers (devDep)The runtime-parity matrix (Deno + Bun + Cloudflare Workers) runs as
three blocking jobs in CI on every code-touching PR (ADR-144). It does
not join npm run validate (ADR-147): contributors who only edit
docs or stay within the Node/Browser/Memory adapters never need to
install Deno or Bun. If you want to validate locally before pushing —
e.g. you touched src/index.default.ts or the Memory adapter — install:
curl -fsSL https://deno.land/install.sh | sh # ~/.deno/bin/deno
curl -fsSL https://bun.sh/install | bash # ~/.bun/bin/bun
# wrangler ships as a devDependency; nothing extra to install for Workers.Then run any of the three test:parity:* recipes above. The Workers
recipe also serves as the wireit-cached gate for the parity-workers
CI job, so a local green is a strong signal for CI.
Bench scenarios are declared with the benchScenario wrapper
(test/bench/support/bench-dsl.ts) so they read with the same
Given/When/Then discipline as unit tests. Hot-path operations (log,
status, pack-read, blame, describe, name-rev) each get a single
<op>.bench.ts that runs at every size tier — small + medium always, large
under TSGIT_BENCH_LARGE — via the shared tieredScenario helper
(test/bench/support/tiered-bench.ts) — see RUNBOOK.md.
Port behaviors are defined once as reusable test suites in test/unit/ports/*.contract.ts and executed against every adapter. Adapters pass a factory returning the adapter plus any fixture helpers it needs:
// test/unit/ports/file-system.contract.ts
export interface FileSystemContractEnv {
readonly fs: FileSystem;
readonly rootDir: string;
readonly getRootDirSibling: () => Promise<string>; // for sibling-bypass tests
readonly getExistingInRoot: () => Promise<string>; // for rename-dst tests
readonly cleanup?: () => Promise<void>;
}
export function fileSystemContractTests(
createSut: () => Promise<FileSystemContractEnv>,
): void { /* 38 behavioral tests + 34 security-matrix tests */ }
// test/unit/adapters/memory/memory-file-system.test.ts
fileSystemContractTests(async () => ({
fs: new MemoryFileSystem({ rootDir: '/repo' }),
rootDir: '/repo',
getRootDirSibling: async () => '/repo-evil/x',
getExistingInRoot: async () => '/repo/existing.txt',
}));Contract files are .contract.ts (NOT .test.ts) — vitest picks up only *.test.ts directly. Contract files are plain modules imported by adapter test files, so they must explicitly import { describe, it, expect } from 'vitest'.
| KPI | Threshold |
|---|---|
| Line coverage | 100% |
| Branch coverage | 100% |
| Function coverage | 100% |
| Statement coverage | 100% |
| Mutation score | Per-bucket budgets — see Mutation budgets |
Mutation testing is bucketed by architecture tier. Each bucket has its own break threshold; the PR gate enforces the budget for files touched in the PR diff.
| Bucket | Globs | break | Why |
|---|---|---|---|
domain |
src/domain/** |
99 | Pure logic, no platform escape hatch. |
application |
src/application/**, src/repository.ts, src/repository/**, src/dispose-adapters.ts |
95 | Composition of primitives + ports; defensive guards covered by integration. |
adapters |
src/adapters/node/**, src/adapters/memory/**, src/adapter-detect.ts |
85 | Errno-conditional code covered by posix-integration + win-integration jobs, not unit mutation. |
infra |
src/operators/**, src/transport/**, src/ports/**, src/progress.ts |
90 | Pure operators + timer-sensitive transport middleware. |
Source of truth: mutation-budgets.json at repo root. The gate is npm run check:mutation-budgets, invoked after stryker run.
Local workflow (full-tree):
npm run test:mutation
npm run check:mutation-budgetsCI workflow (diff-scoped): .github/scripts/compute-mutation-scope.sh derives the file list from the PR diff vs base.sha; test:mutation:pr runs Stryker over that scope; check:mutation-budgets evaluates the resulting report. PRs that don't touch src/ skip mutation entirely — that scope-script skip is unchanged and orthogonal to the label gate below. See docs/design/phase-19-1-mutation-pyramid.md and ADRs 100–102.
The PR mutation job itself only runs on a PR carrying the mutation label. GitHub's labeled event is deliberately not a workflow trigger, so labelling an already-open PR does not by itself start the job — apply the label, then push (any synchronize picks it up).
Equivalent mutants. When a mutant is provably equivalent (the test would pass for both the original and the mutant), annotate the source with // equivalent-mutant: <why> on the line above. No catalogue, no allowlist — the annotation is the documentation.
npm run check:test-pyramid (also part of npm run validate) counts
unit/integration/e2e tests, reports their share against an 80/15/5 target
(ADR-106), and lints unit tests for expressiveness. Source of truth:
test-pyramid-budgets.json at repo root. Tooling lives in tooling/
alongside src/ and test/; tooling tests are at tooling/test/{unit, integration}/ and are scanned by the same audit globs.
Report-only (ADR-104, ADR-107, ADR-125) — never blocks merges:
-
Over-mocked integration — any
test/integration/**(ortooling/test/integration/**) file matching\bvi\.(mock|fn|spyOn|stubGlobal|stubEnv)\(. Use real fixtures (the memory adapter is fine — it's a real class, not a mock). -
Integration usefulness — every
test/integration/**/*.test.tsfile must carry a@provesJSDoc header declaringsurface,bucket, andunique. The audit reports three classes:missing— header absent or grammar-invalidduplicate— two files claim the same(surface, bucket)pair without the platform-only exemption (posix-only/+win-only/with bucketplatform-onlyis allowed)misplaced— bucket's directory rule rejects the file's directory (e.g.real-httpoutsidenetwork/)
The audit also writes
reports/integration-surfaces.json— a derived index consumed by browser surface-parity tooling. Ships warn-only; promotion to gating is a follow-up PR after one clean observation cycle.Bucket taxonomy (one per file):
Bucket What only this tier can prove real-fsReal Node fssemantics against a tmpdir, OS-agnostic.real-httpReal HTTP socket against canonical git-http-backend.real-processReal child_process.spawnagainst canonicalgit.cross-tool-interopBytes on disk round-trip against canonical git.platform-onlyBehaviour bound to one OS (POSIX perms, NTFS, etc.). multi-adapter-parityEnd-to-end flow through the memory adapter. coverage-gapCode path the unit suite cannot reach.
Gating (ADRs 109–116) — exit code 1 on any finding, fails CI:
- Under-asserted unit —
it()/test()blocks intest/unit/**whose body contains noexpect(...)/assert.*(...)call. Promoted from report-only by 19.3..skip/.todo/.failsexempt. - GWT title — every unit
it()/test()title must match^Given .+?, When .+?, Then .+$(case-sensitive). Skipped blocks are still validated (ADR-113). - AAA body comments — every non-skipped unit test body must contain
both
// Arrangeand// Assertmarkers at the start of a//-comment line. Compound forms (// Arrange + Act,// Act + Assert) count as both.// Actis optional (ADR-112). sutnaming — declaring any ofsubject,objectUnderTest,systemUnderTest,cutas aconst/let/varin a unit test body is rejected. The convention issut(ADR-110). Destructured forms (const { subject } = …) bypass the check by design.- Bare-class
.toThrow(Class)—.toThrow(SomeError)/.toThrowError(SomeError)where the only argument is a PascalCase identifier is rejected. Replace with a data-bearing matcher such asexpect.objectContaining({ data: expect.objectContaining({ code: 'X' }) })or a try/catch with.data.codeassertions (ADR-111). - Empty AAA section — every
// Arrange,// Act, or// Assertmarker that is present in a non-skipped unit body must be followed by at least one statement-bearing line before the next marker (or end of body). Markers introduced by an autofix but adjacent to another marker with nothing between them are flagged: extract asutvariable, hoist the act statement, or merge the two markers into a compound// Arrange + Assertline (ADRs 114–116).
Per-heuristic gating — flip gating.<heuristic> to true in
test-pyramid-budgets.json. Default-off so a newly-added heuristic ships
report-only until graduated by ADR.
Self-test exclusions — files listed in excludePaths (e.g. the audit's
own detector tests, which use intentional anti-pattern fixtures) are
skipped from heuristic scanning. Add a path there if a test file is
genuinely demonstrating a lint violation.
Local escape hatch — run node --experimental-strip-types tooling/audit-test-pyramid.ts --report-only to inspect findings without
the gate triggering. Useful mid-fix; CI never uses the flag.
When you add a module under src/ that emits Git-on-disk bytes (an
object writer, a refs writer, an index writer, a config writer, …),
declare its surface with a @writes JSDoc tag on the module header:
/**
* Example writer.
*
* @writes
* surface: exampleSurface
* kind: byte-identical | equivalent-under-readback | readback-only
* format: git-example-format
*/Then ship a matching integration test under test/integration/ whose
@proves block carries bucket: cross-tool-interop and
interopSurface: <surface name> (or a comma-list of surface names if
one test covers multiple). The test invokes canonical git to
produce reference bytes and asserts the contract for the declared kind
(see docs/design/phase-19-7-interop-suite.md §4).
Composite porcelain (mv, add, rm, reset, …) are also @writes
surfaces, even though they define no new on-disk format — they compose the
primitive writers, and their end-to-end faithfulness to git is what the
cross-adapter parity goldens (tsgit-computed) cannot vouch for (ADR-204). Tag
the command module (src/application/commands/<cmd>.ts) with
surface: <cmd>, kind: equivalent-under-readback, format: git-index-tree-state, and ship <cmd>-interop.test.ts. The comparison is the
host-independent readback of each side — git ls-files --stage (index),
git write-tree (tree), git rev-parse HEAD (ref) — never raw .git/index
bytes (stat-cache fields are per-host). Drive the command through the
openRepository facade, not the primitives, and prove refusals symmetrically
(tryRunGit confirms git also refuses, with no mutation on either side). See
docs/design/porcelain-interop-harness.md.
npm run check:write-surfaces (also part of npm run validate) walks
both sides and reports gaps, allowlist rot, orphan coverage, and
malformed headers. Ships warn-only (ADR-139) — promotion to blocking
is a follow-up PR after one clean observation cycle. Exemptions live
in tooling/audit-write-surfaces.allowlist.json with a written
reason and a deferredTo phase tag.
Follow Conventional Commits:
<type>: <description>
<optional body>
Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert
Examples:
feat: add packfile reader with delta resolutionfix: correct fanout table binary search off-by-onerefactor: extract tree diff into dedicated domain servicetest: add roundtrip tests for commit serialization
- Immutability — All domain objects are
readonly. Mutations produce new instances. - FP-first — Pure functions, function composition, no shared mutable state.
- Object Calisthenics — Branded types for domain concepts. No primitive obsession.
- Small functions — Single responsibility. Early returns over nesting.
- No
any— biome enforcesnoExplicitAny. Useunknown+ type narrowing. - Kebab-case files — Enforced by ls-lint. All
.tsfiles insrc/andtest/are kebab-case. - No tracking refs in code or tests — Don't write
Phase 14.5,§14.5.10,ADR-046, orBACKLOG-pointer comments inside source files or test titles. Git history records when and why a change happened; references in code duplicate that and rot when the tracking scheme changes. Tracking refs are fine in commit messages, design docs (docs/design/), plans (docs/plan/), ADRs (docs/adr/), andBACKLOG.mditself — those documents are the tracking surface.
- Domain has zero outward imports —
src/domain/never imports fromapplication/,ports/, oradapters/ - Commands use primitives —
application/commands/is built fromapplication/primitives/ - Ports are interfaces only —
src/ports/contains no implementations - Adapters implement ports — Each adapter in
src/adapters/implements the port interfaces
Before any PR can merge, all of these must pass:
-
npm run check— biome lint + format -
npm run check:types— tsc strict compilation againsttsconfig.typecheck.json(extendstsconfig.jsonwithincremental+ a gitignored.tsbuildinfo); a warm re-check with no source changes runs about 4× faster than a cold one -
npm run check:dead-code— knip (no unused code) -
npm run check:duplicates— jscpd (no copy-paste) -
npm run check:filesystem— ls-lint (naming conventions) -
npm run check:doc-coverage— everyrepo.*andrepo.primitives.*has a docs page -
npm run check:doc-typedoc— committedreports/api.jsonmatches the regenerated snapshot. Scoped tonpm run prepush(and CI), NOTnpm run validate— the typedoc regen costs ~10 s + a multi-MB diff on every export-touching change, so it runs at push time instead of every inner-loop check. If the pre-push hook fails becausereports/api.jsonis stale, runnpm run docs:json && git add reports/api.json && git commit --amend(or a fresh commit), then push again. -
npm run check:doc-links— markdown links resolve (requireslycheelocally;brew install lychee/cargo install lychee) -
npm run test:coverage— 100% on all KPIs -
npm run test:mutation— Stryker (target 0 survivors) - CI pipeline green (7 stages)
Four CI checks detect documentation drift; see docs/design/18-3-doc-maintenance-harness.md.
- Link checker —
lychee(Rust binary) scans every.mdfile in the doc tree. Local install:brew install lycheeorcargo install lychee. Run vianpm run check:doc-links. - API coverage — when you add a
repo.<command>orrepo.primitives.<primitive>binding insrc/repository.ts, the same PR must add the matchingdocs/use/{commands,primitives}/<kebab>.mdand a row in the funnelREADME.md. - TypeDoc snapshot —
reports/api.jsonis the committed public-surface baseline. After any JSDoc change on an exported symbol, runnpm run check:doc-typedocand commit the regeneratedreports/api.json. - Docs PR gate — CI-only, currently warn-only. When you touch
src/application/{commands,primitives}/<name>.ts, the PR comment will flag a missing matching docs change. Promotion to blocking is tracked as a 18.x follow-up.
Tests that pass are not necessarily tests that protect. Every feature branch targets 0 surviving mutants. Mutants that cannot be killed must be:
- Provably equivalent — the mutation produces identical observable behavior for every reachable input. This must be demonstrable via reasoning about the surrounding code, not assumption.
- Documented inline — add a
// equivalent-mutant:comment next to the mutated construct explaining why the two branches are equivalent. Example:// equivalent-mutant: starting i at segments.length + 1 performs extra undefined-segment // skips before landing on the same real index, so the returned value is identical. for (let i = segments.length - 1; i >= 0; i--) { /* ... */ }
Never use stryker-disable, v8 ignore, istanbul ignore, or other suppression directives without explicit approval.
Before merging a feature branch, complete all of these steps:
- 100% coverage —
npm run test:coveragepasses the 100% threshold for lines/branches/functions/statements on all files included invitest.config.tscoverage. - Mutation testing —
npm run test:mutationpasses the 90% break threshold with 0 non-equivalent surviving mutants. Every surviving mutant must be documented per "Mutation Testing Discipline" above. - Parallel reviews — run code review, security review, performance review, and test review. For test review, use the
test-reviewskill's 10-dimension audit. Address HIGH/CRITICAL findings before merge. - Documentation — update post-implementation state:
docs/BACKLOG.md— mark all completed items[x]docs/design/*.md— status line →Implemented (at <sha>)if applicabledocs/adr/*.md— status →Accepted (at <sha>)for new ADRsREADME.md— update the phase-status table if a phase transitionsdocs/understand/architecture.md/docs/use/recipes.md/CONTRIBUTING.md— reflect any new architectural or testing conventions introduced
- Clean commit history — squash WIP commits; ensure every commit passes tests (pre-commit hooks enforce this).
- Worktree cleanup — after squash-and-merge,
git worktree removeandgit branch -Dthe feature branch.