Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

crates/storage-git-gix/parked-gix/.nornir/README.md

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.

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.

Two traps left, and one that is gone

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:

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.