hologit
Version:
Hologit automates the projection of layered composite file trees based on flat, declarative plans
249 lines (199 loc) • 12.2 kB
Markdown
# @hologit/holo-tree
Git tree, ref, blob, and commit primitives for Node.js — a native binding over
the [`holo-tree`](../holo-tree) Rust crate, powered by
[gitoxide](https://github.com/GitoxideLabs/gitoxide).
- **No `git` binary.** Everything runs in-process through gix; nothing shells
out. Works anywhere Node runs, including environments with no git installed.
- **Bare-repo native.** Operates directly on git object databases and refs —
no working directory, no index. A bare clone is a full-fledged store.
- **Mutable in-memory trees.** Load a tree from any ref, upsert/delete/merge
paths against it in memory, then flush only the dirty subtrees to the ODB
and commit — the natural substrate for record stores, content pipelines, and
anything that treats a git repo as a database.
- **Built for embedding.** Stable machine-matchable error codes, panic
containment at the FFI boundary (a Rust bug can never abort your process),
and no reliance on thread-implicit state.
This package is a supported, standalone primitive — the Node-side consumption
mode of the same crate that Rust consumers (e.g.
[gitsheets](https://github.com/JarvusInnovations/gitsheets)) link directly via
Cargo. Both modes ship from `holo-tree-v*` tags and implement the same
contract (`specs/architecture.md` § Embedding consumers).
## Install
```sh
npm install @hologit/holo-tree
```
Prebuilt release binaries ship as `optionalDependencies` for:
linux-x64-gnu, linux-arm64-gnu, linux-x64-musl, darwin-arm64, darwin-x64,
win32-x64-msvc (full matrix under [Publishing](#publishing)). No Rust
toolchain needed on supported platforms.
## Quick start
```js
const { Repo, emptyTreeHash } = require('@hologit/holo-tree');
const repo = Repo.open('/path/to/repo.git'); // bare or .git dir
// load HEAD's tree, upsert a record, commit, advance the branch
const tree = repo.createTreeFromRef('HEAD'); // or repo.createTree() for empty
tree.writeChild('data/widgets/1.toml', 'id = 1\n'); // hash blob + deep insert
const treeHash = tree.write(); // flush dirty subtrees → ODB, returns tree hash
const parent = repo.resolveRef('HEAD');
const commit = repo.commitTree(treeHash, [parent], 'add widget 1');
repo.updateRef('refs/heads/main', commit, parent); // compare-and-swap
const bytes = repo.createTreeFromRef(commit).readBlob('data/widgets/1.toml');
```
Conventions: object ids cross the boundary as lowercase 40-char hex strings;
blob content crosses as `Buffer` (binary-safe).
## Errors: match on `code`, never on message
Every failure is a catchable `Error` carrying a **stable `code` property** —
codes, not message prose, are the API (messages may change freely). The
contract, including the full code table and stability rules, is
[`specs/api/errors.md`](../specs/api/errors.md).
```js
try {
repo.updateRef('refs/heads/main', commit, expectedParent);
} catch (err) {
switch (err.code) {
case 'REF_CONFLICT': // someone else moved the ref — reload and retry
return retryTransaction();
case 'OBJECT_NOT_FOUND': // a hash you passed isn't in the ODB
throw new StoreCorruptError(err.message);
default:
throw err;
}
}
```
Common codes: `REF_CONFLICT` (lost a compare-and-swap `updateRef` — the
optimistic-concurrency signal), `OBJECT_NOT_FOUND`, `NOT_A_TREE`,
`PATH_NOT_FOUND`, `INVALID_ARGUMENT` (malformed hex id, bad merge mode, …),
`GIT` (unclassified git failure), `INTERNAL` / `PANIC` (always a holo-tree
bug — report it; the process stays healthy, but discard the involved objects).
Absence is a value, not an error: probes like `readBlob`, `getChild`, and
`resolveRef` return `null` for "not there".
## Performance — release builds only
> **⚠️ Never benchmark or ship a debug build.** The published npm prebuilds
> are release builds — `npm install` users are fine. But if you build from
> source, `napi build` *without* `--release` produces a debug binary measured
> **~2.4× slower** than the equivalent JS + `git` subprocess path on the
> reference workload, while the same code built with `--release`
> (`npm run build`) is **~4–5× faster**. A debug build silently inverts the
> entire performance story. Check at runtime with `buildProfile()`, which
> returns `'release'` or `'debug'` — the bundled benchmark refuses debug
> builds.
Reference workload (real data: an 18k-record flat tree — see
[`bench/`](bench/)): single-record upsert→commit ~25ms vs ~100ms for the JS
hologit + `git` subprocess path; 500 records in one commit ~41ms vs ~186ms.
Reproduce with:
```sh
npm run build && node bench/holo-tree-bench.mjs
```
## Thread safety
`Repo` is backed by a `gix::ThreadSafeRepository` and is safe to hold
anywhere. Each `Tree` owns its own tree cache, so behavior never depends on
which thread the JS engine dispatches a call on — at worst a thread switch
costs cache warmth, never correctness (`specs/api/errors.md` § Thread-safety
expectations). Objects are not shareable across `worker_threads` (each worker
must `Repo.open` its own handle); within one JS thread, calls are synchronous
and complete before returning.
## Surface
| Method | holo-tree call | Notes |
| --- | --- | --- |
| `Repo.open(gitDir)` | `gix::open().into_sync()` | factory; bare repo or `.git` dir |
| `repo.createTreeFromRef(ref)` → `Tree` | `repo::create_tree_from_ref` | resolves ref→commit→tree |
| `repo.createTree()` → `Tree` | `MutableTree::empty` | |
| `repo.commitTree(treeHash, parents[], msg, author?, committer?)` → hash | `repo::commit_tree` | identities fall back to git config |
| `repo.updateRef(ref, hash, expectedOldHash?)` | `repo::update_ref` | compare-and-swap when `expectedOldHash` given; force otherwise |
| `repo.resolveRef(ref)` → `hash\|null` | `repo::resolve_ref` | peels tags; `null` if unresolved |
| `repo.writeBlob(buf)` → hash | `gix write_blob` | hash bytes into the ODB, no tree |
| `tree.writeChild(path, text)` → hash | `MutableTree::write_child` | UTF-8 text |
| `tree.writeChildBytes(path, buf)` → hash | `MutableTree::write_child_bytes` | binary |
| `tree.writeChildHash(path, hash, mode)` → hash | `MutableTree::write_child_hash` | graft an existing ODB blob without re-hashing |
| `tree.readBlob(path)` → `Buffer\|null` | `MutableTree::read_blob` | |
| `tree.getChild(path)` → `{type,hash,mode}\|null` | `MutableTree::get_child` | read-only; deep path |
| `tree.getChildren(path)` → `[{name,type,hash,mode}]` | `get_subtree`+`ensure_children` | read-only; direct children |
| `tree.getBlobMap(path?)` → `[{path,hash,mode}]` | `get_subtree`+`get_blob_map` | read-only; paths relative to subtree |
| `tree.deleteChildDeep(path)` → bool | `MutableTree::delete_child_deep` | |
| `tree.clearChildren(path)` | `MutableTree::clear_children` | O(1) subtree wipe |
| `tree.merge(other, {files?, mode})` | `MutableTree::merge` | `mode`: `overlay`/`replace`/`underlay` |
| `tree.write()` → treeHash | `MutableTree::write` | flush dirty subtrees |
| `emptyTreeHash()` → hash | `tree::empty_tree_id` | module fn |
| `buildProfile()` → `'release'\|'debug'` | — | guard against debug builds |
`mode` values are the git filemode as a number (e.g. `33188` = `0o100644`).
Tree hashes reported by the read-only navigators reflect the last
`write()`/load and are stale for a subtree mutated since — flush with
`write()` for canonical hashes.
The binding is a deliberately thin marshalling shell: rough edges are fixed
upstream in the `holo-tree` crate, never papered over here
(`specs/principles.md`). New capability requests belong against the crate.
## Origins
This binding began as the integration vehicle for the gitsheets holo-tree
spike — the first embedded consumer of the Rust crates. The `Phase-C finding`
comments in the source record rough edges discovered during that hardening
pass, each fixed upstream in holo-tree (error codes, panic containment, the
consumer-owned tree cache). gitsheets has since moved to linking holo-tree
directly as a Cargo crate; this package continues as the supported Node-side
surface of the same crate.
## Building
Requires a Rust toolchain and `@napi-rs/cli` (a devDependency):
```sh
npm install
npm run build # release — use this for anything you'll measure or ship
npm run build:debug # debug — compile-fast, runs SLOW (see Performance)
npm test # node --test against a scratch git repo
npm run bench # release-build benchmark (refuses debug builds)
```
`napi build` emits `holo-tree.<triple>.node`. The generated `index.js` loader
and `index.d.ts` types **are committed**; only the `.node` binaries are
git-ignored (built per-platform in CI).
## Publishing
Published as the scoped package **`@hologit/holo-tree`** with per-platform
prebuilt binaries shipped as `optionalDependencies`:
| Platform package | Triple | Built on | Smoke-tested |
| --- | --- | --- | --- |
| `@hologit/holo-tree-linux-x64-gnu` | `x86_64-unknown-linux-gnu` | ubuntu-latest | ✓ native |
| `@hologit/holo-tree-linux-arm64-gnu` | `aarch64-unknown-linux-gnu` | ubuntu-24.04-arm | ✓ native |
| `@hologit/holo-tree-linux-x64-musl` | `x86_64-unknown-linux-musl` | ubuntu-latest (musl cross) | build-only |
| `@hologit/holo-tree-darwin-arm64` | `aarch64-apple-darwin` | macos-latest | ✓ native |
| `@hologit/holo-tree-darwin-x64` | `x86_64-apple-darwin` | macos-latest (cross) | build-only |
| `@hologit/holo-tree-win32-x64-msvc` | `x86_64-pc-windows-msvc` | windows-latest | ✓ native |
Native targets build + smoke-test on a matching runner; cross targets (musl,
darwin-x64) build only, since their `.node` can't run on the host arch/libc (the
logic is covered by the native runs). The `.github/workflows/holo-tree-napi.yml`
workflow builds all six on every PR touching the binding, and on a
`holo-tree-v*` tag it builds then publishes.
Auth is **npm trusted publishing (OIDC)** — no tokens, matching hologit's
`publish-npm.yml`. Trusted publishing is configured *per package*, and a package
can't get a trusted publisher until it exists — so the packages need a
**one-time manual bootstrap** before automated releases work.
### One-time bootstrap (manual first publish, then configure trusted publishing)
The packages all start at an early version (currently `0.0.1`). They must
exist on npm before trusted publishing can be turned on.
1. **Get the prebuilt binaries.** Run the `holo-tree-napi` workflow (push the
branch / open a PR, or trigger `workflow_dispatch`) and download its
`bindings-*` artifacts — they hold the `.node` for each platform. A single
machine can't build all of them natively, so use the CI artifacts.
2. **Publish all packages manually**, logged in as an `@hologit` org member
(`npm login`):
```sh
cd holo-tree-napi
npm install
npx napi artifacts --dir <downloaded-artifacts-dir> # → npm/<triple>/*.node
# platform packages first, then the main package:
for d in npm/*/ ; do ( cd "$d" && npm publish --access public ); done
npm publish --access public --ignore-scripts # main; skip the napi
# prepublish hook
```
3. **Turn on trusted publishing** on npmjs.com for **each** of the packages
→ Settings → Trusted Publisher → GitHub Actions, repo
`JarvusInnovations/hologit`, workflow `holo-tree-napi.yml`.
### Releases (after bootstrap — fully automated, tokenless)
```sh
git tag holo-tree-v0.1.1 && git push origin holo-tree-v0.1.1
```
The tag drives the published version; CI builds all platforms, then
publishes via OIDC (provenance). No secret needed. The `holo-tree-v*` tag is the
release marker — napi runs with `--skip-gh-release` so it does **not** create a
bare `v<version>` GitHub release/tag (which would collide with hologit's own
`v*` JS-package release namespace). The same tag serves as the Cargo
git-dependency pin for Rust consumers of the `holo-tree` crate.
To add or drop a platform later, edit `napi.triples.additional` +
`optionalDependencies` in `package.json`, run `napi create-npm-dir -t .`, add the
matching matrix entry in the workflow, and (since it's a new package) bootstrap
- trust that one package too.