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}