use std::collections::HashSet;
use std::ops::ControlFlow;
use std::path::{Path, PathBuf};
use gix::ObjectId;
use gix::diff::Rewrites;
use gix::revision::walk::Sorting;
use gix::traverse::commit::simple::CommitTimeOrder;
use super::identity::ParticipantResolver;
use super::repo::bstr_to_path;
use super::{current_unix_seconds, diff_err, history, walk_err};
use crate::vcs::classify;
use crate::vcs::entropy::shannon_entropy;
use crate::vcs::error::Error;
use crate::vcs::identity::{AuthorId, BotFilter};
use crate::vcs::jit::{
JIT_SCHEMA_VERSION, JIT_SCORE_VERSION, JitCommit, JitDiffusion, JitExperience, JitFeatures,
JitHistory, JitPurpose, JitReport, JitSize, JitSource, score,
};
use crate::vcs::options::{Options, RiskFormula};
#[cfg_attr(test, derive(Debug))]
pub(super) struct Touched {
pub(super) path: PathBuf,
pub(super) parent_path: Option<PathBuf>,
pub(super) added: u64,
pub(super) deleted: u64,
pub(super) hunks: u32,
}
impl Touched {
fn churn(&self) -> u64 {
self.added.saturating_add(self.deleted)
}
}
pub(crate) fn score_commit(root: &Path, spec: &str, options: &Options) -> Result<JitReport, Error> {
let repo = super::repo::open(root)?.repo;
let commit = super::repo::resolve_commit(&repo, spec)?;
let now = options.as_of.unwrap_or_else(current_unix_seconds);
let commit_time = commit.time().map_err(walk_err)?.seconds.min(now);
let (parent_tree, prior_base, parent_count) = parent_context(&repo, &commit)?;
let commit_tree = commit.tree().map_err(walk_err)?;
let ctx = CommitContext {
commit_time,
prior_base,
};
let (features, purpose) = compute_features(
root,
&repo,
&commit,
&parent_tree,
&commit_tree,
&ctx,
options,
)?;
let (total, contributions) = score(&features, purpose);
Ok(JitReport {
jit_schema_version: JIT_SCHEMA_VERSION,
jit_score_version: JIT_SCORE_VERSION,
source: JitSource::Commit,
long_window_days: options.long_window_days(),
recent_window_days: options.recent_window_days(),
risk_score: total,
commit: JitCommit {
id: commit.id.to_hex().to_string(),
parent_count,
is_merge: parent_count > 1,
purpose,
},
features,
contributions,
})
}
struct CommitContext {
commit_time: i64,
prior_base: Option<ObjectId>,
}
fn parent_context<'repo>(
repo: &'repo gix::Repository,
commit: &gix::Commit<'repo>,
) -> Result<(gix::Tree<'repo>, Option<ObjectId>, u32), Error> {
let mut ids = commit.parent_ids();
let first_parent = ids.next().map(gix::Id::detach);
let parent_count =
u32::try_from(usize::from(first_parent.is_some()) + ids.count()).unwrap_or(u32::MAX);
let (parent_tree, prior_base) = match first_parent {
Some(pid) => match repo.try_find_object(pid).map_err(walk_err)? {
Some(object) => (
object
.peel_to_commit()
.map_err(walk_err)?
.tree()
.map_err(walk_err)?,
Some(pid),
),
None => (repo.empty_tree(), None),
},
None => (repo.empty_tree(), None),
};
Ok((parent_tree, prior_base, parent_count))
}
#[allow(clippy::too_many_arguments)]
fn compute_features(
root: &Path,
repo: &gix::Repository,
commit: &gix::Commit<'_>,
parent_tree: &gix::Tree<'_>,
commit_tree: &gix::Tree<'_>,
ctx: &CommitContext,
options: &Options,
) -> Result<(JitFeatures, JitPurpose), Error> {
let touched = collect_touched(repo, parent_tree, commit_tree, options)?;
let size = size_features(&touched);
let diffusion = diffusion_features(&touched);
let history = history_features(root, &touched, ctx.prior_base, ctx.commit_time, options)?;
let mailmap = repo.open_mailmap();
let target = resolve_author(commit, &mailmap)?;
let experience = match ctx.prior_base {
Some(pid) => experience_features(repo, pid, &target, &mailmap, options, ctx.commit_time)?,
None => JitExperience::default(),
};
let class = classify::classify(commit.message_raw().map_err(walk_err)?);
let purpose = JitPurpose {
is_fix: class.bug_fix,
is_security_fix: class.security_fix,
is_revert: class.revert,
};
Ok((
JitFeatures {
size,
diffusion,
history,
experience,
},
purpose,
))
}
fn collect_touched(
repo: &gix::Repository,
parent_tree: &gix::Tree<'_>,
commit_tree: &gix::Tree<'_>,
options: &Options,
) -> Result<Vec<Touched>, Error> {
use gix::object::tree::diff::Change;
let rewrites = options.follow_renames.then(Rewrites::default);
let mut cache = repo.diff_resource_cache_for_tree_diff().map_err(diff_err)?;
let mut touched = Vec::new();
parent_tree
.changes()
.map_err(diff_err)?
.options(|opts| {
opts.track_path();
opts.track_rewrites(rewrites);
})
.for_each_to_obtain_tree(commit_tree, |change| -> Result<ControlFlow<()>, Error> {
let mode = change.entry_mode();
if !mode.is_blob() || mode.is_link() {
return Ok(ControlFlow::Continue(()));
}
let (path, parent_path) = match &change {
Change::Addition { location, .. } => (bstr_to_path(location)?, None),
Change::Deletion { location, .. } | Change::Modification { location, .. } => {
let p = bstr_to_path(location)?;
(p.clone(), Some(p))
}
Change::Rewrite {
source_location,
location,
..
} => (
bstr_to_path(location)?,
Some(bstr_to_path(source_location)?),
),
};
let Some((added, deleted, hunks)) = blob_line_stats(&change, &mut cache)? else {
return Ok(ControlFlow::Continue(())); };
touched.push(Touched {
path,
parent_path,
added,
deleted,
hunks,
});
Ok(ControlFlow::Continue(()))
})
.map_err(diff_err)?;
Ok(touched)
}
fn blob_line_stats(
change: &gix::object::tree::diff::Change<'_, '_, '_>,
cache: &mut gix::diff::blob::Platform,
) -> Result<Option<(u64, u64, u32)>, Error> {
use gix::diff::blob::platform::prepare_diff::Operation;
let platform = change.diff(cache).map_err(diff_err)?;
platform
.resource_cache
.options
.skip_internal_diff_if_external_is_configured = false;
let prep = platform.resource_cache.prepare_diff().map_err(diff_err)?;
let Operation::InternalDiff { algorithm } = prep.operation else {
return Ok(None);
};
let input = prep.interned_input();
let diff = gix::diff::blob::Diff::compute(algorithm, &input);
let hunks = u32::try_from(diff.hunks().count()).unwrap_or(u32::MAX);
Ok(Some((
u64::from(diff.count_additions()),
u64::from(diff.count_removals()),
hunks,
)))
}
pub(super) fn size_features(touched: &[Touched]) -> JitSize {
let mut size = JitSize {
files_touched: u32::try_from(touched.len()).unwrap_or(u32::MAX),
..JitSize::default()
};
for t in touched {
size.lines_added = size.lines_added.saturating_add(t.added);
size.lines_deleted = size.lines_deleted.saturating_add(t.deleted);
size.hunks = size.hunks.saturating_add(t.hunks);
}
size
}
#[allow(clippy::cast_precision_loss)]
pub(super) fn diffusion_features(touched: &[Touched]) -> JitDiffusion {
let mut subsystems: HashSet<&Path> = HashSet::with_capacity(touched.len());
let mut directories: HashSet<&Path> = HashSet::with_capacity(touched.len());
for t in touched {
subsystems.insert(top_level(&t.path));
directories.insert(t.path.parent().unwrap_or_else(|| Path::new("")));
}
JitDiffusion {
subsystems: u32::try_from(subsystems.len()).unwrap_or(u32::MAX),
directories: u32::try_from(directories.len()).unwrap_or(u32::MAX),
entropy: shannon_entropy(touched.iter().map(|t| t.churn() as f64)),
}
}
fn top_level(path: &Path) -> &Path {
let mut components = path.components();
match components.next() {
Some(first) if components.next().is_some() => Path::new(first.as_os_str()),
_ => Path::new(""),
}
}
#[allow(clippy::cast_precision_loss)]
fn history_features(
root: &Path,
touched: &[Touched],
prior_base: Option<ObjectId>,
commit_time: i64,
options: &Options,
) -> Result<JitHistory, Error> {
let Some(base) = prior_base else {
return Ok(JitHistory {
new_files: u32::try_from(touched.len()).unwrap_or(u32::MAX),
..JitHistory::default()
});
};
let prior_options = Options {
reference: base.to_hex().to_string(),
as_of: Some(commit_time),
risk_formula: RiskFormula::Weighted,
compute_bus_factor: false,
..options.clone()
};
let index = crate::vcs::build_history_index(root, &prior_options)?;
let mut history = JitHistory::default();
let mut risk_sum = 0.0_f64;
for t in touched {
match t.parent_path.as_deref().and_then(|p| index.get(p)) {
Some(stats) => {
history.prior_changes = history.prior_changes.saturating_add(stats.commits_long);
history.prior_distinct_authors =
history.prior_distinct_authors.max(stats.authors_long);
history.prior_bug_fix_commits = history
.prior_bug_fix_commits
.saturating_add(stats.bug_fix_commits);
history.prior_security_fix_commits = history
.prior_security_fix_commits
.saturating_add(stats.security_fix_commits);
history.file_risk_max = history.file_risk_max.max(stats.risk_score);
risk_sum += stats.risk_score;
}
None => history.new_files = history.new_files.saturating_add(1),
}
}
if !touched.is_empty() {
history.file_risk_mean = risk_sum / touched.len() as f64;
}
Ok(history)
}
fn resolve_author(
commit: &gix::Commit<'_>,
mailmap: &gix::mailmap::Snapshot,
) -> Result<AuthorId, Error> {
let author = commit
.author()
.map_err(|e| Error::Walk(format!("decoding commit author: {e}")))?;
let resolved = mailmap.resolve(author);
Ok(AuthorId::new(&resolved.name, &resolved.email))
}
fn experience_features(
repo: &gix::Repository,
parent: ObjectId,
target: &AuthorId,
mailmap: &gix::mailmap::Snapshot,
options: &Options,
reference_time: i64,
) -> Result<JitExperience, Error> {
let long_boundary = reference_time.saturating_sub(options.long_window_secs);
let recent_boundary = reference_time.saturating_sub(options.recent_window_secs);
let bots = if options.exclude_bots {
Some(BotFilter::new(&options.bot_pattern)?)
} else {
None
};
let resolver = ParticipantResolver::new(mailmap, bots.as_ref());
let mut platform = repo.rev_walk([parent]);
if !options.full_history {
platform = platform.first_parent_only();
}
let walk = platform
.sorting(Sorting::ByCommitTimeCutoff {
order: CommitTimeOrder::NewestFirst,
seconds: long_boundary,
})
.all()
.map_err(walk_err)?;
let mut experience = JitExperience::default();
for info in walk {
let info = info.map_err(walk_err)?;
let commit = info.object().map_err(walk_err)?;
let commit_time = history::commit_seconds(&info, &commit)?.min(reference_time);
if commit_time < long_boundary {
continue;
}
if !options.include_merges && commit.parent_ids().take(2).count() > 1 {
continue;
}
if resolver.participants(&commit)?.iter().any(|p| p == target) {
experience.author_prior_commits = experience.author_prior_commits.saturating_add(1);
if commit_time >= recent_boundary {
experience.author_recent_commits =
experience.author_recent_commits.saturating_add(1);
}
}
}
Ok(experience)
}
#[cfg(test)]
mod tests {
use super::*;
fn touched(added: u64, deleted: u64) -> Touched {
Touched {
path: PathBuf::new(),
parent_path: None,
added,
deleted,
hunks: 0,
}
}
#[test]
fn churn_sums_added_and_deleted() {
assert_eq!(touched(3, 4).churn(), 7);
}
#[test]
fn churn_saturates_on_overflow() {
assert_eq!(touched(u64::MAX, 5).churn(), u64::MAX);
}
}