Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

crates/gunnar-wire/src/receive_pack.rs

//! `git-receive-pack`, i.e. serving push. Protocol v0, which is the only
//! protocol push has ever had.
//!
//! # The order that matters
//!
//! ```text
//! advertise refs+caps → command list → push-options → PACK → index →
//! connectivity → policy gate → ref transaction → report
//! ```
//!
//! The two steps in the middle are the ones that separate a forge from a
//! directory of files:
//!
//! * **Connectivity is verified before any reference moves.** A thin pack's
//!   deltas resolve against objects the receiver already had, so "the pack
//!   indexed cleanly" does not mean "the new tip is complete". A reference
//!   pointing at an incomplete commit is a repository that every subsequent
//!   clone fails on, and nothing in the push itself reports it.
//! * **`atomic` is all-or-nothing at the storage layer**, not a loop that stops
//!   early. It is one `GitOps::put_refs_cas`, which is where the batch and the
//!   compare-and-swap are the *same* call; a per-reference loop with a break
//!   leaves the references it already applied applied.
//!
//!   That method is why `--atomic` needed a contract change rather than a
//!   rearrangement here (`locked_on_target.md`'s `GitServe` reading contract): `put_refs` is a batch
//!   **without** CAS — `RefUpdate` has no expected-old field — and
//!   `update_ref` is CAS **without** a batch, and git defines `--atomic` as
//!   both at once. Neither composes into the other, so the non-atomic path
//!   below uses `update_ref` (one reference, still compare-and-swap) and the
//!   atomic path uses `put_refs_cas`.
//!
//! # Where the packfile goes
//!
//! Not decided here. `gunnar-wire` must work with no filesystem, and
//! "keep the pushed pack" is inherently a storage decision: an appliance folds
//! it into a znippy archive, a normal-OS server drops it in `objects/pack`. So
//! the caller supplies a [`PackIngest`], and this module's only requirement is
//! the one it can check: **after `ingest` returns, the objects must be readable
//! from the store.** The connectivity walk proves it — and it needs them
//! *decoded*, which is why the handle is a `GitServe` and not a `GitOps`.

use std::io::{BufRead, Write};

use crate::pack::IndexedPack;
use gix_hash::ObjectId;
use gunnar_pktline::{Packet, BAND_PACK};
use gunnar_store::git_storage_trait::{GitServe, Observed, RefCas, RefRejection, RefTarget};

use crate::caps::{self, PeerCapabilities};
use crate::graph::{verify_connectivity, Frontier, Graph};
use crate::refs::RefSnapshot;
use crate::wire_io::{self, read_packet, read_section};
use crate::{Error, Result, Session};

/// The tracing target every line in this module carries, so a push is
/// filterable with the same `RUST_LOG=gunnar::git=info` that selects the
/// server's own transfer log.
///
/// # Why the string and not `gunnar_core::observe::TARGET_GIT`
///
/// That is where it is *named*, and this is a second spelling of it, which one
/// implementation is normally against. The edge that would remove the second
/// spelling runs the wrong way: `gunnar-core` carries the identity table, the
/// ACL and the path sandbox, and this crate is bound by the no-OS rule and the
/// crate layout to plumbing —
/// pulling it in to reach one `&str` would put all of it in the appliance's
/// crate budget.
///
/// So the two spellings are held together by a guard where both crates are
/// already linked (`gunnar-cli`'s `observability` test), which costs nothing
/// and cannot be forgotten by a build that does not link them.
pub const GIT_TARGET: &str = "gunnar::git";

/// One `<old> <new> <ref>` line from the client.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RefCommand {
    /// What the client believes the reference currently holds; null for a
    /// create.
    pub old: ObjectId,
    /// What it should hold afterwards; null for a delete.
    pub new: ObjectId,
    /// The reference.
    ///
    /// `String`, not the `FullName` newtype that died with `RefStore`, because
    /// `RefUpdate::name` and `RefRow::name` on the contract are `String` and a
    /// second validated name type in gunnar is the duplication the 2026-08-10
    /// demolition removes.
    ///
    /// **The validation did not go with the type.** It moved into
    /// `ReceivePack::parse_command`, which routes every client-supplied name
    /// through `gunnar_store::refname::validate` before one of these can
    /// exist — and that is a path-traversal boundary
    /// (`refs/heads/../../etc/passwd`), not a tidiness rule. What is gone is
    /// only the *compile-time* guarantee that a value of this type was
    /// checked; the runtime check is in one place and it is the same rule set.
    pub name: String,
}

impl RefCommand {
    /// Is this a deletion?
    pub fn is_delete(&self) -> bool {
        self.new.is_null()
    }

    /// Is this a creation?
    pub fn is_create(&self) -> bool {
        self.old.is_null()
    }
}

/// What the client sends first: its ref commands (and the ones it could not
/// name) plus the capabilities it chose. Read by [`ReceivePack::read_preamble`]
/// — with the hash check already applied — and consumed by
/// [`ReceivePack::serve_after_preamble`]. The split exists so the
/// first-repository door can read this **before** a store exists.
#[derive(Debug)]
pub struct Preamble {
    /// The nameable commands, in wire order.
    pub commands: Vec<RefCommand>,
    /// The commands whose names gunnar refused.
    pub unnameable: Vec<UnnameableRef>,
    /// What the client asked for.
    pub peer: PeerCapabilities,
}

/// A command line this server will not accept a **name** from.
///
/// # Why an invalid name is not an error
///
/// It used to be. `read_commands` called `FullName::new` with `?`, so one bad
/// name aborted the whole session before the pack had been read: the client was
/// still writing and got a broken pipe, no `report-status` was ever produced,
/// and every *other* reference in the same push was lost with no line saying
/// which one was at fault. That is the `the remote end hung up unexpectedly`
/// shape, and it is the same defect the shallow-clone fix in `read_commands`
/// closed for one particular cause without closing the class.
///
/// **Stock git defers it to the report, and this is measured, not assumed.**
/// `git receive-pack` 2.53.0, 2026-08-06, fed a raw two-command push of
/// `refs/heads/goodone` and `refs/heads/bad~name` over one connection, answered:
///
/// ```text
/// unpack ok
/// ok refs/heads/goodone
/// ng refs/heads/bad~name funny refname
/// ```
///
/// exit 0, `refs/heads/goodone` created. So the good reference lands, the bad
/// one is named in the report, and the session ends cleanly. gunnar now does
/// the same.
///
/// The name is kept as **bytes**: it is precisely a name the validator refused,
/// so there is nothing to hold it in, and it is echoed back verbatim because a
/// client matches the `ng` line against the name it sent. Since the contract's
/// `RefRow::name` is a `String`, a name that is *valid* but not UTF-8 lands
/// here too — see `ReceivePack::parse_command`, which records that narrowing
/// rather than hiding it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnnameableRef {
    /// The reference name exactly as the client spelled it.
    pub name: Vec<u8>,
    /// The one-line reason, rendered into the `ng` line.
    pub reason: String,
    /// Where the line sat in the client's command list, so the report can be
    /// emitted in the order the client sent — which is what git does and what a
    /// client zipping its own list against the report needs.
    pub position: usize,
    /// Whether the line asked for a deletion. A push carrying only unnameable
    /// *deletes* sends no pack, and a server that waited for one would hang.
    pub deletes: bool,
}

/// What happened to one reference.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RefStatus {
    /// Applied.
    Ok,
    /// Refused, with the reason the client is told.
    Rejected(String),
}

impl RefStatus {
    /// Was it applied?
    pub fn is_ok(&self) -> bool {
        matches!(self, RefStatus::Ok)
    }
}

/// Everything `receive-pack` learned about the push, handed to every gate.
///
/// # Why this is a struct and not three arguments
///
/// The gate seam used to be `(&RefCommand, &dyn ObjectStore)` and nothing else,
/// so a gate could not see which objects the push introduced or how many bytes
/// the client sent. `gunnar-policy`'s size and quota gates
/// (`gunnar_policy::gates::limits`) are the things standing between a forge and
/// a client pushing until the disk fills, and neither could be enforced: the
/// object set was recovered downstream by reading the written `.idx` back,
/// which counts a thin pack's resolved bases as if the client had sent them,
/// and the byte count was never produced at all.
///
/// A struct rather than a wider tuple so the *next* fact does not re-break
/// every implementation of [`PushGate`].
pub struct PushFacts<'a> {
    /// The store, after the pack has been taken in.
    ///
    /// `GitServe`: a gate reads objects, and reading means *inflated and
    /// delta-resolved*. `gunnar-policy`'s ancestry walk and its signature gate
    /// were two of the three errors that crate was blocked on, and they are the
    /// same missing capability the connectivity walk here needs.
    pub objects: &'a dyn GitServe,
    /// The objects this push genuinely introduced, or the reason they could not
    /// be enumerated.
    ///
    /// **`Err` is not "none"**: a gate that cannot see what was pushed cannot
    /// decide, and an undecidable gate refuses. Handing an empty slice for
    /// "I do not know" is the shape that lets a configured limit be silently
    /// satisfied, which is what this type exists to prevent.
    pub new_objects: std::result::Result<&'a [ObjectId], &'a str>,
    /// Packfile bytes the client sent, counted off the wire by
    /// [`ReceivePack::serve`] rather than measured from what landed on disk.
    ///
    /// `Some(0)` for a deletions-only push, which carries no pack: that is a
    /// measured zero, not an absent measurement, and a byte limit must not
    /// refuse it. `None` only if the count is genuinely unavailable.
    pub pack_bytes: Option<u64>,
    /// The `push-option` lines the client sent, in wire order.
    ///
    /// # Why this field exists
    ///
    /// `push-options` was **advertised** by
    /// [`caps::receive_pack_capabilities`](crate::caps::receive_pack_capabilities),
    /// **parsed** by [`ReceivePack::serve`] and then thrown away: the only
    /// consumer in the whole tree was `.len()` in one log line. A client that
    /// sent `-o ci.skip` got the capability acknowledged on the wire and the
    /// option silently ignored, which is a wrong answer on the wire rather than
    /// a missing feature — the gate could not see them, so no policy could act
    /// on one even in principle.
    ///
    /// Stock git hands push options to the `pre-receive` hook. gunnar has no
    /// hooks and no shell; [`PushGate`] is what replaces them, so
    /// this is where they have to arrive.
    ///
    /// **Empty means the client sent none**, which is also what a client that
    /// never negotiated the capability produces. The two are not told apart
    /// here on purpose: a gate that requires an option must refuse both, and
    /// a gate that forbids unknown ones must accept both.
    pub push_options: &'a [String],
}

