rpo 0.1.0-beta.4

Git contribution analysis: commits, file changes, and per-line authorship over time as polars DataFrames
//! Git backend abstraction. One trait; neutral data types the rest of the
//! library consumes. Implementations live in `backend/gix.rs` and
//! `backend/git2.rs` behind feature flags.

use std::path::{Path, PathBuf};

use crate::RpoError;

#[cfg(feature = "backend-git2")]
pub mod git2;
#[cfg(feature = "backend-gix")]
pub mod gix;

/// The backend selected by the enabled feature.
#[cfg(all(feature = "backend-gix", not(feature = "backend-git2")))]
pub type DefaultBackend = self::gix::GixBackend;

/// The backend selected by the enabled feature.
#[cfg(all(feature = "backend-git2", not(feature = "backend-gix")))]
pub type DefaultBackend = self::git2::Git2Backend;

#[cfg(all(feature = "backend-gix", feature = "backend-git2"))]
/// The backend selected by the enabled features; gix wins when both
/// are on.
pub type DefaultBackend = self::gix::GixBackend;

/// A git object id: a 40-character SHA, stored as its 20 raw bytes.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct CommitId(
    /// The raw SHA-1 bytes.
    pub [u8; 20],
);

impl CommitId {
    /// The full 40-character hex form.
    pub fn to_hex(&self) -> String {
        let mut out = String::with_capacity(40);
        for b in self.0 {
            out.push_str(&format!("{b:02x}"));
        }
        out
    }

    /// The first 7 hex characters, as git abbreviates them.
    pub fn short_hex(&self) -> String {
        self.to_hex()[..7].to_string()
    }
}

#[derive(Clone, Debug)]
/// A person and the moment they acted, as recorded on a commit.
pub struct Signature {
    /// Display name.
    pub name: String,
    /// Email address.
    pub email: String,
    /// Unix millis, UTC.
    pub time_ms: i64,
}

#[derive(Clone, Debug)]
/// One commit, as the backends report it.
pub struct Commit {
    /// This commit's object id.
    pub id: CommitId,
    /// Who wrote the change.
    pub author: Signature,
    /// Who applied it.
    pub committer: Signature,
    /// Parent ids; two or more means a merge.
    pub parent_ids: Vec<CommitId>,
    /// The message's first line.
    pub message_subject: String,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
/// What happened to a file in a commit.
pub enum ChangeKind {
    /// Newly tracked.
    Added,
    /// Contents changed.
    Modified,
    /// Removed.
    Deleted,
    /// Moved, with contents largely intact.
    Renamed,
    /// Duplicated from another path.
    Copied,
    /// Mode changed, e.g. file to symlink.
    TypeChange,
}

#[derive(Clone, Debug)]
/// One file's change within a commit.
pub struct FileChange {
    /// Repo-relative path after the change.
    pub path: PathBuf,
    /// The previous path, for renames and copies.
    pub old_path: Option<PathBuf>,
    /// What happened to the file.
    pub kind: ChangeKind,
    /// Lines added.
    pub insertions: u64,
    /// Lines removed.
    pub deletions: u64,
}

#[derive(Clone, Debug)]
/// A run of consecutive lines attributed to one commit.
pub struct BlameHunk {
    /// First line of the run, 1-indexed.
    pub start_line: u32,
    /// How many lines the run covers.
    pub line_count: u32,
    /// The commit that last touched them.
    pub commit_id: CommitId,
}

#[derive(Clone, Copy, Debug)]
/// How the backend should traverse history.
pub struct WalkOptions {
    /// Follow only the first parent of each commit.
    pub first_parent_only: bool,
    /// Include commits with two or more parents.
    pub include_merges: bool,
}

/// Git access, abstracted so the library can sit on more than one
/// implementation. Selected at compile time by the backend features;
/// most callers use [`DefaultBackend`] without naming it.
pub trait GitBackend: Send + Sync {
    /// Open a repository.
    fn open(path: &Path) -> Result<Self, RpoError>
    where
        Self: Sized;

    /// The commit HEAD points at.
    fn head_commit(&self) -> Result<CommitId, RpoError>;

    /// Returns an iterator that must be consumed on a single thread. The trait
    /// object is `Send` so the caller can move the whole backend; iteration is
    /// not shared across threads.
    fn iter_commits<'a>(
        &'a self,
        opts: WalkOptions,
    ) -> Box<dyn Iterator<Item = Result<Commit, RpoError>> + 'a>;

    /// Files changed between `parent` and `child`. A `None` parent
    /// means a root commit, so every file counts as added.
    fn diff_tree(
        &self,
        parent: Option<&CommitId>,
        child: &CommitId,
    ) -> Result<Vec<FileChange>, RpoError>;

    /// Every blob path in a commit's tree, recursively.
    fn list_tree_paths(&self, commit: &CommitId) -> Result<Vec<PathBuf>, RpoError>;

    /// Blame `path` at `commit`. Per-file failures return an Err and are
    /// caught by the blame driver, not propagated to the caller of `.blame()`.
    fn blame_file(&self, commit: &CommitId, path: &Path) -> Result<Vec<BlameHunk>, RpoError>;

    /// Resolve a revision string — a SHA, ref name, or `HEAD~3`.
    fn resolve_rev(&self, rev: &str) -> Result<CommitId, RpoError>;

    /// Look up a single commit directly by id. Unlike `iter_commits`, this
    /// works for commits that aren't reachable from HEAD (e.g. tags on
    /// abandoned branches). Returns `RpoError::RevisionNotFound` if the
    /// object doesn't exist or isn't a commit.
    fn commit_meta(&self, id: &CommitId) -> Result<Commit, RpoError>;

    /// Every tag, as `(name, commit)`.
    fn tags(&self) -> Result<Vec<(String, CommitId)>, RpoError>;

    /// The repository's `.mailmap`, if it has one.
    fn mailmap_bytes(&self) -> Result<Option<Vec<u8>>, RpoError>;

    /// The repository's `.gitattributes`, if it has one.
    fn gitattributes_bytes(&self) -> Result<Option<Vec<u8>>, RpoError>;

    /// Returns a cheap handle clone suitable for a new rayon worker thread.
    /// For gix this clones the repo handle (shares object store, separate
    /// object cache). For git2 this is `todo!()` in v1.
    fn thread_handle(&self) -> Result<Self, RpoError>
    where
        Self: Sized;
}