Skip to main content

lean_ctx/core/patterns/git/
mod.rs

1mod commit;
2mod diff;
3mod log;
4mod parser;
5mod status;
6
7use parser::extract_git_subcommand;
8
9macro_rules! static_regex {
10    ($pattern:expr_2021) => {{
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
98/// `git show <revision>:<path>` prints a file blob, not commit metadata.
99///
100/// Treat blob output as source text and preserve it byte-for-byte. Applying the
101/// commit-oriented line compactor can splice an elision marker into code, making
102/// the result look like file content even though it is not.
103fn show_requests_blob(command: &str) -> bool {
104    command.split_whitespace().any(|arg| {
105        let arg = arg.trim_matches(['\'', '"']);
106        !arg.starts_with('-')
107            && arg
108                .split_once(':')
109                .is_some_and(|(revision, path)| !revision.is_empty() && !path.is_empty())
110    })
111}
112
113pub fn compress(command: &str, output: &str) -> Option<String> {
114    let sub = extract_git_subcommand(command)?;
115    match sub {
116        "status" => Some(status::compress_status(output)),
117        "log" => Some(log::compress_log(command, output)),
118        "diff" => Some(diff::compress_diff(output)),
119        "add" => Some(commit::compress_add(output)),
120        "commit" => Some(commit::compress_commit(output)),
121        "push" => Some(commit::compress_push(output)),
122        "pull" => Some(commit::compress_pull(output)),
123        "fetch" => Some(commit::compress_fetch(output)),
124        "clone" => Some(commit::compress_clone(output)),
125        "branch" => Some(commit::compress_branch(output)),
126        "checkout" | "switch" => Some(commit::compress_checkout(output)),
127        "merge" => Some(commit::compress_merge(output)),
128        "stash" => {
129            if command.contains("stash show") || command.contains("show stash") {
130                return Some(commit::compress_show(output));
131            }
132            Some(commit::compress_stash(output))
133        }
134        "tag" => Some(commit::compress_tag(output)),
135        "reset" => Some(commit::compress_reset(output)),
136        "remote" => {
137            if command.contains("remote add") {
138                return Some(commit::compress_add(output));
139            }
140            Some(commit::compress_remote(output))
141        }
142        "blame" => Some(commit::compress_blame(output)),
143        "cherry-pick" => Some(commit::compress_cherry_pick(output)),
144        "show" if show_requests_blob(command) => Some(output.to_string()),
145        "show" => Some(commit::compress_show(output)),
146        "rebase" => Some(commit::compress_rebase(output)),
147        "submodule" => Some(commit::compress_submodule(output)),
148        "worktree" => Some(commit::compress_worktree(output)),
149        "bisect" => Some(commit::compress_bisect(output)),
150        _ => None,
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    #[test]
159    fn git_status_compresses() {
160        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";
161        let result = compress("git status", output).unwrap();
162        assert!(result.contains("main"), "should contain branch name");
163        assert!(result.contains("main.rs"), "should list modified files");
164        assert!(result.len() < output.len(), "should be shorter than input");
165    }
166
167    #[test]
168    fn git_add_compresses_to_ok() {
169        let result = compress("git add .", "").unwrap();
170        assert!(result.contains("ok"), "git add should compress to 'ok'");
171    }
172
173    // A chained `git add … && git commit …` routes the whole compound output
174    // through the add-compressor, which used to label OUTPUT LINE COUNT as a
175    // file count ("ok (+7 files)" for a 1-file commit). Numbers in summaries
176    // must come from the output, not be fabricated (limitations audit, #2).
177    #[test]
178    fn git_add_does_not_label_line_count_as_files() {
179        let output = "[main abc1234] fix: x\n 1 file changed, 1 insertion(+)\nhook line\nanother\nmore\nlines\nseven\n";
180        let result = compress("git add . && git commit -m 'x'", output).unwrap();
181        assert!(
182            !result.contains("files)"),
183            "line count must not be presented as a file count: {result}"
184        );
185    }
186
187    #[test]
188    fn git_add_verbose_counts_real_added_files() {
189        let output = "add 'a.rs'\nadd 'b.rs'\nadd 'c.rs'\nadd 'd.rs'\n";
190        let result = compress("git add -v .", output).unwrap();
191        assert!(
192            result.contains("+4 files"),
193            "verbose add lines are the real file count: {result}"
194        );
195    }
196
197    #[test]
198    fn git_commit_extracts_hash() {
199        let output =
200            "[main abc1234] fix: resolve bug\n 2 files changed, 10 insertions(+), 3 deletions(-)\n";
201        let result = compress("git commit -m 'fix'", output).unwrap();
202        assert!(result.contains("abc1234"), "should extract commit hash");
203    }
204
205    #[test]
206    fn git_push_compresses() {
207        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";
208        let result = compress("git push", output).unwrap();
209        assert!(result.len() < output.len(), "should compress push output");
210    }
211
212    #[test]
213    fn git_log_compresses() {
214        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";
215        let result = compress("git log", output).unwrap();
216        assert!(result.len() < output.len(), "should compress log output");
217    }
218
219    #[test]
220    fn git_log_oneline_truncates_long() {
221        let lines: Vec<String> = (0..150)
222            .map(|i| format!("abc{i:04} feat: commit number {i}"))
223            .collect();
224        let output = lines.join("\n");
225        let result = compress("git log --oneline", &output).unwrap();
226        assert!(
227            result.contains("... (50 more commits"),
228            "should truncate to 100 entries"
229        );
230        assert!(
231            result.lines().count() <= 102,
232            "should have at most 101 lines (100 + summary)"
233        );
234    }
235
236    #[test]
237    fn git_log_oneline_short_unchanged() {
238        let output = "abc1234 feat: one\ndef5678 fix: two\nghi9012 docs: three";
239        let result = compress("git log --oneline", output).unwrap();
240        assert_eq!(result, output, "short oneline should pass through");
241    }
242
243    #[test]
244    fn git_log_standard_truncates_long() {
245        let mut output = String::new();
246        for i in 0..130 {
247            output.push_str(&format!(
248                "commit {i:07}abc1234\nAuthor: U <u@e.com>\nDate:   Mon\n\n    msg {i}\n\n"
249            ));
250        }
251        let result = compress("git log", &output).unwrap();
252        assert!(
253            result.contains("... (30 more commits"),
254            "should truncate standard log at 100"
255        );
256    }
257
258    #[test]
259    fn git_diff_compresses() {
260        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 }";
261        let result = compress("git diff", output).unwrap();
262        assert!(result.contains("main.rs"), "should reference changed file");
263    }
264
265    #[test]
266    fn git_diff_stat_preserves_all_files() {
267        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";
268        let result = compress("git diff --stat", output).unwrap();
269        assert!(
270            result.contains("en.json"),
271            "must keep en.json, got: {result}"
272        );
273        assert!(
274            result.contains("DocsToolsCorePage"),
275            "must keep CorePage, got: {result}"
276        );
277        assert!(
278            result.contains("DocsToolsIntelligencePage"),
279            "must keep IntelligencePage, got: {result}"
280        );
281        assert!(
282            result.contains("DocsToolsMemoryPage"),
283            "must keep MemoryPage, got: {result}"
284        );
285        assert!(
286            result.contains("DocsToolsSessionPage"),
287            "must keep SessionPage, got: {result}"
288        );
289        assert!(
290            result.contains("5 files changed"),
291            "must keep summary line, got: {result}"
292        );
293    }
294
295    #[test]
296    fn git_diff_cached_stat_preserves_all_files() {
297        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";
298        let result = compress("git diff --cached --stat", output).unwrap();
299        assert!(result.contains("a.rs"), "must keep a.rs, got: {result}");
300        assert!(result.contains("d.rs"), "must keep d.rs, got: {result}");
301        assert!(
302            result.contains("4 files changed"),
303            "must keep summary, got: {result}"
304        );
305    }
306
307    #[test]
308    fn git_diff_shortstat_preserved() {
309        let output = " 5 files changed, 45 insertions(+), 172 deletions(-)\n";
310        let result = compress("git diff --shortstat", output).unwrap();
311        assert!(
312            result.contains("5 files changed"),
313            "shortstat must pass through, got: {result}"
314        );
315    }
316
317    #[test]
318    fn git_push_preserves_pipeline_url() {
319        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";
320        let result = compress("git push", output).unwrap();
321        assert!(
322            result.contains("pipeline"),
323            "should preserve pipeline URL, got: {result}"
324        );
325        assert!(
326            result.contains("merge_request"),
327            "should preserve merge request URL"
328        );
329        assert!(result.contains("->"), "should contain ref update line");
330    }
331
332    #[test]
333    fn git_push_preserves_github_pr_url() {
334        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";
335        let result = compress("git push", output).unwrap();
336        assert!(
337            result.contains("pull/"),
338            "should preserve GitHub PR URL, got: {result}"
339        );
340    }
341
342    #[test]
343    fn git_commit_preserves_hook_output() {
344        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";
345        let result = compress("git commit -m 'fix'", output).unwrap();
346        assert!(
347            result.contains("ruff"),
348            "should preserve hook output, got: {result}"
349        );
350        assert!(
351            result.contains("abc1234"),
352            "should still extract commit hash"
353        );
354    }
355
356    #[test]
357    fn git_commit_no_hooks() {
358        let output =
359            "[main abc1234] fix: resolve bug\n 2 files changed, 10 insertions(+), 3 deletions(-)\n";
360        let result = compress("git commit -m 'fix'", output).unwrap();
361        assert!(result.contains("abc1234"), "should extract commit hash");
362        assert!(
363            !result.contains("hook"),
364            "should not mention hooks when none present"
365        );
366    }
367
368    #[test]
369    fn git_log_with_patch_keeps_hunks_for_few_commits() {
370        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";
371        let result = compress("git log -p", output).unwrap();
372        assert!(
373            result.contains("println"),
374            "1-3 commits with -p should KEEP diff hunks, got: {result}"
375        );
376        assert!(result.contains("abc1234"), "should contain commit hash");
377        assert!(
378            result.contains("feat: add feature"),
379            "should contain commit message"
380        );
381    }
382
383    #[test]
384    fn git_log_with_patch_summarizes_many_commits() {
385        let mut output = String::new();
386        for i in 0..10 {
387            output.push_str(&format!(
388                "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"
389            ));
390        }
391        let result = compress("git log -p", &output).unwrap();
392        assert!(
393            result.contains("msg 0"),
394            "newest commit message should be present"
395        );
396        assert!(
397            result.contains("+line 0"),
398            "newest commit should have hunks preserved"
399        );
400        assert!(
401            result.len() < output.len(),
402            "should be compressed ({} vs {})",
403            result.len(),
404            output.len()
405        );
406    }
407
408    #[test]
409    fn git_log_with_stat_filters_stat_content() {
410        let mut output = String::new();
411        for i in 0..5 {
412            output.push_str(&format!(
413                "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"
414            ));
415        }
416        let result = compress("git log --stat", &output).unwrap();
417        assert!(
418            result.len() < output.len() / 2,
419            "stat output should be compressed ({} vs {})",
420            result.len(),
421            output.len()
422        );
423    }
424
425    #[test]
426    fn git_commit_with_feature_branch() {
427        let output = "[feature/my-branch abc1234] feat: add new thing\n 3 files changed, 20 insertions(+), 5 deletions(-)\n";
428        let result = compress("git commit -m 'feat'", output).unwrap();
429        assert!(
430            result.contains("abc1234"),
431            "should extract hash from feature branch, got: {result}"
432        );
433        assert!(
434            result.contains("feature/my-branch"),
435            "should preserve branch name, got: {result}"
436        );
437    }
438
439    #[test]
440    fn git_commit_many_hooks_compressed() {
441        let mut output = String::new();
442        for i in 0..30 {
443            output.push_str(&format!("check-{i}..........passed\n"));
444        }
445        output.push_str("[main abc1234] fix: resolve bug\n 1 file changed, 1 insertion(+)\n");
446        let result = compress("git commit -m 'fix'", &output).unwrap();
447        assert!(result.contains("abc1234"), "should contain commit hash");
448        assert!(
449            result.contains("hooks passed"),
450            "should summarize passed hooks, got: {result}"
451        );
452        assert!(
453            result.len() < output.len() / 2,
454            "should compress verbose hook output ({} vs {})",
455            result.len(),
456            output.len()
457        );
458    }
459
460    #[test]
461    fn stash_push_preserves_short_message() {
462        let output = "Saved working directory and index state WIP on main: abc1234 fix stuff\n";
463        let result = compress("git stash", output).unwrap();
464        assert!(
465            result.contains("Saved working directory"),
466            "short stash messages must be preserved, got: {result}"
467        );
468    }
469
470    #[test]
471    fn stash_drop_preserves_short_message() {
472        let output = "Dropped refs/stash@{0} (abc123def456)\n";
473        let result = compress("git stash drop", output).unwrap();
474        assert!(
475            result.contains("Dropped"),
476            "short drop messages must be preserved, got: {result}"
477        );
478    }
479
480    #[test]
481    fn stash_list_short_preserved() {
482        let output = "stash@{0}: WIP on main: abc1234 fix\nstash@{1}: On feature: def5678 add\n";
483        let result = compress("git stash list", output).unwrap();
484        assert!(
485            result.contains("stash@{0}"),
486            "short stash list must be preserved, got: {result}"
487        );
488    }
489
490    #[test]
491    fn stash_list_long_reformats() {
492        let lines: Vec<String> = (0..10)
493            .map(|i| format!("stash@{{{i}}}: WIP on main: abc{i:04} commit {i}"))
494            .collect();
495        let output = lines.join("\n");
496        let result = compress("git stash list", &output).unwrap();
497        assert!(result.contains("@0:"), "should reformat @0, got: {result}");
498        assert!(result.contains("@9:"), "should reformat @9, got: {result}");
499    }
500
501    #[test]
502    fn stash_show_routes_to_show_compressor() {
503        let output = " src/main.rs | 10 +++++-----\n src/lib.rs  |  3 ++-\n 2 files changed, 7 insertions(+), 6 deletions(-)\n";
504        let result = compress("git stash show", output).unwrap();
505        assert!(
506            result.contains("main.rs"),
507            "stash show should preserve file names, got: {result}"
508        );
509    }
510
511    #[test]
512    fn stash_show_patch_not_over_compressed() {
513        let lines: Vec<String> = (0..40)
514            .map(|i| format!("+line {i}: some content here"))
515            .collect();
516        let output = format!(
517            "diff --git a/file.rs b/file.rs\n--- a/file.rs\n+++ b/file.rs\n{}",
518            lines.join("\n")
519        );
520        let result = compress("git stash show -p", &output).unwrap();
521        let result_lines = result.lines().count();
522        assert!(
523            result_lines >= 10,
524            "stash show -p must not over-compress to 3 lines, got {result_lines} lines"
525        );
526    }
527
528    #[test]
529    fn show_stash_ref_routes_correctly() {
530        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";
531        let result = compress("git show stash@{0}", output).unwrap();
532        assert!(
533            result.len() > 10,
534            "git show stash@{{0}} must not be over-compressed, got: {result}"
535        );
536    }
537
538    #[test]
539    fn show_source_blob_is_preserved_verbatim() {
540        let output = (0..40).fold(String::new(), |mut acc, i| {
541            use std::fmt::Write;
542            let _ = writeln!(acc, "pub const VALUE_{i}: usize = {i};");
543            acc
544        });
545
546        let result = compress("git show HEAD:src/generated.rs", &output).unwrap();
547
548        assert_eq!(result, output);
549        assert!(!result.contains("more lines"));
550    }
551
552    #[test]
553    fn show_format_colon_is_not_mistaken_for_blob_selector() {
554        assert!(!show_requests_blob("git show --format=%H:%s HEAD"));
555    }
556
557    #[test]
558    fn extract_subcommand_basic() {
559        assert_eq!(extract_git_subcommand("git status"), Some("status"));
560        assert_eq!(extract_git_subcommand("git log --oneline"), Some("log"));
561        assert_eq!(extract_git_subcommand("git diff HEAD~1"), Some("diff"));
562        assert_eq!(
563            extract_git_subcommand("git -C /tmp commit -m 'x'"),
564            Some("commit")
565        );
566    }
567
568    #[test]
569    fn extract_subcommand_avoids_filename_ambiguity() {
570        assert_eq!(
571            extract_git_subcommand("git log status.txt"),
572            Some("log"),
573            "should NOT match 'status' in filename"
574        );
575        assert_eq!(
576            extract_git_subcommand("git add commit.rs"),
577            Some("add"),
578            "should NOT match 'commit' in filename"
579        );
580    }
581
582    #[test]
583    fn extract_subcommand_full_path() {
584        assert_eq!(
585            extract_git_subcommand("/usr/bin/git status"),
586            Some("status")
587        );
588    }
589
590    #[test]
591    fn extract_subcommand_no_git() {
592        assert_eq!(extract_git_subcommand("cargo build"), None);
593    }
594
595    #[test]
596    fn filename_not_treated_as_subcommand() {
597        let output = "On branch main\nnothing to commit\n";
598        assert!(
599            compress("git log status.txt", output).is_some(),
600            "should route to log, not status"
601        );
602    }
603}