1use std::collections::HashSet;
2
3use crate::compress::generic::{dedup_consecutive, middle_truncate, GenericCompressor};
4use crate::compress::Compressor;
5
6const STATUS_SHORT_LIMIT: usize = 1024;
7const STATUS_KEEP_PER_SECTION: usize = 10;
8const DIFF_MAX_FILES: usize = 5;
9const DIFF_MAX_HUNKS: usize = 20;
10const HUNK_KEEP_LINES: usize = 30;
11const LOG_KEEP_COMMITS: usize = 20;
12const BLAME_KEEP_LINES: usize = 50;
13const GIT_WRITE_KEEP_LINES: usize = 50;
14const GIT_ADD_KEEP_PATHS: usize = 5;
15const GIT_STASH_STATUS_KEEP_LINES: usize = 20;
16
17pub struct GitCompressor;
18
19impl Compressor for GitCompressor {
20 fn matches(&self, command: &str) -> bool {
21 command_head(command).is_some_and(|head| head == "git")
22 }
23
24 fn compress(&self, command: &str, output: &str) -> String {
25 match git_subcommand(command).as_deref() {
26 Some("add") => compress_add(output),
27 Some("status") => compress_status(output),
28 Some("diff") => compress_diff(output, false),
29 Some("log") => compress_log(output),
30 Some("show") => compress_diff(output, true),
31 Some("branch") => trim_trailing_lines(&dedup_consecutive(output)),
32 Some("blame") => compress_blame(output),
33 Some("commit") => compress_commit(output),
34 Some("push") => compress_push(output),
35 Some("pull") => compress_pull(output),
36 Some("fetch") => compress_fetch(output),
37 Some("stash") => compress_stash(command, output),
38 _ => GenericCompressor::compress_output(output),
39 }
40 }
41}
42
43fn command_head(command: &str) -> Option<&str> {
44 command.split_whitespace().next()
45}
46
47fn git_subcommand(command: &str) -> Option<String> {
48 let mut seen_git = false;
49 for token in command.split_whitespace() {
50 if !seen_git {
51 if token == "git" {
52 seen_git = true;
53 }
54 continue;
55 }
56 if token.starts_with('-') || token.contains('=') {
57 continue;
58 }
59 return Some(token.to_string());
60 }
61 None
62}
63
64fn git_subcommand_after(command: &str, subcommand: &str) -> Option<String> {
65 let mut seen_git = false;
66 let mut seen_subcommand = 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 !seen_subcommand {
75 if token.starts_with('-') || token.contains('=') {
76 continue;
77 }
78 seen_subcommand = token == subcommand;
79 continue;
80 }
81 if token.starts_with('-') || token.contains('=') {
82 continue;
83 }
84 return Some(token.to_string());
85 }
86 None
87}
88
89fn compress_add(output: &str) -> String {
90 if output.trim().is_empty() {
91 return "git: ok".to_string();
92 }
93 if looks_like_git_error(output) {
94 return trim_trailing_lines(output);
95 }
96 let lines: Vec<&str> = output
97 .lines()
98 .filter(|line| !line.trim().is_empty())
99 .collect();
100 if lines.is_empty() {
101 return "git: ok".to_string();
102 }
103 let mut result: Vec<String> = lines
104 .iter()
105 .take(GIT_ADD_KEEP_PATHS)
106 .map(|line| line.trim_end().to_string())
107 .collect();
108 if lines.len() > GIT_ADD_KEEP_PATHS {
109 result.push(format!(
110 "... ({} more files added)",
111 lines.len() - GIT_ADD_KEEP_PATHS
112 ));
113 }
114 cap_git_lines(result, "files added", GIT_WRITE_KEEP_LINES)
115}
116
117fn compress_commit(output: &str) -> String {
118 if output.trim().is_empty() {
119 return GenericCompressor::compress_output(output);
120 }
121 if looks_like_git_error(output) {
122 return trim_trailing_lines(output);
123 }
124 if let Some(line) = output
125 .lines()
126 .find(|line| line.contains("nothing to commit"))
127 {
128 return line.trim_end().to_string();
129 }
130 let subject = output.lines().find(|line| looks_like_commit_subject(line));
131 let summary = output.lines().find(|line| looks_like_commit_summary(line));
132 match (subject, summary) {
133 (Some(subject), Some(summary)) => {
134 trim_trailing_lines(&format!("{}\n{}", subject.trim_end(), summary.trim()))
135 }
136 (Some(subject), None) => subject.trim_end().to_string(),
137 _ => GenericCompressor::compress_output(output),
138 }
139}
140
141fn compress_push(output: &str) -> String {
142 if output.trim().is_empty() {
143 return GenericCompressor::compress_output(output);
144 }
145 if looks_like_git_error(output) {
146 return trim_trailing_lines(output);
147 }
148 if let Some(line) = output
149 .lines()
150 .find(|line| line.trim() == "Everything up-to-date")
151 {
152 return line.trim_end().to_string();
153 }
154 let result: Vec<String> = output
155 .lines()
156 .filter(|line| is_remote_destination(line) || is_ref_update_line(line))
157 .map(|line| line.trim_end().to_string())
158 .collect();
159 if result.is_empty() {
160 return GenericCompressor::compress_output(output);
161 }
162 cap_git_lines(result, "push lines", GIT_WRITE_KEEP_LINES)
163}
164
165fn compress_pull(output: &str) -> String {
166 if output.trim().is_empty() {
167 return GenericCompressor::compress_output(output);
168 }
169 if looks_like_git_error(output) {
170 return trim_trailing_lines(output);
171 }
172 if let Some(line) = output
173 .lines()
174 .find(|line| line.trim() == "Already up to date.")
175 {
176 return line.trim_end().to_string();
177 }
178 let result: Vec<String> = output
179 .lines()
180 .filter(|line| {
181 looks_like_updating_line(line)
182 || looks_like_pull_marker(line)
183 || looks_like_commit_summary(line)
184 })
185 .map(|line| line.trim_end().to_string())
186 .collect();
187 if result.is_empty() {
188 return GenericCompressor::compress_output(output);
189 }
190 cap_git_lines(result, "pull lines", GIT_WRITE_KEEP_LINES)
191}
192
193fn compress_fetch(output: &str) -> String {
194 if output.trim().is_empty() {
195 return "git fetch: ok".to_string();
196 }
197 if looks_like_git_error(output) {
198 return trim_trailing_lines(output);
199 }
200 let result: Vec<String> = output
201 .lines()
202 .filter(|line| is_fetch_from_line(line) || is_ref_update_line(line))
203 .map(|line| line.trim_end().to_string())
204 .collect();
205 if result.is_empty() {
206 return GenericCompressor::compress_output(output);
207 }
208 cap_git_lines(result, "fetch lines", GIT_WRITE_KEEP_LINES)
209}
210
211fn compress_stash(command: &str, output: &str) -> String {
212 if output.trim().is_empty() {
213 return GenericCompressor::compress_output(output);
214 }
215 if looks_like_git_error(output) {
216 return trim_trailing_lines(output);
217 }
218 match git_subcommand_after(command, "stash").as_deref() {
219 None | Some("push") | Some("save") => output
220 .lines()
221 .find(|line| line.starts_with("Saved working directory and index state"))
222 .map(|line| line.trim_end().to_string())
223 .unwrap_or_else(|| GenericCompressor::compress_output(output)),
224 Some("pop" | "apply") => cap_git_lines(
225 output
226 .lines()
227 .map(|line| line.trim_end().to_string())
228 .collect(),
229 "stash status lines",
230 GIT_STASH_STATUS_KEEP_LINES,
231 ),
232 Some("list") => trim_trailing_lines(output),
233 _ => GenericCompressor::compress_output(output),
234 }
235}
236
237fn looks_like_git_error(output: &str) -> bool {
238 output.lines().any(|line| {
239 let trimmed = line.trim_start();
240 trimmed.starts_with("error:")
241 || trimmed.starts_with("fatal:")
242 || trimmed.starts_with("CONFLICT ")
243 || trimmed.starts_with("Automatic merge failed")
244 || trimmed.starts_with("! [rejected]")
245 || trimmed.starts_with("! [remote rejected]")
246 || trimmed.starts_with("failed to push")
247 })
248}
249
250fn looks_like_commit_subject(line: &str) -> bool {
251 let trimmed = line.trim_start();
252 trimmed.starts_with('[') && trimmed.contains("] ")
253}
254
255fn looks_like_commit_summary(line: &str) -> bool {
256 let trimmed = line.trim();
257 (trimmed.contains("file changed") || trimmed.contains("files changed"))
258 && (trimmed.contains("insertion")
259 || trimmed.contains("deletion")
260 || trimmed.contains("changed"))
261}
262
263fn is_remote_destination(line: &str) -> bool {
264 line.starts_with("To ")
265}
266
267fn is_fetch_from_line(line: &str) -> bool {
268 line.starts_with("From ")
269}
270
271fn is_ref_update_line(line: &str) -> bool {
272 let trimmed = line.trim_start();
273 trimmed.contains(" -> ")
274 && (trimmed.starts_with('*')
275 || trimmed.starts_with('+')
276 || trimmed.starts_with('-')
277 || trimmed.starts_with('=')
278 || trimmed.starts_with('!')
279 || trimmed.split_whitespace().next().is_some_and(is_hash_range))
280}
281
282fn is_hash_range(token: &str) -> bool {
283 token
284 .split_once("..")
285 .is_some_and(|(left, right)| is_short_hash(left) && is_short_hash(right))
286}
287
288fn is_short_hash(token: &str) -> bool {
289 (4..=40).contains(&token.len()) && token.bytes().all(|byte| byte.is_ascii_hexdigit())
290}
291
292fn looks_like_updating_line(line: &str) -> bool {
293 line.trim_start().starts_with("Updating ")
294}
295
296fn looks_like_pull_marker(line: &str) -> bool {
297 let trimmed = line.trim();
298 trimmed == "Fast-forward" || trimmed.starts_with("Merge made by ")
299}
300
301fn cap_git_lines(mut lines: Vec<String>, summary_name: &str, keep_lines: usize) -> String {
302 if lines.len() > keep_lines {
303 let omitted = lines.len() - keep_lines;
304 lines.truncate(keep_lines);
305 lines.push(format!("... ({} more {})", omitted, summary_name));
306 }
307 trim_trailing_lines(&lines.join("\n"))
308}
309
310fn compress_status(output: &str) -> String {
311 if output.len() <= STATUS_SHORT_LIMIT {
312 return trim_trailing_lines(output);
313 }
314
315 let mut result = Vec::new();
316 let mut section_entries = Vec::new();
317 let mut in_section = false;
318
319 for line in output.lines() {
320 if is_status_section_header(line) {
321 flush_status_entries(&mut result, &mut section_entries);
322 result.push(line.to_string());
323 in_section = true;
324 } else if in_section && is_status_instructional(line) {
325 result.push(line.to_string());
331 } else if in_section && is_status_entry(line) {
332 section_entries.push(line.to_string());
333 } else {
334 flush_status_entries(&mut result, &mut section_entries);
335 result.push(line.to_string());
336 in_section = false;
337 }
338 }
339 flush_status_entries(&mut result, &mut section_entries);
340
341 trim_trailing_lines(&result.join("\n"))
342}
343
344fn is_status_section_header(line: &str) -> bool {
345 matches!(
346 line.trim_end_matches(':'),
347 "Changes to be committed"
348 | "Changes not staged for commit"
349 | "Untracked files"
350 | "Unmerged paths"
351 )
352}
353
354fn is_status_instructional(line: &str) -> bool {
360 let trimmed = line.trim_start();
361 trimmed.starts_with('(') || trimmed.starts_with("use ")
362}
363
364fn is_status_entry(line: &str) -> bool {
365 let trimmed = line.trim_start();
366 trimmed.starts_with("modified:")
367 || trimmed.starts_with("new file:")
368 || trimmed.starts_with("deleted:")
369 || trimmed.starts_with("renamed:")
370 || trimmed.starts_with("copied:")
371 || trimmed.starts_with("both modified:")
372 || trimmed.starts_with("both added:")
373 || trimmed.starts_with("deleted by us:")
374 || trimmed.starts_with("deleted by them:")
375 || (!trimmed.is_empty()
376 && !trimmed.starts_with('(')
377 && !trimmed.starts_with("use ")
378 && !trimmed.starts_with("no changes"))
379}
380
381fn flush_status_entries(result: &mut Vec<String>, entries: &mut Vec<String>) {
382 if entries.is_empty() {
383 return;
384 }
385
386 let keep = entries.len().min(STATUS_KEEP_PER_SECTION);
387 result.extend(entries.iter().take(keep).cloned());
388 if entries.len() > keep {
389 result.push(format!("... and {} more", entries.len() - keep));
390 }
391 entries.clear();
392}
393
394fn compress_diff(output: &str, keep_commit_header: bool) -> String {
395 let files = split_diff_files(output, keep_commit_header);
396 let total_hunks: usize = files.iter().map(|file| count_hunks(&file.lines)).sum();
397
398 if files.is_empty() || total_hunks <= 2 && output.len() <= 5 * 1024 {
399 return trim_trailing_lines(output);
400 }
401
402 let max_files = if total_hunks > DIFF_MAX_HUNKS {
403 DIFF_MAX_FILES
404 } else {
405 usize::MAX
406 };
407
408 let mut result = Vec::new();
409 let mut emitted_files = 0usize;
410
411 for file in &files {
412 if file.is_diff && emitted_files >= max_files {
413 continue;
414 }
415 result.extend(compress_diff_file(&file.lines));
416 emitted_files += usize::from(file.is_diff);
417 }
418
419 let changed_files = files.iter().filter(|file| file.is_diff).count();
420 if changed_files > emitted_files {
421 result.push(format!(
422 "... and {} more files changed",
423 changed_files - emitted_files
424 ));
425 }
426
427 middle_truncate(
428 &trim_trailing_lines(&result.join("\n")),
429 16 * 1024,
430 7 * 1024,
431 7 * 1024,
432 )
433}
434
435struct DiffFile {
436 lines: Vec<String>,
437 is_diff: bool,
438}
439
440fn split_diff_files(output: &str, keep_commit_header: bool) -> Vec<DiffFile> {
441 let mut files = Vec::new();
442 let mut current = Vec::new();
443 let mut current_is_diff = false;
444
445 for line in output.lines() {
446 if line.starts_with("diff --git ") {
447 if !current.is_empty() {
448 files.push(DiffFile {
449 lines: std::mem::take(&mut current),
450 is_diff: current_is_diff,
451 });
452 }
453 current_is_diff = true;
454 } else if !current_is_diff && !keep_commit_header && !line.starts_with("diff --git ") {
455 current_is_diff = true;
456 }
457 current.push(line.to_string());
458 }
459
460 if !current.is_empty() {
461 files.push(DiffFile {
462 lines: current,
463 is_diff: current_is_diff,
464 });
465 }
466
467 files
468}
469
470fn compress_diff_file(lines: &[String]) -> Vec<String> {
471 let mut result = Vec::new();
472 let mut index = 0usize;
473
474 while index < lines.len() {
475 let line = &lines[index];
476 if !line.starts_with("@@") {
477 result.push(line.clone());
478 index += 1;
479 continue;
480 }
481
482 let hunk_start = index;
483 index += 1;
484 while index < lines.len() && !lines[index].starts_with("@@") {
485 index += 1;
486 }
487 let hunk = &lines[hunk_start..index];
488 append_hunk(&mut result, hunk);
489 }
490
491 result
492}
493
494fn append_hunk(result: &mut Vec<String>, hunk: &[String]) {
495 if hunk.len() <= HUNK_KEEP_LINES + 1 {
496 result.extend(hunk.iter().cloned());
497 return;
498 }
499
500 result.extend(hunk.iter().take(HUNK_KEEP_LINES + 1).cloned());
501 let remaining = &hunk[HUNK_KEEP_LINES + 1..];
502 let added = remaining
503 .iter()
504 .filter(|line| line.starts_with('+'))
505 .count();
506 let removed = remaining
507 .iter()
508 .filter(|line| line.starts_with('-'))
509 .count();
510 result.push(format!(
511 "... +{} -{} in {} more lines",
512 added,
513 removed,
514 remaining.len()
515 ));
516}
517
518fn count_hunks(lines: &[String]) -> usize {
519 lines.iter().filter(|line| line.starts_with("@@")).count()
520}
521
522fn compress_log(output: &str) -> String {
523 let mut commits = 0usize;
524 let mut omitted = 0usize;
525 let mut result = Vec::new();
526 let mut seen_authors = HashSet::new();
527
528 for line in output.lines() {
529 let is_commit = line.starts_with("commit ") || looks_like_oneline_commit(line);
530 if is_commit {
531 commits += 1;
532 if commits > LOG_KEEP_COMMITS {
533 omitted += 1;
534 continue;
535 }
536 }
537
538 if commits > LOG_KEEP_COMMITS {
539 continue;
540 }
541
542 if line.starts_with("Author: ") && !seen_authors.insert(line.to_string()) {
543 continue;
544 }
545
546 result.push(line.to_string());
547 }
548
549 if omitted > 0 {
550 result.push(format!("... {} more commits", omitted));
551 }
552
553 trim_trailing_lines(&result.join("\n"))
554}
555
556fn looks_like_oneline_commit(line: &str) -> bool {
557 let Some((hash, _message)) = line.split_once(' ') else {
558 return false;
559 };
560 (7..=40).contains(&hash.len()) && hash.bytes().all(|byte| byte.is_ascii_hexdigit())
561}
562
563fn compress_blame(output: &str) -> String {
564 let total = output.lines().count();
565 if total <= BLAME_KEEP_LINES {
566 return trim_trailing_lines(output);
567 }
568
569 let mut result: Vec<String> = output
570 .lines()
571 .take(BLAME_KEEP_LINES)
572 .map(ToString::to_string)
573 .collect();
574 result.push(format!("... {} more blame lines", total - BLAME_KEEP_LINES));
575 result.join("\n")
576}
577
578fn trim_trailing_lines(input: &str) -> String {
579 input
580 .lines()
581 .map(str::trim_end)
582 .collect::<Vec<_>>()
583 .join("\n")
584}
585
586#[cfg(test)]
587mod tests {
588 use super::*;
589 use crate::compress::Compressor;
590 #[test]
591 fn test_add_empty_output_ok() {
592 let compressed = GitCompressor.compress("git add .", "");
593 assert_eq!(compressed, "git: ok");
594 }
595 #[test]
596 fn test_add_verbose_many_files() {
597 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";
598 let compressed = GitCompressor.compress("git add --verbose .", raw);
599 assert!(compressed.contains("add 'src/a.rs'"));
600 assert!(compressed.contains("add 'src/e.rs'"));
601 assert!(compressed.contains("... (2 more files added)"));
602 assert!(!compressed.contains("add 'src/g.rs'"));
603 }
604 #[test]
605 fn test_add_error_passthrough() {
606 let raw = "fatal: pathspec 'missing.rs' did not match any files\n";
607 let compressed = GitCompressor.compress("git add missing.rs", raw);
608 assert_eq!(
609 compressed,
610 "fatal: pathspec 'missing.rs' did not match any files"
611 );
612 }
613 #[test]
614 fn test_commit_success_extracts_subject_and_summary() {
615 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";
616 let compressed = GitCompressor.compress("git commit -m 'add git write compression'", raw);
617 assert_eq!(
618 compressed,
619 "[main 1a2b3c4] add git write compression\n3 files changed, 42 insertions(+), 7 deletions(-)"
620 );
621 }
622 #[test]
623 fn test_commit_nothing_to_commit_verbatim() {
624 let raw = "On branch main\nnothing to commit, working tree clean\n";
625 let compressed = GitCompressor.compress("git commit -m noop", raw);
626 assert_eq!(compressed, "nothing to commit, working tree clean");
627 }
628 #[test]
629 fn test_commit_error_passthrough() {
630 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";
631 let compressed = GitCompressor.compress("git commit", raw);
632 assert!(compressed.contains("error: Committing is not possible"));
633 assert!(compressed.contains("fatal: Exiting because of an unresolved conflict."));
634 }
635 #[test]
636 fn test_push_success_drops_progress_keeps_remote_and_ref() {
637 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";
638 let compressed = GitCompressor.compress("git push", raw);
639 assert_eq!(
640 compressed,
641 "To github.com:example/repo.git\n 9d8c7b6..1a2b3c4 main -> main"
642 );
643 }
644 #[test]
645 fn test_push_everything_up_to_date_and_empty() {
646 assert_eq!(
647 GitCompressor.compress("git push", "Everything up-to-date\n"),
648 "Everything up-to-date"
649 );
650 assert_eq!(GitCompressor.compress("git push", ""), "");
651 }
652 #[test]
653 fn test_push_error_passthrough() {
654 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";
655 let compressed = GitCompressor.compress("git push", raw);
656 assert!(compressed.contains("! [rejected] main -> main (fetch first)"));
657 assert!(compressed.contains("error: failed to push some refs"));
658 }
659 #[test]
660 fn test_pull_fast_forward_keeps_summary() {
661 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";
662 let compressed = GitCompressor.compress("git pull --ff-only", raw);
663 assert_eq!(
664 compressed,
665 "Updating 1111111..2222222\nFast-forward\n 1 file changed, 9 insertions(+), 3 deletions(-)"
666 );
667 }
668 #[test]
669 fn test_pull_already_up_to_date_empty_and_error() {
670 assert_eq!(
671 GitCompressor.compress("git pull", "Already up to date.\n"),
672 "Already up to date."
673 );
674 assert_eq!(GitCompressor.compress("git pull", ""), "");
675 let raw = "CONFLICT (content): Merge conflict in README.md\nAutomatic merge failed; fix conflicts and then commit the result.\n";
676 let compressed = GitCompressor.compress("git pull", raw);
677 assert!(compressed.contains("CONFLICT (content): Merge conflict in README.md"));
678 assert!(compressed.contains("Automatic merge failed"));
679 }
680 #[test]
681 fn test_fetch_success_empty_and_error() {
682 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";
683 let compressed = GitCompressor.compress("git fetch --all", raw);
684 assert_eq!(
685 compressed,
686 "From github.com:example/repo\n * [new branch] feature/git-compress -> origin/feature/git-compress\n abc1234..def5678 main -> origin/main"
687 );
688 assert_eq!(
689 GitCompressor.compress("git fetch", " \n"),
690 "git fetch: ok"
691 );
692 let error =
693 "fatal: unable to access 'https://example.invalid/repo.git/': Could not resolve host\n";
694 assert_eq!(
695 GitCompressor.compress("git fetch", error),
696 "fatal: unable to access 'https://example.invalid/repo.git/': Could not resolve host"
697 );
698 }
699 #[test]
700 fn test_stash_push_pop_list_empty_and_error() {
701 let push = "Saved working directory and index state WIP on main: 1a2b3c4 add tests\nHEAD is now at 1a2b3c4 add tests\n";
702 assert_eq!(
703 GitCompressor.compress("git stash push", push),
704 "Saved working directory and index state WIP on main: 1a2b3c4 add tests"
705 );
706 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";
707 let compressed_pop = GitCompressor.compress("git stash pop", pop);
708 assert!(compressed_pop.contains("On branch main"));
709 assert!(compressed_pop.contains("Dropped refs/stash@{0}"));
710 let list = "stash@{0}: WIP on main: 1111111 first\nstash@{1}: On feature: second\n";
711 assert_eq!(
712 GitCompressor.compress("git stash list", list),
713 list.trim_end()
714 );
715 assert_eq!(GitCompressor.compress("git stash", ""), "");
716 let error = "error: Your local changes to the following files would be overwritten by merge:\n\tREADME.md\n";
717 let compressed_error = GitCompressor.compress("git stash apply", error);
718 assert!(compressed_error.contains("error: Your local changes"));
719 assert!(compressed_error.contains("README.md"));
720 }
721}