1use 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#[derive(Debug, Clone)]
14pub struct GitPoolConfig {
15 pub work_dir_template: String,
17 pub cleanup_on_reset: bool,
19 pub verify_health: bool,
21 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#[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
72pub struct PooledGitRepository {
74 work_dir: PathBuf,
76 current_state: Option<RepositoryState>,
78 cached_states: HashMap<String, RepositoryState>,
80 config: GitPoolConfig,
82 repo_metadata: RepositoryMetadata,
84}
85
86#[derive(Debug, Clone)]
88pub struct RepositoryMetadata {
89 pub initialized: bool,
90 pub remote_urls: Vec<String>,
91 pub branches: Vec<String>,
92}
93
94#[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 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 if let Some(current) = &self.current_state {
115 if current.url == url && current.reference == reference {
116 return Ok(&self.repo_metadata);
118 }
119 }
120
121 if let Some(cached_state) = self.cached_states.get(&state_key).cloned() {
123 self.restore_state(&cached_state).await?;
125 self.current_state = Some(cached_state);
126 return Ok(&self.repo_metadata);
127 }
128
129 let state = self.perform_checkout(url, reference).await?;
131
132 if self.cached_states.len() >= self.config.max_cached_states {
134 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 pub fn current_state(&self) -> Option<&RepositoryState> {
148 self.current_state.as_ref()
149 }
150
151 pub fn repository_metadata(&self) -> &RepositoryMetadata {
153 &self.repo_metadata
154 }
155
156 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 async fn perform_checkout(&mut self, url: &str, reference: &str) -> Result<RepositoryState> {
168 tracing::debug!("Performing checkout: {} at {}", url, reference);
175
176 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
178
179 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 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 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
205
206 Ok(())
207 }
208
209 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 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 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 tokio::fs::create_dir_all(&work_dir).await.map_err(|_| {
242 PooledGitError::WorkingDirectoryCreation {
243 path: work_dir.clone(),
244 }
245 })?;
246
247 let repo_metadata = RepositoryMetadata {
249 initialized: true,
250 remote_urls: Vec::new(),
251 branches: vec!["main".to_string()], };
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 if !self.work_dir.exists() {
270 return false;
271 }
272
273 true
281 }
282
283 async fn reset(&mut self) -> std::result::Result<(), Self::Error> {
284 tracing::debug!("Resetting pooled Git repository");
285
286 self.current_state = None;
288
289 if self.config.cleanup_on_reset {
291 self.clear_cache();
292 }
293
294 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 self.current_state = None;
310 self.clear_cache();
311
312 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#[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 let result = repo
369 .checkout_repository("https://github.com/example/repo", Some("main"))
370 .await;
371 assert!(result.is_ok());
372
373 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 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 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 repo.checkout_repository("https://github.com/example/repo", Some("develop"))
401 .await
402 .unwrap();
403
404 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 let mut guard = pool.acquire().await.unwrap();
432 let repo = guard.resource_mut();
433
434 let result = repo
436 .checkout_repository("https://github.com/example/repo", Some("main"))
437 .await;
438 assert!(result.is_ok());
439
440 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 drop(guard);
450
451 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
453
454 let guard2 = pool.acquire().await.unwrap();
456 let repo2 = guard2.resource();
457
458 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 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 assert_eq!(repo.cached_states.len(), 2);
486
487 assert!(!repo
489 .cached_states
490 .contains_key("https://github.com/example/repo1:main"));
491 }
492}