Skip to content
//! 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:?}"),
        }
    }
}