#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Identity {
pub name: String,
pub email: String,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Commit {
pub short_hash: String,
pub subject: String,
pub identities: Vec<Identity>,
}
fn trailer_identity(raw: &str) -> Option<Identity> {
let raw = raw.trim();
let open = raw.rfind('<')?;
let close = raw[open..].find('>')? + open;
let email = raw[open + 1..close].trim().to_owned();
(!email.is_empty()).then(|| Identity {
name: raw[..open].trim().to_owned(),
email,
})
}
fn identities(
author: &gix::actor::SignatureRef<'_>,
body: Option<&gix::bstr::BStr>,
) -> Vec<Identity> {
let mut found = vec![Identity {
name: author.name.to_string().trim().to_owned(),
email: author.email.to_string().trim().to_owned(),
}];
for line in body.map(ToString::to_string).unwrap_or_default().lines() {
if let Some((token, value)) = line.split_once(':')
&& token.trim().eq_ignore_ascii_case("co-authored-by")
&& let Some(identity) = trailer_identity(value)
&& !found.contains(&identity)
{
found.push(identity);
}
}
found
}
#[must_use]
pub fn branch_leaf(repository: &gix::Repository) -> String {
repository.head_name().ok().flatten().map_or_else(
|| "HEAD".to_owned(),
|name| {
let short = name.shorten().to_string();
short.rsplit('/').next().unwrap_or(&short).to_owned()
},
)
}
pub fn commits_since(
repository: &gix::Repository,
base: &str,
) -> sysexits::Result<Vec<Commit>> {
let head = repository.head_commit().map_err(|reason| {
eprintln!("git-harvest: cannot read HEAD: {reason}");
sysexits::ExitCode::Usage
})?;
let base = repository.rev_parse_single(base).map_err(|reason| {
eprintln!("git-harvest: cannot resolve {base:?}: {reason}");
sysexits::ExitCode::Usage
})?;
let boundary =
repository
.merge_base(head.id(), base.detach())
.map_err(|reason| {
eprintln!(
"git-harvest: no shared history with the base: {reason}"
);
sysexits::ExitCode::Unavailable
})?;
let walk = repository
.rev_walk([head.id().detach()])
.with_boundary([boundary.detach()])
.all()
.map_err(|reason| {
eprintln!("git-harvest: cannot walk the history: {reason}");
sysexits::ExitCode::Software
})?;
let mut commits = Vec::new();
for step in walk {
let info = step.map_err(|reason| {
eprintln!("git-harvest: the history walk failed: {reason}");
sysexits::ExitCode::Software
})?;
if info.parent_ids.len() > 1 {
continue;
}
let commit = repository.find_commit(info.id).map_err(|reason| {
eprintln!("git-harvest: cannot read a commit: {reason}");
sysexits::ExitCode::Software
})?;
let message = commit.message().map_err(|reason| {
eprintln!("git-harvest: cannot read a commit message: {reason}");
sysexits::ExitCode::Software
})?;
let author = commit.author().map_err(|reason| {
eprintln!("git-harvest: cannot read a commit author: {reason}");
sysexits::ExitCode::Software
})?;
commits.push(Commit {
short_hash: info.id.to_hex_with_len(7).to_string(),
subject: message.title.to_string().trim().to_owned(),
identities: identities(&author, message.body),
});
}
Ok(commits)
}
pub fn identity() -> sysexits::Result<Identity> {
let repository = open()?;
let config = repository.config_snapshot();
let (Some(name), Some(email)) =
(config.string("user.name"), config.string("user.email"))
else {
eprintln!(
"git-harvest: user.name and user.email must both be set in the \
Git configuration"
);
return Err(sysexits::ExitCode::Unavailable);
};
Ok(Identity {
name: name.to_string(),
email: email.to_string(),
})
}
pub fn open() -> sysexits::Result<gix::Repository> {
gix::discover(".").map_err(|reason| {
eprintln!("git-harvest: not inside a Git repository: {reason}");
sysexits::ExitCode::Usage
})
}