crates/gunnar-sendpack/src/report.rs
//! `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'], " ")
}