GitHub - VishiATChoudhary/graft: Source control for agent-scale monorepos: an open-source implementation of the design in Anthropic's 'Redesigning source control' post. Server-side changesets, derived data, want-only tiered fetch, lock-free landing.

GitHub

11 min read Original article ↗

Source control for agent-scale monorepos. An open-source implementation of the design in Anthropic's Redesigning source control at Anthropic.

graft is an independent project. It is not Anthropic's Scion, contains no Anthropic code, and is not affiliated with or endorsed by Anthropic. It implements the publicly described design. (A scion is the cutting you graft onto rootstock.)

Git assumes every clone is a full, offline-capable repository. When nearly every client is an always-online agent or CI job, that assumption costs packfiles, mirrors, negotiation CPU and merge-queue contention. graft drops it:

  • The server does the work. History, blame, diffs, merge bases, search, commits, rebases and merges all happen server-side, and each is an HTTP API. Many jobs never need a local repository.
  • Commits are changesets in a write-ahead log: the files changed relative to the first parent, pointing at content-addressed blobs. Creating one costs O(size of change), never O(size of repo).
  • Landing is lock-free. gft land rebases server-side and moves the ref with a compare-and-swap on one row. No global lock, no merge queue.
  • Derived data (root trees, a skip-list commit graph, per-path history, rename detection, blame, access-control indexes) comes from one generic, recursive mechanism, eager or lazy, invisible to clients.
  • Want-only fetch through identical tiers (node daemon, regional cache, server). Objects are immutable, so every tier answers from cache. No packfiles, no negotiation, no repository maintenance anywhere.
  • Horizontally scalable. Storage is sharded across nodes; any number of stateless API servers share it.
  • Working copies are optional and sparse. gft mount gives a writable virtual checkout that fetches files on first read, commits only what you changed, and switches revisions instantly.
  • Repository and directory-level access control, plus review gates that tell comment-only, whitespace and docs changes from logic changes.

docs/DESIGN.md maps each part of the post to code.

Prebuilt binaries for Linux, macOS and Windows are on the releases page, or go install github.com/VishiATChoudhary/graft/cmd/gft@latest.

Results vs git

Synthetic agent-scale monorepo: 100,000 files, 20,001 commits of small agent changes (bench/gen_monorepo.py). Same machine for both (Apple silicon, 10 cores, loopback). git serves via upload-pack over file:// with filters and a commit-graph. graft serves through server, regional cache and node daemon. CPU counts client and server. Median of 5 runs; full table in bench/RESULTS.md.

