Files
alkgit/docs/architecture/decisions/013-receive-pack-state-machine.md
T
glm-5.3-flash 11ceead6fd docs(architecture): N-4 + N-5 — push-options trait param, unborn-HEAD rider
- N-4: GitPackIngest's prepare binding carries push_options:
  Option<&PushOptions> (parsed (key, value) pairs, verbatim and
  un-interpreted; None until the config gate opens) — pinned in
  ADR-013 §11 and backend.md's trait description so opening the
  config gate later is value-additive, not a trait redesign
- N-5: ls-refs=unborn verification recorded as a rider in
  transport.md §ls-refs + tracker task tasks/architecture/
  n5-unborn-head-rider.md (unborn fixture, real client, both
  substrates; drop the token if it cannot be served — ADR-003)
- review 001: N-4, N-5 marked resolved

verification: cargo test, clippy -D warnings, fmt --check, doc — clean
2026-09-30 04:19:37 +00:00

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.

  1. Version surface: receive-pack has no version negotiation at all — git push never speaks V2 on the push path (observed: protocol.v2 clients push with the identical V0/V1 shape; no version=2 exchange 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.

  2. 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. The capabilities^{} 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-options under a config gate). quiet is 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-v2 is 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.
  3. 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-refs unpacker error otherwise). An empty pack (zero objects) is valid and reported as such.
  4. Shallow on push: rejected for v1 — confirming the OQ's expected resolution. git push from a shallow clone sends shallow <sha> lines before the commands. Real receive-pack accepts them when the pack completes history and rejects with ng <ref> shallow update not allowed otherwise. 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, and receive.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).

  5. Thin packs: accepted, bases from the server odb. git push sends thin packs by default (push.thin=true, no capability involved): captured packs exclude objects the client believes the server has and fail git index-pack --strict standalone; --fix-thin completes them. Ingestion therefore passes the server's own gix_object::Find handle as thin_pack_base_object_lookup to Bundle::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.

  6. Ingestion composition (ADR-004's tool choice, now bound to the push machine):

    • Stream the pack body into Bundle::write_to_directory_eagerly (= data::input streaming-input with LookupRefDeltaObjectsIter when bases resolve, EntryDataMode::KeepAndCrc32, Mode::Verify), directory = the repo's pack dir: the outcome is a written pack + index with a .keep file 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_blocking pool, 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::Connectivity over 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 by GitRefs (single CAS home, backend.md unchanged).
  7. 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 uses gix-ref transactions (Change::Update/Delete with PreviousValue::MustExistAndMatch / MustNotExist) — one transaction for the whole push, which is what makes atomic correct. 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 (the delete-refs capability 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's denyNonFastForwards-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.

  8. 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-ref ok <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 is unpack ok even when some refs fail; pack-level failure is unpack ng <reason> + all-refs unpacker 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 uses stale 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 — observed fatal: protocol error: bad line length character), so handlers cannot get it wrong. quiet suppresses progress (which we never send), not errors — band-2 diagnostics are always permitted.

  9. Ref-name validation (per-ref, after unpack): gix_validate:: reference::name plus 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 following git check-ref-format rules, not an architecture variable).

  10. Atomic: when atomic was negotiated and any ref fails, every other ref reports ng <ref> atomic push failure (observed live). Implemented by preparing the whole gix-ref transaction and committing it once; per-ref results map from the transaction outcome.

  11. Push-options: negotiated (push-options advertised 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: the prepared binding carries push_options: Option<&PushOptions> — the parsed (key, value) pair list, verbatim and un-interpreted — present only when push-options was negotiated (None until the config gate opens; review 001 N-4's additive-signature pin, so opening the gate later is a value change, not a trait redesign). Rejection of specific options is ng <ref> <reason>; the section's absence when not negotiated must not be parsed as commands (the flush boundary is authoritative).

  12. 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 0000 probe POST first, answered 200-empty. The POST Content-Type is application/x-git-receive-pack-request; the response is application/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); atomic falls 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-options default-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-pack Bundle::write_to_directory[_eagerly] (thin_pack_base_object_lookup, .keep handling), data::input (LookupRefDeltaObjectsIter, Mode::Verify, EntryDataMode), gix-ref transactions (PreviousValue::MustExistAndMatch), gix-fsck (Connectivity), gix-validate::reference::name
  • transport.md §receive-pack, backend.md §"The trait family" (GitPackIngest, GitRefs), doors.md