Nickforall/walgit-elixir

Git hosting where object storage is the only database

Elixir

2

1 commits

updated Aug 25, 2026

See the code

See what people are saying (1)

SourceMessageScoreDate

You can run Git on object storage if you re-make packfiles

After Git At Any Scale there's been a lot of these! (https://cursor.com/blog/git-at-any-scale https://news.ycombinator.com/item?id=49348141) https://github.com/Nickforall/walgit-elixir https://github.com/tobi/walgit (https://news.ycombinator.com/item?id=49420598) https://github.com/tuist/micelio/

0

Sep 19, 2026

README

walgit

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.


Quickstart

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

How it works

A push arrives, and in this order:

  1. git receive-pack writes the incoming packfile into its quarantine directory.
  2. A generated pre-receive hook hands walgit the ref updates and that directory.
  3. walgit uploads the packfile to object storage.
  4. walgit links it into the repository so readers can see the objects.
  5. walgit compare-and-swaps the WAL index — the moment the push becomes real.
  6. Only now does the hook answer 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.

Bucket layout

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.

Layout

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.

Storage adapters

AdapterUseCompare-and-swap
WalgitStorage.Memorytestsserialized through one process
WalgitStorage.Locala folder pretending to be a bucketper-key lock processes (single node)
WalgitStorage.S3S3, 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).

Command line

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

Tests

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:

#ClaimProof
1Bucket-is-truthwipe every cache, restart, clone and push again, git fsck clean
2Portable bucketcopy the bucket elsewhere, fresh install serves every repo
3Durable ackpacks are in storage at CAS time; kill -9 + wiped disk keeps the push
4Linearized pushesN concurrent clients → generations 1..N; ref-race losers get rejected
5Consistent readsa replica under 70 %-lossy gossip never serves a partial push
6Stock clientclone, push, fetch, pull, --depth 1, tags, force-push, deletes
7O(1) hot pathref advertisement flat across 1 / 10³ / 10⁵ commits; index < 4 KB
8Compaction without downtimepushes and clones succeed during a repack; replicas download
9Time travelevery generation reproduces its exact refs; rewind is itself in the log
10Adapter paritythe same round-trip and CAS race on memory, folder and S3
11Escape hatchmaterialize + 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

Linearizability

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.

Operations

/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.

Configuration

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.

Events and webhooks

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.

Contributors

Nickforall

1 commits

Nickforall/walgit-elixir

Git hosting where object storage is the only database

Elixir

2

1 commits

updated Aug 25, 2026

See the code

See what people are saying (1)

SourceMessageScoreDate

You can run Git on object storage if you re-make packfiles

After Git At Any Scale there's been a lot of these! (https://cursor.com/blog/git-at-any-scale https://news.ycombinator.com/item?id=49348141) https://github.com/Nickforall/walgit-elixir https://github.com/tobi/walgit (https://news.ycombinator.com/item?id=49420598) https://github.com/tuist/micelio/

0

Sep 19, 2026

README

walgit

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.


Quickstart

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

How it works

A push arrives, and in this order:

  1. git receive-pack writes the incoming packfile into its quarantine directory.
  2. A generated pre-receive hook hands walgit the ref updates and that directory.
  3. walgit uploads the packfile to object storage.
  4. walgit links it into the repository so readers can see the objects.
  5. walgit compare-and-swaps the WAL index — the moment the push becomes real.
  6. Only now does the hook answer 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.

Bucket layout

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.

Layout

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.

Storage adapters

AdapterUseCompare-and-swap
WalgitStorage.Memorytestsserialized through one process
WalgitStorage.Locala folder pretending to be a bucketper-key lock processes (single node)
WalgitStorage.S3S3, 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).

Command line

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

Tests

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:

#ClaimProof
1Bucket-is-truthwipe every cache, restart, clone and push again, git fsck clean
2Portable bucketcopy the bucket elsewhere, fresh install serves every repo
3Durable ackpacks are in storage at CAS time; kill -9 + wiped disk keeps the push
4Linearized pushesN concurrent clients → generations 1..N; ref-race losers get rejected
5Consistent readsa replica under 70 %-lossy gossip never serves a partial push
6Stock clientclone, push, fetch, pull, --depth 1, tags, force-push, deletes
7O(1) hot pathref advertisement flat across 1 / 10³ / 10⁵ commits; index < 4 KB
8Compaction without downtimepushes and clones succeed during a repack; replicas download
9Time travelevery generation reproduces its exact refs; rewind is itself in the log
10Adapter paritythe same round-trip and CAS race on memory, folder and S3
11Escape hatchmaterialize + 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

Linearizability

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.

Operations

/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.

Configuration

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.

Events and webhooks

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.

Contributors

Nickforall

1 commits

Languages

Elixir

94.8%

CSS

3.3%

JavaScript

1.3%