workload git graft
Sparse checkout, 1 package of 100k files 315 ms (--depth 1 --filter=blob:none --sparse, best case) 26 ms 12x
Virtual checkout + read 1 package (needs a clone) 93 ms
Full checkout, 100k files 8.8 s (--depth 1) 4.7 s, less CPU 1.9x
Move a working copy forward 100 commits 149 ms, 576 ms CPU 50 ms, 78 ms CPU 3x, 7x less CPU
16 agents x 8 concurrent lands 3.2 lands/s, 455 push retries, 223 s CPU 264 lands/s, 2.3 s CPU 82x throughput, 97x less CPU
128 agents x 8 lands, one load generator 4,500 lands/s one server; 3,800 on 3 API servers + 3 shards
Search all 100k files (fixed string) 2.1 s git grep (in a full clone) 73 ms (server-side, no clone) 29x
Log of one file 54 ms (in a full clone) 8.5 ms (no clone) 6x
Last 50 commits under a directory 10.1 ms (in a full clone) 5.8 ms (no clone)
Blame of a hot file 92 ms (in a full clone) 81 ms first time, 8 ms memoized (no clone)
status, clean 100k-file working copy 136 ms 99 ms
status with a filesystem watcher 26.6 ms (git's fsmonitor daemon) 24.7 ms (gft watch)
Is main~19000 an ancestor of main? 10.9 ms (in a full clone) 6.7 ms (no clone)
Files changed under a directory over 5000 commits 10.8 ms (in a full clone) 6.2 ms (no clone)

Storage (on disk; git includes its commit-graph with changed-path filters). graft stores objects with write-time deltas and trained dictionaries and never needs repacking. gft compact is optional, like git gc --aggressive: it re-deltas blobs against similar ones anywhere in history and retrains the dictionaries. graft uses SHA-256 IDs, so the SHA-256 git columns compare like with like:

repository graft graft + gft compact git SHA-1 repack git SHA-1 aggressive git SHA-256 repack git SHA-256 aggressive
synthetic monorepo, 20k commits 97.0 MB 94.4 MB 113.7 MB 106.2 MB 122.6 MB 115.1 MB
redis/redis, 13k commits 84.0 MB 80.3 MB 84.1 MB 80.5 MB 91.3 MB 80.9 MB
spf13/cobra, 1.1k commits 1.49 MB 1.38 MB 1.60 MB 1.48 MB 1.61 MB 1.49 MB

graft's numbers include its history indexes (per-path history, the commit graph, rename sources). Straight after import, graft is smaller than git's repack on all three repositories, SHA-1 or SHA-256, and smaller than gc --aggressive on the monorepo; on cobra it ties SHA-256 aggressive and is 4 KB over SHA-1 aggressive, and on redis aggressive is 4% smaller. After gft compact, graft is smaller than every git configuration on all three.

Correctness checks that run with the benchmarks and tests:

  • every concurrent land is verified present and history linear;
  • imported trees match git for every commit;
  • path logs match git log -- <path> on every file of a real repository;
  • unified diffs are byte-identical to git diff on 3000 random inputs;
  • blame matches git blame on 99.6% of lines of spf13/cobra (the rest are copy-detection edge cases).

Reproduce: make bench (generates the monorepo, then runs everything) and bench/storage.sh <repo> <name>.

Quick start

go build -o bin/gft ./cmd/gft          # Go 1.26+

# Import a git repository (server stopped; HEAD becomes `main`).
gft import --data ./srv --git ~/src/myrepo

# Single-node server, a regional cache and a node daemon (each tier has one upstream).
gft serve --tier server --data ./srv --listen 127.0.0.1:7070
gft serve --tier cache  --upstream http://127.0.0.1:7070 --listen 127.0.0.1:7071 --mem 8G
gft serve --tier daemon --upstream http://127.0.0.1:7071 --listen unix:///tmp/gft.sock --disk 20G

# Work in a sparse checkout.
gft clone http://127.0.0.1:7070 wc --objects unix:///tmp/gft.sock --sparse services/api
cd wc && gft watch --background          # status now only looks at what changed
$EDITOR services/api/handler.go
gft status && gft diff
gft commit -m "api: handle retries"      # created on the server; nothing to push
gft land                                 # server-side rebase if main moved, then CAS

Or skip the checkout entirely and mount a revision:

gft mount http://127.0.0.1:7070 /tmp/repo --objects unix:///tmp/gft.sock
cd /tmp/repo                     # files are fetched on first read
$EDITOR README.md                # writes go to a local overlay
gft status && gft commit -m "docs" && gft land   # only the overlay is uploaded
gft checkout main~100            # instant: the mount switches revision in place

gft mount needs no root anywhere: NFS on macOS (no kernel extension), FUSE on Linux, WebDAV on Windows (net use). Pick a backend with --backend nfs|fuse|webdav.

Ask questions without any checkout:

export GFT_SERVER=http://127.0.0.1:7070
gft log -n 20 services/api                 # commits touching a path
gft log --follow services/api/new_name.go  # across renames
gft blame services/api/handler.go
gft grep -F "TODO(agent)" --in services    # server-side code search
gft diff --stat --from main~500 --to main services/
gft merge-base main some-branch
gft find handler --in services/
gft write --parent main --land main -m "bump" VERSION=@./VERSION   # commit without a checkout

Scaling out

Run storage as shards and put any number of stateless API servers in front:

NODES=http://s0:7600,http://s1:7600,http://s2:7600
gft serve --tier store  --data /data/s0 --nodes $NODES --shard 0 --listen :7600   # on s0 (and s1, s2)
gft serve --tier server --store $NODES --listen :7070                            # on as many hosts as needed
gft import --store $NODES --git ~/src/myrepo

On Google Cloud, point API servers at Spanner instead, the backend Scion uses (build with go build -tags cloud ./cmd/gft; the everyday binary leaves the cloud SDKs out so it starts in about 5 ms), with file contents in cloud storage (--blobs: gs://, s3:// or file://; --spanner-init creates the tables once):

gft serve --spanner projects/P/instances/I/databases/graft --blobs gs://my-bucket \
    --spanner-init --listen :7500

Each shard can be a replica set: list replicas with |. Data is written to a majority of a shard's replicas and read with failover. Shard 0's replicas run Raft for refs and the changeset log, so losing the leader triggers an election and clients carry on:

NODES='http://a0:7600|http://a1:7600|http://a2:7600,http://b0:7600|http://b1:7600|http://b2:7600'
gft serve --tier store --data /data --nodes "$NODES" --shard 0 --replica 1 \
    --raft-addr a1:7700 --raft-peers a0:7700,a1:7700,a2:7700 --listen :7600

API servers are caches that compute derived data and write it back, so they hold no state of their own. Delta bases are resolved across shards, so storage stays dense.

Landing is lock-free but coalesced: lands for one ref that arrive at a server while another is in flight are rebased in a chain and published with one CAS, each still checked and failing on its own. With --peers and --self, API servers forward a land to the server that owns the ref (by hash), so a ref's lands meet in one place; if the owner is down, any server lands it and the CAS keeps it correct. Measured on Google Cloud (us-west1, e2-standard-4 VMs with 4 vCPUs each; gft loadgen from three load-generator VMs at once, after a warm-up round; every land verified), one server on one VM against three API servers and three storage shards on six VMs:

workload one server fleet (3 API + 3 storage VMs)
reads (resolve a ref, read a file) 20,200 reads/s 38,500 reads/s 1.9x
lands spread over 36 refs 4,600 to 7,400 lands/s 6,600 to 10,500 lands/s 1.4x
lands on one ref 1,130 to 1,480 lands/s 880 to 1,230 lands/s 0.8x

Lands on a single ref go through one compare-and-swap in order, wherever they run, so spreading them over machines only adds network hops; the fleet adds capacity for reads and for many refs. On one laptop the fleet's processes share the same cores and it is slower on every workload.

Access control and review

gft serve --tier server --data ./srv --acl policy.json
gft clone http://host:7070 wc --token "$TOKEN"     # or GFT_TOKEN

Policies give tokens principals and groups, repository roles, and per-directory read and write rules:

  • Reads are checked on every path-based query.
  • Writes are checked per path, and every ref move is checked against its whole diff, so merges cannot smuggle changes in.
  • Trees of protected directories are refused on fetch. Shared cache tiers never cache them and always forward the caller's credentials.
  • Checkouts skip directories the caller cannot read.

Review rules require approvals for changes under a path, but can exempt comment-only, whitespace-only and docs changes. Those are recognised per language, so reviewers only see changes that can alter behaviour:

gft review            # what the current change needs
gft approve <rev>     # approvals are per changeset; authors cannot self-approve

Change requests and the merge queue play the part of Scion's Antique. A change request proposes a changeset for a ref and collects revisions, line comments and approvals. Queuing it lands it as soon as its review rules are satisfied, with no one landing by hand; the land is the ordinary lock-free one, so queued requests do not wait on each other. The web UI is at <server>/review (diffs, inline comments, approve, queue), and the same operations are on the CLI:

gft cr create -m "why" [rev]     # open a change request for a changeset
gft cr comment <id> --path f.go --line 12 "what about nil?"
gft cr revise <id> [rev]         # push a new revision (approvals reset)
gft cr queue <id>                # land when approved
gft cr list                      # open change requests

See docs/ACCESS.md.

HTTP API

Everything the CLI does is an API call to the server (internal/api):

endpoint
POST /v1/objects want-only object fetch (same on every tier)
GET /v1/resolve?rev= revision to changeset + root tree (main, main~N, ID prefix)
GET /v1/log?rev=&path=&limit=&follow=&first_parent= history
GET /v1/blame?rev=&path= blame with authors and line text
GET /v1/diff?from=&to=&mergebase=&path=&patch= file changes or a unified patch
GET /v1/grep?rev=&q=&path=&fixed=&i= content search (indexed by per-directory trigram filters)
GET /v1/mergebase?a=&b=, /v1/isancestor ancestry
GET /v1/crs?state=, /v1/cr?id=; POST /v1/cr/{create,revise,comment,queue,close} change requests and the merge queue
GET /review review web UI
GET /v1/ls, /v1/cat, /v1/search trees, file content, path search
POST /v1/changesets, /v1/write create a changeset (from blob IDs or inline content)
POST /v1/rebase, /v1/merge, /v1/land, /v1/refs/update server-side rewrites and ref CAS
POST /v1/reviews/approve, GET /v1/reviews/status review gates
POST /v1/objects/missing, /v1/objects/put blob upload (verified against IDs)

Status

Version 0.1, tested with go test -race ./..., the repository suite against a 3-shard fleet, and bench/smoke.sh, which imports a real repository and cross-checks it against git. CI runs on Linux, macOS and Windows.

License

Apache 2.0. See LICENSE.