garbage_code_hunter/commit_roaster/
analyzer.rs1use anyhow::{Context, Result};
7use git2::{Repository, Sort};
8use std::path::Path;
9
10#[derive(Debug, Clone)]
12pub struct CommitInfo {
13 pub hash: String,
14 pub short_hash: String,
15 pub author: String,
16 pub message: String,
17 pub timestamp: i64,
18 pub files_changed: usize,
19 pub insertions: usize,
20 pub deletions: usize,
21}
22
23#[derive(Debug, Clone)]
25pub struct AnalyzerConfig {
26 pub limit: Option<usize>,
28 pub author: Option<String>,
30 pub since: Option<i64>,
32 pub until: Option<i64>,
34 pub branch: Option<String>,
36}
37
38impl Default for AnalyzerConfig {
39 fn default() -> Self {
40 Self {
41 limit: Some(50),
42 author: None,
43 since: None,
44 until: None,
45 branch: None,
46 }
47 }
48}
49
50pub fn analyze_repo(repo_path: &Path, config: &AnalyzerConfig) -> Result<Vec<CommitInfo>> {
52 let repo = Repository::open(repo_path)
53 .with_context(|| format!("Failed to open git repo at {}", repo_path.display()))?;
54
55 let mut revwalk = repo.revwalk().context("Failed to create revwalk")?;
56
57 revwalk
58 .set_sorting(Sort::TIME)
59 .context("Failed to set sorting")?;
60
61 if let Some(ref branch) = config.branch {
63 let refname = format!("refs/heads/{}", branch);
64 let reference = repo
65 .find_reference(&refname)
66 .with_context(|| format!("Branch '{}' not found", branch))?;
67 revwalk
68 .push(reference.peel_to_commit()?.id())
69 .context("Failed to push branch tip")?;
70 } else {
71 revwalk.push_head().context("Failed to push HEAD")?;
72 }
73
74 let mut commits = Vec::new();
75
76 for oid_result in revwalk {
77 let oid = oid_result.context("Failed to read commit OID")?;
78 let commit = repo.find_commit(oid).context("Failed to find commit")?;
79
80 if let Some(ref author_filter) = config.author {
82 let author_name = commit.author().name().unwrap_or("").to_string();
83 let author_email = commit.author().email().unwrap_or("").to_string();
84 if !author_name.contains(author_filter.as_str())
85 && !author_email.contains(author_filter.as_str())
86 {
87 continue;
88 }
89 }
90
91 let timestamp = commit.time().seconds();
93 if let Some(since) = config.since {
94 if timestamp < since {
95 continue;
96 }
97 }
98 if let Some(until) = config.until {
99 if timestamp > until {
100 continue;
101 }
102 }
103
104 let message = commit.message().unwrap_or("").to_string();
105
106 let hash = oid.to_string();
107 let short_hash = oid.to_string()[..7].to_string();
108
109 let author = commit.author().name().unwrap_or("unknown").to_string();
110
111 let (files_changed, insertions, deletions) =
113 count_changes(&repo, &commit).unwrap_or((0, 0, 0));
114
115 commits.push(CommitInfo {
116 hash,
117 short_hash,
118 author,
119 message,
120 timestamp,
121 files_changed,
122 insertions,
123 deletions,
124 });
125
126 if let Some(limit) = config.limit {
128 if commits.len() >= limit {
129 break;
130 }
131 }
132 }
133
134 Ok(commits)
135}
136
137fn count_changes(repo: &Repository, commit: &git2::Commit) -> Result<(usize, usize, usize)> {
139 let tree = commit.tree().context("Failed to get commit tree")?;
140
141 let parent_tree = if commit.parent_count() > 0 {
142 Some(
143 commit
144 .parent(0)
145 .context("Failed to get parent commit")?
146 .tree()
147 .context("Failed to get parent tree")?,
148 )
149 } else {
150 None
151 };
152
153 let diff = repo
154 .diff_tree_to_tree(parent_tree.as_ref(), Some(&tree), None)
155 .context("Failed to compute diff")?;
156
157 let stats = diff.stats().context("Failed to compute diff stats")?;
158
159 Ok((stats.files_changed(), stats.insertions(), stats.deletions()))
160}
161
162pub fn first_line(message: &str) -> &str {
164 message.lines().next().unwrap_or("").trim()
165}
166
167#[cfg(test)]
168mod tests {
169 use super::*;
170
171 #[test]
172 fn test_first_line_single_line() {
173 assert_eq!(first_line("fix: something"), "fix: something");
174 }
175
176 #[test]
177 fn test_first_line_multi_line() {
178 let msg = "fix: something\n\nThis is a longer description\nwith multiple lines.";
179 assert_eq!(first_line(msg), "fix: something");
180 }
181
182 #[test]
183 fn test_first_line_empty() {
184 assert_eq!(first_line(""), "");
185 }
186
187 #[test]
188 fn test_first_line_whitespace() {
189 assert_eq!(first_line(" fix: something "), "fix: something");
190 }
191
192 #[test]
193 fn test_analyzer_config_default() {
194 let config = AnalyzerConfig::default();
195 assert_eq!(config.limit, Some(50));
196 assert!(config.author.is_none());
197 assert!(config.since.is_none());
198 assert!(config.until.is_none());
199 assert!(config.branch.is_none());
200 }
201}