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 compress_log(output: &str) -> String {
183 let lines: Vec<&str> = output.lines().collect();
184 if lines.is_empty() {
185 return String::new();
186 }
187
188 let max_entries = 20;
189
190 let is_oneline = !lines[0].starts_with("commit ");
191 if is_oneline {
192 if lines.len() <= max_entries {
193 return lines.join("\n");
194 }
195 let shown = &lines[..max_entries];
196 return format!(
197 "{}\n... ({} more commits)",
198 shown.join("\n"),
199 lines.len() - max_entries
200 );
201 }
202
203 let mut entries = Vec::new();
204 for line in &lines {
205 let trimmed = line.trim();
206 if trimmed.starts_with("commit ") {
207 let hash = &trimmed[7..14.min(trimmed.len())];
208 entries.push(hash.to_string());
209 } else if !trimmed.is_empty()
210 && !trimmed.starts_with("Author:")
211 && !trimmed.starts_with("Date:")
212 && !trimmed.starts_with("Merge:")
213 {
214 if let Some(last) = entries.last_mut() {
215 *last = format!("{last} {trimmed}");
216 }
217 }
218 }
219
220 if entries.is_empty() {
221 return output.to_string();
222 }
223
224 if entries.len() > max_entries {
225 let shown = &entries[..max_entries];
226 return format!(
227 "{}\n... ({} more commits)",
228 shown.join("\n"),
229 entries.len() - max_entries
230 );
231 }
232
233 entries.join("\n")
234}
235
236fn compress_diff(output: &str) -> String {
237 let mut files = Vec::new();
238 let mut current_file = String::new();
239 let mut additions = 0;
240 let mut deletions = 0;
241
242 for line in output.lines() {
243 if line.starts_with("diff --git") {
244 if !current_file.is_empty() {
245 files.push(format!("{current_file} +{additions}/-{deletions}"));
246 }
247 current_file = line.split(" b/").nth(1).unwrap_or("?").to_string();
248 additions = 0;
249 deletions = 0;
250 } else if line.starts_with('+') && !line.starts_with("+++") {
251 additions += 1;
252 } else if line.starts_with('-') && !line.starts_with("---") {
253 deletions += 1;
254 }
255 }
256 if !current_file.is_empty() {
257 files.push(format!("{current_file} +{additions}/-{deletions}"));
258 }
259
260 files.join("\n")
261}
262
263fn compress_add(output: &str) -> String {
264 let trimmed = output.trim();
265 if trimmed.is_empty() {
266 return "ok".to_string();
267 }
268 let lines: Vec<&str> = trimmed.lines().collect();
269 if lines.len() <= 3 {
270 return trimmed.to_string();
271 }
272 format!("ok (+{} files)", lines.len())
273}
274
275fn compress_commit(output: &str) -> String {
276 let mut hook_lines: Vec<&str> = Vec::new();
277 let mut commit_part = String::new();
278 let mut found_commit = false;
279
280 for line in output.lines() {
281 if !found_commit && commit_hash_re().is_match(line) {
282 found_commit = true;
283 }
284 if !found_commit {
285 let trimmed = line.trim();
286 if !trimmed.is_empty() {
287 hook_lines.push(trimmed);
288 }
289 }
290 }
291
292 if let Some(caps) = commit_hash_re().captures(output) {
293 let branch = &caps[1];
294 let hash = &caps[2];
295 let commit_line = output
296 .lines()
297 .find(|l| commit_hash_re().is_match(l))
298 .unwrap_or("")
299 .trim();
300 let stats = extract_change_stats(output);
301 commit_part = if stats.is_empty() {
302 format!("{hash} ({branch}) {commit_line}")
303 } else {
304 format!("{hash} ({branch}) {commit_line} {stats}")
305 };
306 }
307
308 if commit_part.is_empty() {
309 let trimmed = output.trim();
310 if trimmed.is_empty() {
311 return "ok".to_string();
312 }
313 return compact_lines(trimmed, 5);
314 }
315
316 if hook_lines.is_empty() {
317 return commit_part;
318 }
319
320 let hook_output = if hook_lines.len() > 10 {
321 let shown: Vec<&str> = hook_lines[..10].to_vec();
322 format!(
323 "{}\n... ({} more hook lines)",
324 shown.join("\n"),
325 hook_lines.len() - 10
326 )
327 } else {
328 hook_lines.join("\n")
329 };
330
331 format!("{hook_output}\n{commit_part}")
332}
333
334fn compress_push(output: &str) -> String {
335 let trimmed = output.trim();
336 if trimmed.is_empty() {
337 return "ok".to_string();
338 }
339
340 let mut ref_line = String::new();
341 let mut remote_urls: Vec<String> = Vec::new();
342 let mut rejected = false;
343
344 for line in trimmed.lines() {
345 let l = line.trim();
346
347 if l.contains("rejected") {
348 rejected = true;
349 }
350
351 if l.contains("->") && !l.starts_with("remote:") {
352 ref_line = l.to_string();
353 }
354
355 if l.contains("Everything up-to-date") {
356 return "ok (up-to-date)".to_string();
357 }
358
359 if l.starts_with("remote:") || l.starts_with("To ") {
360 let content = l.trim_start_matches("remote:").trim();
361 if content.contains("http")
362 || content.contains("pipeline")
363 || content.contains("merge_request")
364 || content.contains("pull/")
365 {
366 remote_urls.push(content.to_string());
367 }
368 }
369 }
370
371 if rejected {
372 let reject_lines: Vec<&str> = trimmed
373 .lines()
374 .filter(|l| l.contains("rejected") || l.contains("error") || l.contains("remote:"))
375 .collect();
376 return format!("REJECTED:\n{}", compact_lines(&reject_lines.join("\n"), 5));
377 }
378
379 let mut parts = Vec::new();
380 if !ref_line.is_empty() {
381 parts.push(format!("ok {ref_line}"));
382 } else {
383 parts.push("ok (pushed)".to_string());
384 }
385 for url in &remote_urls {
386 parts.push(url.clone());
387 }
388
389 parts.join("\n")
390}
391
392fn compress_pull(output: &str) -> String {
393 let trimmed = output.trim();
394 if trimmed.contains("Already up to date") {
395 return "ok (up-to-date)".to_string();
396 }
397
398 let stats = extract_change_stats(trimmed);
399 if !stats.is_empty() {
400 return format!("ok {stats}");
401 }
402
403 compact_lines(trimmed, 5)
404}
405
406fn compress_fetch(output: &str) -> String {
407 let trimmed = output.trim();
408 if trimmed.is_empty() {
409 return "ok".to_string();
410 }
411
412 let mut new_branches = Vec::new();
413 for line in trimmed.lines() {
414 let l = line.trim();
415 if l.contains("[new branch]") || l.contains("[new tag]") {
416 if let Some(name) = l.split("->").last() {
417 new_branches.push(name.trim().to_string());
418 }
419 }
420 }
421
422 if new_branches.is_empty() {
423 return "ok (fetched)".to_string();
424 }
425 format!("ok (new: {})", new_branches.join(", "))
426}
427
428fn compress_clone(output: &str) -> String {
429 let mut objects = 0u32;
430 for line in output.lines() {
431 if let Some(caps) = clone_objects_re().captures(line) {
432 objects = caps[1].parse().unwrap_or(0);
433 }
434 }
435
436 let into = output
437 .lines()
438 .find(|l| l.contains("Cloning into"))
439 .and_then(|l| l.split('\'').nth(1))
440 .unwrap_or("repo");
441
442 if objects > 0 {
443 format!("cloned '{into}' ({objects} objects)")
444 } else {
445 format!("cloned '{into}'")
446 }
447}
448
449fn compress_branch(output: &str) -> String {
450 let trimmed = output.trim();
451 if trimmed.is_empty() {
452 return "ok".to_string();
453 }
454
455 let branches: Vec<String> = trimmed
456 .lines()
457 .filter_map(|line| {
458 let l = line.trim();
459 if l.is_empty() {
460 return None;
461 }
462 if let Some(rest) = l.strip_prefix('*') {
463 Some(format!("*{}", rest.trim()))
464 } else {
465 Some(l.to_string())
466 }
467 })
468 .collect();
469
470 branches.join(", ")
471}
472
473fn compress_checkout(output: &str) -> String {
474 let trimmed = output.trim();
475 if trimmed.is_empty() {
476 return "ok".to_string();
477 }
478
479 for line in trimmed.lines() {
480 let l = line.trim();
481 if l.starts_with("Switched to") || l.starts_with("Already on") {
482 let branch = l.split('\'').nth(1).unwrap_or(l);
483 return format!("→ {branch}");
484 }
485 if l.starts_with("Your branch is up to date") {
486 continue;
487 }
488 }
489
490 compact_lines(trimmed, 3)
491}
492
493fn compress_merge(output: &str) -> String {
494 let trimmed = output.trim();
495 if trimmed.contains("Already up to date") {
496 return "ok (up-to-date)".to_string();
497 }
498 if trimmed.contains("CONFLICT") {
499 let conflicts: Vec<&str> = trimmed.lines().filter(|l| l.contains("CONFLICT")).collect();
500 return format!(
501 "CONFLICT ({} files):\n{}",
502 conflicts.len(),
503 conflicts.join("\n")
504 );
505 }
506
507 let stats = extract_change_stats(trimmed);
508 if !stats.is_empty() {
509 return format!("merged {stats}");
510 }
511 compact_lines(trimmed, 3)
512}
513
514fn compress_stash(output: &str) -> String {
515 let trimmed = output.trim();
516 if trimmed.is_empty() {
517 return "ok".to_string();
518 }
519
520 if trimmed.starts_with("Saved working directory") {
521 return "stashed".to_string();
522 }
523 if trimmed.starts_with("Dropped") {
524 return "dropped".to_string();
525 }
526
527 let stashes: Vec<String> = trimmed
528 .lines()
529 .filter_map(|line| {
530 stash_re()
531 .captures(line)
532 .map(|caps| format!("@{}: {}", &caps[1], &caps[2]))
533 })
534 .collect();
535
536 if stashes.is_empty() {
537 return compact_lines(trimmed, 3);
538 }
539 stashes.join("\n")
540}
541
542fn compress_tag(output: &str) -> String {
543 let trimmed = output.trim();
544 if trimmed.is_empty() {
545 return "ok".to_string();
546 }
547
548 let tags: Vec<&str> = trimmed.lines().filter(|l| !l.trim().is_empty()).collect();
549 if tags.len() <= 10 {
550 return tags.join(", ");
551 }
552 format!("{} (... {} total)", tags[..5].join(", "), tags.len())
553}
554
555fn compress_reset(output: &str) -> String {
556 let trimmed = output.trim();
557 if trimmed.is_empty() {
558 return "ok".to_string();
559 }
560
561 let mut unstaged: Vec<&str> = Vec::new();
562 for line in trimmed.lines() {
563 let l = line.trim();
564 if l.starts_with("Unstaged changes after reset:") {
565 continue;
566 }
567 if l.starts_with('M') || l.starts_with('D') || l.starts_with('A') {
568 unstaged.push(l);
569 }
570 }
571
572 if unstaged.is_empty() {
573 return compact_lines(trimmed, 3);
574 }
575 format!("reset ok ({} files unstaged)", unstaged.len())
576}
577
578fn compress_remote(output: &str) -> String {
579 let trimmed = output.trim();
580 if trimmed.is_empty() {
581 return "ok".to_string();
582 }
583
584 let mut remotes = std::collections::HashMap::new();
585 for line in trimmed.lines() {
586 let parts: Vec<&str> = line.split_whitespace().collect();
587 if parts.len() >= 2 {
588 remotes
589 .entry(parts[0].to_string())
590 .or_insert_with(|| parts[1].to_string());
591 }
592 }
593
594 if remotes.is_empty() {
595 return trimmed.to_string();
596 }
597
598 remotes
599 .iter()
600 .map(|(name, url)| format!("{name}: {url}"))
601 .collect::<Vec<_>>()
602 .join("\n")
603}
604
605fn compress_blame(output: &str) -> String {
606 let lines: Vec<&str> = output.lines().collect();
607 if lines.len() <= 20 {
608 return output.to_string();
609 }
610
611 let unique_authors: std::collections::HashSet<&str> = lines
612 .iter()
613 .filter_map(|l| l.split('(').nth(1)?.split_whitespace().next())
614 .collect();
615
616 format!("{} lines, {} authors", lines.len(), unique_authors.len())
617}
618
619fn compress_cherry_pick(output: &str) -> String {
620 let trimmed = output.trim();
621 if trimmed.is_empty() {
622 return "ok".to_string();
623 }
624 if trimmed.contains("CONFLICT") {
625 return "CONFLICT (cherry-pick)".to_string();
626 }
627 let stats = extract_change_stats(trimmed);
628 if !stats.is_empty() {
629 return format!("ok {stats}");
630 }
631 compact_lines(trimmed, 3)
632}
633
634fn extract_change_stats(output: &str) -> String {
635 let files = files_changed_re()
636 .captures(output)
637 .and_then(|c| c[1].parse::<u32>().ok())
638 .unwrap_or(0);
639 let ins = insertions_re()
640 .captures(output)
641 .and_then(|c| c[1].parse::<u32>().ok())
642 .unwrap_or(0);
643 let del = deletions_re()
644 .captures(output)
645 .and_then(|c| c[1].parse::<u32>().ok())
646 .unwrap_or(0);
647
648 if files > 0 || ins > 0 || del > 0 {
649 format!("{files} files, +{ins}/-{del}")
650 } else {
651 String::new()
652 }
653}
654
655fn compact_lines(text: &str, max: usize) -> String {
656 let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
657 if lines.len() <= max {
658 return lines.join("\n");
659 }
660 format!(
661 "{}\n... ({} more lines)",
662 lines[..max].join("\n"),
663 lines.len() - max
664 )
665}
666
667#[cfg(test)]
668mod tests {
669 use super::*;
670
671 #[test]
672 fn git_status_compresses() {
673 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";
674 let result = compress("git status", output).unwrap();
675 assert!(result.contains("main"), "should contain branch name");
676 assert!(result.contains("main.rs"), "should list modified files");
677 assert!(result.len() < output.len(), "should be shorter than input");
678 }
679
680 #[test]
681 fn git_add_compresses_to_ok() {
682 let result = compress("git add .", "").unwrap();
683 assert!(result.contains("ok"), "git add should compress to 'ok'");
684 }
685
686 #[test]
687 fn git_commit_extracts_hash() {
688 let output =
689 "[main abc1234] fix: resolve bug\n 2 files changed, 10 insertions(+), 3 deletions(-)\n";
690 let result = compress("git commit -m 'fix'", output).unwrap();
691 assert!(result.contains("abc1234"), "should extract commit hash");
692 }
693
694 #[test]
695 fn git_push_compresses() {
696 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";
697 let result = compress("git push", output).unwrap();
698 assert!(result.len() < output.len(), "should compress push output");
699 }
700
701 #[test]
702 fn git_log_compresses() {
703 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";
704 let result = compress("git log", output).unwrap();
705 assert!(result.len() < output.len(), "should compress log output");
706 }
707
708 #[test]
709 fn git_log_oneline_truncates_long() {
710 let lines: Vec<String> = (0..50)
711 .map(|i| format!("abc{i:04} feat: commit number {i}"))
712 .collect();
713 let output = lines.join("\n");
714 let result = compress("git log --oneline", &output).unwrap();
715 assert!(
716 result.contains("... (30 more commits)"),
717 "should truncate to 20 entries"
718 );
719 assert!(
720 result.lines().count() <= 22,
721 "should have at most 21 lines (20 + summary)"
722 );
723 }
724
725 #[test]
726 fn git_log_oneline_short_unchanged() {
727 let output = "abc1234 feat: one\ndef5678 fix: two\nghi9012 docs: three";
728 let result = compress("git log --oneline", output).unwrap();
729 assert_eq!(result, output, "short oneline should pass through");
730 }
731
732 #[test]
733 fn git_log_standard_truncates_long() {
734 let mut output = String::new();
735 for i in 0..30 {
736 output.push_str(&format!(
737 "commit {i:07}abc1234\nAuthor: U <u@e.com>\nDate: Mon\n\n msg {i}\n\n"
738 ));
739 }
740 let result = compress("git log", &output).unwrap();
741 assert!(
742 result.contains("... (10 more commits)"),
743 "should truncate standard log"
744 );
745 }
746
747 #[test]
748 fn git_diff_compresses() {
749 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 }";
750 let result = compress("git diff", output).unwrap();
751 assert!(result.contains("main.rs"), "should reference changed file");
752 }
753
754 #[test]
755 fn git_push_preserves_pipeline_url() {
756 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";
757 let result = compress("git push", output).unwrap();
758 assert!(
759 result.contains("pipeline"),
760 "should preserve pipeline URL, got: {result}"
761 );
762 assert!(
763 result.contains("merge_request"),
764 "should preserve merge request URL"
765 );
766 assert!(result.contains("->"), "should contain ref update line");
767 }
768
769 #[test]
770 fn git_push_preserves_github_pr_url() {
771 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";
772 let result = compress("git push", output).unwrap();
773 assert!(
774 result.contains("pull/"),
775 "should preserve GitHub PR URL, got: {result}"
776 );
777 }
778
779 #[test]
780 fn git_commit_preserves_hook_output() {
781 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";
782 let result = compress("git commit -m 'fix'", output).unwrap();
783 assert!(
784 result.contains("ruff"),
785 "should preserve hook output, got: {result}"
786 );
787 assert!(
788 result.contains("abc1234"),
789 "should still extract commit hash"
790 );
791 }
792
793 #[test]
794 fn git_commit_no_hooks() {
795 let output =
796 "[main abc1234] fix: resolve bug\n 2 files changed, 10 insertions(+), 3 deletions(-)\n";
797 let result = compress("git commit -m 'fix'", output).unwrap();
798 assert!(result.contains("abc1234"), "should extract commit hash");
799 assert!(
800 !result.contains("hook"),
801 "should not mention hooks when none present"
802 );
803 }
804}