1use std::io::{BufRead, BufReader};
6use std::path::{Path, PathBuf};
7use std::process::{Command, Stdio};
8
9use anyhow::{Context, Result};
10
11#[derive(Debug, Clone)]
12pub struct GitCommit {
13 pub timestamp: i64,
14 pub author: String,
15 pub files: Vec<String>,
16}
17
18pub fn git_available() -> bool {
19 Command::new("git")
20 .arg("--version")
21 .stdout(Stdio::null())
22 .stderr(Stdio::null())
23 .status()
24 .map(|s| s.success())
25 .unwrap_or(false)
26}
27
28pub fn repo_root(path: &Path) -> Option<PathBuf> {
29 let output = Command::new("git")
30 .arg("-C")
31 .arg(path)
32 .arg("rev-parse")
33 .arg("--show-toplevel")
34 .output()
35 .ok()?;
36 if !output.status.success() {
37 return None;
38 }
39 let root = String::from_utf8_lossy(&output.stdout).trim().to_string();
40 if root.is_empty() {
41 None
42 } else {
43 Some(PathBuf::from(root))
44 }
45}
46
47pub fn collect_history(
48 repo_root: &Path,
49 max_commits: Option<usize>,
50 max_commit_files: Option<usize>,
51) -> Result<Vec<GitCommit>> {
52 let mut child = Command::new("git")
53 .arg("-C")
54 .arg(repo_root)
55 .arg("log")
56 .arg("--name-only")
57 .arg("--pretty=format:%ct|%ae")
58 .stdout(Stdio::piped())
59 .stderr(Stdio::null())
60 .spawn()
61 .context("Failed to spawn git log")?;
62
63 let stdout = child.stdout.take().context("Missing git log stdout")?;
64 let reader = BufReader::new(stdout);
65
66 let mut commits: Vec<GitCommit> = Vec::new();
67 let mut current: Option<GitCommit> = None;
68
69 for line in reader.lines() {
70 let line = line?;
71 if line.trim().is_empty() {
72 if let Some(commit) = current.take() {
73 commits.push(commit);
74 if max_commits.is_some_and(|limit| commits.len() >= limit) {
75 break;
76 }
77 }
78 continue;
79 }
80
81 if current.is_none() {
82 let mut parts = line.splitn(2, '|');
83 let ts = parts.next().unwrap_or("0").parse::<i64>().unwrap_or(0);
84 let author = parts.next().unwrap_or("").to_string();
85 current = Some(GitCommit {
86 timestamp: ts,
87 author,
88 files: Vec::new(),
89 });
90 continue;
91 }
92
93 if let Some(commit) = current.as_mut()
94 && max_commit_files
95 .map(|limit| commit.files.len() < limit)
96 .unwrap_or(true)
97 {
98 commit.files.push(line.trim().to_string());
99 }
100 }
101
102 if let Some(commit) = current.take() {
103 commits.push(commit);
104 }
105
106 let status = child.wait()?;
107 if !status.success() {
108 return Err(anyhow::anyhow!("git log failed"));
109 }
110
111 Ok(commits)
112}