gunnar-sendpack 1.1.0

git's receive-pack wire format, both ends: the send-pack conversation gitoxide does not have, plus the server-side encoders for the same grammar. Plumbing only, no gunnar types.
Documentation
//! `report-status` and `report-status-v2`: written by the server, read by the
//! client.
//!
//! ```text
//! unpack ok                       …or `unpack <why the pack was refused>`
//! ok refs/heads/main
//! ng refs/heads/x <why>
//! option refname refs/heads/main  …report-status-v2 only, attached to the ref above
//! 0000
//! ```
//!
//! **Both halves of the verdict matter.** `unpack ok` with every ref `ng` is a
//! completely failed push that reports a successful unpack, so
//! [`PushReport::is_ok`] is a conjunction and not a look at the first line.

use bstr::{BString, ByteSlice};

use crate::advertisement::chomp;
use crate::error::{Error, Result};

/// The remote's verdict on one ref.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RefStatus {
    /// The ref the remote is talking about.
    pub name: BString,
    /// `ok` (true) or `ng` (false).
    pub accepted: bool,
    /// The rejection reason, verbatim, for `ng`.
    pub reason: Option<String>,
    /// `report-status-v2` `option <key> [value]` lines attached to this ref.
    pub options: Vec<(String, Option<String>)>,
}

impl RefStatus {
    /// An accepted ref.
    pub fn ok(name: impl Into<BString>) -> Self {
        RefStatus {
            name: name.into(),
            accepted: true,
            reason: None,
            options: Vec::new(),
        }
    }

    /// A refused ref, with the reason the client is told.
    pub fn rejected(name: impl Into<BString>, reason: impl Into<String>) -> Self {
        RefStatus {
            name: name.into(),
            accepted: false,
            reason: Some(reason.into()),
            options: Vec::new(),
        }
    }
}

/// The remote's verdict on the push as a whole.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct PushReport {
    /// `receive-pack`'s verdict on the packfile: `"ok"`, or its error text.
    pub unpack: String,
    /// One entry per ref the remote reported on.
    pub refs: Vec<RefStatus>,
    /// Band-2 output: the remote's hooks and progress meters.
    pub progress: Vec<String>,
    /// Band-3 output: fatal remote errors.
    pub remote_errors: Vec<String>,
    /// Refs this client commanded that the remote's report **never mentioned**.
    ///
    /// Filled by [`PushReport::reconcile`]; empty until it is called, and empty
    /// on the path where no report was negotiated at all.
    pub unreported: Vec<BString>,
}

impl PushReport {
    /// True only if the pack unpacked, every reported ref was accepted, **and
    /// every ref this client commanded was reported on**.
    ///
    /// # The third clause is the one that was missing
    ///
    /// `refs` is one entry per ref *the remote chose to talk about*, so
    /// `iter().all(…)` over an empty list is vacuously true. A report of
    /// exactly `unpack ok` + flush — no `ok`, no `ng` — therefore made
    /// [`is_ok`](Self::is_ok) true, [`failure_summary`](Self::failure_summary)
    /// return `None`, and `gunnar push` exit 0, having moved nothing. The whole
    /// verdict was computed from lines the remote was free not to send.
    ///
    /// The same hole swallows a partial report: a ten-ref push whose remote
    /// reports on one is nine silent no-ops.
    ///
    /// git closes it from the other end and does not trust the report either
    /// (`send-pack.c`): every commanded ref is pre-seeded
    /// `REF_STATUS_EXPECTING_REPORT`, `receive_status()` overwrites the ones it
    /// hears about, and anything still expecting afterwards is printed as
    /// `remote failed to report status`. `transport.c:push_had_errors()` then
    /// whitelists only `NONE`/`UPTODATE`/`OK`, so it is a non-zero exit and not
    /// a warning. [`reconcile`](Self::reconcile) is that pre-seeding, done
    /// after the fact because this parser has no reason to know the command
    /// list until it is handed one.
    ///
    /// # The fourth clause
    ///
    /// Sideband 3 is defined as *"a fatal error message just before the stream
    /// aborts"*; git's `recv_sideband` calls `die` on it. gunnar collected band
    /// 3 into [`remote_errors`](Self::remote_errors), printed it, and then
    /// computed the verdict without it — so a remote that aborted with a
    /// reason on band 3 while its band-1 report still said `unpack ok` exited
    /// 0. The reason was on the screen and the exit code disagreed with it.
    pub fn is_ok(&self) -> bool {
        self.unpack == "ok"
            && self.unreported.is_empty()
            && self.remote_errors.is_empty()
            && self.refs.iter().all(|r| r.accepted)
    }

    /// Account the report against the refs that were actually commanded.
    ///
    /// Anything commanded and not reported on lands in
    /// [`unreported`](Self::unreported) and fails the push. Anything reported
    /// and **not** commanded is turned into a rejection rather than an
    /// acceptance: git treats it as a protocol error, and the shape that
    /// matters here is that such a line must never be what satisfies
    /// `all(|r| r.accepted)` for a ref the remote said nothing about.
    ///
    /// Idempotent, so a caller that reconciles twice does not double-count.
    pub fn reconcile(&mut self, commanded: &[BString]) {
        self.unreported = commanded
            .iter()
            .filter(|name| !self.refs.iter().any(|r| &r.name == *name))
            .cloned()
            .collect();
        for status in &mut self.refs {
            if !commanded.iter().any(|name| name == &status.name) && status.accepted {
                status.accepted = false;
                status.reason = Some(
                    "the remote reported on a reference this push did not command".to_string(),
                );
            }
        }
    }

    /// The refs the remote refused.
    pub fn rejected(&self) -> impl Iterator<Item = &RefStatus> {
        self.refs.iter().filter(|r| !r.accepted)
    }

