nornir 0.4.28

Companion to cargo: dependency tracking, release gating, deploy, benchmarks, and documentation assembly. Project-agnostic.
//! Pure-Rust git activity heat using `gix`.
//!
//! For HEAD's reachable commits we aggregate per-file:
//!   - total commits touching that file
//!   - commits in the last 30 / 90 days
//!   - distinct author count
//!   - last-commit timestamp
//!
//! Skipped: binary files, files under `target/` `.git/` `node_modules/`.
//! gix's diff API gives us file paths per commit without shelling.

use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;

use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use uuid::Uuid;

#[derive(Debug, Default)]
pub struct GitHeatScan {
    pub snapshot_id: Uuid,
    pub ts: DateTime<Utc>,
    pub repo: String,
    pub files: Vec<GitHeatRow>,
}

#[derive(Debug, Clone)]
pub struct GitHeatRow {
    pub file: String,
    pub commits_total: i64,
    pub commits_30d: i64,
    pub commits_90d: i64,
    pub authors_total: i64,
    pub last_commit_ts: DateTime<Utc>,
}

pub fn scan_repo(
    repo_root: &Path,
    repo_name: &str,
    snapshot_id: Uuid,
    ts: DateTime<Utc>,
) -> Result<GitHeatScan> {
    let mut out = GitHeatScan {
        snapshot_id,
        ts,
        repo: repo_name.to_string(),
        files: Vec::new(),
    };
    let repo = match gix::discover(repo_root) {
        Ok(r) => r,
        Err(_) => return Ok(out), // not a git repo — empty scan
    };
    let head_id = match repo.head_id() {
        Ok(id) => id,
        Err(_) => return Ok(out),
    };

    // Per-file aggregates.
    let mut per_file: BTreeMap<String, FileAgg> = BTreeMap::new();
    let now = ts;
    let cutoff_30 = now - chrono::Duration::days(30);
    let cutoff_90 = now - chrono::Duration::days(90);

    let walk = repo.rev_walk([head_id.detach()])
        .all()
        .context("rev_walk all")?;

    // We diff each commit against its first parent (root commit: list its
    // tree).  This is O(commits × tree-size); fine for repos up to ~100k
    // commits, and we ship later batching if needed.
    for info in walk {
        let info = match info { Ok(i) => i, Err(_) => continue };
        let commit = match info.object() { Ok(c) => c, Err(_) => continue };
        let author = match commit.author() {
            Ok(a) => format!("{} <{}>", a.name, a.email),
            Err(_) => "unknown".into(),
        };
        let commit_time = match commit.time() {
            Ok(t) => DateTime::<Utc>::from_timestamp(t.seconds, 0).unwrap_or(now),
            Err(_) => now,
        };

        let tree = match commit.tree() { Ok(t) => t, Err(_) => continue };
        let parent_tree = commit
            .parent_ids()
            .next()
            .and_then(|pid| repo.find_object(pid).ok())
            .and_then(|o| o.try_into_commit().ok())
            .and_then(|c| c.tree().ok());

        let mut touched: BTreeSet<String> = BTreeSet::new();
        if let Some(p_tree) = parent_tree {
            let _ = p_tree.changes().context("changes()")?.for_each_to_obtain_tree(
                &tree,
                |change| -> std::result::Result<std::ops::ControlFlow<()>, std::convert::Infallible> {
                    let loc = change.location();
                    touched.insert(loc.to_string());
                    Ok(std::ops::ControlFlow::Continue(()))
                },
            );
        } else {
            // root commit: count every entry in its tree
            for entry in tree.iter().flatten() {
                touched.insert(entry.filename().to_string());
            }
        }

        for path in touched {
            let agg = per_file.entry(path).or_insert_with(|| FileAgg {
                commits_total: 0,
                commits_30d: 0,
                commits_90d: 0,
                authors: BTreeSet::new(),
                last_commit_ts: commit_time,
            });
            agg.commits_total += 1;
            if commit_time >= cutoff_30 { agg.commits_30d += 1; }
            if commit_time >= cutoff_90 { agg.commits_90d += 1; }
            agg.authors.insert(author.clone());
            if commit_time > agg.last_commit_ts {
                agg.last_commit_ts = commit_time;
            }
        }
    }

    for (file, agg) in per_file {
        out.files.push(GitHeatRow {
            file,
            commits_total: agg.commits_total,
            commits_30d: agg.commits_30d,
            commits_90d: agg.commits_90d,
            authors_total: agg.authors.len() as i64,
            last_commit_ts: agg.last_commit_ts,
        });
    }
    Ok(out)
}

struct FileAgg {
    commits_total: i64,
    commits_30d: i64,
    commits_90d: i64,
    authors: BTreeSet<String>,
    last_commit_ts: DateTime<Utc>,
}