Skip to main content

fff_search/
git_recency.rs

1use 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            // Ignore commits that touched every single file in the repo
18            max_files_per_commit: 50,
19        }
20    }
21}
22
23const MAX_COMMITS_HARD_CAP: usize = 128;
24
25// Computes per file recency bonuses
26#[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    // Unborn/orphan HEAD: there is no window to compute from.
37    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    // only if we can resolve default branch (master, main) attempt to use the recency
48    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    // Bounds total walked commits so histories full of skipped (merge/bulk)
59    // commits can't turn the walk into a full history scan.
60    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 merge commit
72        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            // Fold repo-relative down to base_path-relative when indexing a subdir
105            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            // get-before-insert keeps repeat participations allocation-free
117            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
143// The branch feature work is measured against: `origin/HEAD`, else
144// `init.defaultBranch` when configured, else `main`, else `master`.
145fn 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                // prefer remote branches
170                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}