Skip to main content

aft/compress/
git.rs

1use crate::compress::generic::{dedup_consecutive, middle_truncate, GenericCompressor};
2use crate::compress::{CompressionResult, Compressor};
3
4const STATUS_SHORT_LIMIT: usize = 1024;
5const STATUS_KEEP_PER_SECTION: usize = 10;
6const DIFF_MAX_FILES: usize = 5;
7const DIFF_MAX_HUNKS: usize = 20;
8const HUNK_KEEP_LINES: usize = 30;
9const LOG_SHORT_HASH_LEN: usize = 12;
10const BLAME_KEEP_LINES: usize = 50;
11const GIT_WRITE_KEEP_LINES: usize = 50;
12const GIT_ADD_KEEP_PATHS: usize = 5;
13const GIT_STASH_STATUS_KEEP_LINES: usize = 20;
14
15pub struct GitCompressor;
16
17impl Compressor for GitCompressor {
18    fn matches(&self, command: &str) -> bool {
19        command_head(command).is_some_and(|head| head == "git")
20    }
21
22    fn compress_with_exit_code(
23        &self,
24        command: &str,
25        output: &str,
26        exit_code: Option<i32>,
27    ) -> CompressionResult {
28        let compressed = match git_subcommand(command).as_deref() {
29            Some("add") => compress_add(output),
30            Some("status") => compress_status(output),
31            Some("diff") => compress_diff(output, false),
32            Some("log") => compress_log(output),
33            Some("show") => compress_diff(output, true),
34            Some("branch") => trim_trailing_lines(&dedup_consecutive(output)),
35            Some("blame") => compress_blame(output),
36            Some("commit") => compress_commit(output),
37            Some("push") => compress_push(output),
38            Some("pull") => compress_pull(output),
39            Some("fetch") => compress_fetch(output),
40            Some("stash") => compress_stash(command, output),
41            _ => GenericCompressor::compress_output(output),
42        };
43        if matches!(exit_code, Some(code) if code != 0)
44            && looks_like_git_success_summary(&compressed)
45        {
46            GenericCompressor::compress_output(output).into()
47        } else {
48            compressed.into()
49        }
50    }
51}
52
53fn looks_like_git_success_summary(text: &str) -> bool {
54    let trimmed = text.trim();
55    matches!(
56        trimmed,
57        "git: ok" | "git fetch: ok" | "Everything up-to-date" | "Already up to date."
58    ) || trimmed.contains("working tree clean")
59}
60
61fn command_head(command: &str) -> Option<&str> {
62    command.split_whitespace().next()
63}
64
65fn git_subcommand(command: &str) -> Option<String> {
66    let mut seen_git = 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 token.starts_with('-') || token.contains('=') {
75            continue;
76        }
77        if crate::compress::is_shell_boundary(token) {
78            return None;
79        }
80        return Some(token.to_string());
81    }
82    None
83}
84
85fn git_subcommand_after(command: &str, subcommand: &str) -> Option<String> {
86    let mut seen_git = false;
87    let mut seen_subcommand = false;
88    for token in command.split_whitespace() {
89        if !seen_git {
90            if token == "git" {
91                seen_git = true;
92            }
93            continue;
94        }
95        if !seen_subcommand {
96            if token.starts_with('-') || token.contains('=') {
97                continue;
98            }
99            seen_subcommand = token == subcommand;
100            continue;
101        }
102        if token.starts_with('-') || token.contains('=') {
103            continue;
104        }
105        return Some(token.to_string());
106    }
107    None
108}
109
110fn compress_add(output: &str) -> String {
111    if output.trim().is_empty() {
112        return "git: ok".to_string();
113    }
114    if looks_like_git_error(output) {
115        return trim_trailing_lines(output);
116    }
117    let lines: Vec<&str> = output
118        .lines()
119        .filter(|line| !line.trim().is_empty())
120        .collect();
121    if lines.is_empty() {
122        return "git: ok".to_string();
123    }
124    let mut result: Vec<String> = lines
125        .iter()
126        .take(GIT_ADD_KEEP_PATHS)
127        .map(|line| line.trim_end().to_string())
128        .collect();
129    if lines.len() > GIT_ADD_KEEP_PATHS {
130        result.push(format!(
131            "... ({} more files added)",
132            lines.len() - GIT_ADD_KEEP_PATHS
133        ));
134    }
135    cap_git_lines(result, "files added", GIT_WRITE_KEEP_LINES)
136}
137
138fn compress_commit(output: &str) -> String {
139    if output.trim().is_empty() {
140        return GenericCompressor::compress_output(output);
141    }
142    if looks_like_git_error(output) {
143        return trim_trailing_lines(output);
144    }
145    if let Some(line) = output
146        .lines()
147        .find(|line| line.contains("nothing to commit"))
148    {
149        return line.trim_end().to_string();
150    }
151    let subject = output.lines().find(|line| looks_like_commit_subject(line));
152    let summary = output.lines().find(|line| looks_like_commit_summary(line));
153    match (subject, summary) {
154        (Some(subject), Some(summary)) => {
155            trim_trailing_lines(&format!("{}\n{}", subject.trim_end(), summary.trim()))
156        }
157        (Some(subject), None) => subject.trim_end().to_string(),
158        _ => GenericCompressor::compress_output(output),
159    }
160}
161
162fn compress_push(output: &str) -> String {
163    if output.trim().is_empty() {
164        return GenericCompressor::compress_output(output);
165    }
166    if looks_like_git_error(output) {
167        return trim_trailing_lines(output);
168    }
169    if let Some(line) = output
170        .lines()
171        .find(|line| line.trim() == "Everything up-to-date")
172    {
173        return line.trim_end().to_string();
174    }
175    let result: Vec<String> = output
176        .lines()
177        .filter(|line| is_remote_destination(line) || is_ref_update_line(line))
178        .map(|line| line.trim_end().to_string())
179        .collect();
180    if result.is_empty() {
181        return GenericCompressor::compress_output(output);
182    }
183    cap_git_lines(result, "push lines", GIT_WRITE_KEEP_LINES)
184}
185
186fn compress_pull(output: &str) -> String {
187    if output.trim().is_empty() {
188        return GenericCompressor::compress_output(output);
189    }
190    if looks_like_git_error(output) {
191        return trim_trailing_lines(output);
192    }
193    if let Some(line) = output
194        .lines()
195        .find(|line| line.trim() == "Already up to date.")
196    {
197        return line.trim_end().to_string();
198    }
199    let result: Vec<String> = output
200        .lines()
201        .filter(|line| {
202            looks_like_updating_line(line)
203                || looks_like_pull_marker(line)
204                || looks_like_commit_summary(line)
205        })
206        .map(|line| line.trim_end().to_string())
207        .collect();
208    if result.is_empty() {
209        return GenericCompressor::compress_output(output);
210    }
211    cap_git_lines(result, "pull lines", GIT_WRITE_KEEP_LINES)
212}
213
214fn compress_fetch(output: &str) -> String {
215    if output.trim().is_empty() {
216        return "git fetch: ok".to_string();
217    }
218    if looks_like_git_error(output) {
219        return trim_trailing_lines(output);
220    }
221    let result: Vec<String> = output
222        .lines()
223        .filter(|line| is_fetch_from_line(line) || is_ref_update_line(line))
224        .map(|line| line.trim_end().to_string())
225        .collect();
226    if result.is_empty() {
227        return GenericCompressor::compress_output(output);
228    }
229    cap_git_lines(result, "fetch lines", GIT_WRITE_KEEP_LINES)
230}
231
232fn compress_stash(command: &str, output: &str) -> String {
233    if output.trim().is_empty() {
234        return GenericCompressor::compress_output(output);
235    }
236    if looks_like_git_error(output) {
237        return trim_trailing_lines(output);
238    }
239    match git_subcommand_after(command, "stash").as_deref() {
240        None | Some("push") | Some("save") => output
241            .lines()
242            .find(|line| line.starts_with("Saved working directory and index state"))
243            .map(|line| line.trim_end().to_string())
244            .unwrap_or_else(|| GenericCompressor::compress_output(output)),
245        Some("pop" | "apply") => cap_git_lines(
246            output
247                .lines()
248                .map(|line| line.trim_end().to_string())
249                .collect(),
250            "stash status lines",
251            GIT_STASH_STATUS_KEEP_LINES,
252        ),
253        Some("list") => trim_trailing_lines(output),
254        _ => GenericCompressor::compress_output(output),
255    }
256}
257
258fn looks_like_git_error(output: &str) -> bool {
259    output.lines().any(|line| {
260        let trimmed = line.trim_start();
261        trimmed.starts_with("error:")
262            || trimmed.starts_with("fatal:")
263            || trimmed.starts_with("CONFLICT ")
264            || trimmed.starts_with("Automatic merge failed")
265            || trimmed.starts_with("! [rejected]")
266            || trimmed.starts_with("! [remote rejected]")
267            || trimmed.starts_with("failed to push")
268    })
269}
270
271fn looks_like_commit_subject(line: &str) -> bool {
272    let trimmed = line.trim_start();
273    trimmed.starts_with('[') && trimmed.contains("] ")
274}
275
276fn looks_like_commit_summary(line: &str) -> bool {
277    let trimmed = line.trim();
278    (trimmed.contains("file changed") || trimmed.contains("files changed"))
279        && (trimmed.contains("insertion")
280            || trimmed.contains("deletion")
281            || trimmed.contains("changed"))
282}
283
284fn is_remote_destination(line: &str) -> bool {
285    line.starts_with("To ")
286}
287
288fn is_fetch_from_line(line: &str) -> bool {
289    line.starts_with("From ")
290}
291
292fn is_ref_update_line(line: &str) -> bool {
293    let trimmed = line.trim_start();
294    trimmed.contains(" -> ")
295        && (trimmed.starts_with('*')
296            || trimmed.starts_with('+')
297            || trimmed.starts_with('-')
298            || trimmed.starts_with('=')
299            || trimmed.starts_with('!')
300            || trimmed.split_whitespace().next().is_some_and(is_hash_range))
301}
302
303fn is_hash_range(token: &str) -> bool {
304    token
305        .split_once("..")
306        .is_some_and(|(left, right)| is_short_hash(left) && is_short_hash(right))
307}
308
309fn is_short_hash(token: &str) -> bool {
310    (4..=40).contains(&token.len()) && token.bytes().all(|byte| byte.is_ascii_hexdigit())
311}
312
313fn looks_like_updating_line(line: &str) -> bool {
314    line.trim_start().starts_with("Updating ")
315}
316
317fn looks_like_pull_marker(line: &str) -> bool {
318    let trimmed = line.trim();
319    trimmed == "Fast-forward" || trimmed.starts_with("Merge made by ")
320}
321
322fn cap_git_lines(mut lines: Vec<String>, summary_name: &str, keep_lines: usize) -> String {
323    if lines.len() > keep_lines {
324        let omitted = lines.len() - keep_lines;
325        lines.truncate(keep_lines);
326        lines.push(format!("... ({} more {})", omitted, summary_name));
327    }
328    trim_trailing_lines(&lines.join("\n"))
329}
330
331fn compress_status(output: &str) -> String {
332    if output.len() <= STATUS_SHORT_LIMIT {
333        return trim_trailing_lines(output);
334    }
335
336    let mut result = Vec::new();
337    let mut section_entries = Vec::new();
338    let mut in_section = false;
339
340    for line in output.lines() {
341        if is_status_section_header(line) {
342            flush_status_entries(&mut result, &mut section_entries);
343            result.push(line.to_string());
344            in_section = true;
345        } else if in_section && is_status_instructional(line) {
346            // Lines like `  (use "git add <file>..." to include in what will be
347            // committed)` come right after the section header in real git
348            // output. They're informational, not entries — pass them through
349            // verbatim WITHOUT resetting `in_section` so the entries that
350            // follow still get aggregated and summarized.
351            result.push(line.to_string());
352        } else if in_section && is_status_entry(line) {
353            section_entries.push(line.to_string());
354        } else {
355            flush_status_entries(&mut result, &mut section_entries);
356            result.push(line.to_string());
357            in_section = false;
358        }
359    }
360    flush_status_entries(&mut result, &mut section_entries);
361
362    trim_trailing_lines(&result.join("\n"))
363}
364
365fn is_status_section_header(line: &str) -> bool {
366    matches!(
367        line.trim_end_matches(':'),
368        "Changes to be committed"
369            | "Changes not staged for commit"
370            | "Untracked files"
371            | "Unmerged paths"
372    )
373}
374
375/// Recognize the parenthesized instructional lines git emits inside a status
376/// section, e.g. `  (use "git add <file>..." to include in what will be committed)`.
377/// These come right after the section header and must NOT reset the
378/// in-section state, otherwise the actual entries that follow are missed by
379/// the entry aggregator.
380fn is_status_instructional(line: &str) -> bool {
381    let trimmed = line.trim_start();
382    trimmed.starts_with('(') || trimmed.starts_with("use ")
383}
384
385fn is_status_entry(line: &str) -> bool {
386    let trimmed = line.trim_start();
387    trimmed.starts_with("modified:")
388        || trimmed.starts_with("new file:")
389        || trimmed.starts_with("deleted:")
390        || trimmed.starts_with("renamed:")
391        || trimmed.starts_with("copied:")
392        || trimmed.starts_with("both modified:")
393        || trimmed.starts_with("both added:")
394        || trimmed.starts_with("deleted by us:")
395        || trimmed.starts_with("deleted by them:")
396        || (!trimmed.is_empty()
397            && !trimmed.starts_with('(')
398            && !trimmed.starts_with("use ")
399            && !trimmed.starts_with("no changes"))
400}
401
402fn flush_status_entries(result: &mut Vec<String>, entries: &mut Vec<String>) {
403    if entries.is_empty() {
404        return;
405    }
406
407    let keep = entries.len().min(STATUS_KEEP_PER_SECTION);
408    result.extend(entries.iter().take(keep).cloned());
409    if entries.len() > keep {
410        result.push(format!("... and {} more", entries.len() - keep));
411    }
412    entries.clear();
413}
414
415fn compress_diff(output: &str, keep_commit_header: bool) -> String {
416    let files = split_diff_files(output, keep_commit_header);
417    let total_hunks: usize = files.iter().map(|file| count_hunks(&file.lines)).sum();
418
419    if files.is_empty() || total_hunks <= 2 && output.len() <= 5 * 1024 {
420        return trim_trailing_lines(output);
421    }
422
423    let max_files = if total_hunks > DIFF_MAX_HUNKS {
424        DIFF_MAX_FILES
425    } else {
426        usize::MAX
427    };
428
429    let mut result = Vec::new();
430    let mut emitted_files = 0usize;
431
432    for file in &files {
433        if file.is_diff && emitted_files >= max_files {
434            continue;
435        }
436        result.extend(compress_diff_file(&file.lines));
437        emitted_files += usize::from(file.is_diff);
438    }
439
440    let changed_files = files.iter().filter(|file| file.is_diff).count();
441    if changed_files > emitted_files {
442        result.push(format!(
443            "... and {} more files changed",
444            changed_files - emitted_files
445        ));
446    }
447
448    middle_truncate(
449        &trim_trailing_lines(&result.join("\n")),
450        16 * 1024,
451        7 * 1024,
452        7 * 1024,
453    )
454}
455
456struct DiffFile {
457    lines: Vec<String>,
458    is_diff: bool,
459}
460
461fn split_diff_files(output: &str, keep_commit_header: bool) -> Vec<DiffFile> {
462    let mut files = Vec::new();
463    let mut current = Vec::new();
464    let mut current_is_diff = false;
465
466    for line in output.lines() {
467        if line.starts_with("diff --git ") {
468            if !current.is_empty() {
469                files.push(DiffFile {
470                    lines: std::mem::take(&mut current),
471                    is_diff: current_is_diff,
472                });
473            }
474            current_is_diff = true;
475        } else if !current_is_diff && !keep_commit_header && !line.starts_with("diff --git ") {
476            current_is_diff = true;
477        }
478        current.push(line.to_string());
479    }
480
481    if !current.is_empty() {
482        files.push(DiffFile {
483            lines: current,
484            is_diff: current_is_diff,
485        });
486    }
487
488    files
489}
490
491fn compress_diff_file(lines: &[String]) -> Vec<String> {
492    let mut result = Vec::new();
493    let mut index = 0usize;
494
495    while index < lines.len() {
496        let line = &lines[index];
497        if !line.starts_with("@@") {
498            result.push(line.clone());
499            index += 1;
500            continue;
501        }
502
503        let hunk_start = index;
504        index += 1;
505        while index < lines.len() && !lines[index].starts_with("@@") {
506            index += 1;
507        }
508        let hunk = &lines[hunk_start..index];
509        append_hunk(&mut result, hunk);
510    }
511
512    result
513}
514
515fn append_hunk(result: &mut Vec<String>, hunk: &[String]) {
516    if hunk.len() <= HUNK_KEEP_LINES + 1 {
517        result.extend(hunk.iter().cloned());
518        return;
519    }
520
521    result.extend(hunk.iter().take(HUNK_KEEP_LINES + 1).cloned());
522    let remaining = &hunk[HUNK_KEEP_LINES + 1..];
523    let added = remaining
524        .iter()
525        .filter(|line| line.starts_with('+'))
526        .count();
527    let removed = remaining
528        .iter()
529        .filter(|line| line.starts_with('-'))
530        .count();
531    result.push(format!(
532        "... +{} -{} in {} more lines",
533        added,
534        removed,
535        remaining.len()
536    ));
537}
538
539fn count_hunks(lines: &[String]) -> usize {
540    lines.iter().filter(|line| line.starts_with("@@")).count()
541}
542
543fn compress_log(output: &str) -> String {
544    if output.lines().any(|line| line.starts_with("commit ")) {
545        compress_full_format_log(output)
546    } else {
547        compress_oneline_log(output)
548    }
549}
550
551fn compress_full_format_log(output: &str) -> String {
552    let lines: Vec<&str> = output.lines().collect();
553    let mut blocks: Vec<usize> = Vec::new();
554    for (index, line) in lines.iter().enumerate() {
555        if line.starts_with("commit ") {
556            blocks.push(index);
557        }
558    }
559    if blocks.is_empty() {
560        return trim_trailing_lines(output);
561    }
562
563    let mut result = Vec::new();
564    for (block_index, &start) in blocks.iter().enumerate() {
565        let end = blocks.get(block_index + 1).copied().unwrap_or(lines.len());
566        let block = &lines[start..end];
567        if let Some(compact) = format_log_commit_block(block) {
568            result.push(compact);
569        }
570    }
571
572    trim_trailing_lines(&result.join("\n"))
573}
574
575fn format_log_commit_block(block: &[&str]) -> Option<String> {
576    let first = block.first()?;
577    let full_hash = first.strip_prefix("commit ")?.trim();
578    let short_hash = abbreviate_log_hash(full_hash);
579
580    let mut merge_parents: Option<String> = None;
581    let mut author: Option<String> = None;
582    let mut date_compact: Option<String> = None;
583    let mut header_end = 1usize;
584
585    for (offset, line) in block.iter().enumerate().skip(1) {
586        if line.starts_with("Merge: ") {
587            let rest = line.strip_prefix("Merge: ").unwrap_or("");
588            let parents: Vec<String> = rest.split_whitespace().map(abbreviate_log_hash).collect();
589            merge_parents = Some(format!("Merge: {}", parents.join(" ")));
590            header_end = offset + 1;
591        } else if let Some(rest) = line.strip_prefix("Author: ") {
592            author = Some(format_author_compact(rest.trim()));
593            header_end = offset + 1;
594        } else if let Some(rest) = line.strip_prefix("Date: ") {
595            date_compact = Some(compact_git_log_date(rest.trim()));
596            header_end = offset + 1;
597        } else if line.trim().is_empty() {
598            header_end = offset + 1;
599            break;
600        } else {
601            break;
602        }
603    }
604
605    let mut body_iter = block[header_end..].iter().copied();
606    let mut subject = String::new();
607    let mut body_rest = Vec::new();
608    if let Some(first_body) = body_iter.next() {
609        subject = first_body.trim().to_string();
610        for line in body_iter {
611            body_rest.push(normalize_log_body_line(line));
612        }
613    }
614
615    let author = author.unwrap_or_default();
616    let date_compact = date_compact.unwrap_or_default();
617    let mut header = format!("{short_hash} {subject}");
618    if let Some(merge) = merge_parents {
619        header = format!("{short_hash} {merge} {subject}");
620    }
621    if !author.is_empty() {
622        header.push_str(&format!("  {author}"));
623    }
624    if !date_compact.is_empty() {
625        header.push(' ');
626        header.push_str(&date_compact);
627    }
628
629    let mut out = header;
630    for line in collapse_log_body_blank_runs(&body_rest) {
631        out.push('\n');
632        out.push_str(&line);
633    }
634    Some(out)
635}
636
637fn normalize_log_body_line(line: &str) -> String {
638    if line.starts_with(' ') || line.starts_with('\t') {
639        format!("    {}", line.trim_start())
640    } else {
641        format!("    {line}")
642    }
643}
644
645fn collapse_log_body_blank_runs(body: &[String]) -> Vec<String> {
646    body.iter()
647        .filter(|line| !line.trim().is_empty())
648        .cloned()
649        .collect()
650}
651
652fn format_author_compact(author: &str) -> String {
653    if let Some((name, email)) = author.split_once('<') {
654        let name = name.trim();
655        let email = email.trim_end_matches('>').trim();
656        format!("<{name} {email}>")
657    } else {
658        format!("<{author}>")
659    }
660}
661
662fn abbreviate_log_hash(hash: &str) -> String {
663    let hash = hash.trim();
664    if hash.len() <= LOG_SHORT_HASH_LEN {
665        hash.to_string()
666    } else {
667        hash[..LOG_SHORT_HASH_LEN].to_string()
668    }
669}
670
671fn compact_git_log_date(date_field: &str) -> String {
672    let parts: Vec<&str> = date_field.split_whitespace().collect();
673    if parts.len() < 5 {
674        return date_field.to_string();
675    }
676    let month = parts[1];
677    let day = parts[2];
678    let time = parts[3];
679    let year = parts[4];
680    let month_num = match month {
681        "Jan" => "01",
682        "Feb" => "02",
683        "Mar" => "03",
684        "Apr" => "04",
685        "May" => "05",
686        "Jun" => "06",
687        "Jul" => "07",
688        "Aug" => "08",
689        "Sep" => "09",
690        "Oct" => "10",
691        "Nov" => "11",
692        "Dec" => "12",
693        _ => return date_field.to_string(),
694    };
695    let day_padded = if day.len() == 1 {
696        format!("0{day}")
697    } else {
698        day.to_string()
699    };
700    format!("{year}-{month_num}-{day_padded} {time}")
701}
702
703fn compress_oneline_log(output: &str) -> String {
704    let result: Vec<String> = output
705        .lines()
706        .filter(|line| !line.trim().is_empty())
707        .map(|line| {
708            if looks_like_oneline_commit(line) {
709                let mut parts = line.splitn(2, ' ');
710                let hash = parts.next().unwrap_or("");
711                let rest = parts.next().unwrap_or("");
712                let short = abbreviate_log_hash(hash);
713                if rest.is_empty() {
714                    short
715                } else {
716                    format!("{short} {rest}")
717                }
718            } else {
719                line.trim_end().to_string()
720            }
721        })
722        .collect();
723    trim_trailing_lines(&result.join("\n"))
724}
725
726fn looks_like_oneline_commit(line: &str) -> bool {
727    let Some((hash, _message)) = line.split_once(' ') else {
728        return false;
729    };
730    (7..=40).contains(&hash.len()) && hash.bytes().all(|byte| byte.is_ascii_hexdigit())
731}
732
733fn compress_blame(output: &str) -> String {
734    let total = output.lines().count();
735    if total <= BLAME_KEEP_LINES {
736        return trim_trailing_lines(output);
737    }
738
739    let mut result: Vec<String> = output
740        .lines()
741        .take(BLAME_KEEP_LINES)
742        .map(ToString::to_string)
743        .collect();
744    result.push(format!("... {} more blame lines", total - BLAME_KEEP_LINES));
745    result.join("\n")
746}
747
748fn trim_trailing_lines(input: &str) -> String {
749    input
750        .lines()
751        .map(str::trim_end)
752        .collect::<Vec<_>>()
753        .join("\n")
754}
755
756#[cfg(test)]
757mod tests {
758    use super::*;
759    use crate::compress::Compressor;
760
761    fn compress(command: &str, output: &str) -> CompressionResult {
762        GitCompressor.compress(command, output)
763    }
764
765    #[test]
766    fn test_add_empty_output_ok() {
767        let compressed = compress("git add .", "");
768        assert_eq!(compressed, "git: ok");
769    }
770    #[test]
771    fn test_add_verbose_many_files() {
772        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";
773        let compressed = compress("git add --verbose .", raw);
774        assert!(compressed.contains("add 'src/a.rs'"));
775        assert!(compressed.contains("add 'src/e.rs'"));
776        assert!(compressed.contains("... (2 more files added)"));
777        assert!(!compressed.contains("add 'src/g.rs'"));
778    }
779    #[test]
780    fn test_add_error_passthrough() {
781        let raw = "fatal: pathspec 'missing.rs' did not match any files\n";
782        let compressed = compress("git add missing.rs", raw);
783        assert_eq!(
784            compressed,
785            "fatal: pathspec 'missing.rs' did not match any files"
786        );
787    }
788    #[test]
789    fn test_commit_success_extracts_subject_and_summary() {
790        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";
791        let compressed = compress("git commit -m 'add git write compression'", raw);
792        assert_eq!(
793            compressed,
794            "[main 1a2b3c4] add git write compression\n3 files changed, 42 insertions(+), 7 deletions(-)"
795        );
796    }
797    #[test]
798    fn test_commit_nothing_to_commit_verbatim() {
799        let raw = "On branch main\nnothing to commit, working tree clean\n";
800        let compressed = compress("git commit -m noop", raw);
801        assert_eq!(compressed, "nothing to commit, working tree clean");
802    }
803    #[test]
804    fn test_commit_error_passthrough() {
805        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";
806        let compressed = compress("git commit", raw);
807        assert!(compressed.contains("error: Committing is not possible"));
808        assert!(compressed.contains("fatal: Exiting because of an unresolved conflict."));
809    }
810    #[test]
811    fn test_push_success_drops_progress_keeps_remote_and_ref() {
812        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";
813        let compressed = compress("git push", raw);
814        assert_eq!(
815            compressed,
816            "To github.com:example/repo.git\n   9d8c7b6..1a2b3c4  main -> main"
817        );
818    }
819    #[test]
820    fn test_push_everything_up_to_date_and_empty() {
821        assert_eq!(
822            compress("git push", "Everything up-to-date\n"),
823            "Everything up-to-date"
824        );
825        assert_eq!(compress("git push", ""), "");
826    }
827    #[test]
828    fn test_push_error_passthrough() {
829        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";
830        let compressed = compress("git push", raw);
831        assert!(compressed.contains("! [rejected]        main -> main (fetch first)"));
832        assert!(compressed.contains("error: failed to push some refs"));
833    }
834    #[test]
835    fn test_pull_fast_forward_keeps_summary() {
836        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";
837        let compressed = compress("git pull --ff-only", raw);
838        assert_eq!(
839            compressed,
840            "Updating 1111111..2222222\nFast-forward\n 1 file changed, 9 insertions(+), 3 deletions(-)"
841        );
842    }
843    #[test]
844    fn test_pull_already_up_to_date_empty_and_error() {
845        assert_eq!(
846            compress("git pull", "Already up to date.\n"),
847            "Already up to date."
848        );
849        assert_eq!(compress("git pull", ""), "");
850        let raw = "CONFLICT (content): Merge conflict in README.md\nAutomatic merge failed; fix conflicts and then commit the result.\n";
851        let compressed = compress("git pull", raw);
852        assert!(compressed.contains("CONFLICT (content): Merge conflict in README.md"));
853        assert!(compressed.contains("Automatic merge failed"));
854    }
855    #[test]
856    fn test_fetch_success_empty_and_error() {
857        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";
858        let compressed = compress("git fetch --all", raw);
859        assert_eq!(
860            compressed,
861            "From github.com:example/repo\n * [new branch]      feature/git-compress -> origin/feature/git-compress\n   abc1234..def5678  main                 -> origin/main"
862        );
863        assert_eq!(compress("git fetch", "   \n"), "git fetch: ok");
864        let error =
865            "fatal: unable to access 'https://example.invalid/repo.git/': Could not resolve host\n";
866        assert_eq!(
867            compress("git fetch", error),
868            "fatal: unable to access 'https://example.invalid/repo.git/': Could not resolve host"
869        );
870    }
871    #[test]
872    fn test_stash_push_pop_list_empty_and_error() {
873        let push = "Saved working directory and index state WIP on main: 1a2b3c4 add tests\nHEAD is now at 1a2b3c4 add tests\n";
874        assert_eq!(
875            compress("git stash push", push),
876            "Saved working directory and index state WIP on main: 1a2b3c4 add tests"
877        );
878        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";
879        let compressed_pop = compress("git stash pop", pop);
880        assert!(compressed_pop.contains("On branch main"));
881        assert!(compressed_pop.contains("Dropped refs/stash@{0}"));
882        let list = "stash@{0}: WIP on main: 1111111 first\nstash@{1}: On feature: second\n";
883        assert_eq!(compress("git stash list", list), list.trim_end());
884        assert_eq!(compress("git stash", ""), "");
885        let error = "error: Your local changes to the following files would be overwritten by merge:\n\tREADME.md\n";
886        let compressed_error = compress("git stash apply", error);
887        assert!(compressed_error.contains("error: Your local changes"));
888        assert!(compressed_error.contains("README.md"));
889    }
890
891    #[test]
892    fn test_log_merge_commit_keeps_parents() {
893        let raw = "commit cccccccccccccccccccccccccccccccccccc\nMerge: dddddddddddddddddddddddddddddddddddd eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee\nAuthor: Merger <merge@example.com>\nDate:   Mon Jan 01 12:00:00 2024 +0000\n\n    Merge branch 'feature'\n";
894        let compressed = compress("git log", raw);
895        assert!(compressed.contains("Merge: dddddddddddd eeeeeeeeeeee"));
896        assert!(compressed.contains("Merge branch 'feature'"));
897    }
898
899    #[test]
900    fn test_log_format_collapse_short_log() {
901        let raw = "commit aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\nAuthor: Alice Example <alice@example.com>\nDate:   Thu Jun 18 17:39:12 2026 +0200\n\n    first subject\n    detail one\n\ncommit bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\nAuthor: Bob Example <bob@example.com>\nDate:   Wed Jun 17 10:00:00 2026 +0000\n\n    second subject\n";
902        let compressed = compress("git log", raw);
903        assert!(!compressed.contains("commit "));
904        assert!(!compressed.contains("Author:"));
905        assert!(!compressed.contains("Date:"));
906        assert!(compressed.contains("aaaaaaaaaaaa first subject"));
907        assert!(compressed.contains("<Alice Example alice@example.com>"));
908        assert!(compressed.contains("2026-06-18 17:39:12"));
909        assert!(compressed.contains("detail one"));
910        assert!(compressed.contains("bbbbbbbbbbbb second subject"));
911        assert!(!compressed.contains(" ago"));
912        assert!(compressed.len() < raw.len());
913    }
914
915    #[test]
916    fn test_log_compress_is_deterministic() {
917        let raw = "commit 1111111111111111111111111111111111111111\nAuthor: x <x@y.com>\nDate:   Thu Jun 18 17:39:12 2026 +0200\n\n    subject\n    body\n";
918        let a = compress_log(raw);
919        let b = compress_log(raw);
920        assert_eq!(a, b);
921        assert!(a.contains("111111111111"));
922        assert!(a.contains("2026-06-18 17:39:12"));
923    }
924
925    #[test]
926    fn test_log_oneline_abbreviates_hash_keeps_all_lines() {
927        let raw = "e4e8f7e1234567890abcdef1234567890abcdef (HEAD -> main) chore\n9c4aa18abcdef1234567890abcdef1234567890 feat\n";
928        let compressed = compress("git log --oneline", raw);
929        assert!(compressed.contains("e4e8f7e12345"));
930        assert!(compressed.contains("(HEAD -> main) chore"));
931        assert!(compressed.contains("9c4aa18abcde"));
932        assert!(compressed.contains("feat"));
933        assert!(!compressed.contains("... more commits"));
934    }
935
936    #[test]
937    fn test_log_deep_needle_survives_without_drop_line() {
938        let raw = include_str!("../../tests/fixtures/git_log_deep_needle.txt");
939        let compressed = compress("git log", raw);
940        assert!(compressed.contains("feedfacefeed"));
941        assert!(compressed.contains("NEEDLE_GIT_auth_bypass"));
942        assert!(compressed.contains("UNIQUE_BODY_MARKER_needle_xyz"));
943        assert!(!compressed.contains("... more commits"));
944        assert!(compressed.len() < raw.len());
945    }
946
947    #[test]
948    fn git_subcommand_returns_none_for_pipe_before_subcommand() {
949        assert_eq!(git_subcommand("git --no-pager | grep log"), None);
950    }
951
952    #[test]
953    fn git_subcommand_returns_subcommand_when_before_pipe() {
954        assert_eq!(git_subcommand("git log | grep fix").as_deref(), Some("log"));
955    }
956
957    #[test]
958    fn git_subcommand_returns_none_for_redirect_before_subcommand() {
959        assert_eq!(git_subcommand("git --no-pager > out.log"), None);
960    }
961
962    #[test]
963    fn git_subcommand_unaffected_without_metacharacters() {
964        assert_eq!(git_subcommand("git log --oneline").as_deref(), Some("log"));
965    }
966}