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 } 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(¤t_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(¤t_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}