Skip to main content

grit_lib/
push_report.rs

1//! Push status reporting that matches Git's output.
2//!
3//! After a push, Git prints one line per reference describing how the update
4//! resolved (`[up to date]`, `[new branch]`, `[deleted]`, `[rejected]`, …).
5//! There are two output styles: a human-readable form on stderr and a
6//! machine-readable `--porcelain` form on stdout. This module reproduces both
7//! exactly, including the ordering and the fixed-width summary column.
8//!
9//! The canonical C implementation lives in `transport.c`
10//! (`print_ref_status`, `print_ok_ref_status`, `print_one_push_report`,
11//! `transport_print_push_status`).
12
13use crate::objects::ObjectId;
14use std::fmt::Write as _;
15
16/// The resolved outcome of a single reference update during a push.
17#[derive(Clone, Debug, PartialEq, Eq)]
18pub enum PushRefStatus {
19    /// The reference was already at the requested value (`=`, `[up to date]`).
20    UpToDate,
21    /// A successful update (`*` new, `-` delete, ` ` fast-forward, `+` forced).
22    Ok,
23    /// Client-side rejection: the update is not a fast-forward and `--force`
24    /// was not given (`!`, `[rejected] (non-fast-forward)`).
25    RejectNonFastForward,
26    /// Rejected because the new ref already exists (`!`, `[rejected] (already exists)`).
27    RejectAlreadyExists,
28    /// Rejected because the remote has the ref but we need to fetch first.
29    RejectFetchFirst,
30    /// Rejected because a forced update is required.
31    RejectNeedsForce,
32    /// Rejected because force-with-lease found stale info.
33    RejectStale,
34    /// The remote `receive-pack` declined the update (`!`, `[remote rejected] (<reason>)`).
35    RemoteRejected,
36    /// Part of an atomic push that failed because another ref was rejected
37    /// (`!`, `[rejected] (atomic push failed)`).
38    AtomicPushFailed,
39}
40
41impl PushRefStatus {
42    /// Whether this status represents a hard failure (causes a non-zero exit).
43    #[must_use]
44    pub fn is_error(&self) -> bool {
45        !matches!(self, PushRefStatus::UpToDate | PushRefStatus::Ok)
46    }
47}
48
49/// One reference's resolved push result, ready for display.
50#[derive(Clone, Debug)]
51pub struct PushRefResult {
52    /// The local ref name (source side), e.g. `refs/heads/next`. `None` for deletions.
53    pub local_ref: Option<String>,
54    /// The remote ref name (destination side), e.g. `refs/heads/next`.
55    pub remote_ref: String,
56    /// Old value of the remote ref (`None`/zero for a new ref).
57    pub old_oid: Option<ObjectId>,
58    /// New value of the remote ref (`None`/zero for a deletion).
59    pub new_oid: Option<ObjectId>,
60    /// Whether this update was a forced (non-fast-forward but `--force`d) update.
61    pub forced: bool,
62    /// Whether this update deletes the remote ref.
63    pub deletion: bool,
64    /// The resolved status.
65    pub status: PushRefStatus,
66    /// Extra reason text (used for `[remote rejected]`).
67    pub message: Option<String>,
68}
69
70impl PushRefResult {
71    /// Abbreviated 7-char hex of an OID, or seven zeros for `None`.
72    fn short(oid: Option<ObjectId>) -> String {
73        match oid {
74            Some(o) => o.to_hex()[..7].to_owned(),
75            None => "0000000".to_owned(),
76        }
77    }
78}
79
80/// Strip the common `refs/heads/`, `refs/tags/`, `refs/remotes/` prefix the way
81/// Git's `prettify_refname` does for human-readable output.
82fn prettify_refname(name: &str) -> &str {
83    name.strip_prefix("refs/heads/")
84        .or_else(|| name.strip_prefix("refs/tags/"))
85        .or_else(|| name.strip_prefix("refs/remotes/"))
86        .unwrap_or(name)
87}
88
89/// The flag character, fixed summary text, and optional parenthetical reason for
90/// a result, matching `print_one_push_report` / `print_ok_ref_status`.
91///
92/// Returns `(flag, summary, message)` where `summary` is the bracketed status or
93/// the `old..new` quickref, and `message` is the trailing `(reason)` if any.
94fn describe(result: &PushRefResult) -> (char, String, Option<String>) {
95    match result.status {
96        PushRefStatus::UpToDate => ('=', "[up to date]".to_owned(), None),
97        PushRefStatus::Ok => {
98            if result.deletion {
99                ('-', "[deleted]".to_owned(), None)
100            } else if result.old_oid.is_none() {
101                let summary = if result.remote_ref.starts_with("refs/tags/") {
102                    "[new tag]"
103                } else if result.remote_ref.starts_with("refs/heads/") {
104                    "[new branch]"
105                } else {
106                    "[new reference]"
107                };
108                ('*', summary.to_owned(), None)
109            } else {
110                let old = PushRefResult::short(result.old_oid);
111                let new = PushRefResult::short(result.new_oid);
112                if result.forced {
113                    (
114                        '+',
115                        format!("{old}...{new}"),
116                        Some("forced update".to_owned()),
117                    )
118                } else {
119                    (' ', format!("{old}..{new}"), None)
120                }
121            }
122        }
123        PushRefStatus::RejectNonFastForward => (
124            '!',
125            "[rejected]".to_owned(),
126            Some("non-fast-forward".to_owned()),
127        ),
128        PushRefStatus::RejectAlreadyExists => (
129            '!',
130            "[rejected]".to_owned(),
131            Some("already exists".to_owned()),
132        ),
133        PushRefStatus::RejectFetchFirst => {
134            ('!', "[rejected]".to_owned(), Some("fetch first".to_owned()))
135        }
136        PushRefStatus::RejectNeedsForce => {
137            ('!', "[rejected]".to_owned(), Some("needs force".to_owned()))
138        }
139        PushRefStatus::RejectStale => ('!', "[rejected]".to_owned(), Some("stale info".to_owned())),
140        PushRefStatus::RemoteRejected => (
141            '!',
142            "[remote rejected]".to_owned(),
143            result
144                .message
145                .clone()
146                .or_else(|| Some("remote rejected".to_owned())),
147        ),
148        PushRefStatus::AtomicPushFailed => (
149            '!',
150            "[rejected]".to_owned(),
151            Some("atomic push failed".to_owned()),
152        ),
153    }
154}
155
156/// Output produced by [`format_push_status`].
157#[derive(Default, Debug)]
158pub struct PushStatusOutput {
159    /// Lines for stdout (porcelain mode writes everything here).
160    pub stdout: String,
161    /// Lines for stderr (human-readable mode writes everything here).
162    pub stderr: String,
163    /// Whether any reference failed (the push command should exit non-zero).
164    pub had_errors: bool,
165}
166
167/// Sort key matching Git's push-status output order: up-to-date refs first,
168/// then successful updates, then everything else (errors), preserving the
169/// original order within each bucket.
170fn status_bucket(status: &PushRefStatus) -> u8 {
171    match status {
172        PushRefStatus::UpToDate => 0,
173        PushRefStatus::Ok => 1,
174        _ => 2,
175    }
176}
177
178/// Render the full set of per-ref push results the way Git does.
179///
180/// `dest` is the (already credential-scrubbed) destination URL printed in the
181/// `To <url>` header. When `porcelain` is true the machine-readable format is
182/// emitted to `stdout` and terminated with a `Done` line; otherwise the
183/// human-readable format is emitted to `stderr`. `quiet` suppresses all output
184/// unless there were errors (matching `if (!quiet || err)` in `transport_push`).
185///
186/// Results are reordered into Git's display order but the input slice is left
187/// untouched.
188#[must_use]
189pub fn format_push_status(
190    dest: &str,
191    results: &[PushRefResult],
192    porcelain: bool,
193    quiet: bool,
194) -> PushStatusOutput {
195    let mut out = PushStatusOutput {
196        had_errors: results.iter().any(|r| r.status.is_error()),
197        ..PushStatusOutput::default()
198    };
199
200    if quiet && !out.had_errors {
201        return out;
202    }
203
204    // Sort into Git's three buckets (up-to-date, ok, errors); within a bucket the
205    // remote `refs` list is advertised in sorted order, so order by ref name.
206    let mut order: Vec<usize> = (0..results.len()).collect();
207    order.sort_by(|&a, &b| {
208        status_bucket(&results[a].status)
209            .cmp(&status_bucket(&results[b].status))
210            .then_with(|| results[a].remote_ref.cmp(&results[b].remote_ref))
211    });
212
213    // Compute the fixed summary-column width for human-readable output:
214    // 2 * max_abbrev + 3, where abbrev is 7 here (DEFAULT_ABBREV).
215    let summary_width = 2 * 7 + 3;
216
217    let buf = if porcelain {
218        &mut out.stdout
219    } else {
220        &mut out.stderr
221    };
222
223    let _ = writeln!(buf, "To {dest}");
224
225    for &i in &order {
226        let result = &results[i];
227        let (flag, summary, message) = describe(result);
228
229        if porcelain {
230            let to_name = &result.remote_ref;
231            // Git prints the source side from `ref->peer_ref`. A *successful*
232            // deletion is reported via `print_ok_ref_status` with `from = NULL`
233            // (just `:dst`). Most error paths pass `ref->peer_ref` for deletions
234            // (whose name is the literal `(delete)`), except `REF_STATUS_REMOTE_REJECT`,
235            // which explicitly passes `NULL` for a deletion (`ref->deletion ? NULL : …`).
236            if result.deletion {
237                let from_delete = result.status.is_error()
238                    && !matches!(result.status, PushRefStatus::RemoteRejected);
239                if from_delete {
240                    let _ = write!(buf, "{flag}\t(delete):{to_name}\t");
241                } else {
242                    let _ = write!(buf, "{flag}\t:{to_name}\t");
243                }
244            } else if let Some(from) = &result.local_ref {
245                let _ = write!(buf, "{flag}\t{from}:{to_name}\t");
246            } else {
247                let _ = write!(buf, "{flag}\t:{to_name}\t");
248            }
249            match &message {
250                Some(msg) => {
251                    let _ = writeln!(buf, "{summary} ({msg})");
252                }
253                None => {
254                    let _ = writeln!(buf, "{summary}");
255                }
256            }
257        } else {
258            let _ = write!(buf, " {flag} {summary:<summary_width$} ");
259            match &result.local_ref {
260                Some(from) if !result.deletion => {
261                    let _ = write!(
262                        buf,
263                        "{} -> {}",
264                        prettify_refname(from),
265                        prettify_refname(&result.remote_ref)
266                    );
267                }
268                _ => {
269                    let _ = write!(buf, "{}", prettify_refname(&result.remote_ref));
270                }
271            }
272            if let Some(msg) = &message {
273                let _ = write!(buf, " ({msg})");
274            }
275            let _ = writeln!(buf);
276        }
277    }
278
279    if porcelain {
280        let _ = writeln!(buf, "Done");
281    }
282
283    out
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289
290    fn oid(byte: u8) -> ObjectId {
291        ObjectId::from_bytes(&[byte; 20]).expect("valid 20-byte oid")
292    }
293
294    fn results() -> Vec<PushRefResult> {
295        vec![
296            PushRefResult {
297                local_ref: Some("refs/heads/main".to_owned()),
298                remote_ref: "refs/heads/main".to_owned(),
299                old_oid: Some(oid(0xbb)),
300                new_oid: Some(oid(0xaa)),
301                forced: false,
302                deletion: false,
303                status: PushRefStatus::RejectNonFastForward,
304                message: None,
305            },
306            PushRefResult {
307                local_ref: None,
308                remote_ref: "refs/heads/foo".to_owned(),
309                old_oid: Some(oid(0xaa)),
310                new_oid: None,
311                forced: false,
312                deletion: true,
313                status: PushRefStatus::Ok,
314                message: None,
315            },
316            PushRefResult {
317                local_ref: Some("refs/heads/baz".to_owned()),
318                remote_ref: "refs/heads/baz".to_owned(),
319                old_oid: Some(oid(0xaa)),
320                new_oid: Some(oid(0xaa)),
321                forced: false,
322                deletion: false,
323                status: PushRefStatus::UpToDate,
324                message: None,
325            },
326            PushRefResult {
327                local_ref: Some("refs/heads/next".to_owned()),
328                remote_ref: "refs/heads/next".to_owned(),
329                old_oid: None,
330                new_oid: Some(oid(0xaa)),
331                forced: false,
332                deletion: false,
333                status: PushRefStatus::Ok,
334                message: None,
335            },
336        ]
337    }
338
339    #[test]
340    fn porcelain_orders_and_formats() {
341        let out = format_push_status("URL", &results(), true, false);
342        let expected = "To URL\n\
343            =\trefs/heads/baz:refs/heads/baz\t[up to date]\n\
344            -\t:refs/heads/foo\t[deleted]\n\
345            *\trefs/heads/next:refs/heads/next\t[new branch]\n\
346            !\trefs/heads/main:refs/heads/main\t[rejected] (non-fast-forward)\n\
347            Done\n";
348        assert_eq!(out.stdout, expected);
349        assert!(out.had_errors);
350        assert!(out.stderr.is_empty());
351    }
352
353    #[test]
354    fn quiet_suppresses_when_no_errors() {
355        let mut rs = results();
356        // Make main up-to-date so there are no errors.
357        rs[0].status = PushRefStatus::UpToDate;
358        let out = format_push_status("URL", &rs, true, true);
359        assert!(out.stdout.is_empty());
360        assert!(!out.had_errors);
361    }
362
363    #[test]
364    fn atomic_failure_message() {
365        let mut rs = results();
366        rs[1].status = PushRefStatus::AtomicPushFailed;
367        let out = format_push_status("URL", &rs, true, false);
368        // A rejected deletion (not REMOTE_REJECT) prints `(delete)` as its source,
369        // matching Git's `print_ref_status(... ref->peer_ref ...)`.
370        assert!(out
371            .stdout
372            .contains("!\t(delete):refs/heads/foo\t[rejected] (atomic push failed)"));
373    }
374
375    #[test]
376    fn remote_rejected_deletion_omits_delete_source() {
377        let mut rs = results();
378        rs[1].status = PushRefStatus::RemoteRejected;
379        rs[1].message = Some("pre-receive hook declined".to_owned());
380        let out = format_push_status("URL", &rs, true, false);
381        // REMOTE_REJECT explicitly passes NULL for a deletion: just `:dst`.
382        assert!(out
383            .stdout
384            .contains("!\t:refs/heads/foo\t[remote rejected] (pre-receive hook declined)"));
385    }
386}