acp/git/
history.rs

1//! @acp:module "Git History"
2//! @acp:summary "File commit history and contributor tracking"
3//! @acp:domain cli
4//! @acp:layer integration
5
6use 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/// A single entry in file history
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct HistoryEntry {
17    /// Commit SHA
18    pub commit: String,
19    /// Short commit SHA (7 chars)
20    pub commit_short: String,
21    /// Author name
22    pub author: String,
23    /// Author email
24    pub author_email: String,
25    /// Commit timestamp
26    pub timestamp: DateTime<Utc>,
27    /// Commit message (first line)
28    pub message: String,
29    /// Lines added in this commit (for this file)
30    pub lines_added: usize,
31    /// Lines removed in this commit (for this file)
32    pub lines_removed: usize,
33}
34
35/// File history containing commits that touched a specific file
36#[derive(Debug, Clone)]
37pub struct FileHistory {
38    /// Ordered list of history entries (newest first)
39    commits: Vec<HistoryEntry>,
40    /// Path of the file
41    path: String,
42}
43
44impl FileHistory {
45    /// Get the commit history for a file
46    ///
47    /// # Arguments
48    /// * `repo` - The git repository
49    /// * `path` - Path to the file
50    /// * `limit` - Maximum number of commits to retrieve (0 = unlimited)
51    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        // Start from HEAD
60        revwalk
61            .push_head()
62            .map_err(|e| AcpError::Other(format!("Failed to push HEAD: {}", e)))?;
63
64        // Sort by time (newest first)
65        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            // Check if this commit touched our file
81            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    /// Get the number of commits
94    pub fn commit_count(&self) -> usize {
95        self.commits.len()
96    }
97
98    /// Get unique contributors
99    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    /// Get all history entries
108    pub fn entries(&self) -> &[HistoryEntry] {
109        &self.commits
110    }
111
112    /// Get the most recent commit
113    pub fn latest(&self) -> Option<&HistoryEntry> {
114        self.commits.first()
115    }
116
117    /// Get the oldest commit
118    pub fn oldest(&self) -> Option<&HistoryEntry> {
119        self.commits.last()
120    }
121
122    /// Get the file path
123    pub fn path(&self) -> &str {
124        &self.path
125    }
126
127    /// Get total lines added across all commits
128    pub fn total_lines_added(&self) -> usize {
129        self.commits.iter().map(|c| c.lines_added).sum()
130    }
131
132    /// Get total lines removed across all commits
133    pub fn total_lines_removed(&self) -> usize {
134        self.commits.iter().map(|c| c.lines_removed).sum()
135    }
136
137    /// Check if a commit touches the specified file and return entry if so
138    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        // Get the commit's tree
149        let tree = commit
150            .tree()
151            .map_err(|e| AcpError::Other(format!("Failed to get commit tree: {}", e)))?;
152
153        // Check if the file exists in this commit's tree
154        let file_in_tree = tree.get_path(Path::new(path)).is_ok();
155
156        // For the first commit or merge commits, check differently
157        let parent_count = commit.parent_count();
158
159        if parent_count == 0 {
160            // Initial commit - if file exists, it was added here
161            if file_in_tree {
162                return Ok(Some(Self::create_entry(repo, &commit, path, true)?));
163            }
164            return Ok(None);
165        }
166
167        // Get parent tree
168        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        // Check if file changed between parent and this commit
176        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 there are no deltas for this file, it wasn't changed
185        if diff.deltas().len() == 0 {
186            return Ok(None);
187        }
188
189        // File was changed in this commit
190        Ok(Some(Self::create_entry(repo, &commit, path, false)?))
191    }
192
193    /// Create a history entry from a commit
194    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        // Calculate lines added/removed
204        let (lines_added, lines_removed) = if is_initial {
205            // For initial commit, count all lines as added
206            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    /// Count lines in a file for initial commit
224    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    /// Calculate diff stats for a commit
249    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    /// Helper: make path relative to repo root
281    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            // Path is relative - need to make it relative to repo root
296            // Get current working directory and resolve the path
297            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    /// Helper: convert git2 time to chrono DateTime
316    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}