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