Skip to main content

aft/compress/
git.rs

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            // Lines like `  (use "git add <file>..." to include in what will be
346            // committed)` come right after the section header in real git
347            // output. They're informational, not entries — pass them through
348            // verbatim WITHOUT resetting `in_section` so the entries that
349            // follow still get aggregated and summarized.
350            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
374/// Recognize the parenthesized instructional lines git emits inside a status
375/// section, e.g. `  (use "git add <file>..." to include in what will be committed)`.
376/// These come right after the section header and must NOT reset the
377/// in-section state, otherwise the actual entries that follow are missed by
378/// the entry aggregator.
379fn 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}