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 None
94}
95
96fn compress_status(output: &str) -> String {
97 let mut branch = String::new();
98 let mut ahead = 0u32;
99 let mut staged = Vec::new();
100 let mut unstaged = Vec::new();
101 let mut untracked = Vec::new();
102
103 let mut section = "";
104
105 for line in output.lines() {
106 if let Some(caps) = status_branch_re().captures(line) {
107 branch = caps[1].to_string();
108 }
109 if let Some(caps) = ahead_re().captures(line) {
110 ahead = caps[1].parse().unwrap_or(0);
111 }
112
113 if line.contains("Changes to be committed") {
114 section = "staged";
115 } else if line.contains("Changes not staged") {
116 section = "unstaged";
117 } else if line.contains("Untracked files") {
118 section = "untracked";
119 }
120
121 let trimmed = line.trim();
122 if trimmed.starts_with("new file:") {
123 let file = trimmed.trim_start_matches("new file:").trim();
124 if section == "staged" {
125 staged.push(format!("+{file}"));
126 }
127 } else if trimmed.starts_with("modified:") {
128 let file = trimmed.trim_start_matches("modified:").trim();
129 match section {
130 "staged" => staged.push(format!("~{file}")),
131 "unstaged" => unstaged.push(format!("~{file}")),
132 _ => {}
133 }
134 } else if trimmed.starts_with("deleted:") {
135 let file = trimmed.trim_start_matches("deleted:").trim();
136 if section == "staged" {
137 staged.push(format!("-{file}"));
138 }
139 } else if section == "untracked"
140 && !trimmed.is_empty()
141 && !trimmed.starts_with('(')
142 && !trimmed.starts_with("Untracked")
143 {
144 untracked.push(trimmed.to_string());
145 }
146 }
147
148 if branch.is_empty() && staged.is_empty() && unstaged.is_empty() && untracked.is_empty() {
149 return compact_lines(output.trim(), 10);
150 }
151
152 let mut parts = Vec::new();
153 let branch_display = if branch.is_empty() {
154 "?".to_string()
155 } else {
156 branch
157 };
158 let ahead_str = if ahead > 0 {
159 format!(" ↑{ahead}")
160 } else {
161 String::new()
162 };
163 parts.push(format!("{branch_display}{ahead_str}"));
164
165 if !staged.is_empty() {
166 parts.push(format!("staged: {}", staged.join(" ")));
167 }
168 if !unstaged.is_empty() {
169 parts.push(format!("unstaged: {}", unstaged.join(" ")));
170 }
171 if !untracked.is_empty() {
172 parts.push(format!("untracked: {}", untracked.join(" ")));
173 }
174
175 if output.contains("nothing to commit") && parts.len() == 1 {
176 parts.push("clean".to_string());
177 }
178
179 parts.join("\n")
180}
181
182fn is_diff_or_stat_line(line: &str) -> bool {
183 let t = line.trim();
184 t.starts_with("diff --git")
185 || t.starts_with("index ")
186 || t.starts_with("--- a/")
187 || t.starts_with("+++ b/")
188 || t.starts_with("@@ ")
189 || t.starts_with("Binary files")
190 || t.starts_with("new file mode")
191 || t.starts_with("deleted file mode")
192 || t.starts_with("old mode")
193 || t.starts_with("new mode")
194 || t.starts_with("similarity index")
195 || t.starts_with("rename from")
196 || t.starts_with("rename to")
197 || t.starts_with("copy from")
198 || t.starts_with("copy to")
199 || (t.starts_with('+') && !t.starts_with("+++"))
200 || (t.starts_with('-') && !t.starts_with("---"))
201 || (t.contains(" | ") && t.chars().any(|c| c == '+' || c == '-'))
202}
203
204fn compress_log(output: &str) -> String {
205 let lines: Vec<&str> = output.lines().collect();
206 if lines.is_empty() {
207 return String::new();
208 }
209
210 let max_entries = 20;
211
212 let is_oneline = !lines[0].starts_with("commit ");
213 if is_oneline {
214 if lines.len() <= max_entries {
215 return lines.join("\n");
216 }
217 let shown = &lines[..max_entries];
218 return format!(
219 "{}\n... ({} more commits)",
220 shown.join("\n"),
221 lines.len() - max_entries
222 );
223 }
224
225 let has_diff = lines.iter().any(|l| l.starts_with("diff --git"));
226 let has_stat = lines
227 .iter()
228 .any(|l| l.contains(" | ") && l.trim().ends_with(['+', '-']));
229 let mut total_additions = 0u32;
230 let mut total_deletions = 0u32;
231
232 let mut entries = Vec::new();
233 let mut in_diff = false;
234 let mut got_message = false;
235
236 for line in &lines {
237 let trimmed = line.trim();
238
239 if trimmed.starts_with("commit ") {
240 let hash = &trimmed[7..14.min(trimmed.len())];
241 entries.push(hash.to_string());
242 in_diff = false;
243 got_message = false;
244 continue;
245 }
246
247 if trimmed.starts_with("Author:")
248 || trimmed.starts_with("Date:")
249 || trimmed.starts_with("Merge:")
250 {
251 continue;
252 }
253
254 if trimmed.starts_with("diff --git") || trimmed.starts_with("---") && trimmed.contains("a/")
255 {
256 in_diff = true;
257 }
258
259 if in_diff || is_diff_or_stat_line(trimmed) {
260 if trimmed.starts_with('+') && !trimmed.starts_with("+++") {
261 total_additions += 1;
262 } else if trimmed.starts_with('-') && !trimmed.starts_with("---") {
263 total_deletions += 1;
264 }
265 continue;
266 }
267
268 if trimmed.is_empty() {
269 continue;
270 }
271
272 if !got_message {
273 if let Some(last) = entries.last_mut() {
274 *last = format!("{last} {trimmed}");
275 }
276 got_message = true;
277 }
278 }
279
280 if entries.is_empty() {
281 return output.to_string();
282 }
283
284 let mut result = if entries.len() > max_entries {
285 let shown = &entries[..max_entries];
286 format!(
287 "{}\n... ({} more commits)",
288 shown.join("\n"),
289 entries.len() - max_entries
290 )
291 } else {
292 entries.join("\n")
293 };
294
295 if (has_diff || has_stat) && (total_additions > 0 || total_deletions > 0) {
296 result.push_str(&format!(
297 "\n[{} commits, +{total_additions}/-{total_deletions} total]",
298 entries.len()
299 ));
300 }
301
302 result
303}
304
305fn compress_diff(output: &str) -> String {
306 let mut files = Vec::new();
307 let mut current_file = String::new();
308 let mut additions = 0;
309 let mut deletions = 0;
310
311 for line in output.lines() {
312 if line.starts_with("diff --git") {
313 if !current_file.is_empty() {
314 files.push(format!("{current_file} +{additions}/-{deletions}"));
315 }
316 current_file = line.split(" b/").nth(1).unwrap_or("?").to_string();
317 additions = 0;
318 deletions = 0;
319 } else if line.starts_with('+') && !line.starts_with("+++") {
320 additions += 1;
321 } else if line.starts_with('-') && !line.starts_with("---") {
322 deletions += 1;
323 }
324 }
325 if !current_file.is_empty() {
326 files.push(format!("{current_file} +{additions}/-{deletions}"));
327 }
328
329 files.join("\n")
330}
331
332fn compress_add(output: &str) -> String {
333 let trimmed = output.trim();
334 if trimmed.is_empty() {
335 return "ok".to_string();
336 }
337 let lines: Vec<&str> = trimmed.lines().collect();
338 if lines.len() <= 3 {
339 return trimmed.to_string();
340 }
341 format!("ok (+{} files)", lines.len())
342}
343
344fn compress_commit(output: &str) -> String {
345 let mut hook_lines: Vec<&str> = Vec::new();
346 let mut commit_part = String::new();
347 let mut found_commit = false;
348
349 for line in output.lines() {
350 if !found_commit && commit_hash_re().is_match(line) {
351 found_commit = true;
352 }
353 if !found_commit {
354 let trimmed = line.trim();
355 if !trimmed.is_empty() {
356 hook_lines.push(trimmed);
357 }
358 }
359 }
360
361 if let Some(caps) = commit_hash_re().captures(output) {
362 let branch = &caps[1];
363 let hash = &caps[2];
364 let stats = extract_change_stats(output);
365 let msg = output
366 .lines()
367 .find(|l| commit_hash_re().is_match(l))
368 .and_then(|l| l.split(']').nth(1))
369 .map(|m| m.trim())
370 .unwrap_or("");
371 commit_part = if stats.is_empty() {
372 format!("{hash} ({branch}) {msg}")
373 } else {
374 format!("{hash} ({branch}) {msg} [{stats}]")
375 };
376 }
377
378 if commit_part.is_empty() {
379 let trimmed = output.trim();
380 if trimmed.is_empty() {
381 return "ok".to_string();
382 }
383 return compact_lines(trimmed, 5);
384 }
385
386 if hook_lines.is_empty() {
387 return commit_part;
388 }
389
390 let failed: Vec<&&str> = hook_lines
391 .iter()
392 .filter(|l| {
393 let low = l.to_lowercase();
394 low.contains("failed") || low.contains("error") || low.contains("warning")
395 })
396 .collect();
397 let passed_count = hook_lines.len() - failed.len();
398
399 let hook_output = if !failed.is_empty() {
400 let mut parts = Vec::new();
401 if passed_count > 0 {
402 parts.push(format!("{passed_count} checks passed"));
403 }
404 for f in failed.iter().take(5) {
405 parts.push(f.to_string());
406 }
407 if failed.len() > 5 {
408 parts.push(format!("... ({} more failures)", failed.len() - 5));
409 }
410 parts.join("\n")
411 } else if hook_lines.len() > 5 {
412 format!("{} hooks passed", hook_lines.len())
413 } else {
414 hook_lines.join("\n")
415 };
416
417 format!("{hook_output}\n{commit_part}")
418}
419
420fn compress_push(output: &str) -> String {
421 let trimmed = output.trim();
422 if trimmed.is_empty() {
423 return "ok".to_string();
424 }
425
426 let mut ref_line = String::new();
427 let mut remote_urls: Vec<String> = Vec::new();
428 let mut rejected = false;
429
430 for line in trimmed.lines() {
431 let l = line.trim();
432
433 if l.contains("rejected") {
434 rejected = true;
435 }
436
437 if l.contains("->") && !l.starts_with("remote:") {
438 ref_line = l.to_string();
439 }
440
441 if l.contains("Everything up-to-date") {
442 return "ok (up-to-date)".to_string();
443 }
444
445 if l.starts_with("remote:") || l.starts_with("To ") {
446 let content = l.trim_start_matches("remote:").trim();
447 if content.contains("http")
448 || content.contains("pipeline")
449 || content.contains("merge_request")
450 || content.contains("pull/")
451 {
452 remote_urls.push(content.to_string());
453 }
454 }
455 }
456
457 if rejected {
458 let reject_lines: Vec<&str> = trimmed
459 .lines()
460 .filter(|l| l.contains("rejected") || l.contains("error") || l.contains("remote:"))
461 .collect();
462 return format!("REJECTED:\n{}", compact_lines(&reject_lines.join("\n"), 5));
463 }
464
465 let mut parts = Vec::new();
466 if !ref_line.is_empty() {
467 parts.push(format!("ok {ref_line}"));
468 } else {
469 parts.push("ok (pushed)".to_string());
470 }
471 for url in &remote_urls {
472 parts.push(url.clone());
473 }
474
475 parts.join("\n")
476}
477
478fn compress_pull(output: &str) -> String {
479 let trimmed = output.trim();
480 if trimmed.contains("Already up to date") {
481 return "ok (up-to-date)".to_string();
482 }
483
484 let stats = extract_change_stats(trimmed);
485 if !stats.is_empty() {
486 return format!("ok {stats}");
487 }
488
489 compact_lines(trimmed, 5)
490}
491
492fn compress_fetch(output: &str) -> String {
493 let trimmed = output.trim();
494 if trimmed.is_empty() {
495 return "ok".to_string();
496 }
497
498 let mut new_branches = Vec::new();
499 for line in trimmed.lines() {
500 let l = line.trim();
501 if l.contains("[new branch]") || l.contains("[new tag]") {
502 if let Some(name) = l.split("->").last() {
503 new_branches.push(name.trim().to_string());
504 }
505 }
506 }
507
508 if new_branches.is_empty() {
509 return "ok (fetched)".to_string();
510 }
511 format!("ok (new: {})", new_branches.join(", "))
512}
513
514fn compress_clone(output: &str) -> String {
515 let mut objects = 0u32;
516 for line in output.lines() {
517 if let Some(caps) = clone_objects_re().captures(line) {
518 objects = caps[1].parse().unwrap_or(0);
519 }
520 }
521
522 let into = output
523 .lines()
524 .find(|l| l.contains("Cloning into"))
525 .and_then(|l| l.split('\'').nth(1))
526 .unwrap_or("repo");
527
528 if objects > 0 {
529 format!("cloned '{into}' ({objects} objects)")
530 } else {
531 format!("cloned '{into}'")
532 }
533}
534
535fn compress_branch(output: &str) -> String {
536 let trimmed = output.trim();
537 if trimmed.is_empty() {
538 return "ok".to_string();
539 }
540
541 let branches: Vec<String> = trimmed
542 .lines()
543 .filter_map(|line| {
544 let l = line.trim();
545 if l.is_empty() {
546 return None;
547 }
548 if let Some(rest) = l.strip_prefix('*') {
549 Some(format!("*{}", rest.trim()))
550 } else {
551 Some(l.to_string())
552 }
553 })
554 .collect();
555
556 branches.join(", ")
557}
558
559fn compress_checkout(output: &str) -> String {
560 let trimmed = output.trim();
561 if trimmed.is_empty() {
562 return "ok".to_string();
563 }
564
565 for line in trimmed.lines() {
566 let l = line.trim();
567 if l.starts_with("Switched to") || l.starts_with("Already on") {
568 let branch = l.split('\'').nth(1).unwrap_or(l);
569 return format!("→ {branch}");
570 }
571 if l.starts_with("Your branch is up to date") {
572 continue;
573 }
574 }
575
576 compact_lines(trimmed, 3)
577}
578
579fn compress_merge(output: &str) -> String {
580 let trimmed = output.trim();
581 if trimmed.contains("Already up to date") {
582 return "ok (up-to-date)".to_string();
583 }
584 if trimmed.contains("CONFLICT") {
585 let conflicts: Vec<&str> = trimmed.lines().filter(|l| l.contains("CONFLICT")).collect();
586 return format!(
587 "CONFLICT ({} files):\n{}",
588 conflicts.len(),
589 conflicts.join("\n")
590 );
591 }
592
593 let stats = extract_change_stats(trimmed);
594 if !stats.is_empty() {
595 return format!("merged {stats}");
596 }
597 compact_lines(trimmed, 3)
598}
599
600fn compress_stash(output: &str) -> String {
601 let trimmed = output.trim();
602 if trimmed.is_empty() {
603 return "ok".to_string();
604 }
605
606 if trimmed.starts_with("Saved working directory") {
607 return "stashed".to_string();
608 }
609 if trimmed.starts_with("Dropped") {
610 return "dropped".to_string();
611 }
612
613 let stashes: Vec<String> = trimmed
614 .lines()
615 .filter_map(|line| {
616 stash_re()
617 .captures(line)
618 .map(|caps| format!("@{}: {}", &caps[1], &caps[2]))
619 })
620 .collect();
621
622 if stashes.is_empty() {
623 return compact_lines(trimmed, 3);
624 }
625 stashes.join("\n")
626}
627
628fn compress_tag(output: &str) -> String {
629 let trimmed = output.trim();
630 if trimmed.is_empty() {
631 return "ok".to_string();
632 }
633
634 let tags: Vec<&str> = trimmed.lines().filter(|l| !l.trim().is_empty()).collect();
635 if tags.len() <= 10 {
636 return tags.join(", ");
637 }
638 format!("{} (... {} total)", tags[..5].join(", "), tags.len())
639}
640
641fn compress_reset(output: &str) -> String {
642 let trimmed = output.trim();
643 if trimmed.is_empty() {
644 return "ok".to_string();
645 }
646
647 let mut unstaged: Vec<&str> = Vec::new();
648 for line in trimmed.lines() {
649 let l = line.trim();
650 if l.starts_with("Unstaged changes after reset:") {
651 continue;
652 }
653 if l.starts_with('M') || l.starts_with('D') || l.starts_with('A') {
654 unstaged.push(l);
655 }
656 }
657
658 if unstaged.is_empty() {
659 return compact_lines(trimmed, 3);
660 }
661 format!("reset ok ({} files unstaged)", unstaged.len())
662}
663
664fn compress_remote(output: &str) -> String {
665 let trimmed = output.trim();
666 if trimmed.is_empty() {
667 return "ok".to_string();
668 }
669
670 let mut remotes = std::collections::HashMap::new();
671 for line in trimmed.lines() {
672 let parts: Vec<&str> = line.split_whitespace().collect();
673 if parts.len() >= 2 {
674 remotes
675 .entry(parts[0].to_string())
676 .or_insert_with(|| parts[1].to_string());
677 }
678 }
679
680 if remotes.is_empty() {
681 return trimmed.to_string();
682 }
683
684 remotes
685 .iter()
686 .map(|(name, url)| format!("{name}: {url}"))
687 .collect::<Vec<_>>()
688 .join("\n")
689}
690
691fn compress_blame(output: &str) -> String {
692 let lines: Vec<&str> = output.lines().collect();
693 if lines.len() <= 20 {
694 return output.to_string();
695 }
696
697 let unique_authors: std::collections::HashSet<&str> = lines
698 .iter()
699 .filter_map(|l| l.split('(').nth(1)?.split_whitespace().next())
700 .collect();
701
702 format!("{} lines, {} authors", lines.len(), unique_authors.len())
703}
704
705fn compress_cherry_pick(output: &str) -> String {
706 let trimmed = output.trim();
707 if trimmed.is_empty() {
708 return "ok".to_string();
709 }
710 if trimmed.contains("CONFLICT") {
711 return "CONFLICT (cherry-pick)".to_string();
712 }
713 let stats = extract_change_stats(trimmed);
714 if !stats.is_empty() {
715 return format!("ok {stats}");
716 }
717 compact_lines(trimmed, 3)
718}
719
720fn extract_change_stats(output: &str) -> String {
721 let files = files_changed_re()
722 .captures(output)
723 .and_then(|c| c[1].parse::<u32>().ok())
724 .unwrap_or(0);
725 let ins = insertions_re()
726 .captures(output)
727 .and_then(|c| c[1].parse::<u32>().ok())
728 .unwrap_or(0);
729 let del = deletions_re()
730 .captures(output)
731 .and_then(|c| c[1].parse::<u32>().ok())
732 .unwrap_or(0);
733
734 if files > 0 || ins > 0 || del > 0 {
735 format!("{files} files, +{ins}/-{del}")
736 } else {
737 String::new()
738 }
739}
740
741fn compact_lines(text: &str, max: usize) -> String {
742 let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
743 if lines.len() <= max {
744 return lines.join("\n");
745 }
746 format!(
747 "{}\n... ({} more lines)",
748 lines[..max].join("\n"),
749 lines.len() - max
750 )
751}
752
753#[cfg(test)]
754mod tests {
755 use super::*;
756
757 #[test]
758 fn git_status_compresses() {
759 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";
760 let result = compress("git status", output).unwrap();
761 assert!(result.contains("main"), "should contain branch name");
762 assert!(result.contains("main.rs"), "should list modified files");
763 assert!(result.len() < output.len(), "should be shorter than input");
764 }
765
766 #[test]
767 fn git_add_compresses_to_ok() {
768 let result = compress("git add .", "").unwrap();
769 assert!(result.contains("ok"), "git add should compress to 'ok'");
770 }
771
772 #[test]
773 fn git_commit_extracts_hash() {
774 let output =
775 "[main abc1234] fix: resolve bug\n 2 files changed, 10 insertions(+), 3 deletions(-)\n";
776 let result = compress("git commit -m 'fix'", output).unwrap();
777 assert!(result.contains("abc1234"), "should extract commit hash");
778 }
779
780 #[test]
781 fn git_push_compresses() {
782 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";
783 let result = compress("git push", output).unwrap();
784 assert!(result.len() < output.len(), "should compress push output");
785 }
786
787 #[test]
788 fn git_log_compresses() {
789 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";
790 let result = compress("git log", output).unwrap();
791 assert!(result.len() < output.len(), "should compress log output");
792 }
793
794 #[test]
795 fn git_log_oneline_truncates_long() {
796 let lines: Vec<String> = (0..50)
797 .map(|i| format!("abc{i:04} feat: commit number {i}"))
798 .collect();
799 let output = lines.join("\n");
800 let result = compress("git log --oneline", &output).unwrap();
801 assert!(
802 result.contains("... (30 more commits)"),
803 "should truncate to 20 entries"
804 );
805 assert!(
806 result.lines().count() <= 22,
807 "should have at most 21 lines (20 + summary)"
808 );
809 }
810
811 #[test]
812 fn git_log_oneline_short_unchanged() {
813 let output = "abc1234 feat: one\ndef5678 fix: two\nghi9012 docs: three";
814 let result = compress("git log --oneline", output).unwrap();
815 assert_eq!(result, output, "short oneline should pass through");
816 }
817
818 #[test]
819 fn git_log_standard_truncates_long() {
820 let mut output = String::new();
821 for i in 0..30 {
822 output.push_str(&format!(
823 "commit {i:07}abc1234\nAuthor: U <u@e.com>\nDate: Mon\n\n msg {i}\n\n"
824 ));
825 }
826 let result = compress("git log", &output).unwrap();
827 assert!(
828 result.contains("... (10 more commits)"),
829 "should truncate standard log"
830 );
831 }
832
833 #[test]
834 fn git_diff_compresses() {
835 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 }";
836 let result = compress("git diff", output).unwrap();
837 assert!(result.contains("main.rs"), "should reference changed file");
838 }
839
840 #[test]
841 fn git_push_preserves_pipeline_url() {
842 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";
843 let result = compress("git push", output).unwrap();
844 assert!(
845 result.contains("pipeline"),
846 "should preserve pipeline URL, got: {result}"
847 );
848 assert!(
849 result.contains("merge_request"),
850 "should preserve merge request URL"
851 );
852 assert!(result.contains("->"), "should contain ref update line");
853 }
854
855 #[test]
856 fn git_push_preserves_github_pr_url() {
857 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";
858 let result = compress("git push", output).unwrap();
859 assert!(
860 result.contains("pull/"),
861 "should preserve GitHub PR URL, got: {result}"
862 );
863 }
864
865 #[test]
866 fn git_commit_preserves_hook_output() {
867 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";
868 let result = compress("git commit -m 'fix'", output).unwrap();
869 assert!(
870 result.contains("ruff"),
871 "should preserve hook output, got: {result}"
872 );
873 assert!(
874 result.contains("abc1234"),
875 "should still extract commit hash"
876 );
877 }
878
879 #[test]
880 fn git_commit_no_hooks() {
881 let output =
882 "[main abc1234] fix: resolve bug\n 2 files changed, 10 insertions(+), 3 deletions(-)\n";
883 let result = compress("git commit -m 'fix'", output).unwrap();
884 assert!(result.contains("abc1234"), "should extract commit hash");
885 assert!(
886 !result.contains("hook"),
887 "should not mention hooks when none present"
888 );
889 }
890
891 #[test]
892 fn git_log_with_patch_filters_diff_content() {
893 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";
894 let result = compress("git log -p", &output).unwrap();
895 assert!(
896 !result.contains("println"),
897 "should NOT contain diff content, got: {result}"
898 );
899 assert!(result.contains("abc1234"), "should contain commit hash");
900 assert!(
901 result.contains("feat: add feature"),
902 "should contain commit message"
903 );
904 assert!(
905 result.len() < output.len() / 2,
906 "compressed should be less than half of original ({} vs {})",
907 result.len(),
908 output.len()
909 );
910 }
911
912 #[test]
913 fn git_log_with_stat_filters_stat_content() {
914 let mut output = String::new();
915 for i in 0..5 {
916 output.push_str(&format!(
917 "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"
918 ));
919 }
920 let result = compress("git log --stat", &output).unwrap();
921 assert!(
922 result.len() < output.len() / 2,
923 "stat output should be compressed ({} vs {})",
924 result.len(),
925 output.len()
926 );
927 }
928
929 #[test]
930 fn git_commit_with_feature_branch() {
931 let output = "[feature/my-branch abc1234] feat: add new thing\n 3 files changed, 20 insertions(+), 5 deletions(-)\n";
932 let result = compress("git commit -m 'feat'", output).unwrap();
933 assert!(
934 result.contains("abc1234"),
935 "should extract hash from feature branch, got: {result}"
936 );
937 assert!(
938 result.contains("feature/my-branch"),
939 "should preserve branch name, got: {result}"
940 );
941 }
942
943 #[test]
944 fn git_commit_many_hooks_compressed() {
945 let mut output = String::new();
946 for i in 0..30 {
947 output.push_str(&format!("check-{i}..........passed\n"));
948 }
949 output.push_str("[main abc1234] fix: resolve bug\n 1 file changed, 1 insertion(+)\n");
950 let result = compress("git commit -m 'fix'", &output).unwrap();
951 assert!(result.contains("abc1234"), "should contain commit hash");
952 assert!(
953 result.contains("hooks passed"),
954 "should summarize passed hooks, got: {result}"
955 );
956 assert!(
957 result.len() < output.len() / 2,
958 "should compress verbose hook output ({} vs {})",
959 result.len(),
960 output.len()
961 );
962 }
963}