kardo_core/git/
analysis.rs1use chrono::{DateTime, Utc};
4use git2::Repository;
5use serde::Serialize;
6use std::path::{Path, PathBuf};
7use thiserror::Error;
8
9#[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#[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
31pub struct GitAnalyzer {
33 repo: Repository,
34 root: PathBuf,
35}
36
37impl GitAnalyzer {
38 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 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 if tree.get_path(Path::new(relative_path)).is_err() {
65 continue;
66 }
67
68 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 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 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 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}