Gunnar
100% artificial Rust git server. Apache Arrow IPC. No OS.
Gunnar Git Server — a fusion of the honey badger and Gunnar from Njáls saga. It won't back off.
- Runs with or without an OS — tunnr appliance, container, or systemd.
- Two git transports, one implementation — SSH, and smart HTTP behind a default-off feature. The same pkt-line conversation, the same key table, the same ACL;
git://is never built and the dumb HTTP protocol is refused by name. - No database. Refs and secrets are in memory, persisted by an Apache Arrow IPC log, one
RecordBatchper push. No request reads Arrow to answer. - No shell, no
gitorsshsubprocess, no libgit2. gitea and Forgejo spawngit; Gunnar spawns nothing. - Stock git 2.53.0 is the client and the judge. The whole suite runs under
cargo xtask ci.
Compared to gitea and Forgejo
Equal RAM, CPU and disk parameters on all tests. Column shows best results. Since gunnar gc recomputes deltas, the maintained fetch is 0.7201× — gunnar sends under three quarters of the rival's bytes on fetch, gc'd. forge-bakeoff-benchmarks.md has every measured row, its conditions, and what each spread says.
| gunnar / better of gitea, Forgejo | server→client bytes | net CPU s | peak RSS MB |
| clone (year) | 24.55 MB / 24.56 MB · tie 1.000× | 0.234 / 0.260 · 1.11× less | 304 / 655 · 2.16× less |
| fetch (diverge) | 344.6 kB / 296.7 kB · 1.16× more | 0.027 / 0.059 · 2.23× less | 21 / 262 · 12.68× less |
| fetch, gc'd (diverge-gcx) | 238.7 kB / 331.5 kB · 1.39× less | 0.023 / 0.026 · 1.13× less | 13 / 252 · 19.93× less |
| clone/fetch, ordered gc (nh2-fetch) | 344.6 kB / 331.5 kB · 1.04× more | 0.021 / 0.026 · 1.23× less | 15 / 273 · 17.97× less |
| clone/fetch, ordered gc (pw-clone) | 1.62 MB / 1.41 MB · 1.15× more | 0.033 / 0.015 · 2.28× more | 16 / 274 · 17.60× less |
| push (base) | 1.3 kB / 2.1 kB · 1.62× less | 0.301 / 0.397 · 1.32× less | 91 / 334 · 3.67× less |
| ls-remote (year) | 17.9 kB / 18.1 kB · tie 0.988× | 0.003 / 0.007 · 1.94× less | 74 / 332 · 4.49× less |
| clone, ramped ×4 (c4) | 98.22 MB / 98.23 MB · tie 1.000× | 0.930 / 1.010 · 1.09× less | 315 / 541 · 1.72× less |
| clone, ramped ×4 (c4-pin) | 98.22 MB / 98.24 MB · tie 1.000× | 0.965 / 0.969 · tie 0.995× | 480 / 545 · 1.14× less |
| clone+fetch+push ×8 (mix8) | 38.74 MB / 30.34 MB · 1.28× more | 1.297 / 2.603 · 2.01× less | 189 / 537 · 2.85× less |
iceberg bench_runs · oden · 32 cores · 2026-08-05T01:57:09.874732+00:00 · 3 interleaved rounds · loadavg 155.23 · same RAM, CPU and disk parameters on all tests — 8.00 cpus / 8192 MB per engine, read back out of each container's own cgroup
Get one running
Every line below was run end to end on oden, 2026-08-05, against a build of this tree — gunnar 1.1.0. One binary is the server and the client. The first-boot lines were run against that build; the numbers further down carry their own conditions.
cargo build --release -p gunnar-cli # target/release/gunnar
gunnar serve --ssh-bind 0.0.0.0:2222 # starts EMPTY. No key needed yet.
It starts with nothing in it and says so, loudly, because until somebody claims it anyone who can reach its control port can:
NO IDENTITY HAS EVER BEEN ENROLLED at /home/you/.local/share/gunnar.
Bootstrap.Enroll on 127.0.0.1:50051 is open to ANY caller that can reach it,
with NO credential, and it creates a gunnar-admin. It closes permanently on
the first success and deleting identities does not reopen it.
Claim it, from another terminal. This is the one call that needs no credential, and it works exactly once in a server's life:
gunnar control bootstrap rickard # -> gunnar-admin, window CLOSED
You are now logged out — bootstrap kept no session, and there is nowhere for one to be kept. Everything after this is authenticated with your ssh key:
gunnar control login # proves the key; writes nothing
gunnar control user add henrik --key @henrik.pub
gunnar control user add drift --key @drift.pub --gunnar-admin
gunnar control repo create rickard/hello # your own account: no grant needed
Then, from a working tree, with that key in your agent or IdentitiesOnly:
git init --object-format=sha256 . # not optional — see below
git remote add origin ssh://git@HOST:2222/rickard/hello.git
git push origin HEAD:refs/heads/main # stock git 2.53 still inits `master`
henrik can do the same in henrik/hello with no grant anywhere, and gets this on rickard/hello:
fatal: remote error: repository not found, or you do not have access: rickard/hello
The four steps, and what each one is
1 gunnar control bootstrap <name> NO auth. First user, gunnar-admin.
2 …logged out The door is shut. Permanently.
3 gunnar control login The ssh key. No session either side.
4 gunnar control user add <name> Authenticated. Needs gunnar-admin.
<name>is also the account.rickardownsHOST/rickard/<repo>, the GitHub shape, and is admin of it always. That is not a grant: there is no row to revoke, sogunnar control grantcannot take it away and nothing has to remember to put it back.- gunnar-admin is a server-wide
admingrant — the ordinary one, the same rowgunnar control grant <who> admin --serverwrites. Any gunnar-admin can hand it to anybody. The first user is simply the first; they have no standing afterwards that a later admin lacks. - The window closes on the record, not on the key count. Deleting every user does not reopen it.
gunnar control statussays who claimed the server and when, with no credential, which is also the only question an unenrolled server can be asked.
Two traps left, and one that is gone
- New repositories are sha256 (D11); stock
git initis sha1. Pushing a sha1 working tree at one fails withfatal: the receiving end does not support this repository's hash algorithm, which says nothing about which end chose what. Either init the client--object-format=sha256, or create the repository with--hash sha1(gunnar control repo createonline,gunnar repo createoffline). - The offline admin verbs still need the server down.
gunnar principal,gunnar keyandgunnar grantwrite the control repository directly, so withgunnar serveup they refuse with the store is locked by another writer.gunnar control …is the online path and does not have that constraint: it goes over gRPC to the running server, which is the one writer. Use the offline verbs only when there is no server running — recovering a box whose last key was deleted, for instance. - GONE:
gunnar serveno longer refuses to start against an empty identity table. It used to bail out with refusing to serve: the identity table is empty and no admin public key was supplied, which meant there was no first boot at all: the box could not be reached to be given its first key, and the only way in wasgunnar key addwith the server down — which needs a shell, and the appliance has none. It now starts, warns on every line above, and servesBootstrap.Enrolluntil somebody uses it.--admin-keystill works and closes the same window.
The same thing against a container, with a durable identity
The point of the window is a disposable gunnar with an identity that outlives it. Run 2026-08-05 on oden, podman 5.x, a FROM scratch image holding the 15 742 952 B musl binary and nothing else:
podman volume create gunnar-data
podman run -d --name gunnar-a -v gunnar-data:/data \
-p 127.0.0.1:50051:50051 -p 127.0.0.1:2222:2222 localhost/gunnar:test
gunnar control status # bootstrap OPEN
gunnar control bootstrap rickard --key @rickard.pub
gunnar control repo create rickard/hello
gunnar control user add henrik --key @henrik.pub --gunnar-admin
git push ssh://git@127.0.0.1:2222/rickard/hello.git HEAD:refs/heads/main
podman kill -s KILL gunnar-a && podman rm -f gunnar-a
podman run -d --name gunnar-b -v gunnar-data:/data … # a DIFFERENT container
gunnar-b reports bootstrap closed — enrolled … by "rickard", 2 principals, 2 keys; a second bootstrap is refused; rickard's key still logs in as gunnar-admin; henrik can still create users; and the clone comes back at the same 9b5053eb… the push put there. Nothing survives in the container — the image is one file — so all of it came off the volume.
Why not bake the keys into the image
The alternative considered was putting the operators' public keys into gunnar's container and ISO at build time. A key baked into an image is a credential that cannot be revoked and cannot be rotated: every copy of the image carries it, including copies nobody remembers taking, and changing the answer means building and redeploying an image. The bootstrap window is a credential nobody holds yet, granted to whoever reaches the box first, and shut for good the moment it is used.
…and the same repositories over smart HTTP
Smart HTTP is the second git transport, not a second server: the same pkt-line conversation, driven through the same authorise_service call against the same key table and the same ACL. It is behind a default-off feature, so an appliance image that does not want an HTTP surface compiles none of it — and turning it on costs zero third-party crates, because hyper is already in the graph under tonic (281 → 282 packages, 267 third-party either way; cargo xtask guard, oden 2026-08-05).
cargo build --release -p gunnar-cli --features server,http
gunnar token add alice # printed ONCE; it is not stored in the clear
gunnar serve --ssh-bind 0.0.0.0:2222 --http-bind 0.0.0.0:8080
A public repository then clones with no credential at all, and an access token is the HTTP Basic password where a public key is the SSH one:
git clone http://HOST:8080/demo/hello.git
git push http://alice:<token>@HOST:8080/demo/hello.git main
Both protocol versions work and git picks v2 by default; -c protocol.version=0 forces the fallback, which is the harder path because v0 has no command= boundary to end a stateless round on.
Two things will stop you, and neither reports its cause:
- gunnar terminates no TLS, and refuses a credential on a cleartext connection. A token in an
Authorizationheader over plain HTTP is a password on the wire, so by default gunnar wants a terminator in front of it saying the client spoke TLS (X-Forwarded-Proto: https). For loopback, a test, or a link you vouch for, pass--http-allow-cleartext-credentials. Anonymous read is unaffected either way: it presents no credential. --no-anonymousand repository visibility are one policy across both transports. A repository isPrivateunless created--public, and the switch that closes the anonymous door closes it on SSH and HTTP together. What SSH refuses, HTTP refuses — a stranger cannot tell a private repository from a missing one on either.
Everything above was run end to end on oden, 2026-08-05, against the binary built from this tree; the verification ports were loopback rather than 0.0.0.0 because seven lanes share the box.
Git LFS rides the same URL, the same token and the same grant
git clone http://… of a repository with LFS content used to pull every git object and then die in git lfs smudge, because git-lfs derives its endpoint by appending /info/lfs to the remote and nothing answered there. It does now: the batch API and the basic transfer adapter, at
POST /<ns>/<repo>.git/info/lfs/objects/batch application/vnd.git-lfs+json
GET /<ns>/<repo>.git/info/lfs/objects/<oid> the object
PUT /<ns>/<repo>.git/info/lfs/objects/<oid> the object, verified before it is stored
POST /<ns>/<repo>.git/info/lfs/objects/verify
Nothing to configure and nothing to install: it comes up with the HTTP listener and the boot log names it. git lfs track and clone.
One authorisation, four surfaces. The direction of an LFS request — the JSON operation, or GET against PUT — selects GitService::LfsTransfer(operation), the same enum value the SSH exec line git-lfs-transfer <path> <operation> produces, and goes into the same Dispatch::authorise_service the two git verbs call. So read may download and may not upload, a stranger gets 401 and cannot tell a private repository from an absent one, anonymous read is rule 0 on a Public repository and nothing else, and --max-lfs-object-bytes binds on HTTP exactly as it binds on SSH.
The LFS specification's other authentication path — git-lfs-authenticate over SSH, handing a client a bearer token to use against HTTPS — is deliberately not implemented. gunnar's ssh:// remotes speak git-lfs 3.0's pure-SSH transfer, which needs no token because the connection is already authenticated, and that verb would be a fourth credential type with its own expiry and its own revocation story. The §5 key table has one kind of secret.
Objects live under the repository's own git dir, in <git_dir>/lfs/objects/, raw and uncompressed — one repository is still one directory, so backup, repack's atomic swap and gunnar mirror are unchanged.
Verified on the real corpus, oden 2026-08-05, tunnr (sha256, one LFS file, docs/manual.pdf) pushed to a live gunnar serve and cloned back over http://:
docs/manual.pdf in the clone |
135 449 bytes, PDF document, version 1.7, 5 page(s), cmp identical to the source — not a 131-byte pointer |
| the object on the server | lfs/objects/64/54/6454f836…56bc, byte-identical to what was uploaded |
| with that object removed | git clone exits 128 naming [404] object 6454f836… is not on this server; git lfs fetch exits 2; no pointer file is left behind |
That last row is the one worth having. The pure-SSH grammar's batch line is <oid> SP <size> SP <action> with action ∈ upload | download | noop, so it has to answer noop both when the server already holds an object and when it does not hold one at all — and git-lfs reads an object with neither an action nor an error as one it already has. MEASURED both ways on 2026-08-05, against a server rendering a missing object that way: git lfs fetch --all printed 1 object found, done. and exited zero having transferred nothing, and git clone failed blaming the client's own cache (error opening media file: no such file or directory) for a fault that is entirely the server's. The HTTP batch API can say error: {"code": 404} per object inside a 200, and does. The two surfaces diverge there on purpose; the decision behind them is one function, gunnar_lfs::batch::plan_object.
Where it runs
| shape | measured under | |
| no OS | 29.5 MB ISO from the declared ⚒ Build Thing cell, PID 1, own network over AF_NETLINK. Listening in 3.66 s, banner naming its own commit; the full git matrix incl. a fetch that really negotiates (3 rounds, 2 common); boots again 3.69 s after a power cut and serves the same 15-object exchange. Its data root is the initramfs, so a power cut loses every repository pushed to it — measured, not assumed. |
QEMU/OVMF via tunnr |
| no OS, container | gunnar-scratch — 15 083 584 B, one file, no shell, no libc. Ready 144 ms, 2.3–3.7 MB RSS. |
podman |
| on an OS | systemd via Skidbladnir. Install 17.3 s, push 385 ms, clone 164 ms. | KVM guest |
The data path
Read and write as the structures they run over, each with its complexity and the file:line it lives at. The third panel is the pack itself — pack entry, OfsDelta, RefDelta, the 256-bucket .idx fanout, Fanout16, the EWAH .bitmap — with what keys each one and what a probe into it costs. Read copies: the entry travels in the encoding it was stored in, so a fetch is O(bytes) and one byte-range copy owns 89.98% of the wall. Write hashes: every byte received is inflated once and hashed with collision detection, and 64.81% of a push's cycles is that hash. Shares are the gix census, oden 2026-08-04, 19 repositories, 65 737 objects — gix-census-reasoning.md. cargo xtask svg.
The write and read cycle
Three panels: the five reserved archive sections with their schemas and what each costs to read, four git operations traced stage by stage with bytes in and out, and the append/re-seal timeline. Each box carries a file:line. An edge with no call site is drawn dashed and one with a call site solid — the stroke is computed from the datum, so it cannot be drawn solid without changing it.
TOTAL-PROMPT.md is the spec and the decision log; stinky.md is 51 audit entries, 20 open; performance-design.md is 18. Rendered to docs/gunnar-manual.pdf and docs/gunnar-record.pdf by cargo xtask docs. Appliance build, 281 crates (267 third-party), 0 violations against the 16 banned and 7 tracked deviations (cargo xtask guard, oden 2026-08-05): cargo build -p gunnar-cli --no-default-features --features server. MIT OR Apache-2.0.