Skip to main content

gunnar_sendpack/
report.rs

1//! `report-status` and `report-status-v2`: written by the server, read by the
2//! client.
3//!
4//! ```text
5//! unpack ok                       …or `unpack <why the pack was refused>`
6//! ok refs/heads/main
7//! ng refs/heads/x <why>
8//! option refname refs/heads/main  …report-status-v2 only, attached to the ref above
9//! 0000
10//! ```
11//!
12//! **Both halves of the verdict matter.** `unpack ok` with every ref `ng` is a
13//! completely failed push that reports a successful unpack, so
14//! [`PushReport::is_ok`] is a conjunction and not a look at the first line.
15
16use bstr::{BString, ByteSlice};
17
18use crate::advertisement::chomp;
19use crate::error::{Error, Result};
20
21/// The remote's verdict on one ref.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct RefStatus {
24    /// The ref the remote is talking about.
25    pub name: BString,
26    /// `ok` (true) or `ng` (false).
27    pub accepted: bool,
28    /// The rejection reason, verbatim, for `ng`.
29    pub reason: Option<String>,
30    /// `report-status-v2` `option <key> [value]` lines attached to this ref.
31    pub options: Vec<(String, Option<String>)>,
32}
33
34impl RefStatus {
35    /// An accepted ref.
36    pub fn ok(name: impl Into<BString>) -> Self {
37        RefStatus {
38            name: name.into(),
39            accepted: true,
40            reason: None,
41            options: Vec::new(),
42        }
43    }
44
45    /// A refused ref, with the reason the client is told.
46    pub fn rejected(name: impl Into<BString>, reason: impl Into<String>) -> Self {
47        RefStatus {
48            name: name.into(),
49            accepted: false,
50            reason: Some(reason.into()),
51            options: Vec::new(),
52        }
53    }
54}
55
56/// The remote's verdict on the push as a whole.
57#[derive(Debug, Clone, PartialEq, Eq, Default)]
58pub struct PushReport {
59    /// `receive-pack`'s verdict on the packfile: `"ok"`, or its error text.
60    pub unpack: String,
61    /// One entry per ref the remote reported on.
62    pub refs: Vec<RefStatus>,
63    /// Band-2 output: the remote's hooks and progress meters.
64    pub progress: Vec<String>,
65    /// Band-3 output: fatal remote errors.
66    pub remote_errors: Vec<String>,
67    /// Refs this client commanded that the remote's report **never mentioned**.
68    ///
69    /// Filled by [`PushReport::reconcile`]; empty until it is called, and empty
70    /// on the path where no report was negotiated at all.
71    pub unreported: Vec<BString>,
72}
73
74impl PushReport {
75    /// True only if the pack unpacked, every reported ref was accepted, **and
76    /// every ref this client commanded was reported on**.
77    ///
78    /// # The third clause is the one that was missing
79    ///
80    /// `refs` is one entry per ref *the remote chose to talk about*, so
81    /// `iter().all(…)` over an empty list is vacuously true. A report of
82    /// exactly `unpack ok` + flush — no `ok`, no `ng` — therefore made
83    /// [`is_ok`](Self::is_ok) true, [`failure_summary`](Self::failure_summary)
84    /// return `None`, and `gunnar push` exit 0, having moved nothing. The whole
85    /// verdict was computed from lines the remote was free not to send.
86    ///
87    /// The same hole swallows a partial report: a ten-ref push whose remote
88    /// reports on one is nine silent no-ops.
89    ///
90    /// git closes it from the other end and does not trust the report either
91    /// (`send-pack.c`): every commanded ref is pre-seeded
92    /// `REF_STATUS_EXPECTING_REPORT`, `receive_status()` overwrites the ones it
93    /// hears about, and anything still expecting afterwards is printed as
94    /// `remote failed to report status`. `transport.c:push_had_errors()` then
95    /// whitelists only `NONE`/`UPTODATE`/`OK`, so it is a non-zero exit and not
96    /// a warning. [`reconcile`](Self::reconcile) is that pre-seeding, done
97    /// after the fact because this parser has no reason to know the command
98    /// list until it is handed one.
99    ///
100    /// # The fourth clause
101    ///
102    /// Sideband 3 is defined as *"a fatal error message just before the stream
103    /// aborts"*; git's `recv_sideband` calls `die` on it. gunnar collected band
104    /// 3 into [`remote_errors`](Self::remote_errors), printed it, and then
105    /// computed the verdict without it — so a remote that aborted with a
106    /// reason on band 3 while its band-1 report still said `unpack ok` exited
107    /// 0. The reason was on the screen and the exit code disagreed with it.
108    pub fn is_ok(&self) -> bool {
109        self.unpack == "ok"
110            && self.unreported.is_empty()
111            && self.remote_errors.is_empty()
112            && self.refs.iter().all(|r| r.accepted)
113    }
114
115    /// Account the report against the refs that were actually commanded.
116    ///
117    /// Anything commanded and not reported on lands in
118    /// [`unreported`](Self::unreported) and fails the push. Anything reported
119    /// and **not** commanded is turned into a rejection rather than an
120    /// acceptance: git treats it as a protocol error, and the shape that
121    /// matters here is that such a line must never be what satisfies
122    /// `all(|r| r.accepted)` for a ref the remote said nothing about.
123    ///
124    /// Idempotent, so a caller that reconciles twice does not double-count.
125    pub fn reconcile(&mut self, commanded: &[BString]) {
126        self.unreported = commanded
127            .iter()
128            .filter(|name| !self.refs.iter().any(|r| &r.name == *name))
129            .cloned()
130            .collect();
131        for status in &mut self.refs {
132            if !commanded.iter().any(|name| name == &status.name) && status.accepted {
133                status.accepted = false;
134                status.reason = Some(
135                    "the remote reported on a reference this push did not command".to_string(),
136                );
137            }
138        }
139    }
140
141    /// The refs the remote refused.
142    pub fn rejected(&self) -> impl Iterator<Item = &RefStatus> {
143        self.refs.iter().filter(|r| !r.accepted)
144    }
145
146    /// Everything the remote said in its own words, trimmed, in wire order:
147    /// band 2 first, then band 3. What `git push` prints prefixed `remote:`.
148    ///
149    /// # Why this exists rather than two fields read separately
150    ///
151    /// A `pre-receive` hook writes its refusal to **stderr**, and
152    /// `receive-pack` muxes stderr onto **band 2**, which lands in
153    /// [`progress`](Self::progress). Band 3 is reserved for fatal protocol
154    /// errors and is usually empty. Every reader here looked only at
155    /// [`remote_errors`](Self::remote_errors), so a push refused by a policy
156    /// hook reported the machine reason (`pre-receive hook declined`) and threw
157    /// away the only sentence that said *why* — MEASURED against a real
158    /// `git receive-pack` on 2026-08-05, where stock git printed
159    /// `remote: policy: this branch is protected by the fixture hook` and
160    /// gunnar printed nothing.
161    ///
162    /// One accessor rather than two field reads, so a third caller cannot
163    /// reintroduce the same omission (LAW 5).
164    pub fn remote_lines(&self) -> impl Iterator<Item = &str> {
165        self.progress
166            .iter()
167            .chain(self.remote_errors.iter())
168            .map(|l| l.trim_end_matches(['\n', '\r']))
169            .filter(|l| !l.is_empty())
170    }
171
172    /// A one-line human summary of why the push failed, or `None` if it did not.
173    pub fn failure_summary(&self) -> Option<String> {
174        if self.is_ok() {
175            return None;
176        }
177        let mut parts = Vec::new();
178        if self.unpack != "ok" {
179            parts.push(format!("unpack: {}", self.unpack));
180        }
181        for r in self.rejected() {
182            parts.push(format!(
183                "{}: {}",
184                r.name,
185                r.reason.as_deref().unwrap_or("rejected")
186            ));
187        }
188        for name in &self.unreported {
189            // git's wording, because an operator who has seen it once should
190            // not have to learn a second phrase for the same condition.
191            parts.push(format!("{name}: remote failed to report status"));
192        }
193        for e in self.remote_lines() {
194            parts.push(format!("remote: {e}"));
195        }
196        Some(parts.join("; "))
197    }
198}
199
200/// Parse a report from already-unframed, already-demultiplexed lines.
201pub fn parse(lines: &[Vec<u8>]) -> Result<PushReport> {
202    let mut report = PushReport::default();
203    let mut saw_unpack = false;
204    for line in lines {
205        let raw = chomp(line);
206        let text = std::str::from_utf8(raw)
207            .map_err(|_| Error::protocol(format!("non-UTF-8 report line: {:?}", raw.as_bstr())))?;
208        if let Some(msg) = text.strip_prefix("ERR ") {
209            // Same sentence-not-record line as in the advertisement, and the
210            // same reason to catch it before the `ng `/`ok ` arms: git reads it
211            // with `PACKET_READ_DIE_ON_ERR_PACKET` wherever a packet is read.
212            return Err(Error::Remote(msg.to_string()));
213        } else if let Some(status) = text.strip_prefix("unpack ") {
214            report.unpack = status.to_string();
215            saw_unpack = true;
216        } else if let Some(name) = text.strip_prefix("ok ") {
217            report.refs.push(RefStatus::ok(name));
218        } else if let Some(rest) = text.strip_prefix("ng ") {
219            let (name, reason) = rest.split_once(' ').unwrap_or((rest, ""));
220            report.refs.push(RefStatus::rejected(name, reason));
221        } else if let Some(rest) = text.strip_prefix("option ") {
222            // report-status-v2: options attach to the ref reported just above.
223            let (key, value) = match rest.split_once(' ') {
224                Some((k, v)) => (k.to_string(), Some(v.to_string())),
225                None => (rest.to_string(), None),
226            };
227            match report.refs.last_mut() {
228                Some(r) => r.options.push((key, value)),
229                None => {
230                    return Err(Error::protocol(format!(
231                        "report-status-v2 sent `option {rest}` before any ref status"
232                    )))
233                }
234            }
235        } else if text.is_empty() {
236            continue;
237        } else {
238            return Err(Error::protocol(format!(
239                "unrecognised report-status line: {text:?}"
240            )));
241        }
242    }
243    if !saw_unpack && !lines.is_empty() {
244        return Err(Error::protocol(
245            "the remote reported ref statuses without an `unpack` line",
246        ));
247    }
248    Ok(report)
249}
250
251/// Render a report as payload lines, **without** pkt-line framing and without
252/// the terminating flush-pkt: the caller owns the framer.
253///
254/// This is the server half of [`parse`]. `unpack` is `Ok(())` or the one-line
255/// reason the pack was refused.
256pub fn lines(unpack: std::result::Result<(), &str>, statuses: &[RefStatus]) -> Vec<Vec<u8>> {
257    let mut out = Vec::with_capacity(statuses.len() + 1);
258    out.push(match unpack {
259        Ok(()) => b"unpack ok".to_vec(),
260        Err(reason) => format!("unpack {}", one_line(reason)).into_bytes(),
261    });
262    for s in statuses {
263        let mut line = if s.accepted {
264            b"ok ".to_vec()
265        } else {
266            b"ng ".to_vec()
267        };
268        line.extend_from_slice(s.name.as_slice());
269        if !s.accepted {
270            line.push(b' ');
271            line.extend_from_slice(one_line(s.reason.as_deref().unwrap_or("rejected")).as_bytes());
272        }
273        out.push(line);
274        for (key, value) in &s.options {
275            let mut opt = format!("option {key}");
276            if let Some(v) = value {
277                opt.push(' ');
278                opt.push_str(&one_line(v));
279            }
280            out.push(opt.into_bytes());
281        }
282    }
283    out
284}
285
286/// A status reason is one pkt-line. A newline in it would be read as the end of
287/// the line and the rest as a new status for a reference that does not exist.
288pub fn one_line(reason: &str) -> String {
289    reason.replace(['\n', '\r'], " ")
290}