@classytic/gitkit
v0.2.0
Published
Git-backed repositories for content/config-as-code — CRUD over a versioned file tree, with free history, diff, and PR-review. Built on @classytic/repo-core.
Downloads
333
Readme
@classytic/gitkit
Git-backed repositories for content- and config-as-code. CRUD + real query (
filter/sort/page, pluggable search) over a versioned file tree, on the@classytic/repo-coreDataAdaptercontract — so onedefineResource()in@classytic/arcgives you REST + permissions + OpenAPI + MCP agent tools, backed by git instead of a database.
Every write is a commit. You get history, diff, blame, branch-based draft/publish, PR-review approval, and rollback — for free, no audit plugin.
It's a peer of mongokit / sqlitekit / pgkit / prismakit: same repository
shape, same query surface, different substrate.
When to reach for it
✅ Docs portals, knowledge bases, marketing/CMS content, prompt libraries, feature flags & app config, GitOps manifests — anything human-edited, bounded (hundreds–low-thousands of docs), where you want git's versioning and review flow.
🚫 Not a database. The query engine is in-memory over the loaded collection — great to a few thousand docs, wrong at 100k+. Writes are commit-paced (not high-write-concurrency). No transactions/aggregation. If you need those, use a DB kit. gitkit is for content, not your orders table.
Install
npm i @classytic/gitkit @classytic/repo-core
# GitHub backend uses the official SDK — bring it yourself (optional peer):
npm i octokit60-second example — a git-backed arc resource with agent tools
import { defineResource, requireAuth, allowPublic } from '@classytic/arc';
import { createGitAdapter } from '@classytic/gitkit/adapter';
import { frontmatter } from '@classytic/gitkit/serializers';
import { octokitGitBackend } from '@classytic/gitkit/backends/octokit';
import { App } from 'octokit';
type Post = { id: string; title: string; status: 'draft' | 'published'; tags: string[]; content: string };
const app = new App({ appId, privateKey });
export const postResource = defineResource<Post>({
name: 'post',
adapter: createGitAdapter<Post>({
// Per-request/tenant: auth + repo resolution live HERE. gitkit never sees a token.
resolveBackend: async (scope) => octokitGitBackend({
octokit: await app.getInstallationOctokit(getInstallationId(scope)),
owner: 'acme',
repo: getRepoForTenant(scope),
}),
collection: {
dir: 'content/posts',
serializer: frontmatter<Post>(), // YAML frontmatter + mdx body
idField: 'id',
},
// Git docs are schemaless — give the OpenAPI/MCP shape once:
schema: { entity: POST_JSON_SCHEMA, createBody: POST_JSON_SCHEMA },
}),
permissions: { list: allowPublic(), get: allowPublic(), create: requireAuth(), update: requireAuth(), delete: requireAuth() },
});You now have — over a git repo of .mdx files:
GET/POST/PATCH/DELETE /postswith auth + validation + OpenAPI- Every mutation is a commit on the tenant's repo (audit + rollback built in)
- MCP tools auto-generated (
post.list,post.get,post.create, …) — an agent can read and edit your content, gated by the same permissions
Query — identical to mongokit, because it is mongokit's engine
getAll delegates filtering to repo-core's canonical matchFilter, so the wire
syntax matches every other kit:
GET /posts?filter[status]=published&filter[tags][in]=ai,ml&sort=-updatedAt&page=2&limit=20import { queryDocs } from '@classytic/gitkit/query';
queryDocs(posts, {
filters: { status: 'published', tags: { in: ['ai', 'ml'] } },
sort: '-updatedAt',
page: 2, limit: 20,
}); // → { method: 'offset', data, page, limit, total, pages, hasNext, hasPrev }Full-text search is deliberately NOT here — see the Search section.
filteris exact/structured matching over already-loaded docs; search is a separate, index-backed concern so it never scans the whole collection per request.
Search — a pluggable provider, indexed on commit (never a full scan)
gitkit does not ship a general search that loads every doc per query — that
melts servers at scale. Instead you plug in a SearchProvider (Orama, a vector
store, MeiliSearch, git grep/ripgrep over a clone). The git-native pattern:
import { memorySearchProvider } from '@classytic/gitkit/search';
const repo = gitRepository<Post>({
backend,
collection: { dir: 'content/posts', serializer: frontmatter() },
search: memorySearchProvider({ fields: ['title', 'content'] }), // ← swap for Orama/vector at scale
});
await repo.reindex(); // 1. build ONCE at boot (the only full load)
await repo.create(doc); // 2. writes upsert into the index incrementally…
await repo.delete(id); // …and deletes remove from it — a commit tells you exactly what changed
const hits = await repo.search('vector', { limit: 20 }); // 3. query the index — fast, no scanWithout a provider, repo.search() throws (no silent load-all fallback). The
SearchProvider seam is three optional hooks + one required search:
interface SearchProvider<TDoc> {
reset?(docs: TDoc[]): void | Promise<void>; // reindex()
upsert?(docs: TDoc[]): void | Promise<void>; // on create/update/edit
remove?(ids: string[]): void | Promise<void>; // on delete
search(q: string, o?: { limit?: number }): SearchHit<TDoc>[] | Promise<SearchHit<TDoc>[]>;
}Recipes (implement SearchProvider — gitkit never depends on these):
- Orama (full-text, in-process):
reset/upsert/remove→insert/remove;search→search(db, { term }). This is what a docs portal wants. - Vector / semantic (RAG, "ask the docs"):
upsert→ embed + write to your vector store;search→ embed the query + similarity search. Asyncupsertis fine — it runs off the write path if you queue it. - ripgrep over a
localclone (index-free): implement onlysearch→ shellrg --jsonover the working tree;reset/upsert/removeare no-ops (the clone IS the source, kept fresh by commits/pulls). Fastest to stand up server-side.
Staying fresh when writes come from outside gitkit
Writes through repo.* keep the index incremental automatically. But content
can also change out of band — someone edits on github.com, a PR merges, a CI
job commits. gitkit doesn't own webhooks (that's your app's concern — verify the
signature, own the route); it just gives you the one call to run when a push
lands on your branch:
// your webhook handler (host owns auth + signature verification)
app.post('/webhooks/github', verifyGitHubSignature, async (req) => {
const { ref, repository } = req.body;
if (ref === `refs/heads/${repository.default_branch}`) {
await repo.reindex(); // rebuild the SearchProvider from the new tree
cache.clear(); // and drop any read cache you keep
}
});For a big collection, reindex only what the payload says changed (commits[].added
/ modified / removed) via provider.upsert / provider.remove instead of a full
reindex() — the webhook tells you exactly which files moved, same as a local write.
Backends — auth is the backend's job, not the kit's
GitBackend is the entire git surface (3 methods). Pick one or write your own:
| Backend | Import | Auth | Use for |
|---|---|---|---|
| octokit | @classytic/gitkit/backends/octokit | you pass an authed Octokit (App token / PAT / OAuth) | GitHub, multi-tenant servers |
| local | @classytic/gitkit/backends/local | git's own credential helper / SSH | CI, self-host, scripts |
| memory | @classytic/gitkit/backends/memory | none | tests, reference impl |
export interface GitBackend {
readFile(path: string, ref?: string): Promise<string | null>;
listTree(prefix: string, ref?: string): Promise<string[]>;
commit(input: { changes: FileChange[]; message: string; ref?: string }): Promise<{ sha: string }>;
}GitLab/Bitbucket/Gitea? isomorphic-git? Implement those three methods.
Serializers
frontmatter() (.md/.mdx, YAML frontmatter + body), json(), yaml().
Bring a full YAML parser (js-yaml/yaml) via options for rich frontmatter;
the built-in handles flat scalars (covers most content files).
Test with zero infrastructure
import { memoryGitBackend } from '@classytic/gitkit/backends/memory';
import { gitRepository } from '@classytic/gitkit/repository';
import { frontmatter } from '@classytic/gitkit/serializers';
const backend = memoryGitBackend({ files: { 'content/posts/hi.mdx': '---\ntitle: Hi\nstatus: published\n---\nbody' } });
const repo = gitRepository<Post>({ backend, collection: { dir: 'content/posts', serializer: frontmatter() } });
const page = await repo.getAll({ filters: { status: 'published' } });Performance — fast commits, and the recipes for instant editors
The capability (free, in the backend): the octokit backend commits with
one GraphQL createCommitOnBranch round-trip — atomic multi-file, GitHub-
signed — not the classic 6-call REST dance (getRef → getCommit → createBlob×N →
createTree → createCommit → updateRef). The branch head oid is cached and
advanced after each commit, so steady-state commits skip the head lookup too;
concurrent-write conflicts re-fetch and retry once. Net: a save is ~1 network
hop instead of ~6.
Batch related changes to keep it one commit:
await repo.createMany(docs); // N files → ONE commit
await repo.edit(id, oldStr, newStr); // surgical write → ONE commitThe recipes (host owns UX — gitkit gives you the fast primitives):
- Optimistic UI — return "saved" and update the editor immediately; the commit is already ~1 hop. On failure, reconcile (toast + revert / retry).
- Debounced async flush — don't commit per keystroke. Buffer edits in a
fast store and flush with
createMany/editon idle (e.g. 1–2s) or on "publish", so the editor never awaits GitHub. gitkit's commit is the flush primitive; the queue/debounce is yours (a worker, a job queue, etc.). - Cache reads — serve
page-data/docs.json/MDX from your cache/CDN/ISR, revalidated on commit — don't hit the API per view.readFile(path, ref)makes the two sides of a cache-vs-source comparison trivial. - Hot editors → the
localbackend — a server-side working-tree clone commits locally (~ms, no network), pushed async. Fastest per commit; costs you clone management. Cold/many-tenant → stick withoctokit.
Bulk & cross-repo
The repository does batch writes atomically:
await repo.createMany(docs); // N docs → ONE commit (not N)
await repo.loadAll(); // every doc, uncapped, tree read onceCopying content between repos/tenants/providers is the one genuinely cross-repo op (GitHub → local, template → tenant), with an optional transform to rebrand/remap:
import { copyCollection } from '@classytic/gitkit/operations';
await copyCollection(templateRepo, tenantRepo, {
transform: (doc) => ({ ...doc, brand: 'Acme', status: 'draft' }),
});There is deliberately no importDocs/exportCollection — those would just
be repo.createMany(docs) / repo.loadAll(). And repo lifecycle
(clone/fork/provision) stays in the host — gitkit copies content, not repos.
Diff & surgical edit — no arc-ai needed
Every git-content app needs "change this line" and "show what changed" — so
those live in gitkit itself (pure, zero-dep, isomorphic). Richer agent tooling
(apply_patch v4a, read-before-write) layers on top via arc-ai; you don't need it.
// Surgical edit (search/replace) — committed, re-parsed, occurrence-safe:
import { applyEdit } from '@classytic/gitkit/edit';
await repo.edit(id, 'status: draft', 'status: published'); // one commit
applyEdit(text, 'old', 'new', { replaceAll: true }); // pure string op
// Show what changed — for review/preview UIs, CLIs, agent results:
import { diffDoc, formatUnified } from '@classytic/gitkit/diff';
const hunks = await diffDoc(repo, id, { base: 'main', head: 'draft' });
console.log(formatUnified(hunks)); // git-style unified diffrepo.edit throws EditPatternNotFoundError / EditAmbiguousError so a caller
(or an agent driving it as a tool) can add context and retry — the coding-agent
edit contract. And because both sides come from readFile(path, ref), diffDoc
compares any two branches/commits/tags.
Exports & tree-shaking
No runtime barrel — one subpath per concern, so bundlers chunk and
tree-shake exactly what you import (and a browser bundle never sees node:):
| Import | Runtime? | Isomorphic |
|---|---|---|
| @classytic/gitkit | types only (erased at build) | ✅ |
| @classytic/gitkit/repository | GitRepository, gitRepository | ✅ |
| @classytic/gitkit/query | queryDocs (filter/sort/page) | ✅ |
| @classytic/gitkit/search | SearchProvider seam + memorySearchProvider | ✅ |
| @classytic/gitkit/operations | copyCollection (cross-repo) | ✅ |
| @classytic/gitkit/edit | applyEdit (surgical search/replace) | ✅ |
| @classytic/gitkit/diff | diffLines · formatUnified · diffDoc | ✅ |
| @classytic/gitkit/adapter | createGitAdapter | ✅ |
| @classytic/gitkit/serializers | frontmatter · json · yaml | ✅ |
| @classytic/gitkit/backends/memory | memoryGitBackend | ✅ |
| @classytic/gitkit/backends/octokit | octokitGitBackend | ✅ (fetch-based) |
| @classytic/gitkit/backends/local | localGitBackend | ⛔ Node-only (git CLI) |
Everything except backends/local runs in Node, browsers, Deno/Bun, and
edge/workers — so a React app can CRUD content straight against GitHub with a
user's token, and an agent can drive the same API in a container or on the edge.
License
MIT · © Classytic
