Skip to main content

lean_ctx/core/patterns/
git.rs

1use regex::Regex;
2use std::sync::OnceLock;
3
4static STATUS_BRANCH_RE: OnceLock<Regex> = OnceLock::new();
5static AHEAD_RE: OnceLock<Regex> = OnceLock::new();
6static COMMIT_HASH_RE: OnceLock<Regex> = OnceLock::new();
7static INSERTIONS_RE: OnceLock<Regex> = OnceLock::new();
8static DELETIONS_RE: OnceLock<Regex> = OnceLock::new();
9static FILES_CHANGED_RE: OnceLock<Regex> = OnceLock::new();
10static CLONE_OBJECTS_RE: OnceLock<Regex> = OnceLock::new();
11static STASH_RE: OnceLock<Regex> = OnceLock::new();
12
13fn status_branch_re() -> &'static Regex {
14    STATUS_BRANCH_RE.get_or_init(|| Regex::new(r"On branch (\S+)").unwrap())
15}
16fn ahead_re() -> &'static Regex {
17    AHEAD_RE.get_or_init(|| Regex::new(r"ahead of .+ by (\d+) commit").unwrap())
18}
19fn commit_hash_re() -> &'static Regex {
20    COMMIT_HASH_RE.get_or_init(|| Regex::new(r"\[([\w/.:-]+)\s+([a-f0-9]+)\]").unwrap())
21}
22fn insertions_re() -> &'static Regex {
23    INSERTIONS_RE.get_or_init(|| Regex::new(r"(\d+) insertions?\(\+\)").unwrap())
24}
25fn deletions_re() -> &'static Regex {
26    DELETIONS_RE.get_or_init(|| Regex::new(r"(\d+) deletions?\(-\)").unwrap())
27}
28fn files_changed_re() -> &'static Regex {
29    FILES_CHANGED_RE.get_or_init(|| Regex::new(r"(\d+) files? changed").unwrap())
30}
31fn clone_objects_re() -> &'static Regex {
32    CLONE_OBJECTS_RE.get_or_init(|| Regex::new(r"Receiving objects:.*?(\d+)").unwrap())
33}
34fn stash_re() -> &'static Regex {
35    STASH_RE.get_or_init(|| Regex::new(r"stash@\{(\d+)\}:\s*(.+)").unwrap())
36}
37
38pub fn compress(command: &str, output: &str) -> Option<String> {
39    if command.contains("status") {
40        return Some(compress_status(output));
41    }
42    if command.contains("log") {
43        return Some(compress_log(output));
44    }
45    if command.contains("diff") && !command.contains("difftool") {
46        return Some(compress_diff(output));
47    }
48    if command.contains("add") && !command.contains("remote add") {
49        return Some(compress_add(output));
50    }
51    if command.contains("commit") {
52        return Some(compress_commit(output));
53    }
54    if command.contains("push") {
55        return Some(compress_push(output));
56    }
57    if command.contains("pull") {
58        return Some(compress_pull(output));
59    }
60    if command.contains("fetch") {
61        return Some(compress_fetch(output));
62    }
63    if command.contains("clone") {
64        return Some(compress_clone(output));
65    }
66    if command.contains("branch") {
67        return Some(compress_branch(output));
68    }
69    if command.contains("checkout") || command.contains("switch") {
70        return Some(compress_checkout(output));
71    }
72    if command.contains("merge") {
73        return Some(compress_merge(output));
74    }
75    if command.contains("stash") {
76        return Some(compress_stash(output));
77    }
78    if command.contains("tag") {
79        return Some(compress_tag(output));
80    }
81    if command.contains("reset") {
82        return Some(compress_reset(output));
83    }
84    if command.contains("remote") {
85        return Some(compress_remote(output));
86    }
87    if command.contains("blame") {
88        return Some(compress_blame(output));
89    }
90    if command.contains("cherry-pick") {
91        return Some(compress_cherry_pick(output));
92    }
93    None
94}
95
96fn compress_status(output: &str) -> String {
97    let mut branch = String::new();
98    let mut ahead = 0u32;
99    let mut staged = Vec::new();
100    let mut unstaged = Vec::new();
101    let mut untracked = Vec::new();
102
103    let mut section = "";
104
105    for line in output.lines() {
106        if let Some(caps) = status_branch_re().captures(line) {
107            branch = caps[1].to_string();
108        }
109        if let Some(caps) = ahead_re().captures(line) {
110            ahead = caps[1].parse().unwrap_or(0);
111        }
112
113        if line.contains("Changes to be committed") {
114            section = "staged";
115        } else if line.contains("Changes not staged") {
116            section = "unstaged";
117        } else if line.contains("Untracked files") {
118            section = "untracked";
119        }
120
121        let trimmed = line.trim();
122        if trimmed.starts_with("new file:") {
123            let file = trimmed.trim_start_matches("new file:").trim();
124            if section == "staged" {
125                staged.push(format!("+{file}"));
126            }
127        } else if trimmed.starts_with("modified:") {
128            let file = trimmed.trim_start_matches("modified:").trim();
129            match section {
130                "staged" => staged.push(format!("~{file}")),
131                "unstaged" => unstaged.push(format!("~{file}")),
132                _ => {}
133            }
134        } else if trimmed.starts_with("deleted:") {
135            let file = trimmed.trim_start_matches("deleted:").trim();
136            if section == "staged" {
137                staged.push(format!("-{file}"));
138            }
139        } else if section == "untracked"
140            && !trimmed.is_empty()
141            && !trimmed.starts_with('(')
142            && !trimmed.starts_with("Untracked")
143        {
144            untracked.push(trimmed.to_string());
145        }
146    }
147
148    if branch.is_empty() && staged.is_empty() && unstaged.is_empty() && untracked.is_empty() {
149        return compact_lines(output.trim(), 10);
150    }
151
152    let mut parts = Vec::new();
153    let branch_display = if branch.is_empty() {
154        "?".to_string()
155    } else {
156        branch
157    };
158    let ahead_str = if ahead > 0 {
159        format!(" ↑{ahead}")
160    } else {
161        String::new()
162    };
163    parts.push(format!("{branch_display}{ahead_str}"));
164
165    if !staged.is_empty() {
166        parts.push(format!("staged: {}", staged.join(" ")));
167    }
168    if !unstaged.is_empty() {
169        parts.push(format!("unstaged: {}", unstaged.join(" ")));
170    }
171    if !untracked.is_empty() {
172        parts.push(format!("untracked: {}", untracked.join(" ")));
173    }
174
175    if output.contains("nothing to commit") && parts.len() == 1 {
176        parts.push("clean".to_string());
177    }
178
179    parts.join("\n")
180}
181
182fn is_diff_or_stat_line(line: &str) -> bool {
183    let t = line.trim();
184    t.starts_with("diff --git")
185        || t.starts_with("index ")
186        || t.starts_with("--- a/")
187        || t.starts_with("+++ b/")
188        || t.starts_with("@@ ")
189        || t.starts_with("Binary files")
190        || t.starts_with("new file mode")
191        || t.starts_with("deleted file mode")
192        || t.starts_with("old mode")
193        || t.starts_with("new mode")
194        || t.starts_with("similarity index")
195        || t.starts_with("rename from")
196        || t.starts_with("rename to")
197        || t.starts_with("copy from")
198        || t.starts_with("copy to")
199        || (t.starts_with('+') && !t.starts_with("+++"))
200        || (t.starts_with('-') && !t.starts_with("---"))
201        || (t.contains(" | ") && t.chars().any(|c| c == '+' || c == '-'))
202}
203
204fn compress_log(output: &str) -> String {
205    let lines: Vec<&str> = output.lines().collect();
206    if lines.is_empty() {
207        return String::new();
208    }
209
210    let max_entries = 20;
211
212    let is_oneline = !lines[0].starts_with("commit ");
213    if is_oneline {
214        if lines.len() <= max_entries {
215            return lines.join("\n");
216        }
217        let shown = &lines[..max_entries];
218        return format!(
219            "{}\n... ({} more commits)",
220            shown.join("\n"),
221            lines.len() - max_entries
222        );
223    }
224
225    let has_diff = lines.iter().any(|l| l.starts_with("diff --git"));
226    let has_stat = lines
227        .iter()
228        .any(|l| l.contains(" | ") && l.trim().ends_with(['+', '-']));
229    let mut total_additions = 0u32;
230    let mut total_deletions = 0u32;
231
232    let mut entries = Vec::new();
233    let mut in_diff = false;
234    let mut got_message = false;
235
236    for line in &lines {
237        let trimmed = line.trim();
238
239        if trimmed.starts_with("commit ") {
240            let hash = &trimmed[7..14.min(trimmed.len())];
241            entries.push(hash.to_string());
242            in_diff = false;
243            got_message = false;
244            continue;
245        }
246
247        if trimmed.starts_with("Author:")
248            || trimmed.starts_with("Date:")
249            || trimmed.starts_with("Merge:")
250        {
251            continue;
252        }
253
254        if trimmed.starts_with("diff --git") || trimmed.starts_with("---") && trimmed.contains("a/")
255        {
256            in_diff = true;
257        }
258
259        if in_diff || is_diff_or_stat_line(trimmed) {
260            if trimmed.starts_with('+') && !trimmed.starts_with("+++") {
261                total_additions += 1;
262            } else if trimmed.starts_with('-') && !trimmed.starts_with("---") {
263                total_deletions += 1;
264            }
265            continue;
266        }
267
268        if trimmed.is_empty() {
269            continue;
270        }
271
272        if !got_message {
273            if let Some(last) = entries.last_mut() {
274                *last = format!("{last} {trimmed}");
275            }
276            got_message = true;
277        }
278    }
279
280    if entries.is_empty() {
281        return output.to_string();
282    }
283
284    let mut result = if entries.len() > max_entries {
285        let shown = &entries[..max_entries];
286        format!(
287            "{}\n... ({} more commits)",
288            shown.join("\n"),
289            entries.len() - max_entries
290        )
291    } else {
292        entries.join("\n")
293    };
294
295    if (has_diff || has_stat) && (total_additions > 0 || total_deletions > 0) {
296        result.push_str(&format!(
297            "\n[{} commits, +{total_additions}/-{total_deletions} total]",
298            entries.len()
299        ));
300    }
301
302    result
303}
304
305fn compress_diff(output: &str) -> String {
306    let mut files = Vec::new();
307    let mut current_file = String::new();
308    let mut additions = 0;
309    let mut deletions = 0;
310
311    for line in output.lines() {
312        if line.starts_with("diff --git") {
313            if !current_file.is_empty() {
314                files.push(format!("{current_file} +{additions}/-{deletions}"));
315            }
316            current_file = line.split(" b/").nth(1).unwrap_or("?").to_string();
317            additions = 0;
318            deletions = 0;
319        } else if line.starts_with('+') && !line.starts_with("+++") {
320            additions += 1;
321        } else if line.starts_with('-') && !line.starts_with("---") {
322            deletions += 1;
323        }
324    }
325    if !current_file.is_empty() {
326        files.push(format!("{current_file} +{additions}/-{deletions}"));
327    }
328
329    files.join("\n")
330}
331
332fn compress_add(output: &str) -> String {
333    let trimmed = output.trim();
334    if trimmed.is_empty() {
335        return "ok".to_string();
336    }
337    let lines: Vec<&str> = trimmed.lines().collect();
338    if lines.len() <= 3 {
339        return trimmed.to_string();
340    }
341    format!("ok (+{} files)", lines.len())
342}
343
344fn compress_commit(output: &str) -> String {
345    let mut hook_lines: Vec<&str> = Vec::new();
346    let mut commit_part = String::new();
347    let mut found_commit = false;
348
349    for line in output.lines() {
350        if !found_commit && commit_hash_re().is_match(line) {
351            found_commit = true;
352        }
353        if !found_commit {
354            let trimmed = line.trim();
355            if !trimmed.is_empty() {
356                hook_lines.push(trimmed);
357            }
358        }
359    }
360
361    if let Some(caps) = commit_hash_re().captures(output) {
362        let branch = &caps[1];
363        let hash = &caps[2];
364        let stats = extract_change_stats(output);
365        let msg = output
366            .lines()
367            .find(|l| commit_hash_re().is_match(l))
368            .and_then(|l| l.split(']').nth(1))
369            .map(|m| m.trim())
370            .unwrap_or("");
371        commit_part = if stats.is_empty() {
372            format!("{hash} ({branch}) {msg}")
373        } else {
374            format!("{hash} ({branch}) {msg} [{stats}]")
375        };
376    }
377
378    if commit_part.is_empty() {
379        let trimmed = output.trim();
380        if trimmed.is_empty() {
381            return "ok".to_string();
382        }
383        return compact_lines(trimmed, 5);
384    }
385
386    if hook_lines.is_empty() {
387        return commit_part;
388    }
389
390    let failed: Vec<&&str> = hook_lines
391        .iter()
392        .filter(|l| {
393            let low = l.to_lowercase();
394            low.contains("failed") || low.contains("error") || low.contains("warning")
395        })
396        .collect();
397    let passed_count = hook_lines.len() - failed.len();
398
399    let hook_output = if !failed.is_empty() {
400        let mut parts = Vec::new();
401        if passed_count > 0 {
402            parts.push(format!("{passed_count} checks passed"));
403        }
404        for f in failed.iter().take(5) {
405            parts.push(f.to_string());
406        }
407        if failed.len() > 5 {
408            parts.push(format!("... ({} more failures)", failed.len() - 5));
409        }
410        parts.join("\n")
411    } else if hook_lines.len() > 5 {
412        format!("{} hooks passed", hook_lines.len())
413    } else {
414        hook_lines.join("\n")
415    };
416
417    format!("{hook_output}\n{commit_part}")
418}
419
420fn compress_push(output: &str) -> String {
421    let trimmed = output.trim();
422    if trimmed.is_empty() {
423        return "ok".to_string();
424    }
425
426    let mut ref_line = String::new();
427    let mut remote_urls: Vec<String> = Vec::new();
428    let mut rejected = false;
429
430    for line in trimmed.lines() {
431        let l = line.trim();
432
433        if l.contains("rejected") {
434            rejected = true;
435        }
436
437        if l.contains("->") && !l.starts_with("remote:") {
438            ref_line = l.to_string();
439        }
440
441        if l.contains("Everything up-to-date") {
442            return "ok (up-to-date)".to_string();
443        }
444
445        if l.starts_with("remote:") || l.starts_with("To ") {
446            let content = l.trim_start_matches("remote:").trim();
447            if content.contains("http")
448                || content.contains("pipeline")
449                || content.contains("merge_request")
450                || content.contains("pull/")
451            {
452                remote_urls.push(content.to_string());
453            }
454        }
455    }
456
457    if rejected {
458        let reject_lines: Vec<&str> = trimmed
459            .lines()
460            .filter(|l| l.contains("rejected") || l.contains("error") || l.contains("remote:"))
461            .collect();
462        return format!("REJECTED:\n{}", compact_lines(&reject_lines.join("\n"), 5));
463    }
464
465    let mut parts = Vec::new();
466    if !ref_line.is_empty() {
467        parts.push(format!("ok {ref_line}"));
468    } else {
469        parts.push("ok (pushed)".to_string());
470    }
471    for url in &remote_urls {
472        parts.push(url.clone());
473    }
474
475    parts.join("\n")
476}
477
478fn compress_pull(output: &str) -> String {
479    let trimmed = output.trim();
480    if trimmed.contains("Already up to date") {
481        return "ok (up-to-date)".to_string();
482    }
483
484    let stats = extract_change_stats(trimmed);
485    if !stats.is_empty() {
486        return format!("ok {stats}");
487    }
488
489    compact_lines(trimmed, 5)
490}
491
492fn compress_fetch(output: &str) -> String {
493    let trimmed = output.trim();
494    if trimmed.is_empty() {
495        return "ok".to_string();
496    }
497
498    let mut new_branches = Vec::new();
499    for line in trimmed.lines() {
500        let l = line.trim();
501        if l.contains("[new branch]") || l.contains("[new tag]") {
502            if let Some(name) = l.split("->").last() {
503                new_branches.push(name.trim().to_string());
504            }
505        }
506    }
507
508    if new_branches.is_empty() {
509        return "ok (fetched)".to_string();
510    }
511    format!("ok (new: {})", new_branches.join(", "))
512}
513
514fn compress_clone(output: &str) -> String {
515    let mut objects = 0u32;
516    for line in output.lines() {
517        if let Some(caps) = clone_objects_re().captures(line) {
518            objects = caps[1].parse().unwrap_or(0);
519        }
520    }
521
522    let into = output
523        .lines()
524        .find(|l| l.contains("Cloning into"))
525        .and_then(|l| l.split('\'').nth(1))
526        .unwrap_or("repo");
527
528    if objects > 0 {
529        format!("cloned '{into}' ({objects} objects)")
530    } else {
531        format!("cloned '{into}'")
532    }
533}
534
535fn compress_branch(output: &str) -> String {
536    let trimmed = output.trim();
537    if trimmed.is_empty() {
538        return "ok".to_string();
539    }
540
541    let branches: Vec<String> = trimmed
542        .lines()
543        .filter_map(|line| {
544            let l = line.trim();
545            if l.is_empty() {
546                return None;
547            }
548            if let Some(rest) = l.strip_prefix('*') {
549                Some(format!("*{}", rest.trim()))
550            } else {
551                Some(l.to_string())
552            }
553        })
554        .collect();
555
556    branches.join(", ")
557}
558
559fn compress_checkout(output: &str) -> String {
560    let trimmed = output.trim();
561    if trimmed.is_empty() {
562        return "ok".to_string();
563    }
564
565    for line in trimmed.lines() {
566        let l = line.trim();
567        if l.starts_with("Switched to") || l.starts_with("Already on") {
568            let branch = l.split('\'').nth(1).unwrap_or(l);
569            return format!("→ {branch}");
570        }
571        if l.starts_with("Your branch is up to date") {
572            continue;
573        }
574    }
575
576    compact_lines(trimmed, 3)
577}
578
579fn compress_merge(output: &str) -> String {
580    let trimmed = output.trim();
581    if trimmed.contains("Already up to date") {
582        return "ok (up-to-date)".to_string();
583    }
584    if trimmed.contains("CONFLICT") {
585        let conflicts: Vec<&str> = trimmed.lines().filter(|l| l.contains("CONFLICT")).collect();
586        return format!(
587            "CONFLICT ({} files):\n{}",
588            conflicts.len(),
589            conflicts.join("\n")
590        );
591    }
592
593    let stats = extract_change_stats(trimmed);
594    if !stats.is_empty() {
595        return format!("merged {stats}");
596    }
597    compact_lines(trimmed, 3)
598}
599
600fn compress_stash(output: &str) -> String {
601    let trimmed = output.trim();
602    if trimmed.is_empty() {
603        return "ok".to_string();
604    }
605
606    if trimmed.starts_with("Saved working directory") {
607        return "stashed".to_string();
608    }
609    if trimmed.starts_with("Dropped") {
610        return "dropped".to_string();
611    }
612
613    let stashes: Vec<String> = trimmed
614        .lines()
615        .filter_map(|line| {
616            stash_re()
617                .captures(line)
618                .map(|caps| format!("@{}: {}", &caps[1], &caps[2]))
619        })
620        .collect();
621
622    if stashes.is_empty() {
623        return compact_lines(trimmed, 3);
624    }
625    stashes.join("\n")
626}
627
628fn compress_tag(output: &str) -> String {
629    let trimmed = output.trim();
630    if trimmed.is_empty() {
631        return "ok".to_string();
632    }
633
634    let tags: Vec<&str> = trimmed.lines().filter(|l| !l.trim().is_empty()).collect();
635    if tags.len() <= 10 {
636        return tags.join(", ");
637    }
638    format!("{} (... {} total)", tags[..5].join(", "), tags.len())
639}
640
641fn compress_reset(output: &str) -> String {
642    let trimmed = output.trim();
643    if trimmed.is_empty() {
644        return "ok".to_string();
645    }
646
647    let mut unstaged: Vec<&str> = Vec::new();
648    for line in trimmed.lines() {
649        let l = line.trim();
650        if l.starts_with("Unstaged changes after reset:") {
651            continue;
652        }
653        if l.starts_with('M') || l.starts_with('D') || l.starts_with('A') {
654            unstaged.push(l);
655        }
656    }
657
658    if unstaged.is_empty() {
659        return compact_lines(trimmed, 3);
660    }
661    format!("reset ok ({} files unstaged)", unstaged.len())
662}
663
664fn compress_remote(output: &str) -> String {
665    let trimmed = output.trim();
666    if trimmed.is_empty() {
667        return "ok".to_string();
668    }
669
670    let mut remotes = std::collections::HashMap::new();
671    for line in trimmed.lines() {
672        let parts: Vec<&str> = line.split_whitespace().collect();
673        if parts.len() >= 2 {
674            remotes
675                .entry(parts[0].to_string())
676                .or_insert_with(|| parts[1].to_string());
677        }
678    }
679
680    if remotes.is_empty() {
681        return trimmed.to_string();
682    }
683
684    remotes
685        .iter()
686        .map(|(name, url)| format!("{name}: {url}"))
687        .collect::<Vec<_>>()
688        .join("\n")
689}
690
691fn compress_blame(output: &str) -> String {
692    let lines: Vec<&str> = output.lines().collect();
693    if lines.len() <= 20 {
694        return output.to_string();
695    }
696
697    let unique_authors: std::collections::HashSet<&str> = lines
698        .iter()
699        .filter_map(|l| l.split('(').nth(1)?.split_whitespace().next())
700        .collect();
701
702    format!("{} lines, {} authors", lines.len(), unique_authors.len())
703}
704
705fn compress_cherry_pick(output: &str) -> String {
706    let trimmed = output.trim();
707    if trimmed.is_empty() {
708        return "ok".to_string();
709    }
710    if trimmed.contains("CONFLICT") {
711        return "CONFLICT (cherry-pick)".to_string();
712    }
713    let stats = extract_change_stats(trimmed);
714    if !stats.is_empty() {
715        return format!("ok {stats}");
716    }
717    compact_lines(trimmed, 3)
718}
719
720fn extract_change_stats(output: &str) -> String {
721    let files = files_changed_re()
722        .captures(output)
723        .and_then(|c| c[1].parse::<u32>().ok())
724        .unwrap_or(0);
725    let ins = insertions_re()
726        .captures(output)
727        .and_then(|c| c[1].parse::<u32>().ok())
728        .unwrap_or(0);
729    let del = deletions_re()
730        .captures(output)
731        .and_then(|c| c[1].parse::<u32>().ok())
732        .unwrap_or(0);
733
734    if files > 0 || ins > 0 || del > 0 {
735        format!("{files} files, +{ins}/-{del}")
736    } else {
737        String::new()
738    }
739}
740
741fn compact_lines(text: &str, max: usize) -> String {
742    let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
743    if lines.len() <= max {
744        return lines.join("\n");
745    }
746    format!(
747        "{}\n... ({} more lines)",
748        lines[..max].join("\n"),
749        lines.len() - max
750    )
751}
752
753#[cfg(test)]
754mod tests {
755    use super::*;
756
757    #[test]
758    fn git_status_compresses() {
759        let output = "On branch main\nYour branch is up to date with 'origin/main'.\n\nChanges not staged for commit:\n  (use \"git add <file>...\" to update what will be committed)\n\n\tmodified:   src/main.rs\n\tmodified:   src/lib.rs\n\nno changes added to commit (use \"git add\" and/or \"git commit -a\")\n";
760        let result = compress("git status", output).unwrap();
761        assert!(result.contains("main"), "should contain branch name");
762        assert!(result.contains("main.rs"), "should list modified files");
763        assert!(result.len() < output.len(), "should be shorter than input");
764    }
765
766    #[test]
767    fn git_add_compresses_to_ok() {
768        let result = compress("git add .", "").unwrap();
769        assert!(result.contains("ok"), "git add should compress to 'ok'");
770    }
771
772    #[test]
773    fn git_commit_extracts_hash() {
774        let output =
775            "[main abc1234] fix: resolve bug\n 2 files changed, 10 insertions(+), 3 deletions(-)\n";
776        let result = compress("git commit -m 'fix'", output).unwrap();
777        assert!(result.contains("abc1234"), "should extract commit hash");
778    }
779
780    #[test]
781    fn git_push_compresses() {
782        let output = "Enumerating objects: 5, done.\nCounting objects: 100% (5/5), done.\nDelta compression using up to 8 threads\nCompressing objects: 100% (3/3), done.\nWriting objects: 100% (3/3), 1.2 KiB | 1.2 MiB/s, done.\nTotal 3 (delta 2), reused 0 (delta 0)\nTo github.com:user/repo.git\n   abc1234..def5678  main -> main\n";
783        let result = compress("git push", output).unwrap();
784        assert!(result.len() < output.len(), "should compress push output");
785    }
786
787    #[test]
788    fn git_log_compresses() {
789        let output = "commit abc1234567890\nAuthor: User <user@email.com>\nDate:   Mon Mar 25 10:00:00 2026 +0100\n\n    feat: add feature\n\ncommit def4567890abc\nAuthor: User <user@email.com>\nDate:   Sun Mar 24 09:00:00 2026 +0100\n\n    fix: resolve issue\n";
790        let result = compress("git log", output).unwrap();
791        assert!(result.len() < output.len(), "should compress log output");
792    }
793
794    #[test]
795    fn git_log_oneline_truncates_long() {
796        let lines: Vec<String> = (0..50)
797            .map(|i| format!("abc{i:04} feat: commit number {i}"))
798            .collect();
799        let output = lines.join("\n");
800        let result = compress("git log --oneline", &output).unwrap();
801        assert!(
802            result.contains("... (30 more commits)"),
803            "should truncate to 20 entries"
804        );
805        assert!(
806            result.lines().count() <= 22,
807            "should have at most 21 lines (20 + summary)"
808        );
809    }
810
811    #[test]
812    fn git_log_oneline_short_unchanged() {
813        let output = "abc1234 feat: one\ndef5678 fix: two\nghi9012 docs: three";
814        let result = compress("git log --oneline", output).unwrap();
815        assert_eq!(result, output, "short oneline should pass through");
816    }
817
818    #[test]
819    fn git_log_standard_truncates_long() {
820        let mut output = String::new();
821        for i in 0..30 {
822            output.push_str(&format!(
823                "commit {i:07}abc1234\nAuthor: U <u@e.com>\nDate:   Mon\n\n    msg {i}\n\n"
824            ));
825        }
826        let result = compress("git log", &output).unwrap();
827        assert!(
828            result.contains("... (10 more commits)"),
829            "should truncate standard log"
830        );
831    }
832
833    #[test]
834    fn git_diff_compresses() {
835        let output = "diff --git a/src/main.rs b/src/main.rs\nindex abc1234..def5678 100644\n--- a/src/main.rs\n+++ b/src/main.rs\n@@ -1,3 +1,4 @@\n fn main() {\n+    println!(\"hello\");\n     let x = 1;\n }";
836        let result = compress("git diff", output).unwrap();
837        assert!(result.contains("main.rs"), "should reference changed file");
838    }
839
840    #[test]
841    fn git_push_preserves_pipeline_url() {
842        let output = "Enumerating objects: 5, done.\nCounting objects: 100% (5/5), done.\nDelta compression using up to 8 threads\nCompressing objects: 100% (3/3), done.\nWriting objects: 100% (3/3), 1.2 KiB | 1.2 MiB/s, done.\nTotal 3 (delta 2), reused 0 (delta 0)\nremote:\nremote: To create a merge request for main, visit:\nremote:   https://gitlab.com/user/repo/-/merge_requests/new?source=main\nremote:\nremote: View pipeline for this push:\nremote:   https://gitlab.com/user/repo/-/pipelines/12345\nremote:\nTo gitlab.com:user/repo.git\n   abc1234..def5678  main -> main\n";
843        let result = compress("git push", output).unwrap();
844        assert!(
845            result.contains("pipeline"),
846            "should preserve pipeline URL, got: {result}"
847        );
848        assert!(
849            result.contains("merge_request"),
850            "should preserve merge request URL"
851        );
852        assert!(result.contains("->"), "should contain ref update line");
853    }
854
855    #[test]
856    fn git_push_preserves_github_pr_url() {
857        let output = "Enumerating objects: 5, done.\nremote:\nremote: Create a pull request for 'feature' on GitHub by visiting:\nremote:   https://github.com/user/repo/pull/new/feature\nremote:\nTo github.com:user/repo.git\n   abc1234..def5678  feature -> feature\n";
858        let result = compress("git push", output).unwrap();
859        assert!(
860            result.contains("pull/"),
861            "should preserve GitHub PR URL, got: {result}"
862        );
863    }
864
865    #[test]
866    fn git_commit_preserves_hook_output() {
867        let output = "Running pre-commit hooks...\ncheck-yaml..........passed\ncheck-json..........passed\nruff.................failed\nfixing src/app.py\n[main abc1234] fix: resolve bug\n 2 files changed, 10 insertions(+), 3 deletions(-)\n";
868        let result = compress("git commit -m 'fix'", output).unwrap();
869        assert!(
870            result.contains("ruff"),
871            "should preserve hook output, got: {result}"
872        );
873        assert!(
874            result.contains("abc1234"),
875            "should still extract commit hash"
876        );
877    }
878
879    #[test]
880    fn git_commit_no_hooks() {
881        let output =
882            "[main abc1234] fix: resolve bug\n 2 files changed, 10 insertions(+), 3 deletions(-)\n";
883        let result = compress("git commit -m 'fix'", output).unwrap();
884        assert!(result.contains("abc1234"), "should extract commit hash");
885        assert!(
886            !result.contains("hook"),
887            "should not mention hooks when none present"
888        );
889    }
890
891    #[test]
892    fn git_log_with_patch_filters_diff_content() {
893        let output = "commit abc1234567890\nAuthor: User <user@email.com>\nDate:   Mon Mar 25 10:00:00 2026 +0100\n\n    feat: add feature\n\ndiff --git a/src/main.rs b/src/main.rs\nindex abc1234..def5678 100644\n--- a/src/main.rs\n+++ b/src/main.rs\n@@ -1,3 +1,4 @@\n fn main() {\n+    println!(\"hello\");\n     let x = 1;\n }\n\ncommit def4567890abc\nAuthor: User <user@email.com>\nDate:   Sun Mar 24 09:00:00 2026 +0100\n\n    fix: resolve issue\n\ndiff --git a/src/lib.rs b/src/lib.rs\nindex 111..222 100644\n--- a/src/lib.rs\n+++ b/src/lib.rs\n@@ -1 +1,2 @@\n+pub fn helper() {}\n";
894        let result = compress("git log -p", &output).unwrap();
895        assert!(
896            !result.contains("println"),
897            "should NOT contain diff content, got: {result}"
898        );
899        assert!(result.contains("abc1234"), "should contain commit hash");
900        assert!(
901            result.contains("feat: add feature"),
902            "should contain commit message"
903        );
904        assert!(
905            result.len() < output.len() / 2,
906            "compressed should be less than half of original ({} vs {})",
907            result.len(),
908            output.len()
909        );
910    }
911
912    #[test]
913    fn git_log_with_stat_filters_stat_content() {
914        let mut output = String::new();
915        for i in 0..5 {
916            output.push_str(&format!(
917                "commit {i:07}abc1234\nAuthor: U <u@e.com>\nDate:   Mon\n\n    msg {i}\n\n src/file{i}.rs | 10 ++++------\n 1 file changed, 4 insertions(+), 6 deletions(-)\n\n"
918            ));
919        }
920        let result = compress("git log --stat", &output).unwrap();
921        assert!(
922            result.len() < output.len() / 2,
923            "stat output should be compressed ({} vs {})",
924            result.len(),
925            output.len()
926        );
927    }
928
929    #[test]
930    fn git_commit_with_feature_branch() {
931        let output = "[feature/my-branch abc1234] feat: add new thing\n 3 files changed, 20 insertions(+), 5 deletions(-)\n";
932        let result = compress("git commit -m 'feat'", output).unwrap();
933        assert!(
934            result.contains("abc1234"),
935            "should extract hash from feature branch, got: {result}"
936        );
937        assert!(
938            result.contains("feature/my-branch"),
939            "should preserve branch name, got: {result}"
940        );
941    }
942
943    #[test]
944    fn git_commit_many_hooks_compressed() {
945        let mut output = String::new();
946        for i in 0..30 {
947            output.push_str(&format!("check-{i}..........passed\n"));
948        }
949        output.push_str("[main abc1234] fix: resolve bug\n 1 file changed, 1 insertion(+)\n");
950        let result = compress("git commit -m 'fix'", &output).unwrap();
951        assert!(result.contains("abc1234"), "should contain commit hash");
952        assert!(
953            result.contains("hooks passed"),
954            "should summarize passed hooks, got: {result}"
955        );
956        assert!(
957            result.len() < output.len() / 2,
958            "should compress verbose hook output ({} vs {})",
959            result.len(),
960            output.len()
961        );
962    }
963}