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