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