build-git-datastore
Load this skill when an app needs persistence but not a database yet (a prototype, MVP, or internal tool), even if git is never mentioned: its gate often says no. Trigger on "no-DB", "just use files",
SKILL.mdplugins/standalone/ceh-git-datastore/skills/build-git-datastore/SKILL.md
GitHubGit-backed datastore
A bare git repo as the data layer for an app that does not have a database yet. One repo per deployed instance; one orphan branch per project; JSON records in trees under that branch.
The task is done when the gate verdict is stated. If the gate passes, it is also done when the store sits behind the store interface, backup is wired, and the migration trigger is in the README.
The design is sound but narrow. Most of the value of this skill is in applying the gate honestly first, and in never touching a working tree second.
Procedure
Start with the gate
Run this before writing code. Answer honestly — talking someone out of this is a good outcome, and much cheaper than a migration in three months.
| Question | Fits | Does not fit |
|---|---|---|
| Sustained writes per project | under ~10/sec | steady double digits or bursts of concurrent writers on one project |
| Records per project | up to ~50k | hundreds of thousands |
| Record size | JSON documents, KBs | images, video, large binaries |
| App servers | one node, persistent disk | several nodes, autoscaling, or serverless |
| Queries | fetch by id, list a collection, filter in app memory | ad-hoc filters, joins, aggregates, full-text search |
| Cross-project reads | rare or none | dashboards spanning all tenants |
| Deletion | soft delete is fine | hard "erase every trace" requirement |
Two of these are disqualifying rather than merely awkward:
Serverless or multi-node hosting. The store is a directory on a disk. Two app nodes with two disks are two different databases. Lambda/Vercel-style runtimes have no durable disk at all, so every write would have to push to a remote — putting network latency and API rate limits in the request path. If the deployment target is serverless, stop here and use SQLite on a volume, a hosted Postgres, or a managed document store.
Right to erasure. Git history is append-only by construction. "Delete this user's data" means rewriting every commit that touched it and force-updating the ref, which invalidates every clone and backup. If the app handles personal data under GDPR/CCPA-style erasure obligations, either keep personal data out of the git store entirely (store an opaque id and keep the PII in something you can actually DELETE from) or do not use this pattern.
If the gate passes, say so and build. If it fails, see Stop conditions.
What you get that plain JSON files do not
Worth stating explicitly, because "just write JSON files to disk" is the obvious alternative and this has to beat it:
- Atomic multi-record writes. Several records change in one commit, or none do. A crash mid-write leaves nothing half-applied.
- Lock-free concurrency that is actually correct.
git update-refis an atomic compare-and-swap, so it holds across processes and worker pools. - Snapshot reads. A commit sha is an immutable point-in-time view of the whole project. This is what makes the eventual migration tractable.
- History, audit, and undo for free. Who changed what, when, and the ability to read any record as it was at any past moment.
- Backup is
git push. Restore isgit clone. Both are boring and proven.
Architecture
data.git/ bare repo -- ONE per deployed instance
refs/heads/main instance-level config (optional)
refs/heads/project/<pid> ORPHAN branch, one per project
meta.json { id, name, created_at, schema_version }
collections/
tasks/<record-id>.json
comments/<record-id>.json
Why orphan branches rather than a directory per project on one branch. Not isolation aesthetics — write contention. Every write is a compare-and-swap on a ref. With one branch, every write in the entire app serialises on that one ref, and under any concurrency you get a retry storm. With a ref per project, writes to project A never contend with writes to project B, and throughput scales with the number of active projects instead of being capped globally.
Three secondary payoffs: deleting a project is deleting a ref; exporting one project as a standalone repo is trivial; and at migration time you can cut over project by project instead of all at once.
Orphan branches cost nothing in storage. Git objects are content-addressed and shared across the whole object database regardless of commit ancestry — "orphan" severs history, not deduplication.
Measured behaviour
From scripts/gitstore.py on an ordinary container, ~300-byte records:
| Operation | Result |
|---|---|
| Single write (own commit) | ~17 ms → ~58 writes/sec on one project |
| Batched write in a transaction | ~3.4 ms/record |
| Point read by id | ~1.8 ms (mostly process spawn) |
| List 700 records | ~220 ms via one cat-file --batch |
| List 64 projects | ~9 ms via one cat-file --batch |
| 8 processes hammering one project | 80 writes in ~4 s, zero lost |
Repo after 700 writes, before/after git gc |
7.5 MB → 0.4 MB |
Two things to take from this. Writes are milliseconds, not microseconds — fine
for a CRUD app, wrong for anything write-heavy. And git gc matters enormously;
without it you accumulate loose objects until the store is 20x its real size.
If point reads at ~1.8 ms are too slow, keep a long-lived git cat-file --batch
process and stream requests to it. That removes the spawn cost and takes reads
into the tens of microseconds. Do this only when measurement says to.
Build order
- Apply the gate. Report the verdict before writing anything.
- Copy
scripts/gitstore.pyinto the project and initialise the repo:python gitstore.py init ./data.git. No third-party packages are needed; it shells out to thegitbinary, sogitonPATHis the one dependency. - Put every data access behind the store interface. No git command appears
in a route handler, a template, or a background job — only calls to the store.
This is what makes the later migration a swap rather than a rewrite; see
references/porting.mdfor the contract and how to port it to another language. - Define collections and record shapes in
schema/<collection>.json, and stampschema_versionon every record. Readreferences/data-model.mdfor record layout, id choice, sharding large collections, and derived indexes. - Wire up backup with
scripts/sync.pybefore launch, not after. It pushes to a remote on a background worker, coalescing bursts and backing off on failure, so nothing network-shaped ends up in the request path. Thengit gcscheduling, nightly bundles, and a rehearsed restore. Seereferences/operations.md. - Write the migration trigger down now, while the reasoning is fresh: the
record count, write rate, or feature that will mean it is time to move. Put it
in the README. The
ceh-git-datastore:migrate-git-datastoreskill handles the move itself.
Files
scripts/gitstore.py— working store implementation and CLI. No third-party packages; requires thegitbinary. Tested for concurrency, atomicity, and lost updates.scripts/sync.py— background push to a remote, bundle rotation, restore, and a status dict for health checks. Includes a mass-deletion guard.references/plumbing.md— the exact git command sequences and why each is shaped that way. Read when implementing or debugging the storage layer.references/data-model.md— records, ids, collections, schema versioning, sharding, derived indexes. Read when designing the data.references/operations.md— gc, backup, restore, deployment topology, monitoring. Read before shipping to production.references/porting.md— the store contract, a TypeScript sketch, and the rules that keep migration cheap. Read when the app is not Python.
Rules
The rules that make it work
1. Bare repo. No working tree. Ever.
If any code path runs git checkout, git add, or git commit, the design is
broken: those need a working tree, and a working tree is a single shared mutable
resource that serialises every request in the app and corrupts under concurrency.
Reads go through git cat-file. Writes go through a temporary index file per
write, then write-tree, commit-tree, update-ref.
2. Compare-and-swap on the ref is the concurrency control.
git update-ref <ref> <new> <old> succeeds only if the ref still points at
<old>. On failure, retry with backoff. Pass an empty <old> to mean "only if
this ref does not exist" — that is a safe create.
3. Reads inside a write must come from the commit you will CAS against.
This is the subtle one, and the one that produces silent data loss when missed.
If you read the live ref, compute a new value, then CAS against a base resolved
afterwards, another writer can land in between; your CAS succeeds and their
write is gone. Pin the base commit first, read from that sha, and CAS against it.
scripts/gitstore.py does this in atomic() — use it for anything that reads
before writing (counters, list appends, state transitions).
4. One git process per operation, not per record.
Process spawn dominates everything here. Reading 700 records via one
cat-file --batch takes ~220ms; as 700 subprocesses it takes minutes. Same for
writes: stage a whole transaction with a single update-index --index-info.
This one habit is the difference between "surprisingly fine" and "this is slow".
5. Sortable ids.
ULIDs (26 chars, time-prefixed). git ls-tree returns paths in byte order, so
sortable ids give you "most recent N" without an index, and they survive the
move to SQL unchanged — unlike an auto-increment integer, which you would have
to invent during migration.
Anti-patterns
- Breaking rules 1, 3, or 4 above. Checking out a branch to serve a request
(the most common way to wreck this),
git add/git commitagainst a shared index, CASing against a base resolved after the read, or a subprocess per record. The first two corrupt under concurrency, the third loses writes silently, the fourth is merely slow. - Storing binaries in the repo. Every version of every blob is kept forever. Put files in object storage; store the key.
- Using commit history as application data. Reading
git logto answer product questions ("who edited this") welds you to git and does not survive migration. Keep aneventscollection instead. - A
projects.jsonindex maintained alongside the refs. Two sources of truth that drift. Refs are the index — enumerate them withfor-each-refand batch themeta.jsonreads. - Never running
git gc. The store quietly bloats 20x. - Backing up with
git push --mirror. It also pushesrefs/remotes/*, so deleted projects get resurrected on the remote and can never be garbage collected locally. Pushrefs/heads/*:refs/heads/*with--pruneinstead. - Pushing inside the write handler. Couples write latency to the network.
- Skipping the gate because the prototype is small. The gate is about where the app is going, not where it is.
This is not your code repository
data.git is a bare repo holding application data. It is not the repo your
source lives in, and it must not be handled by the tooling you use for source
control. Two separate things that happen to use the same technology:
| Source repo | data.git |
|
|---|---|---|
| Contents | code you wrote | JSON records |
| Commits | human-authored, reviewed | machine-generated, tens per second |
| Messages | conventional commits, feed a changelog | put tasks/01J8ZQ..., unread |
| Commands | porcelain: add, commit, branch |
plumbing only |
Concretely: never route data operations through commit/branch/PR tooling, which
uses the git add that rule 1 forbids; exclude data.git from repo-scanning and
changelog tooling, which will otherwise try to make sense of 40,000
machine-written commits; and keep it outside the source tree — /srv/data.git,
not ./data.git inside the checkout. If it must live alongside, .gitignore it.
Stop conditions
- The gate fails on one row → say which row and what you would use instead. Do not build it anyway with a warning comment.
Hands off to
ceh-git-datastore:migrate-git-datastorewhen the migration trigger from Build order step 6 fires. That skill handles the move itself.