use std::path::Path;
use argus_core::ArgusError;
use git2::{Delta, DiffOptions, Repository, Sort};
#[derive(Debug, Clone)]
pub struct CommitInfo {
pub hash: String,
pub author: String,
pub email: String,
pub timestamp: i64,
pub message: String,
pub files_changed: Vec<FileChange>,
}
#[derive(Debug, Clone)]
pub struct FileChange {
pub path: String,
pub lines_added: u64,
pub lines_deleted: u64,
pub status: ChangeStatus,
}
#[derive(Debug, Clone, PartialEq)]
pub enum ChangeStatus {
Added,
Modified,
Deleted,
Renamed {
from: String,
},
}
pub struct MiningOptions {
pub since_days: u64,
pub max_files_per_commit: usize,
pub branch: Option<String>,
}
impl Default for MiningOptions {
fn default() -> Self {
Self {
since_days: 180,
max_files_per_commit: 25,
branch: None,
}
}
}
pub fn mine_history(
repo_path: &Path,
options: &MiningOptions,
) -> Result<Vec<CommitInfo>, ArgusError> {
let repo = Repository::open(repo_path)
.map_err(|e| ArgusError::Git(format!("failed to open repository: {e}")))?;
let mut revwalk = repo
.revwalk()
.map_err(|e| ArgusError::Git(format!("failed to create revwalk: {e}")))?;
revwalk.set_sorting(Sort::TIME).ok();
if let Some(ref branch) = options.branch {
let reference = repo
.resolve_reference_from_short_name(branch)
.map_err(|e| ArgusError::Git(format!("failed to resolve branch '{branch}': {e}")))?;
let oid = reference
.target()
.ok_or_else(|| ArgusError::Git("branch has no target".into()))?;
revwalk
.push(oid)
.map_err(|e| ArgusError::Git(format!("failed to push oid: {e}")))?;
} else {
revwalk
.push_head()
.map_err(|e| ArgusError::Git(format!("failed to push HEAD: {e}")))?;
}
let cutoff = compute_cutoff(options.since_days);
let mut commits = Vec::new();
for oid_result in revwalk {
let oid = oid_result.map_err(|e| ArgusError::Git(format!("revwalk error: {e}")))?;
let commit = repo
.find_commit(oid)
.map_err(|e| ArgusError::Git(format!("failed to find commit: {e}")))?;
let timestamp = commit.time().seconds();
if timestamp < cutoff {
break;
}
let parent_count = commit.parent_count();
if parent_count > 1 {
let file_count = count_diff_files(&repo, &commit)?;
if file_count > options.max_files_per_commit {
continue;
}
}
let files_changed = extract_file_changes(&repo, &commit)?;
if files_changed.len() > options.max_files_per_commit {
continue;
}
let author = commit.author();
let hash = oid.to_string();
commits.push(CommitInfo {
hash: hash[..hash.len().min(8)].to_string(),
author: author.name().unwrap_or("unknown").to_string(),
email: author.email().unwrap_or("unknown").to_string(),
timestamp,
message: commit
.message()
.unwrap_or("")
.lines()
.next()
.unwrap_or("")
.to_string(),
files_changed,
});
}
Ok(commits)
}
fn compute_cutoff(since_days: u64) -> i64 {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
now - (since_days as i64 * 86400)
}
fn count_diff_files(repo: &Repository, commit: &git2::Commit) -> Result<usize, ArgusError> {
let commit_tree = commit
.tree()
.map_err(|e| ArgusError::Git(format!("failed to get commit tree: {e}")))?;
let parent_tree = if commit.parent_count() > 0 {
let parent = commit
.parent(0)
.map_err(|e| ArgusError::Git(format!("failed to get parent: {e}")))?;
Some(
parent
.tree()
.map_err(|e| ArgusError::Git(format!("failed to get parent tree: {e}")))?,
)
} else {
None
};
let mut diff_opts = DiffOptions::new();
let diff = repo
.diff_tree_to_tree(
parent_tree.as_ref(),
Some(&commit_tree),
Some(&mut diff_opts),
)
.map_err(|e| ArgusError::Git(format!("failed to compute diff: {e}")))?;
Ok(diff.deltas().len())
}
fn extract_file_changes(
repo: &Repository,
commit: &git2::Commit,
) -> Result<Vec<FileChange>, ArgusError> {
let commit_tree = commit
.tree()
.map_err(|e| ArgusError::Git(format!("failed to get commit tree: {e}")))?;
let parent_tree = if commit.parent_count() > 0 {
let parent = commit
.parent(0)
.map_err(|e| ArgusError::Git(format!("failed to get parent: {e}")))?;
Some(
parent
.tree()
.map_err(|e| ArgusError::Git(format!("failed to get parent tree: {e}")))?,
)
} else {
None
};
let mut diff_opts = DiffOptions::new();
let diff = repo
.diff_tree_to_tree(
parent_tree.as_ref(),
Some(&commit_tree),
Some(&mut diff_opts),
)
.map_err(|e| ArgusError::Git(format!("failed to compute diff: {e}")))?;
let mut find_opts = git2::DiffFindOptions::new();
find_opts.renames(true);
let mut diff = diff;
diff.find_similar(Some(&mut find_opts))
.map_err(|e| ArgusError::Git(format!("failed to find renames: {e}")))?;
let mut changes = Vec::new();
let num_deltas = diff.deltas().len();
for delta_idx in 0..num_deltas {
let delta = diff.get_delta(delta_idx).unwrap();
let new_file = delta.new_file();
let path = new_file
.path()
.unwrap_or(Path::new(""))
.to_string_lossy()
.to_string();
if path.is_empty() {
continue;
}
let status = match delta.status() {
Delta::Added => ChangeStatus::Added,
Delta::Deleted => {
let old_path = delta
.old_file()
.path()
.unwrap_or(Path::new(""))
.to_string_lossy()
.to_string();
changes.push(FileChange {
path: old_path,
lines_added: 0,
lines_deleted: 0,
status: ChangeStatus::Deleted,
});
continue;
}
Delta::Modified => ChangeStatus::Modified,
Delta::Renamed => {
let old_path = delta
.old_file()
.path()
.unwrap_or(Path::new(""))
.to_string_lossy()
.to_string();
ChangeStatus::Renamed { from: old_path }
}
_ => ChangeStatus::Modified,
};
changes.push(FileChange {
path,
lines_added: 0,
lines_deleted: 0,
status,
});
}
let mut line_counts: std::collections::HashMap<String, (u64, u64)> =
std::collections::HashMap::new();
diff.foreach(
&mut |_delta, _progress| true,
None,
None,
Some(&mut |delta, _hunk, line| {
let path = delta
.new_file()
.path()
.or_else(|| delta.old_file().path())
.unwrap_or(Path::new(""))
.to_string_lossy()
.to_string();
let entry = line_counts.entry(path).or_insert((0, 0));
match line.origin() {
'+' => entry.0 += 1,
'-' => entry.1 += 1,
_ => {}
}
true
}),
)
.map_err(|e| ArgusError::Git(format!("failed to iterate diff lines: {e}")))?;
for change in &mut changes {
if let Some((added, deleted)) = line_counts.get(&change.path) {
change.lines_added = *added;
change.lines_deleted = *deleted;
}
}
Ok(changes)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mining_options_defaults_are_correct() {
let opts = MiningOptions::default();
assert_eq!(opts.since_days, 180);
assert_eq!(opts.max_files_per_commit, 25);
assert!(opts.branch.is_none());
}
#[test]
fn mine_argus_repo_returns_commits() {
let repo_path = find_repo_root().expect("should find repo root");
let opts = MiningOptions {
since_days: 365,
..MiningOptions::default()
};
let commits = mine_history(&repo_path, &opts).unwrap();
assert!(!commits.is_empty(), "argus repo should have commits");
let first = &commits[0];
assert!(!first.hash.is_empty());
assert!(!first.author.is_empty());
assert!(first.timestamp > 0);
}
#[test]
fn large_commits_are_skipped() {
let repo_path = find_repo_root().expect("should find repo root");
let opts = MiningOptions {
since_days: 365,
max_files_per_commit: 2, ..MiningOptions::default()
};
let commits = mine_history(&repo_path, &opts).unwrap();
for commit in &commits {
assert!(
commit.files_changed.len() <= 2,
"commit {} has {} files, expected <= 2",
commit.hash,
commit.files_changed.len()
);
}
}
#[test]
fn change_status_identifies_correctly() {
let added = ChangeStatus::Added;
let modified = ChangeStatus::Modified;
let deleted = ChangeStatus::Deleted;
let renamed = ChangeStatus::Renamed {
from: "old.rs".into(),
};
assert_eq!(added, ChangeStatus::Added);
assert_eq!(modified, ChangeStatus::Modified);
assert_eq!(deleted, ChangeStatus::Deleted);
assert_ne!(renamed, ChangeStatus::Modified);
}
fn find_repo_root() -> Option<std::path::PathBuf> {
let mut path = std::env::current_dir().ok()?;
loop {
if path.join(".git").exists() {
return Some(path);
}
if !path.pop() {
return None;
}
}
}
}