1use std::fmt::Write;
2use std::sync::LazyLock;
3
4use super::{
5 CommandMatcher, FilterConfidence, FilterResult, GitFilterConfig, OutputFilter, make_result,
6};
7
8static GIT_MATCHER: LazyLock<CommandMatcher> =
9 LazyLock::new(|| CommandMatcher::Custom(Box::new(|cmd| cmd.trim_start().starts_with("git "))));
10
11pub struct GitFilter {
12 config: GitFilterConfig,
13}
14
15impl GitFilter {
16 #[must_use]
17 pub fn new(config: GitFilterConfig) -> Self {
18 Self { config }
19 }
20}
21
22impl OutputFilter for GitFilter {
23 fn name(&self) -> &'static str {
24 "git"
25 }
26
27 fn matcher(&self) -> &CommandMatcher {
28 &GIT_MATCHER
29 }
30
31 fn filter(&self, command: &str, raw_output: &str, _exit_code: i32) -> FilterResult {
32 let subcmd = command
33 .trim_start()
34 .strip_prefix("git ")
35 .unwrap_or("")
36 .split_whitespace()
37 .next()
38 .unwrap_or("");
39
40 match subcmd {
41 "status" => filter_status(raw_output),
42 "diff" => filter_diff(raw_output, self.config.max_diff_lines),
43 "log" => filter_log(raw_output, self.config.max_log_entries),
44 "push" => filter_push(raw_output),
45 _ => make_result(
46 raw_output,
47 raw_output.to_owned(),
48 FilterConfidence::Fallback,
49 ),
50 }
51 }
52}
53
54fn filter_status(raw: &str) -> FilterResult {
55 let mut modified = 0u32;
56 let mut added = 0u32;
57 let mut deleted = 0u32;
58 let mut untracked = 0u32;
59
60 for line in raw.lines() {
61 let trimmed = line.trim();
62 if trimmed.starts_with("M ") || trimmed.starts_with("MM") || trimmed.starts_with(" M") {
63 modified += 1;
64 } else if trimmed.starts_with("A ") || trimmed.starts_with("AM") {
65 added += 1;
66 } else if trimmed.starts_with("D ") || trimmed.starts_with(" D") {
67 deleted += 1;
68 } else if trimmed.starts_with("??") {
69 untracked += 1;
70 } else if trimmed.starts_with("modified:") {
71 modified += 1;
72 } else if trimmed.starts_with("new file:") {
73 added += 1;
74 } else if trimmed.starts_with("deleted:") {
75 deleted += 1;
76 }
77 }
78
79 let total = modified + added + deleted + untracked;
80 if total == 0 {
81 return make_result(raw, raw.to_owned(), FilterConfidence::Fallback);
82 }
83
84 let mut output = String::new();
85 let _ = write!(
86 output,
87 "M {modified} files | A {added} files | D {deleted} files | ?? {untracked} files"
88 );
89 make_result(raw, output, FilterConfidence::Full)
90}
91
92fn filter_diff(raw: &str, max_diff_lines: usize) -> FilterResult {
93 let mut files: Vec<(String, i32, i32)> = Vec::new();
94 let mut current_file = String::new();
95 let mut additions = 0i32;
96 let mut deletions = 0i32;
97
98 for line in raw.lines() {
99 if line.starts_with("diff --git ") {
100 if !current_file.is_empty() {
101 files.push((current_file.clone(), additions, deletions));
102 }
103 line.strip_prefix("diff --git a/")
104 .and_then(|s| s.split(" b/").next())
105 .unwrap_or("unknown")
106 .clone_into(&mut current_file);
107 additions = 0;
108 deletions = 0;
109 } else if line.starts_with('+') && !line.starts_with("+++") {
110 additions += 1;
111 } else if line.starts_with('-') && !line.starts_with("---") {
112 deletions += 1;
113 }
114 }
115 if !current_file.is_empty() {
116 files.push((current_file, additions, deletions));
117 }
118
119 if files.is_empty() {
120 return make_result(raw, raw.to_owned(), FilterConfidence::Fallback);
121 }
122
123 let total_lines: usize = raw.lines().count();
124 let total_add: i32 = files.iter().map(|(_, a, _)| a).sum();
125 let total_del: i32 = files.iter().map(|(_, _, d)| d).sum();
126 let mut output = String::new();
127 for (file, add, del) in &files {
128 let _ = writeln!(output, "{file} | +{add} -{del}");
129 }
130 let _ = write!(
131 output,
132 "{} files changed, {} insertions(+), {} deletions(-)",
133 files.len(),
134 total_add,
135 total_del
136 );
137 if total_lines > max_diff_lines {
138 let _ = write!(output, " (truncated from {total_lines} lines)");
139 }
140 make_result(raw, output, FilterConfidence::Full)
141}
142
143fn filter_log(raw: &str, max_entries: usize) -> FilterResult {
144 let lines: Vec<&str> = raw.lines().collect();
145 if lines.len() <= max_entries {
146 return make_result(raw, raw.to_owned(), FilterConfidence::Fallback);
147 }
148
149 let mut output: String = lines[..max_entries].join("\n");
150 let remaining = lines.len() - max_entries;
151 let _ = write!(output, "\n... and {remaining} more commits");
152 make_result(raw, output, FilterConfidence::Full)
153}
154
155fn filter_push(raw: &str) -> FilterResult {
156 let mut output = String::new();
157 for line in raw.lines() {
158 let trimmed = line.trim();
159 if trimmed.contains("->") || trimmed.starts_with("To ") || trimmed.starts_with("Branch") {
160 if !output.is_empty() {
161 output.push('\n');
162 }
163 output.push_str(trimmed);
164 }
165 }
166 if output.is_empty() {
167 return make_result(raw, raw.to_owned(), FilterConfidence::Fallback);
168 }
169 make_result(raw, output, FilterConfidence::Full)
170}
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175
176 fn make_filter() -> GitFilter {
177 GitFilter::new(GitFilterConfig::default())
178 }
179
180 #[test]
181 fn matches_git_commands() {
182 let f = make_filter();
183 assert!(f.matcher().matches("git status"));
184 assert!(f.matcher().matches("git diff --stat"));
185 assert!(f.matcher().matches("git log --oneline"));
186 assert!(f.matcher().matches("git push origin main"));
187 assert!(!f.matcher().matches("cargo build"));
188 assert!(!f.matcher().matches("github-cli"));
189 }
190
191 #[test]
192 fn filter_status_summarizes() {
193 let f = make_filter();
194 let raw = " M src/main.rs\n M src/lib.rs\n?? new_file.txt\nA added.rs\n";
195 let result = f.filter("git status --short", raw, 0);
196 assert!(result.output.contains("M 2 files"));
197 assert!(result.output.contains("?? 1 files"));
198 assert!(result.output.contains("A 1 files"));
199 assert_eq!(result.confidence, FilterConfidence::Full);
200 }
201
202 #[test]
203 fn filter_diff_compresses() {
204 let f = make_filter();
205 let raw = "\
206diff --git a/src/main.rs b/src/main.rs
207index abc..def 100644
208--- a/src/main.rs
209+++ b/src/main.rs
210+new line 1
211+new line 2
212-old line 1
213diff --git a/src/lib.rs b/src/lib.rs
214index ghi..jkl 100644
215--- a/src/lib.rs
216+++ b/src/lib.rs
217+added
218";
219 let result = f.filter("git diff", raw, 0);
220 assert!(result.output.contains("src/main.rs"));
221 assert!(result.output.contains("src/lib.rs"));
222 assert!(result.output.contains("2 files changed"));
223 assert!(result.output.contains("3 insertions(+)"));
224 assert!(result.output.contains("1 deletions(-)"));
225 }
226
227 #[test]
228 fn filter_log_truncates() {
229 let f = make_filter();
230 let lines: Vec<String> = (0..50)
231 .map(|i| format!("abc{i:04} feat: commit {i}"))
232 .collect();
233 let raw = lines.join("\n");
234 let result = f.filter("git log --oneline", &raw, 0);
235 assert!(result.output.contains("abc0000"));
236 assert!(result.output.contains("abc0019"));
237 assert!(!result.output.contains("abc0020"));
238 assert!(result.output.contains("and 30 more commits"));
239 assert_eq!(result.confidence, FilterConfidence::Full);
240 }
241
242 #[test]
243 fn filter_log_short_passthrough() {
244 let f = make_filter();
245 let raw = "abc1234 feat: something\ndef5678 fix: other";
246 let result = f.filter("git log --oneline", raw, 0);
247 assert_eq!(result.output, raw);
248 assert_eq!(result.confidence, FilterConfidence::Fallback);
249 }
250
251 #[test]
252 fn filter_push_extracts_summary() {
253 let f = make_filter();
254 let raw = "\
255Enumerating objects: 5, done.
256Counting objects: 100% (5/5), done.
257Delta compression using up to 10 threads
258Compressing objects: 100% (3/3), done.
259Writing objects: 100% (3/3), 1.20 KiB | 1.20 MiB/s, done.
260Total 3 (delta 2), reused 0 (delta 0)
261To github.com:user/repo.git
262 abc1234..def5678 main -> main
263";
264 let result = f.filter("git push origin main", raw, 0);
265 assert!(result.output.contains("main -> main"));
266 assert!(result.output.contains("To github.com"));
267 assert!(!result.output.contains("Enumerating"));
268 }
269
270 #[test]
271 fn filter_status_long_form() {
272 let f = make_filter();
273 let raw = "\
274On branch main
275Changes not staged for commit:
276 modified: src/main.rs
277 modified: src/lib.rs
278 deleted: old_file.rs
279
280Untracked files:
281 new_file.txt
282";
283 let result = f.filter("git status", raw, 0);
284 assert!(result.output.contains("M 2 files"));
285 assert!(result.output.contains("D 1 files"));
286 }
287
288 #[test]
289 fn filter_diff_empty_passthrough() {
290 let f = make_filter();
291 let raw = "";
292 let result = f.filter("git diff", raw, 0);
293 assert_eq!(result.output, raw);
294 }
295
296 #[test]
297 fn filter_unknown_subcommand_passthrough() {
298 let f = make_filter();
299 let raw = "some output";
300 let result = f.filter("git stash list", raw, 0);
301 assert_eq!(result.output, raw);
302 assert_eq!(result.confidence, FilterConfidence::Fallback);
303 }
304
305 #[test]
306 fn filter_diff_snapshot() {
307 let f = make_filter();
308 let raw = "\
309diff --git a/src/main.rs b/src/main.rs
310index abc..def 100644
311--- a/src/main.rs
312+++ b/src/main.rs
313+new line 1
314-old line 1
315diff --git a/src/lib.rs b/src/lib.rs
316index ghi..jkl 100644
317--- a/src/lib.rs
318+++ b/src/lib.rs
319+added line
320";
321 let result = f.filter("git diff", raw, 0);
322 insta::assert_snapshot!(result.output);
323 }
324
325 #[test]
326 fn filter_status_snapshot() {
327 let f = make_filter();
328 let raw = " M src/main.rs\n M src/lib.rs\n?? new_file.txt\nA added.rs\n";
329 let result = f.filter("git status --short", raw, 0);
330 insta::assert_snapshot!(result.output);
331 }
332}