agpm_cli/cache/
mod.rs

1//! Git repository cache management with worktree-based parallel operations.
2//!
3//! Provides caching for Git repositories with safe parallel resource installation via worktrees.
4//!
5//! # Architecture
6//!
7//! - [`Cache`]: Core repository management and worktree orchestration
8//! - [`CacheLock`]: File-based locking for process-safe concurrent access
9//! - SHA-based worktrees: One worktree per unique commit for maximum deduplication
10//! - Notification-based coordination: `tokio::sync::Notify` eliminates polling
11//!
12//! # Cache Structure
13//!
14//! ```text
15//! ~/.agpm/cache/
16//! ├── sources/       # Bare repositories
17//! ├── worktrees/     # SHA-based worktrees with .state.json registry
18//! └── .locks/        # Per-repository and per-worktree locks
19//! ```
20//!
21//! # Key Features
22//!
23//! - Fsync-based verification ensures files readable after worktree creation
24//! - DashMap for lock-free concurrent worktree access
25//! - Command-instance fetch caching (single fetch per repo per command)
26//! - Cross-platform path handling and cache locations
27
28use crate::constants::{default_lock_timeout, pending_state_timeout};
29use crate::core::error::AgpmError;
30use crate::core::file_error::{FileOperation, FileResultExt};
31use crate::git::GitRepo;
32use crate::git::command_builder::GitCommand;
33use crate::utils::fs;
34use crate::utils::security::validate_path_security;
35use anyhow::{Context, Result};
36use dashmap::DashMap;
37use serde::{Deserialize, Serialize};
38use std::collections::{HashMap, HashSet};
39use std::path::{Path, PathBuf};
40use std::sync::Arc;
41use std::time::{Duration, SystemTime, UNIX_EPOCH};
42use tokio::fs as async_fs;
43use tokio::sync::{Mutex, MutexGuard, RwLock};
44
45/// Acquire a tokio Mutex with timeout and diagnostic dump on failure.
46/// Uses test-mode aware timeout from constants.
47async fn acquire_mutex_with_timeout<'a, T>(
48    mutex: &'a Mutex<T>,
49    name: &str,
50) -> Result<MutexGuard<'a, T>> {
51    let timeout = default_lock_timeout();
52    match tokio::time::timeout(timeout, mutex.lock()).await {
53        Ok(guard) => Ok(guard),
54        Err(_) => {
55            eprintln!("[DEADLOCK] Timeout waiting for mutex '{}' after {:?}", name, timeout);
56            anyhow::bail!(
57                "Timeout waiting for mutex '{}' after {:?} - possible deadlock",
58                name,
59                timeout
60            )
61        }
62    }
63}
64
65/// Acquire a tokio RwLock read guard with timeout and diagnostic dump on failure.
66async fn acquire_rwlock_read_with_timeout<'a, T>(
67    rwlock: &'a RwLock<T>,
68    name: &str,
69) -> Result<tokio::sync::RwLockReadGuard<'a, T>> {
70    let timeout = default_lock_timeout();
71    match tokio::time::timeout(timeout, rwlock.read()).await {
72        Ok(guard) => Ok(guard),
73        Err(_) => {
74            eprintln!("[DEADLOCK] Timeout waiting for RwLock read '{}' after {:?}", name, timeout);
75            anyhow::bail!(
76                "Timeout waiting for RwLock read '{}' after {:?} - possible deadlock",
77                name,
78                timeout
79            )
80        }
81    }
82}
83
84/// Acquire a tokio RwLock write guard with timeout and diagnostic dump on failure.
85async fn acquire_rwlock_write_with_timeout<'a, T>(
86    rwlock: &'a RwLock<T>,
87    name: &str,
88) -> Result<tokio::sync::RwLockWriteGuard<'a, T>> {
89    let timeout = default_lock_timeout();
90    match tokio::time::timeout(timeout, rwlock.write()).await {
91        Ok(guard) => Ok(guard),
92        Err(_) => {
93            eprintln!("[DEADLOCK] Timeout waiting for RwLock write '{}' after {:?}", name, timeout);
94            anyhow::bail!(
95                "Timeout waiting for RwLock write '{}' after {:?} - possible deadlock",
96                name,
97                timeout
98            )
99        }
100    }
101}
102
103// Concurrency Architecture:
104// - Direct control approach: Command parallelism (--max-parallel) + per-worktree file locking
105// - Instance-level caching: Worktrees and fetch operations cached per Cache instance
106// - Command-level control: --max-parallel flag controls dependency processing parallelism
107// - Fetch caching: Network operations cached for 5 minutes to reduce redundancy
108
109/// Worktree lifecycle state for concurrent coordination.
110///
111/// State machine enabling safe concurrent access: Pending (creating) → Ready (available).
112/// First thread creates with `Pending(notify)`, others wait on notification.
113/// Key format: `"{cache_dir_hash}:{owner}_{repo}:{sha}"` for SHA-based deduplication.
114#[derive(Debug, Clone)]
115enum WorktreeState {
116    /// Worktree being created. Notification triggered when complete.
117    Pending(Arc<tokio::sync::Notify>),
118    /// Worktree ready at path. Validate before use as may be externally deleted.
119    Ready(PathBuf),
120}
121
122/// Extract notification handle from worktree cache entry to wake waiters.
123fn extract_notify_handle(
124    cache: &DashMap<String, WorktreeState>,
125    key: &str,
126) -> Option<Arc<tokio::sync::Notify>> {
127    cache.get(key).and_then(|entry| {
128        if let WorktreeState::Pending(n) = entry.value() {
129            Some(n.clone())
130        } else {
131            None
132        }
133    })
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize, Default)]
137struct WorktreeRegistry {
138    entries: HashMap<String, WorktreeRecord>,
139}
140
141#[derive(Debug, Clone, Serialize, Deserialize)]
142struct WorktreeRecord {
143    source: String,
144    version: String,
145    path: PathBuf,
146    last_used: u64,
147}
148
149impl WorktreeRegistry {
150    fn load(path: &Path) -> Self {
151        match std::fs::read(path) {
152            Ok(data) => serde_json::from_slice(&data).unwrap_or_default(),
153            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Self::default(),
154            Err(err) => {
155                tracing::warn!("Failed to load worktree registry from {}: {}", path.display(), err);
156                Self::default()
157            }
158        }
159    }
160
161    fn update(&mut self, key: String, source: String, version: String, path: PathBuf) {
162        let timestamp = SystemTime::now()
163            .duration_since(UNIX_EPOCH)
164            .unwrap_or_else(|_| Duration::from_secs(0))
165            .as_secs();
166
167        self.entries.insert(
168            key,
169            WorktreeRecord {
170                source,
171                version,
172                path,
173                last_used: timestamp,
174            },
175        );
176    }
177
178    fn remove_by_path(&mut self, target: &Path) -> bool {
179        if let Some(key) = self.entries.iter().find_map(|(k, record)| {
180            if record.path == target {
181                Some(k.clone())
182            } else {
183                None
184            }
185        }) {
186            self.entries.remove(&key);
187            true
188        } else {
189            false
190        }
191    }
192
193    /// Gets the source URL for a worktree by its path.
194    ///
195    /// Used to look up repository information without parsing the worktree directory name.
196    fn get_source_by_path(&self, target: &Path) -> Option<String> {
197        self.entries
198            .values()
199            .find(|record| record.path == target)
200            .map(|record| record.source.clone())
201    }
202
203    async fn persist(&self, path: &Path) -> Result<()> {
204        if let Some(parent) = path.parent() {
205            async_fs::create_dir_all(parent).await?;
206        }
207
208        let data = serde_json::to_vec_pretty(self)?;
209        async_fs::write(path, data).await?;
210        Ok(())
211    }
212}
213
214/// File-based locking mechanism for cache operations
215///
216/// This module provides thread-safe and process-safe locking for cache
217/// operations through OS-level file locks, ensuring data consistency
218/// when multiple AGPM processes access the same cache directory.
219pub mod lock;
220pub use lock::CacheLock;
221
222/// Git repository cache for efficient resource management.
223///
224/// Manages repository cloning, updating, version management, and resource copying.
225/// Multiple instances can safely operate on same cache via [`CacheLock`].
226pub struct Cache {
227    /// Root directory for cached repositories
228    dir: PathBuf,
229    /// Instance-level worktree cache. Key: `"{cache_dir_hash}:{owner}_{repo}:{sha}"`.
230    /// DashMap enables lock-free concurrent access.
231    worktree_cache: Arc<DashMap<String, WorktreeState>>,
232    /// Per-repository locks preventing redundant fetches
233    fetch_locks: Arc<DashMap<PathBuf, Arc<Mutex<()>>>>,
234    /// Tracks fetched repos in this command instance (single fetch per repo per command)
235    fetched_repos: Arc<RwLock<HashSet<PathBuf>>>,
236    /// Persistent worktree registry for reuse across runs
237    worktree_registry: Arc<Mutex<WorktreeRegistry>>,
238}
239
240impl Clone for Cache {
241    fn clone(&self) -> Self {
242        Self {
243            dir: self.dir.clone(),
244            worktree_cache: Arc::clone(&self.worktree_cache),
245            fetch_locks: Arc::clone(&self.fetch_locks),
246            fetched_repos: Arc::clone(&self.fetched_repos),
247            worktree_registry: Arc::clone(&self.worktree_registry),
248        }
249    }
250}
251
252impl Cache {
253    fn registry_path_for(cache_dir: &Path) -> PathBuf {
254        cache_dir.join("worktrees").join(".state.json")
255    }
256
257    fn registry_path(&self) -> PathBuf {
258        Self::registry_path_for(&self.dir)
259    }
260
261    async fn record_worktree_usage(
262        &self,
263        registry_key: &str,
264        source_name: &str,
265        version_key: &str,
266        worktree_path: &Path,
267    ) -> Result<()> {
268        let mut registry =
269            acquire_mutex_with_timeout(&self.worktree_registry, "worktree_registry").await?;
270        registry.update(
271            registry_key.to_string(),
272            source_name.to_string(),
273            version_key.to_string(),
274            worktree_path.to_path_buf(),
275        );
276        registry.persist(&self.registry_path()).await?;
277        Ok(())
278    }
279
280    async fn remove_worktree_record_by_path(&self, worktree_path: &Path) -> Result<()> {
281        let mut registry =
282            acquire_mutex_with_timeout(&self.worktree_registry, "worktree_registry").await?;
283        if registry.remove_by_path(worktree_path) {
284            registry.persist(&self.registry_path()).await?;
285        }
286        Ok(())
287    }
288
289    async fn configure_connection_pooling(path: &Path) -> Result<()> {
290        let commands = [
291            ("http.version", "HTTP/2"),
292            ("http.postBuffer", "524288000"),
293            ("core.compression", "0"),
294        ];
295
296        for (key, value) in commands {
297            GitCommand::new()
298                .args(["config", key, value])
299                .current_dir(path)
300                .execute_success()
301                .await
302                .ok();
303        }
304
305        Ok(())
306    }
307
308    /// Creates cache instance with default platform-specific directory.
309    ///
310    /// Linux/macOS: `~/.agpm/cache/`, Windows: `%LOCALAPPDATA%\agpm\cache\`.
311    /// Override with `AGPM_CACHE_DIR` environment variable.
312    pub fn new() -> Result<Self> {
313        let dir = crate::config::get_cache_dir()?;
314        let registry_path = Self::registry_path_for(&dir);
315        let registry = WorktreeRegistry::load(&registry_path);
316        Ok(Self {
317            dir,
318            worktree_cache: Arc::new(DashMap::new()),
319            fetch_locks: Arc::new(DashMap::new()),
320            fetched_repos: Arc::new(RwLock::new(HashSet::new())),
321            worktree_registry: Arc::new(Mutex::new(registry)),
322        })
323    }
324
325    /// Creates cache instance with custom directory (useful for testing).
326    pub fn with_dir(dir: PathBuf) -> Result<Self> {
327        let registry_path = Self::registry_path_for(&dir);
328        let registry = WorktreeRegistry::load(&registry_path);
329        Ok(Self {
330            dir,
331            worktree_cache: Arc::new(DashMap::new()),
332            fetch_locks: Arc::new(DashMap::new()),
333            fetched_repos: Arc::new(RwLock::new(HashSet::new())),
334            worktree_registry: Arc::new(Mutex::new(registry)),
335        })
336    }
337
338    /// Ensures cache directory exists, creating if necessary. Safe to call multiple times.
339    pub async fn ensure_cache_dir(&self) -> Result<()> {
340        if !self.dir.exists() {
341            async_fs::create_dir_all(&self.dir).await.with_file_context(
342                FileOperation::CreateDir,
343                &self.dir,
344                "creating cache directory",
345                "cache::ensure_cache_dir",
346            )?;
347        }
348        Ok(())
349    }
350
351    /// Returns path to cache directory.
352    #[must_use]
353    pub fn cache_dir(&self) -> &Path {
354        &self.dir
355    }
356
357    /// Constructs worktree path for URL and SHA (does not check existence or create).
358    pub fn get_worktree_path(&self, url: &str, sha: &str) -> Result<PathBuf> {
359        let (owner, repo) =
360            crate::git::parse_git_url(url).map_err(|e| anyhow::anyhow!("Invalid Git URL: {e}"))?;
361        let sha_short = &sha[..8.min(sha.len())];
362        Ok(self.dir.join("worktrees").join(format!("{owner}_{repo}_{sha_short}")))
363    }
364
365    /// Gets or clones source repository to cache.
366    ///
367    /// Handles cloning new repos and updating existing ones with file-based locking.
368    /// Concurrent calls with same `name` block; different names run in parallel.
369    ///
370    /// # Arguments
371    ///
372    /// * `name` - Source identifier for cache directory and locking
373    /// * `url` - Git repository URL (HTTPS, SSH, or local)
374    /// * `version` - Optional Git ref (tag, branch, commit, or None for default)
375    pub async fn get_or_clone_source(
376        &self,
377        name: &str,
378        url: &str,
379        version: Option<&str>,
380    ) -> Result<PathBuf> {
381        self.get_or_clone_source_impl(name, url, version).await
382    }
383
384    /// Removes worktree using `git worktree remove` to properly clean up metadata.
385    ///
386    /// This ensures both the worktree directory AND the bare repo's metadata are cleaned up,
387    /// preventing "missing but already registered worktree" errors on subsequent creation.
388    pub async fn cleanup_worktree(&self, worktree_path: &Path) -> Result<()> {
389        if !worktree_path.exists() {
390            return Ok(());
391        }
392
393        // Look up source URL from registry instead of parsing the path
394        // This avoids brittle path parsing that breaks with underscores in owner/repo names
395        let source_url = {
396            let registry =
397                acquire_mutex_with_timeout(&self.worktree_registry, "worktree_registry").await?;
398            registry.get_source_by_path(worktree_path)
399        };
400
401        if let Some(url) = source_url {
402            // Use parse_git_url to get owner/repo from the URL
403            if let Ok((owner, repo)) = crate::git::parse_git_url(&url) {
404                let bare_repo_path = self.dir.join("sources").join(format!("{owner}_{repo}.git"));
405                if bare_repo_path.exists() {
406                    // Acquire bare-repo-level EXCLUSIVE lock for worktree removal.
407                    // This blocks parallel worktree creation (which uses shared locks)
408                    // to prevent race conditions when modifying .git/worktrees/ state.
409                    let bare_repo_worktree_lock_name = format!("bare-worktree-{owner}_{repo}");
410                    let _bare_worktree_lock =
411                        CacheLock::acquire(&self.dir, &bare_repo_worktree_lock_name).await?;
412
413                    // Use git worktree remove --force to properly clean up
414                    let repo = GitRepo::new(&bare_repo_path);
415                    let _ = repo.remove_worktree(worktree_path).await;
416                }
417            }
418        }
419
420        // Fallback: remove directory if git worktree remove didn't clean it up
421        if worktree_path.exists() {
422            tokio::fs::remove_dir_all(worktree_path).await.with_file_context(
423                FileOperation::Write,
424                worktree_path,
425                "removing worktree directory",
426                "cache::cleanup_worktree",
427            )?;
428        }
429
430        self.remove_worktree_record_by_path(worktree_path).await?;
431        Ok(())
432    }
433
434    /// Removes all worktrees from cache and prunes bare repo references.
435    pub async fn cleanup_all_worktrees(&self) -> Result<()> {
436        let worktrees_dir = self.dir.join("worktrees");
437
438        if !worktrees_dir.exists() {
439            return Ok(());
440        }
441
442        // Remove the entire worktrees directory
443        tokio::fs::remove_dir_all(&worktrees_dir).await.with_file_context(
444            FileOperation::Write,
445            &worktrees_dir,
446            "cleaning up worktrees directory",
447            "cache_module",
448        )?;
449
450        // Also prune worktree references from all bare repos
451        let sources_dir = self.dir.join("sources");
452        if sources_dir.exists() {
453            let mut entries = tokio::fs::read_dir(&sources_dir).await.with_file_context(
454                FileOperation::Read,
455                &sources_dir,
456                "reading sources directory",
457                "cache_module",
458            )?;
459            while let Some(entry) = entries.next_entry().await? {
460                let path = entry.path();
461                if path.extension().and_then(|s| s.to_str()) == Some("git") {
462                    let bare_repo = GitRepo::new(&path);
463                    bare_repo.prune_worktrees().await.ok();
464                }
465            }
466        }
467
468        {
469            let mut registry =
470                acquire_mutex_with_timeout(&self.worktree_registry, "worktree_registry").await?;
471            if !registry.entries.is_empty() {
472                registry.entries.clear();
473                registry.persist(&self.registry_path()).await?;
474            }
475        }
476
477        Ok(())
478    }
479
480    /// Gets or creates SHA-based worktree with notification coordination.
481    ///
482    /// First thread creates worktree, others wait on notification. SHA-based ensures
483    /// maximum reuse and deterministic installations.
484    ///
485    /// # Arguments
486    ///
487    /// * `sha` - Full 40-character commit SHA (pre-resolved)
488    #[allow(clippy::too_many_lines)]
489    pub async fn get_or_create_worktree_for_sha(
490        &self,
491        name: &str,
492        url: &str,
493        sha: &str,
494        context: Option<&str>,
495    ) -> Result<PathBuf> {
496        // Validate SHA format
497        if sha.len() != 40 || !sha.chars().all(|c| c.is_ascii_hexdigit()) {
498            return Err(anyhow::anyhow!(
499                "Invalid SHA format: expected 40 hex characters, got '{sha}'"
500            ));
501        }
502
503        // Check if this is a local path
504        let is_local_path = crate::utils::is_local_path(url);
505        if is_local_path {
506            // Local paths don't use worktrees
507            return self.get_or_clone_source(name, url, None).await;
508        }
509
510        self.ensure_cache_dir().await?;
511
512        // Parse URL for cache structure
513        let (owner, repo) =
514            crate::git::parse_git_url(url).unwrap_or(("direct".to_string(), "repo".to_string()));
515
516        // Define unified lock name and bare repo path for this repository
517        let bare_repo_dir = self.dir.join("sources").join(format!("{owner}_{repo}.git"));
518        let bare_repo_lock_name = format!("bare-repo-{owner}_{repo}");
519
520        // Create SHA-based cache key
521        // Using first 8 chars of SHA for directory name (like Git does)
522        let sha_short = &sha[..8];
523        let cache_dir_hash = {
524            use std::collections::hash_map::DefaultHasher;
525            use std::hash::{Hash, Hasher};
526            let mut hasher = DefaultHasher::new();
527            self.dir.hash(&mut hasher);
528            format!("{:x}", hasher.finish())[..8].to_string()
529        };
530        let cache_key = format!("{cache_dir_hash}:{owner}_{repo}:{sha}");
531
532        // Check if we already have a worktree for this SHA using DashMap's lock-free API
533        // This eliminates lock contention and deadlocks from the previous RwLock implementation
534        let pending_timeout = pending_state_timeout();
535
536        loop {
537            match self.worktree_cache.entry(cache_key.clone()) {
538                dashmap::mapref::entry::Entry::Occupied(entry) => {
539                    match entry.get() {
540                        WorktreeState::Ready(cached_path) if cached_path.exists() => {
541                            // Worktree already exists and is ready
542                            let cached_path = cached_path.clone();
543                            drop(entry);
544
545                            self.record_worktree_usage(&cache_key, name, sha_short, &cached_path)
546                                .await?;
547
548                            if let Some(ctx) = context {
549                                tracing::debug!(
550                                    target: "git",
551                                    "({}) Reusing SHA-based worktree for {} @ {}",
552                                    ctx,
553                                    url.split('/').next_back().unwrap_or(url),
554                                    sha_short
555                                );
556                            }
557                            return Ok(cached_path);
558                        }
559                        WorktreeState::Ready(_cached_path) => {
560                            // Path exists in cache but not on filesystem - need to recreate
561                            // Create fresh notify handle for this creation attempt
562                            let notify = Arc::new(tokio::sync::Notify::new());
563                            drop(entry);
564                            // Insert Pending state and proceed to creation
565                            self.worktree_cache
566                                .insert(cache_key.clone(), WorktreeState::Pending(notify));
567                            break;
568                        }
569                        WorktreeState::Pending(existing_notify) => {
570                            // Another thread is creating this worktree - wait for notification
571                            let existing_notify = existing_notify.clone();
572
573                            // CRITICAL: Create the notified future BEFORE dropping entry
574                            // This ensures we won't miss the notification if the other thread
575                            // finishes between drop() and notified() - Notify only wakes
576                            // futures that are ALREADY waiting
577                            let notified_future = existing_notify.notified();
578                            drop(entry);
579
580                            if let Some(ctx) = context {
581                                tracing::debug!(
582                                    target: "git",
583                                    "({}) Waiting for SHA worktree creation for {} @ {}",
584                                    ctx,
585                                    url.split('/').next_back().unwrap_or(url),
586                                    sha_short
587                                );
588                            }
589
590                            // Wait for notification with timeout
591                            tokio::select! {
592                                _ = notified_future => {
593                                    // Worktree creation completed (success or failure) - retry from top
594                                    continue;
595                                }
596                                _ = tokio::time::sleep(pending_timeout) => {
597                                    // Timeout waiting - the other thread may have hung.
598                                    // We need to take ownership by inserting our own Pending state.
599                                    // This ensures proper coordination with any other waiting threads.
600                                    let our_notify = Arc::new(tokio::sync::Notify::new());
601                                    self.worktree_cache
602                                        .insert(cache_key.clone(), WorktreeState::Pending(our_notify));
603
604                                    // Notify existing waiters so they can re-evaluate the new state
605                                    existing_notify.notify_waiters();
606                                    tracing::warn!(
607                                        target: "git",
608                                        "Timeout waiting for worktree creation for {} @ {} - taking ownership",
609                                        url.split('/').next_back().unwrap_or(url),
610                                        sha_short
611                                    );
612                                    break;
613                                }
614                            }
615                        }
616                    }
617                }
618                dashmap::mapref::entry::Entry::Vacant(entry) => {
619                    // No entry exists - create notify handle here to ensure
620                    // it's only created when actually needed for a new entry
621                    let notify = Arc::new(tokio::sync::Notify::new());
622                    entry.insert(WorktreeState::Pending(notify));
623                    break;
624                }
625            }
626        }
627
628        // All work wrapped in a result to handle cleanup explicitly on error
629        let worktree_cache = self.worktree_cache.clone();
630        let cache_key_for_cleanup = cache_key.clone();
631
632        let result: Result<PathBuf> = async {
633            tracing::debug!(
634                target: "git::worktree",
635                "Starting worktree creation for {} @ {} (cache_key={})",
636                url.split('/').next_back().unwrap_or(url),
637                sha_short,
638                cache_key
639            );
640
641            // Check if bare repository already exists BEFORE acquiring lock
642            // This avoids lock order violations when multiple worktrees are created concurrently
643            if !bare_repo_dir.exists() {
644                // Bare repo doesn't exist - acquire lock and clone
645                tracing::debug!(
646                    target: "git",
647                    "Bare repo does not exist, acquiring lock to clone: {}",
648                    bare_repo_dir.display()
649                );
650
651                let bare_repo_lock = CacheLock::acquire(&self.dir, &bare_repo_lock_name).await?;
652
653                // Re-check after acquiring lock (another task may have cloned it)
654                if !bare_repo_dir.exists() {
655                    if let Some(parent) = bare_repo_dir.parent() {
656                        tokio::fs::create_dir_all(parent).await.with_file_context(
657                            FileOperation::CreateDir,
658                            parent,
659                            "creating cache parent directory",
660                            "cache_module",
661                        )?;
662                    }
663
664                    if let Some(ctx) = context {
665                        tracing::debug!("📦 ({ctx}) Cloning repository {url}...");
666                    } else {
667                        tracing::debug!("📦 Cloning repository {url} to cache...");
668                    }
669
670                    // Add timeout to prevent hung clone operations
671                    tokio::time::timeout(
672                        crate::constants::GIT_CLONE_TIMEOUT,
673                        GitRepo::clone_bare_with_context(url, &bare_repo_dir, context),
674                    )
675                    .await
676                    .map_err(|_| {
677                        anyhow::anyhow!(
678                            "Git clone operation timed out after {:?} for {}",
679                            crate::constants::GIT_CLONE_TIMEOUT,
680                            url
681                        )
682                    })??;
683
684                    Self::configure_connection_pooling(&bare_repo_dir).await.ok();
685
686                    // Mark as fetched since clone_bare_with_context already fetches
687                    acquire_rwlock_write_with_timeout(&self.fetched_repos, "fetched_repos")
688                        .await?
689                        .insert(bare_repo_dir.clone());
690                }
691
692                // Release bare repo lock before proceeding to worktree creation
693                drop(bare_repo_lock);
694            }
695            // If bare_repo_dir already existed, we never acquired the lock
696
697            let bare_repo = GitRepo::new(&bare_repo_dir);
698
699            // Create worktree path using SHA
700            let worktree_path =
701                self.dir.join("worktrees").join(format!("{owner}_{repo}_{sha_short}"));
702
703            // Acquire per-SHA worktree lock for caching/deduplication.
704            let worktree_lock_name = format!("worktree-{owner}-{repo}-{sha_short}");
705            let _worktree_lock = CacheLock::acquire(&self.dir, &worktree_lock_name).await?;
706
707            // Re-check after lock
708            if worktree_path.exists() {
709                // Notify and update cache to Ready
710                let notify_to_wake = extract_notify_handle(&self.worktree_cache, &cache_key);
711                self.worktree_cache
712                    .insert(cache_key.clone(), WorktreeState::Ready(worktree_path.clone()));
713                if let Some(n) = notify_to_wake {
714                    n.notify_waiters();
715                }
716
717                self.record_worktree_usage(&cache_key, name, sha_short, &worktree_path).await?;
718                return Ok(worktree_path);
719            }
720
721            // NOTE: We intentionally do NOT call prune_worktrees() here.
722            // The speculative prune caused race conditions when multiple threads created
723            // worktrees from the same bare repo simultaneously (each had different SHA locks
724            // but prune affects the entire .git/worktrees/ directory).
725            // If stale worktree metadata exists, git worktree add will fail with
726            // "missing but already registered worktree" and the error handling path
727            // (create_worktree_with_context) will prune and retry.
728
729            // Create worktree at specific SHA
730            if let Some(ctx) = context {
731                tracing::debug!(
732                    target: "git",
733                    "({}) Creating SHA-based worktree: {} @ {}",
734                    ctx,
735                    url.split('/').next_back().unwrap_or(url),
736                    sha_short
737                );
738            }
739
740            // Acquire bare-repo-level SHARED lock for worktree creation.
741            // Shared locks allow parallel worktree creation for different SHAs (each writes
742            // to its own subdirectory in .git/worktrees/). Exclusive locks are used only
743            // for deletion/pruning operations that modify shared state.
744            let bare_repo_worktree_lock_name = format!("bare-worktree-{owner}_{repo}");
745            let _bare_worktree_lock =
746                CacheLock::acquire_shared(&self.dir, &bare_repo_worktree_lock_name).await?;
747
748            // Create worktree using SHA directly
749            // Add timeout to prevent hung worktree creation
750            let worktree_result = tokio::time::timeout(
751                crate::constants::GIT_WORKTREE_TIMEOUT,
752                bare_repo.create_worktree_with_context(&worktree_path, Some(sha), context),
753            )
754            .await
755            .map_err(|_| {
756                anyhow::anyhow!(
757                    "Git worktree creation timed out after {:?} for {} @ {}",
758                    crate::constants::GIT_WORKTREE_TIMEOUT,
759                    url,
760                    sha_short
761                )
762            })?;
763
764            // Keep lock held until cache is updated to ensure git state is fully settled
765            match worktree_result {
766                Ok(_) => {
767                    // Verify worktree is fully accessible before marking as Ready
768                    // Using git status to ensure files are accessible, avoiding deadlocks from retry loops.
769                    // Files verified here are guaranteed readable, eliminating need for retry logic later.
770                    // This architectural choice enables better parallelism and prevents lock contention.
771                    if !worktree_path.exists() {
772                        return Err(anyhow::anyhow!(
773                            "Worktree directory does not exist: {}",
774                            worktree_path.display()
775                        ));
776                    }
777
778                    let git_file = worktree_path.join(".git");
779                    if !git_file.exists() {
780                        return Err(anyhow::anyhow!(
781                            "Worktree .git file does not exist: {}",
782                            git_file.display()
783                        ));
784                    }
785
786                    // Release bare repo lock - worktree creation is complete
787                    drop(_bare_worktree_lock);
788
789                    // Fsync both directories to ensure all file entries are visible.
790                    // This fixes APFS/filesystem buffer cache issues where files aren't
791                    // immediately readable after git worktree add completes.
792                    //
793                    // SKIP ON WINDOWS: Windows uses mandatory file locking which ensures
794                    // file visibility after git operations complete. The fsync operation
795                    // is expensive (100-500ms per call on NTFS) and unnecessary.
796                    #[cfg(not(windows))]
797                    {
798                        let worktree_path_clone = worktree_path.clone();
799                        let bare_worktrees_dir = bare_repo_dir.join("worktrees");
800                        let bare_worktrees_exists = bare_worktrees_dir.exists();
801
802                        let _ = tokio::task::spawn_blocking(move || {
803                            // Fsync worktree directory
804                            if let Ok(dir) = std::fs::File::open(&worktree_path_clone) {
805                                let _ = dir.sync_all();
806                            }
807
808                            // Fsync bare repo's worktrees metadata directory
809                            if bare_worktrees_exists {
810                                if let Ok(dir) = std::fs::File::open(&bare_worktrees_dir) {
811                                    let _ = dir.sync_all();
812                                }
813                            }
814                        })
815                        .await;
816
817                        tracing::debug!(
818                            target: "git::worktree",
819                            "Worktree fsync completed for {} @ {}",
820                            worktree_path.display(),
821                            &sha[..8]
822                        );
823                    }
824
825                    // Notify and update cache to Ready
826                    let notify_to_wake = extract_notify_handle(&self.worktree_cache, &cache_key);
827                    self.worktree_cache
828                        .insert(cache_key.clone(), WorktreeState::Ready(worktree_path.clone()));
829                    if let Some(n) = notify_to_wake {
830                        n.notify_waiters();
831                    }
832
833                    self.record_worktree_usage(&cache_key, name, sha_short, &worktree_path).await?;
834                    Ok(worktree_path)
835                }
836                Err(e) => Err(e),
837            }
838        }
839        .await;
840
841        // Handle result with explicit cleanup on error
842        match result {
843            Ok(path) => Ok(path),
844            Err(e) => {
845                // Cleanup: remove Pending entry and notify waiters
846                let notify = extract_notify_handle(&worktree_cache, &cache_key_for_cleanup);
847                worktree_cache.remove(&cache_key_for_cleanup);
848                if let Some(n) = notify {
849                    n.notify_waiters();
850                }
851                Err(e)
852            }
853        }
854    }
855
856    /// Get or clone a source repository with options to control cache behavior.
857    ///
858    /// This method provides the core functionality for repository access with
859    /// additional control over cache behavior. Creates bare repositories that
860    /// can be shared by all operations (resolution, installation, etc).
861    ///
862    /// # Parameters
863    ///
864    /// * `name` - The name of the source (used for cache directory naming)
865    /// * `url` - The Git repository URL or local path
866    /// * `version` - Optional specific version/tag/branch to checkout
867    /// * `force_refresh` - If true, ignore cached version and clone/fetch fresh
868    ///
869    /// # Returns
870    ///
871    /// Returns the path to the cached bare repository directory
872    async fn get_or_clone_source_impl(
873        &self,
874        name: &str,
875        url: &str,
876        version: Option<&str>,
877    ) -> Result<PathBuf> {
878        // Check if this is a local path (not a git repository URL)
879        let is_local_path = crate::utils::is_local_path(url);
880
881        if is_local_path {
882            // For local paths (directories), validate and return the secure path
883            // No cloning or version management needed
884
885            // Resolve path securely with validation
886            let resolved_path = crate::utils::platform::resolve_path(url)?;
887
888            // Canonicalize to get the real path and prevent symlink attacks
889            let canonical_path = crate::utils::safe_canonicalize(&resolved_path)
890                .map_err(|_| anyhow::anyhow!("Local path is not accessible or does not exist"))?;
891
892            // Security check: Validate path against blacklist and symlinks
893            validate_path_security(&canonical_path, true)?;
894
895            // For local paths, versions don't apply. Suppress warning for internal sentinel values.
896            if let Some(ver) = version
897                && ver != "local"
898            {
899                eprintln!("Warning: Version constraints are ignored for local paths");
900            }
901
902            return Ok(canonical_path);
903        }
904
905        self.ensure_cache_dir().await?;
906
907        // Acquire lock for this source to prevent concurrent access
908        let _lock = CacheLock::acquire(&self.dir, name)
909            .await
910            .with_context(|| format!("Failed to acquire lock for source: {name}"))?;
911
912        // Use the same cache directory structure as worktrees - bare repos with .git suffix
913        // This ensures we have ONE repository that's shared by all operations
914        let (owner, repo) =
915            crate::git::parse_git_url(url).unwrap_or(("direct".to_string(), "repo".to_string()));
916        let source_dir = self.dir.join("sources").join(format!("{owner}_{repo}.git")); // Always use .git suffix for bare repos
917
918        // Ensure parent directory exists
919        if let Some(parent) = source_dir.parent() {
920            tokio::fs::create_dir_all(parent).await.with_file_context(
921                FileOperation::CreateDir,
922                parent,
923                "creating cache directory",
924                "cache_module",
925            )?;
926        }
927
928        if source_dir.exists() {
929            // Use existing cache - fetch to ensure we have latest refs
930            // Skip fetch for local paths as they don't have remotes
931            // For Git URLs, always fetch to get the latest refs (especially important for branches)
932            if crate::utils::is_git_url(url) {
933                // Check if we've already fetched this repo in this command instance
934                let already_fetched = {
935                    let fetched =
936                        acquire_rwlock_read_with_timeout(&self.fetched_repos, "fetched_repos")
937                            .await?;
938                    fetched.contains(&source_dir)
939                };
940
941                if already_fetched {
942                    tracing::debug!(
943                        target: "agpm::cache",
944                        "Skipping fetch for {} (already fetched in this command)",
945                        name
946                    );
947                } else {
948                    tracing::debug!(
949                        target: "agpm::cache",
950                        "Fetching updates for {} from {}",
951                        name,
952                        url
953                    );
954                    let repo = crate::git::GitRepo::new(&source_dir);
955                    if let Err(e) = repo.fetch(None).await {
956                        tracing::warn!(
957                            target: "agpm::cache",
958                            "Failed to fetch updates for {}: {}",
959                            name,
960                            e
961                        );
962                    } else {
963                        // Mark this repo as fetched for this command execution
964                        let mut fetched =
965                            acquire_rwlock_write_with_timeout(&self.fetched_repos, "fetched_repos")
966                                .await?;
967                        fetched.insert(source_dir.clone());
968                        tracing::debug!(
969                            target: "agpm::cache",
970                            "Successfully fetched updates for {}",
971                            name
972                        );
973                    }
974                }
975            } else {
976                tracing::debug!(
977                    target: "agpm::cache",
978                    "Skipping fetch for local path: {}",
979                    url
980                );
981            }
982        } else {
983            // Directory doesn't exist - clone fresh as bare repo
984            self.clone_source(url, &source_dir).await?;
985        }
986
987        Ok(source_dir)
988    }
989
990    /// Clones a Git repository to the specified target directory as a bare repository.
991    ///
992    /// This internal method performs the initial clone operation for repositories
993    /// that are not yet present in the cache. It creates a bare repository which
994    /// is optimal for serving and allows multiple worktrees to be created from it.
995    ///
996    /// # Why Bare Repositories
997    ///
998    /// Bare repositories are used because:
999    /// - **No working directory conflicts**: Multiple worktrees can be created safely
1000    /// - **Optimized for serving**: Like GitHub/GitLab, designed for fetch operations
1001    /// - **Space efficient**: No checkout of files in the main repository
1002    /// - **Thread-safe**: Multiple processes can fetch from it simultaneously
1003    ///
1004    /// # Authentication
1005    ///
1006    /// Repository authentication is handled through:
1007    /// - **SSH keys**: For `git@github.com:` URLs (user's SSH configuration)
1008    /// - **HTTPS tokens**: For private repositories (from global config)
1009    /// - **Public repos**: No authentication required
1010    ///
1011    /// # Parameters
1012    ///
1013    /// * `url` - Git repository URL to clone from
1014    /// * `target` - Local directory path where bare repository should be created
1015    ///
1016    /// # Errors
1017    ///
1018    /// Returns an error if:
1019    /// - Repository URL is invalid or unreachable
1020    /// - Authentication fails for private repositories
1021    /// - Target directory cannot be created or written to
1022    /// - Network connectivity issues
1023    /// - Git command is not available in PATH
1024    async fn clone_source(&self, url: &str, target: &Path) -> Result<()> {
1025        tracing::debug!("📦 Cloning {} to cache...", url);
1026
1027        // Clone as a bare repository for better concurrency and worktree support
1028        GitRepo::clone_bare(url, target)
1029            .await
1030            .with_context(|| format!("Failed to clone repository from {url}"))?;
1031
1032        // Debug: List what was cloned
1033        if cfg!(test)
1034            && let Ok(entries) = std::fs::read_dir(target)
1035        {
1036            tracing::debug!(
1037                target: "agpm::cache",
1038                "Cloned bare repo to {}, contents:",
1039                target.display()
1040            );
1041            for entry in entries.flatten() {
1042                tracing::debug!(
1043                    target: "agpm::cache",
1044                    "  - {}",
1045                    entry.path().display()
1046                );
1047            }
1048        }
1049
1050        Ok(())
1051    }
1052
1053    /// Copies resource file from cached repository to project (silent).
1054    ///
1055    /// Uses copy-based approach (not symlinks) for cross-platform compatibility
1056    /// and Git integration. Creates parent directories automatically.
1057    ///
1058    /// # Arguments
1059    ///
1060    /// * `source_dir` - Cached repository path
1061    /// * `source_path` - Relative path within repository
1062    /// * `target_path` - Absolute installation path
1063    pub async fn copy_resource(
1064        &self,
1065        source_dir: &Path,
1066        source_path: &str,
1067        target_path: &Path,
1068    ) -> Result<()> {
1069        self.copy_resource_with_output(source_dir, source_path, target_path, false).await
1070    }
1071
1072    /// Copies resource file with optional installation output.
1073    ///
1074    /// Same as `copy_resource` but optionally displays "✅ Installed" messages.
1075    ///
1076    /// # Arguments
1077    ///
1078    /// * `show_output` - Whether to display installation progress
1079    pub async fn copy_resource_with_output(
1080        &self,
1081        source_dir: &Path,
1082        source_path: &str,
1083        target_path: &Path,
1084        show_output: bool,
1085    ) -> Result<()> {
1086        let source_file = source_dir.join(source_path);
1087
1088        if !source_file.exists() {
1089            return Err(AgpmError::ResourceFileNotFound {
1090                path: source_path.to_string(),
1091                source_name: source_dir
1092                    .file_name()
1093                    .and_then(|n| n.to_str())
1094                    .unwrap_or("unknown")
1095                    .to_string(),
1096            }
1097            .into());
1098        }
1099
1100        if let Some(parent) = target_path.parent() {
1101            async_fs::create_dir_all(parent)
1102                .await
1103                .with_context(|| format!("Failed to create directory: {}", parent.display()))?;
1104        }
1105
1106        async_fs::copy(&source_file, target_path).await.with_context(|| {
1107            format!("Failed to copy {} to {}", source_file.display(), target_path.display())
1108        })?;
1109
1110        if show_output {
1111            println!("  ✅ Installed {}", target_path.display());
1112        }
1113
1114        Ok(())
1115    }
1116
1117    /// Removes cached repositories not in active sources list.
1118    ///
1119    /// Returns count of removed directories. Displays progress messages.
1120    ///
1121    /// # Arguments
1122    ///
1123    /// * `active_sources` - Source names to preserve
1124    pub async fn clean_unused(&self, active_sources: &[String]) -> Result<usize> {
1125        self.ensure_cache_dir().await?;
1126
1127        let mut removed_count = 0;
1128        let mut entries = async_fs::read_dir(&self.dir)
1129            .await
1130            .with_context(|| "Failed to read cache directory")?;
1131
1132        while let Some(entry) =
1133            entries.next_entry().await.with_context(|| "Failed to read directory entry")?
1134        {
1135            let path = entry.path();
1136            if path.is_dir() {
1137                let dir_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
1138
1139                if !active_sources.contains(&dir_name.to_string()) {
1140                    println!("🗑️  Removing unused cache: {dir_name}");
1141                    async_fs::remove_dir_all(&path).await.with_context(|| {
1142                        format!("Failed to remove cache directory: {}", path.display())
1143                    })?;
1144                    removed_count += 1;
1145                }
1146            }
1147        }
1148
1149        Ok(removed_count)
1150    }
1151
1152    /// Calculates total cache size in bytes (recursive, returns 0 if not exists).
1153    pub async fn get_cache_size(&self) -> Result<u64> {
1154        if !self.dir.exists() {
1155            return Ok(0);
1156        }
1157
1158        let size = fs::get_directory_size(&self.dir).await?;
1159        Ok(size)
1160    }
1161
1162    /// Returns cache directory path (may not exist, use `ensure_cache_dir` to create).
1163    #[must_use]
1164    pub fn get_cache_location(&self) -> &Path {
1165        &self.dir
1166    }
1167
1168    /// Removes entire cache directory (destructive, requires re-cloning repos).
1169    pub async fn clear_all(&self) -> Result<()> {
1170        if self.dir.exists() {
1171            async_fs::remove_dir_all(&self.dir).await.with_context(|| "Failed to clear cache")?;
1172            println!("🗑️  Cleared all cache");
1173        }
1174        Ok(())
1175    }
1176}
1177
1178#[cfg(test)]
1179mod tests {
1180    use super::*;
1181
1182    use tempfile::TempDir;
1183
1184    #[tokio::test]
1185    async fn test_cache_dir_creation() {
1186        let temp_dir = TempDir::new().unwrap();
1187        let cache_dir = temp_dir.path().join("cache");
1188
1189        let cache = Cache::with_dir(cache_dir.clone()).unwrap();
1190        cache.ensure_cache_dir().await.unwrap();
1191
1192        assert!(cache_dir.exists());
1193    }
1194
1195    #[tokio::test]
1196    async fn test_cache_location() {
1197        let temp_dir = TempDir::new().unwrap();
1198        let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
1199        let location = cache.get_cache_location();
1200        assert_eq!(location, temp_dir.path());
1201    }
1202
1203    #[tokio::test]
1204    async fn test_cache_size_empty() {
1205        let temp_dir = TempDir::new().unwrap();
1206        let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
1207
1208        cache.ensure_cache_dir().await.unwrap();
1209        let size = cache.get_cache_size().await.unwrap();
1210        assert_eq!(size, 0);
1211    }
1212
1213    #[tokio::test]
1214    async fn test_cache_size_with_content() {
1215        let temp_dir = TempDir::new().unwrap();
1216        let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
1217
1218        cache.ensure_cache_dir().await.unwrap();
1219
1220        // Create some test content
1221        let test_file = temp_dir.path().join("test.txt");
1222        std::fs::write(&test_file, "test content").unwrap();
1223
1224        let size = cache.get_cache_size().await.unwrap();
1225        assert!(size > 0);
1226        assert_eq!(size, 12); // "test content" is 12 bytes
1227    }
1228
1229    #[tokio::test]
1230    async fn test_clean_unused_empty_cache() {
1231        let temp_dir = TempDir::new().unwrap();
1232        let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
1233
1234        cache.ensure_cache_dir().await.unwrap();
1235
1236        let removed = cache.clean_unused(&["active".to_string()]).await.unwrap();
1237        assert_eq!(removed, 0);
1238    }
1239
1240    #[tokio::test]
1241    async fn test_clean_unused_removes_correct_dirs() {
1242        let temp_dir = TempDir::new().unwrap();
1243        let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
1244
1245        cache.ensure_cache_dir().await.unwrap();
1246
1247        // Create some test directories
1248        let active_dir = temp_dir.path().join("active");
1249        let unused_dir = temp_dir.path().join("unused");
1250        let another_unused = temp_dir.path().join("another_unused");
1251
1252        std::fs::create_dir_all(&active_dir).unwrap();
1253        std::fs::create_dir_all(&unused_dir).unwrap();
1254        std::fs::create_dir_all(&another_unused).unwrap();
1255
1256        // Add some content to verify directories are removed completely
1257        std::fs::write(active_dir.join("file.txt"), "keep").unwrap();
1258        std::fs::write(unused_dir.join("file.txt"), "remove").unwrap();
1259        std::fs::write(another_unused.join("file.txt"), "remove").unwrap();
1260
1261        let removed = cache.clean_unused(&["active".to_string()]).await.unwrap();
1262
1263        assert_eq!(removed, 2);
1264        assert!(active_dir.exists());
1265        assert!(!unused_dir.exists());
1266        assert!(!another_unused.exists());
1267    }
1268
1269    #[tokio::test]
1270    async fn test_clear_all_removes_entire_cache() {
1271        let temp_dir = TempDir::new().unwrap();
1272        let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
1273
1274        cache.ensure_cache_dir().await.unwrap();
1275
1276        // Create some content
1277        let subdir = temp_dir.path().join("subdir");
1278        std::fs::create_dir_all(&subdir).unwrap();
1279        std::fs::write(subdir.join("file.txt"), "content").unwrap();
1280
1281        assert!(temp_dir.path().exists());
1282        assert!(subdir.exists());
1283
1284        cache.clear_all().await.unwrap();
1285
1286        assert!(!temp_dir.path().exists());
1287    }
1288
1289    #[tokio::test]
1290    async fn test_copy_resource() {
1291        let temp_dir = TempDir::new().unwrap();
1292        let cache = Cache::with_dir(temp_dir.path().join("cache")).unwrap();
1293
1294        // Create source file
1295        let source_dir = temp_dir.path().join("source");
1296        std::fs::create_dir_all(&source_dir).unwrap();
1297        let source_file = source_dir.join("resource.md");
1298        std::fs::write(&source_file, "# Test Resource\nContent").unwrap();
1299
1300        // Copy resource
1301        let dest = temp_dir.path().join("dest.md");
1302        cache.copy_resource(&source_dir, "resource.md", &dest).await.unwrap();
1303
1304        assert!(dest.exists());
1305        let content = std::fs::read_to_string(&dest).unwrap();
1306        assert_eq!(content, "# Test Resource\nContent");
1307    }
1308
1309    #[tokio::test]
1310    async fn test_copy_resource_nested_path() {
1311        let temp_dir = TempDir::new().unwrap();
1312        let cache = Cache::with_dir(temp_dir.path().join("cache")).unwrap();
1313
1314        // Create source file in nested directory
1315        let source_dir = temp_dir.path().join("source");
1316        let nested_dir = source_dir.join("nested").join("path");
1317        std::fs::create_dir_all(&nested_dir).unwrap();
1318        let source_file = nested_dir.join("resource.md");
1319        std::fs::write(&source_file, "# Nested Resource").unwrap();
1320
1321        // Copy resource using relative path from source_dir
1322        let dest = temp_dir.path().join("dest.md");
1323        cache.copy_resource(&source_dir, "nested/path/resource.md", &dest).await.unwrap();
1324
1325        assert!(dest.exists());
1326        let content = std::fs::read_to_string(&dest).unwrap();
1327        assert_eq!(content, "# Nested Resource");
1328    }
1329
1330    #[tokio::test]
1331    async fn test_copy_resource_invalid_path() {
1332        let temp_dir = TempDir::new().unwrap();
1333        let cache = Cache::with_dir(temp_dir.path().join("cache")).unwrap();
1334
1335        let source_dir = temp_dir.path().join("source");
1336        std::fs::create_dir_all(&source_dir).unwrap();
1337
1338        // Try to copy non-existent resource
1339        let dest = temp_dir.path().join("dest.md");
1340        let result = cache.copy_resource(&source_dir, "nonexistent.md", &dest).await;
1341
1342        assert!(result.is_err());
1343        assert!(!dest.exists());
1344    }
1345
1346    #[tokio::test]
1347    async fn test_ensure_cache_dir_idempotent() {
1348        let temp_dir = TempDir::new().unwrap();
1349        let cache_dir = temp_dir.path().join("cache");
1350        let cache = Cache::with_dir(cache_dir.clone()).unwrap();
1351
1352        // Call ensure_cache_dir multiple times
1353        cache.ensure_cache_dir().await.unwrap();
1354        assert!(cache_dir.exists());
1355
1356        cache.ensure_cache_dir().await.unwrap();
1357        assert!(cache_dir.exists());
1358
1359        // Add a file and ensure it's preserved
1360        std::fs::write(cache_dir.join("test.txt"), "content").unwrap();
1361
1362        cache.ensure_cache_dir().await.unwrap();
1363        assert!(cache_dir.exists());
1364        assert!(cache_dir.join("test.txt").exists());
1365    }
1366
1367    #[tokio::test]
1368    async fn test_copy_resource_creates_parent_directories() {
1369        let temp_dir = TempDir::new().unwrap();
1370        let cache = Cache::with_dir(temp_dir.path().join("cache")).unwrap();
1371
1372        // Create source file
1373        let source_dir = temp_dir.path().join("source");
1374        std::fs::create_dir_all(&source_dir).unwrap();
1375        std::fs::write(source_dir.join("file.md"), "content").unwrap();
1376
1377        // Copy to a destination with non-existent parent directories
1378        let dest = temp_dir.path().join("deep").join("nested").join("dest.md");
1379        cache.copy_resource(&source_dir, "file.md", &dest).await.unwrap();
1380
1381        assert!(dest.exists());
1382        assert_eq!(std::fs::read_to_string(&dest).unwrap(), "content");
1383    }
1384
1385    #[tokio::test]
1386    async fn test_copy_resource_with_output_flag() {
1387        let temp_dir = TempDir::new().unwrap();
1388        let cache = Cache::with_dir(temp_dir.path().join("cache")).unwrap();
1389
1390        // Create source file
1391        let source_dir = temp_dir.path().join("source");
1392        std::fs::create_dir_all(&source_dir).unwrap();
1393        std::fs::write(source_dir.join("file.md"), "content").unwrap();
1394
1395        // Test with output flag false
1396        let dest1 = temp_dir.path().join("dest1.md");
1397        cache.copy_resource_with_output(&source_dir, "file.md", &dest1, false).await.unwrap();
1398        assert!(dest1.exists());
1399
1400        // Test with output flag true
1401        let dest2 = temp_dir.path().join("dest2.md");
1402        cache.copy_resource_with_output(&source_dir, "file.md", &dest2, true).await.unwrap();
1403        assert!(dest2.exists());
1404    }
1405
1406    #[tokio::test]
1407    async fn test_cache_size_nonexistent_dir() {
1408        let temp_dir = TempDir::new().unwrap();
1409        let nonexistent = temp_dir.path().join("nonexistent");
1410        let cache = Cache::with_dir(nonexistent).unwrap();
1411
1412        let size = cache.get_cache_size().await.unwrap();
1413        assert_eq!(size, 0);
1414    }
1415
1416    #[tokio::test]
1417    async fn test_clear_all_nonexistent_cache() {
1418        let temp_dir = TempDir::new().unwrap();
1419        let nonexistent = temp_dir.path().join("nonexistent");
1420        let cache = Cache::with_dir(nonexistent).unwrap();
1421
1422        // Should not error when clearing non-existent cache
1423        cache.clear_all().await.unwrap();
1424    }
1425
1426    #[tokio::test]
1427    async fn test_clean_unused_with_files_and_dirs() {
1428        let temp_dir = TempDir::new().unwrap();
1429        let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
1430
1431        cache.ensure_cache_dir().await.unwrap();
1432
1433        // Create directories
1434        std::fs::create_dir_all(temp_dir.path().join("keep")).unwrap();
1435        std::fs::create_dir_all(temp_dir.path().join("remove")).unwrap();
1436
1437        // Create a file (not a directory)
1438        std::fs::write(temp_dir.path().join("file.txt"), "content").unwrap();
1439
1440        let removed = cache.clean_unused(&["keep".to_string()]).await.unwrap();
1441
1442        // Should only remove the "remove" directory, not the file
1443        assert_eq!(removed, 1);
1444        assert!(temp_dir.path().join("keep").exists());
1445        assert!(!temp_dir.path().join("remove").exists());
1446        assert!(temp_dir.path().join("file.txt").exists());
1447    }
1448
1449    #[tokio::test]
1450    async fn test_copy_resource_overwrites_existing() {
1451        let temp_dir = TempDir::new().unwrap();
1452        let cache = Cache::with_dir(temp_dir.path().join("cache")).unwrap();
1453
1454        // Create source file
1455        let source_dir = temp_dir.path().join("source");
1456        std::fs::create_dir_all(&source_dir).unwrap();
1457        std::fs::write(source_dir.join("file.md"), "new content").unwrap();
1458
1459        // Create existing destination file
1460        let dest = temp_dir.path().join("dest.md");
1461        std::fs::write(&dest, "old content").unwrap();
1462
1463        // Copy should overwrite
1464        cache.copy_resource(&source_dir, "file.md", &dest).await.unwrap();
1465
1466        assert_eq!(std::fs::read_to_string(&dest).unwrap(), "new content");
1467    }
1468
1469    #[tokio::test]
1470    async fn test_copy_resource_special_characters() {
1471        let temp_dir = TempDir::new().unwrap();
1472        let cache = Cache::with_dir(temp_dir.path().join("cache")).unwrap();
1473
1474        // Create source file with special characters
1475        let source_dir = temp_dir.path().join("source");
1476        std::fs::create_dir_all(&source_dir).unwrap();
1477        let special_name = "file with spaces & special-chars.md";
1478        std::fs::write(source_dir.join(special_name), "content").unwrap();
1479
1480        // Copy resource
1481        let dest = temp_dir.path().join("dest.md");
1482        cache.copy_resource(&source_dir, special_name, &dest).await.unwrap();
1483
1484        assert!(dest.exists());
1485        assert_eq!(std::fs::read_to_string(&dest).unwrap(), "content");
1486    }
1487
1488    #[tokio::test]
1489    async fn test_cache_location_consistency() {
1490        let temp_dir = TempDir::new().unwrap();
1491        let cache_dir = temp_dir.path().join("my_cache");
1492        let cache = Cache::with_dir(cache_dir.clone()).unwrap();
1493
1494        // Get location multiple times
1495        let loc1 = cache.get_cache_location();
1496        let loc2 = cache.get_cache_location();
1497
1498        assert_eq!(loc1, loc2);
1499        assert_eq!(loc1, cache_dir.as_path());
1500    }
1501
1502    #[tokio::test]
1503    async fn test_clean_unused_empty_active_list() {
1504        let temp_dir = TempDir::new().unwrap();
1505        let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
1506
1507        cache.ensure_cache_dir().await.unwrap();
1508
1509        // Create some directories
1510        std::fs::create_dir_all(temp_dir.path().join("source1")).unwrap();
1511        std::fs::create_dir_all(temp_dir.path().join("source2")).unwrap();
1512
1513        // Empty active list should remove all
1514        let removed = cache.clean_unused(&[]).await.unwrap();
1515
1516        assert_eq!(removed, 2);
1517        assert!(!temp_dir.path().join("source1").exists());
1518        assert!(!temp_dir.path().join("source2").exists());
1519    }
1520
1521    #[tokio::test]
1522    async fn test_copy_resource_with_relative_paths() {
1523        let temp_dir = TempDir::new().unwrap();
1524        let cache = Cache::with_dir(temp_dir.path().join("cache")).unwrap();
1525
1526        // Create source with subdirectories
1527        let source_dir = temp_dir.path().join("source");
1528        let sub_dir = source_dir.join("agents");
1529        std::fs::create_dir_all(&sub_dir).unwrap();
1530        std::fs::write(sub_dir.join("helper.md"), "# Helper Agent").unwrap();
1531
1532        // Copy using relative path
1533        let dest = temp_dir.path().join("my-agent.md");
1534        cache.copy_resource(&source_dir, "agents/helper.md", &dest).await.unwrap();
1535
1536        assert!(dest.exists());
1537        assert_eq!(std::fs::read_to_string(&dest).unwrap(), "# Helper Agent");
1538    }
1539
1540    #[tokio::test]
1541    async fn test_cache_size_with_subdirectories() {
1542        let temp_dir = TempDir::new().unwrap();
1543        let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
1544
1545        cache.ensure_cache_dir().await.unwrap();
1546
1547        // Create nested structure with files
1548        let sub1 = temp_dir.path().join("sub1");
1549        let sub2 = sub1.join("sub2");
1550        std::fs::create_dir_all(&sub2).unwrap();
1551
1552        std::fs::write(temp_dir.path().join("file1.txt"), "12345").unwrap(); // 5 bytes
1553        std::fs::write(sub1.join("file2.txt"), "1234567890").unwrap(); // 10 bytes
1554        std::fs::write(sub2.join("file3.txt"), "abc").unwrap(); // 3 bytes
1555
1556        let size = cache.get_cache_size().await.unwrap();
1557        assert_eq!(size, 18); // 5 + 10 + 3
1558    }
1559}