1mod commit;
4mod diff;
5mod log;
6mod parser;
7mod status;
8
9use parser::extract_git_subcommand;
10
11macro_rules! static_regex {
12 ($pattern:expr_2021) => {{
13 static RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
14 RE.get_or_init(|| {
15 regex::Regex::new($pattern).expect(concat!("BUG: invalid static regex: ", $pattern))
16 })
17 }};
18}
19
20fn status_branch_re() -> &'static regex::Regex {
21 static_regex!(r"On branch (\S+)")
22}
23fn ahead_re() -> &'static regex::Regex {
24 static_regex!(r"ahead of .+ by (\d+) commit")
25}
26fn commit_hash_re() -> &'static regex::Regex {
27 static_regex!(r"\[([\w/.:-]+)\s+([a-f0-9]+)\]")
28}
29fn insertions_re() -> &'static regex::Regex {
30 static_regex!(r"(\d+) insertions?\(\+\)")
31}
32fn deletions_re() -> &'static regex::Regex {
33 static_regex!(r"(\d+) deletions?\(-\)")
34}
35fn files_changed_re() -> &'static regex::Regex {
36 static_regex!(r"(\d+) files? changed")
37}
38fn shortstat_line_re() -> &'static regex::Regex {
41 static_regex!(
42 r"^ \d+ files? changed(?:, (\d+) insertions?\(\+\))?(?:, (\d+) deletions?\(-\))?\s*$"
43 )
44}
45fn numstat_row_re() -> &'static regex::Regex {
47 static_regex!(r"^(\d+|-)\t(\d+|-)\t")
48}
49fn clone_objects_re() -> &'static regex::Regex {
50 static_regex!(r"Receiving objects:.*?(\d+)")
51}
52fn stash_re() -> &'static regex::Regex {
53 static_regex!(r"stash@\{(\d+)\}:\s*(.+)")
54}
55
56fn is_diff_or_stat_line(line: &str) -> bool {
57 let t = line.trim();
58 t.starts_with("diff --git")
59 || t.starts_with("index ")
60 || t.starts_with("--- a/")
61 || t.starts_with("+++ b/")
62 || t.starts_with("@@ ")
63 || t.starts_with("Binary files")
64 || t.starts_with("new file mode")
65 || t.starts_with("deleted file mode")
66 || t.starts_with("old mode")
67 || t.starts_with("new mode")
68 || t.starts_with("similarity index")
69 || t.starts_with("rename from")
70 || t.starts_with("rename to")
71 || t.starts_with("copy from")
72 || t.starts_with("copy to")
73 || (t.starts_with('+') && !t.starts_with("+++"))
74 || (t.starts_with('-') && !t.starts_with("---"))
75 || (t.contains(" | ") && t.chars().any(|c| c == '+' || c == '-'))
76}
77
78fn extract_change_stats(output: &str) -> String {
79 let files = files_changed_re()
80 .captures(output)
81 .and_then(|c| c[1].parse::<u32>().ok())
82 .unwrap_or(0);
83 let ins = insertions_re()
84 .captures(output)
85 .and_then(|c| c[1].parse::<u32>().ok())
86 .unwrap_or(0);
87 let del = deletions_re()
88 .captures(output)
89 .and_then(|c| c[1].parse::<u32>().ok())
90 .unwrap_or(0);
91
92 if files > 0 || ins > 0 || del > 0 {
93 format!("{files} files, +{ins}/-{del}")
94 } else {
95 String::new()
96 }
97}
98
99fn compact_lines(text: &str, max: usize) -> String {
100 let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
101 if lines.len() <= max {
102 return lines.join("\n");
103 }
104 format!(
105 "{}\n... ({} more lines)",
106 lines[..max].join("\n"),
107 lines.len() - max
108 )
109}
110
111fn show_requests_blob(command: &str) -> bool {
117 command.split_whitespace().any(|arg| {
118 let arg = arg.trim_matches(['\'', '"']);
119 !arg.starts_with('-')
120 && arg
121 .split_once(':')
122 .is_some_and(|(revision, path)| !revision.is_empty() && !path.is_empty())
123 })
124}
125
126pub fn compress(command: &str, output: &str) -> Option<String> {
127 let sub = extract_git_subcommand(command)?;
128 match sub {
129 "status" => Some(status::compress_status(output)),
130 "log" => Some(log::compress_log(command, output)),
131 "diff" => Some(diff::compress_diff(output)),
132 "add" => Some(commit::compress_add(output)),
133 "commit" => Some(commit::compress_commit(output)),
134 "push" => Some(commit::compress_push(output)),
135 "pull" => Some(commit::compress_pull(output)),
136 "fetch" => Some(commit::compress_fetch(output)),
137 "clone" => Some(commit::compress_clone(output)),
138 "branch" => Some(commit::compress_branch(output)),
139 "checkout" | "switch" => Some(commit::compress_checkout(output)),
140 "merge" => Some(commit::compress_merge(output)),
141 "stash" => {
142 if command.contains("stash show") || command.contains("show stash") {
143 return Some(commit::compress_show(output));
144 }
145 Some(commit::compress_stash(output))
146 }
147 "tag" => Some(commit::compress_tag(output)),
148 "reset" => Some(commit::compress_reset(output)),
149 "remote" => {
150 if command.contains("remote add") {
151 return Some(commit::compress_add(output));
152 }
153 Some(commit::compress_remote(output))
154 }
155 "blame" => Some(commit::compress_blame(output)),
156 "cherry-pick" => Some(commit::compress_cherry_pick(output)),
157 "show" if show_requests_blob(command) => Some(output.to_string()),
158 "show" => Some(commit::compress_show(output)),
159 "rebase" => Some(commit::compress_rebase(output)),
160 "submodule" => Some(commit::compress_submodule(output)),
161 "worktree" => Some(commit::compress_worktree(output)),
162 "bisect" => Some(commit::compress_bisect(output)),
163 _ => None,
164 }
165}
166
167#[cfg(test)]
168mod tests {
169 use super::*;
170
171 #[test]
172 fn git_status_compresses() {
173 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";
174 let result = compress("git status", output).unwrap();
175 assert!(result.contains("main"), "should contain branch name");
176 assert!(result.contains("main.rs"), "should list modified files");
177 assert!(result.len() < output.len(), "should be shorter than input");
178 }
179
180 #[test]
181 fn git_add_compresses_to_ok() {
182 let result = compress("git add .", "").unwrap();
183 assert!(result.contains("ok"), "git add should compress to 'ok'");
184 }
185
186 #[test]
191 fn git_add_does_not_label_line_count_as_files() {
192 let output = "[main abc1234] fix: x\n 1 file changed, 1 insertion(+)\nhook line\nanother\nmore\nlines\nseven\n";
193 let result = compress("git add . && git commit -m 'x'", output).unwrap();
194 assert!(
195 !result.contains("files)"),
196 "line count must not be presented as a file count: {result}"
197 );
198 }
199
200 #[test]
201 fn git_add_verbose_counts_real_added_files() {
202 let output = "add 'a.rs'\nadd 'b.rs'\nadd 'c.rs'\nadd 'd.rs'\n";
203 let result = compress("git add -v .", output).unwrap();
204 assert!(
205 result.contains("+4 files"),
206 "verbose add lines are the real file count: {result}"
207 );
208 }
209
210 #[test]
211 fn git_commit_extracts_hash() {
212 let output =
213 "[main abc1234] fix: resolve bug\n 2 files changed, 10 insertions(+), 3 deletions(-)\n";
214 let result = compress("git commit -m 'fix'", output).unwrap();
215 assert!(result.contains("abc1234"), "should extract commit hash");
216 }
217
218 #[test]
219 fn git_push_compresses() {
220 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";
221 let result = compress("git push", output).unwrap();
222 assert!(result.len() < output.len(), "should compress push output");
223 }
224
225 #[test]
226 fn git_log_compresses() {
227 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";
228 let result = compress("git log", output).unwrap();
229 assert!(result.len() < output.len(), "should compress log output");
230 }
231
232 #[test]
233 fn git_log_oneline_truncates_long() {
234 let lines: Vec<String> = (0..150)
235 .map(|i| format!("abc{i:04} feat: commit number {i}"))
236 .collect();
237 let output = lines.join("\n");
238 let result = compress("git log --oneline", &output).unwrap();
239 assert!(
240 result.contains("... (50 more commits"),
241 "should truncate to 100 entries"
242 );
243 assert!(
244 result.lines().count() <= 102,
245 "should have at most 101 lines (100 + summary)"
246 );
247 }
248
249 #[test]
250 fn git_log_oneline_short_unchanged() {
251 let output = "abc1234 feat: one\ndef5678 fix: two\nghi9012 docs: three";
252 let result = compress("git log --oneline", output).unwrap();
253 assert_eq!(result, output, "short oneline should pass through");
254 }
255
256 #[test]
257 fn git_log_standard_truncates_long() {
258 let mut output = String::new();
259 for i in 0..130 {
260 output.push_str(&format!(
261 "commit {i:07}abc1234\nAuthor: U <u@e.com>\nDate: Mon\n\n msg {i}\n\n"
262 ));
263 }
264 let result = compress("git log", &output).unwrap();
265 assert!(
266 result.contains("... (30 more commits"),
267 "should truncate standard log at 100"
268 );
269 }
270
271 #[test]
272 fn git_diff_compresses() {
273 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 }";
274 let result = compress("git diff", output).unwrap();
275 assert!(result.contains("main.rs"), "should reference changed file");
276 }
277
278 #[test]
279 fn git_diff_stat_preserves_all_files() {
280 let output = " website/src/i18n/locales/en.json | 3 +-\n website/src/page-templates/DocsToolsCorePage.astro | 37 ++------\n .../page-templates/DocsToolsIntelligencePage.astro | 105 +++++----------------\n .../src/page-templates/DocsToolsMemoryPage.astro | 29 ++----\n .../src/page-templates/DocsToolsSessionPage.astro | 43 ++-------\n 5 files changed, 45 insertions(+), 172 deletions(-)\n";
281 let result = compress("git diff --stat", output).unwrap();
282 assert!(
283 result.contains("en.json"),
284 "must keep en.json, got: {result}"
285 );
286 assert!(
287 result.contains("DocsToolsCorePage"),
288 "must keep CorePage, got: {result}"
289 );
290 assert!(
291 result.contains("DocsToolsIntelligencePage"),
292 "must keep IntelligencePage, got: {result}"
293 );
294 assert!(
295 result.contains("DocsToolsMemoryPage"),
296 "must keep MemoryPage, got: {result}"
297 );
298 assert!(
299 result.contains("DocsToolsSessionPage"),
300 "must keep SessionPage, got: {result}"
301 );
302 assert!(
303 result.contains("5 files changed"),
304 "must keep summary line, got: {result}"
305 );
306 }
307
308 #[test]
309 fn git_diff_cached_stat_preserves_all_files() {
310 let output = " src/a.rs | 10 ++++------\n src/b.rs | 3 ++-\n src/c.rs | 7 +++----\n src/d.rs | 1 +\n 4 files changed, 9 insertions(+), 12 deletions(-)\n";
311 let result = compress("git diff --cached --stat", output).unwrap();
312 assert!(result.contains("a.rs"), "must keep a.rs, got: {result}");
313 assert!(result.contains("d.rs"), "must keep d.rs, got: {result}");
314 assert!(
315 result.contains("4 files changed"),
316 "must keep summary, got: {result}"
317 );
318 }
319
320 #[test]
321 fn git_diff_shortstat_preserved() {
322 let output = " 5 files changed, 45 insertions(+), 172 deletions(-)\n";
323 let result = compress("git diff --shortstat", output).unwrap();
324 assert!(
325 result.contains("5 files changed"),
326 "shortstat must pass through, got: {result}"
327 );
328 }
329
330 #[test]
331 fn git_push_preserves_pipeline_url() {
332 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";
333 let result = compress("git push", output).unwrap();
334 assert!(
335 result.contains("pipeline"),
336 "should preserve pipeline URL, got: {result}"
337 );
338 assert!(
339 result.contains("merge_request"),
340 "should preserve merge request URL"
341 );
342 assert!(result.contains("->"), "should contain ref update line");
343 }
344
345 #[test]
346 fn git_push_preserves_github_pr_url() {
347 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";
348 let result = compress("git push", output).unwrap();
349 assert!(
350 result.contains("pull/"),
351 "should preserve GitHub PR URL, got: {result}"
352 );
353 }
354
355 #[test]
356 fn git_commit_preserves_hook_output() {
357 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";
358 let result = compress("git commit -m 'fix'", output).unwrap();
359 assert!(
360 result.contains("ruff"),
361 "should preserve hook output, got: {result}"
362 );
363 assert!(
364 result.contains("abc1234"),
365 "should still extract commit hash"
366 );
367 }
368
369 #[test]
370 fn git_commit_no_hooks() {
371 let output =
372 "[main abc1234] fix: resolve bug\n 2 files changed, 10 insertions(+), 3 deletions(-)\n";
373 let result = compress("git commit -m 'fix'", output).unwrap();
374 assert!(result.contains("abc1234"), "should extract commit hash");
375 assert!(
376 !result.contains("hook"),
377 "should not mention hooks when none present"
378 );
379 }
380
381 #[test]
382 fn git_log_with_patch_keeps_hunks_for_few_commits() {
383 let output = "commit abc1234567890\nAuthor: User <user@email.com>\nDate: Mon Mar 25 10:00:00 2026 +0100\n\n feat: add feature\n\ndiff --git a/src/main.rs b/src/main.rs\nindex abc1234..def5678 100644\n--- a/src/main.rs\n+++ b/src/main.rs\n@@ -1,3 +1,4 @@\n fn main() {\n+ println!(\"hello\");\n let x = 1;\n }\n\ncommit def4567890abc\nAuthor: User <user@email.com>\nDate: Sun Mar 24 09:00:00 2026 +0100\n\n fix: resolve issue\n\ndiff --git a/src/lib.rs b/src/lib.rs\nindex 111..222 100644\n--- a/src/lib.rs\n+++ b/src/lib.rs\n@@ -1 +1,2 @@\n+pub fn helper() {}\n";
384 let result = compress("git log -p", output).unwrap();
385 assert!(
386 result.contains("println"),
387 "1-3 commits with -p should KEEP diff hunks, got: {result}"
388 );
389 assert!(result.contains("abc1234"), "should contain commit hash");
390 assert!(
391 result.contains("feat: add feature"),
392 "should contain commit message"
393 );
394 }
395
396 #[test]
397 fn git_log_with_patch_summarizes_many_commits() {
398 let mut output = String::new();
399 for i in 0..10 {
400 output.push_str(&format!(
401 "commit {i:07}abc1234\nAuthor: U <u@e.com>\nDate: Mon\n\n msg {i}\n\ndiff --git a/src/f{i}.rs b/src/f{i}.rs\nindex 111..222 100644\n--- a/src/f{i}.rs\n+++ b/src/f{i}.rs\n@@ -1 +1,2 @@\n+line {i}\n\n"
402 ));
403 }
404 let result = compress("git log -p", &output).unwrap();
405 assert!(
406 result.contains("msg 0"),
407 "newest commit message should be present"
408 );
409 assert!(
410 result.contains("+line 0"),
411 "newest commit should have hunks preserved"
412 );
413 assert!(
414 result.len() < output.len(),
415 "should be compressed ({} vs {})",
416 result.len(),
417 output.len()
418 );
419 }
420
421 #[test]
422 fn git_log_with_stat_filters_stat_content() {
423 let mut output = String::new();
424 for i in 0..5 {
425 output.push_str(&format!(
426 "commit {i:07}abc1234\nAuthor: U <u@e.com>\nDate: Mon\n\n msg {i}\n\n src/file{i}.rs | 10 ++++------\n 1 file changed, 4 insertions(+), 6 deletions(-)\n\n"
427 ));
428 }
429 let result = compress("git log --stat", &output).unwrap();
430 assert!(
431 result.len() < output.len() / 2,
432 "stat output should be compressed ({} vs {})",
433 result.len(),
434 output.len()
435 );
436 }
437
438 #[test]
439 fn git_commit_with_feature_branch() {
440 let output = "[feature/my-branch abc1234] feat: add new thing\n 3 files changed, 20 insertions(+), 5 deletions(-)\n";
441 let result = compress("git commit -m 'feat'", output).unwrap();
442 assert!(
443 result.contains("abc1234"),
444 "should extract hash from feature branch, got: {result}"
445 );
446 assert!(
447 result.contains("feature/my-branch"),
448 "should preserve branch name, got: {result}"
449 );
450 }
451
452 #[test]
453 fn git_commit_many_hooks_compressed() {
454 let mut output = String::new();
455 for i in 0..30 {
456 output.push_str(&format!("check-{i}..........passed\n"));
457 }
458 output.push_str("[main abc1234] fix: resolve bug\n 1 file changed, 1 insertion(+)\n");
459 let result = compress("git commit -m 'fix'", &output).unwrap();
460 assert!(result.contains("abc1234"), "should contain commit hash");
461 assert!(
462 result.contains("hooks passed"),
463 "should summarize passed hooks, got: {result}"
464 );
465 assert!(
466 result.len() < output.len() / 2,
467 "should compress verbose hook output ({} vs {})",
468 result.len(),
469 output.len()
470 );
471 }
472
473 #[test]
474 fn stash_push_preserves_short_message() {
475 let output = "Saved working directory and index state WIP on main: abc1234 fix stuff\n";
476 let result = compress("git stash", output).unwrap();
477 assert!(
478 result.contains("Saved working directory"),
479 "short stash messages must be preserved, got: {result}"
480 );
481 }
482
483 #[test]
484 fn stash_drop_preserves_short_message() {
485 let output = "Dropped refs/stash@{0} (abc123def456)\n";
486 let result = compress("git stash drop", output).unwrap();
487 assert!(
488 result.contains("Dropped"),
489 "short drop messages must be preserved, got: {result}"
490 );
491 }
492
493 #[test]
494 fn stash_list_short_preserved() {
495 let output = "stash@{0}: WIP on main: abc1234 fix\nstash@{1}: On feature: def5678 add\n";
496 let result = compress("git stash list", output).unwrap();
497 assert!(
498 result.contains("stash@{0}"),
499 "short stash list must be preserved, got: {result}"
500 );
501 }
502
503 #[test]
504 fn stash_list_long_reformats() {
505 let lines: Vec<String> = (0..10)
506 .map(|i| format!("stash@{{{i}}}: WIP on main: abc{i:04} commit {i}"))
507 .collect();
508 let output = lines.join("\n");
509 let result = compress("git stash list", &output).unwrap();
510 assert!(result.contains("@0:"), "should reformat @0, got: {result}");
511 assert!(result.contains("@9:"), "should reformat @9, got: {result}");
512 }
513
514 #[test]
515 fn stash_show_routes_to_show_compressor() {
516 let output = " src/main.rs | 10 +++++-----\n src/lib.rs | 3 ++-\n 2 files changed, 7 insertions(+), 6 deletions(-)\n";
517 let result = compress("git stash show", output).unwrap();
518 assert!(
519 result.contains("main.rs"),
520 "stash show should preserve file names, got: {result}"
521 );
522 }
523
524 #[test]
525 fn stash_show_patch_not_over_compressed() {
526 let lines: Vec<String> = (0..40)
527 .map(|i| format!("+line {i}: some content here"))
528 .collect();
529 let output = format!(
530 "diff --git a/file.rs b/file.rs\n--- a/file.rs\n+++ b/file.rs\n{}",
531 lines.join("\n")
532 );
533 let result = compress("git stash show -p", &output).unwrap();
534 let result_lines = result.lines().count();
535 assert!(
536 result_lines >= 10,
537 "stash show -p must not over-compress to 3 lines, got {result_lines} lines"
538 );
539 }
540
541 #[test]
542 fn show_stash_ref_routes_correctly() {
543 let output = "commit abc1234\nAuthor: User <u@e.com>\nDate: Mon Jan 1\n\n WIP on main\n\ndiff --git a/f.rs b/f.rs\n";
544 let result = compress("git show stash@{0}", output).unwrap();
545 assert!(
546 result.len() > 10,
547 "git show stash@{{0}} must not be over-compressed, got: {result}"
548 );
549 }
550
551 #[test]
552 fn show_source_blob_is_preserved_verbatim() {
553 let output = (0..40).fold(String::new(), |mut acc, i| {
554 use std::fmt::Write;
555 let _ = writeln!(acc, "pub const VALUE_{i}: usize = {i};");
556 acc
557 });
558
559 let result = compress("git show HEAD:src/generated.rs", &output).unwrap();
560
561 assert_eq!(result, output);
562 assert!(!result.contains("more lines"));
563 }
564
565 #[test]
566 fn show_format_colon_is_not_mistaken_for_blob_selector() {
567 assert!(!show_requests_blob("git show --format=%H:%s HEAD"));
568 }
569
570 #[test]
571 fn extract_subcommand_basic() {
572 assert_eq!(extract_git_subcommand("git status"), Some("status"));
573 assert_eq!(extract_git_subcommand("git log --oneline"), Some("log"));
574 assert_eq!(extract_git_subcommand("git diff HEAD~1"), Some("diff"));
575 assert_eq!(
576 extract_git_subcommand("git -C /tmp commit -m 'x'"),
577 Some("commit")
578 );
579 }
580
581 #[test]
582 fn extract_subcommand_avoids_filename_ambiguity() {
583 assert_eq!(
584 extract_git_subcommand("git log status.txt"),
585 Some("log"),
586 "should NOT match 'status' in filename"
587 );
588 assert_eq!(
589 extract_git_subcommand("git add commit.rs"),
590 Some("add"),
591 "should NOT match 'commit' in filename"
592 );
593 }
594
595 #[test]
596 fn extract_subcommand_full_path() {
597 assert_eq!(
598 extract_git_subcommand("/usr/bin/git status"),
599 Some("status")
600 );
601 }
602
603 #[test]
604 fn extract_subcommand_no_git() {
605 assert_eq!(extract_git_subcommand("cargo build"), None);
606 }
607
608 #[test]
609 fn filename_not_treated_as_subcommand() {
610 let output = "On branch main\nnothing to commit\n";
611 assert!(
612 compress("git log status.txt", output).is_some(),
613 "should route to log, not status"
614 );
615 }
616}