Lightning-fast git, pure TypeScript, everywhere.
A pure TypeScript git implementation designed to be the fastest portable git library available. Runs identically on Node.js (Windows, macOS, Linux), browsers, and edge runtimes — with zero native dependencies, zero WASM, and zero compromises on developer experience.
v1.0.0-rc — production-ready surface. All 11 phases (Domain → Repository facade → Polish & Launch) are implemented with 100% line/branch/function/statement coverage and mutation-verified test quality. The pre-publish workflow validates the tarball on every v* tag; release-please drives the npm publish.
| Phase | Scope | Status |
|---|---|---|
| 1 | Domain — Object Model (blob, tree, commit, tag, refs) | ✅ |
| 2 | Domain — Object Storage (loose objects, packfiles, delta) | ✅ |
| 3 | Domain — Refs & Index (loose refs, packed-refs, git index v2) | ✅ |
| 4 | Ports & Adapters (FileSystem, HashService, Compressor, HttpTransport, ProgressReporter — Node + Browser/OPFS + Memory) | ✅ |
| 5 | Domain — Diff & Merge | ✅ |
| 6 | Operators (AsyncIterable composition) | ✅ |
| 7 | Primitives (Tier 2 API) | ✅ |
| 8 | Transport (Smart HTTP + middleware) | ✅ |
| 9 | Commands (Tier 1 API) | ✅ |
| 10 | Repository facade | ✅ |
| 11 | Polish & Launch (CI matrix, browser E2E, benchmarks, TypeDoc, MIGRATION) | ✅ |
| 12.1 | Clone — smart-HTTP pack fetch + write-objects loop | ✅ |
| 12.2 | Fetch — ls-refs + want/have negotiation + shallow + prune | ✅ |
| 12.3 | Push — receive-pack negotiation + pack send + force-with-lease | ✅ |
| 12.4 | Bench — clone:small-repo vs isomorphic-git over git-http-backend |
✅ |
| 13.1 | Working-tree materialisation — checkout writes / deletes / chmods files; index + HEAD updated atomically per file |
✅ |
| 13.2 | reset --mixed rebuilds .git/index from the target commit's tree under index.lock; stat-cache preserved for unchanged paths |
✅ |
| 13.3 | reset --hard materialises both index and working tree to match the target commit's tree; force-rewrites locally-modified files |
✅ |
| 13.5 | checkout acquires index.lock before reading the index — closes a known TOCTOU window; path-restore from 'index' stays lock-free |
✅ |
| 13.6 | checkout({ paths, source: 'index' }) synthesises a tree from staged entries — restores from index content, honouring divergence from HEAD |
✅ |
| 14.4 | Full Windows support — 8.3 short-name reconciliation in NodeFileSystem.checkContainment, ELOOP/EACCES errno parity, windows-latest re-added to the unit-test matrix |
✅ |
- Lightning fast — 3-5x faster than isomorphic-git via fanout binary search, LRU delta cache, zero-copy parsing, streaming inflate
- Portable — Runs on Node.js 18+, Chrome 90+, Firefox 100+, Safari 15.4+, Deno, Bun, Cloudflare Workers
- Lightweight — < 200 kB gzipped full library. Zero runtime dependencies. Tree-shakeable.
- Two-tier API — Ergonomic repository object for common operations + composable AsyncIterable primitives for power users
- Type-safe — Branded types, discriminated unions, exhaustive error codes. No
any. - Testable — First-class in-memory adapter. All ports are mockable. Pure functions throughout.
npm install @scolladon/tsgitimport { openRepository } from '@scolladon/tsgit';
const repo = await openRepository({ cwd: process.cwd() });
const commits = await repo.log();
const changes = await repo.status();
await repo.dispose();openRepository is a frozen handle exposing every command and primitive bound
to a single Context. The Node entry point auto-detects an existing .git
directory by walking up from cwd.
import { openRepository } from '@scolladon/tsgit/auto/browser';
const rootHandle = await navigator.storage.getDirectory();
const repo = await openRepository({ rootHandle });
await repo.init();Browser callers must supply an OPFS rootHandle since there is no
process.cwd() equivalent.
import { openRepository } from '@scolladon/tsgit/auto/memory';
const repo = await openRepository({
files: { '/repo/seed.txt': new TextEncoder().encode('hello') },
});
await repo.init();import { openRepository } from '@scolladon/tsgit';
const repo = await openRepository({
cwd: '/tmp/my-clone',
config: {
dnsResolver: async (host) => (await import('node:dns')).promises.resolve(host),
},
});
const result = await repo.clone({
url: 'https://github.com/owner/repo.git',
resolver: async (host) => (await import('node:dns')).promises.resolve(host),
});
console.log(result.head); // refs/heads/main
console.log(result.fetchedRefs.length); // total refs propagatedPhase 12.1 gives you a valid .git directory whose git log matches the
remote's HEAD line. To materialise the working tree, run
repo.checkout({ target: result.head }) immediately after — Phase 13.1
writes every blob, sets the executable bit, follows symlinks, and commits a
matching .git/index atomically. See
test/integration/network/clone-http-backend.test.ts for an end-to-end
example against a local git-http-backend.
// Literal paths.
await repo.add(['src/foo.ts', 'src/bar.ts']);
// Pathspec globs (Phase 14.2). Any pattern with `*`, `?`, or `**` is a
// glob; everything else is a literal that also matches descendants.
// `!`-prefixed entries exclude. Last-match wins.
await repo.add(['*.ts', '!*.test.ts']); // every .ts minus tests
await repo.add(['src/**']); // everything under src/
// Bulk mode — walk the working tree, stage every modified/new file plus
// every untracked file that isn't ignored, drop tracked files missing
// from disk. `paths` MUST be empty when `all: true`.
const result = await repo.add([], { all: true });
console.log(result.added); // new staged paths (sorted)
console.log(result.modified); // changed paths (sorted)
console.log(result.removed); // tracked paths gone from disk (sorted)repo.rm and repo.checkout({ paths }) accept the same pathspec
syntax. A literal pattern that matches nothing throws
PATHSPEC_NO_MATCH; a glob that matches nothing is a silent no-op
(matches Git's behaviour). Pathspec syntax is *, ?, **, and
! negation; character classes ([abc]) and Git magic prefixes
(:(top), :(literal)) are not supported in v1.
Phase 14.1 ships bulk-mode add --all walking via the new
walkWorkingTree primitive. The host repository's .git is skipped, and
embedded clones (directories containing a .git child) are not
auto-staged — matches Git's default. Symlinks stage as mode 120000
with the link target as blob content. Files larger than
MAX_WORKING_TREE_BLOB_BYTES (256 MiB) reject with
WORKING_TREE_FILE_TOO_LARGE and no partial index commit.
Phase 14.3 wires .gitignore evaluation across add --all AND status:
- Sources (last match wins):
core.excludesFile(from git config,~-expanded againstctx.layout.homeDir) →.git/info/exclude→ repo-root.gitignore→ nested.gitignoreper directory. - Walk-time pruning: ignored directories are skipped without
lstat-ing their contents — big perf win on
node_modules,dist, etc. - Tracked-but-ignored stays tracked: a path already in the index is preserved across re-adds even if a new ignore rule would match it (or its parent directory). Git's invariant.
statusemits'untracked': non-ignored working-tree files not in the index now appear as{ kind: 'untracked', path }andstatus.cleanreflects them..gitignoresize cap: 1 MiB per file (MAX_GITIGNORE_BYTES). Reject withGITIGNORE_FILE_TOO_LARGE.
const result = await repo.push({
remote: 'origin',
refspecs: ['refs/heads/main:refs/heads/main'],
// optional: force-with-lease against the cached remote-tracking ref
// forceWithLease: 'auto',
});
for (const r of result.pushedRefs) {
console.log(r.name, r.status, r.reason ?? ''); // 'refs/heads/main' 'ok'
}Phase 12.3 supports <src>:<dst>, +<src>:<dst>, :<dst> (delete), short-form
branch names, and HEAD as a source. Force-with-lease accepts either an explicit
ObjectId or 'auto' (resolves to the cached refs/remotes/<remote>/<branch>).
A successful push updates the local remote-tracking cache for accepted refs.
See test/integration/network/push-http-backend.test.ts for an end-to-end
example.
import { openRepository, consoleProgress } from '@scolladon/tsgit';
const repo = await openRepository({
progress: consoleProgress((line) => console.log(line)),
});const controller = new AbortController();
const repo = await openRepository({ signal: controller.signal });
controller.abort(); // every bound method now throws REPOSITORY_DISPOSEDimport { walkCommits } from '@scolladon/tsgit/primitives';
import { pipe, filter, take } from '@scolladon/tsgit/operators';
const recentByAlice = walkCommits(ctx, { from: 'main' })
|> filter(c => c.data.author.name === 'Alice')
|> take(5);
for await (const commit of recentByAlice) {
console.log(commit.data.message);
}Comparison against isomorphic-git@1.38 on a synthetic 50-commit repo. Numbers
are medians from vitest bench; ±RME and full p99 distribution live in
reports/benchmarks/raw.json.
| Scenario | tsgit | isomorphic-git | tsgit speedup |
|---|---|---|---|
status:clean |
~1.7 ms | ~4.0 ms | ~2.4× |
status:dirty-25-files |
~1.7 ms | ~3.7 ms | ~2.2× |
log:walk-50-commits |
~6 ms | ~4 ms | ~0.7× |
readBlob:warm-cache |
~0.1 ms | ~0.1 ms | ~1.0× |
clone:small-repo |
~40 ms | ~40 ms | ~1.0× |
Reproduce locally with npm run bench:summary (writes reports/benchmarks/summary.md).
GitHub Actions runners introduce ±20% variance — trust direction more than
absolute numbers.
Hexagonal architecture with a tiered application layer:
- Domain — Pure git objects, parsers, serializers, refs, index. Zero outward dependencies.
- Application — Commands (Tier 1) built from Primitives (Tier 2).
- Ports — Interfaces for
FileSystem,HashService,Compressor,HttpTransport,ProgressReporter+ aContextrecord that threads them through every call. - Adapters —
Node.js(node:fs/crypto/zlib/http),Browser(OPFS + SubtleCrypto + fetch + CompressionStream),Memory(first-class test fixture — primary test double for every upstream phase).
See docs/prd/PRD.md for the full product requirements document, docs/design/ports-and-adapters.md for the Phase 4 port contracts, and docs/adr/ for architecture decisions.
npm install
npm run validate # Run all checks + tests
npm run check # Lint + format (biome)
npm run check:types # Type check (tsc)
npm run test:unit # Unit tests
npm run test:coverage # With 100% coverage enforcement
npm run test:mutation # Mutation testing (stryker)
npm run build # Compile to dist/See CONTRIBUTING.md for development guidelines, test conventions, and the PR workflow.