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/wire-design.md

The wire — design

Class: design · Function axis: f0 git over SSH · f1 pkt-line framing · f2 upload-pack (protocol v2, v0 fallback) · f3 receive-pack (protocol v0) · f4 send-pack · f5 the pack writer and indexer. Strict: this file states what the protocol layer IS. What Gunnar is: design.md. Storage: storage-reasoning.md. The gate: policy-design.md.

gunnar-wire holds the two protocol state machines a git server answers with: upload-pack and receive-pack. Their bytes are framed by gunnar-pktline, and the packfiles they carry are written and indexed by gunnar-pack. The client's send-pack is the third machine and lives in gunnar-client, over the same framing and pack writer.

gunnar-pktline and gunnar-pack are on both sides, and therefore inherit the server's constraint: plumbing only, no gix porcelain, no worktree features. gunnar-wire is server-only in practice today, for the reason recorded under send-pack below.

Both hash kinds are carried end to end: sha1 and sha256, per repository.

Status — 2026-08-03

Built and running. Stock git clones from and pushes to gunnar serve over a real SSH connection, in both hash kinds. gunnar push to Gunnar works. Measured: unpack ok, 3 objects, 209 bytes.

Until 2026-08-03 every SSH session was served at protocol v0 and gunnar clone from Gunnar failed. Fixed; the shape of the fix is under send-pack below.

One thing is deliberately absent: gunnar-pack writes no deltas.

The function map (f0)

part crate what it is
f1 framing gunnar-pktline pkt-line encode, decode, incremental decoder, sideband. Zero dependencies.
f2 upload-pack gunnar-wire serving fetch and clone. Protocol v2, with a v0 fallback.
f3 receive-pack gunnar-wire serving push. Protocol v0.
f4 send-pack gunnar-client the client's push. See the divergence noted below.
f5 pack gunnar-pack pack writer for serving, pack indexer for receiving

Transport-agnostic, on purpose

Every entry point takes &mut dyn BufRead and &mut dyn Write. There is no socket, no async runtime and no SSH in gunnar-wire. gunnar-cli bridges a russh channel into those two traits; a test bridges a pipe; git's own ext:: transport bridges a subprocess's stdio. The state machine cannot tell them apart, which is what let it be tested against the real git binary before an SSH server existed.

The bridge itself is worth naming. russh hands over a tokio duplex stream, which is split into halves (one object cannot be borrowed mutably as both reader and writer), each wrapped in a SyncIoBridge, and the whole transfer runs inside spawn_blocking. SyncIoBridge blocks on the runtime, so running it on a runtime worker would deadlock the reactor that has to feed it.

gunnar-ssh dispatches git-upload-pack '<path>' and git-receive-pack '<path>' from exec_request. Any other exec request fails the channel; there is no shell, and no exec path to a process at all; the command name selects an in-process state machine.

git-upload-pack — serving fetch and clone, protocol v2

  1. Advertise capabilities: version 2, agent=gunnar/<v>, ls-refs=…, fetch=…, object-format=sha1|sha256, server-option.
  2. command=ls-refs, arguments symrefs, peel, ref-prefix <p>, unborn.
  3. command=fetch: want, have, done, thin-pack, no-progress, include-tag, ofs-delta, shallow, deepen, deepen-relative, deepen-since, deepen-not, filter <spec>, want-ref <ref>, wait-for-done. Response sections: acknowledgments, shallow-info, wanted-refs, packfile. With sideband: pack on band 1, progress on band 2, errors on band 3.

The advertisement is generated from the parser's own table

A v2 client will not send an argument unless the feature gating it appears in the advertised value of the capability it belongs to. A server that parses filter <spec> but advertises fetch=shallow wait-for-done has partial-clone support that can never activate — and it looks exactly like working code. No error, no failing test; the client never exercises the path. Stock git 2.53 does this deliberately (uploadpack.allowFilter defaults off), which is why the shape is easy to copy by accident.

So the advertisement is not written down anywhere. One table — FETCH_ARGUMENTS, LS_REFS_ARGUMENTS — says per argument which feature gates it, and both the parser and the advertisement are derived from it. Advertising less than is parsed is not something you can forget to do; it is something you cannot express. tests/advertisement.rs still asserts the invariant from the outside, red-first.

The five shallow arguments are gated on the single shallow feature, exactly as git defines it. Advertising shallow is a promise to accept all five, which is why all five are implemented rather than rejected. filter is the one capability an operator plausibly wants to decline, so it sits behind an Offered switch.

The v0 fallback is not a formality

git 2.53 negotiates v0 whenever GIT_PROTOCOL does not reach the server process. Measured 2026-08-03 over ext::, where git passes GIT_EXT_SERVICE but not GIT_PROTOCOL. A server without a v0 path would not answer those clients at all.

