fff_search/
git_recency.rs1use ahash::AHashMap;
2use git2::{DiffOptions, Oid, Repository};
3use std::path::Path;
4
5#[derive(Debug, Clone, Copy)]
6pub struct GitRecencyConfig {
7 pub enabled: bool,
8 pub max_commits: usize,
9 pub max_files_per_commit: usize,
10}
11
12impl Default for GitRecencyConfig {
13 fn default() -> Self {
14 Self {
15 enabled: true,
16 max_commits: 10,
17 max_files_per_commit: 50,
19 }
20 }
21}
22
23const MAX_COMMITS_HARD_CAP: usize = 128;
24
25#[tracing::instrument(skip(repo), level = tracing::Level::DEBUG)]
27pub(crate) fn compute_git_recency(
28 repo: &Repository,
29 config: &GitRecencyConfig,
30 base_path: &Path,
31) -> Option<AHashMap<String, i16>> {
32 if !config.enabled || config.max_commits == 0 {
33 return None;
34 }
35
36 let head_ref = repo.head().ok()?;
38 let head = head_ref.target()?;
39 let head_branch = head_ref.shorthand().ok().map(str::to_owned);
40
41 let subdir = base_path_within_repo(repo, base_path);
42 let max_commits = config.max_commits.min(MAX_COMMITS_HARD_CAP);
43
44 let mut revwalk = repo.revwalk().ok()?;
45 revwalk.push(head).ok()?;
46
47 if let Some((base_branch, base)) = resolve_base_branch(repo)
49 && head_branch.as_deref() != Some(base_branch.as_str())
50 && let Ok(merge_base) = repo.merge_base(head, base)
51 && merge_base != head
52 {
53 let _ = revwalk.hide(merge_base);
54 }
55
56 let mut scores: AHashMap<String, i16> = AHashMap::new();
57 let mut qualifying = 0usize;
58 let walk_budget = (max_commits * 5).max(64);
61
62 for oid in revwalk.take(walk_budget) {
63 if qualifying >= max_commits {
64 break;
65 }
66
67 let Ok(commit) = oid.and_then(|oid| repo.find_commit(oid)) else {
68 continue;
69 };
70
71 if commit.parent_count() > 1 {
73 continue;
74 }
75
76 let Ok(tree) = commit.tree() else { continue };
77 let parent_tree = commit.parent(0).ok().and_then(|p| p.tree().ok());
78
79 let mut diff_opts = DiffOptions::new();
80 if let Some(subdir) = subdir.as_deref() {
81 diff_opts.pathspec(subdir);
82 }
83 let Ok(diff) =
84 repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&tree), Some(&mut diff_opts))
85 else {
86 continue;
87 };
88
89 let deltas = diff.deltas();
90 if deltas.len() > config.max_files_per_commit {
91 continue;
92 }
93
94 for delta in deltas {
95 let Some(path_bytes) = delta
96 .new_file()
97 .path_bytes()
98 .or_else(|| delta.old_file().path_bytes())
99 else {
100 continue;
101 };
102
103 let repo_relative = String::from_utf8_lossy(path_bytes);
104 let relative_path = match subdir.as_deref() {
106 Some(subdir) => match repo_relative
107 .strip_prefix(subdir)
108 .and_then(|rest| rest.strip_prefix('/'))
109 {
110 Some(rest) => rest,
111 None => continue,
112 },
113 None => repo_relative.as_ref(),
114 };
115
116 if let Some(count) = scores.get_mut(relative_path) {
118 *count = count.saturating_add(1);
119 } else {
120 scores.insert(relative_path.to_owned(), 1);
121 }
122 }
123
124 qualifying += 1;
125 }
126
127 tracing::debug!(
128 files_scored = scores.len(),
129 commits_analyzed = qualifying,
130 "git recency computed"
131 );
132
133 Some(scores)
134}
135
136fn base_path_within_repo(repo: &Repository, base_path: &Path) -> Option<String> {
137 let workdir = crate::path_utils::normalize(repo.workdir()?.to_path_buf());
138 let subdir = base_path.strip_prefix(workdir).ok()?;
139 let subdir = crate::path_utils::to_canonical_slashes(&subdir.to_string_lossy()).into_owned();
140 (!subdir.is_empty()).then_some(subdir)
141}
142
143fn resolve_base_branch(repo: &Repository) -> Option<(String, Oid)> {
146 let remote_head = repo
147 .find_reference("refs/remotes/origin/HEAD")
148 .ok()
149 .and_then(|r| {
150 r.symbolic_target()
151 .ok()??
152 .strip_prefix("refs/remotes/origin/")
153 .map(str::to_owned)
154 });
155
156 let configured = repo
157 .config()
158 .and_then(|config| config.get_string("init.defaultBranch"))
159 .ok()
160 .filter(|name| !name.is_empty());
161
162 remote_head
163 .as_deref()
164 .into_iter()
165 .chain(configured.as_deref())
166 .chain(["main", "master"])
167 .find_map(|branch| {
168 Some((branch.to_owned(), {
169 repo.resolve_reference_from_short_name(&format!("origin/{branch}"))
171 .or_else(|_| repo.resolve_reference_from_short_name(branch))
172 .ok()?
173 .target()
174 }?))
175 })
176}