Skip to main content

garbage_code_hunter/commit_roaster/
analyzer.rs

1//! Git commit analyzer using libgit2.
2//!
3//! Reads commit history from a git repository and collects
4//! commit metadata for rule evaluation.
5
6use anyhow::{Context, Result};
7use git2::{Repository, Sort};
8use std::path::Path;
9
10/// Parsed commit information extracted from git history.
11#[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/// Configuration for filtering which commits to analyze.
24#[derive(Debug, Clone)]
25pub struct AnalyzerConfig {
26    /// Maximum number of commits to analyze. None = all.
27    pub limit: Option<usize>,
28    /// Only commits by this author.
29    pub author: Option<String>,
30    /// Only commits after this timestamp.
31    pub since: Option<i64>,
32    /// Only commits before this timestamp.
33    pub until: Option<i64>,
34    /// Branch name to analyze. None = HEAD.
35    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
50/// Open a git repository and extract commit history.
51pub 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    // Determine starting point
62    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        // Apply author filter
81        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        // Apply time filters
92        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        // Count file changes via diff
112        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        // Apply limit
127        if let Some(limit) = config.limit {
128            if commits.len() >= limit {
129                break;
130            }
131        }
132    }
133
134    Ok(commits)
135}
136
137/// Count file changes, insertions, and deletions for a commit.
138fn 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
162/// Truncate commit message to first line only.
163pub 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}