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}