The rider-3 sessions ran real git 2.43.0 against the landed machine
over both substrate doors for the first time:
- tests/real_client.rs — the real-client sessions: the
git-daemon-framed duplex door (POC-1's tcp bridge shape, the frame
git-upload-pack <repo>\0host=…\0\0version=2\0) and the POC-3-shape
smart-http stateless door; a real clone, a configured
include-tag-sending fetch, and a transfer.advertiseSid=true client,
per door, with packet-trace assertions. Rows skip quietly when the
git binary is absent.
- Decision (flagged for the wave-5 gate): include-tag accepted and
honored by omission — the token is un-gated in the real V2 client's
send path (transport.c v2.43.0: args.include_tag = followtags;
builtin/fetch.c sets it for every configured fetch) and V2's fetch=
feature list has no slot to advertise or retract it, so the
pre-decision decline demonstrably broke a real configured fetch
(fatal: remote error: fetch: unexpected argument "include-tag");
the pack is the want closure as generated, identical with and
without the token.
- Decision (flagged for the wave-5 gate): session-id= stays declined —
the honest retraction. git's V2 client sends the pre-line only when
the server advertises the session-id capability (fetch-pack.c:
advertise_sid && server_supports_v2("session-id"); captured live) —
the decline is unreachable by a conforming client and accepting it
would be advertising what we don't serve.
- src/fetch.rs — the accepted set grew by one (include_tag, the
FetchArgs field + parse row); the session-id pre-line row pins the
fail-closed decline; the advertisement is unchanged and honest.
- tests/fetch_machines.rs — the accepted-set row + the identical-pack
honor-by-omission row.
- docs/research/git-protocol.md — the fetch-argument inventory
corrected in place: grammar vs traffic (include-tag rides configured
fetches only; the capability pre-lines are exactly agent +
object-format).
- tasks/architecture/oq-19-wire-validations.md — the rider-3 section
records both outcomes with bases.
Verification: cargo test (13 suites), -p alkgit,
-p alkgit-substrate --all-features, --all-features, both clippies
(-D warnings), fmt --check, cargo doc --no-deps, taskgraph validate
— all green; the real-client rows 3× repeat-stable.
9.2 KiB
Research: git smart protocol (server side) — what we must implement
Status: initial pass complete
Date: 2026-09-19
Sources: gitoxide source reading (client-side parsers as reference),
git http-protocol + packfile protocol specs (git-scm.com protocol docs),
observation of gitserver's shape as a sanity check that the surface is this
small (no code reused — MPL-2.0 incompatible with our MIT/Apache-2.0; see
license-note.md).
TL;DR
The server-side surface is well-bounded: capability advertisement (V0/V1 and
V2 flavors), ls-refs, fetch negotiation (wants/haves → acks → pack),
receive-pack (pack ingestion + ref update CAS + status report), plus the
stateless HTTP framing and the SSH exec framing around them. gitoxide gives
us every primitive (pkt-line codec, pack parse/write, ref transactions,
fsck); what's missing is only the orchestration, which is ours to write —
and that's the layer where our auth/ACL model lives, which is exactly where
we want to own code.
Protocol surface inventory
V2 (the target; what modern git speaks first)
- Client sends
GIT_PROTOCOL=version=2(SSH: env line / http: header). - Server replies with capability advertisement:
version 2, then capabilities as pkt-lines:agent=...,ls-refs=...,fetch=...(shallow, filter, sideband-64k, packfile-uris...),object-format=sha1. Observed (POC-1): the advertisement is sent once per session; commands follow without re-advertisement. command=ls-refswith args (peel, symrefs, ref-prefix) → server streams ref lines then flush.command=fetchwith args (want lines, have lines, done, thin-pack, no-progress, include-tag, shallow/deepen...) → server sendsacknowledgmentssection (acked ids orNAK), then eitherready+packfilesection (pack streamed over sideband) if done, or waits for more haves. Observed (POC-1, git 2.43): on thedonepath the response is justpackfile\n+ sideband chunks + flush — noacknowledgments, noready. The acknowledgments section appears on multi-round negotiation (nodone), not on the done path.
git:// framing detail (observed, POC-1)
First pkt-line: git-upload-pack <repo>\0host=<host>\0\0version=2\0 —
service + repo, NUL-separated extras, an empty segment, then
version=2. The repo name arrives in-band before any ref data; a server
must resolve it against the registry (and run ACL) before emitting the
advertisement.
V0/V1 (fallback for old clients / http stateless)
- First response line:
"<capabilities> <null-octet> <ref> <obj-id>"style advertisement with refs, then fetch loop: wants → haves (NAK/ACK) → pack over sideband. Stateless-http variant requires the client to POST a separate request for each round;ackstate must be re-derived or the multi-round negotiation declined (we can require V2 for http and keep V0/V1 for ssh only — OQ candidate).
receive-pack (push; mostly version-independent)
- Client sends update requests (
<old> <new> <ref>) + optional shallow lines- pack stream (possibly thin).
- Server: validate CAS per ref (old must match current unless zero-id create),
fsck/connectivity the pack, apply ref transaction atomically, reply with
unpack <ok|ng>+ per-refok|ng <ref> <reason>lines.
Framing per interface
- SSH: single exec request
git-upload-pack '<path>'/git-receive-pack '<path>'/git-upload-archive; pkt-line on stdin/stdout; V2 via env var; sideband on fetch. - HTTP:
GET /info/refs?service=<name>(advertisement in# service=git-upload-packpreamble for smart clients),POST /<name>with pkt-line body; content-typeapplication/x-git-{upload,receive}-pack-*; chunked streaming both ways;Cache-Control: no-cache.
Server-side pack generation — the crux
gitserver (MPL-2.0, read-only reference) demonstrates the pragmatic path:
compute the pack from wants/haves and hand it to the response writer — they
use gix + hand-rolled protocol_v2 (742 LOC) and receive_pack (549 LOC) for
the whole protocol surface. We cannot copy that code, but its size confirms
the surface is small enough to own outright with gitoxide primitives:
- Pack generation options to POC:
a.
gix-pack bundle::writewith an in-memory sink (verify it can stream, not just write files). b.gix-pack::data::output::bytesentries-to-bytes writer fed by an object walk overgix-odb(full control over want/have closure; no file intermediates). Resolved (POC-2,poc2-findings.md): (b) wins — it is gitoxide's own generation pipeline (count::objects→entry::iter_from_counts→bytes::FromEntriesIter, as composed bygix pack create) and streams to anyio::Writewith O(counts) memory. (a) is not a generation tool: it indexes an existing pack stream (receive-pack's job) and always writes a temp file (it mmaps the pack to resolve deltas for the index). - The closure is two stages: commit ancestry
(
gix_traverse::commit::Simple,Parents::All, tips peeled to commits) feedingObjectExpansion::TreeContents(which expands each commit's own tree but does not follow parents). Wants alone throughTreeContentsproduce an incomplete pack thatgit index-pack --strictrejects. - odb handle prerequisites for generation:
prevent_pack_unload()+ignore_replacements = true(asserts/corruption otherwise).gix_odb::Cacheis notSync(per-threadRefCellcaches): share theArc<Store>, build a handle per session, generate on blocking threads. - Delta behavior: existing pack deltas are copied through
(
from_pack_entry); loose objects are written as compressed bases. No delta synthesis exists upstream (gix-deltais apply-only) — an optimization backlog item vsgit upload-pack, not a correctness gap. - Missing objects during generation do not abort by default (they become
skipped "invalid" entries); the server must check entry statistics
(
missing_objects) and abort the fetch with a sideband error band instead of emitting a broken pack. - Thin packs (delta against client haves) are an optimization — v0 can
declare
thin-packunsupported initially; V2 fetch arg parsing must still accept/decline it gracefully. filter(partial clone) andpackfile-uriscan be declined in v1 of alkgit; advertised capabilities must be honest (never advertise what we don't serve — the gitea-class bug pattern is promising something and enforcing elsewhere).- Fetch-argument inventory, capture-corrected (OQ-19 rider 3,
core/w5-oq19-real-client-tokens, real git 2.43.0 against the landed machine — seeoq-19-wire-validations.md§3 for the outcomes): the docs-grammar list above ("include-tag" among the common args) is the grammar, not the traffic. Captured live: the capability pre-lines a real client sends oncommand=fetchare exactlyagent=+object-format=(session-id=only when the server advertises thesession-idcapability,transfer.advertiseSidconfig; none ofshallow/filter/server-option— git's client gates all three on the advertisement'sfetch=features), and the argument grammar isthin-pack/no-progress/ofs-delta+ wants/haves/done — plusinclude-tagon configured fetches (transport.cv2.43.0:args.include_tag = data->options.followtags;builtin/fetch.csets follow-tags for every fetched tracking refspec, and the V2 send path does not gate the token on the advertisement — there is nofetch=feature slot for it, so no honest-retraction option exists in V2's grammar: a server's only non-breaking postures are accept-as-no-op or breaking real clients; alkgit accepts and honors by omission).
Negotiation policy (ours to define)
Minimal correct initial policy:
- V2: respond with full
acknowledgments(common ids) each round; send pack ondone. - Enforce max rounds/haves budget per session (DoS bound), max pack size for receive, wall-clock limits for long negotiations.
- Shallow (` deepen/shallow lines) is a v2-later concern; reject with a clear pkt-line error initially (honest capability advertisement again).
Security-relevant protocol notes
git-upload-archiveis a separate command — do not serve it initially.- Path traversal: repo names on the wire (
git-upload-pack '~/x'or../) must normalize to a registry lookup — never to a filesystem path from the wire. Repo IDs resolve to storage roots configured server-side. - receive-pack ref names: validate against git ref rules (no
.., no control chars, norefs/heads/foo.lockgames) —gix-refname parsing plus our own deny-list for e.g.refs/reserved namespaces. - The advertisement phase must run ACL before emitting a single ref line (ref names leak info; private repos must not advertise anything).
Open items → architecture OQs
- OQ: support V0/V1 at all on http (or V2-only http, V0/V1 ssh)?
- OQ: shallow clone support timeline.
- OQ: thin-pack on fetch (requires delta against client have set).
- Resolved: pack streaming composition (POC-2 decided: entries-to-bytes
pipeline; see
poc2-findings.md). - OQ: delta synthesis for loose-object serving (optimization backlog).
- OQ:
object-format=sha256support policy (feature flag exists; no real ecosystem need yet — default sha1, keep flag).