/// What taking the pack into storage produced.
///
/// [`PackIngest`] returns this rather than a bare [`IndexedPack`] because the
/// only place that can answer *"which objects did this push introduce"*
/// honestly is the code that resolved the pack: it is the one that knows which
/// entries came off the wire and which bases it pulled out of the store to
/// complete a thin pack. Recovering it afterwards from the written index cannot
/// tell those apart.
///
/// `Debug` and `Clone` because both of its fields already are, and because
/// [`crate::fetch_pack::FetchReport`] carries one: a report a caller cannot
/// print is a report a caller logs by hand, field by field, and gets wrong.
#[derive(Debug, Clone)]
pub struct PackReceipt {
    /// The pack as indexed.
    pub indexed: IndexedPack,
    /// The genuinely-new objects, or why they are unknown. See
    /// [`PushFacts::new_objects`] for why this is not an `Option`.
    pub new_objects: std::result::Result<Vec<ObjectId>, String>,
}

impl PackReceipt {
    /// A receipt from an ingest that does not enumerate the pushed objects.
    ///
    /// The reason, rather than an empty set, so gates refuse instead of
    /// deciding on nothing.
    pub fn without_object_set(indexed: IndexedPack) -> Self {
        PackReceipt {
            indexed,
            new_objects: Err(
                "this PackIngest does not report which objects the push introduced".to_owned(),
            ),
        }
    }
}

/// A pre-receive gate.
///
/// `gunnar-policy` supplies the real ones; this crate only defines
/// the seam so that receive-pack has somewhere to ask. **Measuring rather than
/// asserting applies hardest here**: a gate that always returns `Ok` is
/// indistinguishable from a working gate in any test that only pushes valid
/// commits, so every implementation must be seen to reject before it is
/// trusted to allow.
pub trait PushGate {
    /// `Err(reason)` rejects the reference; the reason is sent verbatim.
    fn check(&self, command: &RefCommand, facts: &PushFacts<'_>)
        -> std::result::Result<(), String>;
}

/// The gate that lets everything through. This is the default, and the thing
/// `gunnar-policy` replaces.
pub struct AllowAll;

impl PushGate for AllowAll {
    fn check(
        &self,
        _command: &RefCommand,
        _facts: &PushFacts<'_>,
    ) -> std::result::Result<(), String> {
        Ok(())
    }
}

/// How a pushed packfile is taken into storage.
///
/// The stream handed to [`PackIngest::ingest`] is positioned on the packfile's
/// first byte (the `P` of `PACK`) because [`crate::receive_pack`] reads
/// pkt-lines with exact-length reads and never buffers past the flush.
pub trait PackIngest {
    /// Consume the pack and make its objects readable from the store.
    fn ingest(&self, pack: &mut dyn BufRead) -> Result<PackReceipt>;
}

/// A bare closure is still an ingest, and it reports **no** object set.
///
/// Kept because most of this crate's own tests are closures that only need the
/// pack indexed. The receipt says so out loud
/// ([`PackReceipt::without_object_set`]) rather than answering "no new
/// objects", so a real gate behind such an ingest refuses rather than passing a
/// limit it never evaluated.
impl<F> PackIngest for F
where
    F: Fn(&mut dyn BufRead) -> Result<IndexedPack>,
{
    fn ingest(&self, pack: &mut dyn BufRead) -> Result<PackReceipt> {
        self(pack).map(PackReceipt::without_object_set)
    }
}

/// Counts the bytes an ingest consumes, so [`PushFacts::pack_bytes`] is the
/// number that came off the wire.
///
/// Wrapping the reader is what makes the count true by construction: it is not
/// `IndexedPack`'s business, it is not the storage backend's, and the only code
/// that sees every byte of the packfile go past is the code that hands the
/// stream over.
pub(crate) struct Counting<'a> {
    inner: &'a mut dyn BufRead,
    pub(crate) bytes: u64,
    /// The ceiling this push is being held to, if any. See [`PackCeiling`].
    ceiling: Option<PackCeiling>,
    /// **Bytes charged to the shared ledger that this push has not yet taken.**
    ///
    /// `fill_buf` charges what it is about to OFFER and `consume` discharges
    /// what was actually taken, and the two are not the same call: a consumer
    /// may peek at a bufferful three times and consume half of it. Without this
    /// counter each peek would charge the same bytes again and a push would
    /// spend its namespace's room several times over on bytes it never took.
    ///
    /// Charging at `consume` alone — after the bytes are already out — was the
    /// alternative, and it does not close the race: every one of N pushes would
    /// still hand its ingest a whole buffer measured against a room nobody had
    /// spent yet. The charge has to happen BEFORE the bytes are offered, which
    /// means it has to be remembered until they are taken.
    ///
    /// Left-over credit at the end of a transfer (bytes peeked and never
    /// consumed) stays charged, which is the same over-conservative,
    /// self-healing direction `gunnar_lfs::ledger` documents for an aborted
    /// upload: it is bounded by one buffer, it costs only the holders alive
    /// beside it, and it errs towards refusing rather than towards a byte the
    /// operator pays for.
    charged_ahead: u64,
    /// Set the moment the count passed the ceiling, and never cleared. The
    /// caller reads it rather than trying to recognise the abort in whatever
    /// the ingest turned the cut stream into — an indexer is entitled to
    /// report a truncated pack as a malformed one, and "your plan is full"
    /// and "your pack is corrupt" are not the same sentence to a tenant.
    pub(crate) cut: bool,
}

impl<'a> Counting<'a> {
    /// A reader over `inner` held to `ceiling` — the receive loop's own guard,
    /// lent to a FETCH so a mirror is cut at its namespace's room by the same
    /// code that cuts a push (`fetch_pack::FetchPack::with_ceiling`).
    pub(crate) fn new(inner: &'a mut dyn BufRead, ceiling: Option<PackCeiling>) -> Self {
        Counting {
            inner,
            bytes: 0,
            ceiling,
            charged_ahead: 0,
            cut: false,
        }
    }
}

impl Counting<'_> {
    /// Has the count passed the ceiling?
    ///
    /// **Strictly greater**, so a push of exactly `ceiling` bytes lands. The
    /// ceiling is the last byte that is allowed, not the first that is not —
    /// see [`PackCeiling`] on why the number already carries slack.
    fn over(&self) -> bool {
        matches!(&self.ceiling, Some(ceiling) if self.bytes > ceiling.bytes)
    }

    /// How many more bytes may be handed out before the ceiling is passed —
    /// the room under it **plus the one byte that trips it**, so the trip is
    /// reachable. `None` when there is no ceiling.
    ///
    /// # Why the slice is truncated to this, rather than trusted
    ///
    /// `fill_buf` hands out whatever the source has buffered, and the source
    /// decides how much that is: a `BufReader` gives a bufferful, a `Cursor`
    /// over a `Vec` gives **the whole rest of the push in one call**. Without
    /// this clamp the ceiling's precision would be the source's buffer size —
    /// the guard would still fire, but a pack read out of memory would cross
    /// it by its entire length before anything looked, and the guard's own
    /// test would be measuring the fixture rather than the code (measure,
    /// never assert). With it, a push that overshoots costs the server exactly
    /// `bytes + 1` and the number is the same on every reader.
    fn room(&self) -> Option<usize> {
        let ceiling = self.ceiling.as_ref()?.bytes;
        let room = ceiling.saturating_sub(self.bytes).saturating_add(1);
        // A ceiling past `usize` on a 32-bit box is not a ceiling this read can
        // reach in one call, so clamping to `usize::MAX` is the honest answer
        // rather than a truncating cast.
        Some(usize::try_from(room).unwrap_or(usize::MAX))
    }

    /// **How many of `want` bytes this push may hand on, asking the shared
    /// ledger for whatever it has not already been credited.**
    ///
    /// `want` is what the caller would otherwise offer, already clamped to
    /// [`Self::room`]. With no ceiling, or a ceiling with no ledger behind it,
    /// the answer is `want` and nothing is charged — the community build and
    /// every gunnar before 2026-09-08 take this path, and it is the same code
    /// path, not a second one.
    ///
    /// The room the ledger is asked about is `ceiling + 1`: the ceiling plus
    /// the one byte that trips it. A budget of exactly `ceiling` would starve
    /// the trip byte, and a push of exactly its ceiling would then end as a
    /// stream that simply stopped — which the indexer reports as a malformed
    /// pack, and telling a tenant their pack is corrupt when their plan filled
    /// up is the refusal this file's `refuse` exists to prevent.
    fn admit(&mut self, want: usize) -> usize {
        let Some(ledger) = self.ceiling.as_ref().and_then(|c| c.ledger.as_ref()) else {
            return want;
        };
        // Unwrap-free: the `let else` above already proved there is a ceiling.
        let room = self
            .ceiling
            .as_ref()
            .map(|c| c.bytes.saturating_add(1))
            .unwrap_or(u64::MAX);
        let want = want as u64;
        // Only the part that is not already paid for. On a re-peek of a buffer
        // nothing was consumed from, this is 0 and the ledger is not touched.
        let owing = want.saturating_sub(self.charged_ahead);
        if owing > 0 {
            self.charged_ahead = self
                .charged_ahead
                .saturating_add(ledger.admit_upto(owing, room));
        }
        // `min`: credit carried over from an earlier, larger offer does not
        // make this offer bigger than it is.
        usize::try_from(self.charged_ahead.min(want)).unwrap_or(usize::MAX)
    }

    /// Cut the stream: mark the push and return the error the ingest sees.
    ///
    /// The text here is for the **server log**, not the client: what the client
    /// is told is [`PackCeiling::refusal`], installed over the ingest's own
    /// message by [`ReceivePack::serve_after_preamble`]. Two sentences because
    /// they answer two people.
    fn refuse(&mut self) -> std::io::Error {
        self.cut = true;
        let ceiling = self.ceiling.as_ref().map(|c| c.bytes).unwrap_or_default();
        std::io::Error::other(format!(
            "the push passed its {ceiling}-byte ceiling after {} byte(s); the pack read was \
             aborted mid-transfer and nothing was stored",
            self.bytes
        ))
    }
}

