src/knowledge/git_heat.rs
//! 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;
// The git-heat MODEL PODs (`GitHeatScan`/`GitHeatRow`) now live in the
// `nornir-warehouse-trait` edda leaf (the trait's `append_git_heat_scan_async`
// names `GitHeatScan`), so a consumer can use them WITHOUT depending on nornir.
// Re-exported here at their old path (`crate::knowledge::git_heat::…`) so the
// gix SCANNER below — which stays in nornir and constructs these via their
// public fields — and every other call site are untouched.
pub use nornir_warehouse_trait::{GitHeatRow, GitHeatScan};
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,
// gix 0.88: the callback returns `gix::ExnResult` (it was
// a caller-chosen error type before).
|change| -> gix::Result<std::ops::ControlFlow<()>> {
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,
});
}
// ── test-matrix self-report (L4) ────────────────────────────────────────
//
// Reached ONLY on the full path: `gix::discover` found a repo AND `head_id`
// resolved AND the rev-walk ran. The two early returns above (not a git repo
// / no HEAD) return before this and emit NOTHING, which is deliberate — a row
// that reported "ok" for a directory with no commits would be green by
// construction, and an always-green row is worse than an absent one because it
// looks like coverage.
//
// So the check is a real one: a repo that HAS a reachable HEAD must yield at
// least one heat row. Zero rows means the walk or the first-parent tree diff
// produced nothing — which is exactly the silent failure this scanner can have
// (it swallows every per-commit error with `continue`).
nornir_testmatrix::functional_status(
"nornir/knowledge/git-heat",
"scan_repo_rows",
!out.files.is_empty(),
&format!(
"repo={repo_name} files={} commit_touches={}",
out.files.len(),
out.files.iter().map(|f| f.commits_total).sum::<i64>()
),
);
Ok(out)
}
struct FileAgg {
commits_total: i64,
commits_30d: i64,
commits_90d: i64,
authors: BTreeSet<String>,
last_commit_ts: DateTime<Utc>,
}