agpm_cli/resolver/
version_resolver.rs

1//! Centralized version resolution module for AGPM
2//!
3//! This module implements the core version-to-SHA resolution strategy that ensures
4//! deterministic and efficient dependency management. By resolving all version
5//! specifications to commit SHAs upfront, we enable:
6//!
7//! - **SHA-based worktree caching**: Reuse worktrees for identical commits
8//! - **Reduced network operations**: Single fetch per repository
9//! - **Deterministic installations**: Same SHA always produces same result
10//! - **Efficient deduplication**: Multiple refs to same commit share one worktree
11//!
12//! # Architecture
13//!
14//! The `VersionResolver` operates in two phases:
15//! 1. **Collection Phase**: Gather all unique (source, version) pairs
16//! 2. **Resolution Phase**: Batch resolve all versions to SHAs
17//!
18//! This design minimizes Git operations and enables parallel resolution.
19
20use anyhow::{Context, Result};
21use std::collections::HashMap;
22use std::path::PathBuf;
23
24use crate::cache::Cache;
25use crate::git::GitRepo;
26use crate::manifest::ResourceDependency;
27use crate::source::SourceManager;
28
29/// Version resolution entry tracking source and version to SHA mapping
30#[derive(Debug, Clone)]
31pub struct VersionEntry {
32    /// Source name from manifest
33    pub source: String,
34    /// Source URL (Git repository)
35    pub url: String,
36    /// Version specification (tag, branch, commit, or None for HEAD)
37    pub version: Option<String>,
38    /// Resolved SHA-1 hash (populated during resolution)
39    pub resolved_sha: Option<String>,
40    /// Resolved version (e.g., "latest" -> "v2.0.0")
41    pub resolved_version: Option<String>,
42}
43
44/// Centralized version resolver for efficient SHA resolution
45///
46/// The `VersionResolver` is responsible for resolving all dependency versions
47/// to their corresponding Git commit SHAs before any worktree operations.
48/// This ensures maximum efficiency and deduplication.
49///
50/// # Example
51///
52/// ```no_run
53/// # use agpm_cli::resolver::version_resolver::{VersionResolver, VersionEntry};
54/// # use agpm_cli::cache::Cache;
55/// # async fn example() -> anyhow::Result<()> {
56/// let cache = Cache::new()?;
57/// let mut resolver = VersionResolver::new(cache);
58///
59/// // Add versions to resolve
60/// resolver.add_version("community", "https://github.com/example/repo.git", Some("v1.0.0"));
61/// resolver.add_version("community", "https://github.com/example/repo.git", Some("main"));
62///
63/// // Batch resolve all versions to SHAs
64/// resolver.resolve_all().await?;
65///
66/// // Get resolved SHA for a specific version
67/// let sha = resolver.get_resolved_sha("community", "v1.0.0");
68/// # Ok(())
69/// # }
70/// ```
71/// Resolved version information
72#[derive(Debug, Clone)]
73pub struct ResolvedVersion {
74    /// The resolved SHA-1 hash
75    pub sha: String,
76    /// The resolved version (e.g., "latest" -> "v2.0.0")
77    /// If no constraint resolution happened, this will be the same as input
78    pub resolved_ref: String,
79}
80
81/// Centralized version resolver for batch SHA resolution.
82///
83/// The `VersionResolver` manages the collection and resolution of all dependency
84/// versions in a single batch operation, enabling optimal Git repository access
85/// patterns and maximum worktree reuse.
86pub struct VersionResolver {
87    /// Cache instance for repository access
88    cache: Cache,
89    /// Collection of versions to resolve, keyed by (source, version)
90    entries: HashMap<(String, String), VersionEntry>,
91    /// Resolved SHA cache, keyed by (source, version)
92    resolved: HashMap<(String, String), ResolvedVersion>,
93    /// Bare repository paths, keyed by source name
94    bare_repos: HashMap<String, PathBuf>,
95}
96
97impl VersionResolver {
98    /// Creates a new version resolver with the given cache
99    pub fn new(cache: Cache) -> Self {
100        Self {
101            cache,
102            entries: HashMap::new(),
103            resolved: HashMap::new(),
104            bare_repos: HashMap::new(),
105        }
106    }
107
108    /// Adds a version to be resolved
109    ///
110    /// Multiple calls with the same (source, version) pair will be deduplicated.
111    ///
112    /// # Arguments
113    ///
114    /// * `source` - Source name from manifest
115    /// * `url` - Git repository URL
116    /// * `version` - Version specification (tag, branch, commit, or None for HEAD)
117    pub fn add_version(&mut self, source: &str, url: &str, version: Option<&str>) {
118        let version_key = version.unwrap_or("HEAD").to_string();
119        let key = (source.to_string(), version_key);
120
121        // Only add if not already present (deduplication)
122        self.entries.entry(key).or_insert_with(|| VersionEntry {
123            source: source.to_string(),
124            url: url.to_string(),
125            version: version.map(std::string::ToString::to_string),
126            resolved_sha: None,
127            resolved_version: None,
128        });
129    }
130
131    /// Resolves all collected versions to their commit SHAs using cached repositories.
132    ///
133    /// This method implements the second phase of AGPM's two-phase resolution architecture.
134    /// It processes all version entries collected via `add_version()` calls and resolves
135    /// them to concrete commit SHAs using locally cached Git repositories.
136    ///
137    /// # Prerequisites
138    ///
139    /// **CRITICAL**: `pre_sync_sources()` must be called before this method. The resolver
140    /// requires all repositories to be pre-synced to the cache, and will return an error
141    /// if any required repository is missing from the `bare_repos` map.
142    ///
143    /// # Resolution Process
144    ///
145    /// The method performs the following steps:
146    /// 1. **Source Grouping**: Groups entries by source to minimize repository operations
147    /// 2. **Repository Access**: Uses pre-synced repositories from `pre_sync_sources()`
148    /// 3. **Version Constraint Resolution**: Handles semver constraints (`^1.0`, `~2.1`)
149    /// 4. **SHA Resolution**: Resolves all versions to SHAs using `git rev-parse`
150    /// 5. **Result Caching**: Stores resolved SHAs for quick retrieval
151    ///
152    /// # Version Resolution Strategy
153    ///
154    /// The resolver handles different version types:
155    /// - **Exact SHAs**: Used directly without resolution
156    /// - **Semantic Versions**: Resolved using semver constraint matching
157    /// - **Tags**: Resolved to their commit SHAs
158    /// - **Branch Names**: Resolved to current HEAD commit
159    /// - **Latest/None**: Defaults to the repository's default branch
160    ///
161    /// # Performance Characteristics
162    ///
163    /// - **Time Complexity**: O(n·log(t)) where n = entries, t = tags per repo
164    /// - **Space Complexity**: O(n) for storing resolved results
165    /// - **Network I/O**: Zero (operates on cached repositories only)
166    /// - **Parallelization**: Single-threaded but optimized for batch operations
167    ///
168    /// # Example
169    ///
170    /// ```ignore
171    /// # use agpm_cli::resolver::version_resolver::VersionResolver;
172    /// # use agpm_cli::cache::Cache;
173    /// # async fn example() -> anyhow::Result<()> {
174    /// let cache = Cache::new()?;
175    /// let mut resolver = VersionResolver::new(cache);
176    ///
177    /// // Add various version types
178    /// resolver.add_version("source", "https://github.com/org/repo.git", Some("v1.2.3"));
179    /// resolver.add_version("source", "https://github.com/org/repo.git", Some("^1.0"));
180    /// resolver.add_version("source", "https://github.com/org/repo.git", Some("main"));
181    /// resolver.add_version("source", "https://github.com/org/repo.git", None); // latest
182    ///
183    /// // Phase 1: Sync repositories
184    /// resolver.pre_sync_sources().await?;
185    ///
186    /// // Phase 2: Resolve versions to SHAs (this method)
187    /// resolver.resolve_all().await?;
188    ///
189    /// // Access resolved SHAs
190    /// if resolver.is_resolved("source", "v1.2.3") {
191    ///     println!("v1.2.3 resolved successfully");
192    /// }
193    /// # Ok(())
194    /// # }
195    /// ```
196    ///
197    /// # Error Handling
198    ///
199    /// The method uses fail-fast behavior - if any version resolution fails,
200    /// the entire operation is aborted. This ensures consistency and prevents
201    /// partial resolution states.
202    ///
203    /// # Errors
204    ///
205    /// Returns an error if:
206    /// - **Pre-sync Required**: Repository was not pre-synced (call `pre_sync_sources()` first)
207    /// - **Version Not Found**: Specified version/tag/branch doesn't exist in repository
208    /// - **Constraint Resolution**: Semver constraint cannot be satisfied by available tags
209    /// - **Git Operations**: `git rev-parse` or other Git commands fail
210    /// - **Repository Access**: Cached repository is corrupted or inaccessible
211    pub async fn resolve_all(&mut self) -> Result<()> {
212        // Group entries by source for efficient processing
213        let mut by_source: HashMap<String, Vec<(String, VersionEntry)>> = HashMap::new();
214
215        for (key, entry) in &self.entries {
216            by_source.entry(entry.source.clone()).or_default().push((key.1.clone(), entry.clone()));
217        }
218
219        // Process each source
220        for (source, versions) in by_source {
221            // Repository must have been pre-synced
222            let repo_path = self
223                .bare_repos
224                .get(&source)
225                .ok_or_else(|| {
226                    anyhow::anyhow!("Repository for source '{source}' was not pre-synced. Call pre_sync_sources() first.")
227                })?
228                .clone();
229
230            let repo = GitRepo::new(&repo_path);
231
232            // Resolve each version for this source
233            for (version_str, mut entry) in versions {
234                // Check if this is a local directory source (not a Git repository)
235                let is_local = crate::utils::is_local_path(&entry.url);
236
237                // For local directory sources, we don't resolve versions - just use "local"
238                let resolved_ref = if is_local {
239                    "local".to_string()
240                } else if let Some(ref version) = entry.version {
241                    // First check if this is a version constraint
242                    if is_version_constraint(version) {
243                        // Resolve constraint to actual tag first
244                        // Note: get_or_clone_source already fetched, so tags should be available
245                        let tags = repo.list_tags().await.unwrap_or_default();
246
247                        if tags.is_empty() {
248                            return Err(anyhow::anyhow!(
249                                "No tags found in repository for constraint '{version}'"
250                            ));
251                        }
252
253                        // Find best matching tag
254                        find_best_matching_tag(version, tags)
255                            .with_context(|| format!("Failed to resolve version constraint '{version}' for source '{source}'"))?
256                    } else {
257                        // Not a constraint, use as-is
258                        version.clone()
259                    }
260                } else {
261                    // No version specified for Git source, resolve HEAD to actual branch name
262                    repo.get_default_branch().await.unwrap_or_else(|_| "main".to_string())
263                };
264
265                // For local sources, don't resolve SHA. For Git sources, resolve ref to actual SHA
266                let sha = if is_local {
267                    // Local directories don't have commit SHAs
268                    None
269                } else {
270                    // Resolve the actual ref to SHA for Git repositories
271                    Some(repo.resolve_to_sha(Some(&resolved_ref)).await.with_context(|| {
272                        format!("Failed to resolve version '{version_str}' for source '{source}'")
273                    })?)
274                };
275
276                // Store the resolved SHA and version
277                entry.resolved_sha = sha.clone();
278                entry.resolved_version = Some(resolved_ref.clone());
279                let key = (source.clone(), version_str);
280                // Only insert into resolved map if we have a SHA (Git sources only)
281                if let Some(sha_value) = sha {
282                    self.resolved.insert(
283                        key,
284                        ResolvedVersion {
285                            sha: sha_value,
286                            resolved_ref,
287                        },
288                    );
289                }
290            }
291        }
292
293        Ok(())
294    }
295
296    /// Resolves a single version to SHA without affecting the batch
297    ///
298    /// This is useful for incremental resolution or testing.
299    pub async fn resolve_single(
300        &mut self,
301        source: &str,
302        url: &str,
303        version: Option<&str>,
304    ) -> Result<String> {
305        // Get or clone the repository
306        let repo_path = self
307            .cache
308            .get_or_clone_source(source, url, None)
309            .await
310            .with_context(|| format!("Failed to prepare repository for source '{source}'"))?;
311
312        let repo = GitRepo::new(&repo_path);
313
314        // Resolve the version to SHA
315        let sha = repo.resolve_to_sha(version).await.with_context(|| {
316            format!(
317                "Failed to resolve version '{}' for source '{}'",
318                version.unwrap_or("HEAD"),
319                source
320            )
321        })?;
322
323        // Determine the resolved reference name
324        let resolved_ref = if let Some(v) = version {
325            v.to_string()
326        } else {
327            // When no version is specified, resolve HEAD to the actual branch name
328            repo.get_default_branch().await.unwrap_or_else(|_| "main".to_string())
329        };
330
331        // Cache the result
332        let version_key = version.unwrap_or("HEAD").to_string();
333        let key = (source.to_string(), version_key);
334        self.resolved.insert(
335            key,
336            ResolvedVersion {
337                sha: sha.clone(),
338                resolved_ref,
339            },
340        );
341
342        Ok(sha)
343    }
344
345    /// Gets the resolved SHA for a given source and version
346    ///
347    /// Returns None if the version hasn't been resolved yet.
348    ///
349    /// # Arguments
350    ///
351    /// * `source` - Source name
352    /// * `version` - Version specification (use "HEAD" for None)
353    pub fn get_resolved_sha(&self, source: &str, version: &str) -> Option<String> {
354        let key = (source.to_string(), version.to_string());
355        self.resolved.get(&key).map(|rv| rv.sha.clone())
356    }
357
358    /// Gets all resolved SHAs as a `HashMap`
359    ///
360    /// Useful for bulk operations or debugging.
361    pub fn get_all_resolved(&self) -> HashMap<(String, String), String> {
362        self.resolved.iter().map(|(k, v)| (k.clone(), v.sha.clone())).collect()
363    }
364
365    /// Gets all resolved versions with both SHA and resolved reference
366    ///
367    /// Returns a `HashMap` with (source, version) -> `ResolvedVersion`
368    pub const fn get_all_resolved_full(&self) -> &HashMap<(String, String), ResolvedVersion> {
369        &self.resolved
370    }
371
372    /// Checks if a specific version has been resolved
373    pub fn is_resolved(&self, source: &str, version: &str) -> bool {
374        let key = (source.to_string(), version.to_string());
375        self.resolved.contains_key(&key)
376    }
377
378    /// Pre-syncs all unique sources to ensure repositories are cloned/fetched.
379    ///
380    /// This method implements the first phase of AGPM's two-phase resolution architecture.
381    /// It is designed to be called during the "Syncing sources" phase to perform all
382    /// Git network operations upfront, before version resolution occurs.
383    ///
384    /// The method processes all entries in the resolver, groups them by unique source URLs,
385    /// and ensures each repository is cloned to the cache with the latest refs fetched.
386    /// This enables the subsequent `resolve_all()` method to work purely with local
387    /// cached data, providing better performance and progress reporting.
388    ///
389    /// # Post-Execution State
390    ///
391    /// After this method completes successfully:
392    /// - All required repositories will be cloned to `~/.agpm/cache/sources/`
393    /// - All repositories will have their latest refs fetched from remote
394    /// - The internal `bare_repos` map will be populated with repository paths
395    /// - `resolve_all()` can proceed without any network operations
396    ///
397    /// This separation provides several benefits:
398    /// - **Clear progress phases**: Network operations vs. local resolution
399    /// - **Better error handling**: Network failures separated from resolution logic
400    /// - **Batch optimization**: Single clone/fetch per unique repository
401    /// - **Parallelization potential**: Multiple repositories can be synced concurrently
402    ///
403    /// # Example
404    ///
405    /// ```ignore
406    /// use agpm_cli::resolver::version_resolver::VersionResolver;
407    /// use agpm_cli::cache::Cache;
408    ///
409    /// # async fn example() -> anyhow::Result<()> {
410    /// let cache = Cache::new()?;
411    /// let mut version_resolver = VersionResolver::new(cache);
412    ///
413    /// // Add versions to resolve across multiple sources
414    /// version_resolver.add_version(
415    ///     "community",
416    ///     "https://github.com/org/agpm-community.git",
417    ///     Some("v1.0.0"),
418    /// );
419    /// version_resolver.add_version(
420    ///     "community",
421    ///     "https://github.com/org/agpm-community.git",
422    ///     Some("v2.0.0"),
423    /// );
424    /// version_resolver.add_version(
425    ///     "private-tools",
426    ///     "https://github.com/company/private-agpm.git",
427    ///     Some("main"),
428    /// );
429    ///
430    /// // Phase 1: Pre-sync all repositories (network operations)
431    /// version_resolver.pre_sync_sources().await?;
432    ///
433    /// // Phase 2: Resolve all versions to SHAs (local operations only)
434    /// version_resolver.resolve_all().await?;
435    ///
436    /// // Access resolved data
437    /// if version_resolver.is_resolved("community", "v1.0.0") {
438    ///     println!("Successfully resolved community v1.0.0");
439    /// }
440    /// # Ok(())
441    /// # }
442    /// ```
443    ///
444    /// # Deduplication
445    ///
446    /// The method automatically deduplicates by source URL - if multiple entries
447    /// reference the same repository, only one clone/fetch operation is performed.
448    /// This is particularly efficient when resolving multiple versions from the
449    /// same source.
450    ///
451    /// # Errors
452    ///
453    /// Returns an error if:
454    /// - Repository cloning fails (network issues, authentication, invalid URL)
455    /// - Fetching latest refs fails (network connectivity, permission issues)
456    /// - Authentication fails for private repositories
457    /// - Disk space is insufficient for cloning repositories
458    /// - Repository is corrupted and cannot be accessed
459    pub async fn pre_sync_sources(&mut self) -> Result<()> {
460        // Group entries by source to get unique sources
461        let mut unique_sources: HashMap<String, String> = HashMap::new();
462
463        for entry in self.entries.values() {
464            unique_sources.insert(entry.source.clone(), entry.url.clone());
465        }
466
467        // Pre-sync each unique source
468        for (source, url) in unique_sources {
469            // Clone or update the repository (this does the actual Git operations)
470            let repo_path = self
471                .cache
472                .get_or_clone_source(&source, &url, None)
473                .await
474                .with_context(|| format!("Failed to sync repository for source '{source}'"))?;
475
476            // Store bare repo path for later use in resolve_all
477            self.bare_repos.insert(source.clone(), repo_path);
478        }
479
480        Ok(())
481    }
482
483    /// Gets the bare repository path for a source
484    ///
485    /// Returns None if the source hasn't been processed yet.
486    pub fn get_bare_repo_path(&self, source: &str) -> Option<&PathBuf> {
487        self.bare_repos.get(source)
488    }
489
490    /// Registers a bare repository path for a source
491    ///
492    /// This is used when manually ensuring a repository exists without clearing all state.
493    pub fn register_bare_repo(&mut self, source: String, repo_path: PathBuf) {
494        self.bare_repos.insert(source, repo_path);
495    }
496
497    /// Clears all resolved versions and cached data
498    ///
499    /// Useful for testing or when starting a fresh resolution.
500    pub fn clear(&mut self) {
501        self.entries.clear();
502        self.resolved.clear();
503        self.bare_repos.clear();
504    }
505
506    /// Returns the number of unique versions to resolve
507    pub fn pending_count(&self) -> usize {
508        self.entries.len()
509    }
510
511    /// Checks if the resolver has any entries to resolve.
512    ///
513    /// This is a convenience method to determine if the resolver has been populated
514    /// with version entries via `add_version()` calls. It's useful for conditional
515    /// logic to avoid unnecessary operations when no versions need resolution.
516    ///
517    /// # Returns
518    ///
519    /// Returns `true` if there are entries that need resolution, `false` if the
520    /// resolver is empty.
521    ///
522    /// # Example
523    ///
524    /// ```
525    /// # use agpm_cli::resolver::version_resolver::VersionResolver;
526    /// # use agpm_cli::cache::Cache;
527    /// # let cache = Cache::new().unwrap();
528    /// let mut resolver = VersionResolver::new(cache);
529    /// assert!(!resolver.has_entries()); // Initially empty
530    ///
531    /// resolver.add_version("source", "https://github.com/org/repo.git", Some("v1.0.0"));
532    /// assert!(resolver.has_entries()); // Now has entries
533    /// ```
534    pub fn has_entries(&self) -> bool {
535        !self.entries.is_empty()
536    }
537
538    /// Returns the number of successfully resolved versions
539    pub fn resolved_count(&self) -> usize {
540        self.resolved.len()
541    }
542}
543
544// ============================================================================
545// Version Resolution Service
546// ============================================================================
547
548use super::types::ResolutionCore;
549use std::path::Path;
550
551/// Service for version resolution and worktree management.
552///
553/// Provides high-level orchestration for version constraint resolution,
554/// SHA resolution, and worktree preparation for Git-backed dependencies.
555pub struct VersionResolutionService {
556    /// Centralized version resolver for batch SHA resolution
557    version_resolver: VersionResolver,
558
559    /// Cache of prepared versions (source::version -> worktree info)
560    prepared_versions: HashMap<String, PreparedSourceVersion>,
561}
562
563impl VersionResolutionService {
564    /// Create a new version resolution service.
565    pub fn new(cache: crate::cache::Cache) -> Self {
566        Self {
567            version_resolver: VersionResolver::new(cache),
568            prepared_versions: HashMap::new(),
569        }
570    }
571
572    /// Pre-sync all source repositories needed for dependencies.
573    ///
574    /// This performs all Git network operations upfront:
575    /// 1. Clone/fetch source repositories
576    /// 2. Resolve version constraints to commit SHAs
577    /// 3. Create worktrees for resolved commits
578    ///
579    /// # Arguments
580    ///
581    /// * `core` - The resolution core with cache and source manager
582    /// * `deps` - All dependencies that need sources synced
583    pub async fn pre_sync_sources(
584        &mut self,
585        core: &ResolutionCore,
586        deps: &[(String, ResourceDependency)],
587    ) -> Result<()> {
588        // Clear and rebuild version resolver entries
589        self.version_resolver.clear();
590
591        // Collect all unique (source, version) pairs
592        for (_name, dep) in deps {
593            if let Some(source) = dep.get_source() {
594                let version = dep.get_version(); // None means HEAD
595
596                let source_url = core
597                    .source_manager
598                    .get_source_url(source)
599                    .ok_or_else(|| anyhow::anyhow!("Source '{}' not found", source))?;
600
601                // Add to version resolver for batch syncing (None -> "HEAD")
602                self.version_resolver.add_version(source, &source_url, version);
603            }
604        }
605
606        // Pre-sync all source repositories (clone/fetch)
607        self.version_resolver.pre_sync_sources().await?;
608
609        // Resolve all versions to SHAs in batch
610        self.version_resolver.resolve_all().await?;
611
612        // Handle local paths (non-Git sources) separately
613        // These don't go through version resolution but need to be in prepared_versions
614        for (_name, dep) in deps {
615            if let Some(source) = dep.get_source() {
616                let source_url = core
617                    .source_manager
618                    .get_source_url(source)
619                    .ok_or_else(|| anyhow::anyhow!("Source '{}' not found", source))?;
620
621                if crate::utils::is_local_path(&source_url) {
622                    let version_key = dep.get_version().unwrap_or("HEAD");
623                    let group_key = format!("{}::{}", source, version_key);
624
625                    // Add to prepared_versions with the local path
626                    self.prepared_versions.insert(
627                        group_key,
628                        PreparedSourceVersion {
629                            worktree_path: PathBuf::from(&source_url),
630                            resolved_version: Some("local".to_string()),
631                            resolved_commit: String::new(), // No commit for local sources
632                        },
633                    );
634                }
635            }
636        }
637
638        // Create worktrees for all resolved commits using WorktreeManager
639        let worktree_manager =
640            WorktreeManager::new(&core.cache, &core.source_manager, &self.version_resolver);
641        let prepared = worktree_manager.create_worktrees_for_resolved_versions().await?;
642
643        // Merge Git-backed worktrees with local paths
644        self.prepared_versions.extend(prepared);
645
646        Ok(())
647    }
648
649    /// Get a prepared version by source and version.
650    ///
651    /// # Arguments
652    ///
653    /// * `group_key` - The key in format "source::version"
654    ///
655    /// # Returns
656    ///
657    /// The prepared version info with worktree path and resolved commit
658    pub fn get_prepared_version(&self, group_key: &str) -> Option<&PreparedSourceVersion> {
659        self.prepared_versions.get(group_key)
660    }
661
662    /// Get the prepared versions map.
663    ///
664    /// Returns a reference to the HashMap of prepared source versions.
665    pub fn prepared_versions(&self) -> &HashMap<String, PreparedSourceVersion> {
666        &self.prepared_versions
667    }
668
669    /// Prepare an additional version on-demand without clearing existing ones.
670    ///
671    /// This is used for transitive dependencies discovered during resolution.
672    /// Unlike `pre_sync_sources`, this doesn't clear existing prepared versions.
673    ///
674    /// # Arguments
675    ///
676    /// * `core` - The resolution core with cache and source manager
677    /// * `source_name` - Name of the source repository
678    /// * `version` - Optional version constraint (None = HEAD)
679    pub async fn prepare_additional_version(
680        &mut self,
681        core: &ResolutionCore,
682        source_name: &str,
683        version: Option<&str>,
684    ) -> Result<()> {
685        let version_key = version.unwrap_or("HEAD");
686        let source_url = core
687            .source_manager
688            .get_source_url(source_name)
689            .ok_or_else(|| anyhow::anyhow!("Source '{}' not found", source_name))?;
690
691        // Handle local paths (non-Git sources) separately
692        if crate::utils::is_local_path(&source_url) {
693            let group_key = format!("{}::{}", source_name, version_key);
694            self.prepared_versions.insert(
695                group_key,
696                PreparedSourceVersion {
697                    worktree_path: PathBuf::from(&source_url),
698                    resolved_version: Some("local".to_string()),
699                    resolved_commit: String::new(),
700                },
701            );
702            return Ok(());
703        }
704
705        // For Git sources, proceed with version resolution
706        self.version_resolver.add_version(source_name, &source_url, version);
707
708        // Ensure the bare repository exists
709        if self.version_resolver.get_bare_repo_path(source_name).is_none() {
710            let repo_path =
711                core.cache.get_or_clone_source(source_name, &source_url, None).await.with_context(
712                    || format!("Failed to sync repository for source '{}'", source_name),
713                )?;
714            self.version_resolver.register_bare_repo(source_name.to_string(), repo_path);
715        }
716
717        // Resolve this specific version to SHA
718        self.version_resolver.resolve_all().await?;
719
720        // Get the resolved SHA and resolved reference
721        let resolved_version_data = self
722            .version_resolver
723            .get_all_resolved_full()
724            .get(&(source_name.to_string(), version_key.to_string()))
725            .ok_or_else(|| {
726                anyhow::anyhow!("Failed to resolve version for {} @ {}", source_name, version_key)
727            })?
728            .clone();
729
730        let sha = resolved_version_data.sha.clone();
731        let resolved_ref = resolved_version_data.resolved_ref.clone();
732
733        // Create worktree for this SHA
734        let worktree_path =
735            core.cache.get_or_create_worktree_for_sha(source_name, &source_url, &sha, None).await?;
736
737        // Cache the prepared version with the RESOLVED reference, not the constraint
738        let group_key = format!("{}::{}", source_name, version_key);
739        self.prepared_versions.insert(
740            group_key,
741            PreparedSourceVersion {
742                worktree_path,
743                resolved_version: Some(resolved_ref),
744                resolved_commit: sha,
745            },
746        );
747
748        Ok(())
749    }
750
751    /// Get available versions (tags/branches) for a repository.
752    ///
753    /// # Arguments
754    ///
755    /// * `core` - The resolution core with cache
756    /// * `repo_path` - Path to bare repository
757    ///
758    /// # Returns
759    ///
760    /// List of available version strings
761    pub async fn get_available_versions(
762        _core: &ResolutionCore,
763        repo_path: &Path,
764    ) -> Result<Vec<String>> {
765        let repo = GitRepo::new(repo_path);
766
767        // Get all tags
768        let tags = repo.list_tags().await.context("Failed to list tags")?;
769
770        // TODO: Add branches if needed in future
771        // For now, only use tags
772        let versions = tags;
773
774        Ok(versions)
775    }
776
777    /// Get the version resolver (for testing).
778    #[cfg(test)]
779    pub fn version_resolver(&self) -> &VersionResolver {
780        &self.version_resolver
781    }
782}
783
784// ============================================================================
785// Version Constraint Resolution Helpers
786// ============================================================================
787
788use crate::version::constraints::{ConstraintSet, VersionConstraint};
789use semver::Version;
790
791/// Checks if a string represents a version constraint rather than a direct reference.
792///
793/// Version constraints contain operators like `^`, `~`, `>`, `<`, `=`, or special
794/// keywords. Direct references are branch names, tag names, or commit hashes.
795/// This function now supports prefixed constraints like `agents-^v1.0.0`.
796///
797/// # Arguments
798///
799/// * `version` - The version string to check
800///
801/// # Returns
802///
803/// Returns `true` if the string contains constraint operators or keywords,
804/// `false` for plain tags, branches, or commit hashes.
805#[must_use]
806pub fn is_version_constraint(version: &str) -> bool {
807    // Extract prefix first, then check the version part for constraint indicators
808    let (_prefix, version_str) = crate::version::split_prefix_and_version(version);
809
810    // Check for wildcard (works with or without prefix)
811    if version_str == "*" {
812        return true;
813    }
814
815    // Check for version constraint operators in the version part
816    if version_str.starts_with('^')
817        || version_str.starts_with('~')
818        || version_str.starts_with('>')
819        || version_str.starts_with('<')
820        || version_str.starts_with('=')
821        || version_str.contains(',')
822    // Range constraints like ">=1.0.0, <2.0.0"
823    {
824        return true;
825    }
826
827    false
828}
829
830/// Parses Git tags into semantic versions, filtering out non-semver tags.
831///
832/// This function handles both prefixed and non-prefixed version tags,
833/// including support for monorepo-style prefixes like `agents-v1.0.0`.
834/// Tags that don't represent valid semantic versions are filtered out.
835#[must_use]
836pub fn parse_tags_to_versions(tags: Vec<String>) -> Vec<(String, Version)> {
837    let mut versions = Vec::new();
838
839    for tag in tags {
840        // Extract prefix and version part (handles both prefixed and unprefixed)
841        let (_prefix, version_str) = crate::version::split_prefix_and_version(&tag);
842
843        // Strip 'v' prefix from version part
844        let cleaned = version_str.trim_start_matches('v').trim_start_matches('V');
845
846        if let Ok(version) = Version::parse(cleaned) {
847            versions.push((tag, version));
848        }
849    }
850
851    // Sort by version, highest first
852    versions.sort_by(|a, b| b.1.cmp(&a.1));
853
854    versions
855}
856
857/// Finds the best matching tag for a version constraint.
858///
859/// This function resolves version constraints to actual Git tags by:
860/// 1. Extracting the prefix from the constraint (if any)
861/// 2. Filtering tags to only those with matching prefix
862/// 3. Parsing the constraint and matching tags
863/// 4. Selecting the best match (usually the highest compatible version)
864pub fn find_best_matching_tag(constraint_str: &str, tags: Vec<String>) -> Result<String> {
865    // Extract prefix from constraint
866    let (constraint_prefix, version_str) = crate::version::split_prefix_and_version(constraint_str);
867
868    // Filter tags by prefix first
869    let filtered_tags: Vec<String> = tags
870        .into_iter()
871        .filter(|tag| {
872            let (tag_prefix, _) = crate::version::split_prefix_and_version(tag);
873            tag_prefix.as_ref() == constraint_prefix.as_ref()
874        })
875        .collect();
876
877    if filtered_tags.is_empty() {
878        return Err(anyhow::anyhow!(
879            "No tags found with matching prefix for constraint: {constraint_str}"
880        ));
881    }
882
883    // Parse filtered tags to versions
884    let tag_versions = parse_tags_to_versions(filtered_tags);
885
886    if tag_versions.is_empty() {
887        return Err(anyhow::anyhow!(
888            "No valid semantic version tags found for constraint: {constraint_str}"
889        ));
890    }
891
892    // Special case: wildcard (*) matches the highest available version
893    if version_str == "*" {
894        // tag_versions is already sorted highest first
895        return Ok(tag_versions[0].0.clone());
896    }
897
898    // Parse constraint using ONLY the version part (prefix already filtered)
899    // This ensures semver matching works correctly after prefix filtering
900    let constraint = VersionConstraint::parse(version_str)?;
901
902    // Extract just the versions for constraint matching
903    let versions: Vec<Version> = tag_versions.iter().map(|(_, v)| v.clone()).collect();
904
905    // Create a constraint set with just this constraint
906    let mut constraint_set = ConstraintSet::new();
907    constraint_set.add(constraint)?;
908
909    // Find the best match
910    if let Some(best_version) = constraint_set.find_best_match(&versions) {
911        // Find the original tag name for this version
912        for (tag_name, version) in tag_versions {
913            if &version == best_version {
914                return Ok(tag_name);
915            }
916        }
917    }
918
919    Err(anyhow::anyhow!("No tag found matching constraint: {constraint_str}"))
920}
921
922// ============================================================================
923// Worktree Management
924// ============================================================================
925
926/// Represents a prepared source version with worktree information.
927#[derive(Clone, Debug, Default)]
928pub struct PreparedSourceVersion {
929    /// Path to the worktree for this version
930    pub worktree_path: std::path::PathBuf,
931    /// The resolved version reference (tag, branch, etc.)
932    pub resolved_version: Option<String>,
933    /// The commit SHA for this version
934    pub resolved_commit: String,
935}
936
937/// Manages worktree creation for resolved dependency versions.
938pub struct WorktreeManager<'a> {
939    cache: &'a Cache,
940    source_manager: &'a SourceManager,
941    version_resolver: &'a VersionResolver,
942}
943
944impl<'a> WorktreeManager<'a> {
945    /// Create a new worktree manager.
946    pub fn new(
947        cache: &'a Cache,
948        source_manager: &'a SourceManager,
949        version_resolver: &'a VersionResolver,
950    ) -> Self {
951        Self {
952            cache,
953            source_manager,
954            version_resolver,
955        }
956    }
957
958    /// Create a group key for identifying source-version combinations.
959    pub fn group_key(source: &str, version: &str) -> String {
960        format!("{source}::{version}")
961    }
962
963    /// Create worktrees for all resolved versions in parallel.
964    ///
965    /// This function takes the resolved versions from the VersionResolver
966    /// and creates Git worktrees for each unique commit SHA, enabling
967    /// efficient parallel access to dependency resources.
968    ///
969    /// # Returns
970    ///
971    /// A map of group keys to prepared source versions containing worktree paths.
972    pub async fn create_worktrees_for_resolved_versions(
973        &self,
974    ) -> Result<HashMap<String, PreparedSourceVersion>> {
975        use crate::core::AgpmError;
976        use futures::future::join_all;
977
978        let resolved_full = self.version_resolver.get_all_resolved_full().clone();
979        let mut prepared_versions = HashMap::new();
980
981        // Build futures for parallel worktree creation
982        let mut futures = Vec::new();
983
984        for ((source_name, version_key), resolved_version) in resolved_full {
985            let sha = resolved_version.sha;
986            let resolved_ref = resolved_version.resolved_ref;
987            let repo_key = Self::group_key(&source_name, &version_key);
988            let cache_clone = self.cache.clone();
989            let source_name_clone = source_name.clone();
990
991            // Get the source URL for this source
992            let source_url_clone = self
993                .source_manager
994                .get_source_url(&source_name)
995                .ok_or_else(|| AgpmError::SourceNotFound {
996                    name: source_name.to_string(),
997                })?
998                .to_string();
999
1000            let sha_clone = sha.clone();
1001            let resolved_ref_clone = resolved_ref.clone();
1002
1003            let future = async move {
1004                // Use SHA-based worktree creation
1005                // The version resolver has already handled fetching and SHA resolution
1006                let worktree_path = cache_clone
1007                    .get_or_create_worktree_for_sha(
1008                        &source_name_clone,
1009                        &source_url_clone,
1010                        &sha_clone,
1011                        Some(&source_name_clone), // context for logging
1012                    )
1013                    .await?;
1014
1015                Ok::<_, anyhow::Error>((
1016                    repo_key,
1017                    PreparedSourceVersion {
1018                        worktree_path,
1019                        resolved_version: Some(resolved_ref_clone),
1020                        resolved_commit: sha_clone,
1021                    },
1022                ))
1023            };
1024
1025            futures.push(future);
1026        }
1027
1028        // Execute all futures concurrently and collect results
1029        let results = join_all(futures).await;
1030
1031        // Process results and build the map
1032        for result in results {
1033            let (key, prepared) = result?;
1034            prepared_versions.insert(key, prepared);
1035        }
1036
1037        Ok(prepared_versions)
1038    }
1039}
1040
1041#[cfg(test)]
1042mod tests {
1043    use super::*;
1044    use tempfile::TempDir;
1045
1046    #[tokio::test]
1047    async fn test_version_resolver_deduplication() {
1048        let temp_dir = TempDir::new().unwrap();
1049        let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
1050        let mut resolver = VersionResolver::new(cache);
1051
1052        // Add same version multiple times
1053        resolver.add_version("source1", "https://example.com/repo.git", Some("v1.0.0"));
1054        resolver.add_version("source1", "https://example.com/repo.git", Some("v1.0.0"));
1055        resolver.add_version("source1", "https://example.com/repo.git", Some("v1.0.0"));
1056
1057        // Should only have one entry
1058        assert_eq!(resolver.pending_count(), 1);
1059    }
1060
1061    #[tokio::test]
1062    async fn test_sha_optimization() {
1063        let temp_dir = TempDir::new().unwrap();
1064        let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
1065        let _resolver = VersionResolver::new(cache);
1066
1067        // Test that full SHA is recognized
1068        let full_sha = "a".repeat(40);
1069        assert_eq!(full_sha.len(), 40);
1070        assert!(full_sha.chars().all(|c| c.is_ascii_hexdigit()));
1071    }
1072
1073    #[tokio::test]
1074    async fn test_resolved_retrieval() {
1075        let temp_dir = TempDir::new().unwrap();
1076        let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
1077        let mut resolver = VersionResolver::new(cache);
1078
1079        // Manually insert a resolved SHA for testing
1080        let key = ("test_source".to_string(), "v1.0.0".to_string());
1081        let sha = "1234567890abcdef1234567890abcdef12345678";
1082        resolver.resolved.insert(
1083            key,
1084            ResolvedVersion {
1085                sha: sha.to_string(),
1086                resolved_ref: "v1.0.0".to_string(),
1087            },
1088        );
1089
1090        // Verify retrieval
1091        assert!(resolver.is_resolved("test_source", "v1.0.0"));
1092        assert_eq!(resolver.get_resolved_sha("test_source", "v1.0.0"), Some(sha.to_string()));
1093        assert!(!resolver.is_resolved("test_source", "v2.0.0"));
1094    }
1095
1096    #[tokio::test]
1097    async fn test_worktree_group_key() {
1098        assert_eq!(WorktreeManager::group_key("source", "version"), "source::version");
1099        assert_eq!(WorktreeManager::group_key("community", "v1.0.0"), "community::v1.0.0");
1100    }
1101}