rpo 0.1.0-beta.4

Git contribution analysis: commits, file changes, and per-line authorship over time as polars DataFrames
//! Single-pass commit walker. Produces records for both the commits and
//! file_changes frames in one traversal so `.all()` walks history once.

use std::path::PathBuf;

use crate::{
    ActivityOptions, Phase, Progress, RpoError,
    backend::{GitBackend, WalkOptions},
    filters::FilterSet,
    frames::{CommitRecord, FileChangeRecord, extension_of},
    identity::Canonicalizer,
};

pub struct WalkOutput {
    pub commits: Vec<CommitRecord>,
    pub file_changes: Vec<FileChangeRecord>,
}

const fn walk_options_for(activity: &ActivityOptions) -> WalkOptions {
    WalkOptions {
        first_parent_only: activity.first_parent_only,
        include_merges: !activity.ignore_merges || activity.first_parent_only,
    }
}

pub fn run<B: GitBackend>(
    backend: &B,
    canonicalizer: &Canonicalizer,
    filters: &FilterSet,
    activity: &ActivityOptions,
    progress: Option<&(dyn Fn(Progress) + Send + Sync)>,
) -> Result<WalkOutput, RpoError> {
    let opts = walk_options_for(activity);

    if let Some(cb) = progress {
        cb(Progress {
            phase: Phase::WalkingCommits,
            completed: 0,
            total: 1,
        });
    }
    tracing::info!("walk: start");
    let started = std::time::Instant::now();

    let mut commits_out: Vec<CommitRecord> = Vec::new();
    let mut changes_out: Vec<FileChangeRecord> = Vec::new();

    for commit_res in backend.iter_commits(opts) {
        let commit = commit_res?;

        let parent = commit.parent_ids.first();
        let diff = backend.diff_tree(parent, &commit.id)?;

        let (can_an, can_ae) = canonicalizer.canonicalize(&commit.author);
        let (can_cn, can_ce) = canonicalizer.canonicalize(&commit.committer);

        let mut insertions: u64 = 0;
        let mut deletions: u64 = 0;
        let mut files_changed: u32 = 0;

        for change in &diff {
            // User globs filter the row out entirely, and with it the
            // commit's totals, so the commits frame agrees with the
            // file_changes rows it summarizes.
            if !filters.changes_include(&change.path) {
                continue;
            }

            insertions += change.insertions;
            deletions += change.deletions;
            files_changed += 1;

            let classification = filters.classify(&change.path);
            changes_out.push(FileChangeRecord {
                sha: commit.id.to_hex(),
                commit_time_ms: commit.committer.time_ms,
                canonical_author_name: can_an.clone(),
                canonical_author_email: can_ae.clone(),
                canonical_committer_name: can_cn.clone(),
                canonical_committer_email: can_ce.clone(),
                path: change.path.to_string_lossy().to_string(),
                old_path: change
                    .old_path
                    .as_ref()
                    .map(|p| p.to_string_lossy().to_string()),
                change_kind: change.kind,
                insertions: change.insertions,
                deletions: change.deletions,
                extension: extension_of(&PathBuf::from(&change.path)),
                is_generated: classification.is_generated,
                is_vendored: classification.is_vendored,
            });
        }

        commits_out.push(CommitRecord {
            sha: commit.id.to_hex(),
            short_sha: commit.id.short_hex(),
            author_name: commit.author.name.clone(),
            author_email: commit.author.email.clone(),
            canonical_author_name: can_an,
            canonical_author_email: can_ae,
            committer_name: commit.committer.name.clone(),
            committer_email: commit.committer.email.clone(),
            canonical_committer_name: can_cn,
            canonical_committer_email: can_ce,
            author_time_ms: commit.author.time_ms,
            commit_time_ms: commit.committer.time_ms,
            parent_count: commit.parent_ids.len() as u32,
            is_merge: commit.parent_ids.len() >= 2,
            message_subject: commit.message_subject,
            files_changed,
            insertions,
            deletions,
        });
    }

    if let Some(cb) = progress {
        cb(Progress {
            phase: Phase::WalkingCommits,
            completed: 1,
            total: 1,
        });
    }
    tracing::info!(
        commits = commits_out.len(),
        file_changes = changes_out.len(),
        elapsed_ms = started.elapsed().as_millis() as u64,
        "walk: done"
    );

    Ok(WalkOutput {
        commits: commits_out,
        file_changes: changes_out,
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::ActivityOptions;

    fn opts(ignore_merges: bool, first_parent_only: bool) -> ActivityOptions {
        ActivityOptions {
            ignore_merges,
            first_parent_only,
            ignore_whitespace: false,
            ignore_bots: false,
        }
    }

    #[test]
    fn default_excludes_merges_and_walks_all_parents() {
        let w = walk_options_for(&opts(true, false));
        assert!(!w.first_parent_only);
        assert!(!w.include_merges);
    }

    #[test]
    fn include_merges_only() {
        let w = walk_options_for(&opts(false, false));
        assert!(!w.first_parent_only);
        assert!(w.include_merges);
    }

    #[test]
    fn first_parent_only_makes_ignore_merges_moot() {
        // Per ActivityOptions::first_parent_only docs: when first_parent_only
        // is true, ignore_merges is moot — first-parent walks always traverse
        // merge commits.
        let w = walk_options_for(&opts(true, true));
        assert!(w.first_parent_only);
        assert!(
            w.include_merges,
            "first_parent_only must visit merge commits regardless of ignore_merges"
        );
    }

    #[test]
    fn first_parent_only_with_include_merges_is_consistent() {
        let w = walk_options_for(&opts(false, true));
        assert!(w.first_parent_only);
        assert!(w.include_merges);
    }
}