In v0 the capabilities ride on the first reference line: side-band-64k, ofs-delta, no-progress, include-tag, shallow, deepen-since, deepen-not, deepen-relative, filter if offered, object-format=…, agent=….

multi_ack and multi_ack_detailed are deliberately absent. Gunnar negotiates in single-ACK mode, which every git since 2005 still speaks, and advertising a richer ACK protocol than the one implemented is the same class of bug the capability table exists to prevent.

The protocol version is the client's to choose

A v2 client announces itself by sending GIT_PROTOCOL=version=2 as an SSH environment request. gunnar-ssh accepts that one variable and only that one: gunnar_ssh::server::ENV_ALLOWLIST holds exactly GIT_PROTOCOL; LD_PRELOAD, PATH, GIT_DIR and everything else is refused, and nothing on the allowlist is ever executed or exported. The raw value rides on ServiceRequest::git_protocol and is turned into a ProtocolVersion by gunnar-wire's own parser, so there is one implementation of the grammar rather than two.

A client that sends nothing gets v0. That path is not vestigial: it is what an old client, or one behind a transport that drops the variable, actually gets, and gunnar-cli/tests/ssh_end_to_end.rs exercises it explicitly rather than assuming it.

What this unblocked. While every session was v0, gunnar clone could not clone from gunnar serve at all:

gunnar clone ssh://git@127.0.0.1:2299/team/project.git
  → Could not decode server reply: Currently we require feature
    "multi_ack_detailed", which is not supported by the server

gunnar-client fetches through nornir-git → gix's blocking transport, which demands multi_ack_detailed in v0. Gunnar does not advertise it, on the correct grounds that it does not implement it. In v2 the question does not arise, and gix asks for v2 by itself.

receive-pack is not given the negotiated version, and that is not an oversight. Protocol v2 has no push side. git sends GIT_PROTOCOL on a push too and then speaks v0 anyway, so passing it on would be inventing a protocol neither end implements.

object-format must be echoed from the repository's actual hash kind

Answering sha1 to a sha256 repository yields an empty response with no error. This exact bug is documented in nornir-git/src/ssh.rs and cost a working session to find. The hash kind comes from ObjectStore::hash_kind(), never from a default; a mismatch is rejected loudly, and the interop suite runs against a sha256 fixture rather than inferring the path from a green sha1 run.

Negotiation is where a git server is actually hard

The classic failure is a have/want loop that terminates on the happy path and never terminates on a divergent history. It is tested against a repository with real merge history, not against a three-commit fixture, because a three-commit fixture cannot produce the shape that breaks it.

Once __gunnar_graph__ exists, ancestry tests during negotiation become integer comparisons on generation numbers rather than object walks. Once __gunnar_reach__ exists, want − have becomes a bitmap ANDNOT rather than a traversal. Neither is a prerequisite for a correct negotiator, and neither is claimed to be faster until it is measured. See storage-reasoning.md.

git-receive-pack — serving push, protocol v0

v0 is the only protocol push has.

  1. Advertise refs and capabilities: report-status, report-status-v2, delete-refs, side-band-64k, quiet, atomic, ofs-delta, push-options, object-format=…, agent=….
  2. Read the command list, <old-oid> <new-oid> <refname> per line, then flush.
  3. Read the push-options section, if the capability was negotiated.
  4. Read the packfile, index it, and verify connectivity. Every ref's new tip and its full closure must exist afterwards. A thin pack's bases resolve against the existing object store.
  5. Run the policy gate. A rejection rejects the ref; with atomic, it rejects the whole push. See policy-design.md.
  6. Apply the ref transaction through RefStore::transaction. With atomic, all or nothing.
  7. Report status per ref, then emit the event and the push attestation.

Steps 4 and 5 sit between "the bytes arrived" and "the refs moved", which is the only point at which a server can still say no. That ordering is why Gunnar is a forge rather than a bare repository over SSH.

It also bounds what the gate can promise. By step 5 the bytes have already been received, so a size gate rejects before refs are applied, not before the upload. Refusing an oversized push before receiving it needs a streaming ceiling in gunnar-pack, not a gate.

send-pack, the client's push

gix cannot push. In gix 0.86 gix-protocol carries fetch/, ls_refs.rs and handshake/, and there is no send-pack module; Direction::Push exists only as a refspec direction.

So gunnar-client implements send-pack itself. It costs almost nothing extra, because send-pack is receive-pack seen from the other end: the same pkt-line framing and the pack writer the server already needs for upload-pack.

The conversation, exactly:

S→C   <old-oid> <refname>\0<capabilities>\n     ref advertisement, protocol v0
S→C   <old-oid> <refname>\n                     one line per ref
S→C   0000
C→S   <old> <new> <refname>\0<capabilities>\n   the command list
C→S   <old> <new> <refname>\n                   one line per ref being moved
C→S   0000
C→S   <push-option>\n … 0000                    only if `push-options` was negotiated
C→S   PACK…                                     raw, NOT pkt-line framed
S→C   unpack ok\n                               report-status
S→C   ok refs/heads/main\n | ng refs/heads/x <why>\n
S→C   0000