impl std::io::Read for Counting<'_> {
    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
        if self.cut || self.over() {
            let err = self.refuse();
            return Err(err);
        }
        // **What the source actually has, before anything is charged for it —
        // and asked for ONLY when something is going to be charged.**
        //
        // The ledger has to be asked about bytes that EXIST: charging
        // `buf.len()` and reading fewer would leave the difference charged to
        // the namespace with nothing to discharge it, and
        // `simultaneous_pushes_that_all_fit_are_all_admitted` measured that as
        // seven of eight legitimate pushes admitted where all eight fit. A
        // quota that refuses a push nobody's bytes displaced is the same defect
        // as one that admits too many, pointing the other way.
        //
        // `fill_buf` blocks exactly where `read` would — until one byte is
        // available or the stream ends — so it costs no latency. It does cap
        // this call at what the source has buffered, which is why it is skipped
        // entirely with no ledger to ask: an unmetered `read` still goes
        // straight through to the source and may still hand back more than one
        // bufferful in a call, exactly as it did before this field existed.
        let available = match self.ceiling.as_ref().and_then(|c| c.ledger.as_ref()) {
            Some(_) => {
                let available = self.inner.fill_buf()?.len();
                if available == 0 {
                    // The client is done. Not a refusal: nothing to refuse.
                    return Ok(0);
                }
                available
            }
            None => usize::MAX,
        };
        let want = match self.room() {
            Some(room) => room.min(buf.len()).min(available),
            None => buf.len().min(available),
        };
        // The namespace's answer, not just this push's: `admit` returns `want`
        // unchanged when there is no ledger, so the unmetered path is
        // arithmetically untouched.
        let allowed = self.admit(want);
        if allowed == 0 && !buf.is_empty() {
            // Nothing left in the namespace, and the client is still sending.
            // Cut it here rather than returning `Ok(0)`: a zero-length read is
            // EOF to every consumer in std, and "your plan is full" must not
            // reach the indexer as "the pack ended early".
            let err = self.refuse();
            return Err(err);
        }
        let read = self.inner.read(&mut buf[..allowed])?;
        self.bytes += read as u64;
        self.charged_ahead = self.charged_ahead.saturating_sub(read as u64);
        Ok(read)
    }
}

impl BufRead for Counting<'_> {
    /// **Where the ceiling actually bites**, because this is how the pack is
    /// read: `read_pushed_pack_spooling` peeks with `fill_buf` and takes with
    /// `consume`, so this is the path the shipping ingest takes and `read`
    /// above is the path one that does not peek takes. Both are guarded and
    /// both clamp to [`Counting::room`], so the overshoot is **one byte**, not
    /// one buffer — a push that means to send 400 GiB past its ceiling costs
    /// the server `ceiling + 1` and nothing more.
    fn fill_buf(&mut self) -> std::io::Result<&[u8]> {
        if self.cut || self.over() {
            let err = self.refuse();
            return Err(err);
        }
        // `room()` is never `Some(0)` — `over()` above has already returned for
        // that — so this never turns a live stream into a spurious EOF.
        let room = self.room();
        // **The namespace's half, and it fills the buffer TWICE.** The ledger
        // has to be asked about the bytes actually in hand — asking it about
        // `room` instead would reserve a tenant's whole remaining plan for the
        // first connection to say hello and refuse a sibling push that would
        // have fitted twice over — and it cannot be asked while a slice
        // borrowed out of `self.inner` is alive. A second `fill_buf` with no
        // `consume` between is a peek: it returns the same buffer it just
        // returned, reads nothing more and cannot block. The `min` below makes
        // even a source that ignores that safe.
        //
        // Skipped entirely with no ledger to ask, so the unmetered path fills
        // once and clamps to `room`, exactly as it did before this existed.
        let allowed = match self.ceiling.as_ref().and_then(|c| c.ledger.as_ref()) {
            Some(_) => {
                let available = self.inner.fill_buf()?.len();
                let want = match room {
                    Some(room) if available > room => room,
                    _ => available,
                };
                let allowed = self.admit(want);
                if allowed == 0 && available > 0 {
                    // The namespace's room is gone and the client is still
                    // sending. An empty slice would be EOF to the indexer, i.e.
                    // "the pack is truncated"; the cut says whose bytes filled
                    // the plan.
                    let err = self.refuse();
                    return Err(err);
                }
                Some(allowed)
            }
            None => None,
        };
        let filled = self.inner.fill_buf()?;
        Ok(match (allowed, room) {
            (Some(allowed), _) => &filled[..allowed.min(filled.len())],
            (None, Some(room)) if filled.len() > room => &filled[..room],
            _ => filled,
        })
    }

    /// The only place `fill_buf`'s bytes are charged. `read` charges its own,
    /// and the two never see the same byte: `BufRead::read`'s default goes
    /// through `fill_buf`/`consume`, but this type overrides `read` to delegate
    /// straight to the inner reader instead, so a byte is counted once whichever
    /// way the consumer asks for it.
    fn consume(&mut self, amount: usize) {
        self.inner.consume(amount);
        self.bytes += amount as u64;
        // Taken, so no longer owed: the credit `fill_buf` bought for these
        // bytes is spent. What is left is credit for bytes still in the buffer,
        // which the next `fill_buf` will offer again and must not pay for
        // twice.
        self.charged_ahead = self.charged_ahead.saturating_sub(amount as u64);
    }
}

/// Everything one push did.
#[derive(Debug)]
pub struct PushOutcome {
    /// The commands as sent.
    pub commands: Vec<RefCommand>,
    /// One status per command, in the same order.
    pub statuses: Vec<RefStatus>,
    /// Command lines refused for their reference **name**, which therefore have
    /// no [`RefCommand`] to carry a status. See [`UnnameableRef`].
    pub unnameable: Vec<UnnameableRef>,
    /// `push-option` lines, if the capability was negotiated.
    pub push_options: Vec<String>,
    /// The indexed pack, or `None` when the push was deletions only.
    pub pack: Option<IndexedPack>,
    /// Packfile bytes the client sent, counted off the wire. See
    /// [`PushFacts::pack_bytes`].
    pub pack_bytes: Option<u64>,
    /// The objects the push introduced, or why they are unknown. Same value the
    /// gates saw; see [`PushFacts::new_objects`].
    pub new_objects: std::result::Result<Vec<ObjectId>, String>,
    /// Whether the client asked for `--atomic`.
    pub atomic: bool,
    /// What the client was told about unpacking: `Ok` or a one-line reason.
    pub unpack: std::result::Result<(), String>,
}

impl PushOutcome {
    /// Did every reference land?
    ///
    /// An unnameable reference counts as a refusal: it is reported as `ng`, so
    /// a caller reading this to mean *the push succeeded* must see false.
    pub fn all_ok(&self) -> bool {
        self.unpack.is_ok()
            && self.unnameable.is_empty()
            && self.statuses.iter().all(RefStatus::is_ok)
    }
}

/// **How many packfile bytes this push may send, and what the client is told
/// when it sends more.**
///
/// # Why a ceiling exists at all
///
/// `git-receive-pack`'s pack header carries an **object count, not a byte
/// count**, so the size of a push is not knowable at the moment it is
/// authorised. A quota asked before the pack (`gunnar_ssh::PlanGate::pack_quota`)
/// refuses a tenant who is *already* at the ceiling, and a limit asked after it
/// (`gunnar_policy::gates::limits`, whose own header says so plainly) refuses a
/// pack that has *already been received* — so between those two there was
/// nothing at all in front of one enormous push. A tenant one byte under a
/// 10 GiB cap could send 400 GiB and the box would take every byte of it before
/// anything looked.
///
/// This is that missing thing. It is enforced in the receive loop itself
/// ([`Counting`]), so an unbounded push costs the server the ceiling and not
/// the push.
///
/// # It is a backstop, not a meter
///
/// The polite refusal is `pack_quota`'s, before a byte moves. This fires only
/// for a push that was under the cap when it started and crossed it in flight,
/// which is why whoever sets it is expected to have added **slack** to it: the
/// bytes on the wire are not the bytes that land (a thin pack is completed with
/// bases the client never sent; the engine stores compressed), and a ceiling
/// with no room refuses a push that would have fitted. `gunnar-wire` does not
/// choose that number — it does not know what a plan is — it enforces the one
/// it is handed.
/// # …and it is one number shared by everything pushing at once
///
/// [`PackCeiling::bytes`] is measured for ONE push, and two pushes that arrive
/// together get the same measurement — so before [`PackCeiling::ledger`] each
/// of N simultaneous pushes was held to the whole of the namespace's remaining
/// room, and the room was spent N times. `tests/concurrent_pack_ceiling.rs`
/// measured it at eight packs of 41016 bytes into 65536 bytes of room. The
/// ledger is what makes the number a shared budget rather than a per-connection
/// allowance.
#[derive(Debug, Clone)]
pub struct PackCeiling {
    /// Packfile bytes past which the read is aborted. A push of exactly this
    /// many bytes is **accepted**; the next byte cuts it.
    pub bytes: u64,
    /// The line the client is told, verbatim, in place of whatever the ingest
    /// made of the truncated stream. Composed by whoever set the ceiling — the
    /// metered appliance names the plan, the cap and the billing URL, the same
    /// shape every other entitlement refusal uses — because this crate knows
    /// nothing about plans, caps or money.
    pub refusal: String,
    /// **Who else is spending [`Self::bytes`] right now.** `None` — the
    /// default, and what a build with no byte cap installs — means the number
    /// is this push's alone, which is what every gunnar before 2026-09-08 did
    /// and is correct exactly as long as one push is the only push.
    ///
    /// `Some` makes the ceiling a budget the namespace shares: the receive loop
    /// asks before every chunk it hands to the ingest, and takes what it is
    /// granted. See [`PackLedger`].
    ///
    /// `PartialEq`/`Eq` came off this struct when the field arrived. Two
    /// ceilings are not comparable any more, because a shared standing in a
    /// counter is not a value — and nothing compared them: the equality the
    /// tests use is `gunnar_ssh::PackCeiling`'s, which is the plain twin the
    /// gates return and still derives both.
    pub ledger: Option<std::sync::Arc<dyn PackLedger>>,
}

/// **The namespace's shared standing, asked one chunk at a time.**
///
/// A packfile's header carries an object count and no byte count, so a push
/// cannot declare its size and the door cannot admit it up front the way the
/// LFS door admits an object. The receive loop therefore asks about the chunk
/// in its hand, before it hands it on, and takes what it is granted.
///
/// # Why a trait here instead of the ledger itself
///
/// The implementation is `gunnar_lfs::Mark` (a monotone `u64` per namespace and
/// a per-holder mark — see that module for the whole design, including why
/// there is deliberately nothing to give back), wired up by
/// `gunnar_server::transport`, which is the one place that already holds both
/// halves of the [`PackCeiling`] twin. This crate must not link `gunnar-lfs`
/// and must not link `gunnar-core`: it is bound by the no-OS rule and the crate
/// layout to plumbing, and
/// its own [`GIT_TARGET`] refuses `gunnar-core` for a single `&str` on exactly
/// that argument. So the edge is inverted rather than added, and `gunnar-wire`
/// resolves the same 51 packages it resolved before the ledger existed
/// (MEASURED 2026-09-08, `cargo tree -e normal`).
pub trait PackLedger: Send + Sync + std::fmt::Debug {
    /// **Charge up to `want` bytes against everything admitted to this
    /// namespace since this push measured it, inside a total of `room`, and
    /// return how many were charged.**
    ///
    /// Partial credit, not a verdict: a grant short of `want` is a chunk the
    /// caller may take that much of, and a grant of `0` means the room is gone
    /// and the push must be cut. An implementation that answered all-or-nothing
    /// would refuse a push that fits, for asking about a buffer bigger than the
    /// remainder.
    ///
    /// `room` is passed in on every call rather than held by the
    /// implementation because it is the RECEIVE LOOP's number: it is
    /// [`PackCeiling::bytes`] plus the one byte that makes the ceiling
    /// reachable ([`Counting::room`] explains why that byte exists), and
    /// spelling that `+ 1` in two crates is how two guards start disagreeing.
    ///
    /// **Must not block on I/O.** It is called once per chunk of a transfer at
    /// wire speed; the shipping implementation takes a `Mutex` across a hash
    /// lookup and an integer add, and nothing else.
    fn admit_upto(&self, want: u64, room: u64) -> u64;
}

