- 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
238 lines
13 KiB
Markdown
238 lines
13 KiB
Markdown
# 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 |