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