Skip to main content

lean_ctx/core/patterns/
git.rs

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}