Three details are where implementations go wrong, and each has a test:

  1. The packfile is not framed. It follows the flush-pkt as raw bytes. Framing it is a corrupt push with a checksum error and no clue pointing at the framing layer.
  2. An all-deletes push sends no pack at all. Sending an empty pack is also legal, but sending one when delete-refs was never advertised is not, and neither is sending a pack for a push that has no new objects.
  3. With side-band-64k the report is muxed inside band 1, pkt-lines nested inside pkt-lines, and hook output arrives on band 2. Reading the report without demuxing yields a leading \x01 on every line and a parse failure that looks like a protocol error.

✔ The push grammar was written twice. It is now written once — gunnar-sendpack

Specification §2 said the client's push reuses "the command-list encoding in gunnar-wire". It did not. gunnar-client depends on gunnar-pktline and gunnar-pack and not on gunnar-wire, so gunnar-wire's receive_pack parsed the grammar and gunnar-client's send_pack wrote it, independently — and it was four formats, not one: the capability list, the reference advertisement, the command list and the status report.

That is the twinning LAW 5 forbids, and the resolution was not to fold send-pack into gunnar-wire. D21 (§2.1) makes it a donation-shaped crate: zero Gunnar dependencies, gix-* plumbing only, gix's own conventions, because gitoxide has no push and extraction after the fact never happens.

Landed 2026-08-05 as crates/gunnar-sendpack. One module per format, each with an encoder and a decoder, so there is nothing left for the two ends to drift from:

format client calls server calls
capabilities Capabilities::parse, attach attach, split, Capabilities::parse
advertisement parse lines
command lines parse_line
report parse lines

Every lines returns payloads — no pkt-line frame, no flush-pkt, no trailing newline, because git frames these as text pkt-lines and the newline is the framer's. That is what lets gunnar-wire keep gunnar-pktline as its framer and gunnar-client use gix-packetline as its own, with one grammar under both. The framing half is the blocking-io feature, off in the workspace default, so the appliance graph gains one first-party crate and zero third-party ones and gix-packetline never enters it.

Two things fell out that were not the goal. Reference names are now BString end-to-end: refs/heads/\xff and refs/heads/\xfe are indistinguishable after String::from_utf8_lossy, and the client had been carrying them as String. And a hash-kind mismatch is a structured Error::ObjectFormat rather than a formatted message the server has to parse back apart.

gunnar-pack

Writing, for serving. Walk from want, subtract the closure of have, emit PACK plus version plus count, then per object a type-and-size varint header and the zlib data, then the trailer hash.

Output is non-delta, deliberately. Deltas are optional in the pack format; a pack of whole objects is a valid pack that every git implementation reads. ofs-delta and ref-delta encoding is not implemented. Measure first. The cold tier's own chunk compression covers part of what deltas buy, so whether git-style deltas are needed at all is a measurement, not an assumption. ofs-delta is still advertised because it describes what Gunnar accepts, and Gunnar does accept it.

Reading, for receiving. gix_pack::Bundle::write_to_directory with thin_pack_base_object_lookup wired to the ObjectStore, so a thin pack whose deltas reference bases that only exist in the receiving store resolves against what the repository already holds.

Empty packs are legal and must not crash either direction.

Verification

Pack output is verified by handing the produced pack to gix_pack's own indexer and asserting the object count and a byte-exact readback of a known blob. It is never verified by Gunnar reading back its own bytes — a writer and a reader that share a misunderstanding agree with each other perfectly.

Above that, interoperability is asserted against the real git binary acting as a client. Gunnar never runs git; its tests do. A test in which Gunnar's client agrees with Gunnar's server proves nothing, because both halves can be wrong in the same way.

Pinned specifically:

Not here

multi_ack_detailed and replication are out of scope for v1 (moved here from the front page 2026-08-05). Partial clone is not, and the sentence that used to say so was wrong: blob:none, blob:limit=<n> and tree:<depth> all ship, advertised from the table the parser reads, and all three are driven by real git clone --filter= in crates/gunnar-wire/tests/real_git_surface.rs. sparse:oid, object:type and combine: are the ones out of scope, and each is refused by name rather than ignored. See protocol-capability-audit.md for the full three-way audit against stock git 2.53.0 — including why object-info is measured not worth building (no stock git client has ever sent it).

Smart-HTTP, dumb-HTTP and git:// are not implemented and are not planned. LFS over the pure-SSH git-lfs-transfer protocol is v2, not v1; the only demand it places on v1 is that the exec dispatch in gunnar-ssh is written so that a third command is one match arm rather than a refactor.

Source map

See also: design.md · storage-reasoning.md · policy-design.md · TOTAL-PROMPT.md §6.