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/protocol-capability-audit.md

Every capability stock git speaks, against every capability gunnar speaks — reasoning

Class: reasoning. An audit, not a plan. Three columns, because the question "is gunnar a git server yet" has three different answers and they get conflated: gix has it and we use it, gix lacks it and we coded around it, gix lacks it and we simply do not do it. Taken 2026-08-05 against stock git 2.53.0 on oden, tree 008d74b.


0. Method, and the one place it could have lied

The advertisement gunnar sends is read out of gunnar_wire::caps, which is one table per surface, and tests/advertisement.rs asserts the invariant red-first from outside. The advertisement stock git sends was captured, not remembered:

GIT_PROTOCOL=version=2 git upload-pack --advertise-refs stock.git
git upload-pack --advertise-refs stock.git                       # v0
git receive-pack --advertise-refs stock.git

each run twice: once with git's defaults, once with every relevant uploadpack.* / transfer.* / receive.* knob turned on, because git's defaults advertise less than gunnar does and an audit against the default would have scored gunnar ahead on two capabilities it does implement.

The sentence that used to be here was false, and this is the correction

Until 2026-08-05 this section read: "one table that generates both the parser and the advertisement — so 'advertised but not honoured' is not something this codebase can express." It was expressible, and it was expressed. The table generated the protocol-v2 arguments. It did not generate anything else:

push-options was the one that had actually gone wrong. It was advertised, ReceivePack::serve parsed the section, and the only consumer of a parsed option anywhere in the tree was .len() in a tracing field. PushFacts — the struct that exists so a gate can see what a push carried — had no options field, so no policy could act on a push option even in principle. A client running git push -o ci.skip was told on the wire that the server speaks push options, and the option went nowhere.

Closed by construction, 2026-08-05. caps::ADVERTISED names all three surfaces, every row carries a Honoured value saying where it is honoured (file + a substring of the line of code that reads it, with comments stripped before the guard looks — contains("push-options") is otherwise satisfied by the paragraph explaining push-options two lines above the call), and tests/advertisement.rs walks every row on every surface. It was seen red three ways: a broken consumer, a capability nobody reads, and a literal pushed onto the rendered list beside the table.

Two rows honour themselves by doing nothing, and each carries the argument rather than a label: receive-pack's quiet (gunnar emits no progress on that connection, so there is nothing to suppress) and v2's server-option (the specification says a server SHOULD ignore options it does not understand, which is not the push-options case — git hands push options to pre-receive, which is why discarding those was a wrong answer). agent= is the third: an identifier for the peer's log with no behaviour at either end.

And push-options is now honoured: the parsed options reach PushFacts::push_options, then PushContext::push_options, and gunnar_policy::PushOptionsGate refuses on them — allowed (an option nobody has heard of is refused loudly rather than accepted and ignored) and required (-o ticket=NORD-1). Proven against stock git push -o over real SSH in crates/gunnar-cli/tests/push_facts.rs.

The trap this method avoids. "Does git support X" is not the question. "Does a stock git client ever send X" is, and the two differ for at least one capability — see §3.


1. Protocol v2 — the default for every git since 2.26

capability stock git 2.53.0 gunnar verdict
agent yes yes have
ls-refs=unborn yes yes have
fetch=shallow yes yes have
fetch=wait-for-done yes yes have, and honoured — negotiate suppresses ready entirely under it
fetch=filter config, off by default yes, on by default have, and more than git ships with
fetch=ref-in-want config, off by default yes have
server-option yes yes have; neither side acts on the options, which is what the specification says to do
object-format yes yes have, both sha1 and sha256
fetch=sideband-all config, off no absent — §3
fetch=packfile-uris config, off no absent — §3
session-id config, off no absent — §3
object-info (command) config, off no absent — §3, and the interesting one
bundle-uri (command) config, off no absent — §3

2. Protocol v0, and receive-pack

capability stock git gunnar verdict
side-band-64k, thin-pack, ofs-delta, no-progress, include-tag, shallow, deepen-since, deepen-not, deepen-relative, symref= yes yes have
side-band (the 1000-byte form) yes no — only -64k absent, deliberately. Every git since 2005 offers -64k; a client that has one has both
multi_ack, multi_ack_detailed, no-done yes no, and not advertised absent — §4
allow-tip-sha1-in-want / allow-reachable-sha1-in-want config, off no (there is a WantPolicy instead) absent, and gunnar's knob is the same decision under a different name
receive-pack: report-status, report-status-v2, delete-refs, side-band-64k, quiet, atomic, ofs-delta, push-options yes yes have
receive-pack: push-cert=<nonce> (signed push) config, off no absent — §5

