Skip to main content

debtmap/risk/context/git_history/
batched_function.rs

1//! Batched function-level git history (preload at context load time).
2//!
3//! One repository-wide revwalk drives per-file accounting of `-S`
4//! introductions and `-G` modifications, matching `git log` semantics
5//! without per-function subprocess fan-out.
6
7use super::blame_cache::{FileBlameCache, extract_authors_for_range};
8use super::function_level::{CommitInfo, FunctionHistory, calculate_function_history_with_authors};
9use super::git2_provider::{self, Git2Repository};
10use crate::time_span;
11use anyhow::Result;
12use dashmap::DashMap;
13use rayon::prelude::*;
14use std::collections::HashMap;
15use std::path::{Path, PathBuf};
16use std::sync::atomic::{AtomicUsize, Ordering};
17use std::time::Instant;
18
19/// One function to preload history for (paths relative to repo root).
20#[derive(Debug, Clone)]
21pub struct FunctionPreloadTarget {
22    pub file: PathBuf,
23    pub name: String,
24    pub line_range: (usize, usize),
25}
26
27/// Cache key for function history lookups.
28#[derive(Debug, Clone, PartialEq, Eq, Hash)]
29pub struct FunctionHistoryKey {
30    pub file: PathBuf,
31    pub name: String,
32}
33
34/// Phase of function git history preload (for TUI progress).
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum GitPreloadPhase {
37    /// Scanning repository commits for `-S`/`-G` history.
38    Commits,
39    /// Running per-file blame to attribute authors.
40    BlameFiles,
41}
42
43/// Progress callback: `(phase, processed, total)`.
44pub type ProgressCallback<'a> = &'a (dyn Fn(GitPreloadPhase, usize, usize) + Send + Sync);
45
46/// Preloaded function histories for O(1) lookup during scoring.
47pub struct BatchedFunctionGitHistory {
48    histories: DashMap<FunctionHistoryKey, FunctionHistory>,
49}
50
51/// Result of batched function git history preload.
52pub struct FunctionPreloadResult {
53    pub functions: BatchedFunctionGitHistory,
54    pub file_history: super::batched::BatchedGitHistory,
55}
56
57impl BatchedFunctionGitHistory {
58    pub fn build(
59        repo: &Git2Repository,
60        blame_cache: &FileBlameCache,
61        targets: &[FunctionPreloadTarget],
62        progress_cb: Option<ProgressCallback<'_>>,
63    ) -> Result<FunctionPreloadResult> {
64        time_span!("git_function_history_preload");
65        let start = Instant::now();
66
67        let by_file = group_targets_by_file(targets);
68        let file_count = by_file.len();
69        let file_names: HashMap<PathBuf, Vec<String>> = by_file
70            .iter()
71            .map(|(file, fns)| (file.clone(), fns.iter().map(|(n, _)| n.clone()).collect()))
72            .collect();
73
74        let scan = git2_provider::compute_repo_function_histories(
75            repo.repo_path(),
76            &file_names,
77            progress_cb,
78        )?;
79        let records = scan.functions;
80        let file_history = super::batched::BatchedGitHistory::from_commit_scans(&scan.file_scans);
81
82        let histories: DashMap<FunctionHistoryKey, FunctionHistory> = DashMap::new();
83        let processed_files = AtomicUsize::new(0);
84        if let Some(cb) = progress_cb {
85            cb(GitPreloadPhase::BlameFiles, 0, file_count);
86        }
87
88        by_file.par_iter().for_each(|(file, functions)| {
89            let blame_data = blame_cache.get_or_fetch(file).ok();
90            for (name, line_range) in functions {
91                let Some(record) = records.get(&(file.clone(), name.clone())) else {
92                    continue;
93                };
94                if record.introduction_oid.is_none() {
95                    continue;
96                }
97                let (start_line, end_line) = *line_range;
98                let blame_authors = blame_data
99                    .as_ref()
100                    .map(|data| extract_authors_for_range(data, start_line, end_line))
101                    .unwrap_or_default();
102                let modification_commits: Vec<CommitInfo> = record
103                    .modifications
104                    .iter()
105                    .map(|s| CommitInfo {
106                        hash: s.hash.to_string(),
107                        date: Some(s.date),
108                        message: s.message.clone(),
109                        author: s.author_email.clone(),
110                    })
111                    .collect();
112                let history = calculate_function_history_with_authors(
113                    record.introduction_oid.map(|o| o.to_string()),
114                    record.introduction_date,
115                    &modification_commits,
116                    blame_authors,
117                );
118                histories.insert(
119                    FunctionHistoryKey {
120                        file: file.clone(),
121                        name: name.clone(),
122                    },
123                    history,
124                );
125            }
126
127            let done = processed_files.fetch_add(1, Ordering::Relaxed) + 1;
128            if let Some(cb) = progress_cb
129                && (done % 10 == 0 || done == file_count)
130            {
131                cb(GitPreloadPhase::BlameFiles, done, file_count);
132            }
133        });
134
135        log::info!(
136            "Preloaded {} function histories from {} files in {:?}",
137            histories.len(),
138            file_count,
139            start.elapsed()
140        );
141
142        Ok(FunctionPreloadResult {
143            functions: Self { histories },
144            file_history,
145        })
146    }
147
148    pub fn get(&self, file: &Path, function_name: &str) -> Option<FunctionHistory> {
149        self.histories
150            .get(&FunctionHistoryKey {
151                file: file.to_path_buf(),
152                name: function_name.to_string(),
153            })
154            .map(|entry| entry.clone())
155    }
156
157    pub fn len(&self) -> usize {
158        self.histories.len()
159    }
160
161    pub fn is_empty(&self) -> bool {
162        self.histories.is_empty()
163    }
164}
165
166type FileTargetMap = HashMap<PathBuf, Vec<(String, (usize, usize))>>;
167
168fn group_targets_by_file(targets: &[FunctionPreloadTarget]) -> FileTargetMap {
169    let mut by_file: FileTargetMap = HashMap::new();
170    for target in targets {
171        if target.name.is_empty() {
172            continue;
173        }
174        let entries = by_file.entry(target.file.clone()).or_default();
175        if entries.iter().any(|(name, _)| name == &target.name) {
176            continue;
177        }
178        entries.push((target.name.clone(), target.line_range));
179    }
180    by_file
181}