Git index ingest
The data plane knows only content hashes. The Git index is the layer that puts paths, commits, branches and tags back on top, and these endpoints are how it gets populated.
In practice you don’t call them by hand — cav repo index and the pre-push hook do.
This page is for anyone writing their own indexer or debugging one.
export API=https://cavsnode.com/api/v1export REPO=<repository-uuid>export H="Authorization: Bearer $CAVS_TOKEN"export J="Content-Type: application/json"Every endpoint requires repository.push.
The flow
Section titled “The flow”GET …/index/state what the Hub already knowsPOST …/index/sessions open a session, learn the page limitsPOST …/index/sessions/{sessionID}/commits paged, ≤ 500 per requestPOST …/index/sessions/{sessionID}/tree paged, ≤ 5000 entries per requestPOST …/index/sessions/{sessionID}/finalize refs + tags, atomic generation swapGET /repositories/{repoID}/index/state
Section titled “GET /repositories/{repoID}/index/state”What the Hub currently knows, so an incremental indexer can compute a delta rather than re-walking history.
curl -sS "$API/repositories/$REPO/index/state" -H "$H"{ "indexed": true, "refs": [ { "name": "main", "kind": "branch", "head_sha": "9a3f21c…" }, { "name": "v1.4.0", "kind": "tag", "head_sha": "7c1e88b…" } ]}indexed is false when no refs are known yet — that’s the signal to do a full
backfill (cav repo index --full).
POST /repositories/{repoID}/index/sessions
Section titled “POST /repositories/{repoID}/index/sessions”curl -sS -X POST "$API/repositories/$REPO/index/sessions" \ -H "$H" -H "$J" -d '{"client_version": "cav/1.2.0"}'{ "session_id": "5c8a…", "limits": { "commits_per_page": 500, "tree_entries_per_page": 5000 }}POST /repositories/{repoID}/index/sessions/{sessionID}/commits
Section titled “POST /repositories/{repoID}/index/sessions/{sessionID}/commits”Upload a page of commits. Maximum 500 per request.
curl -sS -X POST "$API/repositories/$REPO/index/sessions/$SESSION/commits" \ -H "$H" -H "$J" \ -d '{ "commits": [ { "sha": "9a3f21c4b5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0", "parents": ["7c1e88b…"], "author_name": "You", "author_email": "[email protected]", "authored_at": "2026-07-28T11:58:00Z", "committed_at": "2026-07-28T11:58:00Z", "message": "Retrain on the augmented set", "files_changed": 3, "artifacts": [ { "path": "checkpoints/model.safetensors", "status": "M", "oid": "9f2c8b0e…", "prev_oid": "1a2b3c4d…", "size": 104857600, "prev_size": 104857600 } ] } ] }'| Field | Notes |
|---|---|
sha |
Full Git commit SHA. Invalid values give 400. |
parents |
Parent SHAs |
author_name, author_email, authored_at, committed_at |
RFC 3339 timestamps |
message |
Full commit message. Indexed for text search. |
files_changed |
Count from the diff |
artifacts[] |
The LFS objects this commit touched — this is what links commits to content |
artifacts[].status |
A, M, D — added, modified, deleted |
artifacts[].oid / prev_oid |
New and previous content hashes |
{ "accepted": 1 }Upserts are keyed by (repository, sha), so re-sending a page is safe and idempotent.
An empty commits array returns {"accepted": 0}.
POST /repositories/{repoID}/index/sessions/{sessionID}/tree
Section titled “POST /repositories/{repoID}/index/sessions/{sessionID}/tree”Upload the file-tree snapshot for a ref. Maximum 5000 entries per request; page as needed.
curl -sS -X POST "$API/repositories/$REPO/index/sessions/$SESSION/tree" \ -H "$H" -H "$J" \ -d '{ "ref": "main", "entries": [ { "path": "checkpoints/model.safetensors", "oid": "9f2c8b0e…", "size": 104857600 }, { "path": "data/train.parquet", "oid": "aa11bb22…", "size": 524288000 } ] }'| Field | Notes |
|---|---|
ref |
Branch or tag name. Invalid values give 400. |
entries[].path |
Repository-relative path |
entries[].oid |
The object’s content hash — for LFS-tracked files |
entries[].size |
Logical size |
Entries accumulate into a new generation, which replaces the previous snapshot atomically at finalize. Readers never see a half-written tree.
POST /repositories/{repoID}/index/sessions/{sessionID}/finalize
Section titled “POST /repositories/{repoID}/index/sessions/{sessionID}/finalize”Commit the session: point refs at their heads, record tags (which become releases), swap the tree generation and sweep the old one.
curl -sS -X POST "$API/repositories/$REPO/index/sessions/$SESSION/finalize" \ -H "$H" -H "$J" \ -d '{ "refs": [ { "name": "main", "kind": "branch", "head_sha": "9a3f21c…", "head_committed_at": "2026-07-28T11:58:00Z" } ], "deleted_refs": ["experiment/old-idea"], "tags": [ { "tag": "v1.4.0", "target_sha": "9a3f21c…", "message": "Vision stack 1.4.0", "tagged_at": "2026-07-28T12:00:00Z" } ], "stats": { "commits": 1, "files": 2 } }'| Field | Notes |
|---|---|
refs[] |
Branches and tags to point at their heads. kind is branch or tag. |
deleted_refs[] |
Refs removed since the last index |
tags[] |
Tags to materialise as releases |
stats |
Advisory counters for the activity feed |
Each array has its own size cap; exceeding one gives
400 bad_request — “too many refs in one finalize”. Split across sessions.
Finalize emits repository.index.finalized (public webhook event
repository.push.indexed) and audits repository.index.
Writing your own indexer
Section titled “Writing your own indexer”-
GET …/index/statefor the known heads. -
Walk your refs. For each, compute the commits between the known head and the new head (
git rev-list <known>..<new>), or everything for a full backfill. -
Batch commit metadata with
git log --stdinand per-commit LFS diffs withgit diff-tree --stdin -z --first-parent. Parse LFS pointer files (they’re ≤ 1024 bytes) to get each object’s oid and size. -
POST …/commitsin pages of ≤ 500. Retry429and5xxwith backoff. -
git ls-tree -r -l -z <ref>for the tree, andPOST …/treein pages of ≤ 5000. -
POST …/finalizewith the refs, deleted refs and tags.
The reference implementation is cav’s git.rs and lfs.rs. Rather than
reimplementing, consider shelling out to cav repo index:
cav repo index --full # backfill everythingcav repo index # incrementalcav repo index --ref main --ref dev # specific refscav repo index --dry-run # inspect what would be sentWhat the index powers
Section titled “What the index powers”| Endpoint | Depends on |
|---|---|
GET …/tree, …/file, …/file/history |
tree entries + commits |
GET …/commits, …/commits/{sha} |
commits |
GET …/branches |
refs |
GET …/releases |
tags |
GET …/artifacts/{oid}, …/timeline |
commits + tree entries |
GET …/storage/folders |
tree entries |
GET /search?type=files|commits |
tree entries + commits |
All of them degrade gracefully with "indexed": false rather than erroring.
Design notes
Section titled “Design notes”- Best-effort by design. The pre-push hook never blocks or fails a push. A missing index degrades the dashboard; it never loses data.
- Idempotent. Commit upserts key on
(repository, sha); tree snapshots swap wholesale by generation. - No bytes. Only metadata travels — paths, hashes, sizes, messages. Object content goes through the data plane.
- Metadata is metadata. Commit messages and paths are stored so they can be shown
and searched. If yours contain secrets, skip indexing
(
cav repo connect --skip-index).
- Connect a Git repository — running the indexer.
- Browsing your data — what the index gives you.
- Repositories — the read endpoints.

