rpo 0.1.0-beta.4

Git contribution analysis: commits, file changes, and per-line authorship over time as polars DataFrames
#![doc = include_str!("../README.md")]
#![warn(missing_docs)]
//!
//! [`commits`]: Builder::commits
//! [`file_changes`]: Builder::file_changes
//! [`blame`]: Builder::blame
//! [`blame_over_time`]: Builder::blame_over_time
//! [`blame_over_time_streaming`]: Builder::blame_over_time_streaming
//! [`all`]: Builder::all
//! [`with_include_globs`]: Builder::with_include_globs
//! [`with_exclude_globs`]: Builder::with_exclude_globs
//! [`rpo::reports`]: crate::reports
//! [`rpo::bus_factor`]: crate::bus_factor

#[cfg(not(any(feature = "backend-gix", feature = "backend-git2")))]
compile_error!("rpo requires at least one backend feature: backend-gix or backend-git2");

use std::path::PathBuf;

/// Anything that can go wrong during an analysis.
#[derive(thiserror::Error, Debug)]
pub enum RpoError {
    /// The path is not a git repository.
    #[error("path {} is not a git repository", path.display())]
    NotARepo {
        /// The path that was opened.
        path: PathBuf,
    },

    /// The git backend failed.
    #[error("backend error: {0}")]
    Backend(String),

    /// A requested revision does not exist in the repository.
    #[error("revision {rev} not found")]
    RevisionNotFound {
        /// The revision that could not be resolved.
        rev: String,
    },

    /// An include or exclude glob failed to parse.
    #[error("invalid glob pattern {pattern}: {source}")]
    InvalidGlob {
        /// The pattern as supplied.
        pattern: String,
        /// The underlying parse error.
        #[source]
        source: globset::Error,
    },

    /// The repository's `.mailmap` could not be parsed.
    #[error("invalid mailmap at line {line}: {reason}")]
    InvalidMailmap {
        /// 1-indexed line number.
        line: usize,
        /// What went wrong.
        reason: String,
    },

    /// Blame failed for one file at one revision.
    #[error("blame failed for {} at {rev}: {reason}", path.display())]
    BlameFailed {
        /// The file being blamed.
        path: PathBuf,
        /// The revision being blamed.
        rev: String,
        /// What went wrong.
        reason: String,
    },

    /// Building or transforming a DataFrame failed.
    #[error("polars error: {0}")]
    Polars(#[from] polars::error::PolarsError),

    /// Reading the repository or writing output failed.
    #[error("io error: {0}")]
    Io(#[from] std::io::Error),

    /// A [`FrameSink`] failed to write.
    #[error("sink error: {0}")]
    Sink(String),
}

mod analyzer;
mod backend;
mod blame;
pub mod bus_factor;
mod filters;
mod frames;
mod identity;
pub mod options;
pub mod reports;
mod sinks;
mod walk;

pub use analyzer::{Builder, RepoAnalyzer};
pub use backend::{
    BlameHunk, ChangeKind, Commit, CommitId, DefaultBackend, FileChange, GitBackend, Signature,
    WalkOptions,
};
pub use identity::IdentityMap;
pub use options::{ActivityOptions, Aggregate, Aggregation, FileSelection, Identify};
#[cfg(feature = "sink-duckdb")]
pub use sinks::{DuckDbSink, DuckDbWriteMode};
pub use sinks::{FrameSink, ParquetDirSink, ParquetSink};

/// A progress update, delivered to the callback registered with
/// [`Builder::with_progress`].
pub struct Progress {
    /// Which stage of the analysis is running.
    pub phase: Phase,
    /// Units finished so far.
    pub completed: u64,
    /// Units expected in total.
    pub total: u64,
}

/// The stage an analysis has reached.
pub enum Phase {
    /// Traversing history to build the commit and file-change frames.
    WalkingCommits,
    /// Blaming files at one snapshot.
    Blaming {
        /// The snapshot's label, or its short SHA.
        snapshot: String,
    },
}

/// Which revisions [`Builder::blame_over_time`] samples.
#[derive(Clone, Debug)]
pub enum SnapshotSelector {
    /// The current tip only. The default.
    Head,
    /// Exactly these revisions, in the order given.
    AtRevs(Vec<String>),
    /// Every tag.
    Tags,
    /// Every nth commit, walking back from HEAD.
    EveryNCommits(usize),
    /// The first commit of each UTC day.
    Daily,
    /// The first commit of each ISO 8601 week.
    Weekly,
    /// The first commit of each calendar month, UTC.
    Monthly,
    /// Every commit. Expensive on any real history.
    AllCommits,
}

/// A file that could not be blamed — binary, unreadable, or otherwise
/// rejected by the backend.
pub struct SkippedFile {
    /// The snapshot being blamed when the file was skipped.
    pub snapshot_sha: String,
    /// The file's repo-relative path.
    pub path: std::path::PathBuf,
    /// Why it was skipped.
    pub reason: String,
}

/// What a streaming blame run wrote, returned by
/// [`Builder::blame_over_time_streaming`].
pub struct StreamStats {
    /// Snapshots handed to the sink.
    pub snapshots_written: u64,
    /// Blame rows across every snapshot.
    pub rows_written: u64,
    /// Bytes the sink reported writing.
    pub bytes_written: u64,
    /// Files skipped during the run.
    pub skipped_files: Vec<SkippedFile>,
}

/// Every frame from one walk, returned by [`Builder::all`].
pub struct Analysis {
    /// One row per commit.
    pub commits: polars::prelude::DataFrame,
    /// One row per (commit, file) touched.
    pub file_changes: polars::prelude::DataFrame,
    /// Per-line authorship at HEAD.
    pub blame: Option<polars::prelude::DataFrame>,
    /// Per-line authorship at each snapshot. `None` unless
    /// [`Builder::with_blame_snapshots`] selected more than
    /// [`SnapshotSelector::Head`].
    pub blame_over_time: Option<polars::prelude::DataFrame>,
    /// Files that could not be blamed.
    pub skipped_files: Vec<SkippedFile>,
}