AkurAI Build
Menu

Public repository / main

AkurAI-Build / src/insights.rs

Latest change e8aa34a9be50d7d7ea72f66991ff82dfca7d23e1 - OLI-276: Git over SSH, forks and mirrors, code search through akurai-index, repository insights by Ólafur Búi Ólafsson

//! Repository insights (OLI-276): who contributed, how much, and when, read
//! straight from the hosted repository's history for the default branch.

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

use anyhow::{Context, Result, ensure};
use serde::Serialize;

use crate::git_process;

const WEEK: i64 = 7 * 86_400;
pub const MAX_DAYS: u32 = 3_660;

#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
pub struct Contributor {
    pub name: String,
    pub email: String,
    pub commits: u64,
    pub additions: u64,
    pub deletions: u64,
    pub last_at: i64,
}

#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
pub struct Week {
    /// Monday 00:00 UTC that starts the week.
    pub start: i64,
    pub commits: u64,
    pub additions: u64,
    pub deletions: u64,
}

#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
pub struct FileActivity {
    pub path: String,
    pub commits: u64,
}

#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
pub struct Insights {
    pub branch: String,
    pub days: u32,
    pub since: i64,
    pub commits: u64,
    pub additions: u64,
    pub deletions: u64,
    pub contributors: Vec<Contributor>,
    /// Oldest first, every week in the window (zero weeks included).
    pub weeks: Vec<Week>,
    /// The ten most-touched files.
    pub files: Vec<FileActivity>,
}

/// Monday 00:00 UTC on or before `at` (day 0 of the epoch was a Thursday).
pub fn week_start(at: i64) -> i64 {
    let day = at.div_euclid(86_400);
    let monday = day - (day + 3).rem_euclid(7);
    monday * 86_400
}

/// Aggregate the last `days` days of `branch` as of `now`.
pub fn insights(repository: &Path, branch: &str, days: u32, now: i64) -> Result<Insights> {
    ensure!(
        (1..=MAX_DAYS).contains(&days),
        "days must be 1 to {MAX_DAYS}"
    );
    ensure!(
        !branch.starts_with('-') && !branch.contains("..") && !branch.contains(char::is_whitespace),
        "invalid branch"
    );
    let since = now - i64::from(days) * 86_400;
    let since_arg = format!("--since={since}");
    let output = git_process::command(repository)
        .args([
            "log",
            &since_arg,
            "--format=%x01%aN%x02%aE%x02%at",
            "--numstat",
            "--no-merges",
            branch,
            "--",
        ])
        .output()
        .context("failed to start git log")?;
    let log = if output.status.success() {
        String::from_utf8_lossy(&output.stdout).into_owned()
    } else {
        let stderr = String::from_utf8_lossy(&output.stderr);
        ensure!(
            stderr.contains("unknown revision")
                || stderr.contains("bad revision")
                || stderr.contains("does not have any commits"),
            "git log failed: {}",
            stderr.trim()
        );
        String::new()
    };
    Ok(aggregate(&log, branch, days, since, now))
}

