1use crate::compress::generic::{dedup_consecutive, middle_truncate, GenericCompressor};
2use crate::compress::{CompressionResult, Compressor};
3
4const STATUS_SHORT_LIMIT: usize = 1024;
5const STATUS_KEEP_PER_SECTION: usize = 10;
6const DIFF_MAX_FILES: usize = 5;
7const DIFF_MAX_HUNKS: usize = 20;
8const HUNK_KEEP_LINES: usize = 30;
9const LOG_SHORT_HASH_LEN: usize = 12;
10const BLAME_KEEP_LINES: usize = 50;
11const GIT_WRITE_KEEP_LINES: usize = 50;
12const GIT_ADD_KEEP_PATHS: usize = 5;
13const GIT_STASH_STATUS_KEEP_LINES: usize = 20;
14
15pub struct GitCompressor;
16
17impl Compressor for GitCompressor {
18 fn matches(&self, command: &str) -> bool {
19 command_head(command).is_some_and(|head| head == "git")
20 }
21
22 fn compress_with_exit_code(
23 &self,
24 command: &str,
25 output: &str,
26 exit_code: Option<i32>,
27 ) -> CompressionResult {
28 let compressed = match git_subcommand(command).as_deref() {
29 Some("add") => compress_add(output),
30 Some("status") => compress_status(output),
31 Some("diff") => compress_diff(output, false),
32 Some("log") => compress_log(output),
33 Some("show") => compress_diff(output, true),
34 Some("branch") => trim_trailing_lines(&dedup_consecutive(output)),
35 Some("blame") => compress_blame(output),
36 Some("commit") => compress_commit(output),
37 Some("push") => compress_push(output),
38 Some("pull") => compress_pull(output),
39 Some("fetch") => compress_fetch(output),
40 Some("stash") => compress_stash(command, output),
41 _ => GenericCompressor::compress_output(output),
42 };
43 if matches!(exit_code, Some(code) if code != 0)
44 && looks_like_git_success_summary(&compressed)
45 {
46 GenericCompressor::compress_output(output).into()
47 } else {
48 compressed.into()
49 }
50 }
51}
52
53fn looks_like_git_success_summary(text: &str) -> bool {
54 let trimmed = text.trim();
55 matches!(
56 trimmed,
57 "git: ok" | "git fetch: ok" | "Everything up-to-date" | "Already up to date."
58 ) || trimmed.contains("working tree clean")
59}
60
61fn command_head(command: &str) -> Option<&str> {
62 command.split_whitespace().next()
63}
64
65fn git_subcommand(command: &str) -> Option<String> {
66 let mut seen_git = false;
67 for token in command.split_whitespace() {
68 if !seen_git {
69 if token == "git" {
70 seen_git = true;
71 }
72 continue;
73 }
74 if token.starts_with('-') || token.contains('=') {
75 continue;
76 }
77 if crate::compress::is_shell_boundary(token) {
78 return None;
79 }
80 return Some(token.to_string());
81 }
82 None
83}
84
85fn git_subcommand_after(command: &str, subcommand: &str) -> Option<String> {
86 let mut seen_git = false;
87 let mut seen_subcommand = false;
88 for token in command.split_whitespace() {
89 if !seen_git {
90 if token == "git" {
91 seen_git = true;
92 }
93 continue;
94 }
95 if !seen_subcommand {
96 if token.starts_with('-') || token.contains('=') {
97 continue;
98 }
99 seen_subcommand = token == subcommand;
100 continue;
101 }
102 if token.starts_with('-') || token.contains('=') {
103 continue;
104 }
105 return Some(token.to_string());
106 }
107 None
108}
109
110fn compress_add(output: &str) -> String {
111 if output.trim().is_empty() {
112 return "git: ok".to_string();
113 }
114 if looks_like_git_error(output) {
115 return trim_trailing_lines(output);
116 }
117 let lines: Vec<&str> = output
118 .lines()
119 .filter(|line| !line.trim().is_empty())
120 .collect();
121 if lines.is_empty() {
122 return "git: ok".to_string();
123 }
124 let mut result: Vec<String> = lines
125 .iter()
126 .take(GIT_ADD_KEEP_PATHS)
127 .map(|line| line.trim_end().to_string())
128 .collect();
129 if lines.len() > GIT_ADD_KEEP_PATHS {
130 result.push(format!(
131 "... ({} more files added)",
132 lines.len() - GIT_ADD_KEEP_PATHS
133 ));
134 }
135 cap_git_lines(result, "files added", GIT_WRITE_KEEP_LINES)
136}
137
138fn compress_commit(output: &str) -> String {
139 if output.trim().is_empty() {
140 return GenericCompressor::compress_output(output);
141 }
142 if looks_like_git_error(output) {
143 return trim_trailing_lines(output);
144 }
145 if let Some(line) = output
146 .lines()
147 .find(|line| line.contains("nothing to commit"))
148 {
149 return line.trim_end().to_string();
150 }
151 let subject = output.lines().find(|line| looks_like_commit_subject(line));
152 let summary = output.lines().find(|line| looks_like_commit_summary(line));
153 match (subject, summary) {
154 (Some(subject), Some(summary)) => {
155 trim_trailing_lines(&format!("{}\n{}", subject.trim_end(), summary.trim()))
156 }
157 (Some(subject), None) => subject.trim_end().to_string(),
158 _ => GenericCompressor::compress_output(output),
159 }
160}
161
162fn compress_push(output: &str) -> String {
163 if output.trim().is_empty() {
164 return GenericCompressor::compress_output(output);
165 }
166 if looks_like_git_error(output) {
167 return trim_trailing_lines(output);
168 }
169 if let Some(line) = output
170 .lines()
171 .find(|line| line.trim() == "Everything up-to-date")
172 {
173 return line.trim_end().to_string();
174 }
175 let result: Vec<String> = output
176 .lines()
177 .filter(|line| is_remote_destination(line) || is_ref_update_line(line))
178 .map(|line| line.trim_end().to_string())
179 .collect();
180 if result.is_empty() {
181 return GenericCompressor::compress_output(output);
182 }
183 cap_git_lines(result, "push lines", GIT_WRITE_KEEP_LINES)
184}
185
186fn compress_pull(output: &str) -> String {
187 if output.trim().is_empty() {
188 return GenericCompressor::compress_output(output);
189 }
190 if looks_like_git_error(output) {
191 return trim_trailing_lines(output);
192 }
193 if let Some(line) = output
194 .lines()
195 .find(|line| line.trim() == "Already up to date.")
196 {
197 return line.trim_end().to_string();
198 }
199 let result: Vec<String> = output
200 .lines()
201 .filter(|line| {
202 looks_like_updating_line(line)
203 || looks_like_pull_marker(line)
204 || looks_like_commit_summary(line)
205 })
206 .map(|line| line.trim_end().to_string())
207 .collect();
208 if result.is_empty() {
209 return GenericCompressor::compress_output(output);
210 }
211 cap_git_lines(result, "pull lines", GIT_WRITE_KEEP_LINES)
212}
213
214fn compress_fetch(output: &str) -> String {
215 if output.trim().is_empty() {
216 return "git fetch: ok".to_string();
217 }
218 if looks_like_git_error(output) {
219 return trim_trailing_lines(output);
220 }
221 let result: Vec<String> = output
222 .lines()
223 .filter(|line| is_fetch_from_line(line) || is_ref_update_line(line))
224 .map(|line| line.trim_end().to_string())
225 .collect();
226 if result.is_empty() {
227 return GenericCompressor::compress_output(output);
228 }
229 cap_git_lines(result, "fetch lines", GIT_WRITE_KEEP_LINES)
230}
231
232fn compress_stash(command: &str, output: &str) -> String {
233 if output.trim().is_empty() {
234 return GenericCompressor::compress_output(output);
235 }
236 if looks_like_git_error(output) {
237 return trim_trailing_lines(output);
238 }
239 match git_subcommand_after(command, "stash").as_deref() {
240 None | Some("push") | Some("save") => output
241 .lines()
242 .find(|line| line.starts_with("Saved working directory and index state"))
243 .map(|line| line.trim_end().to_string())
244 .unwrap_or_else(|| GenericCompressor::compress_output(output)),
245 Some("pop" | "apply") => cap_git_lines(
246 output
247 .lines()
248 .map(|line| line.trim_end().to_string())
249 .collect(),
250 "stash status lines",
251 GIT_STASH_STATUS_KEEP_LINES,
252 ),
253 Some("list") => trim_trailing_lines(output),
254 _ => GenericCompressor::compress_output(output),
255 }
256}
257
258fn looks_like_git_error(output: &str) -> bool {
259 output.lines().any(|line| {
260 let trimmed = line.trim_start();
261 trimmed.starts_with("error:")
262 || trimmed.starts_with("fatal:")
263 || trimmed.starts_with("CONFLICT ")
264 || trimmed.starts_with("Automatic merge failed")
265 || trimmed.starts_with("! [rejected]")
266 || trimmed.starts_with("! [remote rejected]")
267 || trimmed.starts_with("failed to push")
268 })
269}
270
271fn looks_like_commit_subject(line: &str) -> bool {
272 let trimmed = line.trim_start();
273 trimmed.starts_with('[') && trimmed.contains("] ")
274}
275
276fn looks_like_commit_summary(line: &str) -> bool {
277 let trimmed = line.trim();
278 (trimmed.contains("file changed") || trimmed.contains("files changed"))
279 && (trimmed.contains("insertion")
280 || trimmed.contains("deletion")
281 || trimmed.contains("changed"))
282}
283
284fn is_remote_destination(line: &str) -> bool {
285 line.starts_with("To ")
286}
287
288fn is_fetch_from_line(line: &str) -> bool {
289 line.starts_with("From ")
290}
291
292fn is_ref_update_line(line: &str) -> bool {
293 let trimmed = line.trim_start();
294 trimmed.contains(" -> ")
295 && (trimmed.starts_with('*')
296 || trimmed.starts_with('+')
297 || trimmed.starts_with('-')
298 || trimmed.starts_with('=')
299 || trimmed.starts_with('!')
300 || trimmed.split_whitespace().next().is_some_and(is_hash_range))
301}
302
303fn is_hash_range(token: &str) -> bool {
304 token
305 .split_once("..")
306 .is_some_and(|(left, right)| is_short_hash(left) && is_short_hash(right))
307}
308
309fn is_short_hash(token: &str) -> bool {
310 (4..=40).contains(&token.len()) && token.bytes().all(|byte| byte.is_ascii_hexdigit())
311}
312
313fn looks_like_updating_line(line: &str) -> bool {
314 line.trim_start().starts_with("Updating ")
315}
316
317fn looks_like_pull_marker(line: &str) -> bool {
318 let trimmed = line.trim();
319 trimmed == "Fast-forward" || trimmed.starts_with("Merge made by ")
320}
321
322fn cap_git_lines(mut lines: Vec<String>, summary_name: &str, keep_lines: usize) -> String {
323 if lines.len() > keep_lines {
324 let omitted = lines.len() - keep_lines;
325 lines.truncate(keep_lines);
326 lines.push(format!("... ({} more {})", omitted, summary_name));
327 }
328 trim_trailing_lines(&lines.join("\n"))
329}
330
331fn compress_status(output: &str) -> String {
332 if output.len() <= STATUS_SHORT_LIMIT {
333 return trim_trailing_lines(output);
334 }
335
336 let mut result = Vec::new();
337 let mut section_entries = Vec::new();
338 let mut in_section = false;
339
340 for line in output.lines() {
341 if is_status_section_header(line) {
342 flush_status_entries(&mut result, &mut section_entries);
343 result.push(line.to_string());
344 in_section = true;
345 } else if in_section && is_status_instructional(line) {
346 result.push(line.to_string());
352 } else if in_section && is_status_entry(line) {
353 section_entries.push(line.to_string());
354 } else {
355 flush_status_entries(&mut result, &mut section_entries);
356 result.push(line.to_string());
357 in_section = false;
358 }
359 }
360 flush_status_entries(&mut result, &mut section_entries);
361
362 trim_trailing_lines(&result.join("\n"))
363}
364
365fn is_status_section_header(line: &str) -> bool {
366 matches!(
367 line.trim_end_matches(':'),
368 "Changes to be committed"
369 | "Changes not staged for commit"
370 | "Untracked files"
371 | "Unmerged paths"
372 )
373}
374
375fn is_status_instructional(line: &str) -> bool {
381 let trimmed = line.trim_start();
382 trimmed.starts_with('(') || trimmed.starts_with("use ")
383}
384
385fn is_status_entry(line: &str) -> bool {
386 let trimmed = line.trim_start();
387 trimmed.starts_with("modified:")
388 || trimmed.starts_with("new file:")
389 || trimmed.starts_with("deleted:")
390 || trimmed.starts_with("renamed:")
391 || trimmed.starts_with("copied:")
392 || trimmed.starts_with("both modified:")
393 || trimmed.starts_with("both added:")
394 || trimmed.starts_with("deleted by us:")
395 || trimmed.starts_with("deleted by them:")
396 || (!trimmed.is_empty()
397 && !trimmed.starts_with('(')
398 && !trimmed.starts_with("use ")
399 && !trimmed.starts_with("no changes"))
400}
401
402fn flush_status_entries(result: &mut Vec<String>, entries: &mut Vec<String>) {
403 if entries.is_empty() {
404 return;
405 }
406
407 let keep = entries.len().min(STATUS_KEEP_PER_SECTION);
408 result.extend(entries.iter().take(keep).cloned());
409 if entries.len() > keep {
410 result.push(format!("... and {} more", entries.len() - keep));
411 }
412 entries.clear();
413}
414
415fn compress_diff(output: &str, keep_commit_header: bool) -> String {
416 let files = split_diff_files(output, keep_commit_header);
417 let total_hunks: usize = files.iter().map(|file| count_hunks(&file.lines)).sum();
418
419 if files.is_empty() || total_hunks <= 2 && output.len() <= 5 * 1024 {
420 return trim_trailing_lines(output);
421 }
422
423 let max_files = if total_hunks > DIFF_MAX_HUNKS {
424 DIFF_MAX_FILES
425 } else {
426 usize::MAX
427 };
428
429 let mut result = Vec::new();
430 let mut emitted_files = 0usize;
431
432 for file in &files {
433 if file.is_diff && emitted_files >= max_files {
434 continue;
435 }
436 result.extend(compress_diff_file(&file.lines));
437 emitted_files += usize::from(file.is_diff);
438 }
439
440 let changed_files = files.iter().filter(|file| file.is_diff).count();
441 if changed_files > emitted_files {
442 result.push(format!(
443 "... and {} more files changed",
444 changed_files - emitted_files
445 ));
446 }
447
448 middle_truncate(
449 &trim_trailing_lines(&result.join("\n")),
450 16 * 1024,
451 7 * 1024,
452 7 * 1024,
453 )
454}
455
456struct DiffFile {
457 lines: Vec<String>,
458 is_diff: bool,
459}
460
461fn split_diff_files(output: &str, keep_commit_header: bool) -> Vec<DiffFile> {
462 let mut files = Vec::new();
463 let mut current = Vec::new();
464 let mut current_is_diff = false;
465
466 for line in output.lines() {
467 if line.starts_with("diff --git ") {
468 if !current.is_empty() {
469 files.push(DiffFile {
470 lines: std::mem::take(&mut current),
471 is_diff: current_is_diff,
472 });
473 }
474 current_is_diff = true;
475 } else if !current_is_diff && !keep_commit_header && !line.starts_with("diff --git ") {
476 current_is_diff = true;
477 }
478 current.push(line.to_string());
479 }
480
481 if !current.is_empty() {
482 files.push(DiffFile {
483 lines: current,
484 is_diff: current_is_diff,
485 });
486 }
487
488 files
489}
490
491fn compress_diff_file(lines: &[String]) -> Vec<String> {
492 let mut result = Vec::new();
493 let mut index = 0usize;
494
495 while index < lines.len() {
496 let line = &lines[index];
497 if !line.starts_with("@@") {
498 result.push(line.clone());
499 index += 1;
500 continue;
501 }
502
503 let hunk_start = index;
504 index += 1;
505 while index < lines.len() && !lines[index].starts_with("@@") {
506 index += 1;
507 }
508 let hunk = &lines[hunk_start..index];
509 append_hunk(&mut result, hunk);
510 }
511
512 result
513}
514
515fn append_hunk(result: &mut Vec<String>, hunk: &[String]) {
516 if hunk.len() <= HUNK_KEEP_LINES + 1 {
517 result.extend(hunk.iter().cloned());
518 return;
519 }
520
521 result.extend(hunk.iter().take(HUNK_KEEP_LINES + 1).cloned());
522 let remaining = &hunk[HUNK_KEEP_LINES + 1..];
523 let added = remaining
524 .iter()
525 .filter(|line| line.starts_with('+'))
526 .count();
527 let removed = remaining
528 .iter()
529 .filter(|line| line.starts_with('-'))
530 .count();
531 result.push(format!(
532 "... +{} -{} in {} more lines",
533 added,
534 removed,
535 remaining.len()
536 ));
537}
538
539fn count_hunks(lines: &[String]) -> usize {
540 lines.iter().filter(|line| line.starts_with("@@")).count()
541}
542
543fn compress_log(output: &str) -> String {
544 if output.lines().any(|line| line.starts_with("commit ")) {
545 compress_full_format_log(output)
546 } else {
547 compress_oneline_log(output)
548 }
549}
550
551fn compress_full_format_log(output: &str) -> String {
552 let lines: Vec<&str> = output.lines().collect();
553 let mut blocks: Vec<usize> = Vec::new();
554 for (index, line) in lines.iter().enumerate() {
555 if line.starts_with("commit ") {
556 blocks.push(index);
557 }
558 }
559 if blocks.is_empty() {
560 return trim_trailing_lines(output);
561 }
562
563 let mut result = Vec::new();
564 for (block_index, &start) in blocks.iter().enumerate() {
565 let end = blocks.get(block_index + 1).copied().unwrap_or(lines.len());
566 let block = &lines[start..end];
567 if let Some(compact) = format_log_commit_block(block) {
568 result.push(compact);
569 }
570 }
571
572 trim_trailing_lines(&result.join("\n"))
573}
574
575fn format_log_commit_block(block: &[&str]) -> Option<String> {
576 let first = block.first()?;
577 let full_hash = first.strip_prefix("commit ")?.trim();
578 let short_hash = abbreviate_log_hash(full_hash);
579
580 let mut merge_parents: Option<String> = None;
581 let mut author: Option<String> = None;
582 let mut date_compact: Option<String> = None;
583 let mut header_end = 1usize;
584
585 for (offset, line) in block.iter().enumerate().skip(1) {
586 if line.starts_with("Merge: ") {
587 let rest = line.strip_prefix("Merge: ").unwrap_or("");
588 let parents: Vec<String> = rest.split_whitespace().map(abbreviate_log_hash).collect();
589 merge_parents = Some(format!("Merge: {}", parents.join(" ")));
590 header_end = offset + 1;
591 } else if let Some(rest) = line.strip_prefix("Author: ") {
592 author = Some(format_author_compact(rest.trim()));
593 header_end = offset + 1;
594 } else if let Some(rest) = line.strip_prefix("Date: ") {
595 date_compact = Some(compact_git_log_date(rest.trim()));
596 header_end = offset + 1;
597 } else if line.trim().is_empty() {
598 header_end = offset + 1;
599 break;
600 } else {
601 break;
602 }
603 }
604
605 let mut body_iter = block[header_end..].iter().copied();
606 let mut subject = String::new();
607 let mut body_rest = Vec::new();
608 if let Some(first_body) = body_iter.next() {
609 subject = first_body.trim().to_string();
610 for line in body_iter {
611 body_rest.push(normalize_log_body_line(line));
612 }
613 }
614
615 let author = author.unwrap_or_default();
616 let date_compact = date_compact.unwrap_or_default();
617 let mut header = format!("{short_hash} {subject}");
618 if let Some(merge) = merge_parents {
619 header = format!("{short_hash} {merge} {subject}");
620 }
621 if !author.is_empty() {
622 header.push_str(&format!(" {author}"));
623 }
624 if !date_compact.is_empty() {
625 header.push(' ');
626 header.push_str(&date_compact);
627 }
628
629 let mut out = header;
630 for line in collapse_log_body_blank_runs(&body_rest) {
631 out.push('\n');
632 out.push_str(&line);
633 }
634 Some(out)
635}
636
637fn normalize_log_body_line(line: &str) -> String {
638 if line.starts_with(' ') || line.starts_with('\t') {
639 format!(" {}", line.trim_start())
640 } else {
641 format!(" {line}")
642 }
643}
644
645fn collapse_log_body_blank_runs(body: &[String]) -> Vec<String> {
646 body.iter()
647 .filter(|line| !line.trim().is_empty())
648 .cloned()
649 .collect()
650}
651
652fn format_author_compact(author: &str) -> String {
653 if let Some((name, email)) = author.split_once('<') {
654 let name = name.trim();
655 let email = email.trim_end_matches('>').trim();
656 format!("<{name} {email}>")
657 } else {
658 format!("<{author}>")
659 }
660}
661
662fn abbreviate_log_hash(hash: &str) -> String {
663 let hash = hash.trim();
664 if hash.len() <= LOG_SHORT_HASH_LEN {
665 hash.to_string()
666 } else {
667 hash[..LOG_SHORT_HASH_LEN].to_string()
668 }
669}
670
671fn compact_git_log_date(date_field: &str) -> String {
672 let parts: Vec<&str> = date_field.split_whitespace().collect();
673 if parts.len() < 5 {
674 return date_field.to_string();
675 }
676 let month = parts[1];
677 let day = parts[2];
678 let time = parts[3];
679 let year = parts[4];
680 let month_num = match month {
681 "Jan" => "01",
682 "Feb" => "02",
683 "Mar" => "03",
684 "Apr" => "04",
685 "May" => "05",
686 "Jun" => "06",
687 "Jul" => "07",
688 "Aug" => "08",
689 "Sep" => "09",
690 "Oct" => "10",
691 "Nov" => "11",
692 "Dec" => "12",
693 _ => return date_field.to_string(),
694 };
695 let day_padded = if day.len() == 1 {
696 format!("0{day}")
697 } else {
698 day.to_string()
699 };
700 format!("{year}-{month_num}-{day_padded} {time}")
701}
702
703fn compress_oneline_log(output: &str) -> String {
704 let result: Vec<String> = output
705 .lines()
706 .filter(|line| !line.trim().is_empty())
707 .map(|line| {
708 if looks_like_oneline_commit(line) {
709 let mut parts = line.splitn(2, ' ');
710 let hash = parts.next().unwrap_or("");
711 let rest = parts.next().unwrap_or("");
712 let short = abbreviate_log_hash(hash);
713 if rest.is_empty() {
714 short
715 } else {
716 format!("{short} {rest}")
717 }
718 } else {
719 line.trim_end().to_string()
720 }
721 })
722 .collect();
723 trim_trailing_lines(&result.join("\n"))
724}
725
726fn looks_like_oneline_commit(line: &str) -> bool {
727 let Some((hash, _message)) = line.split_once(' ') else {
728 return false;
729 };
730 (7..=40).contains(&hash.len()) && hash.bytes().all(|byte| byte.is_ascii_hexdigit())
731}
732
733fn compress_blame(output: &str) -> String {
734 let total = output.lines().count();
735 if total <= BLAME_KEEP_LINES {
736 return trim_trailing_lines(output);
737 }
738
739 let mut result: Vec<String> = output
740 .lines()
741 .take(BLAME_KEEP_LINES)
742 .map(ToString::to_string)
743 .collect();
744 result.push(format!("... {} more blame lines", total - BLAME_KEEP_LINES));
745 result.join("\n")
746}
747
748fn trim_trailing_lines(input: &str) -> String {
749 input
750 .lines()
751 .map(str::trim_end)
752 .collect::<Vec<_>>()
753 .join("\n")
754}
755
756#[cfg(test)]
757mod tests {
758 use super::*;
759 use crate::compress::Compressor;
760
761 fn compress(command: &str, output: &str) -> CompressionResult {
762 GitCompressor.compress(command, output)
763 }
764
765 #[test]
766 fn test_add_empty_output_ok() {
767 let compressed = compress("git add .", "");
768 assert_eq!(compressed, "git: ok");
769 }
770 #[test]
771 fn test_add_verbose_many_files() {
772 let raw = "add 'src/a.rs'\nadd 'src/b.rs'\nadd 'src/c.rs'\nadd 'src/d.rs'\nadd 'src/e.rs'\nadd 'src/f.rs'\nadd 'src/g.rs'\n";
773 let compressed = compress("git add --verbose .", raw);
774 assert!(compressed.contains("add 'src/a.rs'"));
775 assert!(compressed.contains("add 'src/e.rs'"));
776 assert!(compressed.contains("... (2 more files added)"));
777 assert!(!compressed.contains("add 'src/g.rs'"));
778 }
779 #[test]
780 fn test_add_error_passthrough() {
781 let raw = "fatal: pathspec 'missing.rs' did not match any files\n";
782 let compressed = compress("git add missing.rs", raw);
783 assert_eq!(
784 compressed,
785 "fatal: pathspec 'missing.rs' did not match any files"
786 );
787 }
788 #[test]
789 fn test_commit_success_extracts_subject_and_summary() {
790 let raw = "[main 1a2b3c4] add git write compression\n 3 files changed, 42 insertions(+), 7 deletions(-)\n create mode 100644 crates/aft/src/foo.rs\n rewrite crates/aft/src/bar.rs (80%)\n";
791 let compressed = compress("git commit -m 'add git write compression'", raw);
792 assert_eq!(
793 compressed,
794 "[main 1a2b3c4] add git write compression\n3 files changed, 42 insertions(+), 7 deletions(-)"
795 );
796 }
797 #[test]
798 fn test_commit_nothing_to_commit_verbatim() {
799 let raw = "On branch main\nnothing to commit, working tree clean\n";
800 let compressed = compress("git commit -m noop", raw);
801 assert_eq!(compressed, "nothing to commit, working tree clean");
802 }
803 #[test]
804 fn test_commit_error_passthrough() {
805 let raw = "error: Committing is not possible because you have unmerged files.\nhint: Fix them up in the work tree, and then use 'git add/rm <file>'\nfatal: Exiting because of an unresolved conflict.\n";
806 let compressed = compress("git commit", raw);
807 assert!(compressed.contains("error: Committing is not possible"));
808 assert!(compressed.contains("fatal: Exiting because of an unresolved conflict."));
809 }
810 #[test]
811 fn test_push_success_drops_progress_keeps_remote_and_ref() {
812 let raw = "Counting objects: 12, done.\nDelta compression using up to 8 threads\nCompressing objects: 100% (7/7), done.\nWriting objects: 100% (7/7), 1.23 KiB | 1.23 MiB/s, done.\nTotal 7 (delta 4), reused 0 (delta 0), pack-reused 0\nremote: Resolving deltas: 100% (4/4), completed with 4 local objects.\nTo github.com:example/repo.git\n 9d8c7b6..1a2b3c4 main -> main\n";
813 let compressed = compress("git push", raw);
814 assert_eq!(
815 compressed,
816 "To github.com:example/repo.git\n 9d8c7b6..1a2b3c4 main -> main"
817 );
818 }
819 #[test]
820 fn test_push_everything_up_to_date_and_empty() {
821 assert_eq!(
822 compress("git push", "Everything up-to-date\n"),
823 "Everything up-to-date"
824 );
825 assert_eq!(compress("git push", ""), "");
826 }
827 #[test]
828 fn test_push_error_passthrough() {
829 let raw = "To github.com:example/repo.git\n ! [rejected] main -> main (fetch first)\nerror: failed to push some refs to 'github.com:example/repo.git'\n";
830 let compressed = compress("git push", raw);
831 assert!(compressed.contains("! [rejected] main -> main (fetch first)"));
832 assert!(compressed.contains("error: failed to push some refs"));
833 }
834 #[test]
835 fn test_pull_fast_forward_keeps_summary() {
836 let raw = "remote: Enumerating objects: 9, done.\nremote: Counting objects: 100% (9/9), done.\nFrom github.com:example/repo\n 1111111..2222222 main -> origin/main\nUpdating 1111111..2222222\nFast-forward\n crates/aft/src/compress/git.rs | 12 +++++++++---\n 1 file changed, 9 insertions(+), 3 deletions(-)\n";
837 let compressed = compress("git pull --ff-only", raw);
838 assert_eq!(
839 compressed,
840 "Updating 1111111..2222222\nFast-forward\n 1 file changed, 9 insertions(+), 3 deletions(-)"
841 );
842 }
843 #[test]
844 fn test_pull_already_up_to_date_empty_and_error() {
845 assert_eq!(
846 compress("git pull", "Already up to date.\n"),
847 "Already up to date."
848 );
849 assert_eq!(compress("git pull", ""), "");
850 let raw = "CONFLICT (content): Merge conflict in README.md\nAutomatic merge failed; fix conflicts and then commit the result.\n";
851 let compressed = compress("git pull", raw);
852 assert!(compressed.contains("CONFLICT (content): Merge conflict in README.md"));
853 assert!(compressed.contains("Automatic merge failed"));
854 }
855 #[test]
856 fn test_fetch_success_empty_and_error() {
857 let raw = "remote: Enumerating objects: 5, done.\nremote: Counting objects: 100% (5/5), done.\nFrom github.com:example/repo\n * [new branch] feature/git-compress -> origin/feature/git-compress\n abc1234..def5678 main -> origin/main\n";
858 let compressed = compress("git fetch --all", raw);
859 assert_eq!(
860 compressed,
861 "From github.com:example/repo\n * [new branch] feature/git-compress -> origin/feature/git-compress\n abc1234..def5678 main -> origin/main"
862 );
863 assert_eq!(compress("git fetch", " \n"), "git fetch: ok");
864 let error =
865 "fatal: unable to access 'https://example.invalid/repo.git/': Could not resolve host\n";
866 assert_eq!(
867 compress("git fetch", error),
868 "fatal: unable to access 'https://example.invalid/repo.git/': Could not resolve host"
869 );
870 }
871 #[test]
872 fn test_stash_push_pop_list_empty_and_error() {
873 let push = "Saved working directory and index state WIP on main: 1a2b3c4 add tests\nHEAD is now at 1a2b3c4 add tests\n";
874 assert_eq!(
875 compress("git stash push", push),
876 "Saved working directory and index state WIP on main: 1a2b3c4 add tests"
877 );
878 let pop = "On branch main\nChanges not staged for commit:\n (use \"git add <file>...\" to update what will be committed)\n\tmodified: README.md\nDropped refs/stash@{0} (abc123456789)\n";
879 let compressed_pop = compress("git stash pop", pop);
880 assert!(compressed_pop.contains("On branch main"));
881 assert!(compressed_pop.contains("Dropped refs/stash@{0}"));
882 let list = "stash@{0}: WIP on main: 1111111 first\nstash@{1}: On feature: second\n";
883 assert_eq!(compress("git stash list", list), list.trim_end());
884 assert_eq!(compress("git stash", ""), "");
885 let error = "error: Your local changes to the following files would be overwritten by merge:\n\tREADME.md\n";
886 let compressed_error = compress("git stash apply", error);
887 assert!(compressed_error.contains("error: Your local changes"));
888 assert!(compressed_error.contains("README.md"));
889 }
890
891 #[test]
892 fn test_log_merge_commit_keeps_parents() {
893 let raw = "commit cccccccccccccccccccccccccccccccccccc\nMerge: dddddddddddddddddddddddddddddddddddd eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee\nAuthor: Merger <merge@example.com>\nDate: Mon Jan 01 12:00:00 2024 +0000\n\n Merge branch 'feature'\n";
894 let compressed = compress("git log", raw);
895 assert!(compressed.contains("Merge: dddddddddddd eeeeeeeeeeee"));
896 assert!(compressed.contains("Merge branch 'feature'"));
897 }
898
899 #[test]
900 fn test_log_format_collapse_short_log() {
901 let raw = "commit aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\nAuthor: Alice Example <alice@example.com>\nDate: Thu Jun 18 17:39:12 2026 +0200\n\n first subject\n detail one\n\ncommit bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\nAuthor: Bob Example <bob@example.com>\nDate: Wed Jun 17 10:00:00 2026 +0000\n\n second subject\n";
902 let compressed = compress("git log", raw);
903 assert!(!compressed.contains("commit "));
904 assert!(!compressed.contains("Author:"));
905 assert!(!compressed.contains("Date:"));
906 assert!(compressed.contains("aaaaaaaaaaaa first subject"));
907 assert!(compressed.contains("<Alice Example alice@example.com>"));
908 assert!(compressed.contains("2026-06-18 17:39:12"));
909 assert!(compressed.contains("detail one"));
910 assert!(compressed.contains("bbbbbbbbbbbb second subject"));
911 assert!(!compressed.contains(" ago"));
912 assert!(compressed.len() < raw.len());
913 }
914
915 #[test]
916 fn test_log_compress_is_deterministic() {
917 let raw = "commit 1111111111111111111111111111111111111111\nAuthor: x <x@y.com>\nDate: Thu Jun 18 17:39:12 2026 +0200\n\n subject\n body\n";
918 let a = compress_log(raw);
919 let b = compress_log(raw);
920 assert_eq!(a, b);
921 assert!(a.contains("111111111111"));
922 assert!(a.contains("2026-06-18 17:39:12"));
923 }
924
925 #[test]
926 fn test_log_oneline_abbreviates_hash_keeps_all_lines() {
927 let raw = "e4e8f7e1234567890abcdef1234567890abcdef (HEAD -> main) chore\n9c4aa18abcdef1234567890abcdef1234567890 feat\n";
928 let compressed = compress("git log --oneline", raw);
929 assert!(compressed.contains("e4e8f7e12345"));
930 assert!(compressed.contains("(HEAD -> main) chore"));
931 assert!(compressed.contains("9c4aa18abcde"));
932 assert!(compressed.contains("feat"));
933 assert!(!compressed.contains("... more commits"));
934 }
935
936 #[test]
937 fn test_log_deep_needle_survives_without_drop_line() {
938 let raw = include_str!("../../tests/fixtures/git_log_deep_needle.txt");
939 let compressed = compress("git log", raw);
940 assert!(compressed.contains("feedfacefeed"));
941 assert!(compressed.contains("NEEDLE_GIT_auth_bypass"));
942 assert!(compressed.contains("UNIQUE_BODY_MARKER_needle_xyz"));
943 assert!(!compressed.contains("... more commits"));
944 assert!(compressed.len() < raw.len());
945 }
946
947 #[test]
948 fn git_subcommand_returns_none_for_pipe_before_subcommand() {
949 assert_eq!(git_subcommand("git --no-pager | grep log"), None);
950 }
951
952 #[test]
953 fn git_subcommand_returns_subcommand_when_before_pipe() {
954 assert_eq!(git_subcommand("git log | grep fix").as_deref(), Some("log"));
955 }
956
957 #[test]
958 fn git_subcommand_returns_none_for_redirect_before_subcommand() {
959 assert_eq!(git_subcommand("git --no-pager > out.log"), None);
960 }
961
962 #[test]
963 fn git_subcommand_unaffected_without_metacharacters() {
964 assert_eq!(git_subcommand("git log --oneline").as_deref(), Some("log"));
965 }
966}