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    if command.contains("show") {
94        return Some(compress_show(output));
95    }
96    if command.contains("rebase") {
97        return Some(compress_rebase(output));
98    }
99    if command.contains("submodule") {
100        return Some(compress_submodule(output));
101    }
102    if command.contains("worktree") {
103        return Some(compress_worktree(output));
104    }
105    if command.contains("bisect") {
106        return Some(compress_bisect(output));
107    }
108    None
109}
110
111fn compress_status(output: &str) -> String {
112    let mut branch = String::new();
113    let mut ahead = 0u32;
114    let mut staged = Vec::new();
115    let mut unstaged = Vec::new();
116    let mut untracked = Vec::new();
117
118    let mut section = "";
119
120    for line in output.lines() {
121        if let Some(caps) = status_branch_re().captures(line) {
122            branch = caps[1].to_string();
123        }
124        if let Some(caps) = ahead_re().captures(line) {
125            ahead = caps[1].parse().unwrap_or(0);
126        }
127
128        if line.contains("Changes to be committed") {
129            section = "staged";
130        } else if line.contains("Changes not staged") {
131            section = "unstaged";
132        } else if line.contains("Untracked files") {
133            section = "untracked";
134        }
135
136        let trimmed = line.trim();
137        if trimmed.starts_with("new file:") {
138            let file = trimmed.trim_start_matches("new file:").trim();
139            if section == "staged" {
140                staged.push(format!("+{file}"));
141            }
142        } else if trimmed.starts_with("modified:") {
143            let file = trimmed.trim_start_matches("modified:").trim();
144            match section {
145                "staged" => staged.push(format!("~{file}")),
146                "unstaged" => unstaged.push(format!("~{file}")),
147                _ => {}
148            }
149        } else if trimmed.starts_with("deleted:") {
150            let file = trimmed.trim_start_matches("deleted:").trim();
151            if section == "staged" {
152                staged.push(format!("-{file}"));
153            }
154        } else if trimmed.starts_with("renamed:") {
155            let file = trimmed.trim_start_matches("renamed:").trim();
156            if section == "staged" {
157                staged.push(format!("→{file}"));
158            }
159        } else if trimmed.starts_with("copied:") {
160            let file = trimmed.trim_start_matches("copied:").trim();
161            if section == "staged" {
162                staged.push(format!("©{file}"));
163            }
164        } else if section == "untracked"
165            && !trimmed.is_empty()
166            && !trimmed.starts_with('(')
167            && !trimmed.starts_with("Untracked")
168        {
169            untracked.push(trimmed.to_string());
170        }
171    }
172
173    if branch.is_empty() && staged.is_empty() && unstaged.is_empty() && untracked.is_empty() {
174        return compact_lines(output.trim(), 10);
175    }
176
177    let mut parts = Vec::new();
178    let branch_display = if branch.is_empty() {
179        "?".to_string()
180    } else {
181        branch
182    };
183    let ahead_str = if ahead > 0 {
184        format!(" ↑{ahead}")
185    } else {
186        String::new()
187    };
188    parts.push(format!("{branch_display}{ahead_str}"));
189
190    if !staged.is_empty() {
191        parts.push(format!("staged: {}", staged.join(" ")));
192    }
193    if !unstaged.is_empty() {
194        parts.push(format!("unstaged: {}", unstaged.join(" ")));
195    }
196    if !untracked.is_empty() {
197        parts.push(format!("untracked: {}", untracked.join(" ")));
198    }
199
200    if output.contains("nothing to commit") && parts.len() == 1 {
201        parts.push("clean".to_string());
202    }
203
204    parts.join("\n")
205}
206
207fn is_diff_or_stat_line(line: &str) -> bool {
208    let t = line.trim();
209    t.starts_with("diff --git")
210        || t.starts_with("index ")
211        || t.starts_with("--- a/")
212        || t.starts_with("+++ b/")
213        || t.starts_with("@@ ")
214        || t.starts_with("Binary files")
215        || t.starts_with("new file mode")
216        || t.starts_with("deleted file mode")
217        || t.starts_with("old mode")
218        || t.starts_with("new mode")
219        || t.starts_with("similarity index")
220        || t.starts_with("rename from")
221        || t.starts_with("rename to")
222        || t.starts_with("copy from")
223        || t.starts_with("copy to")
224        || (t.starts_with('+') && !t.starts_with("+++"))
225        || (t.starts_with('-') && !t.starts_with("---"))
226        || (t.contains(" | ") && t.chars().any(|c| c == '+' || c == '-'))
227}
228
229fn compress_log(output: &str) -> String {
230    let lines: Vec<&str> = output.lines().collect();
231    if lines.is_empty() {
232        return String::new();
233    }
234
235    let max_entries = 20;
236
237    let is_oneline = !lines[0].starts_with("commit ");
238    if is_oneline {
239        if lines.len() <= max_entries {
240            return lines.join("\n");
241        }
242        let shown = &lines[..max_entries];
243        return format!(
244            "{}\n... ({} more commits)",
245            shown.join("\n"),
246            lines.len() - max_entries
247        );
248    }
249
250    let has_diff = lines.iter().any(|l| l.starts_with("diff --git"));
251    let has_stat = lines
252        .iter()
253        .any(|l| l.contains(" | ") && l.trim().ends_with(['+', '-']));
254    let mut total_additions = 0u32;
255    let mut total_deletions = 0u32;
256
257    let mut entries = Vec::new();
258    let mut in_diff = false;
259    let mut got_message = false;
260
261    for line in &lines {
262        let trimmed = line.trim();
263
264        if trimmed.starts_with("commit ") {
265            let hash = &trimmed[7..14.min(trimmed.len())];
266            entries.push(hash.to_string());
267            in_diff = false;
268            got_message = false;
269            continue;
270        }
271
272        if trimmed.starts_with("Author:")
273            || trimmed.starts_with("Date:")
274            || trimmed.starts_with("Merge:")
275        {
276            continue;
277        }
278
279        if trimmed.starts_with("diff --git") || trimmed.starts_with("---") && trimmed.contains("a/")
280        {
281            in_diff = true;
282        }
283
284        if in_diff || is_diff_or_stat_line(trimmed) {
285            if trimmed.starts_with('+') && !trimmed.starts_with("+++") {
286                total_additions += 1;
287            } else if trimmed.starts_with('-') && !trimmed.starts_with("---") {
288                total_deletions += 1;
289            }
290            continue;
291        }
292
293        if trimmed.is_empty() {
294            continue;
295        }
296
297        if !got_message {
298            if let Some(last) = entries.last_mut() {
299                *last = format!("{last} {trimmed}");
300            }
301            got_message = true;
302        }
303    }
304
305    if entries.is_empty() {
306        return output.to_string();
307    }
308
309    let mut result = if entries.len() > max_entries {
310        let shown = &entries[..max_entries];
311        format!(
312            "{}\n... ({} more commits)",
313            shown.join("\n"),
314            entries.len() - max_entries
315        )
316    } else {
317        entries.join("\n")
318    };
319
320    if (has_diff || has_stat) && (total_additions > 0 || total_deletions > 0) {
321        result.push_str(&format!(
322            "\n[{} commits, +{total_additions}/-{total_deletions} total]",
323            entries.len()
324        ));
325    }
326
327    result
328}
329
330fn compress_diff(output: &str) -> String {
331    let mut files = Vec::new();
332    let mut current_file = String::new();
333    let mut additions = 0;
334    let mut deletions = 0;
335
336    for line in output.lines() {
337        if line.starts_with("diff --git") {
338            if !current_file.is_empty() {
339                files.push(format!("{current_file} +{additions}/-{deletions}"));
340            }
341            current_file = line.split(" b/").nth(1).unwrap_or("?").to_string();
342            additions = 0;
343            deletions = 0;
344        } else if line.starts_with('+') && !line.starts_with("+++") {
345            additions += 1;
346        } else if line.starts_with('-') && !line.starts_with("---") {
347            deletions += 1;
348        }
349    }
350    if !current_file.is_empty() {
351        files.push(format!("{current_file} +{additions}/-{deletions}"));
352    }
353
354    files.join("\n")
355}
356
357fn compress_add(output: &str) -> String {
358    let trimmed = output.trim();
359    if trimmed.is_empty() {
360        return "ok".to_string();
361    }
362    let lines: Vec<&str> = trimmed.lines().collect();
363    if lines.len() <= 3 {
364        return trimmed.to_string();
365    }
366    format!("ok (+{} files)", lines.len())
367}
368
369fn compress_commit(output: &str) -> String {
370    let mut hook_lines: Vec<&str> = Vec::new();
371    let mut commit_part = String::new();
372    let mut found_commit = false;
373
374    for line in output.lines() {
375        if !found_commit && commit_hash_re().is_match(line) {
376            found_commit = true;
377        }
378        if !found_commit {
379            let trimmed = line.trim();
380            if !trimmed.is_empty() {
381                hook_lines.push(trimmed);
382            }
383        }
384    }
385
386    if let Some(caps) = commit_hash_re().captures(output) {
387        let branch = &caps[1];
388        let hash = &caps[2];
389        let stats = extract_change_stats(output);
390        let msg = output
391            .lines()
392            .find(|l| commit_hash_re().is_match(l))
393            .and_then(|l| l.split(']').nth(1))
394            .map(|m| m.trim())
395            .unwrap_or("");
396        commit_part = if stats.is_empty() {
397            format!("{hash} ({branch}) {msg}")
398        } else {
399            format!("{hash} ({branch}) {msg} [{stats}]")
400        };
401    }
402
403    if commit_part.is_empty() {
404        let trimmed = output.trim();
405        if trimmed.is_empty() {
406            return "ok".to_string();
407        }
408        return compact_lines(trimmed, 5);
409    }
410
411    if hook_lines.is_empty() {
412        return commit_part;
413    }
414
415    let failed: Vec<&&str> = hook_lines
416        .iter()
417        .filter(|l| {
418            let low = l.to_lowercase();
419            low.contains("failed") || low.contains("error") || low.contains("warning")
420        })
421        .collect();
422    let passed_count = hook_lines.len() - failed.len();
423
424    let hook_output = if !failed.is_empty() {
425        let mut parts = Vec::new();
426        if passed_count > 0 {
427            parts.push(format!("{passed_count} checks passed"));
428        }
429        for f in failed.iter().take(5) {
430            parts.push(f.to_string());
431        }
432        if failed.len() > 5 {
433            parts.push(format!("... ({} more failures)", failed.len() - 5));
434        }
435        parts.join("\n")
436    } else if hook_lines.len() > 5 {
437        format!("{} hooks passed", hook_lines.len())
438    } else {
439        hook_lines.join("\n")
440    };
441
442    format!("{hook_output}\n{commit_part}")
443}
444
445fn compress_push(output: &str) -> String {
446    let trimmed = output.trim();
447    if trimmed.is_empty() {
448        return "ok".to_string();
449    }
450
451    let mut ref_line = String::new();
452    let mut remote_urls: Vec<String> = Vec::new();
453    let mut rejected = false;
454
455    for line in trimmed.lines() {
456        let l = line.trim();
457
458        if l.contains("rejected") {
459            rejected = true;
460        }
461
462        if l.contains("->") && !l.starts_with("remote:") {
463            ref_line = l.to_string();
464        }
465
466        if l.contains("Everything up-to-date") {
467            return "ok (up-to-date)".to_string();
468        }
469
470        if l.starts_with("remote:") || l.starts_with("To ") {
471            let content = l.trim_start_matches("remote:").trim();
472            if content.contains("http")
473                || content.contains("pipeline")
474                || content.contains("merge_request")
475                || content.contains("pull/")
476            {
477                remote_urls.push(content.to_string());
478            }
479        }
480    }
481
482    if rejected {
483        let reject_lines: Vec<&str> = trimmed
484            .lines()
485            .filter(|l| l.contains("rejected") || l.contains("error") || l.contains("remote:"))
486            .collect();
487        return format!("REJECTED:\n{}", compact_lines(&reject_lines.join("\n"), 5));
488    }
489
490    let mut parts = Vec::new();
491    if !ref_line.is_empty() {
492        parts.push(format!("ok {ref_line}"));
493    } else {
494        parts.push("ok (pushed)".to_string());
495    }
496    for url in &remote_urls {
497        parts.push(url.clone());
498    }
499
500    parts.join("\n")
501}
502
503fn compress_pull(output: &str) -> String {
504    let trimmed = output.trim();
505    if trimmed.contains("Already up to date") {
506        return "ok (up-to-date)".to_string();
507    }
508
509    let stats = extract_change_stats(trimmed);
510    if !stats.is_empty() {
511        return format!("ok {stats}");
512    }
513
514    compact_lines(trimmed, 5)
515}
516
517fn compress_fetch(output: &str) -> String {
518    let trimmed = output.trim();
519    if trimmed.is_empty() {
520        return "ok".to_string();
521    }
522
523    let mut new_branches = Vec::new();
524    for line in trimmed.lines() {
525        let l = line.trim();
526        if l.contains("[new branch]") || l.contains("[new tag]") {
527            if let Some(name) = l.split("->").last() {
528                new_branches.push(name.trim().to_string());
529            }
530        }
531    }
532
533    if new_branches.is_empty() {
534        return "ok (fetched)".to_string();
535    }
536    format!("ok (new: {})", new_branches.join(", "))
537}
538
539fn compress_clone(output: &str) -> String {
540    let mut objects = 0u32;
541    for line in output.lines() {
542        if let Some(caps) = clone_objects_re().captures(line) {
543            objects = caps[1].parse().unwrap_or(0);
544        }
545    }
546
547    let into = output
548        .lines()
549        .find(|l| l.contains("Cloning into"))
550        .and_then(|l| l.split('\'').nth(1))
551        .unwrap_or("repo");
552
553    if objects > 0 {
554        format!("cloned '{into}' ({objects} objects)")
555    } else {
556        format!("cloned '{into}'")
557    }
558}
559
560fn compress_branch(output: &str) -> String {
561    let trimmed = output.trim();
562    if trimmed.is_empty() {
563        return "ok".to_string();
564    }
565
566    let branches: Vec<String> = trimmed
567        .lines()
568        .filter_map(|line| {
569            let l = line.trim();
570            if l.is_empty() {
571                return None;
572            }
573            if let Some(rest) = l.strip_prefix('*') {
574                Some(format!("*{}", rest.trim()))
575            } else {
576                Some(l.to_string())
577            }
578        })
579        .collect();
580
581    branches.join(", ")
582}
583
584fn compress_checkout(output: &str) -> String {
585    let trimmed = output.trim();
586    if trimmed.is_empty() {
587        return "ok".to_string();
588    }
589
590    for line in trimmed.lines() {
591        let l = line.trim();
592        if l.starts_with("Switched to") || l.starts_with("Already on") {
593            let branch = l.split('\'').nth(1).unwrap_or(l);
594            return format!("→ {branch}");
595        }
596        if l.starts_with("Your branch is up to date") {
597            continue;
598        }
599    }
600
601    compact_lines(trimmed, 3)
602}
603
604fn compress_merge(output: &str) -> String {
605    let trimmed = output.trim();
606    if trimmed.contains("Already up to date") {
607        return "ok (up-to-date)".to_string();
608    }
609    if trimmed.contains("CONFLICT") {
610        let conflicts: Vec<&str> = trimmed.lines().filter(|l| l.contains("CONFLICT")).collect();
611        return format!(
612            "CONFLICT ({} files):\n{}",
613            conflicts.len(),
614            conflicts.join("\n")
615        );
616    }
617
618    let stats = extract_change_stats(trimmed);
619    if !stats.is_empty() {
620        return format!("merged {stats}");
621    }
622    compact_lines(trimmed, 3)
623}
624
625fn compress_stash(output: &str) -> String {
626    let trimmed = output.trim();
627    if trimmed.is_empty() {
628        return "ok".to_string();
629    }
630
631    if trimmed.starts_with("Saved working directory") {
632        return "stashed".to_string();
633    }
634    if trimmed.starts_with("Dropped") {
635        return "dropped".to_string();
636    }
637
638    let stashes: Vec<String> = trimmed
639        .lines()
640        .filter_map(|line| {
641            stash_re()
642                .captures(line)
643                .map(|caps| format!("@{}: {}", &caps[1], &caps[2]))
644        })
645        .collect();
646
647    if stashes.is_empty() {
648        return compact_lines(trimmed, 3);
649    }
650    stashes.join("\n")
651}
652
653fn compress_tag(output: &str) -> String {
654    let trimmed = output.trim();
655    if trimmed.is_empty() {
656        return "ok".to_string();
657    }
658
659    let tags: Vec<&str> = trimmed.lines().filter(|l| !l.trim().is_empty()).collect();
660    if tags.len() <= 10 {
661        return tags.join(", ");
662    }
663    format!("{} (... {} total)", tags[..5].join(", "), tags.len())
664}
665
666fn compress_reset(output: &str) -> String {
667    let trimmed = output.trim();
668    if trimmed.is_empty() {
669        return "ok".to_string();
670    }
671
672    let mut unstaged: Vec<&str> = Vec::new();
673    for line in trimmed.lines() {
674        let l = line.trim();
675        if l.starts_with("Unstaged changes after reset:") {
676            continue;
677        }
678        if l.starts_with('M') || l.starts_with('D') || l.starts_with('A') {
679            unstaged.push(l);
680        }
681    }
682
683    if unstaged.is_empty() {
684        return compact_lines(trimmed, 3);
685    }
686    format!("reset ok ({} files unstaged)", unstaged.len())
687}
688
689fn compress_remote(output: &str) -> String {
690    let trimmed = output.trim();
691    if trimmed.is_empty() {
692        return "ok".to_string();
693    }
694
695    let mut remotes = std::collections::HashMap::new();
696    for line in trimmed.lines() {
697        let parts: Vec<&str> = line.split_whitespace().collect();
698        if parts.len() >= 2 {
699            remotes
700                .entry(parts[0].to_string())
701                .or_insert_with(|| parts[1].to_string());
702        }
703    }
704
705    if remotes.is_empty() {
706        return trimmed.to_string();
707    }
708
709    remotes
710        .iter()
711        .map(|(name, url)| format!("{name}: {url}"))
712        .collect::<Vec<_>>()
713        .join("\n")
714}
715
716fn compress_blame(output: &str) -> String {
717    let lines: Vec<&str> = output.lines().collect();
718    if lines.len() <= 20 {
719        return output.to_string();
720    }
721
722    let unique_authors: std::collections::HashSet<&str> = lines
723        .iter()
724        .filter_map(|l| l.split('(').nth(1)?.split_whitespace().next())
725        .collect();
726
727    format!("{} lines, {} authors", lines.len(), unique_authors.len())
728}
729
730fn compress_cherry_pick(output: &str) -> String {
731    let trimmed = output.trim();
732    if trimmed.is_empty() {
733        return "ok".to_string();
734    }
735    if trimmed.contains("CONFLICT") {
736        return "CONFLICT (cherry-pick)".to_string();
737    }
738    let stats = extract_change_stats(trimmed);
739    if !stats.is_empty() {
740        return format!("ok {stats}");
741    }
742    compact_lines(trimmed, 3)
743}
744
745fn compress_show(output: &str) -> String {
746    let lines: Vec<&str> = output.lines().collect();
747    if lines.is_empty() {
748        return String::new();
749    }
750
751    let mut hash = String::new();
752    let mut message = String::new();
753    let mut additions = 0u32;
754    let mut deletions = 0u32;
755
756    for line in &lines {
757        let trimmed = line.trim();
758        if trimmed.starts_with("commit ") && hash.is_empty() {
759            hash = trimmed[7..14.min(trimmed.len())].to_string();
760        } else if trimmed.starts_with('+') && !trimmed.starts_with("+++") {
761            additions += 1;
762        } else if trimmed.starts_with('-') && !trimmed.starts_with("---") {
763            deletions += 1;
764        } else if !trimmed.is_empty()
765            && !trimmed.starts_with("Author:")
766            && !trimmed.starts_with("Date:")
767            && !trimmed.starts_with("Merge:")
768            && !is_diff_or_stat_line(trimmed)
769            && message.is_empty()
770            && !hash.is_empty()
771        {
772            message = trimmed.to_string();
773        }
774    }
775
776    let stats = extract_change_stats(output);
777    let diff_summary = if additions > 0 || deletions > 0 {
778        format!(" +{additions}/-{deletions}")
779    } else if !stats.is_empty() {
780        format!(" [{stats}]")
781    } else {
782        String::new()
783    };
784
785    if hash.is_empty() {
786        return compact_lines(output.trim(), 10);
787    }
788
789    format!("{hash} {message}{diff_summary}")
790}
791
792fn compress_rebase(output: &str) -> String {
793    let trimmed = output.trim();
794    if trimmed.is_empty() {
795        return "ok".to_string();
796    }
797    if trimmed.contains("Already up to date") || trimmed.contains("is up to date") {
798        return "ok (up-to-date)".to_string();
799    }
800    if trimmed.contains("Successfully rebased") {
801        let stats = extract_change_stats(trimmed);
802        return if stats.is_empty() {
803            "ok (rebased)".to_string()
804        } else {
805            format!("ok (rebased) {stats}")
806        };
807    }
808    if trimmed.contains("CONFLICT") {
809        let conflicts: Vec<&str> = trimmed.lines().filter(|l| l.contains("CONFLICT")).collect();
810        return format!(
811            "CONFLICT ({} files):\n{}",
812            conflicts.len(),
813            conflicts.join("\n")
814        );
815    }
816    compact_lines(trimmed, 5)
817}
818
819fn compress_submodule(output: &str) -> String {
820    let trimmed = output.trim();
821    if trimmed.is_empty() {
822        return "ok".to_string();
823    }
824
825    let mut modules = Vec::new();
826    for line in trimmed.lines() {
827        let parts: Vec<&str> = line.split_whitespace().collect();
828        if parts.len() >= 2 {
829            let status_char = if line.starts_with('+') {
830                "~"
831            } else if line.starts_with('-') {
832                "!"
833            } else {
834                ""
835            };
836            modules.push(format!("{status_char}{}", parts.last().unwrap_or(&"?")));
837        }
838    }
839
840    if modules.is_empty() {
841        return compact_lines(trimmed, 5);
842    }
843    format!("{} submodules: {}", modules.len(), modules.join(", "))
844}
845
846fn compress_worktree(output: &str) -> String {
847    let trimmed = output.trim();
848    if trimmed.is_empty() {
849        return "ok".to_string();
850    }
851
852    let mut worktrees = Vec::new();
853    let mut current_path = String::new();
854    let mut current_branch = String::new();
855
856    for line in trimmed.lines() {
857        let l = line.trim();
858        if !l.contains(' ') && !l.is_empty() && current_path.is_empty() {
859            current_path = l.to_string();
860        } else if l.starts_with("HEAD ") {
861            // skip
862        } else if l.starts_with("branch ") || l.contains("detached") || l.contains("bare") {
863            current_branch = l.to_string();
864        } else if l.is_empty() && !current_path.is_empty() {
865            let short_path = current_path.rsplit('/').next().unwrap_or(&current_path);
866            worktrees.push(format!("{short_path} [{current_branch}]"));
867            current_path.clear();
868            current_branch.clear();
869        }
870    }
871    if !current_path.is_empty() {
872        let short_path = current_path.rsplit('/').next().unwrap_or(&current_path);
873        worktrees.push(format!("{short_path} [{current_branch}]"));
874    }
875
876    if worktrees.is_empty() {
877        return compact_lines(trimmed, 5);
878    }
879    format!("{} worktrees:\n{}", worktrees.len(), worktrees.join("\n"))
880}
881
882fn compress_bisect(output: &str) -> String {
883    let trimmed = output.trim();
884    if trimmed.is_empty() {
885        return "ok".to_string();
886    }
887
888    for line in trimmed.lines() {
889        let l = line.trim();
890        if l.contains("is the first bad commit") {
891            let hash = l.split_whitespace().next().unwrap_or("?");
892            let short = &hash[..7.min(hash.len())];
893            return format!("found: {short} is first bad commit");
894        }
895        if l.starts_with("Bisecting:") {
896            return l.to_string();
897        }
898    }
899
900    compact_lines(trimmed, 5)
901}
902
903fn extract_change_stats(output: &str) -> String {
904    let files = files_changed_re()
905        .captures(output)
906        .and_then(|c| c[1].parse::<u32>().ok())
907        .unwrap_or(0);
908    let ins = insertions_re()
909        .captures(output)
910        .and_then(|c| c[1].parse::<u32>().ok())
911        .unwrap_or(0);
912    let del = deletions_re()
913        .captures(output)
914        .and_then(|c| c[1].parse::<u32>().ok())
915        .unwrap_or(0);
916
917    if files > 0 || ins > 0 || del > 0 {
918        format!("{files} files, +{ins}/-{del}")
919    } else {
920        String::new()
921    }
922}
923
924fn compact_lines(text: &str, max: usize) -> String {
925    let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
926    if lines.len() <= max {
927        return lines.join("\n");
928    }
929    format!(
930        "{}\n... ({} more lines)",
931        lines[..max].join("\n"),
932        lines.len() - max
933    )
934}
935
936#[cfg(test)]
937mod tests {
938    use super::*;
939
940    #[test]
941    fn git_status_compresses() {
942        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";
943        let result = compress("git status", output).unwrap();
944        assert!(result.contains("main"), "should contain branch name");
945        assert!(result.contains("main.rs"), "should list modified files");
946        assert!(result.len() < output.len(), "should be shorter than input");
947    }
948
949    #[test]
950    fn git_add_compresses_to_ok() {
951        let result = compress("git add .", "").unwrap();
952        assert!(result.contains("ok"), "git add should compress to 'ok'");
953    }
954
955    #[test]
956    fn git_commit_extracts_hash() {
957        let output =
958            "[main abc1234] fix: resolve bug\n 2 files changed, 10 insertions(+), 3 deletions(-)\n";
959        let result = compress("git commit -m 'fix'", output).unwrap();
960        assert!(result.contains("abc1234"), "should extract commit hash");
961    }
962
963    #[test]
964    fn git_push_compresses() {
965        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";
966        let result = compress("git push", output).unwrap();
967        assert!(result.len() < output.len(), "should compress push output");
968    }
969
970    #[test]
971    fn git_log_compresses() {
972        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";
973        let result = compress("git log", output).unwrap();
974        assert!(result.len() < output.len(), "should compress log output");
975    }
976
977    #[test]
978    fn git_log_oneline_truncates_long() {
979        let lines: Vec<String> = (0..50)
980            .map(|i| format!("abc{i:04} feat: commit number {i}"))
981            .collect();
982        let output = lines.join("\n");
983        let result = compress("git log --oneline", &output).unwrap();
984        assert!(
985            result.contains("... (30 more commits)"),
986            "should truncate to 20 entries"
987        );
988        assert!(
989            result.lines().count() <= 22,
990            "should have at most 21 lines (20 + summary)"
991        );
992    }
993
994    #[test]
995    fn git_log_oneline_short_unchanged() {
996        let output = "abc1234 feat: one\ndef5678 fix: two\nghi9012 docs: three";
997        let result = compress("git log --oneline", output).unwrap();
998        assert_eq!(result, output, "short oneline should pass through");
999    }
1000
1001    #[test]
1002    fn git_log_standard_truncates_long() {
1003        let mut output = String::new();
1004        for i in 0..30 {
1005            output.push_str(&format!(
1006                "commit {i:07}abc1234\nAuthor: U <u@e.com>\nDate:   Mon\n\n    msg {i}\n\n"
1007            ));
1008        }
1009        let result = compress("git log", &output).unwrap();
1010        assert!(
1011            result.contains("... (10 more commits)"),
1012            "should truncate standard log"
1013        );
1014    }
1015
1016    #[test]
1017    fn git_diff_compresses() {
1018        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 }";
1019        let result = compress("git diff", output).unwrap();
1020        assert!(result.contains("main.rs"), "should reference changed file");
1021    }
1022
1023    #[test]
1024    fn git_push_preserves_pipeline_url() {
1025        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";
1026        let result = compress("git push", output).unwrap();
1027        assert!(
1028            result.contains("pipeline"),
1029            "should preserve pipeline URL, got: {result}"
1030        );
1031        assert!(
1032            result.contains("merge_request"),
1033            "should preserve merge request URL"
1034        );
1035        assert!(result.contains("->"), "should contain ref update line");
1036    }
1037
1038    #[test]
1039    fn git_push_preserves_github_pr_url() {
1040        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";
1041        let result = compress("git push", output).unwrap();
1042        assert!(
1043            result.contains("pull/"),
1044            "should preserve GitHub PR URL, got: {result}"
1045        );
1046    }
1047
1048    #[test]
1049    fn git_commit_preserves_hook_output() {
1050        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";
1051        let result = compress("git commit -m 'fix'", output).unwrap();
1052        assert!(
1053            result.contains("ruff"),
1054            "should preserve hook output, got: {result}"
1055        );
1056        assert!(
1057            result.contains("abc1234"),
1058            "should still extract commit hash"
1059        );
1060    }
1061
1062    #[test]
1063    fn git_commit_no_hooks() {
1064        let output =
1065            "[main abc1234] fix: resolve bug\n 2 files changed, 10 insertions(+), 3 deletions(-)\n";
1066        let result = compress("git commit -m 'fix'", output).unwrap();
1067        assert!(result.contains("abc1234"), "should extract commit hash");
1068        assert!(
1069            !result.contains("hook"),
1070            "should not mention hooks when none present"
1071        );
1072    }
1073
1074    #[test]
1075    fn git_log_with_patch_filters_diff_content() {
1076        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";
1077        let result = compress("git log -p", output).unwrap();
1078        assert!(
1079            !result.contains("println"),
1080            "should NOT contain diff content, got: {result}"
1081        );
1082        assert!(result.contains("abc1234"), "should contain commit hash");
1083        assert!(
1084            result.contains("feat: add feature"),
1085            "should contain commit message"
1086        );
1087        assert!(
1088            result.len() < output.len() / 2,
1089            "compressed should be less than half of original ({} vs {})",
1090            result.len(),
1091            output.len()
1092        );
1093    }
1094
1095    #[test]
1096    fn git_log_with_stat_filters_stat_content() {
1097        let mut output = String::new();
1098        for i in 0..5 {
1099            output.push_str(&format!(
1100                "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"
1101            ));
1102        }
1103        let result = compress("git log --stat", &output).unwrap();
1104        assert!(
1105            result.len() < output.len() / 2,
1106            "stat output should be compressed ({} vs {})",
1107            result.len(),
1108            output.len()
1109        );
1110    }
1111
1112    #[test]
1113    fn git_commit_with_feature_branch() {
1114        let output = "[feature/my-branch abc1234] feat: add new thing\n 3 files changed, 20 insertions(+), 5 deletions(-)\n";
1115        let result = compress("git commit -m 'feat'", output).unwrap();
1116        assert!(
1117            result.contains("abc1234"),
1118            "should extract hash from feature branch, got: {result}"
1119        );
1120        assert!(
1121            result.contains("feature/my-branch"),
1122            "should preserve branch name, got: {result}"
1123        );
1124    }
1125
1126    #[test]
1127    fn git_commit_many_hooks_compressed() {
1128        let mut output = String::new();
1129        for i in 0..30 {
1130            output.push_str(&format!("check-{i}..........passed\n"));
1131        }
1132        output.push_str("[main abc1234] fix: resolve bug\n 1 file changed, 1 insertion(+)\n");
1133        let result = compress("git commit -m 'fix'", &output).unwrap();
1134        assert!(result.contains("abc1234"), "should contain commit hash");
1135        assert!(
1136            result.contains("hooks passed"),
1137            "should summarize passed hooks, got: {result}"
1138        );
1139        assert!(
1140            result.len() < output.len() / 2,
1141            "should compress verbose hook output ({} vs {})",
1142            result.len(),
1143            output.len()
1144        );
1145    }
1146}