impl CommitParser {
pub fn open(path: &Path) -> Result<Self> {
let repo = Repository::discover(path)
.with_context(|| format!("Failed to open git repository at {:?}", path))?;
Ok(Self { repo })
}
pub fn parse_commits(
&self,
since: Option<&str>,
limit: Option<usize>,
) -> Result<Vec<CommitInfo>> {
let mut revwalk = self.repo.revwalk()?;
revwalk.set_sorting(Sort::TIME | Sort::REVERSE)?;
revwalk.push_head()?;
if let Some(since_hash) = since {
if let Ok(oid) = git2::Oid::from_str(since_hash) {
revwalk.hide(oid)?;
}
}
let mut commits = Vec::new();
let mut count = 0;
for oid_result in revwalk {
if let Some(max) = limit {
if count >= max {
break;
}
}
let oid = oid_result?;
let commit = self.repo.find_commit(oid)?;
if let Some(info) = self.parse_commit(&commit)? {
commits.push(info);
count += 1;
}
}
Ok(commits)
}
fn parse_commit(&self, commit: &Commit) -> Result<Option<CommitInfo>> {
let hash = commit.id().to_string();
let message = commit.message().unwrap_or("");
let (subject, body) = Self::split_message(message);
let author = commit.author();
let author_name = author.name().unwrap_or("Unknown").to_string();
let author_email = author.email().unwrap_or("").to_string();
let timestamp = commit.time().seconds();
let is_merge = commit.parent_count() > 1;
let is_fix = Self::is_fix_commit(&subject);
let is_feat = Self::is_feat_commit(&subject);
let issue_refs = Self::extract_issue_refs(&subject, body.as_deref().unwrap_or(""));
let files = self.get_file_changes(commit)?;
Ok(Some(CommitInfo {
hash,
message_subject: subject,
message_body: body,
author_name,
author_email,
timestamp,
is_merge,
is_fix,
is_feat,
issue_refs,
files,
}))
}
fn split_message(message: &str) -> (String, Option<String>) {
let lines: Vec<&str> = message.lines().collect();
if lines.is_empty() {
return (String::new(), None);
}
let subject = lines[0].trim().to_string();
let body_start = lines.iter().skip(1).position(|l| l.trim().is_empty());
let body = if let Some(start) = body_start {
let body_lines: Vec<&str> = lines.iter().skip(start + 2).copied().collect();
if body_lines.is_empty() {
None
} else {
Some(body_lines.join("\n").trim().to_string())
}
} else {
None
};
(subject, body)
}
fn is_fix_commit(subject: &str) -> bool {
let lower = subject.to_lowercase();
lower.starts_with("fix:")
|| lower.starts_with("fix(")
|| lower.starts_with("bugfix:")
|| lower.starts_with("hotfix:")
|| lower.contains("fix ")
|| lower.contains("fixed ")
|| lower.contains("fixes ")
}
fn is_feat_commit(subject: &str) -> bool {
let lower = subject.to_lowercase();
lower.starts_with("feat:") || lower.starts_with("feat(") || lower.starts_with("feature:")
}
fn extract_issue_refs(subject: &str, body: &str) -> Vec<String> {
let mut refs = Vec::new();
let full_text = format!("{} {}", subject, body);
let github_re = regex::Regex::new(r"#(\d+)").expect("valid regex");
for cap in github_re.captures_iter(&full_text) {
refs.push(format!("#{}", &cap[1]));
}
let jira_re = regex::Regex::new(r"([A-Z]+-\d+)").expect("valid regex");
for cap in jira_re.captures_iter(&full_text) {
let issue = cap[1].to_string();
if !refs.contains(&issue) {
refs.push(issue);
}
}
refs
}
fn get_file_changes(&self, commit: &Commit) -> Result<Vec<FileChange>> {
let mut changes = Vec::new();
let tree = commit.tree()?;
let parent_tree = if commit.parent_count() > 0 {
Some(commit.parent(0)?.tree()?)
} else {
None
};
let mut diff_opts = DiffOptions::new();
let diff =
self.repo
.diff_tree_to_tree(parent_tree.as_ref(), Some(&tree), Some(&mut diff_opts))?;
diff.foreach(
&mut |delta, _progress| {
let path = delta
.new_file()
.path()
.or_else(|| delta.old_file().path())
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
let change_type = match delta.status() {
git2::Delta::Added => ChangeType::Added,
git2::Delta::Deleted => ChangeType::Deleted,
git2::Delta::Renamed => ChangeType::Renamed,
_ => ChangeType::Modified,
};
changes.push(FileChange {
path,
change_type,
lines_added: 0,
lines_deleted: 0,
});
true
},
None,
None,
None,
)?;
let stats = diff.stats()?;
let _insertions = stats.insertions();
let _deletions = stats.deletions();
Ok(changes)
}
pub fn head_commit_hash(&self) -> Result<String> {
let head = self.repo.head()?;
let commit = head.peel_to_commit()?;
Ok(commit.id().to_string())
}
}