Chapter 01 — the pain
Git was never meant to be served.
On your laptop, git feels instant. That feeling is an accident of physics — and it evaporates the moment the repository is no longer a file on a local disk.
Almost everything in a repository ends up compressed into packfiles: large binary files where objects — commits, trees, blobs — are laid out to be small, not to be read in order. Blobs are delta-compressed against other blobs that may sit megabytes away. The pack is a puzzle optimized for bytes on the wire, and every git operation solves its corner of that puzzle by seeking.
Reading one file out of history is a random walk: consult the .idx for an offset, seek into the pack, inflate a delta, discover its base lives at another offset, seek again. On a laptop this is fine — the pack sits in page cache and a seek costs microseconds. Each of those hops is a syscall against memory. Nobody notices.
Now move the repository to a network filesystem and every hop becomes a network round trip. The same walk that cost microseconds now costs milliseconds — per hop, thousands of times per operation. This is not a theory; it is why “just put the repositories on NFS” failed at every large host that tried it.
The design that survived looks like GitHub’s Spokes:
keep real repositories on local NVMe so upstream git does the work it was born to do, and replicate at the packfile level with strict consistency. It works — but the bill is steep. Every write goes through a three-phase commit across a fixed replica set. A database remembers which machines hold which repository. Replicas run leader election. Each repository is a little distributed system, and the fleet is a collection of pets: named machines with irreplaceable disks that you feed, patch, and grieve.
Chapter 02 — the insight
Make the log the truth.
Cursor described the architecture in their post Git at any scale — they call the system Continuity. Its move changes the economics of the whole problem.
Put a write-ahead log in object storage and declare it the source of truth. Then demote everything else: every on-disk repository is a cache.
A push is no longer a mutation of a blessed replica. It is stored as an immutable object in the bucket and becomes visible only when a tiny manifest is rewritten with a compare-and-swap. That CAS is the consensus — no election, no quorum, no primary. Any instance may accept a push; if two attempt concurrent writes, they cannot both win.
Watch a push end to end. The server indexes the incoming pack, checks it, uploads the objects under their content address — immutable, write-once — and only then flips the manifest. If the manifest moved underneath it, the bucket answers 412 Precondition Failed and the loser re-reads, re-validates, and retries. The client sees ok only after the bucket does.
The manifest CAS is the single linearization point of the entire system. Put two instances in front of the same bucket and let them race: object storage guarantees exactly one winner. There is nothing to elect because there is nothing to lead.
Reads are consistent without coordination. Every read first asks the store whether anything changed — a conditional GET on the manifest, usually answered 304 Not Modified, served from cache. When it comes back 200, the replica applies the new log entries and serves the new state. There is no “eventually”: every request revalidates against the truth first.
A replica that has never seen a repository simply reads the log — and has it.
Maintenance follows the same rule. Compaction is done once, by whoever holds a lease, and the result is published into the log — so replicas download compacted packs instead of repacking locally. And because the WAL is the truth, you get complete provenance for free: every push and every repack, replayable to any point.
Chapter 03 — the realization
walgit: one binary, one bucket.
walgit is a Rust implementation of that architecture — with the changes needed to run on machines smaller than the repository.
The entire deployment story fits on one screen. One config file, one binary, walgit serve — and the first push creates the repository.
# walgit.toml — the whole configuration (from the README) [server] listen = "0.0.0.0:8080" public_url = "https://git.example.com" auto_create_on_push = true # push creates the repo [server.auth] mode = "token" # none | token | oidc tokens = [{ principal = "me", token_env = "WALGIT_TOKEN_ME", write = true }] [store] backend = "s3" # S3-compatible + GCS bucket = "my-walgit" $ WALGIT_TOKEN_ME=$(openssl rand -hex 24) walgit serve --config walgit.toml ▸ listening on 0.0.0.0:8080 — the bucket is the repository $ git push https://git.example.com/acme/app.git main ▸ ok — main accepted: the manifest CAS landed in the bucket
Pointed at the bucket, that one process gives you smart HTTP (v0/v2) fetch and push, bundle-uri clones served as static files, Git LFS, a browsing web UI, a JSON API with an SDK, per-repository push policy (policy.json), and webhooks driven by an events bridge that tails the WAL and POSTs ref events exactly-once. Repositories can be sha1 or sha256. A background maintainer self-heals: checkpoints, bundle builds, geometric compaction, audits — and its output is a pure function of (config, WAL).
Everything walgit ever writes lands in one of a handful of paths:
The classic catch with object storage: a repository can be larger than any machine you want to pay for. walgit’s monorepo additions erase the catch. A remote reader serves refs and web pages for a repository whose packs never fit on the instance, reading straight out of the bucket with HTTP Range requests. A history pack keeps commits and trees local while the blobs — the bulk of the bytes — stay in the bucket. And bundle-uri turns fresh clones and catch-ups into static files: weekly full bundles plus chained dailies and hourlies, cut as a pure function of the WAL, handed out by the bucket or a CDN — bytes that never touch a walgit process.
Which leaves the operational posture: every machine running walgit is a disposable cache. Add machines pointed at the same bucket and they serve the same repositories consistently with nothing to coordinate. Kill them all and you lose warmth — nothing else.
Chapter 04 — the payoff
Same protocol. Radically less system.
To a git client, all three worlds look identical. Underneath, the operational difference is not a matter of degree.
| Spokes-style hosting | Cursor · Continuity | walgit | |
|---|---|---|---|
| source of truth | replica disks + routing DB | WAL in object storage | WAL in object storage |
| consensus | three-phase commit, leader election, quorum | manifest compare-and-swap | manifest compare-and-swap |
| control plane | database mapping repo → machines | none — any instance serves any repo | none — any instance serves any repo |
| machines | pets: named, stateful, irreplaceable | cattle sized to the repos | disposable — smaller than the repo is fine |
| clone traffic | served by git processes on replicas | served from caches | static bundle files via bucket / CDN |
| maintenance | per-replica repack, coordinated failover | lease holder compacts once, published into the log | self-healing maintainer — pure function of (config, WAL) |
| provenance | what the DB happens to record | every push and repack, replayable | every push and repack, replayable |
| kill every machine | restore from replicas, replay routing DB, re-elect | caches rebuild from the log | you lose warmth, nothing else |