/// Knobs for one push.
#[derive(Debug, Clone)]
pub struct Config {
    /// The `agent=` string.
    pub agent: String,
    /// Traversal budget for the connectivity check.
    pub budget: Option<usize>,
    /// **A line the client is told beside the report**, on the progress band
    /// (`remote: …`), for a push that is ALLOWED but carries a warning — the
    /// metered appliance's `plan expired <date>; renew at <url>` for a tenant
    /// in Grace. `None` prints nothing. Sent only when the client negotiated a
    /// sideband, which every git since 1.7 does; a client without one gets the
    /// bare report, never a line where the report should be.
    pub notice: Option<String>,
    /// **The mid-upload ceiling.** `None` — the default, and what a community
    /// build with no byte cap installs — reads the pack to its end whatever
    /// its size, which is what every gunnar before 2026-09-05 did. See
    /// [`PackCeiling`].
    pub pack_ceiling: Option<PackCeiling>,
}

impl Default for Config {
    fn default() -> Self {
        Config {
            agent: caps::default_agent(),
            budget: Some(crate::graph::CONNECTIVITY_BUDGET),
            notice: None,
            pack_ceiling: None,
        }
    }
}

/// The server side of `git-receive-pack`.
pub struct ReceivePack<'a> {
    /// **One store, not two.** `ObjectStore` and `RefStore` were separate
    /// gunnar traits and are deleted; `GitOps` says both, and `GitServe`
    /// extends it with the decoded read the connectivity walk needs.
    git: &'a dyn GitServe,
    /// The repository's hash kind.
    ///
    /// Carried in rather than asked for — `GitServe` has no `hash_kind()` and
    /// the frozen signature leaves whether `GitOps` should grow one open. It is not a
    /// convenience here: `parse_command` refuses a command line whose oids are
    /// in the wrong hash, and answering that from a default would accept a
    /// sha1 push into a sha256 repository.
    hash_kind: gix_hash::Kind,
    config: Config,
}

/// An [`Observed`] in git's own vocabulary for a ref-status line.
///
/// git says `is at <oid> but expected <oid>`, and for a ref that did not exist
/// it says the all-zero oid. `Unreadable` has no git spelling at all — the
/// backend could not decode what the ref held — so it is named rather than
/// flattened into "absent", which would tell a client the ref was empty when it
/// was in fact corrupt.
fn render(o: &Observed) -> String {
    match o {
        Observed::Nothing => "0000000000000000000000000000000000000000".to_owned(),
        // A symbolic ref is spelled `ref: <name>`, as git does, so a client is
        // never handed a branch name where it expects an oid.
        Observed::Value(RefTarget::Object(oid)) => {
            // Written out rather than pulling in a `hex` dependency for one
            // call: this crate has none and a ref-status line is not worth one.
            oid.iter()
                .fold(String::with_capacity(oid.len() * 2), |mut acc, b| {
                    use std::fmt::Write as _;
                    let _ = write!(acc, "{b:02x}");
                    acc
                })
        }
        Observed::Value(RefTarget::Symbolic(name)) => format!("ref: {name}"),
        Observed::Unreadable(what) => format!("unreadable ({what})"),
    }
}

impl<'a> ReceivePack<'a> {
    /// Receive into `git` with default configuration.
    pub fn new(git: &'a dyn GitServe, hash_kind: gix_hash::Kind) -> Self {
        ReceivePack {
            git,
            hash_kind,
            config: Config::default(),
        }
    }

    /// Replace the configuration.
    pub fn with_config(mut self, config: Config) -> Self {
        self.config = config;
        self
    }

    /// The repository's hash kind.
    pub fn hash_kind(&self) -> gix_hash::Kind {
        self.hash_kind
    }

    /// Write the reference advertisement and its capabilities.
    pub fn write_ref_advertisement(&self, out: &mut dyn Write) -> Result<()> {
        let capabilities = caps::receive_pack_capabilities(self.hash_kind(), &self.config.agent);
        // ONE snapshot: `RefStore::iter` followed by a `resolve` per symbolic
        // row was N+1 reads of a namespace that can move between them, so an
        // advertisement could name an oid for a reference that had already
        // been deleted. A client then pushes against what it was told.
        let snapshot = RefSnapshot::read(self.git)?;
        let mut refs = Vec::new();
        for row in snapshot.rows() {
            let Some(id) = snapshot.resolve_row(row)? else {
                continue;
            };
            refs.push(gunnar_sendpack::RemoteRef::new(row.name.as_bytes(), id));
        }
        // Rendered by the shared grammar, including the `capabilities^{}`
        // pseudo-ref an empty repository has to emit so a client can still
        // learn the hash algorithm. `gunnar-client` parses these lines with the
        // same module.
        for line in gunnar_sendpack::advertisement::lines(&refs, &capabilities, self.hash_kind()) {
            wire_io::text(out, &line)?;
        }
        wire_io::flush(out)?;
        Ok(())
    }

    /// Serve one push, from the advertisement through the status report.
    pub fn serve(
        &self,
        input: &mut dyn BufRead,
        out: &mut dyn Write,
        ingest: &dyn PackIngest,
        gate: &dyn PushGate,
    ) -> Result<Option<PushOutcome>> {
        self.serve_session(Session::Connected, input, out, ingest, gate)
    }

    /// Serve one push for either [`Session`].
    ///
    /// **Push is the easy half of stateless-rpc, and it is worth saying why.**
    /// `receive-pack` has no negotiation: the client is told the refs once, and
    /// then sends its command list, its options and its packfile in one go. So
    /// a smart-HTTP push is exactly one `POST` and the only difference from SSH
    /// is that the advertisement was a separate `GET`. There is no round to cut
    /// short here, and protocol v2 has no push side to complicate it.
    pub fn serve_session(
        &self,
        session: Session,
        input: &mut dyn BufRead,
        out: &mut dyn Write,
        ingest: &dyn PackIngest,
        gate: &dyn PushGate,
    ) -> Result<Option<PushOutcome>> {
        if session.advertises_inline() {
            let _s = crate::stage::enter("advertise");
            self.write_ref_advertisement(out)?;
            out.flush()?;
        }
        let Some(preamble) = Self::read_preamble(self.hash_kind(), input)? else {
            return Ok(None);
        };
        self.serve_after_preamble(preamble, input, out, ingest, gate)
            .map(Some)
    }

    /// The advertisement of a repository that **does not exist yet**: no
    /// references, the capabilities and `object-format` of `hash_kind`. The
    /// first-repository door writes this instead of [`Self::write_ref_advertisement`]
    /// because there is no store to snapshot — and must not be one, until the
    /// client has sent a command that passes the hash check.
    pub fn write_empty_advertisement(
        hash_kind: gix_hash::Kind,
        config: &Config,
        out: &mut dyn Write,
    ) -> Result<()> {
        let capabilities = caps::receive_pack_capabilities(hash_kind, &config.agent);
        for line in gunnar_sendpack::advertisement::lines(&[], &capabilities, hash_kind) {
            wire_io::text(out, &line)?;
        }
        wire_io::flush(out)?;
        out.flush()?;
        Ok(())
    }

    /// The client's half of the opening: its command list and capabilities,
    /// with the hash check applied. `None` when the client took the
    /// advertisement and sent nothing, which is `git push` with nothing to do
    /// — and a sha1 working tree that read `object-format=sha256` and gave up
    ///. Neither is an error and neither is a push.
    ///
    /// An associated function, not a method: the first-repository door calls
    /// it **before** any store exists, so it must not need one.
    pub fn read_preamble(
        hash_kind: gix_hash::Kind,
        input: &mut dyn BufRead,
    ) -> Result<Option<Preamble>> {
        let (commands, unnameable, peer) = {
            let _s = crate::stage::enter("command_read");
            Self::read_commands(hash_kind, input)?
        };
        if commands.is_empty() && unnameable.is_empty() {
            tracing::info!(
                target: GIT_TARGET,
                event = "git.receive_pack.no_commands",
                "receive-pack: the client sent no ref commands"
            );
            // The client took the advertisement and hung up; `git push` with
            // nothing to do does exactly this. Not an error, and not a push.
            return Ok(None);
        }
        caps::check_object_format(hash_kind, peer.value("object-format"))?;
        Ok(Some(Preamble {
            commands,
            unnameable,
            peer,
        }))
    }

