lean_ctx/core/
git_signals.rs1use std::collections::{HashMap, HashSet};
13use std::sync::Mutex;
14
15const RECENCY_HALF_LIFE_HOURS: f64 = 48.0;
17const CHURN_WINDOW: &str = "--since=14.days";
19const CHURN_MAX_COMMITS: &str = "200";
21
22static NO_GIT_ROOTS: Mutex<Option<HashSet<String>>> = Mutex::new(None);
24
25#[derive(Debug, Clone, Default)]
26pub struct GitSignals {
27 pub recency: HashMap<String, f64>,
29 pub churn: HashMap<String, f64>,
31}
32
33impl GitSignals {
34 pub fn is_empty(&self) -> bool {
35 self.recency.is_empty() && self.churn.is_empty()
36 }
37
38 pub fn recency_for(&self, path: &str, root: &str) -> f64 {
39 lookup(&self.recency, path, root)
40 }
41
42 pub fn churn_for(&self, path: &str, root: &str) -> f64 {
43 lookup(&self.churn, path, root)
44 }
45
46 pub fn boost_for(&self, path: &str, root: &str) -> f64 {
48 self.recency_for(path, root) * 0.25 + self.churn_for(path, root) * 0.10
49 }
50}
51
52fn lookup(map: &HashMap<String, f64>, path: &str, root: &str) -> f64 {
53 if let Some(v) = map.get(path) {
54 return *v;
55 }
56 let rel = relativize(path, root);
58 map.get(rel.as_ref()).copied().unwrap_or(0.0)
59}
60
61fn relativize<'a>(path: &'a str, root: &str) -> std::borrow::Cow<'a, str> {
62 let trimmed = root.trim_end_matches('/');
63 if !trimmed.is_empty()
64 && trimmed != "."
65 && let Some(rest) = path.strip_prefix(trimmed)
66 {
67 return std::borrow::Cow::Owned(rest.trim_start_matches('/').to_string());
68 }
69 std::borrow::Cow::Borrowed(path.trim_start_matches("./"))
70}
71
72fn known_non_git(root: &str) -> bool {
73 NO_GIT_ROOTS
74 .lock()
75 .ok()
76 .and_then(|g| g.as_ref().map(|s| s.contains(root)))
77 .unwrap_or(false)
78}
79
80fn remember_non_git(root: &str) {
81 if let Ok(mut guard) = NO_GIT_ROOTS.lock() {
82 guard
83 .get_or_insert_with(HashSet::new)
84 .insert(root.to_string());
85 }
86}
87
88pub fn collect(project_root: &str) -> GitSignals {
91 if known_non_git(project_root) {
92 return GitSignals::default();
93 }
94 if !std::path::Path::new(project_root).join(".git").exists() {
95 remember_non_git(project_root);
96 return GitSignals::default();
97 }
98
99 let mut signals = GitSignals::default();
100 collect_churn_and_commit_recency(project_root, &mut signals);
101 collect_uncommitted(project_root, &mut signals);
102 signals
103}
104
105fn collect_uncommitted(root: &str, signals: &mut GitSignals) {
108 let Some(status) = crate::core::git_cache::git_status_cached(root) else {
109 return;
110 };
111 for line in status.lines() {
112 if line.len() < 4 {
114 continue;
115 }
116 let path_part = &line[3..];
117 let path = path_part
118 .rsplit(" -> ")
119 .next()
120 .unwrap_or(path_part)
121 .trim()
122 .trim_matches('"');
123 if path.is_empty() {
124 continue;
125 }
126 signals.recency.insert(path.to_string(), 1.0);
127 }
128}
129
130fn collect_churn_and_commit_recency(root: &str, signals: &mut GitSignals) {
133 let Some(log) = crate::core::git_cache::git_log_cached(
134 &[
135 "--name-only",
136 "--pretty=format:%ct",
137 CHURN_WINDOW,
138 "-n",
139 CHURN_MAX_COMMITS,
140 ],
141 root,
142 ) else {
143 return;
144 };
145
146 let now = std::time::SystemTime::now()
147 .duration_since(std::time::UNIX_EPOCH)
148 .map_or(0, |d| d.as_secs());
149
150 let mut counts: HashMap<String, u32> = HashMap::new();
151 let mut newest_ts: HashMap<String, u64> = HashMap::new();
152 let mut current_ts: u64 = 0;
153
154 for line in log.lines() {
155 let line = line.trim();
156 if line.is_empty() {
157 continue;
158 }
159 if let Ok(ts) = line.parse::<u64>() {
160 current_ts = ts;
161 continue;
162 }
163 *counts.entry(line.to_string()).or_insert(0) += 1;
164 let entry = newest_ts.entry(line.to_string()).or_insert(0);
165 *entry = (*entry).max(current_ts);
166 }
167
168 let max_count = counts.values().copied().max().unwrap_or(0);
169 if max_count == 0 {
170 return;
171 }
172
173 for (path, count) in counts {
174 signals
175 .churn
176 .insert(path.clone(), f64::from(count) / f64::from(max_count));
177
178 if let Some(&ts) = newest_ts.get(&path)
179 && ts > 0
180 && now >= ts
181 {
182 let age_hours = (now - ts) as f64 / 3600.0;
183 let decay = 0.5_f64.powf(age_hours / RECENCY_HALF_LIFE_HOURS);
184 if decay > 0.01 {
185 signals.recency.insert(path, decay);
186 }
187 }
188 }
189}
190
191pub fn apply_boost(scores: &mut [crate::core::task_relevance::RelevanceScore], root: &str) {
194 let signals = collect(root);
195 if signals.is_empty() {
196 return;
197 }
198 for s in scores.iter_mut() {
199 let boost = signals.boost_for(&s.path, root);
200 if boost > 0.0 {
201 s.score = (s.score + boost).min(1.0);
202 }
203 }
204 scores.sort_by(|a, b| {
205 b.score
206 .partial_cmp(&a.score)
207 .unwrap_or(std::cmp::Ordering::Equal)
208 });
209}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214
215 fn run(dir: &std::path::Path, args: &[&str]) {
216 let out = std::process::Command::new("git")
217 .args(args)
218 .current_dir(dir)
219 .env("GIT_AUTHOR_NAME", "t")
220 .env("GIT_AUTHOR_EMAIL", "t@t")
221 .env("GIT_COMMITTER_NAME", "t")
222 .env("GIT_COMMITTER_EMAIL", "t@t")
223 .output()
224 .expect("git runs");
225 assert!(out.status.success(), "git {args:?}: {out:?}");
226 }
227
228 fn temp_repo() -> tempfile::TempDir {
229 let dir = tempfile::tempdir().unwrap();
230 run(dir.path(), &["init", "-q"]);
231 dir
232 }
233
234 #[test]
235 fn uncommitted_file_scores_recency_one() {
236 let repo = temp_repo();
237 std::fs::write(repo.path().join("wip.rs"), "fn main() {}").unwrap();
238 let root = repo.path().to_string_lossy().into_owned();
239 crate::core::git_cache::invalidate(&root);
240 let signals = collect(&root);
241 assert!((signals.recency_for("wip.rs", &root) - 1.0).abs() < f64::EPSILON);
242 }
243
244 #[test]
245 fn churn_normalized_to_max() {
246 let repo = temp_repo();
247 let root = repo.path().to_string_lossy().into_owned();
248 for i in 0..3 {
249 std::fs::write(repo.path().join("hot.rs"), format!("// v{i}")).unwrap();
250 run(repo.path(), &["add", "."]);
251 run(repo.path(), &["commit", "-qm", &format!("c{i}")]);
252 }
253 std::fs::write(repo.path().join("cold.rs"), "// once").unwrap();
254 run(repo.path(), &["add", "."]);
255 run(repo.path(), &["commit", "-qm", "cold"]);
256 crate::core::git_cache::invalidate(&root);
257
258 let signals = collect(&root);
259 let hot = signals.churn_for("hot.rs", &root);
260 let cold = signals.churn_for("cold.rs", &root);
261 assert!((hot - 1.0).abs() < f64::EPSILON, "hot file = max churn");
262 assert!(cold > 0.0 && cold < hot);
263 assert!(signals.recency_for("cold.rs", &root) > 0.9);
265 }
266
267 #[test]
268 fn non_git_root_yields_empty_and_is_cached() {
269 let dir = tempfile::tempdir().unwrap();
270 let root = dir.path().to_string_lossy().into_owned();
271 assert!(collect(&root).is_empty());
272 assert!(known_non_git(&root), "non-git root remembered");
273 assert!(collect(&root).is_empty());
274 }
275
276 #[test]
277 fn absolute_paths_relativized_in_lookup() {
278 let mut signals = GitSignals::default();
279 signals.recency.insert("src/a.rs".to_string(), 1.0);
280 let root = "/repo";
281 assert!((signals.recency_for("/repo/src/a.rs", root) - 1.0).abs() < f64::EPSILON);
282 assert!((signals.recency_for("src/a.rs", root) - 1.0).abs() < f64::EPSILON);
283 }
284
285 #[test]
286 fn boost_combines_recency_and_churn() {
287 let mut signals = GitSignals::default();
288 signals.recency.insert("a.rs".to_string(), 1.0);
289 signals.churn.insert("a.rs".to_string(), 1.0);
290 let boost = signals.boost_for("a.rs", ".");
291 assert!((boost - 0.35).abs() < 1e-9);
292 }
293}