1use chrono::{DateTime, TimeZone, Utc};
7use git2::{DiffOptions, Oid};
8use serde::{Deserialize, Serialize};
9use std::path::Path;
10
11use super::repository::GitRepository;
12use crate::error::{AcpError, Result};
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct HistoryEntry {
17 pub commit: String,
19 pub commit_short: String,
21 pub author: String,
23 pub author_email: String,
25 pub timestamp: DateTime<Utc>,
27 pub message: String,
29 pub lines_added: usize,
31 pub lines_removed: usize,
33}
34
35#[derive(Debug, Clone)]
37pub struct FileHistory {
38 commits: Vec<HistoryEntry>,
40 path: String,
42}
43
44impl FileHistory {
45 pub fn for_file(repo: &GitRepository, path: &Path, limit: usize) -> Result<Self> {
52 let relative_path = Self::make_relative_path(repo, path)?;
53
54 let mut revwalk = repo
55 .inner()
56 .revwalk()
57 .map_err(|e| AcpError::Other(format!("Failed to create revwalk: {}", e)))?;
58
59 revwalk
61 .push_head()
62 .map_err(|e| AcpError::Other(format!("Failed to push HEAD: {}", e)))?;
63
64 revwalk
66 .set_sorting(git2::Sort::TIME)
67 .map_err(|e| AcpError::Other(format!("Failed to set sorting: {}", e)))?;
68
69 let mut commits = Vec::new();
70 let mut count = 0;
71
72 for oid_result in revwalk {
73 if limit > 0 && count >= limit {
74 break;
75 }
76
77 let oid = oid_result
78 .map_err(|e| AcpError::Other(format!("Failed to get commit oid: {}", e)))?;
79
80 if let Some(entry) = Self::commit_touches_file(repo, oid, &relative_path)? {
82 commits.push(entry);
83 count += 1;
84 }
85 }
86
87 Ok(Self {
88 commits,
89 path: relative_path,
90 })
91 }
92
93 pub fn commit_count(&self) -> usize {
95 self.commits.len()
96 }
97
98 pub fn contributors(&self) -> Vec<String> {
100 let mut authors: Vec<String> = self.commits.iter().map(|c| c.author.clone()).collect();
101
102 authors.sort();
103 authors.dedup();
104 authors
105 }
106
107 pub fn entries(&self) -> &[HistoryEntry] {
109 &self.commits
110 }
111
112 pub fn latest(&self) -> Option<&HistoryEntry> {
114 self.commits.first()
115 }
116
117 pub fn oldest(&self) -> Option<&HistoryEntry> {
119 self.commits.last()
120 }
121
122 pub fn path(&self) -> &str {
124 &self.path
125 }
126
127 pub fn total_lines_added(&self) -> usize {
129 self.commits.iter().map(|c| c.lines_added).sum()
130 }
131
132 pub fn total_lines_removed(&self) -> usize {
134 self.commits.iter().map(|c| c.lines_removed).sum()
135 }
136
137 fn commit_touches_file(
139 repo: &GitRepository,
140 oid: Oid,
141 path: &str,
142 ) -> Result<Option<HistoryEntry>> {
143 let commit = repo
144 .inner()
145 .find_commit(oid)
146 .map_err(|e| AcpError::Other(format!("Failed to find commit: {}", e)))?;
147
148 let tree = commit
150 .tree()
151 .map_err(|e| AcpError::Other(format!("Failed to get commit tree: {}", e)))?;
152
153 let file_in_tree = tree.get_path(Path::new(path)).is_ok();
155
156 let parent_count = commit.parent_count();
158
159 if parent_count == 0 {
160 if file_in_tree {
162 return Ok(Some(Self::create_entry(repo, &commit, path, true)?));
163 }
164 return Ok(None);
165 }
166
167 let parent = commit
169 .parent(0)
170 .map_err(|e| AcpError::Other(format!("Failed to get parent commit: {}", e)))?;
171 let parent_tree = parent
172 .tree()
173 .map_err(|e| AcpError::Other(format!("Failed to get parent tree: {}", e)))?;
174
175 let mut diff_opts = DiffOptions::new();
177 diff_opts.pathspec(path);
178
179 let diff = repo
180 .inner()
181 .diff_tree_to_tree(Some(&parent_tree), Some(&tree), Some(&mut diff_opts))
182 .map_err(|e| AcpError::Other(format!("Failed to diff trees: {}", e)))?;
183
184 if diff.deltas().len() == 0 {
186 return Ok(None);
187 }
188
189 Ok(Some(Self::create_entry(repo, &commit, path, false)?))
191 }
192
193 fn create_entry(
195 repo: &GitRepository,
196 commit: &git2::Commit,
197 path: &str,
198 is_initial: bool,
199 ) -> Result<HistoryEntry> {
200 let sig = commit.author();
201 let timestamp = Self::git_time_to_datetime(sig.when());
202
203 let (lines_added, lines_removed) = if is_initial {
205 Self::count_file_lines(repo, commit, path)?
207 } else {
208 Self::calculate_diff_stats(repo, commit, path)?
209 };
210
211 Ok(HistoryEntry {
212 commit: commit.id().to_string(),
213 commit_short: commit.id().to_string().chars().take(7).collect(),
214 author: sig.name().unwrap_or("Unknown").to_string(),
215 author_email: sig.email().unwrap_or("").to_string(),
216 timestamp,
217 message: commit.summary().unwrap_or("").to_string(),
218 lines_added,
219 lines_removed,
220 })
221 }
222
223 fn count_file_lines(
225 repo: &GitRepository,
226 commit: &git2::Commit,
227 path: &str,
228 ) -> Result<(usize, usize)> {
229 let tree = commit
230 .tree()
231 .map_err(|e| AcpError::Other(format!("Failed to get tree: {}", e)))?;
232
233 let entry = tree
234 .get_path(Path::new(path))
235 .map_err(|e| AcpError::Other(format!("Failed to get tree entry: {}", e)))?;
236
237 let blob = repo
238 .inner()
239 .find_blob(entry.id())
240 .map_err(|e| AcpError::Other(format!("Failed to get blob: {}", e)))?;
241
242 let content = std::str::from_utf8(blob.content()).unwrap_or("");
243 let line_count = content.lines().count();
244
245 Ok((line_count, 0))
246 }
247
248 fn calculate_diff_stats(
250 repo: &GitRepository,
251 commit: &git2::Commit,
252 path: &str,
253 ) -> Result<(usize, usize)> {
254 let tree = commit
255 .tree()
256 .map_err(|e| AcpError::Other(format!("Failed to get tree: {}", e)))?;
257
258 let parent = commit
259 .parent(0)
260 .map_err(|e| AcpError::Other(format!("Failed to get parent: {}", e)))?;
261 let parent_tree = parent
262 .tree()
263 .map_err(|e| AcpError::Other(format!("Failed to get parent tree: {}", e)))?;
264
265 let mut diff_opts = DiffOptions::new();
266 diff_opts.pathspec(path);
267
268 let diff = repo
269 .inner()
270 .diff_tree_to_tree(Some(&parent_tree), Some(&tree), Some(&mut diff_opts))
271 .map_err(|e| AcpError::Other(format!("Failed to create diff: {}", e)))?;
272
273 let stats = diff
274 .stats()
275 .map_err(|e| AcpError::Other(format!("Failed to get diff stats: {}", e)))?;
276
277 Ok((stats.insertions(), stats.deletions()))
278 }
279
280 fn make_relative_path(repo: &GitRepository, path: &Path) -> Result<String> {
282 let root = repo.root()?;
283
284 let relative = if path.is_absolute() {
285 path.strip_prefix(root)
286 .map_err(|_| {
287 AcpError::Other(format!(
288 "Path {} is not within repository root {}",
289 path.display(),
290 root.display()
291 ))
292 })?
293 .to_path_buf()
294 } else {
295 let cwd = std::env::current_dir()
298 .map_err(|e| AcpError::Other(format!("Failed to get current directory: {}", e)))?;
299 let absolute = cwd.join(path);
300 absolute
301 .strip_prefix(root)
302 .map_err(|_| {
303 AcpError::Other(format!(
304 "Path {} is not within repository root {}",
305 absolute.display(),
306 root.display()
307 ))
308 })?
309 .to_path_buf()
310 };
311
312 Ok(relative.to_string_lossy().to_string())
313 }
314
315 fn git_time_to_datetime(time: git2::Time) -> DateTime<Utc> {
317 Utc.timestamp_opt(time.seconds(), 0)
318 .single()
319 .unwrap_or_else(Utc::now)
320 }
321}
322
323#[cfg(test)]
324mod tests {
325 use super::*;
326 use std::env;
327
328 #[test]
329 fn test_file_history() {
330 let cwd = env::current_dir().unwrap();
331 if let Ok(repo) = GitRepository::open(&cwd) {
332 let cargo_path = cwd.join("cli/Cargo.toml");
333 if cargo_path.exists() {
334 let history = FileHistory::for_file(&repo, &cargo_path, 10);
335 assert!(history.is_ok(), "Should get file history");
336
337 let info = history.unwrap();
338 assert!(info.commit_count() > 0, "Should have at least one commit");
339 }
340 }
341 }
342
343 #[test]
344 fn test_contributors() {
345 let cwd = env::current_dir().unwrap();
346 if let Ok(repo) = GitRepository::open(&cwd) {
347 let cargo_path = cwd.join("cli/Cargo.toml");
348 if cargo_path.exists() {
349 if let Ok(history) = FileHistory::for_file(&repo, &cargo_path, 100) {
350 let contributors = history.contributors();
351 assert!(!contributors.is_empty(), "Should have contributors");
352 }
353 }
354 }
355 }
356
357 #[test]
358 fn test_latest_commit() {
359 let cwd = env::current_dir().unwrap();
360 if let Ok(repo) = GitRepository::open(&cwd) {
361 let cargo_path = cwd.join("cli/Cargo.toml");
362 if cargo_path.exists() {
363 if let Ok(history) = FileHistory::for_file(&repo, &cargo_path, 10) {
364 let latest = history.latest();
365 assert!(latest.is_some(), "Should have a latest commit");
366
367 if let Some(entry) = latest {
368 assert_eq!(entry.commit.len(), 40);
369 assert!(!entry.author.is_empty());
370 }
371 }
372 }
373 }
374 }
375}