use ahash::AHashMap;
use git2::{DiffOptions, Oid, Repository};
use std::path::Path;
#[derive(Debug, Clone, Copy)]
pub struct GitRecencyConfig {
pub enabled: bool,
pub max_commits: usize,
pub max_files_per_commit: usize,
}
impl Default for GitRecencyConfig {
fn default() -> Self {
Self {
enabled: true,
max_commits: 10,
max_files_per_commit: 50,
}
}
}
const MAX_COMMITS_HARD_CAP: usize = 128;
#[tracing::instrument(skip(repo), level = tracing::Level::DEBUG)]
pub(crate) fn compute_git_recency(
repo: &Repository,
config: &GitRecencyConfig,
base_path: &Path,
) -> Option<AHashMap<String, i16>> {
if !config.enabled || config.max_commits == 0 {
return None;
}
let head_ref = repo.head().ok()?;
let head = head_ref.target()?;
let head_branch = head_ref.shorthand().ok().map(str::to_owned);
let subdir = base_path_within_repo(repo, base_path);
let max_commits = config.max_commits.min(MAX_COMMITS_HARD_CAP);
let mut revwalk = repo.revwalk().ok()?;
revwalk.push(head).ok()?;
if let Some((base_branch, base)) = resolve_base_branch(repo)
&& head_branch.as_deref() != Some(base_branch.as_str())
&& let Ok(merge_base) = repo.merge_base(head, base)
&& merge_base != head
{
let _ = revwalk.hide(merge_base);
}
let mut scores: AHashMap<String, i16> = AHashMap::new();
let mut qualifying = 0usize;
let walk_budget = (max_commits * 5).max(64);
for oid in revwalk.take(walk_budget) {
if qualifying >= max_commits {
break;
}
let Ok(commit) = oid.and_then(|oid| repo.find_commit(oid)) else {
continue;
};
if commit.parent_count() > 1 {
continue;
}
let Ok(tree) = commit.tree() else { continue };
let parent_tree = commit.parent(0).ok().and_then(|p| p.tree().ok());
let mut diff_opts = DiffOptions::new();
if let Some(subdir) = subdir.as_deref() {
diff_opts.pathspec(subdir);
}
let Ok(diff) =
repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&tree), Some(&mut diff_opts))
else {
continue;
};
let deltas = diff.deltas();
if deltas.len() > config.max_files_per_commit {
continue;
}
for delta in deltas {
let Some(path_bytes) = delta
.new_file()
.path_bytes()
.or_else(|| delta.old_file().path_bytes())
else {
continue;
};
let repo_relative = String::from_utf8_lossy(path_bytes);
let relative_path = match subdir.as_deref() {
Some(subdir) => match repo_relative
.strip_prefix(subdir)
.and_then(|rest| rest.strip_prefix('/'))
{
Some(rest) => rest,
None => continue,
},
None => repo_relative.as_ref(),
};
if let Some(count) = scores.get_mut(relative_path) {
*count = count.saturating_add(1);
} else {
scores.insert(relative_path.to_owned(), 1);
}
}
qualifying += 1;
}
tracing::debug!(
files_scored = scores.len(),
commits_analyzed = qualifying,
"git recency computed"
);
Some(scores)
}
fn base_path_within_repo(repo: &Repository, base_path: &Path) -> Option<String> {
let workdir = crate::path_utils::normalize(repo.workdir()?.to_path_buf());
let subdir = base_path.strip_prefix(workdir).ok()?;
let subdir = crate::path_utils::to_canonical_slashes(&subdir.to_string_lossy()).into_owned();
(!subdir.is_empty()).then_some(subdir)
}
fn resolve_base_branch(repo: &Repository) -> Option<(String, Oid)> {
let remote_head = repo
.find_reference("refs/remotes/origin/HEAD")
.ok()
.and_then(|r| {
r.symbolic_target()
.ok()??
.strip_prefix("refs/remotes/origin/")
.map(str::to_owned)
});
let configured = repo
.config()
.and_then(|config| config.get_string("init.defaultBranch"))
.ok()
.filter(|name| !name.is_empty());
remote_head
.as_deref()
.into_iter()
.chain(configured.as_deref())
.chain(["main", "master"])
.find_map(|branch| {
Some((branch.to_owned(), {
repo.resolve_reference_from_short_name(&format!("origin/{branch}"))
.or_else(|_| repo.resolve_reference_from_short_name(branch))
.ok()?
.target()
}?))
})
}