    /// Everything the remote said in its own words, trimmed, in wire order:
    /// band 2 first, then band 3. What `git push` prints prefixed `remote:`.
    ///
    /// # Why this exists rather than two fields read separately
    ///
    /// A `pre-receive` hook writes its refusal to **stderr**, and
    /// `receive-pack` muxes stderr onto **band 2**, which lands in
    /// [`progress`](Self::progress). Band 3 is reserved for fatal protocol
    /// errors and is usually empty. Every reader here looked only at
    /// [`remote_errors`](Self::remote_errors), so a push refused by a policy
    /// hook reported the machine reason (`pre-receive hook declined`) and threw
    /// away the only sentence that said *why* — MEASURED against a real
    /// `git receive-pack` on 2026-08-05, where stock git printed
    /// `remote: policy: this branch is protected by the fixture hook` and
    /// gunnar printed nothing.
    ///
    /// One accessor rather than two field reads, so a third caller cannot
    /// reintroduce the same omission (LAW 5).
    pub fn remote_lines(&self) -> impl Iterator<Item = &str> {
        self.progress
            .iter()
            .chain(self.remote_errors.iter())
            .map(|l| l.trim_end_matches(['\n', '\r']))
            .filter(|l| !l.is_empty())
    }

    /// A one-line human summary of why the push failed, or `None` if it did not.
    pub fn failure_summary(&self) -> Option<String> {
        if self.is_ok() {
            return None;
        }
        let mut parts = Vec::new();
        if self.unpack != "ok" {
            parts.push(format!("unpack: {}", self.unpack));
        }
        for r in self.rejected() {
            parts.push(format!(
                "{}: {}",
                r.name,
                r.reason.as_deref().unwrap_or("rejected")
            ));
        }
        for name in &self.unreported {
            // git's wording, because an operator who has seen it once should
            // not have to learn a second phrase for the same condition.
            parts.push(format!("{name}: remote failed to report status"));
        }
        for e in self.remote_lines() {
            parts.push(format!("remote: {e}"));
        }
        Some(parts.join("; "))
    }
}

/// Parse a report from already-unframed, already-demultiplexed lines.
pub fn parse(lines: &[Vec<u8>]) -> Result<PushReport> {
    let mut report = PushReport::default();
    let mut saw_unpack = false;
    for line in lines {
        let raw = chomp(line);
        let text = std::str::from_utf8(raw)
            .map_err(|_| Error::protocol(format!("non-UTF-8 report line: {:?}", raw.as_bstr())))?;
        if let Some(msg) = text.strip_prefix("ERR ") {
            // Same sentence-not-record line as in the advertisement, and the
            // same reason to catch it before the `ng `/`ok ` arms: git reads it
            // with `PACKET_READ_DIE_ON_ERR_PACKET` wherever a packet is read.
            return Err(Error::Remote(msg.to_string()));
        } else if let Some(status) = text.strip_prefix("unpack ") {
            report.unpack = status.to_string();
            saw_unpack = true;
        } else if let Some(name) = text.strip_prefix("ok ") {
            report.refs.push(RefStatus::ok(name));
        } else if let Some(rest) = text.strip_prefix("ng ") {
            let (name, reason) = rest.split_once(' ').unwrap_or((rest, ""));
            report.refs.push(RefStatus::rejected(name, reason));
        } else if let Some(rest) = text.strip_prefix("option ") {
            // report-status-v2: options attach to the ref reported just above.
            let (key, value) = match rest.split_once(' ') {
                Some((k, v)) => (k.to_string(), Some(v.to_string())),
                None => (rest.to_string(), None),
            };
            match report.refs.last_mut() {
                Some(r) => r.options.push((key, value)),
                None => {
                    return Err(Error::protocol(format!(
                        "report-status-v2 sent `option {rest}` before any ref status"
                    )))
                }
            }
        } else if text.is_empty() {
            continue;
        } else {
            return Err(Error::protocol(format!(
                "unrecognised report-status line: {text:?}"
            )));
        }
    }
    if !saw_unpack && !lines.is_empty() {
        return Err(Error::protocol(
            "the remote reported ref statuses without an `unpack` line",
        ));
    }
    Ok(report)
}

/// Render a report as payload lines, **without** pkt-line framing and without
/// the terminating flush-pkt: the caller owns the framer.
///
/// This is the server half of [`parse`]. `unpack` is `Ok(())` or the one-line
/// reason the pack was refused.
pub fn lines(unpack: std::result::Result<(), &str>, statuses: &[RefStatus]) -> Vec<Vec<u8>> {
    let mut out = Vec::with_capacity(statuses.len() + 1);
    out.push(match unpack {
        Ok(()) => b"unpack ok".to_vec(),
        Err(reason) => format!("unpack {}", one_line(reason)).into_bytes(),
    });
    for s in statuses {
        let mut line = if s.accepted {
            b"ok ".to_vec()
        } else {
            b"ng ".to_vec()
        };
        line.extend_from_slice(s.name.as_slice());
        if !s.accepted {
            line.push(b' ');
            line.extend_from_slice(one_line(s.reason.as_deref().unwrap_or("rejected")).as_bytes());
        }
        out.push(line);
        for (key, value) in &s.options {
            let mut opt = format!("option {key}");
            if let Some(v) = value {
                opt.push(' ');
                opt.push_str(&one_line(v));
            }
            out.push(opt.into_bytes());
        }
    }
    out
}

/// A status reason is one pkt-line. A newline in it would be read as the end of
/// the line and the rest as a new status for a reference that does not exist.
pub fn one_line(reason: &str) -> String {
    reason.replace(['\n', '\r'], " ")
}