Git hosting where object storage is the only database
Elixir
2
1 commits
updated Aug 25, 2026
Git hosting where object storage is the only database.
It is an implementation of the design in Git at any scale (Cursor's Continuity), built as a Phoenix umbrella you can run on your laptop.
Requires Elixir ≥ 1.17 / OTP ≥ 26 and git ≥ 2.40 on $PATH.
mix setup
mix walgit.new acme/demo
mix phx.server
Then push to it with any git client:
git init && git add . && git commit -m "first commit"
git remote add origin http://localhost:4000/acme/demo
git push -u origin main
Your repository can be found at http://localhost:4000/acme/demo.
To prove the central claim, delete the caches while the server is running:
rm -rf data/cache
git clone http://localhost:4000/acme/demo somewhere-else # still works
A push arrives, and in this order:
git receive-pack writes the incoming packfile into its quarantine directory.pre-receive hook hands walgit the ref updates and that directory.ok, letting git update refs and acknowledge.A crash before step 5 loses nothing but an unreferenced pack. A crash after it has already persisted the push. Because publication is a single CAS on one small object, all pushes are linearized without a consensus protocol, an election, or a primary.
Reads work the other way round. Every read — a clone, a fetch, a page in the web
UI — starts with one conditional GET of the index. A 304 proves the local copy
is current and it is served immediately; a 200 brings the new index, and the
cache downloads only the packs it is missing before serving. Cache copies are
therefore always consistent or caught up first, which is why any number of them
can exist and why losing the gossip that pre-warms them costs latency and never
correctness.
Compaction happens once, on whoever runs it, and is published to the WAL like everything else. Other copies pick up the compacted packs by downloading them — bandwidth in exchange for CPU, and no repack storm.
index/<owner>/<name>.idx the WAL index — the source of truth
repos/<owner>/<name>/wal/<sha>.pack one packfile per push
repos/<owner>/<name>/packs/<sha>.pack compacted packs
repos/<owner>/<name>/history/<a>-<b>.log archived WAL entries
Indexes live in their own namespace so listing every repository is one cheap prefix listing. The index holds the materialized refs snapshot (so advertising refs never walks history), the packs a fresh materialization must download, and a bounded window of recent entries; older entries are archived into history segments, which is what keeps the index a few kilobytes no matter how much history there is.
It also holds a uid, minted once when the repository is created and carried
through every generation after. A repository deleted and recreated under the
same name is a different repository, and the uid is the only thing that says so:
the name is the same and the generation is back at zero. Without it two
repositories created in the same millisecond encode to identical bytes — and
because ETags are content hashes, identical bytes mean an ETag read before the
delete still matches the index that replaced it.
Everything written to the bucket is serialized with string keys and no atoms — an index written by the server has to decode in the CLI, on a fresh install, and in a future build.
apps/
walgit_storage/ storage adapter behaviour + memory, local folder, S3
walgit_core/ the WAL: entries, index, CAS publish, history, repos
walgit_git/ git plumbing: cache repos, materialize, ingest, compact, browse
walgit_events/ webhooks: a WAL consumer with its cursor in the bucket
walgit_web/ smart-HTTP Git endpoints + a GitHub-style LiveView UI
Dependencies point one way: walgit_web → walgit_git → walgit_core → walgit_storage,
with walgit_events hanging off walgit_core — it reads the log and never
touches git.
All Git semantics come from the stock git binary — walgit orchestrates it and
never reimplements the object model, the pack format, or the protocol.
| Adapter | Use | Compare-and-swap |
|---|---|---|
WalgitStorage.Memory | tests | serialized through one process |
WalgitStorage.Local | a folder pretending to be a bucket | per-key lock processes (single node) |
WalgitStorage.S3 | S3, MinIO, R2 … | If-Match / If-None-Match, enforced by the store |
config :walgit_storage, adapter: WalgitStorage.Local
config :walgit_storage, WalgitStorage.Local, root: "/srv/walgit/bucket"
Every adapter must pass the same conformance suite (WalgitStorage.AdapterCase).
mix walgit.new acme/demo # create a repository
mix walgit.list # every repository in the bucket
mix walgit.log acme/demo # the write-ahead log
mix walgit.fsck acme/demo --all # rebuild from the WAL and verify with git fsck
mix walgit.rewind acme/demo 12 # republish generation 12's refs
mix walgit.compact acme/demo # roll up packs now
mix walgit.sweep # janitor: compact, GC caches, sweep orphans
mix walgit.materialize acme/demo # write a plain bare repo you can clone from
mix walgit.events # every webhook and how far behind it is
mix walgit.events.replay ci # redeliver a webhook's entries
mix test # the whole suite
mix test.adapters # the same suite against memory, local folder, and S3
mix walgit.minio # start a local MinIO and print the environment for S3
apps/walgit_web/test/acceptance_test.exs has one test per claim in
goal.md, all end-to-end against a real git binary and a real HTTP
server:
| # | Claim | Proof |
|---|---|---|
| 1 | Bucket-is-truth | wipe every cache, restart, clone and push again, git fsck clean |
| 2 | Portable bucket | copy the bucket elsewhere, fresh install serves every repo |
| 3 | Durable ack | packs are in storage at CAS time; kill -9 + wiped disk keeps the push |
| 4 | Linearized pushes | N concurrent clients → generations 1..N; ref-race losers get rejected |
| 5 | Consistent reads | a replica under 70 %-lossy gossip never serves a partial push |
| 6 | Stock client | clone, push, fetch, pull, --depth 1, tags, force-push, deletes |
| 7 | O(1) hot path | ref advertisement flat across 1 / 10³ / 10⁵ commits; index < 4 KB |
| 8 | Compaction without downtime | pushes and clones succeed during a repack; replicas download |
| 9 | Time travel | every generation reproduces its exact refs; rewind is itself in the log |
| 10 | Adapter parity | the same round-trip and CAS race on memory, folder and S3 |
| 11 | Escape hatch | materialize + git clone, upstream tooling only |
Claim 7 in a recent run, median ref-advertisement latency:
1 commit → 8.7 ms 1,000 commits → 9.0 ms 100,000 commits → 8.9 ms
Claim 4 says pushes are linearized.
Deleted the repo, recreated it, pushed — and the new commit was gone.
With one single process I created something, deleted it, wrote a replacement, and the new version got deleted.
Neither needs concurrency, and neither is visible in a generation counter. So walgit is tested for linearizability proper, following Gavin Lowe's Testing for Linearizability: run workers against the real system, have each keep a private log of its calls and returns, merge the logs into one history, and search for an order in which those operations could have taken effect one at a time.
WalgitCore.Linearizability implements two of the paper's five algorithms, the
Just-in-Time Linearization Graph Search (§4) and the Wing & Gong Graph Search
(§3.1), and runs both on every history so they have to agree. A failure prints
the maximum linearizable prefix, the return that could not be linearized, and
the results the specification would have allowed instead:
232 2 invokes delete(acme/one)
234 2 returns :ok
236 3 invokes create(acme/one)
-- maximum linearizable prefix ends here --
237 3 returns {:error, :exists}
-- allowed results here: {:ok, 0}
Two suites use it. walgit_core treats the whole bucket as one datatype —
create / delete / push / refs / list over a deliberately tiny name
space, so workers collide constantly (§8). walgit_git runs the same check one
level up, where the answers are the commits you get from a clone, which is where
"my commit disappeared" is actually observable.
/dev/dashboard carries walgit's own metrics: push latency split into upload
and CAS, CAS attempts per publish (anything above 1 is contention), catch-up
cost, ref-advertisement latency, and the conditional-GET hit rate the entire
read path rests on.
A background janitor compacts repositories that accumulate too many live packs, drops cache copies nobody has touched, and sweeps packs that were uploaded but never published (the debris of a rejected push). Published packs are kept forever by default — they are what makes rewinding possible.
config :walgit_core, index_window: 200 # entries kept inline before archiving
config :walgit_git,
cache_root: "/var/lib/walgit/cache",
replica_roots: [], # extra cache roots on this node
compaction_threshold: 16,
cache_ttl_seconds: 7 * 24 * 3600
config :walgit_web, auth_token: nil # when set, pushes need it
Auth is deliberately a single token — walgit is single-operator and local-first.
Which is why the server binds to loopback and only loopback. Reads are open
unless you turn on require_auth_for_reads, so a listener on 0.0.0.0 would
be an open Git host by accident; WALGIT_HTTP_IP picks which loopback
address (default 127.0.0.1) and refuses anything else.
I might work on a better auth layer later.
The write-ahead log is also an event stream.
config :walgit_events,
webhooks: [
[id: "ci", url: "https://ci.example.com/hooks/walgit", secret: System.get_env("WALGIT_WEBHOOK_SECRET")]
]
Deliveries are at-least-once, ordered per repository, HMAC-signed. Because the cursor is in the bucket and not in a database, a webhook survives losing the machine it was running on.
1 commits
Elixir
94.8%
CSS
3.3%
JavaScript
1.3%
Git hosting where object storage is the only database
Elixir
2
1 commits
updated Aug 25, 2026
Git hosting where object storage is the only database.
It is an implementation of the design in Git at any scale (Cursor's Continuity), built as a Phoenix umbrella you can run on your laptop.
Requires Elixir ≥ 1.17 / OTP ≥ 26 and git ≥ 2.40 on $PATH.
mix setup
mix walgit.new acme/demo
mix phx.server
Then push to it with any git client:
git init && git add . && git commit -m "first commit"
git remote add origin http://localhost:4000/acme/demo
git push -u origin main
Your repository can be found at http://localhost:4000/acme/demo.
To prove the central claim, delete the caches while the server is running:
rm -rf data/cache
git clone http://localhost:4000/acme/demo somewhere-else # still works
A push arrives, and in this order:
git receive-pack writes the incoming packfile into its quarantine directory.pre-receive hook hands walgit the ref updates and that directory.ok, letting git update refs and acknowledge.A crash before step 5 loses nothing but an unreferenced pack. A crash after it has already persisted the push. Because publication is a single CAS on one small object, all pushes are linearized without a consensus protocol, an election, or a primary.
Reads work the other way round. Every read — a clone, a fetch, a page in the web
UI — starts with one conditional GET of the index. A 304 proves the local copy
is current and it is served immediately; a 200 brings the new index, and the
cache downloads only the packs it is missing before serving. Cache copies are
therefore always consistent or caught up first, which is why any number of them
can exist and why losing the gossip that pre-warms them costs latency and never
correctness.
Compaction happens once, on whoever runs it, and is published to the WAL like everything else. Other copies pick up the compacted packs by downloading them — bandwidth in exchange for CPU, and no repack storm.
index/<owner>/<name>.idx the WAL index — the source of truth
repos/<owner>/<name>/wal/<sha>.pack one packfile per push
repos/<owner>/<name>/packs/<sha>.pack compacted packs
repos/<owner>/<name>/history/<a>-<b>.log archived WAL entries
Indexes live in their own namespace so listing every repository is one cheap prefix listing. The index holds the materialized refs snapshot (so advertising refs never walks history), the packs a fresh materialization must download, and a bounded window of recent entries; older entries are archived into history segments, which is what keeps the index a few kilobytes no matter how much history there is.
It also holds a uid, minted once when the repository is created and carried
through every generation after. A repository deleted and recreated under the
same name is a different repository, and the uid is the only thing that says so:
the name is the same and the generation is back at zero. Without it two
repositories created in the same millisecond encode to identical bytes — and
because ETags are content hashes, identical bytes mean an ETag read before the
delete still matches the index that replaced it.
Everything written to the bucket is serialized with string keys and no atoms — an index written by the server has to decode in the CLI, on a fresh install, and in a future build.
apps/
walgit_storage/ storage adapter behaviour + memory, local folder, S3
walgit_core/ the WAL: entries, index, CAS publish, history, repos
walgit_git/ git plumbing: cache repos, materialize, ingest, compact, browse
walgit_events/ webhooks: a WAL consumer with its cursor in the bucket
walgit_web/ smart-HTTP Git endpoints + a GitHub-style LiveView UI
Dependencies point one way: walgit_web → walgit_git → walgit_core → walgit_storage,
with walgit_events hanging off walgit_core — it reads the log and never
touches git.
All Git semantics come from the stock git binary — walgit orchestrates it and
never reimplements the object model, the pack format, or the protocol.
| Adapter | Use | Compare-and-swap |
|---|---|---|
WalgitStorage.Memory | tests | serialized through one process |
WalgitStorage.Local | a folder pretending to be a bucket | per-key lock processes (single node) |
WalgitStorage.S3 | S3, MinIO, R2 … | If-Match / If-None-Match, enforced by the store |
config :walgit_storage, adapter: WalgitStorage.Local
config :walgit_storage, WalgitStorage.Local, root: "/srv/walgit/bucket"
Every adapter must pass the same conformance suite (WalgitStorage.AdapterCase).
mix walgit.new acme/demo # create a repository
mix walgit.list # every repository in the bucket
mix walgit.log acme/demo # the write-ahead log
mix walgit.fsck acme/demo --all # rebuild from the WAL and verify with git fsck
mix walgit.rewind acme/demo 12 # republish generation 12's refs
mix walgit.compact acme/demo # roll up packs now
mix walgit.sweep # janitor: compact, GC caches, sweep orphans
mix walgit.materialize acme/demo # write a plain bare repo you can clone from
mix walgit.events # every webhook and how far behind it is
mix walgit.events.replay ci # redeliver a webhook's entries
mix test # the whole suite
mix test.adapters # the same suite against memory, local folder, and S3
mix walgit.minio # start a local MinIO and print the environment for S3
apps/walgit_web/test/acceptance_test.exs has one test per claim in
goal.md, all end-to-end against a real git binary and a real HTTP
server:
| # | Claim | Proof |
|---|---|---|
| 1 | Bucket-is-truth | wipe every cache, restart, clone and push again, git fsck clean |
| 2 | Portable bucket | copy the bucket elsewhere, fresh install serves every repo |
| 3 | Durable ack | packs are in storage at CAS time; kill -9 + wiped disk keeps the push |
| 4 | Linearized pushes | N concurrent clients → generations 1..N; ref-race losers get rejected |
| 5 | Consistent reads | a replica under 70 %-lossy gossip never serves a partial push |
| 6 | Stock client | clone, push, fetch, pull, --depth 1, tags, force-push, deletes |
| 7 | O(1) hot path | ref advertisement flat across 1 / 10³ / 10⁵ commits; index < 4 KB |
| 8 | Compaction without downtime | pushes and clones succeed during a repack; replicas download |
| 9 | Time travel | every generation reproduces its exact refs; rewind is itself in the log |
| 10 | Adapter parity | the same round-trip and CAS race on memory, folder and S3 |
| 11 | Escape hatch | materialize + git clone, upstream tooling only |
Claim 7 in a recent run, median ref-advertisement latency:
1 commit → 8.7 ms 1,000 commits → 9.0 ms 100,000 commits → 8.9 ms
Claim 4 says pushes are linearized.
Deleted the repo, recreated it, pushed — and the new commit was gone.
With one single process I created something, deleted it, wrote a replacement, and the new version got deleted.
Neither needs concurrency, and neither is visible in a generation counter. So walgit is tested for linearizability proper, following Gavin Lowe's Testing for Linearizability: run workers against the real system, have each keep a private log of its calls and returns, merge the logs into one history, and search for an order in which those operations could have taken effect one at a time.
WalgitCore.Linearizability implements two of the paper's five algorithms, the
Just-in-Time Linearization Graph Search (§4) and the Wing & Gong Graph Search
(§3.1), and runs both on every history so they have to agree. A failure prints
the maximum linearizable prefix, the return that could not be linearized, and
the results the specification would have allowed instead:
232 2 invokes delete(acme/one)
234 2 returns :ok
236 3 invokes create(acme/one)
-- maximum linearizable prefix ends here --
237 3 returns {:error, :exists}
-- allowed results here: {:ok, 0}
Two suites use it. walgit_core treats the whole bucket as one datatype —
create / delete / push / refs / list over a deliberately tiny name
space, so workers collide constantly (§8). walgit_git runs the same check one
level up, where the answers are the commits you get from a clone, which is where
"my commit disappeared" is actually observable.
/dev/dashboard carries walgit's own metrics: push latency split into upload
and CAS, CAS attempts per publish (anything above 1 is contention), catch-up
cost, ref-advertisement latency, and the conditional-GET hit rate the entire
read path rests on.
A background janitor compacts repositories that accumulate too many live packs, drops cache copies nobody has touched, and sweeps packs that were uploaded but never published (the debris of a rejected push). Published packs are kept forever by default — they are what makes rewinding possible.
config :walgit_core, index_window: 200 # entries kept inline before archiving
config :walgit_git,
cache_root: "/var/lib/walgit/cache",
replica_roots: [], # extra cache roots on this node
compaction_threshold: 16,
cache_ttl_seconds: 7 * 24 * 3600
config :walgit_web, auth_token: nil # when set, pushes need it
Auth is deliberately a single token — walgit is single-operator and local-first.
Which is why the server binds to loopback and only loopback. Reads are open
unless you turn on require_auth_for_reads, so a listener on 0.0.0.0 would
be an open Git host by accident; WALGIT_HTTP_IP picks which loopback
address (default 127.0.0.1) and refuses anything else.
I might work on a better auth layer later.
The write-ahead log is also an event stream.
config :walgit_events,
webhooks: [
[id: "ci", url: "https://ci.example.com/hooks/walgit", secret: System.get_env("WALGIT_WEBHOOK_SECRET")]
]
Deliveries are at-least-once, ordered per repository, HMAC-signed. Because the cursor is in the bucket and not in a database, a webhook survives losing the machine it was running on.
1 commits
Elixir
94.8%
CSS
3.3%
JavaScript
1.3%