    /// Serve a push whose [`Preamble`] has been read: options, the pack, the
    /// gates, the ref updates and the status report.
    pub fn serve_after_preamble(
        &self,
        preamble: Preamble,
        input: &mut dyn BufRead,
        out: &mut dyn Write,
        ingest: &dyn PackIngest,
        gate: &dyn PushGate,
    ) -> Result<PushOutcome> {
        let Preamble {
            commands,
            unnameable,
            peer,
        } = preamble;
        let _s_opts = crate::stage::enter("push_options");
        let push_options = if peer.has("push-options") {
            // The push side has its own ceiling for the same reason the fetch
            // side does: the section ends when the *peer* sends a flush-pkt.
            // The number is `gunnar-wire`'s one request ceiling, not a second
            // constant that could drift from it (one implementation).
            let mut budget =
                crate::limits::Budget::new(Some(crate::limits::DEFAULT_REQUEST_LIMIT_BYTES));
            read_section(input, "the push-options section", &mut budget)?
                .into_iter()
                .map(|line| String::from_utf8_lossy(&line).into_owned())
                .collect()
        } else {
            Vec::new()
        };

        crate::stage::end(_s_opts);
        let sideband = peer.has("side-band-64k");
        let report_requested = peer.has("report-status") || peer.has("report-status-v2");
        let atomic = peer.has("atomic");

        // Deletions carry no objects, so the client sends no pack at all. A
        // server that reads one anyway blocks until the connection times out.
        //
        // An unnameable reference counts here too. The client does not know
        // gunnar refused its name and streams the pack regardless, so a server
        // that skipped the read because every *nameable* command was a delete
        // would leave a pack in the pipe, write its report into the middle of
        // it, and desync the session.
        let expects_pack = commands.iter().any(|command| !command.is_delete())
            || unnameable.iter().any(|refused| !refused.deletes);

        // ── the phase log ───────────────────────────────────────────────────
        //
        // Everything below this point can take minutes on a repository the
        // size of a kernel, and until now it said **nothing at all** until it
        // was over. The Fet Kaos Monkey rig's `linux.git` import (13 816 503
        // objects, 8 170 MB pack, 2 872 refs; 2026-08-05, load 25–33)
        // wrote its pack and its index in ~30 min and had still logged no line
        // at 40 min. That is an operability defect on its own terms and
        // separate from the walk being slow: an operator watching a working
        // import and one watching a hang were reading the identical empty log.
        //
        // So each phase announces itself on **entry** and reports on **exit**.
        // The entry line is the load-bearing one — an exit-only log still
        // leaves the ten minutes in between unattributed, which is precisely
        // the state that was unreadable.
        //
        // `info`, not `debug`: a phase that can run for ten minutes is not
        // diagnostic detail, and a default-level log an operator has to know to
        // turn on is one that is off during the incident.
        tracing::info!(
            target: GIT_TARGET,
            event = "git.receive_pack.commands",
            commands = commands.len(),
            deletes = commands.iter().filter(|c| c.is_delete()).count(),
            push_options = push_options.len(),
            atomic,
            expects_pack,
            "receive-pack: read the ref command list"
        );
        let mut pack = None;
        // `Some(0)`, not `None`: a deletions-only push carries no pack, and that
        // is a MEASURED zero. A byte limit must refuse an oversized pack, not a
        // `git push --delete`.
        let mut pack_bytes = Some(0u64);
        let mut new_objects: std::result::Result<Vec<ObjectId>, String> = Ok(Vec::new());
        let mut unpack: std::result::Result<(), String> = Ok(());
        if expects_pack {
            // Indexing and verifying what the client sent. This is the stage
            // the receipt's `num_objects` is about, and on a push it is where
            // the money is.
            let _s = crate::stage::enter("pack_ingest");
            tracing::info!(
                target: GIT_TARGET,
                event = "git.receive_pack.receiving",
                "receiving and indexing the pushed pack"
            );
            let started = std::time::Instant::now();
            // Counted HERE rather than by measuring the pack that landed on
            // disk. What a limit is defending against is what the client sent;
            // the file that lands is a different number, because completing a
            // thin pack adds bases the client did not send, and on a store with
            // no filesystem there is no file to measure at all.
            let mut counting = Counting {
                inner: input,
                bytes: 0,
                ceiling: self.config.pack_ceiling.clone(),
                charged_ahead: 0,
                cut: false,
            };
            let received = ingest.ingest(&mut counting);
            pack_bytes = Some(counting.bytes);
            let cut = counting.cut;
            match received {
                Ok(receipt) => {
                    tracing::info!(
                        target: GIT_TARGET,
                        event = "git.receive_pack.indexed",
                        objects = receipt.indexed.num_objects,
                        pack = %receipt.indexed.pack_id,
                        pack_bytes = counting.bytes,
                        elapsed_ms = started.elapsed().as_millis() as u64,
                        "indexed a pushed pack"
                    );
                    new_objects = receipt.new_objects;
                    pack = Some(receipt.indexed);
                }
                Err(err) => {
                    let message = err.client_message();
                    tracing::warn!(
                        target: GIT_TARGET,
                        event = "git.receive_pack.index_failed",
                        pack_bytes = counting.bytes,
                        elapsed_ms = started.elapsed().as_millis() as u64,
                        // **The unredacted error, in the server's own log.**
                        //
                        // This logged `client_message()` — the redacted
                        // sentence the *client* is told — so a push that failed
                        // to index left `the repository is unavailable; try
                        // again` in the log and nothing anywhere said what the
                        // store had actually refused. `Error::client_message`'s
                        // own doc promises the opposite: "the unredacted text is
                        // still what the server logs". The fetch side kept that
                        // promise (`git.upload_pack.failed` carries `%err`);
                        // this side did not, and an operator debugging a refused
                        // push had the client's sentence twice and the cause
                        // never.
                        error = %err,
                        // Kept beside it, so a log reader can match what the
                        // operator was shown to what actually happened.
                        client_message = %message,
                        "the pushed pack did not index"
                    );
                    unpack = Err(message);
                }
            }
            // ── the ceiling, installed over whatever the ingest concluded ──
            //
            // Checked AFTER the match and unconditionally, not inside the `Err`
            // arm, because an ingest that swallowed the cut and answered `Ok`
            // would otherwise hand a partially-read push to `apply` — and a
            // guard that only fires when the code under it already failed is
            // the guard a measured assertion forbids.
            //
            // What the client is told is the ceiling's own line
            // (`PackCeiling::refusal`) and not `Error::client_message`'s: the
            // indexer's honest answer to a truncated stream is "the pack is
            // malformed", and telling a paying tenant their pack was corrupt
            // when what happened is that their plan filled up is the refusal an
            // operator gets a ticket about.
            if cut {
                let line = self
                    .config
                    .pack_ceiling
                    .as_ref()
                    .map(|ceiling| ceiling.refusal.clone())
                    // Unreachable — `Counting::cut` is set only where a ceiling
                    // exists — and still spelled out rather than
                    // `unwrap_or_default()`, because an empty `unpack` line is
                    // a report that says a push failed and will not say why.
                    .unwrap_or_else(|| "the push was cut at its byte ceiling".to_owned());
                tracing::warn!(
                    target: GIT_TARGET,
                    event = "git.receive_pack.ceiling",
                    pack_bytes = counting.bytes,
                    ceiling = self.config.pack_ceiling.as_ref().map(|c| c.bytes),
                    refusal = %line,
                    "the pushed pack passed its byte ceiling and the transfer was aborted \
                     mid-stream; nothing was indexed and no reference was moved"
                );
                // The pack is not `Some`: the ingest never completed one, and a
                // receipt here would make `PushOutcome` claim a pack that does
                // not exist.
                pack = None;
                new_objects = Err(
                    "the push was cut at its byte ceiling before the pack was indexed".to_owned(),
                );
                unpack = Err(line);
            }
        }

        let statuses = if unpack.is_err() {
            vec![RefStatus::Rejected("unpacker error".to_owned()); commands.len()]
        } else {
            let facts = PushFacts {
                objects: self.git,
                new_objects: new_objects.as_deref().map_err(String::as_str),
                pack_bytes,
                push_options: &push_options,
            };
            // `--atomic` is all-or-nothing across the batch, and an unnameable
            // reference is a refusal *in* the batch. `apply` cannot see one —
            // it is only ever handed the commands that have a name — so the
            // fact is passed in, and it must be passed **in** rather than
            // collapsed afterwards: an after-the-fact collapse would rewrite
            // statuses for references the transaction had already applied, and
            // report as refused what is on disk.
            self.apply(&commands, atomic, !unnameable.is_empty(), gate, &facts)?
        };

        let outcome = PushOutcome {
            commands,
            statuses,
            unnameable,
            push_options,
            pack,
            pack_bytes,
            new_objects,
            atomic,
            unpack,
        };

        if report_requested {
            let _s = crate::stage::enter("report");
            self.write_report(out, &outcome, sideband)?;
            out.flush()?;
        }
        Ok(outcome)
    }

    fn read_commands(
        hash_kind: gix_hash::Kind,
        input: &mut dyn BufRead,
    ) -> Result<(Vec<RefCommand>, Vec<UnnameableRef>, PeerCapabilities)> {
        let mut commands = Vec::new();
        let mut unnameable = Vec::new();
        let mut peer = PeerCapabilities::default();
        let mut first = true;
        let mut position = 0usize;
        loop {
            let packet = match read_packet(input)? {
                None => {
                    if first {
                        return Ok((commands, unnameable, peer));
                    }
                    return Err(Error::UnexpectedEof("the push command list"));
                }
                Some(packet) => packet,
            };
            match packet {
                Packet::Flush => break,
                Packet::Data(data) => {
                    let mut text: &[u8] = data.strip_suffix(b"\n").unwrap_or(&data);
                    // ── a shallow announcement is not the first COMMAND ──────
                    //
                    // A shallow client sends `shallow <oid>` for each graft
                    // before its commands. They are not commands, which this
                    // already knew — and they are not the line the capabilities
                    // ride on, which it did not.
                    //
                    // `first` used to mean "the first packet", so a shallow
                    // pusher spent it on a `shallow` line. That line carries no
                    // NUL, so no capabilities were parsed; `first` went false;
                    // and the real first command was then handed to
                    // `parse_command` with its whole capability section still
                    // attached:
                    //
                    // ```text
                    // invalid reference name "refs/heads/x\0 report-status-v2 side-band-64k
                    //   quiet object-format=sha1 agent=git/2.53.0-Linux": control character or space
                    // ```
                    //
                    // So EVERY push from a shallow clone was refused, and the
                    // client saw only `the remote end hung up unexpectedly`.
                    // That is not an exotic client: `actions/checkout` clones at
                    // `fetch-depth: 1` by default, so a CI job that pushes back
                    // is the shape that hits it.
                    //
                    // ISOLATED 2026-08-05 against stock git 2.53.0 over
                    // real SSH, one client, one commit, one server:
                    //
                    // | client                         | push        |
                    // |--------------------------------|-------------|
                    // | `clone --depth 3`              | **refused** |
                    // | the same clone, `fetch --unshallow` first | accepted |
                    //
                    // The second failure hid behind the first: `peer` stayed
                    // empty, so `report-status`, `side-band-64k`, `atomic`,
                    // `push-options` and the `object-format` check were all
                    // silently off for a shallow pusher even where the ref name
                    // happened to validate.
                    //
                    // git does it this way round too — `receive-pack.c`'s
                    // `read_head_info` takes the capabilities off the first
                    // **ref command**, having consumed any `shallow` lines
                    // ahead of it.
                    if text.starts_with(b"shallow ") {
                        continue;
                    }
                    if first {
                        if let Some(nul) = text.iter().position(|&b| b == 0) {
                            peer =
                                PeerCapabilities::parse(&String::from_utf8_lossy(&text[nul + 1..]));
                            text = &text[..nul];
                        }
                        first = false;
                    }
                    match Self::parse_command(hash_kind, text, position)? {
                        Ok(command) => commands.push(command),
                        Err(refused) => unnameable.push(refused),
                    }
                    position += 1;
                }
                other => {
                    return Err(Error::protocol(format!(
                        "unexpected {other:?} in the push command list"
                    )))
                }
            }
        }
        Ok((commands, unnameable, peer))
    }

