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