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 { 50 };
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
390fn 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 additions = 0u32;
849 let mut deletions = 0u32;
850
851 for line in &lines {
852 let trimmed = line.trim();
853 if trimmed.starts_with("commit ") && hash.is_empty() {
854 hash = trimmed[7..14.min(trimmed.len())].to_string();
855 } else if trimmed.starts_with('+') && !trimmed.starts_with("+++") {
856 additions += 1;
857 } else if trimmed.starts_with('-') && !trimmed.starts_with("---") {
858 deletions += 1;
859 } else if !trimmed.is_empty()
860 && !trimmed.starts_with("Author:")
861 && !trimmed.starts_with("Date:")
862 && !trimmed.starts_with("Merge:")
863 && !is_diff_or_stat_line(trimmed)
864 && message.is_empty()
865 && !hash.is_empty()
866 {
867 message = trimmed.to_string();
868 }
869 }
870
871 let stats = extract_change_stats(output);
872 let diff_summary = if additions > 0 || deletions > 0 {
873 format!(" +{additions}/-{deletions}")
874 } else if !stats.is_empty() {
875 format!(" [{stats}]")
876 } else {
877 String::new()
878 };
879
880 if hash.is_empty() {
881 return compact_lines(output.trim(), 10);
882 }
883
884 format!("{hash} {message}{diff_summary}")
885}
886
887fn compress_rebase(output: &str) -> String {
888 let trimmed = output.trim();
889 if trimmed.is_empty() {
890 return "ok".to_string();
891 }
892 if trimmed.contains("Already up to date") || trimmed.contains("is up to date") {
893 return "ok (up-to-date)".to_string();
894 }
895 if trimmed.contains("Successfully rebased") {
896 let stats = extract_change_stats(trimmed);
897 return if stats.is_empty() {
898 "ok (rebased)".to_string()
899 } else {
900 format!("ok (rebased) {stats}")
901 };
902 }
903 if trimmed.contains("CONFLICT") {
904 let conflicts: Vec<&str> = trimmed.lines().filter(|l| l.contains("CONFLICT")).collect();
905 return format!(
906 "CONFLICT ({} files):\n{}",
907 conflicts.len(),
908 conflicts.join("\n")
909 );
910 }
911 compact_lines(trimmed, 5)
912}
913
914fn compress_submodule(output: &str) -> String {
915 let trimmed = output.trim();
916 if trimmed.is_empty() {
917 return "ok".to_string();
918 }
919
920 let mut modules = Vec::new();
921 for line in trimmed.lines() {
922 let parts: Vec<&str> = line.split_whitespace().collect();
923 if parts.len() >= 2 {
924 let status_char = if line.starts_with('+') {
925 "~"
926 } else if line.starts_with('-') {
927 "!"
928 } else {
929 ""
930 };
931 modules.push(format!("{status_char}{}", parts.last().unwrap_or(&"?")));
932 }
933 }
934
935 if modules.is_empty() {
936 return compact_lines(trimmed, 5);
937 }
938 format!("{} submodules: {}", modules.len(), modules.join(", "))
939}
940
941fn compress_worktree(output: &str) -> String {
942 let trimmed = output.trim();
943 if trimmed.is_empty() {
944 return "ok".to_string();
945 }
946
947 let mut worktrees = Vec::new();
948 let mut current_path = String::new();
949 let mut current_branch = String::new();
950
951 for line in trimmed.lines() {
952 let l = line.trim();
953 if !l.contains(' ') && !l.is_empty() && current_path.is_empty() {
954 current_path = l.to_string();
955 } else if l.starts_with("HEAD ") {
956 } else if l.starts_with("branch ") || l.contains("detached") || l.contains("bare") {
958 current_branch = l.to_string();
959 } else if l.is_empty() && !current_path.is_empty() {
960 let short_path = current_path.rsplit('/').next().unwrap_or(¤t_path);
961 worktrees.push(format!("{short_path} [{current_branch}]"));
962 current_path.clear();
963 current_branch.clear();
964 }
965 }
966 if !current_path.is_empty() {
967 let short_path = current_path.rsplit('/').next().unwrap_or(¤t_path);
968 worktrees.push(format!("{short_path} [{current_branch}]"));
969 }
970
971 if worktrees.is_empty() {
972 return compact_lines(trimmed, 5);
973 }
974 format!("{} worktrees:\n{}", worktrees.len(), worktrees.join("\n"))
975}
976
977fn compress_bisect(output: &str) -> String {
978 let trimmed = output.trim();
979 if trimmed.is_empty() {
980 return "ok".to_string();
981 }
982
983 for line in trimmed.lines() {
984 let l = line.trim();
985 if l.contains("is the first bad commit") {
986 let hash = l.split_whitespace().next().unwrap_or("?");
987 let short = &hash[..7.min(hash.len())];
988 return format!("found: {short} is first bad commit");
989 }
990 if l.starts_with("Bisecting:") {
991 return l.to_string();
992 }
993 }
994
995 compact_lines(trimmed, 5)
996}
997
998fn extract_change_stats(output: &str) -> String {
999 let files = files_changed_re()
1000 .captures(output)
1001 .and_then(|c| c[1].parse::<u32>().ok())
1002 .unwrap_or(0);
1003 let ins = insertions_re()
1004 .captures(output)
1005 .and_then(|c| c[1].parse::<u32>().ok())
1006 .unwrap_or(0);
1007 let del = deletions_re()
1008 .captures(output)
1009 .and_then(|c| c[1].parse::<u32>().ok())
1010 .unwrap_or(0);
1011
1012 if files > 0 || ins > 0 || del > 0 {
1013 format!("{files} files, +{ins}/-{del}")
1014 } else {
1015 String::new()
1016 }
1017}
1018
1019fn compact_lines(text: &str, max: usize) -> String {
1020 let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
1021 if lines.len() <= max {
1022 return lines.join("\n");
1023 }
1024 format!(
1025 "{}\n... ({} more lines)",
1026 lines[..max].join("\n"),
1027 lines.len() - max
1028 )
1029}
1030
1031#[cfg(test)]
1032mod tests {
1033 use super::*;
1034
1035 #[test]
1036 fn git_status_compresses() {
1037 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";
1038 let result = compress("git status", output).unwrap();
1039 assert!(result.contains("main"), "should contain branch name");
1040 assert!(result.contains("main.rs"), "should list modified files");
1041 assert!(result.len() < output.len(), "should be shorter than input");
1042 }
1043
1044 #[test]
1045 fn git_add_compresses_to_ok() {
1046 let result = compress("git add .", "").unwrap();
1047 assert!(result.contains("ok"), "git add should compress to 'ok'");
1048 }
1049
1050 #[test]
1051 fn git_commit_extracts_hash() {
1052 let output =
1053 "[main abc1234] fix: resolve bug\n 2 files changed, 10 insertions(+), 3 deletions(-)\n";
1054 let result = compress("git commit -m 'fix'", output).unwrap();
1055 assert!(result.contains("abc1234"), "should extract commit hash");
1056 }
1057
1058 #[test]
1059 fn git_push_compresses() {
1060 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";
1061 let result = compress("git push", output).unwrap();
1062 assert!(result.len() < output.len(), "should compress push output");
1063 }
1064
1065 #[test]
1066 fn git_log_compresses() {
1067 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";
1068 let result = compress("git log", output).unwrap();
1069 assert!(result.len() < output.len(), "should compress log output");
1070 }
1071
1072 #[test]
1073 fn git_log_oneline_truncates_long() {
1074 let lines: Vec<String> = (0..80)
1075 .map(|i| format!("abc{i:04} feat: commit number {i}"))
1076 .collect();
1077 let output = lines.join("\n");
1078 let result = compress("git log --oneline", &output).unwrap();
1079 assert!(
1080 result.contains("... (30 more commits"),
1081 "should truncate to 50 entries"
1082 );
1083 assert!(
1084 result.lines().count() <= 52,
1085 "should have at most 51 lines (50 + summary)"
1086 );
1087 }
1088
1089 #[test]
1090 fn git_log_oneline_short_unchanged() {
1091 let output = "abc1234 feat: one\ndef5678 fix: two\nghi9012 docs: three";
1092 let result = compress("git log --oneline", output).unwrap();
1093 assert_eq!(result, output, "short oneline should pass through");
1094 }
1095
1096 #[test]
1097 fn git_log_standard_truncates_long() {
1098 let mut output = String::new();
1099 for i in 0..70 {
1100 output.push_str(&format!(
1101 "commit {i:07}abc1234\nAuthor: U <u@e.com>\nDate: Mon\n\n msg {i}\n\n"
1102 ));
1103 }
1104 let result = compress("git log", &output).unwrap();
1105 assert!(
1106 result.contains("... (20 more commits"),
1107 "should truncate standard log at 50"
1108 );
1109 }
1110
1111 #[test]
1112 fn git_diff_compresses() {
1113 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 }";
1114 let result = compress("git diff", output).unwrap();
1115 assert!(result.contains("main.rs"), "should reference changed file");
1116 }
1117
1118 #[test]
1119 fn git_push_preserves_pipeline_url() {
1120 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";
1121 let result = compress("git push", output).unwrap();
1122 assert!(
1123 result.contains("pipeline"),
1124 "should preserve pipeline URL, got: {result}"
1125 );
1126 assert!(
1127 result.contains("merge_request"),
1128 "should preserve merge request URL"
1129 );
1130 assert!(result.contains("->"), "should contain ref update line");
1131 }
1132
1133 #[test]
1134 fn git_push_preserves_github_pr_url() {
1135 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";
1136 let result = compress("git push", output).unwrap();
1137 assert!(
1138 result.contains("pull/"),
1139 "should preserve GitHub PR URL, got: {result}"
1140 );
1141 }
1142
1143 #[test]
1144 fn git_commit_preserves_hook_output() {
1145 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";
1146 let result = compress("git commit -m 'fix'", output).unwrap();
1147 assert!(
1148 result.contains("ruff"),
1149 "should preserve hook output, got: {result}"
1150 );
1151 assert!(
1152 result.contains("abc1234"),
1153 "should still extract commit hash"
1154 );
1155 }
1156
1157 #[test]
1158 fn git_commit_no_hooks() {
1159 let output =
1160 "[main abc1234] fix: resolve bug\n 2 files changed, 10 insertions(+), 3 deletions(-)\n";
1161 let result = compress("git commit -m 'fix'", output).unwrap();
1162 assert!(result.contains("abc1234"), "should extract commit hash");
1163 assert!(
1164 !result.contains("hook"),
1165 "should not mention hooks when none present"
1166 );
1167 }
1168
1169 #[test]
1170 fn git_log_with_patch_filters_diff_content() {
1171 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";
1172 let result = compress("git log -p", output).unwrap();
1173 assert!(
1174 !result.contains("println"),
1175 "should NOT contain diff content, got: {result}"
1176 );
1177 assert!(result.contains("abc1234"), "should contain commit hash");
1178 assert!(
1179 result.contains("feat: add feature"),
1180 "should contain commit message"
1181 );
1182 assert!(
1183 result.len() < output.len() / 2,
1184 "compressed should be less than half of original ({} vs {})",
1185 result.len(),
1186 output.len()
1187 );
1188 }
1189
1190 #[test]
1191 fn git_log_with_stat_filters_stat_content() {
1192 let mut output = String::new();
1193 for i in 0..5 {
1194 output.push_str(&format!(
1195 "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"
1196 ));
1197 }
1198 let result = compress("git log --stat", &output).unwrap();
1199 assert!(
1200 result.len() < output.len() / 2,
1201 "stat output should be compressed ({} vs {})",
1202 result.len(),
1203 output.len()
1204 );
1205 }
1206
1207 #[test]
1208 fn git_commit_with_feature_branch() {
1209 let output = "[feature/my-branch abc1234] feat: add new thing\n 3 files changed, 20 insertions(+), 5 deletions(-)\n";
1210 let result = compress("git commit -m 'feat'", output).unwrap();
1211 assert!(
1212 result.contains("abc1234"),
1213 "should extract hash from feature branch, got: {result}"
1214 );
1215 assert!(
1216 result.contains("feature/my-branch"),
1217 "should preserve branch name, got: {result}"
1218 );
1219 }
1220
1221 #[test]
1222 fn git_commit_many_hooks_compressed() {
1223 let mut output = String::new();
1224 for i in 0..30 {
1225 output.push_str(&format!("check-{i}..........passed\n"));
1226 }
1227 output.push_str("[main abc1234] fix: resolve bug\n 1 file changed, 1 insertion(+)\n");
1228 let result = compress("git commit -m 'fix'", &output).unwrap();
1229 assert!(result.contains("abc1234"), "should contain commit hash");
1230 assert!(
1231 result.contains("hooks passed"),
1232 "should summarize passed hooks, got: {result}"
1233 );
1234 assert!(
1235 result.len() < output.len() / 2,
1236 "should compress verbose hook output ({} vs {})",
1237 result.len(),
1238 output.len()
1239 );
1240 }
1241
1242 #[test]
1243 fn stash_push_preserves_short_message() {
1244 let output = "Saved working directory and index state WIP on main: abc1234 fix stuff\n";
1245 let result = compress("git stash", output).unwrap();
1246 assert!(
1247 result.contains("Saved working directory"),
1248 "short stash messages must be preserved, got: {result}"
1249 );
1250 }
1251
1252 #[test]
1253 fn stash_drop_preserves_short_message() {
1254 let output = "Dropped refs/stash@{0} (abc123def456)\n";
1255 let result = compress("git stash drop", output).unwrap();
1256 assert!(
1257 result.contains("Dropped"),
1258 "short drop messages must be preserved, got: {result}"
1259 );
1260 }
1261
1262 #[test]
1263 fn stash_list_short_preserved() {
1264 let output = "stash@{0}: WIP on main: abc1234 fix\nstash@{1}: On feature: def5678 add\n";
1265 let result = compress("git stash list", output).unwrap();
1266 assert!(
1267 result.contains("stash@{0}"),
1268 "short stash list must be preserved, got: {result}"
1269 );
1270 }
1271
1272 #[test]
1273 fn stash_list_long_reformats() {
1274 let lines: Vec<String> = (0..10)
1275 .map(|i| format!("stash@{{{i}}}: WIP on main: abc{i:04} commit {i}"))
1276 .collect();
1277 let output = lines.join("\n");
1278 let result = compress("git stash list", &output).unwrap();
1279 assert!(result.contains("@0:"), "should reformat @0, got: {result}");
1280 assert!(result.contains("@9:"), "should reformat @9, got: {result}");
1281 }
1282
1283 #[test]
1284 fn stash_show_routes_to_show_compressor() {
1285 let output = " src/main.rs | 10 +++++-----\n src/lib.rs | 3 ++-\n 2 files changed, 7 insertions(+), 6 deletions(-)\n";
1286 let result = compress("git stash show", output).unwrap();
1287 assert!(
1288 result.contains("main.rs"),
1289 "stash show should preserve file names, got: {result}"
1290 );
1291 }
1292
1293 #[test]
1294 fn stash_show_patch_not_over_compressed() {
1295 let lines: Vec<String> = (0..40)
1296 .map(|i| format!("+line {i}: some content here"))
1297 .collect();
1298 let output = format!(
1299 "diff --git a/file.rs b/file.rs\n--- a/file.rs\n+++ b/file.rs\n{}",
1300 lines.join("\n")
1301 );
1302 let result = compress("git stash show -p", &output).unwrap();
1303 let result_lines = result.lines().count();
1304 assert!(
1305 result_lines >= 10,
1306 "stash show -p must not over-compress to 3 lines, got {result_lines} lines"
1307 );
1308 }
1309
1310 #[test]
1311 fn show_stash_ref_routes_correctly() {
1312 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";
1313 let result = compress("git show stash@{0}", output).unwrap();
1314 assert!(
1315 result.len() > 10,
1316 "git show stash@{{0}} must not be over-compressed, got: {result}"
1317 );
1318 }
1319}