Skip to main content

kardo_core/git/
analysis.rs

1//! Git file analysis — per-file commit history, authorship, and staleness metadata.
2
3use chrono::{DateTime, Utc};
4use git2::Repository;
5use serde::Serialize;
6use std::path::{Path, PathBuf};
7use thiserror::Error;
8
9/// Errors that can occur during git analysis.
10#[derive(Debug, Error)]
11pub enum GitError {
12    #[error("Git error: {0}")]
13    Git(#[from] git2::Error),
14    #[error("Not a git repository: {0}")]
15    NotARepo(String),
16    #[error("IO error: {0}")]
17    Io(#[from] std::io::Error),
18}
19
20/// Git metadata for a single tracked file.
21#[derive(Debug, Clone, Serialize)]
22pub struct GitFileInfo {
23    pub path: PathBuf,
24    pub relative_path: String,
25    pub last_modified: Option<String>,
26    pub commit_count: usize,
27    pub last_author: Option<String>,
28    pub days_since_modified: Option<i64>,
29}
30
31/// Analyzes git history for file-level commit metadata.
32pub struct GitAnalyzer {
33    repo: Repository,
34    root: PathBuf,
35}
36
37impl GitAnalyzer {
38    /// Open a git repository at the given path
39    pub fn open(project_root: impl AsRef<Path>) -> Result<Self, GitError> {
40        let project_root = project_root.as_ref();
41        let repo = Repository::open(project_root).map_err(|_| {
42            GitError::NotARepo(project_root.display().to_string())
43        })?;
44        let root = project_root.to_path_buf();
45        Ok(Self { repo, root })
46    }
47
48    /// Analyze a single file: get last commit date, commit count, last author
49    pub fn analyze_file(&self, relative_path: &str) -> Result<GitFileInfo, GitError> {
50        let mut revwalk = self.repo.revwalk()?;
51        revwalk.push_head()?;
52        revwalk.set_sorting(git2::Sort::TIME)?;
53
54        let mut commit_count: usize = 0;
55        let mut last_modified: Option<DateTime<Utc>> = None;
56        let mut last_author: Option<String> = None;
57
58        for oid in revwalk {
59            let oid = oid?;
60            let commit = self.repo.find_commit(oid)?;
61
62            let tree = commit.tree()?;
63            // Check if this file exists in this commit's tree
64            if tree.get_path(Path::new(relative_path)).is_err() {
65                continue;
66            }
67
68            // Check if file was modified in this commit vs its parent
69            let parent_tree = commit
70                .parent(0)
71                .ok()
72                .and_then(|p| p.tree().ok());
73
74            let diff = self.repo.diff_tree_to_tree(
75                parent_tree.as_ref(),
76                Some(&tree),
77                None,
78            )?;
79
80            let file_changed = diff.deltas().any(|delta| {
81                let new_path = delta.new_file().path();
82                let old_path = delta.old_file().path();
83                let target = Path::new(relative_path);
84                new_path == Some(target) || old_path == Some(target)
85            });
86
87            if file_changed {
88                commit_count += 1;
89
90                if last_modified.is_none() {
91                    let time = commit.time();
92                    let dt = DateTime::from_timestamp(time.seconds(), 0)
93                        .unwrap_or_default();
94                    last_modified = Some(dt);
95                    last_author = Some(
96                        commit.author().name().unwrap_or("unknown").to_string(),
97                    );
98                }
99            }
100        }
101
102        let days_since = last_modified.map(|dt| {
103            let now = Utc::now();
104            (now - dt).num_days()
105        });
106
107        Ok(GitFileInfo {
108            path: self.root.join(relative_path),
109            relative_path: relative_path.to_string(),
110            last_modified: last_modified.map(|dt| dt.to_rfc3339()),
111            commit_count,
112            last_author,
113            days_since_modified: days_since,
114        })
115    }
116
117    /// Get the short HEAD commit SHA (8 chars).
118    pub fn head_sha(&self) -> Option<String> {
119        let head = self.repo.head().ok()?;
120        let oid = head.target()?;
121        Some(oid.to_string()[..8].to_string())
122    }
123
124    /// Get the current branch name.
125    pub fn current_branch(&self) -> Option<String> {
126        let head = self.repo.head().ok()?;
127        if head.is_branch() {
128            head.shorthand().map(|s| s.to_string())
129        } else {
130            Some("HEAD".to_string())
131        }
132    }
133
134    /// Analyze all given files
135    pub fn analyze_files(
136        &self,
137        relative_paths: &[String],
138    ) -> Result<Vec<GitFileInfo>, GitError> {
139        relative_paths
140            .iter()
141            .map(|path| self.analyze_file(path))
142            .collect()
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149    use git2::Signature;
150    use std::fs;
151    use tempfile::TempDir;
152
153    fn commit_file(
154        repo: &Repository,
155        path: &str,
156        content: &str,
157        message: &str,
158    ) {
159        let root = repo.workdir().unwrap();
160        let file_path = root.join(path);
161        if let Some(parent) = file_path.parent() {
162            fs::create_dir_all(parent).unwrap();
163        }
164        fs::write(&file_path, content).unwrap();
165
166        let mut index = repo.index().unwrap();
167        index.add_path(Path::new(path)).unwrap();
168        index.write().unwrap();
169
170        let tree_id = index.write_tree().unwrap();
171        let tree = repo.find_tree(tree_id).unwrap();
172        let sig = Signature::now("Test", "test@test.com").unwrap();
173
174        let parent = repo.head().ok().and_then(|h| h.peel_to_commit().ok());
175        let parents: Vec<&git2::Commit> = parent.iter().collect();
176
177        repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &parents)
178            .unwrap();
179    }
180
181    fn create_test_repo() -> (TempDir, Repository) {
182        let dir = TempDir::new().unwrap();
183        let repo = Repository::init(dir.path()).unwrap();
184        commit_file(&repo, "README.md", "# Hello", "Initial commit");
185        (dir, repo)
186    }
187
188    #[test]
189    fn test_analyze_file_basic() {
190        let (dir, _repo) = create_test_repo();
191        let analyzer = GitAnalyzer::open(dir.path()).unwrap();
192        let info = analyzer.analyze_file("README.md").unwrap();
193
194        assert_eq!(info.relative_path, "README.md");
195        assert_eq!(info.commit_count, 1);
196        assert!(info.last_modified.is_some());
197        assert_eq!(info.last_author.as_deref(), Some("Test"));
198    }
199
200    #[test]
201    fn test_analyze_file_multiple_commits() {
202        let (dir, repo) = create_test_repo();
203        commit_file(&repo, "README.md", "# Updated", "Second commit");
204        commit_file(&repo, "README.md", "# Final", "Third commit");
205
206        let analyzer = GitAnalyzer::open(dir.path()).unwrap();
207        let info = analyzer.analyze_file("README.md").unwrap();
208
209        assert_eq!(info.commit_count, 3);
210        assert!(info.last_modified.is_some());
211    }
212
213    #[test]
214    fn test_analyze_file_not_found() {
215        let (dir, _repo) = create_test_repo();
216        let analyzer = GitAnalyzer::open(dir.path()).unwrap();
217        let info = analyzer.analyze_file("nonexistent.txt").unwrap();
218
219        assert_eq!(info.commit_count, 0);
220        assert!(info.last_modified.is_none());
221        assert!(info.last_author.is_none());
222    }
223
224    #[test]
225    fn test_days_since_modified() {
226        let (dir, _repo) = create_test_repo();
227        let analyzer = GitAnalyzer::open(dir.path()).unwrap();
228        let info = analyzer.analyze_file("README.md").unwrap();
229
230        assert!(info.days_since_modified.is_some());
231        let days = info.days_since_modified.unwrap();
232        assert!((0..=1).contains(&days));
233    }
234
235    #[test]
236    fn test_analyze_files_batch() {
237        let (dir, repo) = create_test_repo();
238        commit_file(&repo, "src/main.rs", "fn main() {}", "Add main");
239
240        let analyzer = GitAnalyzer::open(dir.path()).unwrap();
241        let paths = vec!["README.md".to_string(), "src/main.rs".to_string()];
242        let results = analyzer.analyze_files(&paths).unwrap();
243
244        assert_eq!(results.len(), 2);
245        assert_eq!(results[0].commit_count, 1);
246        assert_eq!(results[1].commit_count, 1);
247    }
248}