Resolves review 001 findings A-2 (critical) and A-6 (major) — the same signature surface: - ADR-012 §1: registry traits amended to #[async_trait] (bare async fn in traits is not dyn-compatible, E0038; ops sit behind Arc<dyn GitRegistryStore>). Desugared boxed Future form pinned in OQ-03's freeze inventory. async-trait = "0.1" added to the manifest. - backend.md concurrency model: the five-trait family is #[async_trait] Send + Sync dyn-compatible; the wire layer enforces ADR-009's pipeline-concurrency budget itself (permit acquired around each GitPackGen/GitPackIngest call — the concrete admission point); impls must not block the async executor and own their internal threading (gix impls run spawn_blocking inside the impl — POC-2's shape restated at its true layer). - transport.md §fetch: spawn_blocking sentence rephrased to the trait-contract version (the wire spec stops speaking gix). - ADR-009: enforcement point of the blocking-pool budget made concrete. - ADR-013 §6: ingestion spawn_blocking line aligned. - review 001: A-2, A-6 marked resolved. Verification: cargo test, clippy -D warnings, fmt --check, doc --no-deps, check --no-default-features, check --all-features — all clean.
13 KiB
ADR-013: receive-pack (push) state machine
Status
Accepted (resolves OQ-04)
Context
OQ-04 (deferred(unclear)) held the receive-pack validation gap: the
pieces were decided (ADR-004 ingestion via gix-pack::data::input with
streaming-input; gix-ref transaction CAS; gix-fsck connectivity;
POC-3: request bodies stream) but the push state machine's shape was not.
The listed unknowns: capability advertisement set under the
honest-advertisement invariant, request-line parsing + shallow policy,
thin-pack acceptance, ingestion composition, CAS timing, status-report
shape, and the http framing surface (ADR-003's V2 decision covers fetch
only).
Resolution method: a walkthrough against real git push captures
(git 2.43.0 over file://, git:// daemon, and smart-http against a
ground-truth mock receive-pack), raw stdio requests driven into real
git receive-pack for policy-error ground truth, and gitoxide source
verification of the ingestion composition. Captures are recorded in
docs/research/push-captures.md.
Decision
Receive-pack is V0-framed, by upstream design, and we serve it as such.
-
Version surface: receive-pack has no version negotiation at all —
git pushnever speaks V2 on the push path (observed:protocol.v2clients push with the identical V0/V1 shape; noversion=2exchange exists). ADR-003's V2-first decision therefore governs fetch only; the push side is V0-framed on every door. This is upstream's shape, not a choice of ours, and it is wire-stable by the same token. -
Capability advertisement (per-door, honest per ADR-003):
- Smart-http GET:
# service=git-receive-pack+ flush, then a V0-shaped ref advertisement — capabilities ride NUL-attached on the FIRST ref line; remaining ref lines are bare; flush. Thecapabilities^{}zero-id sentinel replaces the ref lines only when the repo has no refs (git rejects a bare capability dump, and rejects a trailing sentinel when refs exist). - Duplex: the same ref advertisement without the service prefix; the server speaks first.
- The served set is
report-status report-status-v2 delete-refs side-band-64k atomic ofs-delta object-format=sha1(+push-optionsunder a config gate).quietis not advertised (it is a client-side preference whose only server duty is suppressing band-2 progress chatter — we never send progress).agent=alkgit/<version>is advertised (same convention upstream uses; value is the crate version).shallow/allow-tip-sha1-in-want-style fetch caps do not appear here. - Everything we do not serve (e.g.
push-cert) is declined by omission. report-status-v2is served: the v1 report shape (no option lines) IS valid v2 — option lines exist only when the server emits them, and v1 emits none (push-options are metadata-only in v1). Clients selecting v2 parse the same shape.- ACL runs before the first ref line (ADR-007 unchanged): private repos advertise nothing.
- Smart-http GET:
-
Request parsing (one POST / one stream):
command lines (<old> <new> <ref>, caps NUL-attached on line 1) [shallow lines] → v1: rejected up front with a pkt-line error (see 4) flush [push-options section: bare pkt-lines + flush] (only if negotiated) PACK stream (raw, to end of body / stream)- Deletes:
old=<current> new=zero-id; creates:old=zero-id. - An immediate flush (no commands) is a client-side nothing-to-do — reply flush, no report.
- A pack is ALWAYS expected after the command flush (real receive-pack
errors with
unpack eof …+ all-refsunpacker errorotherwise). An empty pack (zero objects) is valid and reported as such.
- Deletes:
-
Shallow on push: rejected for v1 — confirming the OQ's expected resolution.
git pushfrom a shallow clone sendsshallow <sha>lines before the commands. Real receive-pack accepts them when the pack completes history and rejects withng <ref> shallow update not allowedotherwise. We serve neither: shallow request lines are rejected up front with a clear pkt-line error (mirroring fetch's decline-by-omission in ADR-003, keeping depth semantics symmetric). Declining up front is cheaper than accepting packs we cannot fsck: a shallow push's pack terminates on grafted boundaries that our connectivity check cannot close, andreceive.shallowUpdateDeepen-style semantics are a scope decision for a later ADR, not a v1 need. This is a two-way door: adding shallow support later is additive (the shallow-request capability rides upstream's existing grammar when implemented). -
Thin packs: accepted, bases from the server odb.
git pushsends thin packs by default (push.thin=true, no capability involved): captured packs exclude objects the client believes the server has and failgit index-pack --strictstandalone;--fix-thincompletes them. Ingestion therefore passes the server's owngix_object::Findhandle asthin_pack_base_object_lookuptoBundle::write_to_directory(the upstream parameter exists for exactly this); the no-lookup composition (Option<Never>— a lookup that can never resolve, valid only for packs known self-contained, per poc2-findings) is not used on the push path. Thin-pack base availability is enforced by the lookup: a base the server lacks fails ingestion (→unpack ng), which is the correct honest outcome. -
Ingestion composition (ADR-004's tool choice, now bound to the push machine):
- Stream the pack body into
Bundle::write_to_directory_eagerly(=data::inputstreaming-inputwithLookupRefDeltaObjectsIterwhen bases resolve,EntryDataMode::KeepAndCrc32,Mode::Verify),directory= the repo's pack dir: the outcome is a written pack + index with a.keepfile created before the pack lands (gc-safe against the not-yet-applied refs) — gitoxide's own receive shape. - Budgets (ADR-009): max pack size counts the streamed body
(413-mapped on http); wall clock and the blocking-pipeline
concurrency budget apply (the generation budget covers ingestion —
same
spawn_blockingpool, amended in ADR-009's table). - Ingestion runs on
spawn_blocking(POC-2's shape: store shared, handle per session — inside the gix impl; the wire layer's pipeline-concurrency permit wraps the trait call per ADR-009, backend.md concurrency model). - fsck/connectivity:
gix-fsck::Connectivityover the odb handle after indexing, for each new tip reachable from the pushed refs; missing objects →unpack ng <reason>. - The ingest trait (
GitPackIngest) prepares validated updates; the ref transaction itself is applied byGitRefs(single CAS home, backend.md unchanged).
- Stream the pack body into
-
CAS timing: unpack-first, then per-ref checks (the observed upstream order, adopted): parse commands → read + index + fsck the pack → per-ref validation (name, CAS, policy) → atomic rollback if selected → report. Evidence: real receive-pack reports
unpack ok+ng <ref> funny refname/deletion prohibited/ stale-old ng only AFTER unpack; a missing pack fails everything at unpack. A pre-unpack CAS pre-check is permitted as a fail-fast optimization (reject before reading the body), but the protocol-correct order is as observed and is what v1 implements. Per-ref CAS usesgix-reftransactions (Change::Update/DeletewithPreviousValue::MustExistAndMatch/MustNotExist) — one transaction for the whole push, which is what makesatomiccorrect. The v1 update policy (the "policy" leg of the per-ref check): CAS on old-value per the request (zero-id create, current-id update/delete) and nothing more — deletes are allowed (thedelete-refscapability is advertised) and non-fast-forward updates are allowed (force-push is the client's explicit choice via the advertised old value; the CAS contract is exact-match, and upstream'sdenyNonFastForwards-style history checks are a deployment concern, expressible later as config in the assembly layer without a trait change — the reasons observed above come from upstream's config-dependent policies, not protocol requirements). A reason alkgit emits itself (funny refname,shallow update not allowed,atomic push failure, CAS failures) needs no config. -
Status report: when the client selected
side-band-64k, the report is band-1 chunks whose payload is ITSELF pkt-line-framed:unpack ok|ng <reason>, per-refok <ref>/ng <ref> <reason>, an INNER flush terminating the report section, then the OUTER flush. Without sideband: bare pkt-lines to one flush. The report isunpack okeven when some refs fail; pack-level failure isunpack ng <reason>+ all-refsunpacker error. Observed per-ref reasons from real receive-pack are forwarded verbatim (shallow update not allowed,deletion prohibited,non-fast-forward,atomic push failure,funny refname); CAS-stale failures carry a server-chosen reason (upstream usesstale info-style text internally; ours is free to pick — clients display it verbatim). Human-readable diagnostics ride band-2. The substrate owns the framing (a report sent unwrapped when sideband was selected aborts real clients — observedfatal: protocol error: bad line length character), so handlers cannot get it wrong.quietsuppresses progress (which we never send), not errors — band-2 diagnostics are always permitted. -
Ref-name validation (per-ref, after unpack):
gix_validate:: reference::nameplus alkgit's deny-list — the git-protocol.md security note, now pinned to the exact validation point and reason string (funny refname). The deny-list is a small constant in the transport layer (git-refname-invalid namespaces: names ending in.lock,refs/reserved prefixes used by our own metadata — the list lives with the validation code; exact contents are an implementation-time detail followinggit check-ref-formatrules, not an architecture variable). -
Atomic: when
atomicwas negotiated and any ref fails, every other ref reportsng <ref> atomic push failure(observed live). Implemented by preparing the wholegix-reftransaction and committing it once; per-ref results map from the transaction outcome. -
Push-options: negotiated (
push-optionsadvertised only when the assembler enables it; default off in v1), request section parsed (bare pkt-lines between the command flush and the pack) and surfaced to the ingest/refs seam as per-push metadata. Rejection of specific options isng <ref> <reason>; the section's absence when not negotiated must not be parsed as commands (the flush boundary is authoritative). -
Http framing for push (POC-3 facts, now extended): small pushes arrive Content-Length; large pushes arrive chunked, and the client may send a 4-byte
0000probe POST first, answered 200-empty. The POST Content-Type isapplication/x-git-receive-pack-request; the response isapplication/x-git-receive-pack-result(sideband-wrapped report when negotiated), ending at a flush — the stateless substrate (ADR-005) owns these rules unchanged.
Consequences
- Positive: every wire shape in the push path is POC/capture-grounded
(not grammar-inferred); the ingestion composition binds ADR-004's tools
to concrete calls, with thin-pack handling first-class upstream
(
.keep-guarded pack landing, base lookup);atomicfalls out of the single-transaction commit; the honest-advertisement invariant holds with a small served set; OQ-04's seven unknowns all have decisions. - Negative: shallow pushes are rejected (clients see a clear error,
not a silent decline) — a real capability gap vs
git receive-pack, accepted for v1 symmetry with fetch's shallow decline.push-optionsdefault-off adds a config surface (additive, two-way). - Neutral: the V0 framing of push coexists with the V2 fetch path in
the same session layer; the state machines differ per command, which
the substrate already models. CAS-before-unpack fail-fast remains an
optimization backlog item (tracked:
tasks/architecture/oq-13-cas-failfast.md).
References
docs/research/push-captures.md(the captures this decision is built on)docs/research/poc3-findings.md(request streaming, http framing facts)- ADR-003 (V2-first — fetch only; honest advertisement everywhere), ADR-004 (pack pipeline: ingestion tools), ADR-005 (substrate owns framing), ADR-007 (ACL before advertisement), ADR-009 (budgets)
- gitoxide:
gix-packBundle::write_to_directory[_eagerly](thin_pack_base_object_lookup,.keephandling),data::input(LookupRefDeltaObjectsIter,Mode::Verify,EntryDataMode),gix-reftransactions (PreviousValue::MustExistAndMatch),gix-fsck(Connectivity),gix-validate::reference::name - transport.md §receive-pack, backend.md §"The trait family" (GitPackIngest, GitRefs), doors.md