Skip to main content

lean_ctx/core/
git_cache.rs

1//! TTL-based cache for git command results.
2//!
3//! Prevents redundant git invocations within the same session by caching
4//! results with a configurable time-to-live (default 10s for status/diff, 60s for log).
5
6use std::collections::HashMap;
7use std::sync::Mutex;
8use std::time::{Duration, Instant};
9
10static CACHE: std::sync::LazyLock<Mutex<GitCache>> =
11    std::sync::LazyLock::new(|| Mutex::new(GitCache::new()));
12
13struct CacheEntry {
14    output: String,
15    inserted: Instant,
16    ttl: Duration,
17}
18
19struct GitCache {
20    entries: HashMap<String, CacheEntry>,
21}
22
23impl GitCache {
24    fn new() -> Self {
25        Self {
26            entries: HashMap::new(),
27        }
28    }
29
30    fn get(&self, key: &str) -> Option<&str> {
31        let now = Instant::now();
32        if let Some(entry) = self.entries.get(key) {
33            if now.duration_since(entry.inserted) < entry.ttl {
34                return Some(&entry.output);
35            }
36        }
37        None
38    }
39
40    fn prune_expired(&mut self) {
41        let now = Instant::now();
42        self.entries
43            .retain(|_, e| now.duration_since(e.inserted) < e.ttl);
44    }
45
46    fn insert(&mut self, key: String, output: String, ttl: Duration) {
47        if self.entries.len() > 100 {
48            self.prune_expired();
49            // Hard cap: if still over after expiry-pruning (>100 distinct live keys
50            // within the TTL window), evict oldest by insertion time. Dropping a live
51            // entry is safe — it just forces a git re-run on next access.
52            if self.entries.len() >= 100 {
53                let mut by_age: Vec<(String, Instant)> = self
54                    .entries
55                    .iter()
56                    .map(|(k, e)| (k.clone(), e.inserted))
57                    .collect();
58                by_age.sort_by_key(|(_, inserted)| *inserted);
59                let to_drop = self.entries.len() + 1 - 100;
60                for (k, _) in by_age.into_iter().take(to_drop) {
61                    self.entries.remove(&k);
62                }
63            }
64        }
65        self.entries.insert(
66            key,
67            CacheEntry {
68                output,
69                inserted: Instant::now(),
70                ttl,
71            },
72        );
73    }
74}
75
76/// Run a git command with TTL caching. Returns cached result if available.
77pub fn git_cached(args: &[&str], cwd: &str, ttl: Duration) -> Option<String> {
78    let key = format!("{cwd}:{}", args.join(" "));
79
80    if let Ok(cache) = CACHE.lock() {
81        if let Some(cached) = cache.get(&key) {
82            return Some(cached.to_string());
83        }
84    }
85
86    let output = std::process::Command::new("git")
87        .args(args)
88        .current_dir(cwd)
89        .output()
90        .ok()?;
91
92    if !output.status.success() {
93        return None;
94    }
95
96    let result = String::from_utf8_lossy(&output.stdout).to_string();
97
98    if let Ok(mut cache) = CACHE.lock() {
99        cache.insert(key, result.clone(), ttl);
100    }
101
102    Some(result)
103}
104
105/// Short-TTL (10s) for frequently-changing git data (status, diff).
106pub fn git_status_cached(cwd: &str) -> Option<String> {
107    git_cached(&["status", "--porcelain"], cwd, Duration::from_secs(10))
108}
109
110/// Short-TTL (10s) for git diff.
111pub fn git_diff_cached(args: &[&str], cwd: &str) -> Option<String> {
112    let mut full_args = vec!["diff"];
113    full_args.extend_from_slice(args);
114    git_cached(&full_args, cwd, Duration::from_secs(10))
115}
116
117/// Longer-TTL (60s) for git log (rarely changes within a session).
118pub fn git_log_cached(args: &[&str], cwd: &str) -> Option<String> {
119    let mut full_args = vec!["log"];
120    full_args.extend_from_slice(args);
121    git_cached(&full_args, cwd, Duration::from_mins(1))
122}
123
124/// Invalidate all cached entries for a given directory.
125pub fn invalidate(cwd: &str) {
126    if let Ok(mut cache) = CACHE.lock() {
127        cache.entries.retain(|k, _| !k.starts_with(cwd));
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    #[test]
136    fn cache_insert_and_retrieve() {
137        let mut cache = GitCache::new();
138        cache.insert(
139            "test:key".to_string(),
140            "output".to_string(),
141            Duration::from_mins(1),
142        );
143        assert_eq!(cache.get("test:key"), Some("output"));
144    }
145
146    #[test]
147    fn cache_miss_on_unknown_key() {
148        let cache = GitCache::new();
149        assert_eq!(cache.get("unknown"), None);
150    }
151
152    #[test]
153    fn cache_evicts_when_full() {
154        let mut cache = GitCache::new();
155        for i in 0..105 {
156            cache.insert(
157                format!("key:{i}"),
158                "val".to_string(),
159                Duration::from_mins(1),
160            );
161        }
162        assert!(cache.entries.len() <= 105);
163    }
164}