snp/
pooled_git.rs

1// Pooled Git Repository Implementation
2// Provides efficient reuse of Git repository instances with state management
3
4use async_trait::async_trait;
5use std::collections::HashMap;
6use std::path::PathBuf;
7use thiserror::Error;
8
9use crate::error::{Result, SnpError};
10use crate::resource_pool::Poolable;
11
12/// Configuration for creating pooled Git repositories
13#[derive(Debug, Clone)]
14pub struct GitPoolConfig {
15    /// Template for temporary working directories
16    pub work_dir_template: String,
17    /// Whether to cleanup working directories on reset
18    pub cleanup_on_reset: bool,
19    /// Whether to verify repository health on checkout
20    pub verify_health: bool,
21    /// Maximum number of cached repository states
22    pub max_cached_states: usize,
23}
24
25impl Default for GitPoolConfig {
26    fn default() -> Self {
27        Self {
28            work_dir_template: "/tmp/snp-git-{}".to_string(),
29            cleanup_on_reset: true,
30            verify_health: true,
31            max_cached_states: 10,
32        }
33    }
34}
35
36/// Errors specific to pooled Git repository operations
37#[derive(Debug, Error)]
38pub enum PooledGitError {
39    #[error("Failed to create Git repository: {source}")]
40    RepositoryCreation {
41        #[source]
42        source: SnpError,
43    },
44
45    #[error("Failed to checkout repository {url} at {reference}: {source}")]
46    CheckoutFailed {
47        url: String,
48        reference: String,
49        #[source]
50        source: SnpError,
51    },
52
53    #[error("Repository health check failed: {reason}")]
54    HealthCheckFailed { reason: String },
55
56    #[error("Failed to reset repository state: {source}")]
57    ResetFailed {
58        #[source]
59        source: SnpError,
60    },
61
62    #[error("Failed to cleanup repository: {source}")]
63    CleanupFailed {
64        #[source]
65        source: SnpError,
66    },
67
68    #[error("Working directory creation failed: {path}")]
69    WorkingDirectoryCreation { path: PathBuf },
70}
71
72/// A pooled Git repository that can be reused across different checkouts
73pub struct PooledGitRepository {
74    /// Working directory path
75    work_dir: PathBuf,
76    /// Current checkout state
77    current_state: Option<RepositoryState>,
78    /// Cache of previously checked out states
79    cached_states: HashMap<String, RepositoryState>,
80    /// Configuration
81    config: GitPoolConfig,
82    /// Repository metadata
83    repo_metadata: RepositoryMetadata,
84}
85
86/// Metadata about the Git repository
87#[derive(Debug, Clone)]
88pub struct RepositoryMetadata {
89    pub initialized: bool,
90    pub remote_urls: Vec<String>,
91    pub branches: Vec<String>,
92}
93
94/// State information for a Git repository checkout
95#[derive(Debug, Clone)]
96pub struct RepositoryState {
97    pub url: String,
98    pub reference: String,
99    pub commit_hash: String,
100    pub checkout_time: std::time::SystemTime,
101}
102
103impl PooledGitRepository {
104    /// Checkout a repository at the specified URL and reference
105    pub async fn checkout_repository(
106        &mut self,
107        url: &str,
108        reference: Option<&str>,
109    ) -> Result<&RepositoryMetadata> {
110        let reference = reference.unwrap_or("HEAD");
111        let state_key = format!("{url}:{reference}");
112
113        // Check if we already have this state
114        if let Some(current) = &self.current_state {
115            if current.url == url && current.reference == reference {
116                // Already in the desired state
117                return Ok(&self.repo_metadata);
118            }
119        }
120
121        // Check cached states
122        if let Some(cached_state) = self.cached_states.get(&state_key).cloned() {
123            // Restore from cache
124            self.restore_state(&cached_state).await?;
125            self.current_state = Some(cached_state);
126            return Ok(&self.repo_metadata);
127        }
128
129        // Perform fresh checkout
130        let state = self.perform_checkout(url, reference).await?;
131
132        // Cache the state
133        if self.cached_states.len() >= self.config.max_cached_states {
134            // Remove oldest cached state
135            if let Some(oldest_key) = self.find_oldest_cached_state() {
136                self.cached_states.remove(&oldest_key);
137            }
138        }
139
140        self.cached_states.insert(state_key, state.clone());
141        self.current_state = Some(state);
142
143        Ok(&self.repo_metadata)
144    }
145
146    /// Get the current repository state
147    pub fn current_state(&self) -> Option<&RepositoryState> {
148        self.current_state.as_ref()
149    }
150
151    /// Get the repository metadata
152    pub fn repository_metadata(&self) -> &RepositoryMetadata {
153        &self.repo_metadata
154    }
155
156    /// Get repository statistics
157    pub fn stats(&self) -> PooledGitStats {
158        PooledGitStats {
159            current_url: self.current_state.as_ref().map(|s| s.url.clone()),
160            current_reference: self.current_state.as_ref().map(|s| s.reference.clone()),
161            cached_states: self.cached_states.len(),
162            work_dir: self.work_dir.clone(),
163        }
164    }
165
166    /// Perform the actual checkout operation
167    async fn perform_checkout(&mut self, url: &str, reference: &str) -> Result<RepositoryState> {
168        // For now, we'll simulate checkout by creating a mock state
169        // In a real implementation, this would:
170        // 1. Clone or fetch the repository
171        // 2. Checkout the specified reference
172        // 3. Get the commit hash
173
174        tracing::debug!("Performing checkout: {} at {}", url, reference);
175
176        // Simulate some work
177        tokio::time::sleep(std::time::Duration::from_millis(10)).await;
178
179        // Create repository state
180        let state = RepositoryState {
181            url: url.to_string(),
182            reference: reference.to_string(),
183            commit_hash: format!("mock_commit_hash_{}", uuid::Uuid::new_v4()),
184            checkout_time: std::time::SystemTime::now(),
185        };
186
187        Ok(state)
188    }
189
190    /// Restore a previously cached repository state
191    async fn restore_state(&mut self, state: &RepositoryState) -> Result<()> {
192        tracing::debug!(
193            "Restoring cached state: {} at {}",
194            state.url,
195            state.reference
196        );
197
198        // In a real implementation, this would:
199        // 1. Verify the cached state is still valid
200        // 2. Reset working directory to the cached commit
201        // 3. Update any necessary Git state
202
203        // Simulate restoration work
204        tokio::time::sleep(std::time::Duration::from_millis(5)).await;
205
206        Ok(())
207    }
208
209    /// Find the oldest cached state for eviction
210    fn find_oldest_cached_state(&self) -> Option<String> {
211        self.cached_states
212            .iter()
213            .min_by_key(|(_, state)| state.checkout_time)
214            .map(|(key, _)| key.clone())
215    }
216
217    /// Clear all cached states
218    fn clear_cache(&mut self) {
219        self.cached_states.clear();
220    }
221}
222
223#[async_trait]
224impl Poolable for PooledGitRepository {
225    type Config = GitPoolConfig;
226    type Error = PooledGitError;
227
228    async fn create(config: &Self::Config) -> std::result::Result<Self, Self::Error> {
229        // Create a unique working directory
230        let work_dir = if config.work_dir_template.contains("{}") {
231            PathBuf::from(
232                config
233                    .work_dir_template
234                    .replace("{}", &uuid::Uuid::new_v4().to_string()),
235            )
236        } else {
237            PathBuf::from(&config.work_dir_template).join(uuid::Uuid::new_v4().to_string())
238        };
239
240        // Create the working directory
241        tokio::fs::create_dir_all(&work_dir).await.map_err(|_| {
242            PooledGitError::WorkingDirectoryCreation {
243                path: work_dir.clone(),
244            }
245        })?;
246
247        // Initialize repository metadata
248        let repo_metadata = RepositoryMetadata {
249            initialized: true,
250            remote_urls: Vec::new(),
251            branches: vec!["main".to_string()], // Default branch
252        };
253
254        Ok(Self {
255            work_dir,
256            current_state: None,
257            cached_states: HashMap::new(),
258            config: config.clone(),
259            repo_metadata,
260        })
261    }
262
263    async fn is_healthy(&self) -> bool {
264        if !self.config.verify_health {
265            return true;
266        }
267
268        // Check if working directory exists
269        if !self.work_dir.exists() {
270            return false;
271        }
272
273        // Check if Git repository is still valid
274        // In a real implementation, we would check:
275        // - Repository integrity
276        // - Working directory state
277        // - Git locks
278
279        // For now, always return true for simplicity
280        true
281    }
282
283    async fn reset(&mut self) -> std::result::Result<(), Self::Error> {
284        tracing::debug!("Resetting pooled Git repository");
285
286        // Clear current state
287        self.current_state = None;
288
289        // Optionally clear cache
290        if self.config.cleanup_on_reset {
291            self.clear_cache();
292        }
293
294        // In a real implementation, we would:
295        // 1. Reset Git working directory to clean state
296        // 2. Clean untracked files
297        // 3. Reset any Git state (HEAD, index, etc.)
298
299        // Simulate reset work
300        tokio::time::sleep(std::time::Duration::from_millis(5)).await;
301
302        Ok(())
303    }
304
305    async fn cleanup(&mut self) -> std::result::Result<(), Self::Error> {
306        tracing::debug!("Cleaning up pooled Git repository at {:?}", self.work_dir);
307
308        // Clear all state
309        self.current_state = None;
310        self.clear_cache();
311
312        // Remove working directory
313        if self.work_dir.exists() {
314            tokio::fs::remove_dir_all(&self.work_dir)
315                .await
316                .map_err(|e| PooledGitError::CleanupFailed {
317                    source: SnpError::Io(e),
318                })?;
319        }
320
321        Ok(())
322    }
323}
324
325/// Statistics for a pooled Git repository
326#[derive(Debug)]
327pub struct PooledGitStats {
328    pub current_url: Option<String>,
329    pub current_reference: Option<String>,
330    pub cached_states: usize,
331    pub work_dir: PathBuf,
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337    use crate::resource_pool::ResourcePool;
338    use tempfile::TempDir;
339
340    #[tokio::test]
341    async fn test_pooled_git_creation() {
342        let temp_dir = TempDir::new().unwrap();
343        let config = GitPoolConfig {
344            work_dir_template: temp_dir.path().join("git-{}").to_string_lossy().to_string(),
345            ..Default::default()
346        };
347
348        let result = PooledGitRepository::create(&config).await;
349        assert!(result.is_ok());
350
351        let repo = result.unwrap();
352        assert!(repo.work_dir.exists());
353        assert!(repo.cached_states.is_empty());
354        assert!(repo.current_state.is_none());
355    }
356
357    #[tokio::test]
358    async fn test_pooled_git_checkout() {
359        let temp_dir = TempDir::new().unwrap();
360        let config = GitPoolConfig {
361            work_dir_template: temp_dir.path().join("git-{}").to_string_lossy().to_string(),
362            ..Default::default()
363        };
364
365        let mut repo = PooledGitRepository::create(&config).await.unwrap();
366
367        // Perform checkout
368        let result = repo
369            .checkout_repository("https://github.com/example/repo", Some("main"))
370            .await;
371        assert!(result.is_ok());
372
373        // Check state
374        assert!(repo.current_state.is_some());
375        let state = repo.current_state.as_ref().unwrap();
376        assert_eq!(state.url, "https://github.com/example/repo");
377        assert_eq!(state.reference, "main");
378
379        // Check cache
380        assert_eq!(repo.cached_states.len(), 1);
381    }
382
383    #[tokio::test]
384    async fn test_pooled_git_state_reuse() {
385        let temp_dir = TempDir::new().unwrap();
386        let config = GitPoolConfig {
387            work_dir_template: temp_dir.path().join("git-{}").to_string_lossy().to_string(),
388            ..Default::default()
389        };
390
391        let mut repo = PooledGitRepository::create(&config).await.unwrap();
392
393        // First checkout
394        repo.checkout_repository("https://github.com/example/repo", Some("main"))
395            .await
396            .unwrap();
397        let first_hash = repo.current_state.as_ref().unwrap().commit_hash.clone();
398
399        // Checkout different reference
400        repo.checkout_repository("https://github.com/example/repo", Some("develop"))
401            .await
402            .unwrap();
403
404        // Checkout original reference again - should use cache
405        repo.checkout_repository("https://github.com/example/repo", Some("main"))
406            .await
407            .unwrap();
408        let second_hash = repo.current_state.as_ref().unwrap().commit_hash.clone();
409
410        assert_eq!(first_hash, second_hash);
411        assert_eq!(repo.cached_states.len(), 2);
412    }
413
414    #[tokio::test]
415    async fn test_pooled_git_resource_pool_integration() {
416        let temp_dir = TempDir::new().unwrap();
417        let git_config = GitPoolConfig {
418            work_dir_template: temp_dir.path().join("git-{}").to_string_lossy().to_string(),
419            ..Default::default()
420        };
421
422        let pool_config = crate::resource_pool::PoolConfig {
423            min_size: 1,
424            max_size: 3,
425            ..Default::default()
426        };
427
428        let pool: ResourcePool<PooledGitRepository> = ResourcePool::new(git_config, pool_config);
429
430        // Acquire repository
431        let mut guard = pool.acquire().await.unwrap();
432        let repo = guard.resource_mut();
433
434        // Use repository
435        let result = repo
436            .checkout_repository("https://github.com/example/repo", Some("main"))
437            .await;
438        assert!(result.is_ok());
439
440        // Check stats
441        let stats = repo.stats();
442        assert_eq!(
443            stats.current_url,
444            Some("https://github.com/example/repo".to_string())
445        );
446        assert_eq!(stats.current_reference, Some("main".to_string()));
447
448        // Resource should be automatically returned to pool when guard is dropped
449        drop(guard);
450
451        // Wait for async return
452        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
453
454        // Acquire again - should get reset repository
455        let guard2 = pool.acquire().await.unwrap();
456        let repo2 = guard2.resource();
457
458        // Should be reset (no current state)
459        assert!(repo2.current_state.is_none());
460    }
461
462    #[tokio::test]
463    async fn test_pooled_git_cache_eviction() {
464        let temp_dir = TempDir::new().unwrap();
465        let config = GitPoolConfig {
466            work_dir_template: temp_dir.path().join("git-{}").to_string_lossy().to_string(),
467            max_cached_states: 2,
468            ..Default::default()
469        };
470
471        let mut repo = PooledGitRepository::create(&config).await.unwrap();
472
473        // Fill cache beyond limit
474        repo.checkout_repository("https://github.com/example/repo1", Some("main"))
475            .await
476            .unwrap();
477        repo.checkout_repository("https://github.com/example/repo2", Some("main"))
478            .await
479            .unwrap();
480        repo.checkout_repository("https://github.com/example/repo3", Some("main"))
481            .await
482            .unwrap();
483
484        // Should have evicted the oldest entry
485        assert_eq!(repo.cached_states.len(), 2);
486
487        // Should not contain the first repo anymore
488        assert!(!repo
489            .cached_states
490            .contains_key("https://github.com/example/repo1:main"));
491    }
492}