1use crate::text::compact_summary_snippet;
2use crate::types::HailCompactFileChange;
3use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5
6#[derive(Debug, Clone)]
7pub struct GitSummaryContext {
8 pub source: String,
9 pub repo_root: PathBuf,
10 pub commit: Option<String>,
11 pub timeline_signals: Vec<String>,
12 pub file_changes: Vec<HailCompactFileChange>,
13}
14
15pub trait GitCommandRunner {
16 fn run(&self, repo_root: &Path, args: &[&str]) -> Result<String, String>;
17}
18
19#[derive(Debug, Clone, Copy, Default)]
20pub struct ShellGitCommandRunner;
21
22impl GitCommandRunner for ShellGitCommandRunner {
23 fn run(&self, repo_root: &Path, args: &[&str]) -> Result<String, String> {
24 let output = std::process::Command::new("git")
25 .arg("-C")
26 .arg(repo_root)
27 .args(args)
28 .output()
29 .map_err(|err| format!("failed to run git {}: {err}", args.join(" ")))?;
30
31 if !output.status.success() {
32 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
33 if stderr.is_empty() {
34 return Err(format!(
35 "git {} failed with status {}",
36 args.join(" "),
37 output.status
38 ));
39 }
40 return Err(stderr);
41 }
42
43 Ok(String::from_utf8_lossy(&output.stdout).to_string())
44 }
45}
46
47pub struct GitSummaryService<R> {
48 runner: R,
49}
50
51impl<R: GitCommandRunner> GitSummaryService<R> {
52 pub fn new(runner: R) -> Self {
53 Self { runner }
54 }
55
56 pub fn collect_commit_context(
57 &self,
58 repo_root: &Path,
59 commit: &str,
60 max_entries: usize,
61 classify_arch_layer: fn(&str) -> &'static str,
62 ) -> Option<GitSummaryContext> {
63 let name_status = self
64 .runner
65 .run(
66 repo_root,
67 &["show", "--name-status", "--format=", "--no-color", commit],
68 )
69 .ok()?;
70 let numstat = self
71 .runner
72 .run(
73 repo_root,
74 &["show", "--numstat", "--format=", "--no-color", commit],
75 )
76 .unwrap_or_default();
77
78 let file_changes = build_git_file_changes(
79 parse_git_name_status(&name_status),
80 parse_git_numstat(&numstat),
81 Vec::new(),
82 max_entries,
83 classify_arch_layer,
84 );
85 if file_changes.is_empty() {
86 return None;
87 }
88
89 let mut timeline_signals = Vec::new();
90 if let Ok(subject) = self
91 .runner
92 .run(repo_root, &["show", "--no-patch", "--format=%h %s", commit])
93 {
94 let compact = compact_summary_snippet(&subject, 180);
95 if !compact.is_empty() {
96 timeline_signals.push(format!("commit: {compact}"));
97 }
98 }
99 if timeline_signals.is_empty() {
100 timeline_signals.push(format!("commit: {}", compact_summary_snippet(commit, 32)));
101 }
102
103 Some(GitSummaryContext {
104 source: "git_commit".to_string(),
105 repo_root: repo_root.to_path_buf(),
106 commit: Some(commit.to_string()),
107 timeline_signals,
108 file_changes,
109 })
110 }
111
112 pub fn collect_working_tree_context(
113 &self,
114 repo_root: &Path,
115 max_entries: usize,
116 classify_arch_layer: fn(&str) -> &'static str,
117 ) -> Option<GitSummaryContext> {
118 let mut operation_by_path = HashMap::new();
119 for args in [
120 ["diff", "--name-status", "--no-color"].as_slice(),
121 ["diff", "--cached", "--name-status", "--no-color"].as_slice(),
122 ] {
123 let Ok(raw) = self.runner.run(repo_root, args) else {
124 continue;
125 };
126 for (path, operation) in parse_git_name_status(&raw) {
127 operation_by_path.insert(path, operation);
128 }
129 }
130
131 let mut numstat_by_path: HashMap<String, (u64, u64)> = HashMap::new();
132 for args in [
133 ["diff", "--numstat", "--no-color"].as_slice(),
134 ["diff", "--cached", "--numstat", "--no-color"].as_slice(),
135 ] {
136 let Ok(raw) = self.runner.run(repo_root, args) else {
137 continue;
138 };
139 for (path, (added, removed)) in parse_git_numstat(&raw) {
140 let entry = numstat_by_path.entry(path).or_insert((0, 0));
141 entry.0 = entry.0.saturating_add(added);
142 entry.1 = entry.1.saturating_add(removed);
143 }
144 }
145
146 let untracked_paths = self
147 .runner
148 .run(repo_root, &["ls-files", "--others", "--exclude-standard"])
149 .map(|raw| parse_git_untracked_paths(&raw))
150 .unwrap_or_default();
151
152 let file_changes = build_git_file_changes(
153 operation_by_path,
154 numstat_by_path,
155 untracked_paths,
156 max_entries,
157 classify_arch_layer,
158 );
159 if file_changes.is_empty() {
160 return None;
161 }
162
163 let mut timeline_signals = vec![format!(
164 "working_tree: {} files changed",
165 file_changes.len()
166 )];
167 if let Ok(status) = self.runner.run(
168 repo_root,
169 &["status", "--short", "--untracked-files=normal"],
170 ) {
171 for line in status.lines().take(6) {
172 let compact = compact_summary_snippet(line, 140);
173 if compact.is_empty() {
174 continue;
175 }
176 timeline_signals.push(format!("status: {compact}"));
177 }
178 }
179
180 Some(GitSummaryContext {
181 source: "git_working_tree".to_string(),
182 repo_root: repo_root.to_path_buf(),
183 commit: None,
184 timeline_signals,
185 file_changes,
186 })
187 }
188
189 pub fn build_diff_preview_lines(
190 &self,
191 context: &GitSummaryContext,
192 max_files: usize,
193 ) -> Result<Vec<String>, String> {
194 let mut lines = Vec::new();
195 let commit = context.commit.as_deref();
196 for change in context.file_changes.iter().take(max_files) {
197 let patch = self.git_patch_for_file(
198 &context.repo_root,
199 commit,
200 &change.path,
201 &change.operation,
202 );
203 if patch.trim().is_empty() {
204 continue;
205 }
206
207 lines.push(format!("{} [{}]", change.path, change.operation));
208 for line in diff_preview_lines(&patch, 14, 180) {
209 lines.push(format!(" {line}"));
210 }
211 lines.push(String::new());
212 }
213
214 while lines.last().is_some_and(|line| line.is_empty()) {
215 lines.pop();
216 }
217
218 if lines.is_empty() {
219 return Err("Diff patch is unavailable for detected changes".to_string());
220 }
221 Ok(lines)
222 }
223
224 fn git_patch_for_file(
225 &self,
226 repo_root: &Path,
227 commit: Option<&str>,
228 path: &str,
229 operation: &str,
230 ) -> String {
231 if let Some(commit) = commit {
232 return self
233 .runner
234 .run(
235 repo_root,
236 &["show", "--no-color", "--format=", commit, "--", path],
237 )
238 .unwrap_or_default();
239 }
240
241 let staged = self
242 .runner
243 .run(repo_root, &["diff", "--cached", "--no-color", "--", path])
244 .unwrap_or_default();
245 let unstaged = self
246 .runner
247 .run(repo_root, &["diff", "--no-color", "--", path])
248 .unwrap_or_default();
249
250 let mut patch = String::new();
251 if !staged.trim().is_empty() {
252 patch.push_str(&staged);
253 if !staged.ends_with('\n') {
254 patch.push('\n');
255 }
256 }
257 if !unstaged.trim().is_empty() {
258 patch.push_str(&unstaged);
259 }
260 if !patch.trim().is_empty() {
261 return patch;
262 }
263 if operation == "create" {
264 return synthetic_new_file_patch(repo_root, path, 20).unwrap_or_default();
265 }
266 patch
267 }
268}
269
270pub fn parse_git_name_status(raw: &str) -> HashMap<String, String> {
271 let mut operations = HashMap::new();
272 for line in raw.lines() {
273 let trimmed = line.trim();
274 if trimmed.is_empty() {
275 continue;
276 }
277 let parts = trimmed.split('\t').collect::<Vec<_>>();
278 if parts.is_empty() {
279 continue;
280 }
281
282 let status = parts[0].trim();
283 let path = if status.starts_with('R') || status.starts_with('C') {
284 parts.get(2).or_else(|| parts.get(1))
285 } else {
286 parts.get(1)
287 };
288 let Some(path) = path
289 .copied()
290 .map(str::trim)
291 .filter(|value| !value.is_empty())
292 else {
293 continue;
294 };
295
296 let operation = match status.chars().next().unwrap_or('M') {
297 'A' => "create",
298 'D' => "delete",
299 _ => "edit",
300 };
301 operations.insert(path.to_string(), operation.to_string());
302 }
303 operations
304}
305
306pub fn parse_git_numstat(raw: &str) -> HashMap<String, (u64, u64)> {
307 let mut stats = HashMap::new();
308 for line in raw.lines() {
309 let trimmed = line.trim();
310 if trimmed.is_empty() {
311 continue;
312 }
313 let parts = trimmed.split('\t').collect::<Vec<_>>();
314 if parts.len() < 3 {
315 continue;
316 }
317 let path = parts[2].trim();
318 if path.is_empty() {
319 continue;
320 }
321 let added = parts[0].trim().parse::<u64>().unwrap_or(0);
322 let removed = parts[1].trim().parse::<u64>().unwrap_or(0);
323 stats.insert(path.to_string(), (added, removed));
324 }
325 stats
326}
327
328pub fn parse_git_untracked_paths(raw: &str) -> Vec<String> {
329 raw.lines()
330 .map(str::trim)
331 .filter(|line| !line.is_empty())
332 .map(ToString::to_string)
333 .collect()
334}
335
336fn build_git_file_changes(
337 operation_by_path: HashMap<String, String>,
338 numstat_by_path: HashMap<String, (u64, u64)>,
339 untracked_paths: Vec<String>,
340 max_entries: usize,
341 classify_arch_layer: fn(&str) -> &'static str,
342) -> Vec<HailCompactFileChange> {
343 let mut by_path: HashMap<String, HailCompactFileChange> = HashMap::new();
344
345 for (path, operation) in operation_by_path {
346 by_path
347 .entry(path.clone())
348 .and_modify(|entry| {
349 entry.operation = operation.clone();
350 entry.layer = classify_arch_layer(&path).to_string();
351 })
352 .or_insert_with(|| HailCompactFileChange {
353 path: path.clone(),
354 layer: classify_arch_layer(&path).to_string(),
355 operation,
356 lines_added: 0,
357 lines_removed: 0,
358 });
359 }
360
361 for (path, (added, removed)) in numstat_by_path {
362 let entry = by_path
363 .entry(path.clone())
364 .or_insert_with(|| HailCompactFileChange {
365 path: path.clone(),
366 layer: classify_arch_layer(&path).to_string(),
367 operation: "edit".to_string(),
368 lines_added: 0,
369 lines_removed: 0,
370 });
371 entry.lines_added = entry.lines_added.saturating_add(added);
372 entry.lines_removed = entry.lines_removed.saturating_add(removed);
373 }
374
375 for path in untracked_paths {
376 by_path
377 .entry(path.clone())
378 .and_modify(|entry| {
379 entry.operation = "create".to_string();
380 entry.layer = classify_arch_layer(&path).to_string();
381 })
382 .or_insert_with(|| HailCompactFileChange {
383 path: path.clone(),
384 layer: classify_arch_layer(&path).to_string(),
385 operation: "create".to_string(),
386 lines_added: 0,
387 lines_removed: 0,
388 });
389 }
390
391 let mut changes = by_path.into_values().collect::<Vec<_>>();
392 changes.sort_by(|lhs, rhs| lhs.path.cmp(&rhs.path));
393 changes.truncate(max_entries);
394 changes
395}
396
397fn synthetic_new_file_patch(repo_root: &Path, path: &str, max_lines: usize) -> Option<String> {
398 let full_path = repo_root.join(path);
399 if !full_path.exists() {
400 return None;
401 }
402 let bytes = std::fs::read(&full_path).ok()?;
403 let content = String::from_utf8_lossy(&bytes);
404 let mut out = String::new();
405 out.push_str(&format!("diff --git a/{path} b/{path}\n"));
406 out.push_str("new file mode 100644\n");
407 out.push_str("--- /dev/null\n");
408 out.push_str(&format!("+++ b/{path}\n"));
409 out.push_str("@@ new file @@\n");
410
411 let mut wrote_any = false;
412 for line in content.lines().take(max_lines) {
413 out.push('+');
414 out.push_str(line);
415 out.push('\n');
416 wrote_any = true;
417 }
418 if !wrote_any {
419 out.push_str("+(empty file)\n");
420 } else if content.lines().count() > max_lines {
421 out.push_str("+…\n");
422 }
423 Some(out)
424}
425
426fn truncate_preview_line(raw: &str, max_chars: usize) -> String {
427 if raw.chars().count() <= max_chars {
428 return raw.to_string();
429 }
430 let mut out = String::new();
431 for ch in raw.chars().take(max_chars.saturating_sub(1)) {
432 out.push(ch);
433 }
434 out.push('…');
435 out
436}
437
438fn diff_preview_lines(raw: &str, max_lines: usize, max_chars: usize) -> Vec<String> {
439 let mut lines = Vec::new();
440 let mut iter = raw.lines();
441 for _ in 0..max_lines {
442 let Some(line) = iter.next() else {
443 break;
444 };
445 lines.push(truncate_preview_line(line, max_chars));
446 }
447 if iter.next().is_some() {
448 lines.push("…".to_string());
449 }
450 lines
451}
452
453#[cfg(test)]
454mod tests {
455 use super::{
456 parse_git_name_status, parse_git_numstat, GitCommandRunner, GitSummaryContext,
457 GitSummaryService,
458 };
459 use crate::types::HailCompactFileChange;
460 use std::collections::HashMap;
461 use std::path::{Path, PathBuf};
462
463 #[derive(Clone, Default)]
464 struct MockRunner {
465 outputs: HashMap<String, Result<String, String>>,
466 }
467
468 impl MockRunner {
469 fn with(args: &[&str], result: Result<&str, &str>) -> (String, Result<String, String>) {
470 (
471 args.join("\u{1f}"),
472 result.map(ToString::to_string).map_err(ToString::to_string),
473 )
474 }
475 }
476
477 impl GitCommandRunner for MockRunner {
478 fn run(&self, _repo_root: &Path, args: &[&str]) -> Result<String, String> {
479 self.outputs
480 .get(&args.join("\u{1f}"))
481 .cloned()
482 .unwrap_or_else(|| Err(format!("missing mock for {}", args.join(" "))))
483 }
484 }
485
486 fn classify(path: &str) -> &'static str {
487 if path.ends_with(".md") {
488 "docs"
489 } else {
490 "application"
491 }
492 }
493
494 #[test]
495 fn parse_git_name_status_extracts_operations_and_paths() {
496 let raw = "M\tpackages/ui/src/components/SessionDetailPage.svelte\nA\tdocs/summary.md\nR100\told.rs\tnew.rs\n";
497 let parsed = parse_git_name_status(raw);
498 assert_eq!(
499 parsed.get("packages/ui/src/components/SessionDetailPage.svelte"),
500 Some(&"edit".to_string())
501 );
502 assert_eq!(parsed.get("docs/summary.md"), Some(&"create".to_string()));
503 assert_eq!(parsed.get("new.rs"), Some(&"edit".to_string()));
504 }
505
506 #[test]
507 fn parse_git_numstat_extracts_line_counts() {
508 let raw =
509 "12\t3\tpackages/ui/src/components/SessionDetailPage.svelte\n-\t-\tassets/logo.png\n";
510 let parsed = parse_git_numstat(raw);
511 assert_eq!(
512 parsed.get("packages/ui/src/components/SessionDetailPage.svelte"),
513 Some(&(12, 3))
514 );
515 assert_eq!(parsed.get("assets/logo.png"), Some(&(0, 0)));
516 }
517
518 #[test]
519 fn collect_commit_context_uses_subject_and_file_changes() {
520 let runner = MockRunner {
521 outputs: HashMap::from([
522 MockRunner::with(
523 &["show", "--name-status", "--format=", "--no-color", "abc123"],
524 Ok("M\tsrc/lib.rs\nA\tdocs/summary.md\n"),
525 ),
526 MockRunner::with(
527 &["show", "--numstat", "--format=", "--no-color", "abc123"],
528 Ok("5\t1\tsrc/lib.rs\n2\t0\tdocs/summary.md\n"),
529 ),
530 MockRunner::with(
531 &["show", "--no-patch", "--format=%h %s", "abc123"],
532 Ok("abc123 feat: improve auth\n"),
533 ),
534 ]),
535 };
536 let service = GitSummaryService::new(runner);
537
538 let context = service
539 .collect_commit_context(Path::new("/tmp/repo"), "abc123", 10, classify)
540 .expect("commit context");
541
542 assert_eq!(context.source, "git_commit");
543 assert_eq!(context.commit.as_deref(), Some("abc123"));
544 assert_eq!(
545 context.timeline_signals.first().map(String::as_str),
546 Some("commit: abc123 feat: improve auth")
547 );
548 assert_eq!(context.file_changes.len(), 2);
549 assert_eq!(context.file_changes[0].path, "docs/summary.md");
550 assert_eq!(context.file_changes[0].operation, "create");
551 assert_eq!(context.file_changes[0].lines_added, 2);
552 assert_eq!(context.file_changes[1].path, "src/lib.rs");
553 assert_eq!(context.file_changes[1].operation, "edit");
554 assert_eq!(context.file_changes[1].lines_added, 5);
555 assert_eq!(context.file_changes[1].lines_removed, 1);
556 }
557
558 #[test]
559 fn collect_commit_context_falls_back_to_commit_when_subject_missing() {
560 let runner = MockRunner {
561 outputs: HashMap::from([
562 MockRunner::with(
563 &[
564 "show",
565 "--name-status",
566 "--format=",
567 "--no-color",
568 "deadbeef",
569 ],
570 Ok("M\tsrc/lib.rs\n"),
571 ),
572 MockRunner::with(
573 &["show", "--numstat", "--format=", "--no-color", "deadbeef"],
574 Ok("1\t0\tsrc/lib.rs\n"),
575 ),
576 MockRunner::with(
577 &["show", "--no-patch", "--format=%h %s", "deadbeef"],
578 Err("no subject"),
579 ),
580 ]),
581 };
582 let service = GitSummaryService::new(runner);
583
584 let context = service
585 .collect_commit_context(Path::new("/tmp/repo"), "deadbeef", 10, classify)
586 .expect("commit context fallback");
587 assert_eq!(
588 context.timeline_signals.first().map(String::as_str),
589 Some("commit: deadbeef")
590 );
591 }
592
593 #[test]
594 fn collect_working_tree_context_merges_sources_and_limits_status_lines() {
595 let runner = MockRunner {
596 outputs: HashMap::from([
597 MockRunner::with(
598 &["diff", "--name-status", "--no-color"],
599 Ok("M\tsrc/app.rs\n"),
600 ),
601 MockRunner::with(
602 &["diff", "--cached", "--name-status", "--no-color"],
603 Ok("A\tnew/file.rs\nD\told/file.rs\n"),
604 ),
605 MockRunner::with(
606 &["diff", "--numstat", "--no-color"],
607 Ok("3\t1\tsrc/app.rs\n"),
608 ),
609 MockRunner::with(
610 &["diff", "--cached", "--numstat", "--no-color"],
611 Ok("10\t0\tnew/file.rs\n0\t4\told/file.rs\n"),
612 ),
613 MockRunner::with(
614 &["ls-files", "--others", "--exclude-standard"],
615 Ok("scratch.txt\n"),
616 ),
617 MockRunner::with(
618 &["status", "--short", "--untracked-files=normal"],
619 Ok("M src/app.rs\nA new/file.rs\nD old/file.rs\n?? scratch.txt\nline5\nline6\nline7\nline8\n"),
620 ),
621 ]),
622 };
623 let service = GitSummaryService::new(runner);
624
625 let context = service
626 .collect_working_tree_context(Path::new("/tmp/repo"), 10, classify)
627 .expect("working tree context");
628 assert_eq!(context.source, "git_working_tree");
629 assert!(context.commit.is_none());
630 assert_eq!(
631 context.timeline_signals.first().map(String::as_str),
632 Some("working_tree: 4 files changed")
633 );
634 assert_eq!(context.timeline_signals.len(), 7);
635 assert_eq!(context.file_changes.len(), 4);
636 assert!(context
637 .file_changes
638 .iter()
639 .any(|change| change.path == "scratch.txt" && change.operation == "create"));
640 assert!(context
641 .file_changes
642 .iter()
643 .any(|change| change.path == "old/file.rs" && change.operation == "delete"));
644 }
645
646 #[test]
647 fn build_diff_preview_lines_returns_error_when_patch_missing() {
648 let runner = MockRunner {
649 outputs: HashMap::from([
650 MockRunner::with(
651 &["diff", "--cached", "--no-color", "--", "src/app.rs"],
652 Ok(""),
653 ),
654 MockRunner::with(&["diff", "--no-color", "--", "src/app.rs"], Ok("")),
655 ]),
656 };
657 let service = GitSummaryService::new(runner);
658 let context = GitSummaryContext {
659 source: "git_working_tree".to_string(),
660 repo_root: PathBuf::from("/tmp"),
661 commit: None,
662 timeline_signals: Vec::new(),
663 file_changes: vec![HailCompactFileChange {
664 path: "src/app.rs".to_string(),
665 layer: "application".to_string(),
666 operation: "edit".to_string(),
667 lines_added: 0,
668 lines_removed: 0,
669 }],
670 };
671
672 let error = service
673 .build_diff_preview_lines(&context, 4)
674 .expect_err("missing patch should fail");
675 assert!(error.contains("Diff patch is unavailable"));
676 }
677
678 #[test]
679 fn build_diff_preview_lines_uses_synthetic_patch_for_create_operation() {
680 let unique = format!(
681 "ops-summary-git-synthetic-{}",
682 chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0)
683 );
684 let repo_root = std::env::temp_dir().join(unique);
685 std::fs::create_dir_all(&repo_root).expect("create repo dir");
686 std::fs::write(repo_root.join("added.txt"), "line-1\nline-2\n").expect("write new file");
687
688 let runner = MockRunner {
689 outputs: HashMap::from([
690 MockRunner::with(
691 &["diff", "--cached", "--no-color", "--", "added.txt"],
692 Ok(""),
693 ),
694 MockRunner::with(&["diff", "--no-color", "--", "added.txt"], Ok("")),
695 ]),
696 };
697 let service = GitSummaryService::new(runner);
698 let context = GitSummaryContext {
699 source: "git_working_tree".to_string(),
700 repo_root: repo_root.clone(),
701 commit: None,
702 timeline_signals: Vec::new(),
703 file_changes: vec![HailCompactFileChange {
704 path: "added.txt".to_string(),
705 layer: "application".to_string(),
706 operation: "create".to_string(),
707 lines_added: 0,
708 lines_removed: 0,
709 }],
710 };
711
712 let lines = service
713 .build_diff_preview_lines(&context, 4)
714 .expect("synthetic diff preview");
715 assert_eq!(
716 lines.first().map(String::as_str),
717 Some("added.txt [create]")
718 );
719 assert!(lines
720 .iter()
721 .any(|line| line.contains("new file mode 100644")));
722 assert!(lines.iter().any(|line| line.contains("+line-1")));
723
724 std::fs::remove_dir_all(&repo_root).ok();
725 }
726}