Skip to main content

vibe_workspace/cache/
git_status_cache.rs

1use anyhow::{Context, Result};
2use chrono::{DateTime, Duration, Utc};
3use serde::{Deserialize, Serialize};
4use std::path::PathBuf;
5use tokio_rusqlite::{params, Connection};
6
7/// Cached git status information
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct CachedGitStatus {
10    pub repository_name: String,
11    pub path: PathBuf,
12    pub branch: Option<String>,
13    pub clean: bool,
14    pub ahead: usize,
15    pub behind: usize,
16    pub staged: usize,
17    pub unstaged: usize,
18    pub untracked: usize,
19    pub remote_url: Option<String>,
20    pub last_updated: DateTime<Utc>,
21}
22
23impl From<crate::workspace::operations::GitStatus> for CachedGitStatus {
24    fn from(status: crate::workspace::operations::GitStatus) -> Self {
25        Self {
26            repository_name: status.repository_name,
27            path: PathBuf::from(status.path),
28            branch: status.branch,
29            clean: status.clean,
30            ahead: status.ahead,
31            behind: status.behind,
32            staged: status.staged,
33            unstaged: status.unstaged,
34            untracked: status.untracked,
35            remote_url: status.remote_url,
36            last_updated: Utc::now(),
37        }
38    }
39}
40
41impl From<CachedGitStatus> for crate::workspace::operations::GitStatus {
42    fn from(cached: CachedGitStatus) -> Self {
43        Self {
44            repository_name: cached.repository_name,
45            path: cached.path.to_string_lossy().to_string(),
46            branch: cached.branch,
47            clean: cached.clean,
48            ahead: cached.ahead,
49            behind: cached.behind,
50            staged: cached.staged,
51            unstaged: cached.unstaged,
52            untracked: cached.untracked,
53            remote_url: cached.remote_url,
54        }
55    }
56}
57
58/// Fast SQLite-based cache for git status information
59pub struct GitStatusCache {
60    db_path: PathBuf,
61    /// Cache TTL in minutes - how long cached git status is considered valid
62    cache_ttl_minutes: i64,
63}
64
65impl GitStatusCache {
66    /// Create a new git status cache
67    pub fn new<P: Into<PathBuf>>(db_path: P) -> Self {
68        Self {
69            db_path: db_path.into(),
70            cache_ttl_minutes: 5, // Default: 5 minutes
71        }
72    }
73
74    /// Create a git status cache with custom TTL
75    #[allow(dead_code)]
76    pub fn with_ttl<P: Into<PathBuf>>(db_path: P, ttl_minutes: i64) -> Self {
77        Self {
78            db_path: db_path.into(),
79            cache_ttl_minutes: ttl_minutes,
80        }
81    }
82
83    /// Initialize the cache database with required tables
84    pub async fn initialize(&self) -> Result<()> {
85        let conn = Connection::open(&self.db_path)
86            .await
87            .context("Failed to open git status cache database")?;
88
89        conn.call(move |conn| {
90            conn.execute(
91                r#"
92                CREATE TABLE IF NOT EXISTS git_status (
93                    repository_name TEXT PRIMARY KEY,
94                    path TEXT NOT NULL,
95                    branch TEXT,
96                    clean INTEGER NOT NULL,
97                    ahead INTEGER NOT NULL,
98                    behind INTEGER NOT NULL,
99                    staged INTEGER NOT NULL,
100                    unstaged INTEGER NOT NULL,
101                    untracked INTEGER NOT NULL,
102                    remote_url TEXT,
103                    last_updated TEXT NOT NULL
104                )
105                "#,
106                [],
107            )?;
108
109            // Create indexes for faster lookups
110            conn.execute(
111                "CREATE INDEX IF NOT EXISTS idx_git_status_path ON git_status(path)",
112                [],
113            )?;
114
115            conn.execute(
116                "CREATE INDEX IF NOT EXISTS idx_git_status_last_updated ON git_status(last_updated)",
117                [],
118            )?;
119
120            Ok(())
121        })
122        .await
123        .context("Failed to initialize git status cache tables")?;
124
125        Ok(())
126    }
127
128    /// Cache git status information
129    #[allow(dead_code)]
130    pub async fn cache_git_status(&self, status: &CachedGitStatus) -> Result<()> {
131        let conn = Connection::open(&self.db_path).await?;
132        let status = status.clone();
133
134        conn.call(move |conn| {
135            let last_updated = status.last_updated.to_rfc3339();
136
137            conn.execute(
138                r#"
139                INSERT OR REPLACE INTO git_status 
140                (repository_name, path, branch, clean, ahead, behind, staged, unstaged, untracked, remote_url, last_updated)
141                VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
142                "#,
143                params![
144                    status.repository_name,
145                    status.path.to_string_lossy(),
146                    status.branch,
147                    status.clean as i32,
148                    status.ahead as i64,
149                    status.behind as i64,
150                    status.staged as i64,
151                    status.unstaged as i64,
152                    status.untracked as i64,
153                    status.remote_url,
154                    last_updated
155                ],
156            )?;
157
158            Ok(())
159        })
160        .await
161        .context("Failed to cache git status")?;
162
163        Ok(())
164    }
165
166    /// Get cached git status if it's still valid (within TTL)
167    #[allow(dead_code)]
168    pub async fn get_git_status(&self, repository_name: &str) -> Result<Option<CachedGitStatus>> {
169        let conn = Connection::open(&self.db_path).await?;
170        let repo_name = repository_name.to_string();
171        let ttl_minutes = self.cache_ttl_minutes;
172
173        let result = conn
174            .call(move |conn| {
175                let mut stmt = conn.prepare(
176                    r#"
177                    SELECT repository_name, path, branch, clean, ahead, behind, staged, unstaged, untracked, remote_url, last_updated 
178                    FROM git_status 
179                    WHERE repository_name = ?1
180                    "#
181                )?;
182
183                let status = stmt.query_row(params![repo_name], |row| {
184                    let last_updated_str: String = row.get(10)?;
185                    let last_updated = DateTime::parse_from_rfc3339(&last_updated_str)
186                        .map_err(|e| rusqlite::Error::FromSqlConversionFailure(10, rusqlite::types::Type::Text, Box::new(e)))?
187                        .with_timezone(&Utc);
188
189                    Ok(CachedGitStatus {
190                        repository_name: row.get(0)?,
191                        path: PathBuf::from(row.get::<_, String>(1)?),
192                        branch: row.get(2)?,
193                        clean: row.get::<_, i32>(3)? != 0,
194                        ahead: row.get::<_, i64>(4)? as usize,
195                        behind: row.get::<_, i64>(5)? as usize,
196                        staged: row.get::<_, i64>(6)? as usize,
197                        unstaged: row.get::<_, i64>(7)? as usize,
198                        untracked: row.get::<_, i64>(8)? as usize,
199                        remote_url: row.get(9)?,
200                        last_updated,
201                    })
202                });
203
204                match status {
205                    Ok(status) => {
206                        // Check if the cached status is still valid (within TTL)
207                        let now = Utc::now();
208                        let age = now.signed_duration_since(status.last_updated);
209                        if age <= Duration::minutes(ttl_minutes) {
210                            Ok(Some(status))
211                        } else {
212                            // Cached data is too old
213                            Ok(None)
214                        }
215                    }
216                    Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
217                    Err(e) => Err(tokio_rusqlite::Error::Rusqlite(e)),
218                }
219            })
220            .await
221            .context("Failed to get cached git status")?;
222
223        Ok(result)
224    }
225
226    /// Get all cached git statuses (for batch operations)
227    pub async fn get_all_git_statuses(&self) -> Result<Vec<CachedGitStatus>> {
228        let conn = Connection::open(&self.db_path).await?;
229        let ttl_minutes = self.cache_ttl_minutes;
230
231        let statuses = conn
232            .call(move |conn| {
233                let mut stmt = conn.prepare(
234                    r#"
235                    SELECT repository_name, path, branch, clean, ahead, behind, staged, unstaged, untracked, remote_url, last_updated 
236                    FROM git_status
237                    ORDER BY repository_name
238                    "#
239                )?;
240
241                let status_iter = stmt.query_map([], |row| {
242                    let last_updated_str: String = row.get(10)?;
243                    let last_updated = DateTime::parse_from_rfc3339(&last_updated_str)
244                        .map_err(|e| rusqlite::Error::FromSqlConversionFailure(10, rusqlite::types::Type::Text, Box::new(e)))?
245                        .with_timezone(&Utc);
246
247                    Ok(CachedGitStatus {
248                        repository_name: row.get(0)?,
249                        path: PathBuf::from(row.get::<_, String>(1)?),
250                        branch: row.get(2)?,
251                        clean: row.get::<_, i32>(3)? != 0,
252                        ahead: row.get::<_, i64>(4)? as usize,
253                        behind: row.get::<_, i64>(5)? as usize,
254                        staged: row.get::<_, i64>(6)? as usize,
255                        unstaged: row.get::<_, i64>(7)? as usize,
256                        untracked: row.get::<_, i64>(8)? as usize,
257                        remote_url: row.get(9)?,
258                        last_updated,
259                    })
260                })?;
261
262                let mut statuses = Vec::new();
263                let now = Utc::now();
264                for status_result in status_iter {
265                    let status = status_result?;
266                    // Only include valid (within TTL) cached statuses
267                    let age = now.signed_duration_since(status.last_updated);
268                    if age <= Duration::minutes(ttl_minutes) {
269                        statuses.push(status);
270                    }
271                }
272
273                Ok(statuses)
274            })
275            .await
276            .context("Failed to get all cached git statuses")?;
277
278        Ok(statuses)
279    }
280
281    /// Remove expired cache entries
282    pub async fn cleanup_expired(&self) -> Result<usize> {
283        let conn = Connection::open(&self.db_path).await?;
284        let ttl_minutes = self.cache_ttl_minutes;
285
286        let deleted_count = conn
287            .call(move |conn| {
288                let cutoff_time = Utc::now() - Duration::minutes(ttl_minutes);
289                let cutoff_str = cutoff_time.to_rfc3339();
290
291                let result = conn.execute(
292                    "DELETE FROM git_status WHERE last_updated < ?1",
293                    params![cutoff_str],
294                )?;
295
296                Ok(result)
297            })
298            .await
299            .context("Failed to cleanup expired git status cache entries")?;
300
301        Ok(deleted_count)
302    }
303
304    /// Invalidate cache for a specific repository (useful when changes are detected)
305    pub async fn invalidate_repository(&self, repository_name: &str) -> Result<()> {
306        let conn = Connection::open(&self.db_path).await?;
307        let repo_name = repository_name.to_string();
308
309        conn.call(move |conn| {
310            conn.execute(
311                "DELETE FROM git_status WHERE repository_name = ?1",
312                params![repo_name],
313            )?;
314            Ok(())
315        })
316        .await
317        .context("Failed to invalidate repository cache")?;
318
319        Ok(())
320    }
321
322    /// Get cache statistics
323    pub async fn get_stats(&self) -> Result<GitCacheStats> {
324        let conn = Connection::open(&self.db_path).await?;
325        let ttl_minutes = self.cache_ttl_minutes;
326
327        let stats = conn
328            .call(move |conn| {
329                let total_entries: i64 =
330                    conn.query_row("SELECT COUNT(*) FROM git_status", [], |row| row.get(0))?;
331
332                let cutoff_time = Utc::now() - Duration::minutes(ttl_minutes);
333                let cutoff_str = cutoff_time.to_rfc3339();
334
335                let valid_entries: i64 = conn.query_row(
336                    "SELECT COUNT(*) FROM git_status WHERE last_updated >= ?1",
337                    params![cutoff_str],
338                    |row| row.get(0),
339                )?;
340
341                let expired_entries = total_entries - valid_entries;
342
343                Ok::<_, tokio_rusqlite::Error>(GitCacheStats {
344                    total_entries: total_entries as usize,
345                    valid_entries: valid_entries as usize,
346                    expired_entries: expired_entries as usize,
347                    ttl_minutes: ttl_minutes as usize,
348                })
349            })
350            .await
351            .context("Failed to get git cache statistics")?;
352
353        Ok(stats)
354    }
355}
356
357/// Git cache statistics for monitoring and debugging
358#[derive(Debug, Clone)]
359pub struct GitCacheStats {
360    pub total_entries: usize,
361    pub valid_entries: usize,
362    pub expired_entries: usize,
363    pub ttl_minutes: usize,
364}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369    use tempfile::tempdir;
370
371    #[tokio::test]
372    async fn test_git_status_cache_operations() {
373        let temp_dir = tempdir().unwrap();
374        let db_path = temp_dir.path().join("test_git_status.db");
375
376        let cache = GitStatusCache::new(db_path);
377        cache.initialize().await.unwrap();
378
379        // Test caching git status
380        let status = CachedGitStatus {
381            repository_name: "test-repo".to_string(),
382            path: PathBuf::from("/path/to/repo"),
383            branch: Some("main".to_string()),
384            clean: false,
385            ahead: 2,
386            behind: 1,
387            staged: 3,
388            unstaged: 1,
389            untracked: 2,
390            remote_url: Some("https://github.com/user/repo.git".to_string()),
391            last_updated: Utc::now(),
392        };
393
394        cache.cache_git_status(&status).await.unwrap();
395
396        // Test retrieving the git status
397        let cached = cache.get_git_status("test-repo").await.unwrap();
398        assert!(cached.is_some());
399
400        let cached = cached.unwrap();
401        assert_eq!(cached.repository_name, "test-repo");
402        assert_eq!(cached.branch, Some("main".to_string()));
403        assert_eq!(cached.ahead, 2);
404        assert_eq!(cached.behind, 1);
405        assert!(!cached.clean);
406    }
407
408    #[tokio::test]
409    async fn test_cache_ttl_expiration() {
410        let temp_dir = tempdir().unwrap();
411        let db_path = temp_dir.path().join("test_ttl.db");
412
413        // Create cache with very short TTL for testing
414        let cache = GitStatusCache::with_ttl(db_path, 0); // 0 minutes TTL
415        cache.initialize().await.unwrap();
416
417        let status = CachedGitStatus {
418            repository_name: "test-repo".to_string(),
419            path: PathBuf::from("/path/to/repo"),
420            branch: Some("main".to_string()),
421            clean: true,
422            ahead: 0,
423            behind: 0,
424            staged: 0,
425            unstaged: 0,
426            untracked: 0,
427            remote_url: None,
428            last_updated: Utc::now() - Duration::minutes(1), // 1 minute ago
429        };
430
431        cache.cache_git_status(&status).await.unwrap();
432
433        // Should return None because the cache entry is expired
434        let cached = cache.get_git_status("test-repo").await.unwrap();
435        assert!(cached.is_none());
436    }
437}