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