1use std::collections::HashSet;
2
3use crate::compress::generic::{dedup_consecutive, middle_truncate, GenericCompressor};
4use crate::compress::{CompressionResult, Compressor};
5
6const STATUS_SHORT_LIMIT: usize = 1024;
7const STATUS_KEEP_PER_SECTION: usize = 10;
8const DIFF_MAX_FILES: usize = 5;
9const DIFF_MAX_HUNKS: usize = 20;
10const HUNK_KEEP_LINES: usize = 30;
11const LOG_KEEP_COMMITS: usize = 20;
12const BLAME_KEEP_LINES: usize = 50;
13const GIT_WRITE_KEEP_LINES: usize = 50;
14const GIT_ADD_KEEP_PATHS: usize = 5;
15const GIT_STASH_STATUS_KEEP_LINES: usize = 20;
16
17pub struct GitCompressor;
18
19impl Compressor for GitCompressor {
20 fn matches(&self, command: &str) -> bool {
21 command_head(command).is_some_and(|head| head == "git")
22 }
23
24 fn compress_with_exit_code(
25 &self,
26 command: &str,
27 output: &str,
28 exit_code: Option<i32>,
29 ) -> CompressionResult {
30 let compressed = match git_subcommand(command).as_deref() {
31 Some("add") => compress_add(output),
32 Some("status") => compress_status(output),
33 Some("diff") => compress_diff(output, false),
34 Some("log") => compress_log(output),
35 Some("show") => compress_diff(output, true),
36 Some("branch") => trim_trailing_lines(&dedup_consecutive(output)),
37 Some("blame") => compress_blame(output),
38 Some("commit") => compress_commit(output),
39 Some("push") => compress_push(output),
40 Some("pull") => compress_pull(output),
41 Some("fetch") => compress_fetch(output),
42 Some("stash") => compress_stash(command, output),
43 _ => GenericCompressor::compress_output(output),
44 };
45 if matches!(exit_code, Some(code) if code != 0)
46 && looks_like_git_success_summary(&compressed)
47 {
48 GenericCompressor::compress_output(output).into()
49 } else {
50 compressed.into()
51 }
52 }
53}
54
55fn looks_like_git_success_summary(text: &str) -> bool {
56 let trimmed = text.trim();
57 matches!(
58 trimmed,
59 "git: ok" | "git fetch: ok" | "Everything up-to-date" | "Already up to date."
60 ) || trimmed.contains("working tree clean")
61}
62
63fn command_head(command: &str) -> Option<&str> {
64 command.split_whitespace().next()
65}
66
67fn git_subcommand(command: &str) -> Option<String> {
68 let mut seen_git = false;
69 for token in command.split_whitespace() {
70 if !seen_git {
71 if token == "git" {
72 seen_git = true;
73 }
74 continue;
75 }
76 if token.starts_with('-') || token.contains('=') {
77 continue;
78 }
79 return Some(token.to_string());
80 }
81 None
82}
83
84fn git_subcommand_after(command: &str, subcommand: &str) -> Option<String> {
85 let mut seen_git = false;
86 let mut seen_subcommand = false;
87 for token in command.split_whitespace() {
88 if !seen_git {
89 if token == "git" {
90 seen_git = true;
91 }
92 continue;
93 }
94 if !seen_subcommand {
95 if token.starts_with('-') || token.contains('=') {
96 continue;
97 }
98 seen_subcommand = token == subcommand;
99 continue;
100 }
101 if token.starts_with('-') || token.contains('=') {
102 continue;
103 }
104 return Some(token.to_string());
105 }
106 None
107}
108
109fn compress_add(output: &str) -> String {
110 if output.trim().is_empty() {
111 return "git: ok".to_string();
112 }
113 if looks_like_git_error(output) {
114 return trim_trailing_lines(output);
115 }
116 let lines: Vec<&str> = output
117 .lines()
118 .filter(|line| !line.trim().is_empty())
119 .collect();
120 if lines.is_empty() {
121 return "git: ok".to_string();
122 }
123 let mut result: Vec<String> = lines
124 .iter()
125 .take(GIT_ADD_KEEP_PATHS)
126 .map(|line| line.trim_end().to_string())
127 .collect();
128 if lines.len() > GIT_ADD_KEEP_PATHS {
129 result.push(format!(
130 "... ({} more files added)",
131 lines.len() - GIT_ADD_KEEP_PATHS
132 ));
133 }
134 cap_git_lines(result, "files added", GIT_WRITE_KEEP_LINES)
135}
136
137fn compress_commit(output: &str) -> String {
138 if output.trim().is_empty() {
139 return GenericCompressor::compress_output(output);
140 }
141 if looks_like_git_error(output) {
142 return trim_trailing_lines(output);
143 }
144 if let Some(line) = output
145 .lines()
146 .find(|line| line.contains("nothing to commit"))
147 {
148 return line.trim_end().to_string();
149 }
150 let subject = output.lines().find(|line| looks_like_commit_subject(line));
151 let summary = output.lines().find(|line| looks_like_commit_summary(line));
152 match (subject, summary) {
153 (Some(subject), Some(summary)) => {
154 trim_trailing_lines(&format!("{}\n{}", subject.trim_end(), summary.trim()))
155 }
156 (Some(subject), None) => subject.trim_end().to_string(),
157 _ => GenericCompressor::compress_output(output),
158 }
159}
160
161fn compress_push(output: &str) -> String {
162 if output.trim().is_empty() {
163 return GenericCompressor::compress_output(output);
164 }
165 if looks_like_git_error(output) {
166 return trim_trailing_lines(output);
167 }
168 if let Some(line) = output
169 .lines()
170 .find(|line| line.trim() == "Everything up-to-date")
171 {
172 return line.trim_end().to_string();
173 }
174 let result: Vec<String> = output
175 .lines()
176 .filter(|line| is_remote_destination(line) || is_ref_update_line(line))
177 .map(|line| line.trim_end().to_string())
178 .collect();
179 if result.is_empty() {
180 return GenericCompressor::compress_output(output);
181 }
182 cap_git_lines(result, "push lines", GIT_WRITE_KEEP_LINES)
183}
184
185fn compress_pull(output: &str) -> String {
186 if output.trim().is_empty() {
187 return GenericCompressor::compress_output(output);
188 }
189 if looks_like_git_error(output) {
190 return trim_trailing_lines(output);
191 }
192 if let Some(line) = output
193 .lines()
194 .find(|line| line.trim() == "Already up to date.")
195 {
196 return line.trim_end().to_string();
197 }
198 let result: Vec<String> = output
199 .lines()
200 .filter(|line| {
201 looks_like_updating_line(line)
202 || looks_like_pull_marker(line)
203 || looks_like_commit_summary(line)
204 })
205 .map(|line| line.trim_end().to_string())
206 .collect();
207 if result.is_empty() {
208 return GenericCompressor::compress_output(output);
209 }
210 cap_git_lines(result, "pull lines", GIT_WRITE_KEEP_LINES)
211}
212
213fn compress_fetch(output: &str) -> String {
214 if output.trim().is_empty() {
215 return "git fetch: ok".to_string();
216 }
217 if looks_like_git_error(output) {
218 return trim_trailing_lines(output);
219 }
220 let result: Vec<String> = output
221 .lines()
222 .filter(|line| is_fetch_from_line(line) || is_ref_update_line(line))
223 .map(|line| line.trim_end().to_string())
224 .collect();
225 if result.is_empty() {
226 return GenericCompressor::compress_output(output);
227 }
228 cap_git_lines(result, "fetch lines", GIT_WRITE_KEEP_LINES)
229}
230
231fn compress_stash(command: &str, output: &str) -> String {
232 if output.trim().is_empty() {
233 return GenericCompressor::compress_output(output);
234 }
235 if looks_like_git_error(output) {
236 return trim_trailing_lines(output);
237 }
238 match git_subcommand_after(command, "stash").as_deref() {
239 None | Some("push") | Some("save") => output
240 .lines()
241 .find(|line| line.starts_with("Saved working directory and index state"))
242 .map(|line| line.trim_end().to_string())
243 .unwrap_or_else(|| GenericCompressor::compress_output(output)),
244 Some("pop" | "apply") => cap_git_lines(
245 output
246 .lines()
247 .map(|line| line.trim_end().to_string())
248 .collect(),
249 "stash status lines",
250 GIT_STASH_STATUS_KEEP_LINES,
251 ),
252 Some("list") => trim_trailing_lines(output),
253 _ => GenericCompressor::compress_output(output),
254 }
255}
256
257fn looks_like_git_error(output: &str) -> bool {
258 output.lines().any(|line| {
259 let trimmed = line.trim_start();
260 trimmed.starts_with("error:")
261 || trimmed.starts_with("fatal:")
262 || trimmed.starts_with("CONFLICT ")
263 || trimmed.starts_with("Automatic merge failed")
264 || trimmed.starts_with("! [rejected]")
265 || trimmed.starts_with("! [remote rejected]")
266 || trimmed.starts_with("failed to push")
267 })
268}
269
270fn looks_like_commit_subject(line: &str) -> bool {
271 let trimmed = line.trim_start();
272 trimmed.starts_with('[') && trimmed.contains("] ")
273}
274
275fn looks_like_commit_summary(line: &str) -> bool {
276 let trimmed = line.trim();
277 (trimmed.contains("file changed") || trimmed.contains("files changed"))
278 && (trimmed.contains("insertion")
279 || trimmed.contains("deletion")
280 || trimmed.contains("changed"))
281}
282
283fn is_remote_destination(line: &str) -> bool {
284 line.starts_with("To ")
285}
286
287fn is_fetch_from_line(line: &str) -> bool {
288 line.starts_with("From ")
289}
290
291fn is_ref_update_line(line: &str) -> bool {
292 let trimmed = line.trim_start();
293 trimmed.contains(" -> ")
294 && (trimmed.starts_with('*')
295 || trimmed.starts_with('+')
296 || trimmed.starts_with('-')
297 || trimmed.starts_with('=')
298 || trimmed.starts_with('!')
299 || trimmed.split_whitespace().next().is_some_and(is_hash_range))
300}
301
302fn is_hash_range(token: &str) -> bool {
303 token
304 .split_once("..")
305 .is_some_and(|(left, right)| is_short_hash(left) && is_short_hash(right))
306}
307
308fn is_short_hash(token: &str) -> bool {
309 (4..=40).contains(&token.len()) && token.bytes().all(|byte| byte.is_ascii_hexdigit())
310}
311
312fn looks_like_updating_line(line: &str) -> bool {
313 line.trim_start().starts_with("Updating ")
314}
315
316fn looks_like_pull_marker(line: &str) -> bool {
317 let trimmed = line.trim();
318 trimmed == "Fast-forward" || trimmed.starts_with("Merge made by ")
319}
320
321fn cap_git_lines(mut lines: Vec<String>, summary_name: &str, keep_lines: usize) -> String {
322 if lines.len() > keep_lines {
323 let omitted = lines.len() - keep_lines;
324 lines.truncate(keep_lines);
325 lines.push(format!("... ({} more {})", omitted, summary_name));
326 }
327 trim_trailing_lines(&lines.join("\n"))
328}
329
330fn compress_status(output: &str) -> String {
331 if output.len() <= STATUS_SHORT_LIMIT {
332 return trim_trailing_lines(output);
333 }
334
335 let mut result = Vec::new();
336 let mut section_entries = Vec::new();
337 let mut in_section = false;
338
339 for line in output.lines() {
340 if is_status_section_header(line) {
341 flush_status_entries(&mut result, &mut section_entries);
342 result.push(line.to_string());
343 in_section = true;
344 } else if in_section && is_status_instructional(line) {
345 result.push(line.to_string());
351 } else if in_section && is_status_entry(line) {
352 section_entries.push(line.to_string());
353 } else {
354 flush_status_entries(&mut result, &mut section_entries);
355 result.push(line.to_string());
356 in_section = false;
357 }
358 }
359 flush_status_entries(&mut result, &mut section_entries);
360
361 trim_trailing_lines(&result.join("\n"))
362}
363
364fn is_status_section_header(line: &str) -> bool {
365 matches!(
366 line.trim_end_matches(':'),
367 "Changes to be committed"
368 | "Changes not staged for commit"
369 | "Untracked files"
370 | "Unmerged paths"
371 )
372}
373
374fn is_status_instructional(line: &str) -> bool {
380 let trimmed = line.trim_start();
381 trimmed.starts_with('(') || trimmed.starts_with("use ")
382}
383
384fn is_status_entry(line: &str) -> bool {
385 let trimmed = line.trim_start();
386 trimmed.starts_with("modified:")
387 || trimmed.starts_with("new file:")
388 || trimmed.starts_with("deleted:")
389 || trimmed.starts_with("renamed:")
390 || trimmed.starts_with("copied:")
391 || trimmed.starts_with("both modified:")
392 || trimmed.starts_with("both added:")
393 || trimmed.starts_with("deleted by us:")
394 || trimmed.starts_with("deleted by them:")
395 || (!trimmed.is_empty()
396 && !trimmed.starts_with('(')
397 && !trimmed.starts_with("use ")
398 && !trimmed.starts_with("no changes"))
399}
400
401fn flush_status_entries(result: &mut Vec<String>, entries: &mut Vec<String>) {
402 if entries.is_empty() {
403 return;
404 }
405
406 let keep = entries.len().min(STATUS_KEEP_PER_SECTION);
407 result.extend(entries.iter().take(keep).cloned());
408 if entries.len() > keep {
409 result.push(format!("... and {} more", entries.len() - keep));
410 }
411 entries.clear();
412}
413
414fn compress_diff(output: &str, keep_commit_header: bool) -> String {
415 let files = split_diff_files(output, keep_commit_header);
416 let total_hunks: usize = files.iter().map(|file| count_hunks(&file.lines)).sum();
417
418 if files.is_empty() || total_hunks <= 2 && output.len() <= 5 * 1024 {
419 return trim_trailing_lines(output);
420 }
421
422 let max_files = if total_hunks > DIFF_MAX_HUNKS {
423 DIFF_MAX_FILES
424 } else {
425 usize::MAX
426 };
427
428 let mut result = Vec::new();
429 let mut emitted_files = 0usize;
430
431 for file in &files {
432 if file.is_diff && emitted_files >= max_files {
433 continue;
434 }
435 result.extend(compress_diff_file(&file.lines));
436 emitted_files += usize::from(file.is_diff);
437 }
438
439 let changed_files = files.iter().filter(|file| file.is_diff).count();
440 if changed_files > emitted_files {
441 result.push(format!(
442 "... and {} more files changed",
443 changed_files - emitted_files
444 ));
445 }
446
447 middle_truncate(
448 &trim_trailing_lines(&result.join("\n")),
449 16 * 1024,
450 7 * 1024,
451 7 * 1024,
452 )
453}
454
455struct DiffFile {
456 lines: Vec<String>,
457 is_diff: bool,
458}
459
460fn split_diff_files(output: &str, keep_commit_header: bool) -> Vec<DiffFile> {
461 let mut files = Vec::new();
462 let mut current = Vec::new();
463 let mut current_is_diff = false;
464
465 for line in output.lines() {
466 if line.starts_with("diff --git ") {
467 if !current.is_empty() {
468 files.push(DiffFile {
469 lines: std::mem::take(&mut current),
470 is_diff: current_is_diff,
471 });
472 }
473 current_is_diff = true;
474 } else if !current_is_diff && !keep_commit_header && !line.starts_with("diff --git ") {
475 current_is_diff = true;
476 }
477 current.push(line.to_string());
478 }
479
480 if !current.is_empty() {
481 files.push(DiffFile {
482 lines: current,
483 is_diff: current_is_diff,
484 });
485 }
486
487 files
488}
489
490fn compress_diff_file(lines: &[String]) -> Vec<String> {
491 let mut result = Vec::new();
492 let mut index = 0usize;
493
494 while index < lines.len() {
495 let line = &lines[index];
496 if !line.starts_with("@@") {
497 result.push(line.clone());
498 index += 1;
499 continue;
500 }
501
502 let hunk_start = index;
503 index += 1;
504 while index < lines.len() && !lines[index].starts_with("@@") {
505 index += 1;
506 }
507 let hunk = &lines[hunk_start..index];
508 append_hunk(&mut result, hunk);
509 }
510
511 result
512}
513
514fn append_hunk(result: &mut Vec<String>, hunk: &[String]) {
515 if hunk.len() <= HUNK_KEEP_LINES + 1 {
516 result.extend(hunk.iter().cloned());
517 return;
518 }
519
520 result.extend(hunk.iter().take(HUNK_KEEP_LINES + 1).cloned());
521 let remaining = &hunk[HUNK_KEEP_LINES + 1..];
522 let added = remaining
523 .iter()
524 .filter(|line| line.starts_with('+'))
525 .count();
526 let removed = remaining
527 .iter()
528 .filter(|line| line.starts_with('-'))
529 .count();
530 result.push(format!(
531 "... +{} -{} in {} more lines",
532 added,
533 removed,
534 remaining.len()
535 ));
536}
537
538fn count_hunks(lines: &[String]) -> usize {
539 lines.iter().filter(|line| line.starts_with("@@")).count()
540}
541
542fn compress_log(output: &str) -> String {
543 let mut commits = 0usize;
544 let mut omitted = 0usize;
545 let mut result = Vec::new();
546 let mut seen_authors = HashSet::new();
547
548 for line in output.lines() {
549 let is_commit = line.starts_with("commit ") || looks_like_oneline_commit(line);
550 if is_commit {
551 commits += 1;
552 if commits > LOG_KEEP_COMMITS {
553 omitted += 1;
554 continue;
555 }
556 }
557
558 if commits > LOG_KEEP_COMMITS {
559 continue;
560 }
561
562 if line.starts_with("Author: ") && !seen_authors.insert(line.to_string()) {
563 continue;
564 }
565
566 result.push(line.to_string());
567 }
568
569 if omitted > 0 {
570 result.push(format!("... {} more commits", omitted));
571 }
572
573 trim_trailing_lines(&result.join("\n"))
574}
575
576fn looks_like_oneline_commit(line: &str) -> bool {
577 let Some((hash, _message)) = line.split_once(' ') else {
578 return false;
579 };
580 (7..=40).contains(&hash.len()) && hash.bytes().all(|byte| byte.is_ascii_hexdigit())
581}
582
583fn compress_blame(output: &str) -> String {
584 let total = output.lines().count();
585 if total <= BLAME_KEEP_LINES {
586 return trim_trailing_lines(output);
587 }
588
589 let mut result: Vec<String> = output
590 .lines()
591 .take(BLAME_KEEP_LINES)
592 .map(ToString::to_string)
593 .collect();
594 result.push(format!("... {} more blame lines", total - BLAME_KEEP_LINES));
595 result.join("\n")
596}
597
598fn trim_trailing_lines(input: &str) -> String {
599 input
600 .lines()
601 .map(str::trim_end)
602 .collect::<Vec<_>>()
603 .join("\n")
604}
605
606#[cfg(test)]
607mod tests {
608 use super::*;
609 use crate::compress::Compressor;
610
611 fn compress(command: &str, output: &str) -> CompressionResult {
612 GitCompressor.compress(command, output)
613 }
614
615 #[test]
616 fn test_add_empty_output_ok() {
617 let compressed = compress("git add .", "");
618 assert_eq!(compressed, "git: ok");
619 }
620 #[test]
621 fn test_add_verbose_many_files() {
622 let raw = "add 'src/a.rs'\nadd 'src/b.rs'\nadd 'src/c.rs'\nadd 'src/d.rs'\nadd 'src/e.rs'\nadd 'src/f.rs'\nadd 'src/g.rs'\n";
623 let compressed = compress("git add --verbose .", raw);
624 assert!(compressed.contains("add 'src/a.rs'"));
625 assert!(compressed.contains("add 'src/e.rs'"));
626 assert!(compressed.contains("... (2 more files added)"));
627 assert!(!compressed.contains("add 'src/g.rs'"));
628 }
629 #[test]
630 fn test_add_error_passthrough() {
631 let raw = "fatal: pathspec 'missing.rs' did not match any files\n";
632 let compressed = compress("git add missing.rs", raw);
633 assert_eq!(
634 compressed,
635 "fatal: pathspec 'missing.rs' did not match any files"
636 );
637 }
638 #[test]
639 fn test_commit_success_extracts_subject_and_summary() {
640 let raw = "[main 1a2b3c4] add git write compression\n 3 files changed, 42 insertions(+), 7 deletions(-)\n create mode 100644 crates/aft/src/foo.rs\n rewrite crates/aft/src/bar.rs (80%)\n";
641 let compressed = compress("git commit -m 'add git write compression'", raw);
642 assert_eq!(
643 compressed,
644 "[main 1a2b3c4] add git write compression\n3 files changed, 42 insertions(+), 7 deletions(-)"
645 );
646 }
647 #[test]
648 fn test_commit_nothing_to_commit_verbatim() {
649 let raw = "On branch main\nnothing to commit, working tree clean\n";
650 let compressed = compress("git commit -m noop", raw);
651 assert_eq!(compressed, "nothing to commit, working tree clean");
652 }
653 #[test]
654 fn test_commit_error_passthrough() {
655 let raw = "error: Committing is not possible because you have unmerged files.\nhint: Fix them up in the work tree, and then use 'git add/rm <file>'\nfatal: Exiting because of an unresolved conflict.\n";
656 let compressed = compress("git commit", raw);
657 assert!(compressed.contains("error: Committing is not possible"));
658 assert!(compressed.contains("fatal: Exiting because of an unresolved conflict."));
659 }
660 #[test]
661 fn test_push_success_drops_progress_keeps_remote_and_ref() {
662 let raw = "Counting objects: 12, done.\nDelta compression using up to 8 threads\nCompressing objects: 100% (7/7), done.\nWriting objects: 100% (7/7), 1.23 KiB | 1.23 MiB/s, done.\nTotal 7 (delta 4), reused 0 (delta 0), pack-reused 0\nremote: Resolving deltas: 100% (4/4), completed with 4 local objects.\nTo github.com:example/repo.git\n 9d8c7b6..1a2b3c4 main -> main\n";
663 let compressed = compress("git push", raw);
664 assert_eq!(
665 compressed,
666 "To github.com:example/repo.git\n 9d8c7b6..1a2b3c4 main -> main"
667 );
668 }
669 #[test]
670 fn test_push_everything_up_to_date_and_empty() {
671 assert_eq!(
672 compress("git push", "Everything up-to-date\n"),
673 "Everything up-to-date"
674 );
675 assert_eq!(compress("git push", ""), "");
676 }
677 #[test]
678 fn test_push_error_passthrough() {
679 let raw = "To github.com:example/repo.git\n ! [rejected] main -> main (fetch first)\nerror: failed to push some refs to 'github.com:example/repo.git'\n";
680 let compressed = compress("git push", raw);
681 assert!(compressed.contains("! [rejected] main -> main (fetch first)"));
682 assert!(compressed.contains("error: failed to push some refs"));
683 }
684 #[test]
685 fn test_pull_fast_forward_keeps_summary() {
686 let raw = "remote: Enumerating objects: 9, done.\nremote: Counting objects: 100% (9/9), done.\nFrom github.com:example/repo\n 1111111..2222222 main -> origin/main\nUpdating 1111111..2222222\nFast-forward\n crates/aft/src/compress/git.rs | 12 +++++++++---\n 1 file changed, 9 insertions(+), 3 deletions(-)\n";
687 let compressed = compress("git pull --ff-only", raw);
688 assert_eq!(
689 compressed,
690 "Updating 1111111..2222222\nFast-forward\n 1 file changed, 9 insertions(+), 3 deletions(-)"
691 );
692 }
693 #[test]
694 fn test_pull_already_up_to_date_empty_and_error() {
695 assert_eq!(
696 compress("git pull", "Already up to date.\n"),
697 "Already up to date."
698 );
699 assert_eq!(compress("git pull", ""), "");
700 let raw = "CONFLICT (content): Merge conflict in README.md\nAutomatic merge failed; fix conflicts and then commit the result.\n";
701 let compressed = compress("git pull", raw);
702 assert!(compressed.contains("CONFLICT (content): Merge conflict in README.md"));
703 assert!(compressed.contains("Automatic merge failed"));
704 }
705 #[test]
706 fn test_fetch_success_empty_and_error() {
707 let raw = "remote: Enumerating objects: 5, done.\nremote: Counting objects: 100% (5/5), done.\nFrom github.com:example/repo\n * [new branch] feature/git-compress -> origin/feature/git-compress\n abc1234..def5678 main -> origin/main\n";
708 let compressed = compress("git fetch --all", raw);
709 assert_eq!(
710 compressed,
711 "From github.com:example/repo\n * [new branch] feature/git-compress -> origin/feature/git-compress\n abc1234..def5678 main -> origin/main"
712 );
713 assert_eq!(compress("git fetch", " \n"), "git fetch: ok");
714 let error =
715 "fatal: unable to access 'https://example.invalid/repo.git/': Could not resolve host\n";
716 assert_eq!(
717 compress("git fetch", error),
718 "fatal: unable to access 'https://example.invalid/repo.git/': Could not resolve host"
719 );
720 }
721 #[test]
722 fn test_stash_push_pop_list_empty_and_error() {
723 let push = "Saved working directory and index state WIP on main: 1a2b3c4 add tests\nHEAD is now at 1a2b3c4 add tests\n";
724 assert_eq!(
725 compress("git stash push", push),
726 "Saved working directory and index state WIP on main: 1a2b3c4 add tests"
727 );
728 let pop = "On branch main\nChanges not staged for commit:\n (use \"git add <file>...\" to update what will be committed)\n\tmodified: README.md\nDropped refs/stash@{0} (abc123456789)\n";
729 let compressed_pop = compress("git stash pop", pop);
730 assert!(compressed_pop.contains("On branch main"));
731 assert!(compressed_pop.contains("Dropped refs/stash@{0}"));
732 let list = "stash@{0}: WIP on main: 1111111 first\nstash@{1}: On feature: second\n";
733 assert_eq!(compress("git stash list", list), list.trim_end());
734 assert_eq!(compress("git stash", ""), "");
735 let error = "error: Your local changes to the following files would be overwritten by merge:\n\tREADME.md\n";
736 let compressed_error = compress("git stash apply", error);
737 assert!(compressed_error.contains("error: Your local changes"));
738 assert!(compressed_error.contains("README.md"));
739 }
740}