fn aggregate(log: &str, branch: &str, days: u32, since: i64, now: i64) -> Insights {
    let mut contributors: BTreeMap<String, Contributor> = BTreeMap::new();
    let mut weeks: BTreeMap<i64, Week> = BTreeMap::new();
    let mut files: BTreeMap<String, u64> = BTreeMap::new();
    let mut total = Insights {
        branch: branch.to_owned(),
        days,
        since,
        ..Insights::default()
    };
    let mut start = week_start(since);
    while start <= now {
        weeks.insert(
            start,
            Week {
                start,
                ..Week::default()
            },
        );
        start += WEEK;
    }
    for record in log.split('\u{1}').skip(1) {
        let mut lines = record.lines();
        let Some(header) = lines.next() else { continue };
        let mut fields = header.split('\u{2}');
        let (Some(name), Some(email), Some(at)) = (fields.next(), fields.next(), fields.next())
        else {
            continue;
        };
        let at: i64 = at.trim().parse().unwrap_or(since);
        let (mut additions, mut deletions) = (0u64, 0u64);
        for line in lines.filter(|line| !line.trim().is_empty()) {
            let mut parts = line.splitn(3, '\t');
            let (Some(add), Some(del), Some(path)) = (parts.next(), parts.next(), parts.next())
            else {
                continue;
            };
            additions += add.trim().parse::<u64>().unwrap_or(0);
            deletions += del.trim().parse::<u64>().unwrap_or(0);
            *files.entry(path.to_owned()).or_default() += 1;
        }
        let contributor = contributors
            .entry(email.to_lowercase())
            .or_insert_with(|| Contributor {
                name: name.to_owned(),
                email: email.to_owned(),
                ..Contributor::default()
            });
        contributor.commits += 1;
        contributor.additions += additions;
        contributor.deletions += deletions;
        contributor.last_at = contributor.last_at.max(at);
        let week = weeks.entry(week_start(at)).or_insert_with(|| Week {
            start: week_start(at),
            ..Week::default()
        });
        week.commits += 1;
        week.additions += additions;
        week.deletions += deletions;
        total.commits += 1;
        total.additions += additions;
        total.deletions += deletions;
    }
    let mut contributors: Vec<Contributor> = contributors.into_values().collect();
    contributors.sort_by(|a, b| b.commits.cmp(&a.commits).then_with(|| a.name.cmp(&b.name)));
    let mut files: Vec<FileActivity> = files
        .into_iter()
        .map(|(path, commits)| FileActivity { path, commits })
        .collect();
    files.sort_by(|a, b| b.commits.cmp(&a.commits).then_with(|| a.path.cmp(&b.path)));
    files.truncate(10);
    total.contributors = contributors;
    total.weeks = weeks.into_values().collect();
    total.files = files;
    total
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::{fs, process::Command};

    fn git(repository: &Path, arguments: &[&str], at: i64) {
        let date = format!("{at} +0000");
        let output = Command::new("git")
            .arg("-C")
            .arg(repository)
            .args(arguments)
            .env("GIT_AUTHOR_DATE", &date)
            .env("GIT_COMMITTER_DATE", &date)
            .output()
            .expect("git");
        assert!(
            output.status.success(),
            "{}",
            String::from_utf8_lossy(&output.stderr)
        );
    }

    fn commit(repository: &Path, who: (&str, &str), file: &str, text: &str, at: i64) {
        fs::write(repository.join(file), text).expect("write");
        git(repository, &["add", "."], at);
        git(
            repository,
            &[
                "-c",
                &format!("user.name={}", who.0),
                "-c",
                &format!("user.email={}", who.1),
                "commit",
                "-q",
                "-m",
                text,
            ],
            at,
        );
    }

    #[test]
    fn week_starts_are_mondays_at_midnight_utc() {
        // 2026-10-03 is a Saturday; 2026-09-28 a Monday.
        assert_eq!(week_start(1_790_985_600 + 3_600), 1_790_553_600);
        assert_eq!(week_start(1_790_553_600), 1_790_553_600);
        assert_eq!(week_start(0), -3 * 86_400);
    }

    #[test]
    fn aggregates_contributors_weeks_and_files_inside_the_window() {
        let temp = tempfile::tempdir().expect("tempdir");
        let repo = temp.path();
        git(repo, &["init", "-q", "-b", "main"], 0);
        let now = 1_790_985_600; // 2026-10-03 00:00 UTC
        let day = 86_400;
        let ada = ("Ada", "ada@example");
        let bob = ("Bob", "BOB@example");
        commit(repo, ada, "a.txt", "one\ntwo\n", now - 40 * day); // outside a 30-day window
        commit(repo, ada, "a.txt", "one\n", now - 10 * day);
        commit(repo, bob, "b.txt", "x\ny\nz\n", now - 9 * day);
        commit(
            repo,
            ("Bob again", "bob@example"),
            "a.txt",
            "one\nthree\n",
            now - day,
        );

        let report = insights(repo, "main", 30, now).expect("insights");
        assert_eq!(report.commits, 3);
        assert_eq!(report.additions, 3 + 1);
        assert_eq!(report.deletions, 1);
        assert_eq!(report.contributors.len(), 2, "{:?}", report.contributors);
        let bob_row = &report.contributors[0];
        assert_eq!(
            (
                bob_row.name.as_str(),
                bob_row.commits,
                bob_row.additions,
                bob_row.deletions
            ),
            ("Bob again", 2, 4, 0)
        );
        assert_eq!(bob_row.last_at, now - day);
        assert_eq!(
            (
                report.contributors[1].name.as_str(),
                report.contributors[1].commits
            ),
            ("Ada", 1)
        );
        assert_eq!(
            report.files[0],
            FileActivity {
                path: "a.txt".into(),
                commits: 2
            }
        );
        // every week of the window is present, oldest first, and the sum matches
        assert!(
            report.weeks.len() >= 5 && report.weeks.len() <= 6,
            "{}",
            report.weeks.len()
        );
        assert!(
            report
                .weeks
                .windows(2)
                .all(|w| w[1].start - w[0].start == WEEK)
        );
        assert_eq!(report.weeks.iter().map(|w| w.commits).sum::<u64>(), 3);
        assert_eq!(
            report.weeks.last().map(|w| w.commits),
            Some(1),
            "yesterday's commit is in the last week"
        );

        let everything = insights(repo, "main", 60, now).expect("insights");
        assert_eq!(everything.commits, 4);
        let none = insights(repo, "no-such-branch", 30, now).expect("empty");
        assert_eq!(none.commits, 0);
        assert!(insights(repo, "main", 0, now).is_err());
        assert!(insights(repo, "--output=/tmp/x", 30, now).is_err());
    }
}