//! Git interaction layer built on top of `git2`.
//!
//! Resolves refs/tags/branches and collects the commits that make up the
//! range `from..to` (commits reachable from `to` but not from `from`), plus
//! an aggregate diff so the AI has enough context to write a changelog.
use std::fmt;
use std::path::Path;
use git2::{DiffOptions, Oid, Repository, Sort};
/// A single commit in the changelog range.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CommitInfo {
/// Abbreviated (short) commit hash.
pub short_hash: String,
/// First line of the commit message.
pub summary: String,
/// Remaining lines of the commit message (may be empty).
pub body: String,
/// Author name.
pub author: String,
}
/// The collected changelog data for a `from..to` range.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ChangeSet {
/// The resolved `from` revision, as provided by the user.
pub from: String,
/// The resolved `to` revision, as provided by the user.
pub to: String,
/// The commits in the range, newest first.
pub commits: Vec<CommitInfo>,
/// A unified diff summarising the tree changes between `from` and `to`.
pub diff: String,
}
/// Errors that can occur while gathering git history.
#[derive(Debug)]
pub enum GitError {
/// The repository could not be opened.
OpenRepo(git2::Error),
/// A revision (ref/tag/branch/sha) could not be resolved.
ResolveRef { rev: String, source: git2::Error },
/// A lower-level git operation failed.
Git(git2::Error),
}
impl fmt::Display for GitError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
GitError::OpenRepo(e) => write!(f, "failed to open git repository: {e}"),
GitError::ResolveRef { rev, source } => {
write!(f, "failed to resolve revision '{rev}': {source}")
}
GitError::Git(e) => write!(f, "git error: {e}"),
}
}
}
impl std::error::Error for GitError {}
impl From<git2::Error> for GitError {
fn from(e: git2::Error) -> Self {
GitError::Git(e)
}
}
/// Collect the changelog data for the range `from..to` in the repository
/// located at (or above) `path`.
pub fn collect_changes(path: &Path, from: &str, to: &str) -> Result<ChangeSet, GitError> {
let repo = Repository::discover(path).map_err(GitError::OpenRepo)?;
collect_changes_in(&repo, from, to)
}
/// Collect the changelog data for the range `from..to` in an already-open
/// repository. Separated out so tests can operate on a `Repository` directly.
pub fn collect_changes_in(repo: &Repository, from: &str, to: &str) -> Result<ChangeSet, GitError> {
let from_oid = resolve(repo, from)?;
let to_oid = resolve(repo, to)?;
let commits = walk_commits(repo, from_oid, to_oid)?;
let diff = diff_between(repo, from_oid, to_oid)?;
Ok(ChangeSet {
from: from.to_string(),
to: to.to_string(),
commits,
diff,
})
}
/// Resolve a user-supplied revision string (tag, branch, ref, or sha) to the
/// commit `Oid` it points at.
fn resolve(repo: &Repository, rev: &str) -> Result<Oid, GitError> {
let obj = repo
.revparse_single(rev)
.map_err(|source| GitError::ResolveRef {
rev: rev.to_string(),
source,
})?;
let commit = obj
.peel_to_commit()
.map_err(|source| GitError::ResolveRef {
rev: rev.to_string(),
source,
})?;
Ok(commit.id())
}
/// Walk commits reachable from `to` but not from `from`, newest first.
fn walk_commits(repo: &Repository, from: Oid, to: Oid) -> Result<Vec<CommitInfo>, GitError> {
let mut revwalk = repo.revwalk()?;
revwalk.set_sorting(Sort::TOPOLOGICAL | Sort::TIME)?;
revwalk.push(to)?;
revwalk.hide(from)?;
let mut commits = Vec::new();
for oid in revwalk {
let oid = oid?;
let commit = repo.find_commit(oid)?;
let message = commit.message().unwrap_or("");
let mut lines = message.splitn(2, '\n');
let summary = lines.next().unwrap_or("").trim_end().to_string();
let body = lines.next().unwrap_or("").trim().to_string();
let author = commit.author().name().unwrap_or("unknown").to_string();
let short_hash = oid.to_string().chars().take(8).collect();
commits.push(CommitInfo {
short_hash,
summary,
body,
author,
});
}
Ok(commits)
}
/// Produce a unified diff between the trees of `from` and `to`.
fn diff_between(repo: &Repository, from: Oid, to: Oid) -> Result<String, GitError> {
let from_tree = repo.find_commit(from)?.tree()?;
let to_tree = repo.find_commit(to)?.tree()?;
let mut opts = DiffOptions::new();
opts.context_lines(3);
let diff = repo.diff_tree_to_tree(Some(&from_tree), Some(&to_tree), Some(&mut opts))?;
let mut buf = String::new();
diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
match line.origin() {
'+' | '-' | ' ' => buf.push(line.origin()),
_ => {}
}
if let Ok(content) = std::str::from_utf8(line.content()) {
buf.push_str(content);
}
true
})?;
Ok(buf)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil::make_git_repo_with_tags as make_repo;
#[test]
fn collects_commits_in_range() {
let dir = make_repo();
let cs = collect_changes(dir.path(), "v1", "v2").expect("collect");
assert_eq!(cs.from, "v1");
assert_eq!(cs.to, "v2");
assert_eq!(cs.commits.len(), 2);
// Newest first.
assert_eq!(cs.commits[0].summary, "fix: correct typo");
assert_eq!(cs.commits[1].summary, "feat: add greeting");
assert_eq!(cs.commits[1].body, "More detailed body text.");
assert_eq!(cs.commits[0].author, "Tester");
}
#[test]
fn empty_range_has_no_commits() {
let dir = make_repo();
let cs = collect_changes(dir.path(), "v2", "v2").expect("collect");
assert!(cs.commits.is_empty());
}
#[test]
fn diff_contains_changes() {
let dir = make_repo();
let cs = collect_changes(dir.path(), "v1", "v2").expect("collect");
assert!(cs.diff.contains("b.txt"));
assert!(cs.diff.contains("hello world"));
}
#[test]
fn unknown_ref_errors() {
let dir = make_repo();
let err = collect_changes(dir.path(), "nope", "v2").unwrap_err();
match err {
GitError::ResolveRef { rev, .. } => assert_eq!(rev, "nope"),
other => panic!("expected ResolveRef, got {other:?}"),
}
}
}