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 dashmap::DashMap;
22use futures::stream::{self, StreamExt};
23use std::collections::HashMap;
24use std::path::PathBuf;
25use std::sync::Arc;
26
27use super::types::ResolutionMode;
28use crate::cache::Cache;
29use crate::git::GitRepo;
30use crate::manifest::ResourceDependency;
31use crate::source::SourceManager;
32
33/// Version resolution entry tracking source and version to SHA mapping
34#[derive(Debug, Clone)]
35pub struct VersionEntry {
36    /// Source name from manifest
37    pub source: String,
38    /// Source URL (Git repository)
39    pub url: String,
40    /// Version specification (tag, branch, commit, or None for HEAD)
41    pub version: Option<String>,
42    /// Resolved SHA-1 hash (populated during resolution)
43    pub resolved_sha: Option<String>,
44    /// Resolved version (e.g., "latest" -> "v2.0.0")
45    pub resolved_version: Option<String>,
46    /// Resolution mode used for this entry
47    pub resolution_mode: ResolutionMode,
48}
49
50impl VersionEntry {
51    /// Format the version entry for display in progress UI.
52    ///
53    /// Formats as: `source@version` or `source@HEAD` if no version specified.
54    ///
55    /// # Examples
56    ///
57    /// ```no_run
58    /// # use agpm_cli::resolver::version_resolver::VersionEntry;
59    /// # use agpm_cli::resolver::types::ResolutionMode;
60    /// let entry = VersionEntry {
61    ///     source: "community".to_string(),
62    ///     url: "https://github.com/example/repo.git".to_string(),
63    ///     version: Some("v1.0.0".to_string()),
64    ///     resolved_sha: None,
65    ///     resolved_version: None,
66    ///     resolution_mode: ResolutionMode::Version,
67    /// };
68    /// assert_eq!(entry.format_display(), "community@v1.0.0");
69    /// ```
70    pub fn format_display(&self) -> String {
71        let version = self.version.as_deref().unwrap_or("HEAD");
72        format!("{}@{}", self.source, version)
73    }
74
75    /// Create a unique key for tracking this entry in the progress window.
76    ///
77    /// Uses source and version to create a unique identifier.
78    pub fn unique_key(&self) -> String {
79        let version = self.version.as_deref().unwrap_or("HEAD");
80        format!("{}:{}", self.source, version)
81    }
82}
83
84/// Centralized version resolver for efficient SHA resolution
85///
86/// The `VersionResolver` is responsible for resolving all dependency versions
87/// to their corresponding Git commit SHAs before any worktree operations.
88/// This ensures maximum efficiency and deduplication.
89///
90/// # Example
91///
92/// ```no_run
93/// # use agpm_cli::resolver::version_resolver::{VersionResolver, VersionEntry};
94/// # use agpm_cli::resolver::types::ResolutionMode;
95/// # use agpm_cli::cache::Cache;
96/// # async fn example() -> anyhow::Result<()> {
97/// let cache = Cache::new()?;
98/// let mut resolver = VersionResolver::new(cache);
99///
100/// // Add versions to resolve
101/// resolver.add_version("community", "https://github.com/example/repo.git", Some("v1.0.0"), ResolutionMode::Version);
102/// resolver.add_version("community", "https://github.com/example/repo.git", Some("main"), ResolutionMode::GitRef);
103///
104/// // Batch resolve all versions to SHAs
105/// resolver.resolve_all(None).await?;
106///
107/// // Get resolved SHA for a specific version
108/// let sha = resolver.get_resolved_sha("community", "v1.0.0");
109/// # Ok(())
110/// # }
111/// ```
112/// Resolved version information
113#[derive(Debug, Clone)]
114pub struct ResolvedVersion {
115    /// The resolved SHA-1 hash
116    pub sha: String,
117    /// The resolved version (e.g., "latest" -> "v2.0.0")
118    /// If no constraint resolution happened, this will be the same as input
119    pub resolved_ref: String,
120}
121
122/// Centralized version resolver for batch SHA resolution.
123///
124/// The `VersionResolver` manages the collection and resolution of all dependency
125/// versions in a single batch operation, enabling optimal Git repository access
126/// patterns and maximum worktree reuse.
127pub struct VersionResolver {
128    /// Cache instance for repository access
129    cache: Cache,
130    /// Collection of versions to resolve, keyed by (source, version)
131    entries: Arc<DashMap<(String, String), VersionEntry>>,
132    /// Resolved SHA cache, keyed by (source, version)
133    resolved: Arc<DashMap<(String, String), ResolvedVersion>>,
134    /// Bare repository paths, keyed by source name
135    bare_repos: Arc<DashMap<String, PathBuf>>,
136    /// Maximum concurrency for parallel version resolution
137    max_concurrency: usize,
138}
139
140impl VersionResolver {
141    /// Creates a new version resolver with the given cache and default concurrency
142    ///
143    /// Uses the same default concurrency as installation: max(10, 2 × CPU cores)
144    pub fn new(cache: Cache) -> Self {
145        let cores = std::thread::available_parallelism().map(std::num::NonZero::get).unwrap_or(4);
146        let default_concurrency = std::cmp::max(10, cores * 2);
147
148        Self {
149            cache,
150            entries: Arc::new(DashMap::new()),
151            resolved: Arc::new(DashMap::new()),
152            bare_repos: Arc::new(DashMap::new()),
153            max_concurrency: default_concurrency,
154        }
155    }
156
157    /// Creates a new version resolver with explicit concurrency limit
158    pub fn with_concurrency(cache: Cache, max_concurrency: usize) -> Self {
159        Self {
160            cache,
161            entries: Arc::new(DashMap::new()),
162            resolved: Arc::new(DashMap::new()),
163            bare_repos: Arc::new(DashMap::new()),
164            max_concurrency,
165        }
166    }
167
168    /// Adds a version to be resolved
169    ///
170    /// Multiple calls with the same (source, version) pair will be deduplicated.
171    ///
172    /// # Arguments
173    ///
174    /// * `source` - Source name from manifest
175    /// * `url` - Git repository URL
176    /// * `version` - Version specification (tag, branch, commit, or None for HEAD)
177    /// * `resolution_mode` - The resolution mode to use for this entry
178    pub fn add_version(
179        &self,
180        source: &str,
181        url: &str,
182        version: Option<&str>,
183        resolution_mode: ResolutionMode,
184    ) {
185        let version_key = version.unwrap_or("HEAD").to_string();
186        let key = (source.to_string(), version_key);
187
188        // Only add if not already present (deduplication)
189        self.entries.entry(key).or_insert_with(|| VersionEntry {
190            source: source.to_string(),
191            url: url.to_string(),
192            version: version.map(std::string::ToString::to_string),
193            resolved_sha: None,
194            resolved_version: None,
195            resolution_mode,
196        });
197    }
198
199    /// Resolves all collected versions to their commit SHAs using cached repositories.
200    ///
201    /// This is the second phase of AGPM's two-phase resolution architecture. Call after `pre_sync_sources()`.
202    /// See documentation for detailed resolution process and performance characteristics.
203    ///
204    /// # Prerequisites
205    ///
206    /// **CRITICAL**: `pre_sync_sources()` must be called first to populate the cache.
207    ///
208    /// # Performance
209    ///
210    /// Uses batch `git rev-parse --stdin` to resolve multiple refs in a single process,
211    /// reducing process spawn overhead from O(n) to O(1) per source. This is especially
212    /// impactful on Windows where process spawning is expensive.
213    ///
214    /// # Example
215    ///
216    /// ```no_run
217    /// # use agpm_cli::resolver::version_resolver::VersionResolver;
218    /// # use agpm_cli::resolver::types::ResolutionMode;
219    /// # use agpm_cli::cache::Cache;
220    /// # async fn example() -> anyhow::Result<()> {
221    /// let cache = Cache::new()?;
222    /// let mut resolver = VersionResolver::new(cache);
223    /// resolver.add_version("source", "https://github.com/org/repo.git", Some("v1.2.3"), ResolutionMode::Version);
224    ///
225    /// resolver.pre_sync_sources(None).await?;  // Pass None for no progress tracking
226    /// resolver.resolve_all(None).await?;  // Pass None for no progress tracking
227    /// # Ok(())
228    /// # }
229    /// ```
230    ///
231    /// # Errors
232    ///
233    /// Returns an error if:
234    /// - Repository not pre-synced (call `pre_sync_sources()` first)
235    /// - Version/tag/branch not found or constraint unsatisfied
236    /// - Git operations fail or repository inaccessible
237    pub async fn resolve_all(
238        &self,
239        progress: Option<std::sync::Arc<crate::utils::MultiPhaseProgress>>,
240    ) -> Result<()> {
241        // Group entries by source for efficient processing
242        let mut by_source: HashMap<String, Vec<(String, VersionEntry)>> = HashMap::new();
243
244        for entry_ref in self.entries.iter() {
245            let (key, entry) = entry_ref.pair();
246            by_source.entry(entry.source.clone()).or_default().push((key.1.clone(), entry.clone()));
247        }
248
249        // Calculate total versions to resolve for progress tracking
250        let total_versions: usize = by_source.values().map(|v| v.len()).sum();
251
252        // Thread-safe counter for completed versions
253        let completed_counter = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
254
255        // Process each source with batch resolution
256        for (source, versions) in by_source {
257            // Repository must have been pre-synced
258            let repo_path = self
259                .bare_repos
260                .get(&source)
261                .ok_or_else(|| {
262                    anyhow::anyhow!("Repository for source '{source}' was not pre-synced. Call pre_sync_sources() first.")
263                })?
264                .clone();
265
266            let repo = GitRepo::new(&repo_path);
267
268            // Pre-fetch tags once per source (cached in GitRepo)
269            // Always fetch tags - they're needed for both constraint resolution and ref type detection
270            let tags_cache = if versions.iter().any(|(_, e)| !crate::utils::is_local_path(&e.url)) {
271                repo.list_tags().await.ok()
272            } else {
273                None
274            };
275
276            // === PHASE 1: Resolve version constraints and determine refs ===
277            // This phase processes each version entry to determine the final ref to resolve,
278            // handling version constraints (e.g., ^1.0.0) and determining tag vs branch.
279            let mut version_to_ref: Vec<(String, VersionEntry, String)> = Vec::new();
280            let mut branch_checks_needed: Vec<(String, String)> = Vec::new(); // (origin_ref, version_str)
281
282            for (version_str, entry) in &versions {
283                // Mark as active in progress window
284                if let Some(ref pm) = progress {
285                    let display = entry.format_display();
286                    let key = entry.unique_key();
287                    pm.mark_item_active(&display, &key);
288                }
289
290                let is_local = crate::utils::is_local_path(&entry.url);
291
292                if is_local {
293                    // Local directories don't need SHA resolution
294                    version_to_ref.push((version_str.clone(), entry.clone(), "local".to_string()));
295                    continue;
296                }
297
298                // Determine the resolved ref for this version
299                let resolved_ref = if let Some(ref version) = entry.version {
300                    if is_version_constraint(version) {
301                        // Resolve version constraint to best matching tag
302                        let tags = tags_cache.as_ref().ok_or_else(|| {
303                            anyhow::anyhow!(
304                                "Tags should have been pre-fetched for constraint '{version}'"
305                            )
306                        })?;
307
308                        find_best_matching_tag(version, tags.clone())
309                            .with_context(|| format!("Failed to resolve version constraint '{version}' for source '{source}'"))?
310                    } else {
311                        // Not a constraint, use as-is but determine if it's tag or branch
312                        version.clone()
313                    }
314                } else {
315                    // No version specified, use default branch
316                    repo.get_default_branch().await.unwrap_or_else(|_| "main".to_string())
317                };
318
319                // Determine what ref to actually resolve
320                let ref_result = determine_ref_to_resolve(&resolved_ref, tags_cache.as_ref());
321
322                match ref_result {
323                    RefResolutionResult::DirectSha(sha) => {
324                        // Already a SHA, store directly
325                        let key = (source.clone(), version_str.clone());
326                        self.resolved.insert(
327                            key,
328                            ResolvedVersion {
329                                sha: sha.clone(),
330                                resolved_ref: resolved_ref.clone(),
331                            },
332                        );
333                        // Mark complete
334                        if let Some(ref pm) = progress {
335                            let completed = completed_counter
336                                .fetch_add(1, std::sync::atomic::Ordering::SeqCst)
337                                + 1;
338                            pm.mark_item_complete(
339                                &entry.unique_key(),
340                                Some(&entry.format_display()),
341                                completed,
342                                total_versions,
343                                "Resolving dependencies",
344                            );
345                        }
346                    }
347                    RefResolutionResult::DirectRef(ref_name) => {
348                        // Use this ref directly for batch resolution
349                        version_to_ref.push((version_str.clone(), entry.clone(), ref_name));
350                    }
351                    RefResolutionResult::NeedsBranchCheck {
352                        origin_ref,
353                    } => {
354                        // Need to check if origin/branch exists
355                        branch_checks_needed.push((origin_ref.clone(), version_str.clone()));
356                        version_to_ref.push((version_str.clone(), entry.clone(), origin_ref));
357                    }
358                }
359            }
360
361            // === PHASE 2: Batch check origin/branch refs ===
362            // For refs that might be branches, check if origin/branch exists
363            if !branch_checks_needed.is_empty() {
364                let refs_to_check: Vec<&str> = branch_checks_needed
365                    .iter()
366                    .map(|(origin_ref, _)| origin_ref.as_str())
367                    .collect();
368
369                let origin_exists = repo.resolve_refs_batch(&refs_to_check).await?;
370
371                // Update version_to_ref based on batch check results
372                for (version_str, entry, ref_name) in version_to_ref.iter_mut() {
373                    if ref_name.starts_with("origin/") {
374                        let branch = ref_name.strip_prefix("origin/").unwrap();
375                        // Check if origin/branch resolved successfully
376                        if origin_exists.get(ref_name.as_str()).and_then(|v| v.as_ref()).is_none() {
377                            // origin/branch doesn't exist, fall back to plain branch name
378                            *ref_name = branch.to_string();
379                        }
380                        // If it does exist, keep origin/branch as the ref
381                        let _ = (version_str, entry); // suppress warnings
382                    }
383                }
384            }
385
386            // === PHASE 3: Batch resolve all refs to SHAs ===
387            // Collect all refs that need resolution (skip local)
388            let refs_to_resolve: Vec<&str> = version_to_ref
389                .iter()
390                .filter(|(_, _, ref_name)| ref_name != "local")
391                .map(|(_, _, ref_name)| ref_name.as_str())
392                .collect();
393
394            let sha_results = if !refs_to_resolve.is_empty() {
395                repo.resolve_refs_batch(&refs_to_resolve).await?
396            } else {
397                HashMap::new()
398            };
399
400            // === PHASE 4: Store results ===
401            for (version_str, entry, ref_name) in version_to_ref {
402                if ref_name == "local" {
403                    // Local sources don't get stored in resolved map
404                    if let Some(ref pm) = progress {
405                        let completed =
406                            completed_counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1;
407                        pm.mark_item_complete(
408                            &entry.unique_key(),
409                            Some(&entry.format_display()),
410                            completed,
411                            total_versions,
412                            "Resolving dependencies",
413                        );
414                    }
415                    continue;
416                }
417
418                let sha = sha_results.get(&ref_name).and_then(|v| v.clone());
419
420                if let Some(sha_value) = sha {
421                    tracing::debug!(
422                        "RESOLVE: source='{}' version='{}' ref='{}' -> SHA={}",
423                        source,
424                        version_str,
425                        ref_name,
426                        &sha_value[..8.min(sha_value.len())]
427                    );
428
429                    let key = (source.clone(), version_str);
430                    self.resolved.insert(
431                        key,
432                        ResolvedVersion {
433                            sha: sha_value,
434                            resolved_ref: ref_name,
435                        },
436                    );
437                } else {
438                    return Err(anyhow::anyhow!(
439                        "Failed to resolve version '{}' (ref '{}') for source '{}'",
440                        version_str,
441                        ref_name,
442                        source
443                    ));
444                }
445
446                // Mark complete
447                if let Some(ref pm) = progress {
448                    let completed =
449                        completed_counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1;
450                    pm.mark_item_complete(
451                        &entry.unique_key(),
452                        Some(&entry.format_display()),
453                        completed,
454                        total_versions,
455                        "Resolving dependencies",
456                    );
457                }
458            }
459        }
460
461        Ok(())
462    }
463
464    /// Resolves a single version to SHA without affecting the batch
465    ///
466    /// This is useful for incremental resolution or testing.
467    pub async fn resolve_single(
468        &self,
469        source: &str,
470        url: &str,
471        version: Option<&str>,
472    ) -> Result<String> {
473        // Get or clone the repository
474        let repo_path = self
475            .cache
476            .get_or_clone_source(source, url, None)
477            .await
478            .with_context(|| format!("Failed to prepare repository for source '{source}'"))?;
479
480        let repo = GitRepo::new(&repo_path);
481
482        // Resolve the version to SHA
483        let sha = repo.resolve_to_sha(version).await.with_context(|| {
484            format!(
485                "Failed to resolve version '{}' for source '{}'",
486                version.unwrap_or("HEAD"),
487                source
488            )
489        })?;
490
491        // Determine the resolved reference name
492        let resolved_ref = if let Some(v) = version {
493            v.to_string()
494        } else {
495            // When no version is specified, resolve HEAD to the actual branch name
496            repo.get_default_branch().await.unwrap_or_else(|_| "main".to_string())
497        };
498
499        // Cache the result
500        let version_key = version.unwrap_or("HEAD").to_string();
501        let key = (source.to_string(), version_key);
502        self.resolved.insert(
503            key,
504            ResolvedVersion {
505                sha: sha.clone(),
506                resolved_ref,
507            },
508        );
509
510        Ok(sha)
511    }
512
513    /// Gets the resolved SHA for a given source and version
514    ///
515    /// Returns None if the version hasn't been resolved yet.
516    ///
517    /// # Arguments
518    ///
519    /// * `source` - Source name
520    /// * `version` - Version specification (use "HEAD" for None)
521    pub fn get_resolved_sha(&self, source: &str, version: &str) -> Option<String> {
522        let key = (source.to_string(), version.to_string());
523        self.resolved.get(&key).map(|rv| rv.sha.clone())
524    }
525
526    /// Gets all resolved SHAs as a `HashMap`
527    ///
528    /// Useful for bulk operations or debugging.
529    pub fn get_all_resolved(&self) -> HashMap<(String, String), String> {
530        self.resolved.iter().map(|entry| (entry.key().clone(), entry.value().sha.clone())).collect()
531    }
532
533    /// Gets all resolved versions with both SHA and resolved reference
534    ///
535    /// Returns a `HashMap` with (source, version) -> `ResolvedVersion`
536    pub fn get_all_resolved_full(&self) -> HashMap<(String, String), ResolvedVersion> {
537        self.resolved.iter().map(|entry| (entry.key().clone(), entry.value().clone())).collect()
538    }
539
540    /// Checks if a specific version has been resolved
541    pub fn is_resolved(&self, source: &str, version: &str) -> bool {
542        let key = (source.to_string(), version.to_string());
543        self.resolved.contains_key(&key)
544    }
545
546    /// Pre-syncs all unique sources to ensure repositories are cloned/fetched.
547    ///
548    /// This is the first phase of AGPM's two-phase resolution architecture. Performs all
549    /// Git network operations upfront before `resolve_all()`. Automatically deduplicates
550    /// by source URL for efficiency.
551    ///
552    /// # Prerequisites
553    ///
554    /// Call this method after adding versions via `add_version()` calls.
555    ///
556    /// # Example
557    ///
558    /// ```no_run
559    /// use agpm_cli::resolver::version_resolver::VersionResolver;
560    /// use agpm_cli::resolver::types::ResolutionMode;
561    /// use agpm_cli::cache::Cache;
562    ///
563    /// # async fn example() -> anyhow::Result<()> {
564    /// let cache = Cache::new()?;
565    /// let mut resolver = VersionResolver::new(cache);
566    /// resolver.add_version("source", "https://github.com/org/repo.git", Some("v1.0.0"), ResolutionMode::Version);
567    ///
568    /// // Phase 1: Sync repositories (parallel network operations with progress)
569    /// resolver.pre_sync_sources(None).await?;  // Pass None for no progress tracking
570    ///
571    /// // Phase 2: Resolve versions to SHAs (local operations)
572    /// resolver.resolve_all(None).await?;  // Pass None for no progress tracking
573    /// # Ok(())
574    /// # }
575    /// ```
576    ///
577    /// # Arguments
578    ///
579    /// * `progress` - Optional progress tracker. Pass `None` to disable progress tracking.
580    ///   When provided, displays real-time sync status with windowed updates showing which
581    ///   sources are being synced. The progress tracker automatically calculates window size
582    ///   based on the number of concurrent operations.
583    ///
584    /// # Errors
585    ///
586    /// Returns an error if:
587    /// - Repository cloning or fetching fails (network, auth, invalid URL)
588    /// - Authentication fails for private repositories
589    /// - Insufficient disk space or repository corruption
590    pub async fn pre_sync_sources(
591        &self,
592        progress: Option<std::sync::Arc<crate::utils::MultiPhaseProgress>>,
593    ) -> Result<()> {
594        // Group entries by source to get unique sources
595        let mut unique_sources: HashMap<String, String> = HashMap::new();
596
597        for entry_ref in self.entries.iter() {
598            let entry = entry_ref.value();
599            unique_sources.insert(entry.source.clone(), entry.url.clone());
600        }
601
602        let total = unique_sources.len();
603        if total == 0 {
604            return Ok(());
605        }
606
607        // Calculate effective concurrency
608        let concurrency = std::cmp::min(self.max_concurrency, total);
609
610        // Start windowed progress tracking if enabled
611        if let Some(ref pm) = progress {
612            let window_size =
613                crate::utils::progress::MultiPhaseProgress::calculate_window_size(concurrency);
614            pm.start_phase_with_active_tracking(
615                crate::utils::progress::InstallationPhase::SyncingSources,
616                total,
617                window_size,
618            );
619        }
620
621        // Atomic counter for progress tracking
622        let completed = std::sync::atomic::AtomicUsize::new(0);
623
624        // Parallel sync of all unique sources
625        let results: Vec<Result<(String, PathBuf), anyhow::Error>> = stream::iter(unique_sources)
626            .map(|(source, url)| {
627                let cache = self.cache.clone();
628                let progress_clone = progress.clone();
629                let completed_ref = &completed;
630                let total_count = total;
631                // Format display name with URL for better visibility
632                let display_name = format_source_display(&source, &url);
633                async move {
634                    // Mark as active in progress window
635                    if let Some(ref pm) = progress_clone {
636                        pm.mark_item_active(&display_name, &source);
637                    }
638
639                    // Clone or update the repository (this does the actual Git operations)
640                    let repo_path =
641                        cache.get_or_clone_source(&source, &url, None).await.with_context(
642                            || format!("Failed to sync repository for source '{source}'"),
643                        )?;
644
645                    // Mark complete in progress window
646                    if let Some(ref pm) = progress_clone {
647                        let done =
648                            completed_ref.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1;
649                        pm.mark_item_complete(
650                            &source,
651                            Some(&display_name),
652                            done,
653                            total_count,
654                            "Syncing sources",
655                        );
656                    }
657
658                    Ok((source, repo_path))
659                }
660            })
661            .buffer_unordered(concurrency)
662            .collect()
663            .await;
664
665        // Complete progress tracking
666        if let Some(ref pm) = progress {
667            pm.complete_phase_with_window(Some("Sources synced"));
668        }
669
670        // Process results - collect all errors or populate bare_repos
671        let mut errors = Vec::new();
672        for result in results {
673            match result {
674                Ok((source, repo_path)) => {
675                    self.bare_repos.insert(source, repo_path);
676                }
677                Err(e) => {
678                    errors.push(e);
679                }
680            }
681        }
682
683        // Report all errors if any occurred
684        if !errors.is_empty() {
685            if errors.len() == 1 {
686                // Safe: errors.len() == 1 guarantees next() returns Some
687                return Err(errors.into_iter().next().unwrap());
688            }
689
690            // Aggregate multiple errors for better diagnostics
691            let error_messages: Vec<String> = errors.iter().map(|e| format!("  - {e}")).collect();
692
693            return Err(anyhow::anyhow!(
694                "Failed to sync {} sources:\n{}",
695                errors.len(),
696                error_messages.join("\n")
697            ));
698        }
699
700        Ok(())
701    }
702
703    /// Gets the bare repository path for a source
704    ///
705    /// Returns None if the source hasn't been processed yet.
706    pub fn get_bare_repo_path(&self, source: &str) -> Option<PathBuf> {
707        self.bare_repos.get(source).map(|entry| entry.value().clone())
708    }
709
710    /// Registers a bare repository path for a source
711    ///
712    /// This is used when manually ensuring a repository exists without clearing all state.
713    pub fn register_bare_repo(&self, source: String, repo_path: PathBuf) {
714        self.bare_repos.insert(source, repo_path);
715    }
716
717    /// Clears all resolved versions and cached data
718    ///
719    /// Useful for testing or when starting a fresh resolution.
720    pub fn clear(&self) {
721        self.entries.clear();
722        self.resolved.clear();
723        self.bare_repos.clear();
724    }
725
726    /// Returns the number of unique versions to resolve
727    pub fn pending_count(&self) -> usize {
728        self.entries.len()
729    }
730
731    /// Checks if the resolver has any entries to resolve.
732    ///
733    /// This is a convenience method to determine if the resolver has been populated
734    /// with version entries via `add_version()` calls. It's useful for conditional
735    /// logic to avoid unnecessary operations when no versions need resolution.
736    ///
737    /// # Returns
738    ///
739    /// Returns `true` if there are entries that need resolution, `false` if the
740    /// resolver is empty.
741    ///
742    /// # Example
743    ///
744    /// ```no_run
745    /// # use agpm_cli::resolver::version_resolver::VersionResolver;
746    /// # use agpm_cli::cache::Cache;
747    /// # use agpm_cli::resolver::types::ResolutionMode;
748    /// # let cache = Cache::new().unwrap();
749    /// let mut resolver = VersionResolver::new(cache);
750    /// assert!(!resolver.has_entries()); // Initially empty
751    ///
752    /// resolver.add_version("source", "https://github.com/org/repo.git", Some("v1.0.0"), ResolutionMode::Version);
753    /// assert!(resolver.has_entries()); // Now has entries
754    /// ```
755    pub fn has_entries(&self) -> bool {
756        !self.entries.is_empty()
757    }
758
759    /// Returns the number of successfully resolved versions
760    pub fn resolved_count(&self) -> usize {
761        self.resolved.len()
762    }
763}
764
765// ============================================================================
766// Version Resolution Service
767// ============================================================================
768
769use super::types::ResolutionCore;
770use std::path::Path;
771
772/// Service for version resolution and worktree management.
773///
774/// Provides high-level orchestration for version constraint resolution,
775/// SHA resolution, and worktree preparation for Git-backed dependencies.
776pub struct VersionResolutionService {
777    /// Centralized version resolver for batch SHA resolution
778    version_resolver: VersionResolver,
779
780    /// Cache of prepared versions (source::version -> state)
781    /// Uses DashMap with PreparedVersionState for safe concurrent preparation.
782    /// Multiple callers requesting the same version coordinate via Preparing/Ready states.
783    prepared_versions: std::sync::Arc<dashmap::DashMap<String, PreparedVersionState>>,
784}
785
786impl VersionResolutionService {
787    /// Determine resolution mode from a version string.
788    ///
789    /// This is a fallback for cases where we don't have a ResourceDependency.
790    /// If the version string looks like a semver constraint, use Version mode.
791    /// Otherwise, assume GitRef mode.
792    fn resolution_mode_from_version(version: Option<&str>) -> ResolutionMode {
793        match version {
794            Some(v) => {
795                // Check if it looks like a semver constraint
796                if v.starts_with('^')
797                    || v.starts_with('~')
798                    || v.starts_with('>')
799                    || v.starts_with('<')
800                    || v.starts_with('=')
801                    || v.starts_with('v')
802                    || v == "latest"
803                {
804                    ResolutionMode::Version
805                } else {
806                    // Assume it's a branch name or commit SHA
807                    ResolutionMode::GitRef
808                }
809            }
810            None => ResolutionMode::Version, // Default to Version for HEAD
811        }
812    }
813
814    /// Create a new version resolution service with default concurrency.
815    pub fn new(cache: crate::cache::Cache) -> Self {
816        Self {
817            version_resolver: VersionResolver::new(cache),
818            prepared_versions: std::sync::Arc::new(dashmap::DashMap::new()),
819        }
820    }
821
822    /// Create a new version resolution service with explicit concurrency limit.
823    pub fn with_concurrency(cache: crate::cache::Cache, max_concurrency: usize) -> Self {
824        Self {
825            version_resolver: VersionResolver::with_concurrency(cache, max_concurrency),
826            prepared_versions: std::sync::Arc::new(dashmap::DashMap::new()),
827        }
828    }
829
830    /// Pre-sync all source repositories needed for dependencies.
831    ///
832    /// This performs all Git network operations upfront:
833    /// 1. Clone/fetch source repositories
834    /// 2. Resolve version constraints to commit SHAs
835    /// 3. Create worktrees for resolved commits
836    ///
837    /// # Arguments
838    ///
839    /// * `core` - The resolution core with cache and source manager
840    /// * `deps` - All dependencies that need sources synced
841    /// * `progress` - Optional progress tracker for UI updates
842    pub async fn pre_sync_sources(
843        &self,
844        core: &ResolutionCore,
845        deps: &[(String, ResourceDependency)],
846        progress: Option<std::sync::Arc<crate::utils::MultiPhaseProgress>>,
847    ) -> Result<()> {
848        // Clear and rebuild version resolver entries
849        self.version_resolver.clear();
850
851        // Collect all unique (source, version) pairs
852        for (_name, dep) in deps {
853            if let Some(source) = dep.get_source() {
854                let version = dep.get_version(); // None means HEAD
855
856                let source_url = core
857                    .source_manager
858                    .get_source_url(source)
859                    .ok_or_else(|| anyhow::anyhow!("Source '{}' not found", source))?;
860
861                // Add to version resolver for batch syncing (None -> "HEAD")
862                self.version_resolver.add_version(
863                    source,
864                    &source_url,
865                    version,
866                    dep.resolution_mode(),
867                );
868            }
869        }
870
871        // Pre-sync all source repositories (clone/fetch) with parallel operations
872        // Progress tracking for "Syncing sources" phase is handled inside pre_sync_sources
873        self.version_resolver.pre_sync_sources(progress.clone()).await?;
874
875        // Resolve all versions to SHAs in batch
876        self.version_resolver.resolve_all(progress).await?;
877
878        // Handle local paths (non-Git sources) separately
879        // These don't go through version resolution but need to be in prepared_versions
880        for (_name, dep) in deps {
881            if let Some(source) = dep.get_source() {
882                let source_url = core
883                    .source_manager
884                    .get_source_url(source)
885                    .ok_or_else(|| anyhow::anyhow!("Source '{}' not found", source))?;
886
887                if crate::utils::is_local_path(&source_url) {
888                    let version_key = dep.get_version().unwrap_or("HEAD");
889                    let group_key = format!("{}::{}", source, version_key);
890
891                    // Add to prepared_versions with the local path
892                    self.prepared_versions.insert(
893                        group_key,
894                        PreparedVersionState::Ready(PreparedSourceVersion {
895                            worktree_path: PathBuf::from(&source_url),
896                            resolved_version: Some("local".to_string()),
897                            resolved_commit: String::new(), // No commit for local sources
898                            resource_variants: dashmap::DashMap::new(),
899                        }),
900                    );
901                }
902            }
903        }
904
905        // Create worktrees for all resolved commits using WorktreeManager
906        let worktree_manager =
907            WorktreeManager::new(&core.cache, &core.source_manager, &self.version_resolver);
908        let prepared = worktree_manager.create_worktrees_for_resolved_versions().await?;
909
910        // Merge Git-backed worktrees with local paths
911        // DashMap doesn't support extend with Arc, so iterate and insert
912        for (key, value) in prepared {
913            self.prepared_versions.insert(key, PreparedVersionState::Ready(value));
914        }
915
916        Ok(())
917    }
918
919    /// Get a prepared version by source and version.
920    ///
921    /// # Arguments
922    ///
923    /// * `group_key` - The key in format "source::version"
924    ///
925    /// # Returns
926    ///
927    /// The prepared version info with worktree path and resolved commit (if Ready)
928    pub fn get_prepared_version(&self, group_key: &str) -> Option<PreparedSourceVersion> {
929        self.prepared_versions.get(group_key).and_then(|entry| {
930            if let PreparedVersionState::Ready(prepared) = entry.value() {
931                Some(prepared.clone())
932            } else {
933                None
934            }
935        })
936    }
937
938    /// Get the prepared versions map (raw state).
939    ///
940    /// Returns a reference to the DashMap of prepared source version states.
941    /// Most callers should use `prepared_versions_ready()` instead.
942    pub fn prepared_versions(
943        &self,
944    ) -> &std::sync::Arc<dashmap::DashMap<String, PreparedVersionState>> {
945        &self.prepared_versions
946    }
947
948    /// Get a clone of the prepared versions map Arc (raw state).
949    ///
950    /// Returns a cloned Arc to the DashMap of prepared source version states.
951    /// Most callers should use `prepared_versions_ready_arc()` instead.
952    pub fn prepared_versions_arc(
953        &self,
954    ) -> std::sync::Arc<dashmap::DashMap<String, PreparedVersionState>> {
955        std::sync::Arc::clone(&self.prepared_versions)
956    }
957
958    /// Get a snapshot of only the Ready prepared versions.
959    ///
960    /// Creates a new DashMap containing only versions that are Ready (not Preparing).
961    /// This is safe for use by other code that doesn't need to participate in the
962    /// synchronization protocol.
963    pub fn prepared_versions_ready(
964        &self,
965    ) -> std::sync::Arc<dashmap::DashMap<String, PreparedSourceVersion>> {
966        let ready_map = dashmap::DashMap::new();
967        for entry in self.prepared_versions.iter() {
968            if let PreparedVersionState::Ready(prepared) = entry.value() {
969                ready_map.insert(entry.key().clone(), prepared.clone());
970            }
971        }
972        std::sync::Arc::new(ready_map)
973    }
974
975    /// Get a snapshot Arc of only the Ready prepared versions.
976    ///
977    /// Alias for `prepared_versions_ready()` for compatibility.
978    pub fn prepared_versions_ready_arc(
979        &self,
980    ) -> std::sync::Arc<dashmap::DashMap<String, PreparedSourceVersion>> {
981        self.prepared_versions_ready()
982    }
983
984    /// Get or prepare a version, coordinating concurrent requests.
985    ///
986    /// This method ensures that only one task prepares a given version at a time.
987    /// Other tasks requesting the same version will wait for the first task to complete.
988    /// This prevents the race condition where multiple tasks simultaneously try to
989    /// prepare the same version.
990    ///
991    /// # Arguments
992    ///
993    /// * `core` - The resolution core with cache and source manager
994    /// * `source_name` - Name of the source repository
995    /// * `version` - Optional version constraint (None = HEAD)
996    ///
997    /// # Returns
998    ///
999    /// The prepared version info with worktree path and resolved commit
1000    pub async fn get_or_prepare_version(
1001        &self,
1002        core: &ResolutionCore,
1003        source_name: &str,
1004        version: Option<&str>,
1005    ) -> Result<PreparedSourceVersion> {
1006        let version_key = version.unwrap_or("HEAD");
1007        let group_key = format!("{}::{}", source_name, version_key);
1008
1009        // Use a timeout for coordination to prevent indefinite hangs
1010        let timeout_duration = crate::constants::pending_state_timeout();
1011
1012        loop {
1013            // Check current state atomically
1014            let action = {
1015                let entry = self.prepared_versions.entry(group_key.clone());
1016                match entry {
1017                    dashmap::mapref::entry::Entry::Occupied(occ) => {
1018                        match occ.get() {
1019                            PreparedVersionState::Ready(prepared) => {
1020                                // Version is ready, return it
1021                                return Ok(prepared.clone());
1022                            }
1023                            PreparedVersionState::Preparing(notify) => {
1024                                // Another task is preparing, grab notify and wait
1025                                let notify = notify.clone();
1026                                drop(occ);
1027                                Some(notify)
1028                            }
1029                        }
1030                    }
1031                    dashmap::mapref::entry::Entry::Vacant(vac) => {
1032                        // We're first, insert Preparing state and do the work
1033                        let notify = std::sync::Arc::new(tokio::sync::Notify::new());
1034                        vac.insert(PreparedVersionState::Preparing(notify.clone()));
1035                        None // Signal that we should do the preparation
1036                    }
1037                }
1038            };
1039
1040            match action {
1041                Some(notify) => {
1042                    // Wait for the other task to complete (with timeout)
1043                    tracing::debug!(
1044                        target: "version_resolver",
1045                        "get_or_prepare_version: waiting for {} @ {} (another task preparing)",
1046                        source_name,
1047                        version_key
1048                    );
1049
1050                    let notified = notify.notified();
1051                    tokio::pin!(notified);
1052
1053                    match tokio::time::timeout(timeout_duration, &mut notified).await {
1054                        Ok(()) => {
1055                            // Notified, loop back to check the new state
1056                            continue;
1057                        }
1058                        Err(_) => {
1059                            // Timeout waiting for other task - check if it completed anyway
1060                            if let Some(prepared) = self.get_prepared_version(&group_key) {
1061                                return Ok(prepared);
1062                            }
1063                            // Still not ready, try again (may become leader if other task failed)
1064                            tracing::warn!(
1065                                target: "version_resolver",
1066                                "get_or_prepare_version: timeout waiting for {} @ {}, retrying",
1067                                source_name,
1068                                version_key
1069                            );
1070                            continue;
1071                        }
1072                    }
1073                }
1074                None => {
1075                    // We're the leader, do the preparation
1076                    let result =
1077                        self.do_prepare_version(core, source_name, version, &group_key).await;
1078
1079                    match result {
1080                        Ok(prepared) => {
1081                            return Ok(prepared);
1082                        }
1083                        Err(e) => {
1084                            // Preparation failed, remove the Preparing state and notify waiters
1085                            if let Some((_, PreparedVersionState::Preparing(notify))) =
1086                                self.prepared_versions.remove(&group_key)
1087                            {
1088                                notify.notify_waiters();
1089                            }
1090                            return Err(e);
1091                        }
1092                    }
1093                }
1094            }
1095        }
1096    }
1097
1098    /// Internal: perform the actual version preparation.
1099    ///
1100    /// Called by `get_or_prepare_version` after acquiring the Preparing state.
1101    async fn do_prepare_version(
1102        &self,
1103        core: &ResolutionCore,
1104        source_name: &str,
1105        version: Option<&str>,
1106        group_key: &str,
1107    ) -> Result<PreparedSourceVersion> {
1108        let version_key = version.unwrap_or("HEAD");
1109        tracing::debug!(
1110            target: "version_resolver",
1111            "do_prepare_version: starting for {} @ {}",
1112            source_name,
1113            version_key
1114        );
1115        let source_url = core
1116            .source_manager
1117            .get_source_url(source_name)
1118            .ok_or_else(|| anyhow::anyhow!("Source '{}' not found", source_name))?;
1119
1120        // Handle local paths (non-Git sources) separately
1121        if crate::utils::is_local_path(&source_url) {
1122            let prepared = PreparedSourceVersion {
1123                worktree_path: PathBuf::from(&source_url),
1124                resolved_version: Some("local".to_string()),
1125                resolved_commit: String::new(),
1126                resource_variants: dashmap::DashMap::new(),
1127            };
1128            // Update state to Ready and notify waiters
1129            if let Some(mut entry) = self.prepared_versions.get_mut(group_key) {
1130                if let PreparedVersionState::Preparing(notify) = entry.value() {
1131                    let notify = notify.clone();
1132                    *entry.value_mut() = PreparedVersionState::Ready(prepared.clone());
1133                    drop(entry);
1134                    notify.notify_waiters();
1135                }
1136            }
1137            return Ok(prepared);
1138        }
1139
1140        // For Git sources, proceed with version resolution
1141        let resolution_mode = Self::resolution_mode_from_version(version);
1142        self.version_resolver.add_version(source_name, &source_url, version, resolution_mode);
1143
1144        // Ensure the bare repository path is registered
1145        if self.version_resolver.get_bare_repo_path(source_name).is_none() {
1146            let (owner, repo) = crate::git::parse_git_url(&source_url)
1147                .unwrap_or(("direct".to_string(), "repo".to_string()));
1148            let bare_repo_path =
1149                core.cache.cache_dir().join("sources").join(format!("{owner}_{repo}.git"));
1150            self.version_resolver.register_bare_repo(source_name.to_string(), bare_repo_path);
1151        }
1152
1153        // Resolve this specific version to SHA
1154        tracing::debug!(
1155            target: "version_resolver",
1156            "do_prepare_version: calling resolve_all for {} @ {}",
1157            source_name,
1158            version_key
1159        );
1160        self.version_resolver.resolve_all(None).await?;
1161
1162        // Get the resolved SHA and resolved reference
1163        let resolved_version_data = self
1164            .version_resolver
1165            .get_all_resolved_full()
1166            .get(&(source_name.to_string(), version_key.to_string()))
1167            .ok_or_else(|| {
1168                anyhow::anyhow!("Failed to resolve version for {} @ {}", source_name, version_key)
1169            })?
1170            .clone();
1171
1172        let sha = resolved_version_data.sha.clone();
1173        let resolved_ref = resolved_version_data.resolved_ref.clone();
1174
1175        // Create worktree for this SHA
1176        tracing::debug!(
1177            target: "version_resolver",
1178            "do_prepare_version: creating worktree for {} @ {} (SHA: {})",
1179            source_name,
1180            version_key,
1181            &sha[..8.min(sha.len())]
1182        );
1183        let worktree_path =
1184            core.cache.get_or_create_worktree_for_sha(source_name, &source_url, &sha, None).await?;
1185
1186        let prepared = PreparedSourceVersion {
1187            worktree_path,
1188            resolved_version: Some(resolved_ref),
1189            resolved_commit: sha,
1190            resource_variants: dashmap::DashMap::new(),
1191        };
1192
1193        // Update state to Ready and notify waiters
1194        if let Some(mut entry) = self.prepared_versions.get_mut(group_key) {
1195            if let PreparedVersionState::Preparing(notify) = entry.value() {
1196                let notify = notify.clone();
1197                *entry.value_mut() = PreparedVersionState::Ready(prepared.clone());
1198                drop(entry);
1199                notify.notify_waiters();
1200            }
1201        }
1202
1203        tracing::debug!(
1204            target: "version_resolver",
1205            "do_prepare_version: completed for {} @ {}",
1206            source_name,
1207            version_key
1208        );
1209
1210        Ok(prepared)
1211    }
1212
1213    /// Prepare an additional version on-demand without clearing existing ones.
1214    ///
1215    /// This is a convenience wrapper around `get_or_prepare_version` that discards the result.
1216    /// Prefer using `get_or_prepare_version` directly when you need the prepared version info.
1217    ///
1218    /// # Arguments
1219    ///
1220    /// * `core` - The resolution core with cache and source manager
1221    /// * `source_name` - Name of the source repository
1222    /// * `version` - Optional version constraint (None = HEAD)
1223    pub async fn prepare_additional_version(
1224        &self,
1225        core: &ResolutionCore,
1226        source_name: &str,
1227        version: Option<&str>,
1228    ) -> Result<()> {
1229        self.get_or_prepare_version(core, source_name, version).await?;
1230        Ok(())
1231    }
1232
1233    /// Get available versions (tags/branches) for a repository.
1234    ///
1235    /// # Arguments
1236    ///
1237    /// * `core` - The resolution core with cache
1238    /// * `repo_path` - Path to bare repository
1239    ///
1240    /// # Returns
1241    ///
1242    /// List of available version strings
1243    pub async fn get_available_versions(
1244        _core: &ResolutionCore,
1245        repo_path: &Path,
1246    ) -> Result<Vec<String>> {
1247        let repo = GitRepo::new(repo_path);
1248
1249        // Get all tags
1250        let tags = repo.list_tags().await.context("Failed to list tags")?;
1251
1252        // TODO: Add branches if needed in future
1253        // For now, only use tags
1254        let versions = tags;
1255
1256        Ok(versions)
1257    }
1258
1259    /// Get the bare repository path for a source.
1260    ///
1261    /// Returns None if the source hasn't been synced yet.
1262    ///
1263    /// # Arguments
1264    ///
1265    /// * `source` - Name of the source repository
1266    pub fn get_bare_repo_path(&self, source: &str) -> Option<PathBuf> {
1267        self.version_resolver.get_bare_repo_path(source)
1268    }
1269
1270    /// Get the version resolver (for testing).
1271    #[cfg(test)]
1272    pub fn version_resolver(&self) -> &VersionResolver {
1273        &self.version_resolver
1274    }
1275}
1276
1277// ============================================================================
1278// Batch Ref Resolution Helpers
1279// ============================================================================
1280
1281/// Determines the ref to resolve for a given version entry without making git calls.
1282///
1283/// This is a pure function that uses the provided tag cache to determine whether
1284/// a ref is a tag or branch, enabling batch resolution of multiple refs.
1285///
1286/// # Arguments
1287///
1288/// * `entry` - The version entry to process
1289/// * `tags_cache` - Optional list of tags from the repository
1290///
1291/// # Returns
1292///
1293/// The ref string to use for SHA resolution (tag name, branch name, or "origin/branch")
1294fn determine_ref_to_resolve(
1295    version: &str,
1296    tags_cache: Option<&Vec<String>>,
1297) -> RefResolutionResult {
1298    // Check if this is already a full SHA
1299    if version.len() == 40 && version.chars().all(|c| c.is_ascii_hexdigit()) {
1300        return RefResolutionResult::DirectSha(version.to_string());
1301    }
1302
1303    // Check if it's a tag using the cache
1304    let is_tag = tags_cache.is_some_and(|tags| tags.contains(&version.to_string()));
1305
1306    if is_tag {
1307        // It's a tag - use directly
1308        RefResolutionResult::DirectRef(version.to_string())
1309    } else if !version.contains('/') && version != "HEAD" {
1310        // Looks like a branch name - need to check if origin/branch exists
1311        RefResolutionResult::NeedsBranchCheck {
1312            origin_ref: format!("origin/{version}"),
1313        }
1314    } else {
1315        // Already contains '/' or is HEAD - use directly
1316        RefResolutionResult::DirectRef(version.to_string())
1317    }
1318}
1319
1320/// Result of determining what ref to resolve
1321#[derive(Debug, Clone)]
1322enum RefResolutionResult {
1323    /// Already a SHA, no resolution needed
1324    DirectSha(String),
1325    /// Use this ref directly (tag or already qualified ref)
1326    DirectRef(String),
1327    /// Need to check if origin/branch exists before deciding.
1328    /// Contains the origin_ref to check (e.g., "origin/main").
1329    NeedsBranchCheck {
1330        /// The origin-prefixed ref to check (e.g., "origin/main")
1331        origin_ref: String,
1332    },
1333}
1334
1335// ============================================================================
1336// Version Constraint Resolution Helpers
1337// ============================================================================
1338
1339use crate::version::constraints::{ConstraintSet, VersionConstraint};
1340use semver::Version;
1341
1342/// Checks if a string represents a version constraint rather than a direct reference.
1343///
1344/// Version constraints contain operators like `^`, `~`, `>`, `<`, `=`, or special
1345/// keywords. Direct references are branch names, tag names, or commit hashes.
1346/// This function now supports prefixed constraints like `agents-^v1.0.0`.
1347///
1348/// # Arguments
1349///
1350/// * `version` - The version string to check
1351///
1352/// # Returns
1353///
1354/// Returns `true` if the string contains constraint operators or keywords,
1355/// `false` for plain tags, branches, or commit hashes.
1356#[must_use]
1357pub fn is_version_constraint(version: &str) -> bool {
1358    // Extract prefix first, then check the version part for constraint indicators
1359    let (_prefix, version_str) = crate::version::split_prefix_and_version(version);
1360
1361    // Check for wildcard (works with or without prefix)
1362    if version_str == "*" {
1363        return true;
1364    }
1365
1366    // Check for version constraint operators in the version part
1367    if version_str.starts_with('^')
1368        || version_str.starts_with('~')
1369        || version_str.starts_with('>')
1370        || version_str.starts_with('<')
1371        || version_str.starts_with('=')
1372        || version_str.contains(',')
1373    // Range constraints like ">=1.0.0, <2.0.0"
1374    {
1375        return true;
1376    }
1377
1378    false
1379}
1380
1381/// Sorts tag-version pairs by semantic version (descending), with deterministic tie-breaking.
1382///
1383/// When versions compare equal, uses tag name (lexicographic order) as a tie-breaker.
1384/// This ensures consistent ordering across runs, which is critical for reproducible
1385/// dependency resolution.
1386///
1387/// # Arguments
1388///
1389/// * `pairs` - Mutable reference to vector of (tag_name, semver::Version) pairs
1390///
1391/// # Examples
1392///
1393/// ```no_run
1394/// use semver::Version;
1395///
1396/// let mut versions = vec![
1397///     ("a-v1.0.0".to_string(), Version::new(1, 0, 0)),
1398///     ("z-v1.0.0".to_string(), Version::new(1, 0, 0)),  // Same version
1399///     ("b-v2.0.0".to_string(), Version::new(2, 0, 0)),
1400/// ];
1401/// agpm_cli::resolver::version_resolver::sort_versions_deterministic(&mut versions);
1402/// // After sorting: b-v2.0.0 (highest), then a-v1.0.0, z-v1.0.0 (alphabetical)
1403/// ```
1404pub fn sort_versions_deterministic(pairs: &mut [(String, Version)]) {
1405    pairs.sort_by(|a, b| match b.1.cmp(&a.1) {
1406        std::cmp::Ordering::Equal => a.0.cmp(&b.0), // Tag name tie-breaker
1407        other => other,
1408    });
1409}
1410
1411/// Parses Git tags into semantic versions, filtering out non-semver tags.
1412///
1413/// This function handles both prefixed and non-prefixed version tags,
1414/// including support for monorepo-style prefixes like `agents-v1.0.0`.
1415/// Tags that don't represent valid semantic versions are filtered out.
1416#[must_use]
1417pub fn parse_tags_to_versions(tags: Vec<String>) -> Vec<(String, Version)> {
1418    let mut versions = Vec::new();
1419
1420    for tag in tags {
1421        // Extract prefix and version part (handles both prefixed and unprefixed)
1422        let (_prefix, version_str) = crate::version::split_prefix_and_version(&tag);
1423
1424        // Strip 'v' prefix from version part
1425        let cleaned = version_str.trim_start_matches('v').trim_start_matches('V');
1426
1427        if let Ok(version) = Version::parse(cleaned) {
1428            versions.push((tag, version));
1429        }
1430    }
1431
1432    // Sort deterministically: highest version first, tag name for ties
1433    sort_versions_deterministic(&mut versions);
1434
1435    versions
1436}
1437
1438/// Finds the best matching tag for a version constraint.
1439///
1440/// This function resolves version constraints to actual Git tags by:
1441/// 1. Extracting the prefix from the constraint (if any)
1442/// 2. Filtering tags to only those with matching prefix
1443/// 3. Parsing the constraint and matching tags
1444/// 4. Selecting the best match (usually the highest compatible version)
1445pub fn find_best_matching_tag(constraint_str: &str, tags: Vec<String>) -> Result<String> {
1446    // Extract prefix from constraint
1447    let (constraint_prefix, version_str) = crate::version::split_prefix_and_version(constraint_str);
1448
1449    // Filter tags by prefix first
1450    let filtered_tags: Vec<String> = tags
1451        .into_iter()
1452        .filter(|tag| {
1453            let (tag_prefix, _) = crate::version::split_prefix_and_version(tag);
1454            tag_prefix.as_ref() == constraint_prefix.as_ref()
1455        })
1456        .collect();
1457
1458    if filtered_tags.is_empty() {
1459        return Err(anyhow::anyhow!(
1460            "No tags found with matching prefix for constraint: {constraint_str}"
1461        ));
1462    }
1463
1464    // Parse filtered tags to versions
1465    let tag_versions = parse_tags_to_versions(filtered_tags);
1466
1467    if tag_versions.is_empty() {
1468        return Err(anyhow::anyhow!(
1469            "No valid semantic version tags found for constraint: {constraint_str}"
1470        ));
1471    }
1472
1473    // Special case: wildcard (*) matches the highest available version
1474    if version_str == "*" {
1475        // tag_versions is already sorted highest first
1476        return Ok(tag_versions[0].0.clone());
1477    }
1478
1479    // Parse constraint using ONLY the version part (prefix already filtered)
1480    // This ensures semver matching works correctly after prefix filtering
1481    let constraint = VersionConstraint::parse(version_str)?;
1482
1483    // Extract just the versions for constraint matching
1484    let versions: Vec<Version> = tag_versions.iter().map(|(_, v)| v.clone()).collect();
1485
1486    // Create a constraint set with just this constraint
1487    let mut constraint_set = ConstraintSet::new();
1488    constraint_set.add(constraint)?;
1489
1490    // Find the best match
1491    if let Some(best_version) = constraint_set.find_best_match(&versions) {
1492        // Find the original tag name for this version
1493        for (tag_name, version) in tag_versions {
1494            if &version == best_version {
1495                return Ok(tag_name);
1496            }
1497        }
1498    }
1499
1500    Err(anyhow::anyhow!("No tag found matching constraint: {constraint_str}"))
1501}
1502
1503// ============================================================================
1504// Worktree Management
1505// ============================================================================
1506
1507/// Represents a prepared source version with worktree information.
1508#[derive(Clone, Debug)]
1509pub struct PreparedSourceVersion {
1510    /// Path to the worktree for this version
1511    pub worktree_path: std::path::PathBuf,
1512    /// The resolved version reference (tag, branch, etc.)
1513    pub resolved_version: Option<String>,
1514    /// The commit SHA for this version
1515    pub resolved_commit: String,
1516    /// Template variables for each resource in this version.
1517    /// Maps resource_id (format: "source:path") to variant_inputs (template variables).
1518    /// Used during backtracking to preserve template variables when changing versions.
1519    /// Uses DashMap for concurrent access during parallel dependency resolution.
1520    pub resource_variants: dashmap::DashMap<String, Option<serde_json::Value>>,
1521}
1522
1523impl Default for PreparedSourceVersion {
1524    fn default() -> Self {
1525        Self {
1526            worktree_path: std::path::PathBuf::new(),
1527            resolved_version: None,
1528            resolved_commit: String::new(),
1529            resource_variants: dashmap::DashMap::new(),
1530        }
1531    }
1532}
1533
1534/// State of a prepared version in the concurrent preparation cache.
1535///
1536/// This enum enables safe concurrent access to version preparation:
1537/// - Multiple callers requesting the same version will coordinate
1538/// - Only one caller performs the actual preparation
1539/// - Other callers wait for the preparation to complete
1540#[derive(Clone)]
1541pub enum PreparedVersionState {
1542    /// Version is being prepared by another task. Wait on the Notify.
1543    Preparing(std::sync::Arc<tokio::sync::Notify>),
1544    /// Version is ready to use.
1545    Ready(PreparedSourceVersion),
1546}
1547
1548/// Manages worktree creation for resolved dependency versions.
1549pub struct WorktreeManager<'a> {
1550    cache: &'a Cache,
1551    source_manager: &'a SourceManager,
1552    version_resolver: &'a VersionResolver,
1553}
1554
1555impl<'a> WorktreeManager<'a> {
1556    /// Create a new worktree manager.
1557    pub fn new(
1558        cache: &'a Cache,
1559        source_manager: &'a SourceManager,
1560        version_resolver: &'a VersionResolver,
1561    ) -> Self {
1562        Self {
1563            cache,
1564            source_manager,
1565            version_resolver,
1566        }
1567    }
1568
1569    /// Create a group key for identifying source-version combinations.
1570    pub fn group_key(source: &str, version: &str) -> String {
1571        format!("{source}::{version}")
1572    }
1573
1574    /// Create worktrees for all resolved versions in parallel.
1575    ///
1576    /// This function takes the resolved versions from the VersionResolver
1577    /// and creates Git worktrees for each unique commit SHA, enabling
1578    /// efficient parallel access to dependency resources.
1579    ///
1580    /// # Returns
1581    ///
1582    /// A map of group keys to prepared source versions containing worktree paths.
1583    pub async fn create_worktrees_for_resolved_versions(
1584        &self,
1585    ) -> Result<HashMap<String, PreparedSourceVersion>> {
1586        use crate::core::AgpmError;
1587        use futures::future::join_all;
1588
1589        let resolved_full = self.version_resolver.get_all_resolved_full().clone();
1590        let mut prepared_versions = HashMap::new();
1591
1592        // Build futures for parallel worktree creation
1593        let mut futures = Vec::new();
1594
1595        for ((source_name, version_key), resolved_version) in resolved_full {
1596            let sha = resolved_version.sha;
1597            let resolved_ref = resolved_version.resolved_ref;
1598            let repo_key = Self::group_key(&source_name, &version_key);
1599            let cache_clone = self.cache.clone();
1600            let source_name_clone = source_name.clone();
1601
1602            // Get the source URL for this source
1603            let source_url_clone = self
1604                .source_manager
1605                .get_source_url(&source_name)
1606                .ok_or_else(|| AgpmError::SourceNotFound {
1607                    name: source_name.to_string(),
1608                })?
1609                .to_string();
1610
1611            let sha_clone = sha.clone();
1612            let resolved_ref_clone = resolved_ref.clone();
1613
1614            let future = async move {
1615                // Use SHA-based worktree creation
1616                // The version resolver has already handled fetching and SHA resolution
1617                let worktree_path = cache_clone
1618                    .get_or_create_worktree_for_sha(
1619                        &source_name_clone,
1620                        &source_url_clone,
1621                        &sha_clone,
1622                        Some(&source_name_clone), // context for logging
1623                    )
1624                    .await?;
1625
1626                Ok::<_, anyhow::Error>((
1627                    repo_key,
1628                    PreparedSourceVersion {
1629                        worktree_path,
1630                        resolved_version: Some(resolved_ref_clone),
1631                        resolved_commit: sha_clone,
1632                        resource_variants: dashmap::DashMap::new(),
1633                    },
1634                ))
1635            };
1636
1637            futures.push(future);
1638        }
1639
1640        // Execute all futures concurrently and collect results with timeout
1641        let timeout_duration = crate::constants::batch_operation_timeout();
1642        let results =
1643            tokio::time::timeout(timeout_duration, join_all(futures)).await.with_context(|| {
1644                format!(
1645                    "Worktree creation batch timed out after {:?} - possible deadlock",
1646                    timeout_duration
1647                )
1648            })?;
1649
1650        // Process results and build the map
1651        for result in results {
1652            let (key, prepared) = result?;
1653            prepared_versions.insert(key, prepared);
1654        }
1655
1656        Ok(prepared_versions)
1657    }
1658}
1659
1660/// Formats a source name with its URL for progress display.
1661///
1662/// Extracts the host and path from the URL for a cleaner display.
1663/// Examples:
1664/// - "community" + "https://github.com/org/repo.git" → "community (github.com/org/repo)"
1665/// - "local" + "file:///path/to/repo" → "local (file:///path/to/repo)"
1666fn format_source_display(source: &str, url: &str) -> String {
1667    // Try to extract a clean display from the URL
1668    let clean_url = if let Some(stripped) = url.strip_prefix("https://") {
1669        stripped.trim_end_matches(".git")
1670    } else if let Some(stripped) = url.strip_prefix("http://") {
1671        stripped.trim_end_matches(".git")
1672    } else if let Some(stripped) = url.strip_prefix("git@") {
1673        // git@github.com:org/repo.git -> github.com/org/repo
1674        return format!("{source} ({})", stripped.replace(':', "/").trim_end_matches(".git"));
1675    } else {
1676        // Local path or other format - show as-is
1677        url
1678    };
1679
1680    format!("{source} ({clean_url})")
1681}
1682
1683#[cfg(test)]
1684mod tests {
1685    use super::*;
1686    use tempfile::TempDir;
1687
1688    #[tokio::test]
1689    async fn test_version_resolver_deduplication() {
1690        let temp_dir = TempDir::new().unwrap();
1691        let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
1692        let resolver = VersionResolver::new(cache);
1693
1694        // Add same version multiple times
1695        resolver.add_version(
1696            "source1",
1697            "https://example.com/repo.git",
1698            Some("v1.0.0"),
1699            ResolutionMode::Version,
1700        );
1701        resolver.add_version(
1702            "source1",
1703            "https://example.com/repo.git",
1704            Some("v1.0.0"),
1705            ResolutionMode::Version,
1706        );
1707        resolver.add_version(
1708            "source1",
1709            "https://example.com/repo.git",
1710            Some("v1.0.0"),
1711            ResolutionMode::Version,
1712        );
1713
1714        // Should only have one entry
1715        assert_eq!(resolver.pending_count(), 1);
1716    }
1717
1718    #[tokio::test]
1719    async fn test_sha_optimization() {
1720        let temp_dir = TempDir::new().unwrap();
1721        let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
1722        let _resolver = VersionResolver::new(cache);
1723
1724        // Test that full SHA is recognized
1725        let full_sha = "a".repeat(40);
1726        assert_eq!(full_sha.len(), 40);
1727        assert!(full_sha.chars().all(|c| c.is_ascii_hexdigit()));
1728    }
1729
1730    #[tokio::test]
1731    async fn test_resolved_retrieval() {
1732        let temp_dir = TempDir::new().unwrap();
1733        let cache = Cache::with_dir(temp_dir.path().to_path_buf()).unwrap();
1734        let resolver = VersionResolver::new(cache);
1735
1736        // Manually insert a resolved SHA for testing
1737        let key = ("test_source".to_string(), "v1.0.0".to_string());
1738        let sha = "1234567890abcdef1234567890abcdef12345678";
1739        resolver.resolved.insert(
1740            key,
1741            ResolvedVersion {
1742                sha: sha.to_string(),
1743                resolved_ref: "v1.0.0".to_string(),
1744            },
1745        );
1746
1747        // Verify retrieval
1748        assert!(resolver.is_resolved("test_source", "v1.0.0"));
1749        assert_eq!(resolver.get_resolved_sha("test_source", "v1.0.0"), Some(sha.to_string()));
1750        assert!(!resolver.is_resolved("test_source", "v2.0.0"));
1751    }
1752
1753    #[tokio::test]
1754    async fn test_worktree_group_key() {
1755        assert_eq!(WorktreeManager::group_key("source", "version"), "source::version");
1756        assert_eq!(WorktreeManager::group_key("community", "v1.0.0"), "community::v1.0.0");
1757    }
1758
1759    #[test]
1760    fn test_format_source_display() {
1761        // HTTPS URLs
1762        assert_eq!(
1763            format_source_display("community", "https://github.com/org/repo.git"),
1764            "community (github.com/org/repo)"
1765        );
1766        assert_eq!(
1767            format_source_display("other", "https://gitlab.com/org/repo"),
1768            "other (gitlab.com/org/repo)"
1769        );
1770
1771        // HTTP URLs
1772        assert_eq!(
1773            format_source_display("test", "http://example.com/repo.git"),
1774            "test (example.com/repo)"
1775        );
1776
1777        // Git SSH URLs
1778        assert_eq!(
1779            format_source_display("ssh-source", "git@github.com:org/repo.git"),
1780            "ssh-source (github.com/org/repo)"
1781        );
1782
1783        // Local paths (preserved as-is)
1784        assert_eq!(
1785            format_source_display("local", "file:///path/to/repo"),
1786            "local (file:///path/to/repo)"
1787        );
1788        assert_eq!(format_source_display("relative", "../some/path"), "relative (../some/path)");
1789    }
1790}