1use crate::objects::ObjectId;
14use std::fmt::Write as _;
15
16#[derive(Clone, Debug, PartialEq, Eq)]
18pub enum PushRefStatus {
19 UpToDate,
21 Ok,
23 RejectNonFastForward,
26 RejectAlreadyExists,
28 RejectFetchFirst,
30 RejectNeedsForce,
32 RejectStale,
34 RemoteRejected,
36 RejectRemoteRefMissing,
39 AtomicPushFailed,
42}
43
44impl PushRefStatus {
45 #[must_use]
47 pub fn is_error(&self) -> bool {
48 !matches!(self, PushRefStatus::UpToDate | PushRefStatus::Ok)
49 }
50}
51
52#[derive(Clone, Debug)]
54pub struct PushRefResult {
55 pub local_ref: Option<String>,
57 pub remote_ref: String,
59 pub old_oid: Option<ObjectId>,
61 pub new_oid: Option<ObjectId>,
63 pub forced: bool,
65 pub deletion: bool,
67 pub status: PushRefStatus,
69 pub message: Option<String>,
71}
72
73impl PushRefResult {
74 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
83fn 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
92fn 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#[derive(Default, Debug)]
166pub struct PushStatusOutput {
167 pub stdout: String,
169 pub stderr: String,
171 pub had_errors: bool,
173}
174
175fn status_bucket(status: &PushRefStatus) -> u8 {
179 match status {
180 PushRefStatus::UpToDate => 0,
181 PushRefStatus::Ok => 1,
182 _ => 2,
183 }
184}
185
186#[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 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 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 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 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 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 assert!(out
391 .stdout
392 .contains("!\t:refs/heads/foo\t[remote rejected] (pre-receive hook declined)"));
393 }
394}