    /// One `<old> <new> <ref>` line, decoded by the shared grammar and then
    /// validated as a reference name by this server.
    ///
    /// **The split is on bytes, not on a lossy `String`,** and that is
    /// [`gunnar_sendpack::command::parse_line`]'s job now rather than this
    /// function's. Reference names are bytes in git: `refs/heads/\xff` is a
    /// name git will create and serve. Routing the line through
    /// `String::from_utf8_lossy` first replaced every invalid byte with U+FFFD
    /// *before* the name was validated, so a non-UTF-8 name was accepted and
    /// stored under a name the client never asked for — and any two names
    /// differing only in their invalid bytes collapsed onto the same reference,
    /// making a push to one silently overwrite the other.
    ///
    /// What stays here is the part that is this **server's** policy rather than
    /// the format's: **validating the name**, and
    /// reporting a hash-kind mismatch as [`Error::ObjectFormatMismatch`] so the
    /// client is told which two formats disagreed. The grammar refuses the same
    /// oid; it just cannot know what gunnar wants to call the refusal.
    /// The grammar's own failures stay hard errors — a line that is not
    /// `<old> <new> <ref>`, or an oid in the wrong hash, means the two ends
    /// disagree about the *format* and there is nothing left to report over.
    /// A name that parses and this server will not have is a different thing:
    /// the conversation is intact and the client is owed a line about that one
    /// reference, so it comes back as [`UnnameableRef`] rather than as `Err`.
    fn parse_command(
        hash_kind: gix_hash::Kind,
        line: &[u8],
        position: usize,
    ) -> Result<std::result::Result<RefCommand, UnnameableRef>> {
        let command =
            gunnar_sendpack::command::parse_line(line, hash_kind).map_err(|err| match err {
                gunnar_sendpack::Error::ObjectFormat { ours, theirs } => {
                    Error::ObjectFormatMismatch {
                        ours,
                        theirs: theirs.to_owned(),
                    }
                }
                other => Error::protocol(other.to_string()),
            })?;
        // ── the validator moved; the rule set did not ───────────────────────
        //
        // This was `FullName::new`, whose constructor ran the rules. The type
        // is gone with `RefStore` and the rules live in
        // `gunnar_store::refname::validate` — the same rules, in the place
        // `locked_on_target.md`'s checklist names, reached the same way
        // `gunnar-core::check_branch` reaches them. **This call is
        // load-bearing**: `gunnar-policy`'s gate layer no longer catches
        // `refs/heads/../../etc/passwd`, because its `RefUpdate::name` is a
        // plain `String` now, so this is the path-traversal boundary.
        //
        // ⚠ **A narrowing, recorded rather than hidden.** Reference names are
        // bytes in git and `FullName` held bytes; `RefRow::name` and
        // `RefUpdate::name` on the contract are `String`, so a name that
        // passes validation but is not UTF-8 — `refs/heads/\xff`, which git
        // will create and serve — can no longer be represented and comes back
        // as an [`UnnameableRef`] instead. That refuses **one** reference with
        // a line the client can read, and leaves the rest of the push
        // untouched, which is the failure direction this whole type exists to
        // produce. It is a real interop narrowing and it belongs to the
        // contract, not to this function: widening it means widening
        // `RefRow::name`, a two-implementation decision.
        let name = match validate_ref_name(command.name.as_slice()) {
            Ok(name) => name,
            Err(err) => {
                tracing::warn!(
                    target: GIT_TARGET,
                    event = "git.receive_pack.unnameable_ref",
                    ref_name = %String::from_utf8_lossy(&command.name),
                    error = %err,
                    "refusing one reference for its name; the rest of the push continues"
                );
                return Ok(Err(UnnameableRef {
                    name: command.name.to_vec(),
                    // git says `funny refname` and nothing else; gunnar's
                    // validator already knows *which* rule was broken and the
                    // client can only act on being told.
                    //
                    // **The rule and not the error.** `Error::InvalidRefName`
                    // renders the offending name inside its own message, so
                    // `format!("{err}")` here put the name in the `ng` line
                    // twice — and the `ng` line is one pkt-line. A 60 000-byte
                    // name then produced a 120 105-byte packet, `write_text`
                    // refused it with `TooLong`, and the whole report failed to
                    // write: the refusal path had its own way of dropping the
                    // session, which is the exact defect this type exists to
                    // remove. MEASURED 2026-08-06.
                    reason: format!("funny refname: {err}"),
                    position,
                    deletes: command.new.is_null(),
                }));
            }
        };
        Ok(Ok(RefCommand {
            old: command.old,
            new: command.new,
            name,
        }))
    }

