Skip to main content

opensession_summary/
git.rs

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}