lean_ctx/core/
git_cache.rs1use 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 && now.duration_since(entry.inserted) < entry.ttl
34 {
35 return Some(&entry.output);
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 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
76pub 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 && let Some(cached) = cache.get(&key)
82 {
83 return Some(cached.to_string());
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
105pub fn git_status_cached(cwd: &str) -> Option<String> {
107 git_cached(&["status", "--porcelain"], cwd, Duration::from_secs(10))
108}
109
110pub 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
117pub 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
124pub 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}