Skip to main content

gitgrip/git/
cache.rs

1//! Git status cache
2//!
3//! Caches git status calls to avoid redundant operations within a single command execution.
4
5use once_cell::sync::Lazy;
6use std::collections::HashMap;
7use std::path::PathBuf;
8use std::sync::Mutex;
9use std::time::{Duration, Instant};
10
11use super::status::RepoStatusInfo;
12
13#[cfg(feature = "telemetry")]
14use crate::telemetry::metrics::GLOBAL_METRICS;
15#[cfg(feature = "telemetry")]
16use tracing::trace;
17
18/// Cache entry with status and timestamp
19struct CacheEntry {
20    status: RepoStatusInfo,
21    timestamp: Instant,
22}
23
24/// Git status cache with TTL
25pub struct GitStatusCache {
26    cache: Mutex<HashMap<PathBuf, CacheEntry>>,
27    ttl: Duration,
28}
29
30impl GitStatusCache {
31    /// Create a new cache with the given TTL
32    pub fn new(ttl: Duration) -> Self {
33        Self {
34            cache: Mutex::new(HashMap::new()),
35            ttl,
36        }
37    }
38
39    /// Check if an entry is expired
40    fn is_expired(&self, entry: &CacheEntry) -> bool {
41        entry.timestamp.elapsed() > self.ttl
42    }
43
44    /// Get cached status or None if not cached/expired
45    pub fn get(&self, repo_path: &PathBuf) -> Option<RepoStatusInfo> {
46        let cache = self.cache.lock().expect("mutex poisoned");
47        if let Some(entry) = cache.get(repo_path) {
48            if !self.is_expired(entry) {
49                #[cfg(feature = "telemetry")]
50                {
51                    GLOBAL_METRICS.record_cache(true);
52                    trace!(path = %repo_path.display(), "Cache hit");
53                }
54                return Some(entry.status.clone());
55            }
56        }
57        #[cfg(feature = "telemetry")]
58        {
59            GLOBAL_METRICS.record_cache(false);
60            trace!(path = %repo_path.display(), "Cache miss");
61        }
62        None
63    }
64
65    /// Set status in cache
66    pub fn set(&self, repo_path: PathBuf, status: RepoStatusInfo) {
67        let mut cache = self.cache.lock().expect("mutex poisoned");
68        cache.insert(
69            repo_path,
70            CacheEntry {
71                status,
72                timestamp: Instant::now(),
73            },
74        );
75    }
76
77    /// Invalidate cache for a specific repo
78    pub fn invalidate(&self, repo_path: &PathBuf) {
79        let mut cache = self.cache.lock().expect("mutex poisoned");
80        cache.remove(repo_path);
81    }
82
83    /// Clear the entire cache
84    pub fn clear(&self) {
85        let mut cache = self.cache.lock().expect("mutex poisoned");
86        cache.clear();
87    }
88}
89
90impl Default for GitStatusCache {
91    fn default() -> Self {
92        Self::new(Duration::from_millis(5000))
93    }
94}
95
96/// Global singleton cache instance
97pub static STATUS_CACHE: Lazy<GitStatusCache> = Lazy::new(GitStatusCache::default);
98
99/// Invalidate cached status for a repository (call after git add, commit, etc.)
100pub fn invalidate_status_cache(repo_path: &PathBuf) {
101    STATUS_CACHE.invalidate(repo_path);
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    #[test]
109    fn test_cache_set_get() {
110        let cache = GitStatusCache::new(Duration::from_secs(60));
111        let path = PathBuf::from("/test/repo");
112        let status = RepoStatusInfo {
113            current_branch: "main".to_string(),
114            is_clean: true,
115            staged: vec![],
116            modified: vec![],
117            untracked: vec![],
118            ahead: 0,
119            behind: 0,
120        };
121
122        cache.set(path.clone(), status.clone());
123        let cached = cache.get(&path).unwrap();
124        assert_eq!(cached.current_branch, "main");
125        assert!(cached.is_clean);
126    }
127
128    #[test]
129    fn test_cache_invalidate() {
130        let cache = GitStatusCache::new(Duration::from_secs(60));
131        let path = PathBuf::from("/test/repo");
132        let status = RepoStatusInfo {
133            current_branch: "main".to_string(),
134            is_clean: true,
135            staged: vec![],
136            modified: vec![],
137            untracked: vec![],
138            ahead: 0,
139            behind: 0,
140        };
141
142        cache.set(path.clone(), status);
143        assert!(cache.get(&path).is_some());
144
145        cache.invalidate(&path);
146        assert!(cache.get(&path).is_none());
147    }
148
149    #[test]
150    fn test_cache_expiry() {
151        let cache = GitStatusCache::new(Duration::from_millis(10));
152        let path = PathBuf::from("/test/repo");
153        let status = RepoStatusInfo {
154            current_branch: "main".to_string(),
155            is_clean: true,
156            staged: vec![],
157            modified: vec![],
158            untracked: vec![],
159            ahead: 0,
160            behind: 0,
161        };
162
163        cache.set(path.clone(), status);
164        assert!(cache.get(&path).is_some());
165
166        std::thread::sleep(Duration::from_millis(20));
167        assert!(cache.get(&path).is_none());
168    }
169}