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 AtomicPushFailed,
39}
40
41impl PushRefStatus {
42 #[must_use]
44 pub fn is_error(&self) -> bool {
45 !matches!(self, PushRefStatus::UpToDate | PushRefStatus::Ok)
46 }
47}
48
49#[derive(Clone, Debug)]
51pub struct PushRefResult {
52 pub local_ref: Option<String>,
54 pub remote_ref: String,
56 pub old_oid: Option<ObjectId>,
58 pub new_oid: Option<ObjectId>,
60 pub forced: bool,
62 pub deletion: bool,
64 pub status: PushRefStatus,
66 pub message: Option<String>,
68}
69
70impl PushRefResult {
71 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
80fn 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
89fn 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#[derive(Default, Debug)]
158pub struct PushStatusOutput {
159 pub stdout: String,
161 pub stderr: String,
163 pub had_errors: bool,
165}
166
167fn status_bucket(status: &PushRefStatus) -> u8 {
171 match status {
172 PushRefStatus::UpToDate => 0,
173 PushRefStatus::Ok => 1,
174 _ => 2,
175 }
176}
177
178#[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 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 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 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 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 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 assert!(out
383 .stdout
384 .contains("!\t:refs/heads/foo\t[remote rejected] (pre-receive hook declined)"));
385 }
386}