use regex::Regex;
use std::sync::LazyLock;
use std::{cell::OnceCell, str::FromStr};
use crate::{Actor, Error, ModifiedFile, Repository};
fn iter_co_authors(haystack: &str) -> impl Iterator<Item = &str> {
const CO_AUTHOR_REGEX: &str = r"(?m)^Co-authored-by: (.*) <(.*?)>$";
static RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(CO_AUTHOR_REGEX).unwrap());
let prefix = "Co-authored-by:";
RE.find_iter(haystack).map(move |re_match| {
re_match
.as_str()
.strip_prefix(prefix)
.unwrap_or_default()
.trim()
})
}
pub struct Commit<'repo> {
inner: git2::Commit<'repo>,
ctx: &'repo Repository,
cache: OnceCell<git2::Diff<'repo>>,
}
impl<'repo> Commit<'repo> {
pub fn new(commit: git2::Commit<'repo>, repository: &'repo Repository) -> Self {
Self {
inner: commit.to_owned(),
ctx: repository,
cache: OnceCell::new(),
}
}
pub fn hash(&self) -> String {
self.inner.id().to_string()
}
pub fn msg(&self) -> Option<&str> {
self.inner.message()
}
pub fn author(&self) -> Actor {
Actor::new(self.inner.author())
}
pub fn co_authors(&self) -> impl Iterator<Item = Result<Actor, Error>> {
let commit_msg = self.msg().unwrap_or_default();
iter_co_authors(commit_msg).map(Actor::from_str)
}
pub fn committer(&self) -> Actor {
Actor::new(self.inner.committer())
}
pub fn branches(&self) -> Result<impl Iterator<Item = Result<String, Error>>, Error> {
self.branch_iterator(None)
}
pub fn local_branches(&self) -> Result<impl Iterator<Item = Result<String, Error>>, Error> {
let flag = Some(git2::BranchType::Local);
self.branch_iterator(flag)
}
pub fn remote_branches(&self) -> Result<impl Iterator<Item = Result<String, Error>>, Error> {
let flag = Some(git2::BranchType::Remote);
self.branch_iterator(flag)
}
pub fn parents(&self) -> impl Iterator<Item = String> {
self.inner.parent_ids().map(|id| id.to_string())
}
pub fn is_merge(&self) -> bool {
self.inner.parent_count() > 1
}
pub fn in_main(&self) -> Result<bool, Error> {
let b = self
.local_branches()?
.collect::<Vec<Result<String, Error>>>();
Ok(b.contains(&Ok("main".to_string())) || b.contains(&Ok("master".to_string())))
}
pub fn mod_files(&self) -> Result<impl Iterator<Item = ModifiedFile<'_>>, Error> {
let diff = self.diff()?;
Ok((0..diff.deltas().len()).map(move |n| ModifiedFile::new(diff, n)))
}
pub fn insertions(&self) -> Result<usize, Error> {
Ok(self.stats()?.insertions())
}
pub fn deletions(&self) -> Result<usize, Error> {
Ok(self.stats()?.deletions())
}
pub fn lines(&self) -> Result<usize, Error> {
Ok(self.insertions()? + self.deletions()?)
}
pub fn files(&self) -> Result<usize, Error> {
Ok(self.stats()?.files_changed())
}
fn stats(&self) -> Result<git2::DiffStats, Error> {
let diff = self.diff()?;
diff.stats().map_err(Error::Git)
}
fn diff(&self) -> Result<&git2::Diff<'repo>, Error> {
let diff = self.calculate_diff()?;
Ok(self.cache.get_or_init(|| diff))
}
fn calculate_diff(&self) -> Result<git2::Diff<'repo>, Error> {
let this_tree = self.inner.tree().ok();
let parent_tree = self.resolve_parent_tree()?;
self.ctx
.raw()
.diff_tree_to_tree(parent_tree.as_ref(), this_tree.as_ref(), None)
.map_err(Error::Git)
}
fn resolve_parent_tree(&self) -> Result<Option<git2::Tree<'_>>, Error> {
Ok(match self.inner.parent_count() {
0 => None,
1 => self.inner.parent(0).map_err(Error::Git)?.tree().ok(),
_ => return Err(Error::PathError("Placeholder error".to_string())),
})
}
fn commit_contains_branch(&self, branch: git2::Oid, commit: git2::Oid) -> bool {
self.ctx.raw().graph_descendant_of(branch, commit).is_ok()
}
fn branch_iterator(
&self,
bt: Option<git2::BranchType>,
) -> Result<impl Iterator<Item = Result<String, Error>>, Error> {
let commit_id = self.inner.id();
let branches = self.ctx.raw().branches(bt).map_err(Error::Git)?;
Ok(branches.filter_map(move |res| {
let branch = match res {
Ok(v) => v.0,
Err(e) => return Some(Err(Error::Git(e))),
};
let oid = match branch.get().target() {
Some(v) => v,
None => return None,
};
if !self.commit_contains_branch(oid, commit_id) {
return None;
}
match branch.name() {
Ok(Some(name)) => Some(Ok(name.to_string())),
Ok(None) => None, Err(e) => Some(Err(Error::Git(e))),
}
}))
}
}
#[cfg(test)]
mod test {
use super::*;
use crate::{
Local, Repository,
common::{EXPECTED_ACTOR_EMAIL, EXPECTED_ACTOR_NAME, EXPECTED_MSG, init_repo},
};
fn commit_fixture<F, R>(f: F) -> R
where
F: FnOnce(&Repository<Local>, &Commit) -> R,
{
let repo = init_repo();
let repo = Repository::<Local>::from_repository(repo);
let commit = repo.head().expect("Failed to get HEAD");
f(&repo, &commit)
}
#[test]
fn test_msg() {
commit_fixture(|_, commit| {
assert_eq!(commit.msg(), Some(EXPECTED_MSG));
});
}
#[test]
fn test_author() {
commit_fixture(|_, commit| {
assert_eq!(
commit.author().name().unwrap(),
EXPECTED_ACTOR_NAME.to_string()
);
assert_eq!(
commit.author().email().unwrap(),
EXPECTED_ACTOR_EMAIL.to_string()
);
});
}
#[test]
fn test_co_authors() {
commit_fixture(|_, commit| {
for co_auth in commit.co_authors() {
assert!(co_auth.is_ok());
}
});
}
#[test]
fn test_committer() {
commit_fixture(|_, commit| {
assert_eq!(
commit.committer().name().unwrap(),
EXPECTED_ACTOR_NAME.to_string()
);
assert_eq!(
commit.committer().email().unwrap(),
EXPECTED_ACTOR_EMAIL.to_string()
);
});
}
#[test]
fn test_parents() {
commit_fixture(|_, commit| {
assert_eq!(commit.parents().collect::<Vec<String>>().len(), 1);
});
}
#[test]
fn test_is_merge() {
commit_fixture(|_, commit| {
assert!(!commit.is_merge());
});
}
#[test]
fn test_insertions() {
commit_fixture(|_, commit| {
assert_eq!(commit.insertions().unwrap(), 1);
});
}
#[test]
fn test_deletions() {
commit_fixture(|_, commit| {
assert_eq!(commit.deletions().unwrap(), 0);
});
}
#[test]
fn test_lines() {
commit_fixture(|_, commit| {
assert_eq!(commit.lines().unwrap(), 1);
});
}
#[test]
fn test_stat() {
commit_fixture(|_, commit| {
let _: git2::DiffStats = commit
.stats()
.expect("Failed to construct git2 Stats object");
});
}
#[test]
fn test_iter_matches() {
let haystack = "Co-authored-by: John <john@example.com>";
assert_eq!(iter_co_authors(haystack).collect::<Vec<&str>>().len(), 1);
let haystack = "No matches expected";
assert_eq!(iter_co_authors(haystack).collect::<Vec<&str>>().len(), 0);
}
}