tsgit exposes a unified snapshot+join surface for querying the four git
"sources" — tree, index, working tree, and stash — through a single pipeline
API. This page introduces the mental model and links to the worked examples.
A snapshot is a description, not data. Calling repo.snapshot.head()
returns immediately and performs zero I/O. Reading happens only when you
iterate the result:
const tree = repo.snapshot.head();
for await (const entry of tree.entries()) {
console.log(entry.path, await entry.read());
}Three properties hold for every snapshot kind (design §8.0, ADR-149):
- Lazy — no syscall until
.entries()is consumed. - Atomic per handle — the data captured on the first iteration is
replayed for subsequent iterations on the same handle. Concurrent writes
never disturb an in-flight
for awaitloop. - Fresh per call to the factory —
repo.snapshot.index()always re-reads the cache state at call time; an external write between two factory calls produces two different snapshots.
repo.snapshot returns a SnapshotFactory:
| Method | Returns | Notes |
|---|---|---|
head() |
TreeSnapshot |
the HEAD commit's tree |
commit(oid) |
TreeSnapshot |
a specific commit's tree |
tree(oid) |
TreeSnapshot |
a tree by oid (no peeling) |
index() |
IndexSnapshot |
the current .git/index |
workdir(opts) |
WorkdirSnapshot |
the working tree |
mergeHead() |
Promise<TreeSnapshot | null> |
compound state |
cherryPickHead() |
Promise<TreeSnapshot | null> |
compound state |
revertHead() |
Promise<TreeSnapshot | null> |
compound state |
fetchHead() |
Promise<TreeSnapshot | null> |
compound state |
stashEntry(i) |
Promise<StashSnapshot | null> |
a stash entry (index + workdir + untracked) |
The compound-state factories return Promise because they have to check
whether the underlying ref file exists. They still don't parse the tree
until iterated.
Every source is reached through repo.snapshot.*, never as a top-level
repo.index / repo.workdir / repo.tree(rev) accessor. This is deliberate:
snapshots are a power-tool (lazy handles with isolation semantics), kept off
the everyday porcelain surface that returns plain structured data. Folding them
onto repo.* would either duplicate the factory (repo.index() ≡
repo.snapshot.index()), drop capability (a bare repo.index getter cannot
carry the options bag the method takes), or collide with a command namespace
(repo.stash is the stash command). The factory keeps the surface cohesive —
one place for all four sources.
To snapshot a tree at an arbitrary revision, resolve it first:
const tree = repo.snapshot.commit(await repo.revParse('v1.0'));Compare head, index, and workdir in a single pass:
import { join, count } from 'tsgit';
const rows = join({
head: repo.snapshot.head(),
index: repo.snapshot.index(),
workdir: repo.snapshot.workdir(),
});
for await (const row of rows) {
// row.head / row.index / row.workdir are optional — undefined when that
// source has no entry at row.path
}import { innerJoin } from 'tsgit';
const changed = innerJoin({
before: repo.snapshot.commit(parentOid),
after: repo.snapshot.head(),
});
for await (const row of changed) {
// row.before.oid !== row.after.oid (when content differs)
}import { join } from 'tsgit';
const rows = join({
index: repo.snapshot.index(),
workdir: repo.snapshot.workdir(),
});
for await (const row of rows) {
if (row.index === undefined && row.workdir !== undefined) {
// untracked file
}
}Compound-state factories return Promise<TreeSnapshot | null>. You must
null-check or wrap with requireSnapshot before passing to join:
import { requireSnapshot } from 'tsgit';
const theirs = await requireSnapshot(repo.snapshot.mergeHead(), 'no merge in progress');
const rows = join({ ours: repo.snapshot.head(), theirs });Passing a Promise<… | null> directly into join is a type error by
design — the row's slot type cannot be inferred through a promise wrapper.
The pipeline composes via async-iterable operators (see
tsgit/application/primitives/snapshot-operators):
hashWorkdir({ concurrency })— pre-warmsWorkdirEntry.hash()calls.loadBlob(slot, { maxInflightBytes })— pre-loads blob bytes with a bounded byte budget (default 64 MiB).verifyWorkdir({ onRace: 'throw' | 'skip' | 'emit' })— re-lstats workdir entries on iteration for race detection.groupByDir()— groups consecutive rows by parent directory.count,toArray,first— terminal operators returningPromise<T>.
All snapshot+join iterables yield rows in canonical git path order.
Operators consuming a row stream MUST preserve order; a downstream
assertOrdered will throw ORDER_INVARIANT_VIOLATION if a stage reorders
rows. This guarantees compositions like groupByDir see contiguous rows
per directory without needing buffering.
Every iteration honours three signals composed by an AND-of-aborts:
ctx.signalfromopenRepository,SnapshotOptions.signalper snapshot,JoinOptions.signalfrom the join call.
The first abort wins; downstream iterators surface the abort as
OPERATION_ABORTED.
- docs/understand/caching.md — caching protocol, generation tracking, racy-stat handling.
- docs/adr/148–161 — every design decision behind the snapshot+join surface.
- docs/design/phase-20-1-snapshot-and-join.md — the full design spec.