    /// Connectivity, policy, then the reference transaction.
    fn apply(
        &self,
        commands: &[RefCommand],
        atomic: bool,
        already_refused: bool,
        gate: &dyn PushGate,
        facts: &PushFacts<'_>,
    ) -> Result<Vec<RefStatus>> {
        let mut statuses = vec![RefStatus::Ok; commands.len()];
        let _conn = crate::stage::enter("connectivity");
        tracing::info!(
            target: GIT_TARGET,
            event = "git.receive_pack.checking",
            commands = commands.len(),
            "checking that the push closes, before any reference moves"
        );
        let started = std::time::Instant::now();

        // ── THE INDEX, BEFORE THE WALK AND NOT DURING IT (`S-0xx`) ─────────
        //
        // The znippy arm acks a push's *bytes* before its *oids* exist: a pack
        // entry does not carry an oid, so one is knowable only after its delta
        // chain has been applied, and doing 13.9 M of those is what the indexer
        // puts behind the ack. Everything below reads objects by oid.
        //
        // Without this call the walk's first `peel` fell into that backend's
        // read-time fallback, which resolves the whole pending pack **on this
        // thread and throws the rows away** — a second resolver beside the
        // indexer's, over the same objects, each holding its own O(pack) tables.
        // MEASURED on the rig 2026-08-30, gunnar v1.1.3-180-g01ce6c58,
        // torvalds/linux --mirror at 16 GB and at 8 GB:
        //
        // ```text
        // 07:43:49  git.receive_pack.checking   commands=2874
        // 09:20:21  index.scanned  "a reader scanned a pending pack for itself:
        //                           13948244 objects in 5792442 ms"
        // ```
        //
        // 1h36m32s and 1h44m14s for the scan alone, and `git.receive_pack.checked`
        // never fired on either box: the clients sat unacked 2h14m30s and
        // 2h12m24s and the run ended only because the repository was deleted.
        // Stock git acks the same mirror in ~11 min, and it does it by running
        // `index-pack` first and `check_connected` second — which is exactly
        // this line and then the rest of this function.
        //
        // One call, before any read, so the resolve happens ONCE and everything
        // after it is a point lookup. A backend that indexes inside `put_pack`
        // (the gix arm) answers `Ok(0)` and pays nothing.
        match self.git.index_now() {
            Ok(0) => {}
            Ok(packs) => tracing::info!(
                target: GIT_TARGET,
                event = "git.receive_pack.indexed_for_check",
                packs = packs,
                elapsed_ms = started.elapsed().as_millis() as u64,
                "finished the deferred index before checking that the push closes"
            ),
            // **The push is REFUSED, and this used to be a warning it walked
            // past.** MEASURED on the rig 2026-08-30 at 8 GB, which is
            // what changed my mind: the backend's watchdog gave up by name
            // after 300 s (`index.gave_up ... 1.5G available, 1.6G needed`) —
            // exactly as designed, five minutes instead of the 1h45m park it
            // replaced — and then this arm logged a warning and went on to the
            // walk anyway. The walk's first read then fell into the very
            // read-time fallback `index_now` exists to keep it out of: a
            // whole-pack resolve on the connection thread, on a box that had
            // just said in writing that it cannot resolve this pack. The client
            // was back to hanging, one layer down.
            //
            // "Failing the push on this line would turn a slow index into a
            // refused mirror" — the old comment — mistook the error class. This
            // is not the backend being slow; it is the backend saying *I could
            // not make these objects addressable*. A check that cannot look
            // objects up cannot say whether the push closes, and the only
            // honest answers are "refused, here is why" or an unbounded wait.
            //
            // The backend's own sentence is carried through verbatim, because
            // it is the one that names what was available, what the floor is,
            // how far it got and what the operator can do. The bytes stay
            // durable and nothing partial is indexed, so the same push retried
            // on a bigger box is a no-op transfer and an ack.
            //
            // **A DIFFERENT event name from the ingest's**, deliberately.
            // `git.receive_pack.index_failed` already means *the pushed pack
            // could not be stored*; this one means *the pack is stored and
            // could not be indexed*. One name over two outcomes is a log
            // nobody can read.
            Err(err) => {
                tracing::warn!(
                    target: GIT_TARGET,
                    event = "git.receive_pack.index_for_check_failed",
                    commands = commands.len(),
                    elapsed_ms = started.elapsed().as_millis() as u64,
                    error = %format_args!("{err:#}"),
                    "the deferred index did not finish, so the push cannot be checked and is \
                     refused; the pack's bytes are durable and nothing partial was indexed"
                );
                // **The ROOT cause on the `ng` line, the whole chain in the
                // log.** `{err:#}` prints outermost-first, and on the rig at
                // 8 GB the outermost frame is the absorber's own context —
                // "absorbing pack 0 at (0, 8723679817) — its bytes are durable
                // and it stays un-indexed; reads will keep falling back rather
                // than answer absent" — which is true for a READER and is not
                // what happened to this push, and which buried the sentence
                // that matters ("this box cannot index this pack: the memory
                // watchdog gave up (deadline) with 1.2G available against a
                // 1.6G floor") 150 characters into a one-line pkt.
                //
                // A `ng` line is read by a person at a terminal, once. It gets
                // the cause; the log keeps the chain.
                let reason = format!("the push cannot be checked: {}", err.root_cause());
                return Ok(commands
                    .iter()
                    .map(|command| {
                        // A delete needs no object of this push to be
                        // addressable, and refusing one because an unrelated
                        // pack would not index is a refusal with no cause.
                        if command.is_delete() {
                            RefStatus::Ok
                        } else {
                            RefStatus::Rejected(reason.clone())
                        }
                    })
                    .collect());
            }
        }

        // The frontier is what the repository already pointed at. It is built
        // ONCE, not once per command: a push of a thousand references used to
        // peel every reference in the repository a thousand times.
        let snapshot = RefSnapshot::read(self.git)?;
        let mut frontier_values: Vec<ObjectId> = Vec::with_capacity(snapshot.rows().len());
        for row in snapshot.rows() {
            // Direct rows only, exactly as before: a symbolic reference points
            // at one of the others, so following it would put the same oid in
            // the frontier twice and add nothing.
            if let Some(bytes) = &row.oid {
                frontier_values.push(crate::graph::oid(bytes)?);
            }
        }
        let graph = Graph::new(self.git, self.hash_kind).with_budget(self.config.budget);
        let frontier = Frontier::new(&graph, &frontier_values)?;

        // ── ONE walk for the whole push, not one per reference ──────────────
        //
        // `verify_connectivity` already takes a *slice* of tips and shares one
        // `seen` set across all of them, and `Frontier` is already built once
        // for the whole push. What was still per-command was the
        // call, so N references over one shared history walked that history N
        // times — the second axis the earlier fix never touched.
        //
        // A mirror push is exactly that shape and it is the ordinary case for
        // an import: `linux.git` is 13 816 503 objects under **2 872**
        // references on one mainline, so the union of their closures is the
        // closure of the newest and the per-command loop was doing it 2 872
        // times. MEASURED 2026-08-05 on the in-memory fixture
        // (`tests/connectivity_cost.rs`): 64 references over one 200-commit
        // history cost **54.2x** what pushing the newest alone costs, 33 376
        // object touches against 616. The ratio is the reference count, so at
        // 2 872 it is the reason a kernel-sized push writes its pack and its
        // index and then never reports.
        //
        // And in wall clock, on a real repository over the `fs` backend, with
        // every object already present and an empty pack so that the
        // connectivity walk is the only thing in the bracket. `small.git`,
        // 12 328 objects, sha1; N references taken down one first-parent line,
        // which is `linux.git`'s shape; 2026-08-05, `taskset -c 0-7`, best
        // of three, **box not idle** (load average 33–48):
        //
        // ```text
        //   refs      per-command        batched      ratio
        //      1          0.086 s        0.084 s       1.0x
        //     16          0.561 s        0.104 s       5.4x
        //     64          1.978 s        0.141 s      14.0x
        //    256          6.903 s        0.122 s      56.6x
        //    512         10.424 s        0.153 s      68.1x
        // ```
        //
        // The left column is linear in the reference count and the right one is
        // flat, which is the whole claim. `njord.git` (10 468 objects, one
        // 966 MB blob) is the same shape at 17.0x for 256 references.
        //
        // # Why the per-command walk is still here
        //
        // Because the event stream reports **per reference**, and one batched walk can only
        // say that *something* in the push does not close. So the batch is the
        // fast path and the per-command loop is the attribution path, entered
        // only when the batch has already failed. That keeps the common case
        // O(union) and pays O(sum) only on a push that is being refused
        // anyway — which is also the one case where an operator needs the
        // precision. git makes the cheaper choice here and refuses the whole
        // batch with one message (`check_connected` over the entire command
        // list, `receive-pack.c`); this keeps gunnar's finer report at a cost
        // that is only ever paid on failure.
        let new_tips: Vec<ObjectId> = commands
            .iter()
            .filter(|command| !command.is_delete())
            .map(|command| command.new)
            .collect();
        let batch = if new_tips.is_empty() {
            Ok(0)
        } else {
            verify_connectivity(&graph, &new_tips, &frontier)
        };
        match &batch {
            Ok(objects) => tracing::info!(
                target: GIT_TARGET,
                event = "git.receive_pack.checked",
                tips = new_tips.len(),
                objects = *objects,
                elapsed_ms = started.elapsed().as_millis() as u64,
                "connectivity verified"
            ),
            Err(err) => tracing::warn!(
                target: GIT_TARGET,
                event = "git.receive_pack.check_failed",
                tips = new_tips.len(),
                elapsed_ms = started.elapsed().as_millis() as u64,
                error = %err,
                "the push does not close; re-walking one reference at a time to say which"
            ),
        }

        for (index, command) in commands.iter().enumerate() {
            // **A deletion skips the WALK, not the GATE.**
            //
            // `if command.is_delete() { continue; }` stood here, and the half
            // of it that is right is the walk: a deletion introduces no
            // objects, `command.new` is the zero oid, and there is nothing for
            // `verify_connectivity` to reach. The `continue` carried
            // `gate.check` out with it — so **every deletion bypassed the
            // pre-receive gate entirely**.
            //
            // What that made unenforceable is not a corner: `allow_delete` is
            // the field whose whole purpose is refusing this push, and
            // `allowed_principal_ids` and the ref-name denylist were bypassable
            // the same way. A branch could be protected against a force-push,
            // visibly and correctly, and deleted outright by anyone with WRITE
            // — which is a strictly worse outcome than the force-push beside
            // it, and was invisible behind it because the force-push guard
            // worked.
            //
            // `gunnar-policy`'s protection gate has always had a
            // `UpdateKind::Delete` arm with its own refusal message, and
            // `tests/protection.rs` exercises `allow_delete` directly. The gate
            // was written for this and simply never handed one.
            //
            // Found by `gunnar-ui/uitest`'s `a-protected-branch-refuses-a-deletion`
            // going red while the force-push row beside it passed; pinned in
            // `gunnar-cli/tests/policy_mirror.rs::a_protection_rule_refuses_a_real_branch_deletion`,
            // which drives stock git over real ssh.
            if !command.is_delete() && batch.is_err() {
                if let Err(err) = verify_connectivity(&graph, &[command.new], &frontier) {
                    tracing::warn!(target: GIT_TARGET, ref_name = %command.name, error = %err, "push failed connectivity");
                    statuses[index] =
                        RefStatus::Rejected(format!("missing necessary objects: {err}"));
                    continue;
                }
            }
            if let Err(reason) = gate.check(command, facts) {
                statuses[index] = RefStatus::Rejected(reason);
            }
        }

        crate::stage::end(_conn);
        if atomic && (already_refused || statuses.iter().any(|s| !s.is_ok())) {
            // One refusal fails the batch. Every other reference is reported
            // as refused too, or the client is told a lie about what landed.
            // `already_refused` carries in the refusals that never became a
            // command at all — a reference whose *name* this server will not
            // have (see [`UnnameableRef`]).
            for status in statuses.iter_mut() {
                if status.is_ok() {
                    *status = RefStatus::Rejected("atomic transaction failed".to_owned());
                }
            }
            return Ok(statuses);
        }

        // `edit_for` peels a new annotated-tag tip **once, here on the write
        // path**, while the objects the push just delivered are warm and the
        // connectivity walk above has already proven them present, and hands the
        // terminal commit to the store as the ref row's peeled column. That is
        // the write half of the stored peel: with it stored, `advertise_from` reads the
        // peeled oid straight out of the row and never re-peels, so every later
        // clone, fetch and `ls-remote` pays zero object reads per tag instead of
        // one. The graph is the same one `verify_connectivity` just used.
        let edits: Vec<(usize, RefCas<'_>)> = commands
            .iter()
            .enumerate()
            .filter(|(index, _)| statuses[*index].is_ok())
            .map(|(index, command)| Ok((index, edit_for(command, &graph)?)))
            .collect::<Result<_>>()?;

        let _txn = crate::stage::enter("ref_txn");
        tracing::info!(
            target: GIT_TARGET,
            event = "git.receive_pack.updating",
            edits = edits.len(),
            atomic,
            "applying the reference updates"
        );
        let started = std::time::Instant::now();
        if atomic {
            // ONE call. `put_refs_cas` applies every edit or none and names the
            // first whose expected value did not match; a loop with a break
            // leaves what it already applied applied, which is the thing
            // `--atomic` promises does not happen.
            let batch: Vec<RefCas<'_>> = edits
                .iter()
                .map(|(_, edit)| RefCas {
                    name: edit.name.clone(),
                    old: edit.old,
                    new: edit.new,
                    peeled: edit.peeled.clone(),
                })
                .collect();
            if let Err(err) = self.git.put_refs_cas(&batch) {
                let reason = format!("atomic transaction failed: {err:#}");
                for (index, _) in &edits {
                    statuses[*index] = RefStatus::Rejected(reason.clone());
                }
            }
        } else {
            // **One transaction when every compare-and-swap holds** (GUN-PERF, 2026-10-31).
            // `put_refs_cas` applies all of its edits or none, so a batch that lands is
            // exactly the per-reference outcome of a push where no race was lost, at one
            // durable commit instead of one per reference (the first push of git.git:
            // 1 006 references, 22 s of fsyncs). A batch that fails applied nothing, and
            // the loop below then gives each reference its own answer as before.
            let batch: Vec<RefCas<'_>> = edits
                .iter()
                .map(|(_, edit)| RefCas {
                    name: edit.name.clone(),
                    old: edit.old,
                    new: edit.new,
                    peeled: edit.peeled.clone(),
                })
                .collect();
            let landed = edits.len() > 1 && self.git.put_refs_cas(&batch).is_ok();
            for (index, edit) in edits.into_iter().filter(|_| !landed) {
                // A **one-element `put_refs_cas`**, not `update_ref`: both are
                // the contract's compare-and-swap-one-reference call and a
                // single-edit atomic batch has identical single-ref semantics —
                // it applies that one ref or rejects it, raising the same typed
                // `RefRejection::Cas` a lost race needs. `update_ref` cannot
                // carry a peel (its signature is `name/old/new`), and `put_refs`
                // would lose the CAS; `put_refs_cas` keeps the CAS **and** takes
                // the `RefCas::peeled` this edit computed, so the non-atomic path
                // stores a tag's peel exactly as the atomic path above does
                // (one implementation: one writer for both, not a peel-losing
                // second one).
                // Per-ref independence is preserved because each edit is its own
                // call — a rejection on one does not touch the others.
                if let Err(err) = self.git.put_refs_cas(std::slice::from_ref(&edit)) {
                    // ── THE TYPED REJECTION, RECOVERED ──────────────────────
                    //
                    // This used to render `{err:#}` and the comment here said
                    // so: "A LOST COMPARE-AND-SWAP AND A BROKEN DISK ARE THE
                    // SAME STRING HERE … Nothing may recover the distinction by
                    // matching on this text; the fix is a typed rejection on
                    // the contract."
                    //
                    // The contract had `RefRejection` all along
                    // (`git-storage-trait`); `put_refs_cas` raised it and
                    // `update_ref` raised a bare `bail!`. That is fixed, so the
                    // distinction is a `downcast_ref` rather than a substring
                    // match — and this is deliberately NOT parsing the message,
                    // which the old comment forbade in as many words.
                    //
                    // A lost race is rendered in git's own words so a client
                    // can act on it; anything else keeps the full chain,
                    // because a broken disk deserves every line it has.
                    statuses[index] = match err.downcast_ref::<RefRejection>() {
                        Some(RefRejection::Cas {
                            name,
                            expected,
                            actual,
                        }) => RefStatus::Rejected(format!(
                            "cannot lock ref '{name}': is at {} but expected {}",
                            render(actual),
                            render(expected)
                        )),
                        Some(RefRejection::Locked { name }) => RefStatus::Rejected(format!(
                            "cannot lock ref '{name}': held by another writer, try again"
                        )),
                        _ => RefStatus::Rejected(format!("{err:#}")),
                    };
                }
            }
        }
        tracing::info!(
            target: GIT_TARGET,
            event = "git.receive_pack.updated",
            // Counted off `statuses`, which is what the client is about to be
            // told, rather than off `edits`, which is only what was attempted.
            // A transaction that lost its compare-and-swap turns an edit into a
            // refusal, and a log reporting the attempt would report a reference
            // as moved that did not (measure, never assert).
            refs_ok = statuses.iter().filter(|s| s.is_ok()).count(),
            refs_rejected = statuses.iter().filter(|s| !s.is_ok()).count(),
            elapsed_ms = started.elapsed().as_millis() as u64,
            "the reference updates are applied"
        );

        Ok(statuses)
    }

    fn write_report(
        &self,
        out: &mut dyn Write,
        outcome: &PushOutcome,
        sideband: bool,
    ) -> Result<()> {
        // The lines come from the shared grammar (`gunnar-sendpack`), which is
        // the same code `gunnar-client` parses them with. What stays here is
        // the framing and the sideband, which belong to this transport.
        //
        // The two lists are merged back into the order the client sent, using
        // the position [`UnnameableRef`] carries. A client zips the report
        // against its own command list — `gunnar-client` does, and so does
        // `git push`'s per-ref output — so a report that silently reordered
        // would attribute a refusal to the wrong reference.
        let mut statuses: Vec<(usize, gunnar_sendpack::RefStatus)> = Vec::new();
        // The nameable commands sit at the positions the unnameable ones left
        // free, in order, which is enough to recover the original interleaving
        // without threading a position through `RefCommand` as well.
        let taken: std::collections::BTreeSet<usize> =
            outcome.unnameable.iter().map(|r| r.position).collect();
        let mut free = (0..).filter(|position| !taken.contains(position));
        for (command, status) in outcome.commands.iter().zip(&outcome.statuses) {
            let position = free.next().expect("the position sequence is infinite");
            statuses.push((
                position,
                match status {
                    RefStatus::Ok => gunnar_sendpack::RefStatus::ok(command.name.as_bytes()),
                    RefStatus::Rejected(reason) => {
                        gunnar_sendpack::RefStatus::rejected(command.name.as_bytes(), reason)
                    }
                },
            ));
        }
        for refused in &outcome.unnameable {
            statuses.push((
                refused.position,
                gunnar_sendpack::RefStatus::rejected(
                    echoable_name(&refused.name, refused.reason.len()),
                    &refused.reason,
                ),
            ));
        }
        statuses.sort_by_key(|(position, _)| *position);
        let statuses: Vec<gunnar_sendpack::RefStatus> =
            statuses.into_iter().map(|(_, status)| status).collect();
        let mut report = Vec::new();
        for line in gunnar_sendpack::report::lines(
            outcome.unpack.as_ref().copied().map_err(String::as_str),
            &statuses,
        ) {
            gunnar_pktline::write_text(&mut report, &line)?;
        }
        gunnar_pktline::write_flush(&mut report)?;

        if sideband {
            // The notice first, on band 2, so `git push` prints it as
            // `remote: …` ABOVE the per-ref lines — where a person reads before
            // the summary — and separately framed so a client that ignores band
            // 2 still parses the report untouched.
            if let Some(notice) = &self.config.notice {
                let mut line = notice.clone();
                if !line.ends_with('\n') {
                    line.push('\n');
                }
                wire_io::progress(out, true, &line)?;
            }
            // The whole report, its terminating flush-pkt included, rides band
            // 1; the connection is then ended by an *unframed* flush. That is
            // what `git-receive-pack` 2.53 puts on the wire (captured
            // 2026-08-03), and a client reading a demultiplexed stream cannot
            // see the difference between the two flushes any other way.
            wire_io::sideband(out, BAND_PACK, &report)?;
            wire_io::flush(out)?;
        } else {
            out.write_all(&report)?;
        }
        Ok(())
    }
}

/// The most of a refused reference name that can go into an `ng` line without
/// the line outgrowing a pkt-line.
///
/// A refused name is the one name on the wire that is **not** bounded by
/// anything gunnar chose: it arrived because a client sent it, and the only
/// ceiling it ever passed is the command packet's own 65 516 bytes. Echoing it
/// whole into `ng <name> <reason>\n` therefore does not fit whenever the name is
/// within a few dozen bytes of that ceiling, and `write_text` refuses the packet
/// rather than emitting a header that lies. The report then does not get
/// written at all — the session dies on the refusal path, which is precisely the
/// failure [`UnnameableRef`] exists to remove.
///
/// So a name too big to echo is truncated and **says so**, rather than costing
/// the client the whole report. A name this long cannot match anything the
/// client will keep anyway: every legal name is at most
/// [`gunnar_store::MAX_REF_COMPONENT_BYTES`] per component.
fn echoable_name(name: &[u8], reason_len: usize) -> Vec<u8> {
    const MARKER: &[u8] = b"\xe2\x80\xa6[truncated]";
    // `ng ` + name + ` ` + reason + `\n`.
    let overhead = 3 + 1 + reason_len + 1;
    let budget = gunnar_pktline::MAX_PAYLOAD.saturating_sub(overhead);
    if name.len() <= budget {
        return name.to_vec();
    }
    let keep = budget.saturating_sub(MARKER.len());
    let mut out = name[..keep].to_vec();
    out.extend_from_slice(MARKER);
    out
}

/// The rules `FullName::new` used to run, plus the one the contract adds.
///
/// Two failures with one shape, because both produce the same `ng` line and a
/// caller that had to tell them apart would be re-deriving the difference from
/// a message: the name is not a legal reference name, or it is legal and is not
/// UTF-8 and therefore cannot be a `RefRow::name`. See
/// [`ReceivePack::parse_command`] for why the second is a recorded narrowing
/// rather than a bug.
fn validate_ref_name(name: &[u8]) -> std::result::Result<String, String> {
    gunnar_store::refname::validate(name).map_err(|err| match err {
        // The validator renders the offending name inside its own message and
        // the `ng` line already carries the name, so only the *rule* is taken.
        // A 60 000-byte name once produced a 120 105-byte packet, `write_text`
        // refused it, and the whole report failed to write. MEASURED
        // 2026-08-06.
        gunnar_store::Error::InvalidRefName { reason, .. } => reason.to_owned(),
        other => other.to_string(),
    })?;
    String::from_utf8(name.to_vec())
        .map_err(|_| "a reference name gunnar can store must be UTF-8".to_owned())
}

/// One command as a compare-and-swap edit, with a tag's peeled target attached.
///
/// The three shapes `PreviousValue`/`Change` used to spell out collapse into
/// two `Option`s, because that is what the contract says and it says the same
/// thing: `old: None` is *"must not exist"* (a create) and `new: None` is a
/// delete. A non-create carries `command.old`, which is the client's claim
/// about what the reference holds and the only thing that makes this a race
/// the server can lose safely.
///
/// The fourth field, `peeled`, is the one this fn computes rather than copies:
/// an annotated tag's terminal commit, so the store persists it and the
/// advertisement never re-peels. It needs the object graph, which is why
/// this takes `graph` and returns a `Result` — a peel is a store read.
fn edit_for<'a>(command: &'a RefCommand, graph: &Graph<'_>) -> Result<RefCas<'a>> {
    // The peeled column, filled only for a create/update whose new tip is an
    // annotated tag. `peel` follows the tag chain to the first non-tag object
    // (a commit, in the common case of one tag over a commit) and reports the
    // tags it walked through: `tags` is non-empty **iff** the tip really was a
    // tag object, which is the only case git's `packed-refs` writes a `^peeled`
    // line for. A delete has no tip; a branch head or a lightweight tag peels to
    // itself with no tag walked, so both carry `None` and the store keeps no
    // peel — matching packed-refs exactly. This is the one peel per new tag the
    // whole stored-peel fix costs, paid once at ingest instead of on every
    // advertisement.
    //
    // The oid is **owned** here (`RefCas::peeled` is `Option<Vec<u8>>`): unlike
    // `old`/`new`, which are slices of the command line the caller still holds,
    // the peel is derived from the store and borrows nothing.
    let peeled = if command.is_delete() {
        None
    } else {
        let peeled = graph.peel(&command.new)?;
        (!peeled.tags.is_empty()).then(|| peeled.id.as_bytes().to_vec())
    };
    Ok(RefCas {
        name: command.name.clone(),
        old: (!command.is_create()).then(|| command.old.as_bytes()),
        new: (!command.is_delete()).then(|| command.new.as_bytes()),
        peeled,
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    /// **[`echoable_name`] is a fix, not a guard, and it is not reachable from
    /// the wire today. Both facts, with the arithmetic that decides them.**
    ///
    /// A command line is one pkt-line, so the longest reference name a client
    /// can physically send is `MAX_PAYLOAD - (40 + 1 + 40 + 1 + 1)` =
    /// **65 433** bytes. The `ng` line it comes back in is
    /// `"ng " + name + " " + reason + "\n"`, so the name fits whenever the
    /// reason is at most `65 511 - 65 433` = **78** bytes. Every reason
    /// [`ReceivePack::parse_command`] can produce is `"funny refname: "` plus
    /// one of the validator's fixed strings, and the longest of those makes
    /// **51**. 51 < 78, so no push can currently reach the truncation.
    ///
    /// That margin is 27 bytes of coincidence between two constants nobody
    /// holds together. One longer refusal string and the report becomes
    /// unwritable — `write_text` refuses the packet, `write_report` returns
    /// `Err`, and the session dies **on the refusal path**, which is the exact
    /// failure [`UnnameableRef`] exists to remove. So the truncation stays, and
    /// this is where it is proven, since the wire cannot prove it.
    #[test]
    fn a_refused_name_too_long_to_echo_is_truncated_rather_than_costing_the_report() {
        let name = vec![b'x'; gunnar_pktline::MAX_PAYLOAD - 40];
        let reason = "funny refname: ".to_owned() + &"y".repeat(200);

        let echoed = echoable_name(&name, reason.len());

        // The property that matters, asserted FIRST so a red run names the
        // consequence and not a proxy for it: the line frames.
        let mut line = b"ng ".to_vec();
        line.extend_from_slice(&echoed);
        line.push(b' ');
        line.extend_from_slice(reason.as_bytes());
        let mut out = Vec::new();
        gunnar_pktline::write_text(&mut out, &line).unwrap_or_else(|err| {
            panic!(
                "the `ng` line for a refused {}-byte name does not fit in a pkt-line ({err}), \
                 so the whole report fails to write and the session dies on the refusal path",
                name.len()
            )
        });
        assert!(
            echoed.ends_with("…[truncated]".as_bytes()),
            "the name was cut to fit and the client is not told; a truncated name that \
             does not say so is a name the client cannot match against its own list"
        );

        // And the acceptance half: a name that fits is not touched.
        let short = b"refs/heads/main".to_vec();
        assert_eq!(
            echoable_name(&short, reason.len()),
            short,
            "a name that fits was truncated anyway"
        );
    }
}