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());
}
}