Skip to main content

lean_ctx/core/
git_signals.rs

1//! Git working-set signals for relevance ranking (#497).
2//!
3//! Git already knows what the developer is working on: uncommitted changes
4//! are near-certainly task-relevant, and files with recent churn are
5//! hotspots. This module turns `git status` + `git log` into per-file scores
6//! consumed by `task_relevance`, `ctx_preload` and the context triage.
7//!
8//! All git access goes through `git_cache` (TTL cache) — no subprocess storm,
9//! and absent-repo roots are remembered per process so non-git projects never
10//! pay more than one probe.
11
12use std::collections::{HashMap, HashSet};
13use std::sync::Mutex;
14
15/// Half-life for commit recency decay: a file committed 48h ago scores 0.5.
16const RECENCY_HALF_LIFE_HOURS: f64 = 48.0;
17/// Look-back window for churn computation.
18const CHURN_WINDOW: &str = "--since=14.days";
19/// Bound the parsed log; 200 commits is plenty for a 14-day window.
20const CHURN_MAX_COMMITS: &str = "200";
21
22/// Roots probed and found to be non-git — never probe them again this process.
23static NO_GIT_ROOTS: Mutex<Option<HashSet<String>>> = Mutex::new(None);
24
25#[derive(Debug, Clone, Default)]
26pub struct GitSignals {
27    /// Relative path -> 0..1. 1.0 = uncommitted change (active working set).
28    pub recency: HashMap<String, f64>,
29    /// Relative path -> 0..1, commit-count in window normalized to the max.
30    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    /// Combined ranking boost: uncommitted work dominates, churn hints.
47    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    // Graph stores may carry absolute paths; git emits root-relative ones.
57    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() && trimmed != "." {
64        if let Some(rest) = path.strip_prefix(trimmed) {
65            return std::borrow::Cow::Owned(rest.trim_start_matches('/').to_string());
66        }
67    }
68    std::borrow::Cow::Borrowed(path.trim_start_matches("./"))
69}
70
71fn known_non_git(root: &str) -> bool {
72    NO_GIT_ROOTS
73        .lock()
74        .ok()
75        .and_then(|g| g.as_ref().map(|s| s.contains(root)))
76        .unwrap_or(false)
77}
78
79fn remember_non_git(root: &str) {
80    if let Ok(mut guard) = NO_GIT_ROOTS.lock() {
81        guard
82            .get_or_insert_with(HashSet::new)
83            .insert(root.to_string());
84    }
85}
86
87/// Collect git signals for a project root. Cheap on repeat calls (TTL cache),
88/// empty for non-git roots (probed once per process).
89pub fn collect(project_root: &str) -> GitSignals {
90    if known_non_git(project_root) {
91        return GitSignals::default();
92    }
93    if !std::path::Path::new(project_root).join(".git").exists() {
94        remember_non_git(project_root);
95        return GitSignals::default();
96    }
97
98    let mut signals = GitSignals::default();
99    collect_churn_and_commit_recency(project_root, &mut signals);
100    collect_uncommitted(project_root, &mut signals);
101    signals
102}
103
104/// `git status --porcelain`: any modified/added/renamed path is the active
105/// working set — maximum recency.
106fn collect_uncommitted(root: &str, signals: &mut GitSignals) {
107    let Some(status) = crate::core::git_cache::git_status_cached(root) else {
108        return;
109    };
110    for line in status.lines() {
111        // Porcelain v1: `XY <path>` or `XY <old> -> <new>` for renames.
112        if line.len() < 4 {
113            continue;
114        }
115        let path_part = &line[3..];
116        let path = path_part
117            .rsplit(" -> ")
118            .next()
119            .unwrap_or(path_part)
120            .trim()
121            .trim_matches('"');
122        if path.is_empty() {
123            continue;
124        }
125        signals.recency.insert(path.to_string(), 1.0);
126    }
127}
128
129/// `git log --name-only` over the churn window: commit count per file (churn)
130/// and exponential-decay recency from the newest commit touching the file.
131fn collect_churn_and_commit_recency(root: &str, signals: &mut GitSignals) {
132    let Some(log) = crate::core::git_cache::git_log_cached(
133        &[
134            "--name-only",
135            "--pretty=format:%ct",
136            CHURN_WINDOW,
137            "-n",
138            CHURN_MAX_COMMITS,
139        ],
140        root,
141    ) else {
142        return;
143    };
144
145    let now = std::time::SystemTime::now()
146        .duration_since(std::time::UNIX_EPOCH)
147        .map_or(0, |d| d.as_secs());
148
149    let mut counts: HashMap<String, u32> = HashMap::new();
150    let mut newest_ts: HashMap<String, u64> = HashMap::new();
151    let mut current_ts: u64 = 0;
152
153    for line in log.lines() {
154        let line = line.trim();
155        if line.is_empty() {
156            continue;
157        }
158        if let Ok(ts) = line.parse::<u64>() {
159            current_ts = ts;
160            continue;
161        }
162        *counts.entry(line.to_string()).or_insert(0) += 1;
163        let entry = newest_ts.entry(line.to_string()).or_insert(0);
164        *entry = (*entry).max(current_ts);
165    }
166
167    let max_count = counts.values().copied().max().unwrap_or(0);
168    if max_count == 0 {
169        return;
170    }
171
172    for (path, count) in counts {
173        signals
174            .churn
175            .insert(path.clone(), f64::from(count) / f64::from(max_count));
176
177        if let Some(&ts) = newest_ts.get(&path) {
178            if ts > 0 && now >= ts {
179                let age_hours = (now - ts) as f64 / 3600.0;
180                let decay = 0.5_f64.powf(age_hours / RECENCY_HALF_LIFE_HOURS);
181                if decay > 0.01 {
182                    signals.recency.insert(path, decay);
183                }
184            }
185        }
186    }
187}
188
189/// Apply the git boost to an already-computed relevance ranking and re-sort.
190/// Call sites own the project root; the ranking itself is root-agnostic.
191pub fn apply_boost(scores: &mut [crate::core::task_relevance::RelevanceScore], root: &str) {
192    let signals = collect(root);
193    if signals.is_empty() {
194        return;
195    }
196    for s in scores.iter_mut() {
197        let boost = signals.boost_for(&s.path, root);
198        if boost > 0.0 {
199            s.score = (s.score + boost).min(1.0);
200        }
201    }
202    scores.sort_by(|a, b| {
203        b.score
204            .partial_cmp(&a.score)
205            .unwrap_or(std::cmp::Ordering::Equal)
206    });
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    fn run(dir: &std::path::Path, args: &[&str]) {
214        let out = std::process::Command::new("git")
215            .args(args)
216            .current_dir(dir)
217            .env("GIT_AUTHOR_NAME", "t")
218            .env("GIT_AUTHOR_EMAIL", "t@t")
219            .env("GIT_COMMITTER_NAME", "t")
220            .env("GIT_COMMITTER_EMAIL", "t@t")
221            .output()
222            .expect("git runs");
223        assert!(out.status.success(), "git {args:?}: {out:?}");
224    }
225
226    fn temp_repo() -> tempfile::TempDir {
227        let dir = tempfile::tempdir().unwrap();
228        run(dir.path(), &["init", "-q"]);
229        dir
230    }
231
232    #[test]
233    fn uncommitted_file_scores_recency_one() {
234        let repo = temp_repo();
235        std::fs::write(repo.path().join("wip.rs"), "fn main() {}").unwrap();
236        let root = repo.path().to_string_lossy().into_owned();
237        crate::core::git_cache::invalidate(&root);
238        let signals = collect(&root);
239        assert!((signals.recency_for("wip.rs", &root) - 1.0).abs() < f64::EPSILON);
240    }
241
242    #[test]
243    fn churn_normalized_to_max() {
244        let repo = temp_repo();
245        let root = repo.path().to_string_lossy().into_owned();
246        for i in 0..3 {
247            std::fs::write(repo.path().join("hot.rs"), format!("// v{i}")).unwrap();
248            run(repo.path(), &["add", "."]);
249            run(repo.path(), &["commit", "-qm", &format!("c{i}")]);
250        }
251        std::fs::write(repo.path().join("cold.rs"), "// once").unwrap();
252        run(repo.path(), &["add", "."]);
253        run(repo.path(), &["commit", "-qm", "cold"]);
254        crate::core::git_cache::invalidate(&root);
255
256        let signals = collect(&root);
257        let hot = signals.churn_for("hot.rs", &root);
258        let cold = signals.churn_for("cold.rs", &root);
259        assert!((hot - 1.0).abs() < f64::EPSILON, "hot file = max churn");
260        assert!(cold > 0.0 && cold < hot);
261        // Committed minutes ago -> commit recency near 1.0.
262        assert!(signals.recency_for("cold.rs", &root) > 0.9);
263    }
264
265    #[test]
266    fn non_git_root_yields_empty_and_is_cached() {
267        let dir = tempfile::tempdir().unwrap();
268        let root = dir.path().to_string_lossy().into_owned();
269        assert!(collect(&root).is_empty());
270        assert!(known_non_git(&root), "non-git root remembered");
271        assert!(collect(&root).is_empty());
272    }
273
274    #[test]
275    fn absolute_paths_relativized_in_lookup() {
276        let mut signals = GitSignals::default();
277        signals.recency.insert("src/a.rs".to_string(), 1.0);
278        let root = "/repo";
279        assert!((signals.recency_for("/repo/src/a.rs", root) - 1.0).abs() < f64::EPSILON);
280        assert!((signals.recency_for("src/a.rs", root) - 1.0).abs() < f64::EPSILON);
281    }
282
283    #[test]
284    fn boost_combines_recency_and_churn() {
285        let mut signals = GitSignals::default();
286        signals.recency.insert("a.rs".to_string(), 1.0);
287        signals.churn.insert("a.rs".to_string(), 1.0);
288        let boost = signals.boost_for("a.rs", ".");
289        assert!((boost - 0.35).abs() < 1e-9);
290    }
291}