Both directions checked, because a capability advertised and not honoured is worse than one not advertised. Every v0 and receive-pack capability above has a reader, and since 2026-08-05 the reader is named in the table itself (caps::Honoured) rather than in this paragraph, so the claim cannot go stale independently of the code — which is what happened to the sentence in §0. The one that has no reader is receive-pack's quiet, and it is vacuously honoured: gunnar emits no progress on that connection at all, so there is nothing for it to suppress. That is recorded rather than called a bug.

(This paragraph previously listed push-options among the honoured with a file:line. The line was real; what it pointed at was the parser, not a consumer. That is precisely why the consumer claim now lives beside the advertisement and is checked by a test.)


3. object-info is the row worth reading, and the answer is do not build it

It is the natural companion to the one forge-grade capability gunnar already ships beyond git's own defaults — partial clone. A blob:none client that wants a missing blob's size should be able to ask for it instead of downloading it.

MEASURED, not assumed. A stock blob:none partial clone from a stock server with transfer.advertiseObjectInfo=true, then git cat-file --batch-check on a blob the clone does not have, under GIT_TRACE_PACKET:

command=fetch   2
command=object-info   0

git downloaded the blob. Reading the binary confirms why: every object-info string in git 2.53.0 is a message from upload-pack's server-side parser ("object-info: expected flush after arguments", "object-info: protocol error, expected to get oid, not '%s'"), there is no --use-remote-object-info option on cat-file, and no config selects one.

So there is no stock git client that sends it, and PLAN.md's rule — stock git 2.53.0 is the judge — means implementing it could not be exercised by anything except a test speaking the protocol back to itself. That is the exact shape the rule exists to forbid. Verdict: do not build, and revisit if a git release ships the client.

The other four v2 absences are CDN, forge or trace features rather than protocol gaps a client hits:

4. multi_ack_detailed is a v0 capability, and that is the whole answer

It is the one absence the README has always named, and it is smaller than it sounds. multi_ack, multi_ack_detailed and no-done change how the v0 have/ACK loop terminates. Protocol v2 has no multi_ack at all — it negotiates with acknowledgments, ready and wait-for-done, and gunnar implements all three, including git's own ready criterion (every want can reach at least one commit the client claims) and git's process_haves suppression that took an ACK list from 508 lines to 1.

Every git since 2.26 (2020) defaults to protocol.version=2 over SSH. A client reaches gunnar's v0 path only by setting protocol.version=0 explicitly or by being older than that, and the cost it then pays is round trips, not correctness: gunnar's single-ACK mode is what git has spoken since 2005, and caps.rs refuses to advertise the richer form precisely so that no client is promised a negotiation gunnar does not run.

Verdict: record, do not build. It is real work on a path real clients do not take, and the honest ranking puts it below every row in §1.

5. Signed push (push-cert) — the one absence with a security argument

git push --signed sends a certificate over receive-pack, and gunnar does not advertise push-cert=<nonce>, so stock git refuses the push with "the receiving end does not support --signed push". A correct refusal, not a bug.

It is named here rather than in §3 because gunnar is the server with a pre-receive gate, an attestation surface and an audit log — the one place a push certificate would actually be used rather than filed. What stops it is not the protocol: the certificate format and the HMAC nonce are small. It is that verifying the signature means OpenPGP, and §0's "zero shell" forbids shelling out to gpg, so this needs a pure-Rust OpenPGP verifier in the serve graph. Accepting a certificate without verifying it would be security theatre with an audit trail, which is worse than not offering it.


6. The three-way summary, which is the deliverable

gix has it and gunnar uses it. Object and pack plumbing, ref storage, hash, the .idx reader, pack indexing on the receive path.

gix lacks it and gunnar coded around it. Everything in this document, in fact: gitoxide has no git server at all. gix-protocol is a client. There is no upload-pack, no receive-pack, no negotiation, no capability advertisement, and — the largest single hole — no push in either direction. Three pieces of that were already extracted as donation-shaped crates (gunnar-delta, the delta encoder gitoxide has none of; gunnar-bitmap, EWAH and .bitmap, which gitoxide has none of), and the third landed with this audit: gunnar-sendpack, the v0 push grammar, both directions, one implementation.

gix lacks it and gunnar does not do it either. object-info (no client exists — §3), bundle-uri, packfile-uris, sideband-all, session-id, multi_ack_detailed/no-done (v0 only — §4), signed push (§5), replication, and the three partial-clone filters gunnar refuses by name: sparse:oid, object:type and combine:.

And one correction, which is why an audit beats a memory. The README said "no partial clone beyond blob:none" for as long as it has existed. gunnar implements three filters — blob:none, blob:limit=<n> and tree:<depth> — all advertised from the table the parser reads, and all three driven by real git clone --filter= in crates/gunnar-wire/tests/real_git_surface.rs. The sentence understated the product; it is fixed.