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 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 }
50 self.entries.insert(
51 key,
52 CacheEntry {
53 output,
54 inserted: Instant::now(),
55 ttl,
56 },
57 );
58 }
59}
60
61pub fn git_cached(args: &[&str], cwd: &str, ttl: Duration) -> Option<String> {
63 let key = format!("{cwd}:{}", args.join(" "));
64
65 if let Ok(cache) = CACHE.lock() {
66 if let Some(cached) = cache.get(&key) {
67 return Some(cached.to_string());
68 }
69 }
70
71 let output = std::process::Command::new("git")
72 .args(args)
73 .current_dir(cwd)
74 .output()
75 .ok()?;
76
77 if !output.status.success() {
78 return None;
79 }
80
81 let result = String::from_utf8_lossy(&output.stdout).to_string();
82
83 if let Ok(mut cache) = CACHE.lock() {
84 cache.insert(key, result.clone(), ttl);
85 }
86
87 Some(result)
88}
89
90pub fn git_status_cached(cwd: &str) -> Option<String> {
92 git_cached(&["status", "--porcelain"], cwd, Duration::from_secs(10))
93}
94
95pub fn git_diff_cached(args: &[&str], cwd: &str) -> Option<String> {
97 let mut full_args = vec!["diff"];
98 full_args.extend_from_slice(args);
99 git_cached(&full_args, cwd, Duration::from_secs(10))
100}
101
102pub fn git_log_cached(args: &[&str], cwd: &str) -> Option<String> {
104 let mut full_args = vec!["log"];
105 full_args.extend_from_slice(args);
106 git_cached(&full_args, cwd, Duration::from_mins(1))
107}
108
109pub fn invalidate(cwd: &str) {
111 if let Ok(mut cache) = CACHE.lock() {
112 cache.entries.retain(|k, _| !k.starts_with(cwd));
113 }
114}
115
116#[cfg(test)]
117mod tests {
118 use super::*;
119
120 #[test]
121 fn cache_insert_and_retrieve() {
122 let mut cache = GitCache::new();
123 cache.insert(
124 "test:key".to_string(),
125 "output".to_string(),
126 Duration::from_mins(1),
127 );
128 assert_eq!(cache.get("test:key"), Some("output"));
129 }
130
131 #[test]
132 fn cache_miss_on_unknown_key() {
133 let cache = GitCache::new();
134 assert_eq!(cache.get("unknown"), None);
135 }
136
137 #[test]
138 fn cache_evicts_when_full() {
139 let mut cache = GitCache::new();
140 for i in 0..105 {
141 cache.insert(
142 format!("key:{i}"),
143 "val".to_string(),
144 Duration::from_mins(1),
145 );
146 }
147 assert!(cache.entries.len() <= 105);
148 }
149}