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