agpm_cli/git/
mod.rs

1//! Git operations wrapper for AGPM
2//!
3//! This module provides a safe, async wrapper around the system `git` command, serving as
4//! the foundation for AGPM's distributed package management capabilities. Unlike libraries
5//! that use embedded Git implementations (like `libgit2`), this module leverages the system's
6//! installed Git binary to ensure maximum compatibility with existing Git configurations,
7//! authentication methods, and platform-specific optimizations.
8//!
9//! # Design Philosophy: CLI-Based Git Integration
10//!
11//! AGPM follows the same approach as Cargo with `git-fetch-with-cli`, using the system's
12//! `git` command rather than an embedded Git library. This design choice provides several
13//! critical advantages:
14//!
15//! - **Authentication Compatibility**: Seamlessly works with SSH agents, credential helpers,
16//!   Git configuration, and platform-specific authentication (Windows Credential Manager,
17//!   macOS Keychain, Linux credential stores)
18//! - **Feature Completeness**: Access to all Git features without library limitations
19//! - **Platform Integration**: Leverages platform-optimized Git builds and configurations
20//! - **Security**: Benefits from system Git's security updates and hardening
21//! - **Debugging**: Uses familiar Git commands for troubleshooting and logging
22//!
23//! # Core Features
24//!
25//! ## Asynchronous Operations
26//! All Git operations are async and built on Tokio, enabling:
27//! - Non-blocking I/O for better performance
28//! - Concurrent repository operations
29//! - Progress reporting during long operations
30//! - Graceful cancellation support
31//!
32//! ## Worktree Support for Parallel Operations
33//! Advanced Git worktree integration for safe parallel package installation:
34//! - **Bare repository cloning**: Creates repositories optimized for worktrees
35//! - **Parallel worktree creation**: Multiple versions checked out simultaneously
36//! - **Per-worktree locking**: Individual worktree creation locks prevent conflicts
37//! - **Command-level concurrency**: Parallelism controlled by `--max-parallel` flag
38//! - **Automatic cleanup**: Efficient worktree lifecycle management
39//! - **Conflict-free operations**: Each dependency gets its own isolated working directory
40//!
41//! ## Progress Reporting
42//! User feedback during:
43//! - Repository cloning with transfer progress
44//! - Fetch operations with network activity
45//! - Large repository operations
46//!
47//! ## Authentication Handling
48//! Supports multiple authentication methods through URL-based configuration:
49//! - HTTPS with embedded tokens: `https://token@github.com/user/repo.git`
50//! - SSH with key-based authentication: `git@github.com:user/repo.git`
51//! - System credential helpers and Git configuration
52//! - Platform-specific credential storage
53//!
54//! ## Cross-Platform Compatibility
55//! Tested and optimized for:
56//! - **Windows**: Handles path length limits, `PowerShell` vs CMD differences
57//! - **macOS**: Integrates with Keychain and Xcode command line tools
58//! - **Linux**: Works with various distributions and Git installations
59//!
60//! # Security Considerations
61//!
62//! ## Command Injection Prevention
63//! All Git operations use proper argument passing to prevent injection attacks:
64//! - Arguments passed as separate parameters, not shell strings
65//! - URL validation before Git operations
66//! - Path sanitization for repository locations
67//!
68//! ## Authentication Security
69//! - Credentials never logged or exposed in error messages
70//! - Authentication URLs are stripped from public error output
71//! - Supports secure credential storage via system Git configuration
72//!
73//! ## Network Security
74//! - HTTPS verification enabled by default
75//! - Support for custom CA certificates via Git configuration
76//! - Timeout handling for network operations
77//!
78//! # Performance Characteristics
79//!
80//! ## Network Operations
81//! - Async I/O prevents blocking during network operations
82//! - Parallel fetch operations for multiple repositories
83//! - Efficient progress reporting without polling
84//!
85//! ## Local Operations
86//! - Direct file system access for repository validation
87//! - Optimized branch/tag listing with minimal Git calls
88//! - Efficient checkout operations with proper reset handling
89//!
90//! # Error Handling Strategy
91//!
92//! The module provides rich error context through [`AgpmError`] variants:
93//! - Network failures with retry suggestions
94//! - Authentication errors with configuration guidance
95//! - Repository format errors with recovery steps
96//! - Platform-specific error translation
97//!
98//! # Usage Examples
99//!
100//! ## Basic Repository Operations
101//! ```rust,no_run
102//! use agpm_cli::git::GitRepo;
103//! use std::env;
104//!
105//! # async fn example() -> anyhow::Result<()> {
106//! // Use platform-appropriate temp directory
107//! let temp_dir = env::temp_dir();
108//! let repo_path = temp_dir.join("repo");
109//!
110//! // Clone a repository
111//! let repo = GitRepo::clone(
112//!     "https://github.com/example/repo.git",
113//!     &repo_path
114//! ).await?;
115//!
116//! // Fetch updates from remote
117//! repo.fetch(None).await?;
118//!
119//! // Checkout a specific version
120//! repo.checkout("v1.2.3").await?;
121//!
122//! // List available tags
123//! let tags = repo.list_tags().await?;
124//! println!("Available versions: {:?}", tags);
125//! # Ok(())
126//! # }
127//! ```
128//!
129//! ## Authentication with URLs
130//! ```rust,no_run
131//! use agpm_cli::git::GitRepo;
132//! use std::env;
133//!
134//! # async fn auth_example() -> anyhow::Result<()> {
135//! // Use platform-appropriate temp directory
136//! let temp_dir = env::temp_dir();
137//! let repo_path = temp_dir.join("private-repo");
138//!
139//! // Clone with authentication embedded in URL
140//! let repo = GitRepo::clone(
141//!     "https://token:ghp_xxxx@github.com/private/repo.git",
142//!     &repo_path
143//! ).await?;
144//!
145//! // Fetch with different authentication URL
146//! let auth_url = "https://oauth2:token@github.com/private/repo.git";
147//! repo.fetch(Some(auth_url)).await?;
148//! # Ok(())
149//! # }
150//! ```
151//!
152//! ## Repository Validation
153//! ```rust,no_run
154//! use agpm_cli::git::{GitRepo, ensure_git_available, is_valid_git_repo};
155//! use std::env;
156//!
157//! # async fn validation_example() -> anyhow::Result<()> {
158//! // Ensure Git is installed
159//! ensure_git_available()?;
160//!
161//! // Verify repository URL before cloning
162//! GitRepo::verify_url("https://github.com/example/repo.git").await?;
163//!
164//! // Check if directory is a valid Git repository
165//! let temp_dir = env::temp_dir();
166//! let path = temp_dir.join("repo");
167//! if is_valid_git_repo(&path) {
168//!     let repo = GitRepo::new(&path);
169//!     let url = repo.get_remote_url().await?;
170//!     println!("Repository URL: {}", url);
171//! }
172//! # Ok(())
173//! # }
174//! ```
175//!
176//! ## Worktree-based Parallel Operations
177//! ```rust,no_run
178//! use agpm_cli::git::GitRepo;
179//! use std::env;
180//!
181//! # async fn worktree_example() -> anyhow::Result<()> {
182//! // Use platform-appropriate temp directory
183//! let temp_dir = env::temp_dir();
184//! let cache_dir = temp_dir.join("cache");
185//! let bare_path = cache_dir.join("repo.git");
186//!
187//! // Clone repository as bare for worktree use
188//! let bare_repo = GitRepo::clone_bare(
189//!     "https://github.com/example/repo.git",
190//!     &bare_path
191//! ).await?;
192//!
193//! // Create multiple worktrees for parallel processing
194//! let work1 = temp_dir.join("work1");
195//! let work2 = temp_dir.join("work2");
196//! let work3 = temp_dir.join("work3");
197//!
198//! let worktree1 = bare_repo.create_worktree(&work1, Some("v1.0.0")).await?;
199//! let worktree2 = bare_repo.create_worktree(&work2, Some("v2.0.0")).await?;
200//! let worktree3 = bare_repo.create_worktree(&work3, Some("main")).await?;
201//!
202//! // Each worktree can be used independently and concurrently
203//! // Process files from worktree1 at v1.0.0
204//! // Process files from worktree2 at v2.0.0  
205//! // Process files from worktree3 at latest main
206//!
207//! // Clean up when done
208//! bare_repo.remove_worktree(&work1).await?;
209//! bare_repo.remove_worktree(&work2).await?;
210//! bare_repo.remove_worktree(&work3).await?;
211//! # Ok(())
212//! # }
213//! ```
214//!
215//! # Platform-Specific Considerations
216//!
217//! ## Windows
218//! - Uses `git.exe` or `git.cmd` detection via PATH
219//! - Handles long path names (>260 characters)
220//! - Works with Windows Credential Manager
221//! - Supports both CMD and `PowerShell` environments
222//!
223//! ## macOS
224//! - Integrates with Xcode Command Line Tools Git
225//! - Supports Keychain authentication
226//! - Handles case-sensitive vs case-insensitive filesystems
227//!
228//! ## Linux
229//! - Works with package manager installed Git
230//! - Supports various credential helpers
231//! - Handles different filesystem permissions
232//!
233//! # Integration with AGPM
234//!
235//! This module integrates with other AGPM components:
236//! - [`crate::source`] - Repository source management
237//! - [`crate::manifest`] - Manifest-based dependency resolution
238//! - [`crate::lockfile`] - Lockfile generation with commit hashes
239//! - [`crate::utils::progress`] - User progress feedback
240//! - [`crate::core::AgpmError`] - Centralized error handling
241//!
242//! [`AgpmError`]: crate::core::AgpmError
243
244pub mod command_builder;
245#[cfg(test)]
246mod tests;
247
248use crate::core::AgpmError;
249use crate::git::command_builder::GitCommand;
250use anyhow::{Context, Result};
251use std::path::{Path, PathBuf};
252
253/// A Git repository handle providing async operations via CLI commands.
254///
255/// `GitRepo` represents a local Git repository and provides methods for common
256/// Git operations such as cloning, fetching, checking out specific references,
257/// and querying repository state. All operations are performed asynchronously
258/// using the system's `git` command rather than an embedded Git library.
259///
260/// # Design Principles
261///
262/// - **CLI-based**: Uses system `git` command for maximum compatibility
263/// - **Async**: All operations are non-blocking and support cancellation
264/// - **Progress-aware**: Integration with progress reporting for long operations
265/// - **Error-rich**: Detailed error information with context and suggestions
266/// - **Cross-platform**: Tested on Windows, macOS, and Linux
267///
268/// # Repository State
269///
270/// The struct holds minimal state (just the repository path) and queries Git
271/// directly for current information. This ensures consistency with external
272/// Git operations and avoids state synchronization issues.
273///
274/// # Examples
275///
276/// ```rust,no_run
277/// use agpm_cli::git::GitRepo;
278/// use std::path::Path;
279///
280/// # async fn example() -> anyhow::Result<()> {
281/// // Create handle for existing repository
282/// let repo = GitRepo::new("/path/to/existing/repo");
283///
284/// // Verify it's a valid Git repository
285/// if repo.is_git_repo() {
286///     let tags = repo.list_tags().await?;
287///     repo.checkout("main").await?;
288/// }
289/// # Ok(())
290/// # }
291/// ```
292///
293/// # Thread Safety
294///
295/// `GitRepo` is `Send` and `Sync`, allowing it to be used across async tasks.
296/// However, concurrent Git operations on the same repository may conflict
297/// at the Git level (e.g., simultaneous checkouts).
298#[derive(Debug)]
299pub struct GitRepo {
300    /// The local filesystem path to the Git repository.
301    ///
302    /// This path should point to the root directory of a Git repository
303    /// (the directory containing `.git/` subdirectory).
304    path: PathBuf,
305}
306
307impl GitRepo {
308    /// Creates a new `GitRepo` instance for an existing local repository.
309    ///
310    /// This constructor does not verify that the path contains a valid Git repository.
311    /// Use [`is_git_repo`](#method.is_git_repo) or [`ensure_valid_git_repo`] to validate
312    /// the repository before performing Git operations.
313    ///
314    /// # Arguments
315    ///
316    /// * `path` - The filesystem path to the Git repository root directory
317    ///
318    /// # Examples
319    ///
320    /// ```rust,no_run
321    /// use agpm_cli::git::GitRepo;
322    /// use std::path::Path;
323    ///
324    /// // Create repository handle
325    /// let repo = GitRepo::new("/path/to/repo");
326    ///
327    /// // Verify it's valid before operations
328    /// if repo.is_git_repo() {
329    ///     println!("Valid Git repository at: {:?}", repo.path());
330    /// }
331    /// ```
332    ///
333    /// # See Also
334    ///
335    /// * [`clone`](#method.clone) - For creating repositories by cloning from remote
336    /// * [`is_git_repo`](#method.is_git_repo) - For validating repository state
337    pub fn new(path: impl AsRef<Path>) -> Self {
338        Self {
339            path: path.as_ref().to_path_buf(),
340        }
341    }
342
343    /// Clones a Git repository from a remote URL to a local path.
344    ///
345    /// This method performs a full clone operation, downloading the entire repository
346    /// history to the target directory. The operation is async and supports progress
347    /// reporting for large repositories.
348    ///
349    /// # Arguments
350    ///
351    /// * `url` - The remote repository URL (HTTPS, SSH, or file://)
352    /// * `target` - The local directory where the repository will be cloned
353    /// * `progress` - Optional progress bar for user feedback
354    ///
355    /// # Authentication
356    ///
357    /// Authentication can be provided in several ways:
358    /// - **HTTPS with tokens**: `https://token:value@github.com/user/repo.git`
359    /// - **SSH keys**: Handled by system SSH agent and Git configuration
360    /// - **Credential helpers**: System Git credential managers
361    ///
362    /// # Supported URL Formats
363    ///
364    /// - `https://github.com/user/repo.git` - HTTPS
365    /// - `git@github.com:user/repo.git` - SSH
366    /// - `file:///path/to/repo.git` - Local file system
367    /// - `https://user:token@github.com/user/repo.git` - HTTPS with auth
368    ///
369    /// # Examples
370    ///
371    /// ```ignore
372    /// use agpm_cli::git::GitRepo;
373    /// use std::env;
374    ///
375    /// # async fn example() -> anyhow::Result<()> {
376    /// let temp_dir = env::temp_dir();
377    ///
378    /// // Clone public repository
379    /// let repo = GitRepo::clone(
380    ///     "https://github.com/rust-lang/git2-rs.git",
381    ///     temp_dir.join("git2-rs")
382    /// ).await?;
383    ///
384    /// // Clone another repository
385    /// let repo = GitRepo::clone(
386    ///     "https://github.com/example/repository.git",
387    ///     temp_dir.join("example-repo")
388    /// ).await?;
389    /// # Ok(())
390    /// # }
391    /// ```
392    ///
393    /// # Errors
394    ///
395    /// Returns [`AgpmError::GitCloneFailed`] if:
396    /// - The URL is invalid or unreachable
397    /// - Authentication fails
398    /// - The target directory already exists and is not empty
399    /// - Network connectivity issues
400    /// - Insufficient disk space
401    ///
402    /// # Security
403    ///
404    /// URLs are validated and sanitized before passing to Git. Authentication
405    /// tokens in URLs are never logged or exposed in error messages.
406    ///
407    /// [`AgpmError::GitCloneFailed`]: crate::core::AgpmError::GitCloneFailed
408    pub async fn clone(url: &str, target: impl AsRef<Path>) -> Result<Self> {
409        let target_path = target.as_ref();
410
411        // Use command builder for consistent clone operations
412        let mut cmd = GitCommand::clone(url, target_path);
413
414        // For file:// URLs, clone with all branches to ensure commit availability
415        if url.starts_with("file://") {
416            cmd = GitCommand::new()
417                .args(["clone", "--progress", "--no-single-branch", "--recurse-submodules", url])
418                .arg(target_path.display().to_string());
419        }
420
421        // Execute will handle error context properly
422        cmd.execute().await?;
423
424        Ok(Self::new(target_path))
425    }
426
427    /// Fetches updates from the remote repository without modifying the working tree.
428    ///
429    /// This operation downloads new commits, branches, and tags from the remote
430    /// repository but does not modify the current branch or working directory.
431    /// It's equivalent to `git fetch --all --tags`.
432    ///
433    /// # Arguments
434    ///
435    /// * `auth_url` - Optional URL with authentication for private repositories
436    /// * `progress` - Optional progress bar for network operation feedback
437    ///
438    /// # Authentication URL
439    ///
440    /// The `auth_url` parameter allows fetching from repositories that require
441    /// different authentication than the original clone URL. This is useful when:
442    /// - Using rotating tokens or credentials
443    /// - Accessing private repositories through different auth methods
444    /// - Working with multiple authentication contexts
445    ///
446    /// # Local Repository Optimization
447    ///
448    /// For local repositories (file:// URLs), fetch is automatically skipped
449    /// as local repositories don't require network synchronization.
450    ///
451    /// # Examples
452    ///
453    /// ```rust,no_run
454    /// use agpm_cli::git::GitRepo;
455    /// use std::env;
456    ///
457    /// # async fn example() -> anyhow::Result<()> {
458    /// let temp_dir = env::temp_dir();
459    /// let repo_path = temp_dir.join("repo");
460    /// let repo = GitRepo::new(&repo_path);
461    ///
462    /// // Basic fetch from configured remote
463    /// repo.fetch(None).await?;
464    ///
465    /// // Fetch with authentication
466    /// let auth_url = "https://token:ghp_xxxx@github.com/user/repo.git";
467    /// repo.fetch(Some(auth_url)).await?;
468    /// # Ok(())
469    /// # }
470    /// ```
471    ///
472    /// # Errors
473    ///
474    /// Returns [`AgpmError::GitCommandError`] if:
475    /// - Network connectivity fails
476    /// - Authentication is rejected
477    /// - The remote repository is unavailable
478    /// - The local repository is in an invalid state
479    ///
480    /// # Performance
481    ///
482    /// Fetch operations are optimized to:
483    /// - Skip unnecessary work for local repositories
484    /// - Provide progress feedback for large transfers
485    /// - Use efficient Git transfer protocols
486    ///
487    /// [`AgpmError::GitCommandError`]: crate::core::AgpmError::GitCommandError
488    pub async fn fetch(&self, auth_url: Option<&str>) -> Result<()> {
489        // Note: file:// URLs are local repositories, but we still need to fetch
490        // from them to get updates from the source repository
491
492        // Use git fetch with authentication from global config URL if provided
493        if let Some(url) = auth_url {
494            // Temporarily update the remote URL with auth for this fetch
495            GitCommand::set_remote_url(url).current_dir(&self.path).execute_success().await?;
496        }
497
498        // Now fetch with the potentially updated URL
499        GitCommand::fetch().current_dir(&self.path).execute_success().await?;
500
501        Ok(())
502    }
503
504    /// Checks out a specific Git reference (branch, tag, or commit hash).
505    ///
506    /// This operation switches the repository's working directory to match the
507    /// specified reference. It performs a hard reset before checkout to ensure
508    /// a clean state, discarding any local modifications.
509    ///
510    /// # Arguments
511    ///
512    /// * `ref_name` - The Git reference to checkout (branch, tag, or commit)
513    ///
514    /// # Reference Resolution Strategy
515    ///
516    /// The method attempts to resolve references in the following order:
517    /// 1. **Direct reference**: Exact match for tags, branches, or commit hashes
518    /// 2. **Remote branch**: Tries `origin/{ref_name}` for remote branches
519    /// 3. **Error**: If neither resolution succeeds, returns an error
520    ///
521    /// # Supported Reference Types
522    ///
523    /// - **Tags**: `v1.0.0`, `release-2023-01`, etc.
524    /// - **Branches**: `main`, `develop`, `feature/new-ui`, etc.
525    /// - **Commit hashes**: `abc123def`, `1234567890abcdef` (full or abbreviated)
526    /// - **Remote branches**: Automatically tries `origin/{branch_name}`
527    ///
528    /// # State Management
529    ///
530    /// Before checkout, the method performs:
531    /// 1. **Hard reset**: `git reset --hard HEAD` to discard local changes
532    /// 2. **Clean checkout**: Switches to the target reference
533    /// 3. **Detached HEAD**: For tags/commits (normal Git behavior)
534    ///
535    /// # Examples
536    ///
537    /// ```rust,no_run
538    /// use agpm_cli::git::GitRepo;
539    ///
540    /// # async fn example() -> anyhow::Result<()> {
541    /// let repo = GitRepo::new("/path/to/repo");
542    ///
543    /// // Checkout a specific version tag
544    /// repo.checkout("v1.2.3").await?;
545    ///
546    /// // Checkout a branch
547    /// repo.checkout("main").await?;
548    ///
549    /// // Checkout a commit hash
550    /// repo.checkout("abc123def456").await?;
551    ///
552    /// // Checkout remote branch
553    /// repo.checkout("feature/experimental").await?;
554    /// # Ok(())
555    /// # }
556    /// ```
557    ///
558    /// # Data Loss Warning
559    ///
560    /// **This operation discards uncommitted changes.** The hard reset before
561    /// checkout ensures a clean state but will permanently lose any local
562    /// modifications. This behavior is intentional for AGPM's package management
563    /// use case where clean, reproducible states are required.
564    ///
565    /// # Errors
566    ///
567    /// Returns [`AgpmError::GitCheckoutFailed`] if:
568    /// - The reference doesn't exist in the repository
569    /// - The repository is in an invalid state
570    /// - File system permissions prevent checkout
571    /// - The working directory is locked by another process
572    ///
573    /// # Performance
574    ///
575    /// Checkout operations are optimized for:
576    /// - Fast switching between cached references
577    /// - Minimal file system operations
578    /// - Efficient handling of large repositories
579    ///
580    /// [`AgpmError::GitCheckoutFailed`]: crate::core::AgpmError::GitCheckoutFailed
581    pub async fn checkout(&self, ref_name: &str) -> Result<()> {
582        // Reset to clean state before checkout
583        let reset_result = GitCommand::reset_hard().current_dir(&self.path).execute().await;
584
585        if let Err(e) = reset_result {
586            // Only warn if it's not a detached HEAD situation (which is normal)
587            let error_str = e.to_string();
588            if !error_str.contains("HEAD detached") {
589                eprintln!("Warning: git reset failed: {error_str}");
590            }
591        }
592
593        // Check if this ref exists as a remote branch
594        // If it does, always use -B to ensure we get the latest
595        let remote_ref = format!("origin/{ref_name}");
596        let check_remote =
597            GitCommand::verify_ref(&remote_ref).current_dir(&self.path).execute().await;
598
599        if check_remote.is_ok() {
600            // Remote branch exists, use -B to force update to latest
601            if GitCommand::checkout_branch(ref_name, &remote_ref)
602                .current_dir(&self.path)
603                .execute_success()
604                .await
605                .is_ok()
606            {
607                return Ok(());
608            }
609        }
610
611        // Not a remote branch, try direct checkout (works for tags and commits)
612        GitCommand::checkout(ref_name).current_dir(&self.path).execute_success().await.map_err(
613            |e| {
614                // If it's already a GitCheckoutFailed error, return as-is
615                // Otherwise wrap it
616                if let Some(agpm_err) = e.downcast_ref::<AgpmError>()
617                    && matches!(agpm_err, AgpmError::GitCheckoutFailed { .. })
618                {
619                    return e;
620                }
621                AgpmError::GitCheckoutFailed {
622                    reference: ref_name.to_string(),
623                    reason: e.to_string(),
624                }
625                .into()
626            },
627        )
628    }
629
630    /// Lists all tags in the repository, sorted by Git's default ordering.
631    ///
632    /// This method retrieves all Git tags from the local repository using
633    /// `git tag -l`. Tags are returned as strings in Git's natural ordering,
634    /// which may not be semantic version order.
635    ///
636    /// # Return Value
637    ///
638    /// Returns a `Vec<String>` containing all tag names. Empty if no tags exist.
639    /// Tags are returned exactly as they appear in Git (no prefix stripping).
640    ///
641    /// # Repository Validation
642    ///
643    /// The method validates that:
644    /// - The repository path exists on the filesystem
645    /// - The directory contains a `.git` subdirectory
646    /// - The repository is in a valid state for Git operations
647    ///
648    /// # Examples
649    ///
650    /// ```rust,no_run
651    /// use agpm_cli::git::GitRepo;
652    ///
653    /// # async fn example() -> anyhow::Result<()> {
654    /// let repo = GitRepo::new("/path/to/repo");
655    ///
656    /// // Get all available tags
657    /// let tags = repo.list_tags().await?;
658    /// for tag in tags {
659    ///     println!("Available version: {}", tag);
660    /// }
661    ///
662    /// // Check for specific tag
663    /// let tags = repo.list_tags().await?;
664    /// if tags.contains(&"v1.0.0".to_string()) {
665    ///     repo.checkout("v1.0.0").await?;
666    /// }
667    /// # Ok(())
668    /// # }
669    /// ```
670    ///
671    /// # Version Parsing
672    ///
673    /// For semantic version ordering, consider using the `semver` crate:
674    ///
675    /// ```rust,no_run
676    /// # use anyhow::Result;
677    /// use semver::Version;
678    /// use agpm_cli::git::GitRepo;
679    ///
680    /// # async fn version_example() -> Result<()> {
681    /// let repo = GitRepo::new("/path/to/repo");
682    /// let tags = repo.list_tags().await?;
683    ///
684    /// // Parse and sort semantic versions
685    /// let mut versions: Vec<Version> = tags
686    ///     .iter()
687    ///     .filter_map(|tag| tag.strip_prefix('v'))
688    ///     .filter_map(|v| Version::parse(v).ok())
689    ///     .collect();
690    /// versions.sort();
691    /// # Ok(())
692    /// # }
693    /// ```
694    ///
695    /// # Errors
696    ///
697    /// Returns [`AgpmError::GitCommandError`] if:
698    /// - The repository path doesn't exist
699    /// - The directory is not a valid Git repository
700    /// - Git command execution fails
701    /// - File system permissions prevent access
702    ///
703    /// # Performance
704    ///
705    /// This operation is relatively fast as it only reads Git's tag database
706    /// without network access. For repositories with thousands of tags,
707    /// consider filtering or pagination if memory usage is a concern.
708    ///
709    /// [`AgpmError::GitCommandError`]: crate::core::AgpmError::GitCommandError
710    pub async fn list_tags(&self) -> Result<Vec<String>> {
711        // Check if the directory exists and is a git repo
712        if !self.path.exists() {
713            return Err(anyhow::anyhow!("Repository path does not exist: {:?}", self.path));
714        }
715
716        // Check if it's a git repository (either regular or bare)
717        // Regular repos have .git directory, bare repos have HEAD file
718        if !self.path.join(".git").exists() && !self.path.join("HEAD").exists() {
719            return Err(anyhow::anyhow!("Not a git repository: {:?}", self.path));
720        }
721
722        let stdout = GitCommand::list_tags()
723            .current_dir(&self.path)
724            .execute_stdout()
725            .await
726            .context(format!("Failed to list git tags in {:?}", self.path))?;
727
728        Ok(stdout
729            .lines()
730            .filter(|line| !line.is_empty())
731            .map(std::string::ToString::to_string)
732            .collect())
733    }
734
735    /// Retrieves the URL of the remote 'origin' repository.
736    ///
737    /// This method queries the Git repository for the URL associated with the
738    /// 'origin' remote, which is typically the source repository from which
739    /// the local repository was cloned.
740    ///
741    /// # Return Value
742    ///
743    /// Returns the origin URL as configured in the repository's Git configuration.
744    /// The URL format depends on how the repository was cloned:
745    /// - HTTPS: `https://github.com/user/repo.git`
746    /// - SSH: `git@github.com:user/repo.git`
747    /// - File: `file:///path/to/repo.git`
748    ///
749    /// # Authentication Handling
750    ///
751    /// The returned URL reflects the repository's configured origin, which may
752    /// or may not include authentication information depending on the original
753    /// clone method and Git configuration.
754    ///
755    /// # Examples
756    ///
757    /// ```rust,no_run
758    /// use agpm_cli::git::GitRepo;
759    ///
760    /// # async fn example() -> anyhow::Result<()> {
761    /// let repo = GitRepo::new("/path/to/repo");
762    ///
763    /// // Get the origin URL
764    /// let url = repo.get_remote_url().await?;
765    /// println!("Repository origin: {}", url);
766    ///
767    /// // Check if it's a specific platform
768    /// if url.contains("github.com") {
769    ///     println!("This is a GitHub repository");
770    /// }
771    /// # Ok(())
772    /// # }
773    /// ```
774    ///
775    /// # URL Processing
776    ///
777    /// For processing the URL further, consider using [`parse_git_url`]:
778    ///
779    /// ```rust,no_run
780    /// use agpm_cli::git::{GitRepo, parse_git_url};
781    ///
782    /// # async fn parse_example() -> anyhow::Result<()> {
783    /// let repo = GitRepo::new("/path/to/repo");
784    /// let url = repo.get_remote_url().await?;
785    ///
786    /// // Parse into owner and repository name
787    /// let (owner, name) = parse_git_url(&url)?;
788    /// println!("Owner: {}, Repository: {}", owner, name);
789    /// # Ok(())
790    /// # }
791    /// ```
792    ///
793    /// # Errors
794    ///
795    /// Returns [`AgpmError::GitCommandError`] if:
796    /// - No 'origin' remote is configured
797    /// - The repository is not a valid Git repository
798    /// - Git command execution fails
799    /// - File system access is denied
800    ///
801    /// # Security
802    ///
803    /// The returned URL may contain authentication information if it was
804    /// configured that way. Be cautious when logging or displaying URLs
805    /// that might contain sensitive tokens or credentials.
806    ///
807    /// [`parse_git_url`]: fn.parse_git_url.html
808    /// [`AgpmError::GitCommandError`]: crate::core::AgpmError::GitCommandError
809    pub async fn get_remote_url(&self) -> Result<String> {
810        GitCommand::remote_url().current_dir(&self.path).execute_stdout().await
811    }
812
813    /// Checks if the directory contains a valid Git repository.\n    ///
814    /// This method detects both regular and bare Git repositories:\n    /// - **Regular repositories**: Have a `.git` subdirectory\n    /// - **Bare repositories**: Have a `HEAD` file in the root\n    ///
815    /// Bare repositories are commonly used for:\n    /// - Serving repositories (like GitHub/GitLab)\n    /// - Cache storage in package managers\n    /// - Worktree sources for parallel operations\n    ///
816    /// # Return Value\n    ///
817    /// - `true` if the directory is a valid Git repository (regular or bare)\n    /// - `false` if neither `.git` directory nor `HEAD` file exists\n    ///
818    /// # Performance\n    ///
819    /// This method is intentionally synchronous and lightweight for efficiency.\n    /// It performs at most two filesystem checks without spawning async tasks or\n    /// executing Git commands.\n    ///
820    /// # Examples\n    ///
821    /// ```rust,no_run
822    /// use agpm_cli::git::GitRepo;
823    ///
824    /// // Regular repository
825    /// let repo = GitRepo::new("/path/to/regular/repo");
826    /// if repo.is_git_repo() {
827    ///     println!("Valid Git repository detected");
828    /// }
829    ///
830    /// // Bare repository
831    /// let bare_repo = GitRepo::new("/path/to/repo.git");
832    /// if bare_repo.is_git_repo() {
833    ///     println!("Valid bare Git repository detected");
834    /// }
835    ///
836    /// // Use before async operations
837    /// # async fn async_example() -> anyhow::Result<()> {
838    /// let repo = GitRepo::new("/path/to/repo");
839    /// if repo.is_git_repo() {
840    ///     let tags = repo.list_tags().await?;
841    ///     // Process tags...
842    /// }
843    /// # Ok(())
844    /// # }
845    /// ```
846    ///
847    /// # Validation Scope
848    ///
849    /// This method only checks for the presence of Git repository markers. It does not:
850    /// - Validate Git repository integrity
851    /// - Check for repository corruption
852    /// - Verify specific Git version compatibility
853    /// - Test network connectivity to remotes
854    ///
855    /// For more thorough validation, use Git operations that will fail with\n    /// detailed error information if the repository is corrupted.
856    ///
857    /// # Alternative
858    ///
859    /// For error-based validation with detailed context, use [`ensure_valid_git_repo`]:
860    ///
861    /// ```rust,no_run
862    /// use agpm_cli::git::ensure_valid_git_repo;
863    /// use std::path::Path;
864    ///
865    /// # fn example() -> anyhow::Result<()> {
866    /// let path = Path::new("/path/to/repo");
867    /// ensure_valid_git_repo(path)?; // Returns detailed error if invalid
868    /// # Ok(())
869    /// # }
870    /// ```
871    ///
872    /// [`ensure_valid_git_repo`]: fn.ensure_valid_git_repo.html
873    #[must_use]
874    pub fn is_git_repo(&self) -> bool {
875        is_git_repository(&self.path)
876    }
877
878    /// Returns the filesystem path to the Git repository.
879    ///
880    /// This method provides access to the repository's root directory path
881    /// as configured when the `GitRepo` instance was created.
882    ///
883    /// # Return Value
884    ///
885    /// Returns a reference to the [`Path`] representing the repository's
886    /// root directory (the directory containing the `.git` subdirectory).
887    ///
888    /// # Examples
889    ///
890    /// ```rust,no_run
891    /// use agpm_cli::git::GitRepo;
892    /// use std::path::Path;
893    ///
894    /// let repo = GitRepo::new("/home/user/my-project");
895    /// let path = repo.path();
896    ///
897    /// println!("Repository path: {}", path.display());
898    /// assert_eq!(path, Path::new("/home/user/my-project"));
899    ///
900    /// // Use for file operations within the repository
901    /// let readme_path = path.join("README.md");
902    /// if readme_path.exists() {
903    ///     println!("Repository has a README file");
904    /// }
905    /// ```
906    ///
907    /// # File System Operations
908    ///
909    /// The returned path can be used for various filesystem operations:
910    ///
911    /// ```rust,no_run
912    /// use agpm_cli::git::GitRepo;
913    ///
914    /// # fn example() -> std::io::Result<()> {
915    /// let repo = GitRepo::new("/path/to/repo");
916    /// let repo_path = repo.path();
917    ///
918    /// // Check repository contents
919    /// for entry in std::fs::read_dir(repo_path)? {
920    ///     let entry = entry?;
921    ///     println!("Found: {}", entry.file_name().to_string_lossy());
922    /// }
923    ///
924    /// // Access specific files
925    /// let manifest_path = repo_path.join("Cargo.toml");
926    /// if manifest_path.exists() {
927    ///     println!("Rust project detected");
928    /// }
929    /// # Ok(())
930    /// # }
931    /// ```
932    ///
933    /// # Path Validity
934    ///
935    /// The returned path reflects the value provided during construction and
936    /// may not exist or may not be a valid Git repository. Use [`is_git_repo`]
937    /// to validate the repository state.
938    ///
939    /// [`is_git_repo`]: #method.is_git_repo
940    #[must_use]
941    pub fn path(&self) -> &Path {
942        &self.path
943    }
944
945    /// Verifies that a Git repository URL is accessible without performing a full clone.
946    ///
947    /// This static method performs a lightweight check to determine if a repository
948    /// URL is valid and accessible. It uses `git ls-remote` for remote repositories
949    /// or filesystem checks for local paths.
950    ///
951    /// # Arguments
952    ///
953    /// * `url` - The repository URL to verify
954    ///
955    /// # Verification Methods
956    ///
957    /// - **Local repositories** (`file://` URLs): Checks if the path exists
958    /// - **Remote repositories**: Uses `git ls-remote --heads` to test connectivity
959    /// - **Authentication**: Leverages system Git configuration and credential helpers
960    ///
961    /// # Supported URL Types
962    ///
963    /// - `https://github.com/user/repo.git` - HTTPS with optional authentication
964    /// - `git@github.com:user/repo.git` - SSH with key-based authentication
965    /// - `file:///path/to/repo` - Local filesystem repositories
966    /// - `https://token:value@host.com/repo.git` - HTTPS with embedded credentials
967    ///
968    /// # Examples
969    ///
970    /// ```ignore
971    /// use agpm_cli::git::GitRepo;
972    ///
973    /// # async fn example() -> anyhow::Result<()> {
974    /// // Verify public repository
975    /// GitRepo::verify_url("https://github.com/rust-lang/git2-rs.git").await?;
976    ///
977    /// // Verify before cloning
978    /// let url = "https://github.com/user/private-repo.git";
979    /// match GitRepo::verify_url(url).await {
980    ///     Ok(_) => {
981    ///         let repo = GitRepo::clone(url, "/tmp/repo").await?;
982    ///         println!("Repository cloned successfully");
983    ///     }
984    ///     Err(e) => {
985    ///         eprintln!("Repository not accessible: {}", e);
986    ///     }
987    /// }
988    ///
989    /// // Verify local repository
990    /// GitRepo::verify_url("file:///home/user/local-repo").await?;
991    /// # Ok(())
992    /// # }
993    /// ```
994    ///
995    /// # Performance Benefits
996    ///
997    /// This method is much faster than attempting a full clone because it:
998    /// - Only queries repository metadata (refs and heads)
999    /// - Transfers minimal data over the network
1000    /// - Avoids creating local filesystem structures
1001    /// - Provides quick feedback on accessibility
1002    ///
1003    /// # Authentication Testing
1004    ///
1005    /// The verification process tests the complete authentication chain:
1006    /// - Credential helper invocation
1007    /// - SSH key validation (for SSH URLs)
1008    /// - Token validation (for HTTPS URLs)
1009    /// - Network connectivity and DNS resolution
1010    ///
1011    /// # Use Cases
1012    ///
1013    /// - **Pre-flight checks**: Validate URLs before expensive clone operations
1014    /// - **Dependency validation**: Ensure all repository sources are accessible
1015    /// - **Configuration testing**: Verify authentication setup
1016    /// - **Network diagnostics**: Test connectivity to repository hosts
1017    ///
1018    /// # Errors
1019    ///
1020    /// Returns an error if:
1021    /// - **Network issues**: DNS resolution, connectivity, timeouts
1022    /// - **Authentication failures**: Invalid credentials, expired tokens
1023    /// - **Repository issues**: Repository doesn't exist, access denied
1024    /// - **Local path issues**: File doesn't exist (for `file://` URLs)
1025    /// - **URL format issues**: Malformed or unsupported URL schemes
1026    ///
1027    /// # Security
1028    ///
1029    /// This method respects the same security boundaries as Git operations:
1030    /// - Uses system Git configuration and security settings
1031    /// - Never bypasses authentication requirements
1032    /// - Doesn't cache or expose authentication credentials
1033    /// - Follows Git's SSL/TLS verification policies
1034    pub async fn verify_url(url: &str) -> Result<()> {
1035        // For file:// URLs, just check if the path exists
1036        if url.starts_with("file://") {
1037            let path = url.strip_prefix("file://").unwrap();
1038            return if std::path::Path::new(path).exists() {
1039                Ok(())
1040            } else {
1041                Err(anyhow::anyhow!("Local path does not exist: {path}"))
1042            };
1043        }
1044
1045        // For all other URLs, use ls-remote to verify
1046        GitCommand::ls_remote(url)
1047            .execute_success()
1048            .await
1049            .context("Failed to verify remote repository")
1050    }
1051
1052    /// Fetch updates for a bare repository with logging context.
1053    async fn ensure_bare_repo_has_refs_with_context(&self, context: Option<&str>) -> Result<()> {
1054        // Try to fetch to ensure we have refs
1055        let mut fetch_cmd = GitCommand::fetch().current_dir(&self.path);
1056
1057        if let Some(ctx) = context {
1058            fetch_cmd = fetch_cmd.with_context(ctx);
1059        }
1060
1061        let fetch_result = fetch_cmd.execute_success().await;
1062
1063        if fetch_result.is_err() {
1064            // If fetch fails, it might be because there's no remote
1065            // Just check if we have any refs at all
1066            let mut check_cmd =
1067                GitCommand::new().args(["show-ref", "--head"]).current_dir(&self.path);
1068
1069            if let Some(ctx) = context {
1070                check_cmd = check_cmd.with_context(ctx);
1071            }
1072
1073            check_cmd
1074                .execute_success()
1075                .await
1076                .map_err(|e| anyhow::anyhow!("Bare repository has no refs available: {e}"))?;
1077        }
1078
1079        Ok(())
1080    }
1081
1082    /// Clone a repository as a bare repository (no working directory).
1083    ///
1084    /// Bare repositories are optimized for use as a source for worktrees,
1085    /// allowing multiple concurrent checkouts without conflicts.
1086    ///
1087    /// # Arguments
1088    ///
1089    /// * `url` - The remote repository URL
1090    /// * `target` - The local directory where the bare repository will be stored
1091    /// * `progress` - Optional progress bar for user feedback
1092    ///
1093    /// # Returns
1094    ///
1095    /// Returns a new `GitRepo` instance pointing to the bare repository
1096    ///
1097    /// # Examples
1098    ///
1099    /// ```ignore
1100    /// use agpm_cli::git::GitRepo;
1101    /// use std::env;
1102    ///
1103    /// # async fn example() -> anyhow::Result<()> {
1104    /// let temp_dir = env::temp_dir();
1105    /// let bare_repo = GitRepo::clone_bare(
1106    ///     "https://github.com/example/repo.git",
1107    ///     temp_dir.join("repo.git")
1108    /// ).await?;
1109    /// # Ok(())
1110    /// # }
1111    /// ```
1112    pub async fn clone_bare(url: &str, target: impl AsRef<Path>) -> Result<Self> {
1113        Self::clone_bare_with_context(url, target, None).await
1114    }
1115
1116    /// Clone a repository as a bare repository with logging context.
1117    ///
1118    /// Bare repositories are optimized for use as a source for worktrees,
1119    /// allowing multiple concurrent checkouts without conflicts.
1120    ///
1121    /// # Arguments
1122    ///
1123    /// * `url` - The remote repository URL
1124    /// * `target` - The local directory where the bare repository will be stored
1125    /// * `progress` - Optional progress bar for user feedback
1126    /// * `context` - Optional context for logging (e.g., dependency name)
1127    ///
1128    /// # Returns
1129    ///
1130    /// Returns a new `GitRepo` instance pointing to the bare repository
1131    pub async fn clone_bare_with_context(
1132        url: &str,
1133        target: impl AsRef<Path>,
1134        context: Option<&str>,
1135    ) -> Result<Self> {
1136        let target_path = target.as_ref();
1137
1138        let mut cmd = GitCommand::clone_bare(url, target_path);
1139
1140        if let Some(ctx) = context {
1141            cmd = cmd.with_context(ctx);
1142        }
1143
1144        cmd.execute_success().await?;
1145
1146        let repo = Self::new(target_path);
1147
1148        // Configure the fetch refspec to ensure all branches are fetched as remote tracking branches
1149        // This is crucial for file:// URLs and ensures we can resolve origin/branch after fetching
1150        let _ = GitCommand::new()
1151            .args(["config", "remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*"])
1152            .current_dir(repo.path())
1153            .execute_success()
1154            .await;
1155
1156        // Ensure the bare repo has refs available for worktree creation
1157        // Also needs context for the fetch operation
1158        repo.ensure_bare_repo_has_refs_with_context(context).await.ok();
1159
1160        Ok(repo)
1161    }
1162
1163    /// Create a new worktree from this repository.
1164    ///
1165    /// Worktrees allow multiple working directories to be checked out from
1166    /// a single repository, enabling parallel operations on different versions.
1167    ///
1168    /// # Arguments
1169    ///
1170    /// * `worktree_path` - The path where the worktree will be created
1171    /// * `reference` - Optional Git reference (branch/tag/commit) to checkout
1172    ///
1173    /// # Returns
1174    ///
1175    /// Returns a new `GitRepo` instance pointing to the worktree
1176    ///
1177    /// # Examples
1178    ///
1179    /// ```rust,no_run
1180    /// use agpm_cli::git::GitRepo;
1181    ///
1182    /// # async fn example() -> anyhow::Result<()> {
1183    /// let bare_repo = GitRepo::new("/path/to/bare.git");
1184    ///
1185    /// // Create worktree with specific version
1186    /// let worktree = bare_repo.create_worktree(
1187    ///     "/tmp/worktree1",
1188    ///     Some("v1.0.0")
1189    /// ).await?;
1190    ///
1191    /// // Create worktree with default branch
1192    /// let worktree2 = bare_repo.create_worktree(
1193    ///     "/tmp/worktree2",
1194    ///     None
1195    /// ).await?;
1196    /// # Ok(())
1197    /// # }
1198    /// ```
1199    pub async fn create_worktree(
1200        &self,
1201        worktree_path: impl AsRef<Path>,
1202        reference: Option<&str>,
1203    ) -> Result<Self> {
1204        self.create_worktree_with_context(worktree_path, reference, None).await
1205    }
1206
1207    /// Create a new worktree from this repository with logging context.
1208    ///
1209    /// Worktrees allow multiple working directories to be checked out from
1210    /// a single repository, enabling parallel operations on different versions.
1211    ///
1212    /// # Arguments
1213    ///
1214    /// * `worktree_path` - The path where the worktree will be created
1215    /// * `reference` - Optional Git reference (branch/tag/commit) to checkout
1216    /// * `context` - Optional context for logging (e.g., dependency name)
1217    ///
1218    /// # Returns
1219    ///
1220    /// Returns a new `GitRepo` instance pointing to the worktree
1221    pub async fn create_worktree_with_context(
1222        &self,
1223        worktree_path: impl AsRef<Path>,
1224        reference: Option<&str>,
1225        context: Option<&str>,
1226    ) -> Result<Self> {
1227        let worktree_path = worktree_path.as_ref();
1228
1229        // Ensure parent directory exists
1230        if let Some(parent) = worktree_path.parent() {
1231            tokio::fs::create_dir_all(parent).await.with_context(|| {
1232                format!("Failed to create parent directory for worktree: {parent:?}")
1233            })?;
1234        }
1235
1236        // Retry logic for worktree creation to handle concurrent operations
1237        let max_retries = 3;
1238        let mut retry_count = 0;
1239
1240        loop {
1241            // For bare repositories, we may need to handle the case where no default branch exists yet
1242            // If no reference provided, try to use the default branch
1243            let default_branch = if reference.is_none() && retry_count == 0 {
1244                // Try to get the default branch
1245                GitCommand::new()
1246                    .args(["symbolic-ref", "refs/remotes/origin/HEAD"])
1247                    .current_dir(&self.path)
1248                    .execute_stdout()
1249                    .await
1250                    .ok()
1251                    .and_then(|s| s.strip_prefix("refs/remotes/origin/").map(String::from))
1252                    .or_else(|| Some("main".to_string()))
1253            } else {
1254                None
1255            };
1256
1257            let effective_ref = if let Some(ref branch) = default_branch {
1258                Some(branch.as_str())
1259            } else {
1260                reference
1261            };
1262
1263            let mut cmd =
1264                GitCommand::worktree_add(worktree_path, effective_ref).current_dir(&self.path);
1265
1266            if let Some(ctx) = context {
1267                cmd = cmd.with_context(ctx);
1268            }
1269
1270            let result = cmd.execute_success().await;
1271
1272            match result {
1273                Ok(()) => {
1274                    // Initialize and update submodules in the new worktree
1275                    let worktree_repo = Self::new(worktree_path);
1276
1277                    // Initialize submodules
1278                    let mut init_cmd =
1279                        GitCommand::new().args(["submodule", "init"]).current_dir(worktree_path);
1280
1281                    if let Some(ctx) = context {
1282                        init_cmd = init_cmd.with_context(ctx);
1283                    }
1284
1285                    // Ignore errors - if there are no submodules, this will fail
1286                    let _ = init_cmd.execute_success().await;
1287
1288                    // Update submodules
1289                    let mut update_cmd = GitCommand::new()
1290                        .args(["submodule", "update", "--recursive"])
1291                        .current_dir(worktree_path);
1292
1293                    if let Some(ctx) = context {
1294                        update_cmd = update_cmd.with_context(ctx);
1295                    }
1296
1297                    // Ignore errors - if there are no submodules, this will fail
1298                    let _ = update_cmd.execute_success().await;
1299
1300                    return Ok(worktree_repo);
1301                }
1302                Err(e) => {
1303                    let error_str = e.to_string();
1304
1305                    // Check if this is a concurrent access issue
1306                    if error_str.contains("already exists")
1307                        || error_str.contains("is already checked out")
1308                        || error_str.contains("fatal: could not create directory")
1309                    {
1310                        retry_count += 1;
1311                        if retry_count >= max_retries {
1312                            return Err(e).with_context(|| {
1313                                format!(
1314                                    "Failed to create worktree at {} from {} after {} retries",
1315                                    worktree_path.display(),
1316                                    self.path.display(),
1317                                    max_retries
1318                                )
1319                            });
1320                        }
1321
1322                        // Wait a bit before retrying
1323                        tokio::time::sleep(tokio::time::Duration::from_millis(100 * retry_count))
1324                            .await;
1325                        continue;
1326                    }
1327
1328                    // Handle stale registration: "missing but already registered worktree"
1329                    if error_str.contains("missing but already registered worktree") {
1330                        // Prune stale admin entries, then retry (once) with --force
1331                        let _ = self.prune_worktrees().await;
1332
1333                        // Retry with --force
1334                        let worktree_path_str = worktree_path.display().to_string();
1335                        let mut args = vec![
1336                            "worktree".to_string(),
1337                            "add".to_string(),
1338                            "--force".to_string(),
1339                            worktree_path_str,
1340                        ];
1341                        if let Some(r) = effective_ref {
1342                            args.push(r.to_string());
1343                        }
1344
1345                        let mut force_cmd = GitCommand::new().args(args).current_dir(&self.path);
1346                        if let Some(ctx) = context {
1347                            force_cmd = force_cmd.with_context(ctx);
1348                        }
1349
1350                        match force_cmd.execute_success().await {
1351                            Ok(()) => {
1352                                // Initialize and update submodules in the new worktree
1353                                let worktree_repo = Self::new(worktree_path);
1354
1355                                let mut init_cmd = GitCommand::new()
1356                                    .args(["submodule", "init"])
1357                                    .current_dir(worktree_path);
1358                                if let Some(ctx) = context {
1359                                    init_cmd = init_cmd.with_context(ctx);
1360                                }
1361                                let _ = init_cmd.execute_success().await;
1362
1363                                let mut update_cmd = GitCommand::new()
1364                                    .args(["submodule", "update", "--recursive"])
1365                                    .current_dir(worktree_path);
1366                                if let Some(ctx) = context {
1367                                    update_cmd = update_cmd.with_context(ctx);
1368                                }
1369                                let _ = update_cmd.execute_success().await;
1370
1371                                return Ok(worktree_repo);
1372                            }
1373                            Err(e2) => {
1374                                // Fall through to other recovery paths with the original error context
1375                                // but include the forced attempt error as context
1376                                return Err(e).with_context(|| {
1377                                    format!(
1378                                        "Failed to create worktree at {} from {} (forced add failed: {})",
1379                                        worktree_path.display(),
1380                                        self.path.display(),
1381                                        e2
1382                                    )
1383                                });
1384                            }
1385                        }
1386                    }
1387
1388                    // If no reference was provided and the command failed, it might be because
1389                    // the bare repo doesn't have a default branch set. Try with explicit HEAD
1390                    if reference.is_none() && retry_count == 0 {
1391                        let mut head_cmd = GitCommand::worktree_add(worktree_path, Some("HEAD"))
1392                            .current_dir(&self.path);
1393
1394                        if let Some(ctx) = context {
1395                            head_cmd = head_cmd.with_context(ctx);
1396                        }
1397
1398                        let head_result = head_cmd.execute_success().await;
1399
1400                        match head_result {
1401                            Ok(()) => {
1402                                // Initialize and update submodules in the new worktree
1403                                let worktree_repo = Self::new(worktree_path);
1404
1405                                // Initialize submodules
1406                                let mut init_cmd = GitCommand::new()
1407                                    .args(["submodule", "init"])
1408                                    .current_dir(worktree_path);
1409
1410                                if let Some(ctx) = context {
1411                                    init_cmd = init_cmd.with_context(ctx);
1412                                }
1413
1414                                // Ignore errors - if there are no submodules, this will fail
1415                                let _ = init_cmd.execute_success().await;
1416
1417                                // Update submodules
1418                                let mut update_cmd = GitCommand::new()
1419                                    .args(["submodule", "update", "--recursive"])
1420                                    .current_dir(worktree_path);
1421
1422                                if let Some(ctx) = context {
1423                                    update_cmd = update_cmd.with_context(ctx);
1424                                }
1425
1426                                // Ignore errors - if there are no submodules, this will fail
1427                                let _ = update_cmd.execute_success().await;
1428
1429                                return Ok(worktree_repo);
1430                            }
1431                            Err(head_err) => {
1432                                // If HEAD also fails, return the original error
1433                                return Err(e).with_context(|| {
1434                                    format!(
1435                                        "Failed to create worktree at {} from {} (also tried HEAD: {})",
1436                                        worktree_path.display(),
1437                                        self.path.display(),
1438                                        head_err
1439                                    )
1440                                });
1441                            }
1442                        }
1443                    }
1444
1445                    // Check if the error is likely due to an invalid reference
1446                    let error_str = e.to_string();
1447                    if let Some(ref_name) = reference
1448                        && (error_str.contains("pathspec")
1449                            || error_str.contains("not found")
1450                            || error_str.contains("ambiguous")
1451                            || error_str.contains("invalid")
1452                            || error_str.contains("unknown revision"))
1453                    {
1454                        return Err(anyhow::anyhow!(
1455                            "Invalid version or reference '{ref_name}': Failed to checkout reference - the specified version/tag/branch does not exist in the repository"
1456                        ));
1457                    }
1458
1459                    return Err(e).with_context(|| {
1460                        format!(
1461                            "Failed to create worktree at {} from {}",
1462                            worktree_path.display(),
1463                            self.path.display()
1464                        )
1465                    });
1466                }
1467            }
1468        }
1469    }
1470
1471    /// Remove a worktree associated with this repository.
1472    ///
1473    /// This removes the worktree and its administrative files, but preserves
1474    /// the bare repository for future use.
1475    ///
1476    /// # Arguments
1477    ///
1478    /// * `worktree_path` - The path to the worktree to remove
1479    ///
1480    /// # Examples
1481    ///
1482    /// ```rust,no_run
1483    /// use agpm_cli::git::GitRepo;
1484    ///
1485    /// # async fn example() -> anyhow::Result<()> {
1486    /// let bare_repo = GitRepo::new("/path/to/bare.git");
1487    /// bare_repo.remove_worktree("/tmp/worktree1").await?;
1488    /// # Ok(())
1489    /// # }
1490    /// ```
1491    pub async fn remove_worktree(&self, worktree_path: impl AsRef<Path>) -> Result<()> {
1492        let worktree_path = worktree_path.as_ref();
1493
1494        GitCommand::worktree_remove(worktree_path)
1495            .current_dir(&self.path)
1496            .execute_success()
1497            .await
1498            .with_context(|| format!("Failed to remove worktree at {}", worktree_path.display()))?;
1499
1500        // Also try to remove the directory if it still exists
1501        if worktree_path.exists() {
1502            tokio::fs::remove_dir_all(worktree_path).await.ok(); // Ignore errors as git worktree remove may have already cleaned it
1503        }
1504
1505        Ok(())
1506    }
1507
1508    /// List all worktrees associated with this repository.
1509    ///
1510    /// Returns a list of paths to existing worktrees.
1511    ///
1512    /// # Examples
1513    ///
1514    /// ```rust,no_run
1515    /// use agpm_cli::git::GitRepo;
1516    ///
1517    /// # async fn example() -> anyhow::Result<()> {
1518    /// let bare_repo = GitRepo::new("/path/to/bare.git");
1519    /// let worktrees = bare_repo.list_worktrees().await?;
1520    /// for worktree in worktrees {
1521    ///     println!("Worktree: {}", worktree.display());
1522    /// }
1523    /// # Ok(())
1524    /// # }
1525    /// ```
1526    pub async fn list_worktrees(&self) -> Result<Vec<PathBuf>> {
1527        let output = GitCommand::worktree_list().current_dir(&self.path).execute_stdout().await?;
1528
1529        let mut worktrees = Vec::new();
1530        let mut current_worktree: Option<PathBuf> = None;
1531
1532        for line in output.lines() {
1533            if line.starts_with("worktree ") {
1534                if let Some(path) = line.strip_prefix("worktree ") {
1535                    current_worktree = Some(PathBuf::from(path));
1536                }
1537            } else if line == "bare" {
1538                // Skip bare repository entry
1539                current_worktree = None;
1540            } else if line.is_empty()
1541                && current_worktree.is_some()
1542                && let Some(path) = current_worktree.take()
1543            {
1544                worktrees.push(path);
1545            }
1546        }
1547
1548        // Add the last worktree if there is one
1549        if let Some(path) = current_worktree {
1550            worktrees.push(path);
1551        }
1552
1553        Ok(worktrees)
1554    }
1555
1556    /// Prune stale worktree administrative files.
1557    ///
1558    /// This cleans up worktree entries that no longer have a corresponding
1559    /// working directory on disk.
1560    ///
1561    /// # Examples
1562    ///
1563    /// ```rust,no_run
1564    /// use agpm_cli::git::GitRepo;
1565    ///
1566    /// # async fn example() -> anyhow::Result<()> {
1567    /// let bare_repo = GitRepo::new("/path/to/bare.git");
1568    /// bare_repo.prune_worktrees().await?;
1569    /// # Ok(())
1570    /// # }
1571    /// ```
1572    pub async fn prune_worktrees(&self) -> Result<()> {
1573        GitCommand::worktree_prune()
1574            .current_dir(&self.path)
1575            .execute_success()
1576            .await
1577            .with_context(|| "Failed to prune worktrees")?;
1578
1579        Ok(())
1580    }
1581
1582    /// Check if this repository is a bare repository.
1583    ///
1584    /// Bare repositories don't have a working directory and are optimized
1585    /// for use as a source for worktrees.
1586    ///
1587    /// # Examples
1588    ///
1589    /// ```rust,no_run
1590    /// use agpm_cli::git::GitRepo;
1591    ///
1592    /// # async fn example() -> anyhow::Result<()> {
1593    /// let repo = GitRepo::new("/path/to/repo.git");
1594    /// if repo.is_bare().await? {
1595    ///     println!("This is a bare repository");
1596    /// }
1597    /// # Ok(())
1598    /// # }
1599    /// ```
1600    pub async fn is_bare(&self) -> Result<bool> {
1601        let output = GitCommand::new()
1602            .args(["config", "--get", "core.bare"])
1603            .current_dir(&self.path)
1604            .execute_stdout()
1605            .await?;
1606
1607        Ok(output.trim() == "true")
1608    }
1609
1610    /// Get the current commit SHA of the repository.
1611    ///
1612    /// Returns the full 40-character SHA-1 hash of the current HEAD commit.
1613    /// This is useful for recording exact versions in lockfiles.
1614    ///
1615    /// # Returns
1616    ///
1617    /// The full commit hash as a string.
1618    ///
1619    /// # Errors
1620    ///
1621    /// Returns an error if:
1622    /// - The repository is not valid
1623    /// - HEAD is not pointing to a valid commit
1624    /// - Git command fails
1625    ///
1626    /// # Examples
1627    ///
1628    /// ```no_run
1629    /// # use agpm_cli::git::GitRepo;
1630    /// # async fn example() -> anyhow::Result<()> {
1631    /// let repo = GitRepo::new("/path/to/repo");
1632    /// let commit = repo.get_current_commit().await?;
1633    /// println!("Current commit: {}", commit);
1634    /// # Ok(())
1635    /// # }
1636    /// ```
1637    pub async fn get_current_commit(&self) -> Result<String> {
1638        GitCommand::current_commit()
1639            .current_dir(&self.path)
1640            .execute_stdout()
1641            .await
1642            .context("Failed to get current commit")
1643    }
1644
1645    /// Resolves a Git reference (tag, branch, commit) to its full SHA-1 hash.
1646    ///
1647    /// This method is central to AGPM's optimization strategy - by resolving all
1648    /// version specifications to SHAs upfront, we can:
1649    /// - Create worktrees keyed by SHA for maximum reuse
1650    /// - Avoid redundant checkouts for the same commit
1651    /// - Ensure deterministic, reproducible installations
1652    ///
1653    /// # Arguments
1654    ///
1655    /// * `ref_spec` - The Git reference to resolve (tag, branch, short/full SHA, or None for HEAD)
1656    ///
1657    /// # Returns
1658    ///
1659    /// Returns the full 40-character SHA-1 hash of the resolved reference.
1660    ///
1661    /// # Resolution Strategy
1662    ///
1663    /// 1. If `ref_spec` is None or "HEAD", resolves to current HEAD commit
1664    /// 2. If already a full SHA (40 hex chars), returns it unchanged
1665    /// 3. Otherwise uses `git rev-parse` to resolve:
1666    ///    - Tags (e.g., "v1.0.0")
1667    ///    - Branches (e.g., "main", "origin/main")
1668    ///    - Short SHAs (e.g., "abc123")
1669    ///    - Symbolic refs (e.g., "HEAD~1")
1670    ///
1671    /// # Examples
1672    ///
1673    /// ```no_run
1674    /// # use agpm_cli::git::GitRepo;
1675    /// # async fn example() -> anyhow::Result<()> {
1676    /// let repo = GitRepo::new("/path/to/repo");
1677    ///
1678    /// // Resolve a tag
1679    /// let sha = repo.resolve_to_sha(Some("v1.2.3")).await?;
1680    /// assert_eq!(sha.len(), 40);
1681    ///
1682    /// // Resolve HEAD
1683    /// let head_sha = repo.resolve_to_sha(None).await?;
1684    ///
1685    /// // Already a full SHA - returned as-is
1686    /// let full_sha = "a".repeat(40);
1687    /// let resolved = repo.resolve_to_sha(Some(&full_sha)).await?;
1688    /// assert_eq!(resolved, full_sha);
1689    /// # Ok(())
1690    /// # }
1691    /// ```
1692    ///
1693    /// # Errors
1694    ///
1695    /// Returns an error if:
1696    /// - The reference doesn't exist in the repository
1697    /// - The repository is invalid or corrupted
1698    /// - Git command execution fails
1699    pub async fn resolve_to_sha(&self, ref_spec: Option<&str>) -> Result<String> {
1700        let reference = ref_spec.unwrap_or("HEAD");
1701
1702        // Optimization: if it's already a full SHA, return it directly
1703        if reference.len() == 40 && reference.chars().all(|c| c.is_ascii_hexdigit()) {
1704            return Ok(reference.to_string());
1705        }
1706
1707        // For branch names, try to resolve origin/branch first to get the latest from remote
1708        // This ensures we get the most recent commit after a fetch
1709        let ref_to_resolve = if !reference.contains('/') && reference != "HEAD" {
1710            // Looks like a branch name (not a tag or special ref)
1711            // Try origin/branch first
1712            let origin_ref = format!("origin/{reference}");
1713            if GitCommand::rev_parse(&origin_ref)
1714                .current_dir(&self.path)
1715                .execute_stdout()
1716                .await
1717                .is_ok()
1718            {
1719                origin_ref
1720            } else {
1721                // Fallback to the original reference (might be a tag or local branch)
1722                reference.to_string()
1723            }
1724        } else {
1725            reference.to_string()
1726        };
1727
1728        // Use rev-parse to get the full SHA
1729        let sha = GitCommand::rev_parse(&ref_to_resolve)
1730            .current_dir(&self.path)
1731            .execute_stdout()
1732            .await
1733            .with_context(|| format!("Failed to resolve reference '{reference}' to SHA"))?;
1734
1735        // Ensure we have a full SHA (sometimes rev-parse can return short SHAs)
1736        if sha.len() < 40 {
1737            // Request the full SHA explicitly
1738            let full_sha = GitCommand::new()
1739                .args(["rev-parse", "--verify", &format!("{reference}^{{commit}}")])
1740                .current_dir(&self.path)
1741                .execute_stdout()
1742                .await
1743                .with_context(|| format!("Failed to get full SHA for reference '{reference}'"))?;
1744            Ok(full_sha)
1745        } else {
1746            Ok(sha)
1747        }
1748    }
1749
1750    pub async fn get_current_branch(&self) -> Result<String> {
1751        let branch = GitCommand::current_branch()
1752            .current_dir(&self.path)
1753            .execute_stdout()
1754            .await
1755            .context("Failed to get current branch")?;
1756
1757        if branch.is_empty() {
1758            // Fallback for very old Git or repos without commits
1759            Ok("master".to_string())
1760        } else {
1761            Ok(branch)
1762        }
1763    }
1764
1765    /// Gets the default branch name for the repository.
1766    ///
1767    /// For bare repositories, this queries `refs/remotes/origin/HEAD` to find
1768    /// the default branch. For non-bare repositories, it returns the current branch.
1769    ///
1770    /// # Returns
1771    ///
1772    /// The default branch name (e.g., "main", "master") without the "refs/heads/" prefix.
1773    ///
1774    /// # Errors
1775    ///
1776    /// Returns an error if Git commands fail or the default branch cannot be determined.
1777    pub async fn get_default_branch(&self) -> Result<String> {
1778        // Try to get the symbolic ref for origin/HEAD (works for bare repos)
1779        let result = GitCommand::new()
1780            .args(["symbolic-ref", "refs/remotes/origin/HEAD"])
1781            .current_dir(&self.path)
1782            .execute_stdout()
1783            .await;
1784
1785        if let Ok(symbolic_ref) = result {
1786            // Parse "refs/remotes/origin/main" -> "main"
1787            if let Some(branch) = symbolic_ref.strip_prefix("refs/remotes/origin/") {
1788                return Ok(branch.to_string());
1789            }
1790        }
1791
1792        // Fallback: try to get current branch (for non-bare repos)
1793        self.get_current_branch().await
1794    }
1795}
1796
1797// Module-level helper functions for Git environment management and URL processing
1798
1799/// Checks if Git is installed and accessible on the system.
1800///
1801/// This function verifies that the system's `git` command is available in the PATH
1802/// and responds to version queries. It's a prerequisite check for all Git operations
1803/// in AGPM.
1804///
1805/// # Return Value
1806///
1807/// - `true` if Git is installed and responding to `--version` commands
1808/// - `false` if Git is not found, not in PATH, or not executable
1809///
1810/// # Implementation Details
1811///
1812/// The function uses [`get_git_command()`] to determine the appropriate Git command
1813/// for the current platform, then executes `git --version` to verify functionality.
1814///
1815/// # Platform Differences
1816///
1817/// - **Windows**: Checks for `git.exe`, `git.cmd`, or `git.bat` in PATH
1818/// - **Unix-like**: Checks for `git` command in PATH
1819/// - **All platforms**: Respects PATH environment variable ordering
1820///
1821/// # Examples
1822///
1823/// ```rust,no_run
1824/// use agpm_cli::git::is_git_installed;
1825///
1826/// if is_git_installed() {
1827///     println!("Git is available - proceeding with repository operations");
1828/// } else {
1829///     eprintln!("Error: Git is not installed or not in PATH");
1830///     std::process::exit(1);
1831/// }
1832/// ```
1833///
1834/// # Usage in AGPM
1835///
1836/// This function is typically called during:
1837/// - Application startup to validate prerequisites
1838/// - Before any Git operations to provide clear error messages
1839/// - In CI/CD pipelines to verify build environment
1840///
1841/// # Alternative
1842///
1843/// For error-based validation with detailed context, use [`ensure_git_available()`]:
1844///
1845/// ```rust,no_run
1846/// use agpm_cli::git::ensure_git_available;
1847///
1848/// # fn example() -> anyhow::Result<()> {
1849/// ensure_git_available()?; // Throws AgpmError::GitNotFound if not available
1850/// # Ok(())
1851/// # }
1852/// ```
1853///
1854/// [`get_git_command()`]: crate::utils::platform::get_git_command
1855/// [`ensure_git_available()`]: fn.ensure_git_available.html
1856#[must_use]
1857pub fn is_git_installed() -> bool {
1858    // For synchronous checking, we still use std::process::Command directly
1859    std::process::Command::new(crate::utils::platform::get_git_command())
1860        .arg("--version")
1861        .output()
1862        .map(|output| output.status.success())
1863        .unwrap_or(false)
1864}
1865
1866/// Ensures Git is available on the system or returns a detailed error.
1867///
1868/// This function validates that Git is installed and accessible, providing a
1869/// [`AgpmError::GitNotFound`] with actionable guidance if Git is unavailable.
1870/// It's the error-throwing equivalent of [`is_git_installed()`].
1871///
1872/// # Return Value
1873///
1874/// - `Ok(())` if Git is properly installed and accessible
1875/// - `Err(AgpmError::GitNotFound)` if Git is not available
1876///
1877/// # Error Context
1878///
1879/// The returned error includes:
1880/// - Clear description of the missing Git requirement
1881/// - Platform-specific installation instructions
1882/// - Troubleshooting guidance for common PATH issues
1883///
1884/// # Examples
1885///
1886/// ```rust,no_run
1887/// use agpm_cli::git::ensure_git_available;
1888///
1889/// # fn example() -> anyhow::Result<()> {
1890/// // Validate Git before starting operations
1891/// ensure_git_available()?;
1892///
1893/// // Git is guaranteed to be available beyond this point
1894/// println!("Git is available - proceeding with operations");
1895/// # Ok(())
1896/// # }
1897/// ```
1898///
1899/// # Error Handling
1900///
1901/// ```rust,no_run
1902/// use agpm_cli::git::ensure_git_available;
1903/// use agpm_cli::core::AgpmError;
1904///
1905/// match ensure_git_available() {
1906///     Ok(_) => println!("Git is ready"),
1907///     Err(e) => {
1908///         if let Some(AgpmError::GitNotFound) = e.downcast_ref::<AgpmError>() {
1909///             eprintln!("Please install Git to continue");
1910///             // Show platform-specific installation instructions
1911///         }
1912///     }
1913/// }
1914/// ```
1915///
1916/// # Usage Pattern
1917///
1918/// Typically called at the start of Git-dependent operations:
1919///
1920/// ```rust,no_run
1921/// use agpm_cli::git::{ensure_git_available, GitRepo};
1922/// use std::env;
1923///
1924/// # async fn git_operation() -> anyhow::Result<()> {
1925/// // Validate prerequisites first
1926/// ensure_git_available()?;
1927///
1928/// // Then proceed with Git operations
1929/// let temp_dir = env::temp_dir();
1930/// let repo = GitRepo::clone(
1931///     "https://github.com/example/repo.git",
1932///     temp_dir.join("repo")
1933/// ).await?;
1934/// # Ok(())
1935/// # }
1936/// ```
1937///
1938/// [`AgpmError::GitNotFound`]: crate::core::AgpmError::GitNotFound
1939/// [`is_git_installed()`]: fn.is_git_installed.html
1940pub fn ensure_git_available() -> Result<()> {
1941    if !is_git_installed() {
1942        return Err(AgpmError::GitNotFound.into());
1943    }
1944    Ok(())
1945}
1946
1947/// Checks if a path contains a Git repository (regular or bare).
1948///
1949/// This function detects both types of Git repositories:
1950/// - **Regular repositories**: Contain a `.git` subdirectory
1951/// - **Bare repositories**: Contain a `HEAD` file in the root
1952///
1953/// # Arguments
1954///
1955/// * `path` - The path to check for a Git repository
1956///
1957/// # Returns
1958///
1959/// * `true` if the path is a valid Git repository (regular or bare)
1960/// * `false` if neither repository marker exists
1961///
1962/// # Examples
1963///
1964/// ```rust,no_run
1965/// use std::path::Path;
1966/// use agpm_cli::git::is_git_repository;
1967///
1968/// // Check a regular repository
1969/// let repo_path = Path::new("/path/to/repo");
1970/// if is_git_repository(repo_path) {
1971///     println!("Found Git repository");
1972/// }
1973///
1974/// // Check a bare repository
1975/// let bare_path = Path::new("/path/to/repo.git");
1976/// if is_git_repository(bare_path) {
1977///     println!("Found bare Git repository");
1978/// }
1979/// ```
1980///
1981/// # Performance
1982///
1983/// This is a lightweight synchronous check that performs at most two
1984/// filesystem operations to determine repository type.
1985#[must_use]
1986pub fn is_git_repository(path: &Path) -> bool {
1987    // Check for regular repository (.git directory) or bare repository (HEAD file)
1988    path.join(".git").exists() || path.join("HEAD").exists()
1989}
1990
1991/// Checks if a directory contains a valid Git repository.
1992///
1993/// This function performs the same validation as [`GitRepo::is_git_repo()`] but
1994/// operates on an arbitrary path without requiring a `GitRepo` instance. It's
1995/// useful for validating paths before creating repository handles.
1996///
1997/// # Arguments
1998///
1999/// * `path` - The directory path to check for Git repository validity
2000///
2001/// # Return Value
2002///
2003/// - `true` if the path contains a `.git` subdirectory
2004/// - `false` if the `.git` subdirectory is missing or the path doesn't exist
2005///
2006/// # Examples
2007///
2008/// ```rust,no_run
2009/// use agpm_cli::git::is_valid_git_repo;
2010/// use std::path::Path;
2011///
2012/// let path = Path::new("/home/user/my-project");
2013///
2014/// if is_valid_git_repo(path) {
2015///     println!("Found Git repository at: {}", path.display());
2016/// } else {
2017///     println!("Not a Git repository: {}", path.display());
2018/// }
2019/// ```
2020///
2021/// # Use Cases
2022///
2023/// - **Path validation**: Check directories before creating `GitRepo` instances
2024/// - **Discovery**: Scan directories to find Git repositories
2025/// - **Conditional logic**: Branch behavior based on repository presence
2026/// - **Bulk operations**: Filter lists of paths to Git repositories only
2027///
2028/// # Batch Processing Example
2029///
2030/// ```rust,no_run
2031/// use agpm_cli::git::is_valid_git_repo;
2032/// use std::fs;
2033/// use std::path::Path;
2034///
2035/// # fn example() -> std::io::Result<()> {
2036/// let search_dir = Path::new("/home/user/projects");
2037///
2038/// // Find all Git repositories in a directory
2039/// for entry in fs::read_dir(search_dir)? {
2040///     let path = entry?.path();
2041///     if path.is_dir() && is_valid_git_repo(&path) {
2042///         println!("Found repository: {}", path.display());
2043///     }
2044/// }
2045/// # Ok(())
2046/// # }
2047/// ```
2048///
2049/// # Validation Scope
2050///
2051/// This function only verifies the presence of a `.git` directory and does not:
2052/// - Check repository integrity or corruption
2053/// - Validate Git version compatibility  
2054/// - Test network connectivity to remotes
2055/// - Verify specific repository content or structure
2056///
2057/// # Performance
2058///
2059/// This is a lightweight, synchronous operation that performs a single
2060/// filesystem check. It's suitable for bulk validation scenarios.
2061///
2062/// [`GitRepo::is_git_repo()`]: struct.GitRepo.html#method.is_git_repo
2063#[must_use]
2064pub fn is_valid_git_repo(path: &Path) -> bool {
2065    is_git_repository(path)
2066}
2067
2068/// Ensures a directory contains a valid Git repository or returns a detailed error.
2069///
2070/// This function validates that the specified path contains a Git repository,
2071/// providing a [`AgpmError::GitRepoInvalid`] with actionable guidance if the
2072/// validation fails. It's the error-throwing equivalent of [`is_valid_git_repo()`].
2073///
2074/// # Arguments
2075///
2076/// * `path` - The directory path to validate as a Git repository
2077///
2078/// # Return Value
2079///
2080/// - `Ok(())` if the path contains a valid `.git` directory
2081/// - `Err(AgpmError::GitRepoInvalid)` if the path is not a Git repository
2082///
2083/// # Error Context
2084///
2085/// The returned error includes:
2086/// - The specific path that failed validation
2087/// - Clear description of what constitutes a valid Git repository
2088/// - Suggestions for initializing or cloning repositories
2089///
2090/// # Examples
2091///
2092/// ```rust,no_run
2093/// use agpm_cli::git::ensure_valid_git_repo;
2094/// use std::path::Path;
2095///
2096/// # fn example() -> anyhow::Result<()> {
2097/// let path = Path::new("/home/user/my-project");
2098///
2099/// // Validate before operations
2100/// ensure_valid_git_repo(path)?;
2101///
2102/// // Path is guaranteed to be a Git repository beyond this point
2103/// println!("Validated Git repository at: {}", path.display());
2104/// # Ok(())
2105/// # }
2106/// ```
2107///
2108/// # Error Handling Pattern
2109///
2110/// ```rust,no_run
2111/// use agpm_cli::git::ensure_valid_git_repo;
2112/// use agpm_cli::core::AgpmError;
2113/// use std::path::Path;
2114///
2115/// let path = Path::new("/some/directory");
2116///
2117/// match ensure_valid_git_repo(path) {
2118///     Ok(_) => println!("Valid repository found"),
2119///     Err(e) => {
2120///         if let Some(AgpmError::GitRepoInvalid { path }) = e.downcast_ref::<AgpmError>() {
2121///             eprintln!("Directory {} is not a Git repository", path);
2122///             eprintln!("Try: git clone <url> {} or git init {}", path, path);
2123///         }
2124///     }
2125/// }
2126/// ```
2127///
2128/// # Integration with `GitRepo`
2129///
2130/// This function provides validation before creating `GitRepo` instances:
2131///
2132/// ```rust,no_run
2133/// use agpm_cli::git::{ensure_valid_git_repo, GitRepo};
2134/// use std::path::Path;
2135///
2136/// # async fn validated_repo_operations() -> anyhow::Result<()> {
2137/// let path = Path::new("/path/to/repo");
2138///
2139/// // Validate first
2140/// ensure_valid_git_repo(path)?;
2141///
2142/// // Then create repository handle
2143/// let repo = GitRepo::new(path);
2144/// let tags = repo.list_tags().await?;
2145/// # Ok(())
2146/// # }
2147/// ```
2148///
2149/// # Use Cases
2150///
2151/// - **Precondition validation**: Ensure paths are Git repositories before operations
2152/// - **Error-first APIs**: Provide detailed errors rather than boolean returns
2153/// - **Pipeline validation**: Fail fast in processing pipelines
2154/// - **User feedback**: Give actionable error messages with suggestions
2155///
2156/// [`AgpmError::GitRepoInvalid`]: crate::core::AgpmError::GitRepoInvalid
2157/// [`is_valid_git_repo()`]: fn.is_valid_git_repo.html
2158pub fn ensure_valid_git_repo(path: &Path) -> Result<()> {
2159    if !is_valid_git_repo(path) {
2160        return Err(AgpmError::GitRepoInvalid {
2161            path: path.display().to_string(),
2162        }
2163        .into());
2164    }
2165    Ok(())
2166}
2167
2168/// Parses a Git URL into owner and repository name components.
2169///
2170/// This function extracts the repository owner (user/organization) and repository
2171/// name from various Git URL formats. It handles the most common Git URL patterns
2172/// used across different hosting platforms and local repositories.
2173///
2174/// # Arguments
2175///
2176/// * `url` - The Git repository URL to parse
2177///
2178/// # Return Value
2179///
2180/// Returns a tuple `(owner, repository_name)` where:
2181/// - `owner` is the user, organization, or "local" for local repositories
2182/// - `repository_name` is the repository name (with `.git` suffix removed)
2183///
2184/// # Supported URL Formats
2185///
2186/// ## HTTPS URLs
2187/// - `https://github.com/rust-lang/cargo.git` → `("rust-lang", "cargo")`
2188/// - `https://gitlab.com/group/project.git` → `("group", "project")
2189/// - `https://bitbucket.org/user/repo.git` → `("user", "repo")
2190///
2191/// ## SSH URLs
2192/// - `git@github.com:rust-lang/cargo.git` → `("rust-lang", "cargo")`
2193/// - `git@gitlab.com:group/project.git` → `("group", "project")`
2194///
2195/// ## Local URLs
2196/// - `file:///path/to/repo.git` → `("local", "repo")`
2197/// - `/absolute/path/to/repo` → `("local", "repo")`
2198/// - `./relative/path/repo.git` → `("local", "repo")`
2199///
2200///
2201/// # Examples
2202///
2203/// ```rust,no_run
2204/// use agpm_cli::git::parse_git_url;
2205///
2206/// # fn example() -> anyhow::Result<()> {
2207/// // Parse GitHub URL
2208/// let (owner, repo) = parse_git_url("https://github.com/rust-lang/cargo.git")?;
2209/// assert_eq!(owner, "rust-lang");
2210/// assert_eq!(repo, "cargo");
2211///
2212/// // Parse SSH URL
2213/// let (owner, repo) = parse_git_url("git@github.com:user/project.git")?;
2214/// assert_eq!(owner, "user");
2215/// assert_eq!(repo, "project");
2216///
2217/// // Parse local repository
2218/// let (owner, repo) = parse_git_url("/home/user/my-repo")?;
2219/// assert_eq!(owner, "local");
2220/// assert_eq!(repo, "my-repo");
2221/// # Ok(())
2222/// # }
2223/// ```
2224///
2225/// # Use Cases
2226///
2227/// - **Cache directory naming**: Generate consistent cache paths
2228/// - **Repository identification**: Create unique identifiers for repositories
2229/// - **Metadata extraction**: Extract repository information for display
2230/// - **Path generation**: Create filesystem-safe directory names
2231///
2232/// # Cache Integration Example
2233///
2234/// ```rust,no_run
2235/// use agpm_cli::git::parse_git_url;
2236/// use std::path::PathBuf;
2237///
2238/// # fn cache_example() -> anyhow::Result<()> {
2239/// let url = "https://github.com/rust-lang/cargo.git";
2240/// let (owner, repo) = parse_git_url(url)?;
2241///
2242/// // Create cache directory path
2243/// let cache_path = PathBuf::from("/home/user/.agpm/cache")
2244///     .join(&owner)
2245///     .join(&repo);
2246///     
2247/// println!("Cache location: {}", cache_path.display());
2248/// // Output: Cache location: /home/user/.agpm/cache/rust-lang/cargo
2249/// # Ok(())
2250/// # }
2251/// ```
2252///
2253/// # Authentication Handling
2254///
2255/// The parser handles URLs with embedded authentication but extracts only
2256/// the repository components:
2257///
2258/// ```rust,no_run
2259/// use agpm_cli::git::parse_git_url;
2260///
2261/// # fn auth_example() -> anyhow::Result<()> {
2262/// // Authentication is ignored in parsing
2263/// let (owner, repo) = parse_git_url("https://token:value@github.com/user/repo.git")?;
2264/// assert_eq!(owner, "user");
2265/// assert_eq!(repo, "repo");
2266/// # Ok(())
2267/// # }
2268/// ```
2269///
2270/// # Errors
2271///
2272/// Returns an error if:
2273/// - The URL format is not recognized
2274/// - The URL doesn't contain sufficient path components
2275/// - The URL structure doesn't match expected patterns
2276///
2277/// # Platform Considerations
2278///
2279/// The parser handles platform-specific path formats:
2280/// - Windows: Supports backslash separators in local paths
2281/// - Unix: Handles standard forward slash separators
2282/// - All platforms: Normalizes path separators internally
2283pub fn parse_git_url(url: &str) -> Result<(String, String)> {
2284    // Handle file:// URLs
2285    if url.starts_with("file://") {
2286        let path = url.trim_start_matches("file://");
2287        if let Some(last_slash) = path.rfind('/') {
2288            let repo_name = &path[last_slash + 1..];
2289            let repo_name = repo_name.trim_end_matches(".git");
2290            return Ok(("local".to_string(), repo_name.to_string()));
2291        }
2292    }
2293
2294    // Handle plain local paths (absolute or relative)
2295    if url.starts_with('/') || url.starts_with("./") || url.starts_with("../") {
2296        if let Some(last_slash) = url.rfind('/') {
2297            let repo_name = &url[last_slash + 1..];
2298            let repo_name = repo_name.trim_end_matches(".git");
2299            return Ok(("local".to_string(), repo_name.to_string()));
2300        }
2301        let repo_name = url.trim_end_matches(".git");
2302        return Ok(("local".to_string(), repo_name.to_string()));
2303    }
2304
2305    // Handle SSH URLs like git@github.com:user/repo.git
2306    if url.contains('@')
2307        && url.contains(':')
2308        && !url.starts_with("ssh://")
2309        && let Some(colon_pos) = url.find(':')
2310    {
2311        let path = &url[colon_pos + 1..];
2312        let path = path.trim_end_matches(".git");
2313        if let Some(slash_pos) = path.find('/') {
2314            return Ok((path[..slash_pos].to_string(), path[slash_pos + 1..].to_string()));
2315        }
2316    }
2317
2318    // Handle HTTPS URLs
2319    if url.contains("github.com") || url.contains("gitlab.com") || url.contains("bitbucket.org") {
2320        let parts: Vec<&str> = url.split('/').collect();
2321        if parts.len() >= 2 {
2322            let repo = parts[parts.len() - 1].trim_end_matches(".git");
2323            let owner = parts[parts.len() - 2];
2324            return Ok((owner.to_string(), repo.to_string()));
2325        }
2326    }
2327
2328    Err(anyhow::anyhow!("Could not parse repository owner and name from URL"))
2329}
2330
2331/// Strips authentication information from a Git URL for safe display or logging.
2332///
2333/// This function removes sensitive authentication tokens, usernames, and passwords
2334/// from Git URLs while preserving the repository location information. It's essential
2335/// for security when logging or displaying URLs that might contain credentials.
2336///
2337/// # Arguments
2338///
2339/// * `url` - The Git URL that may contain authentication information
2340///
2341/// # Return Value
2342///
2343/// Returns the URL with authentication components removed:
2344/// - HTTPS URLs: Removes `user:token@` prefix
2345/// - SSH URLs: Returned unchanged (no embedded auth to strip)
2346/// - Other formats: Returned unchanged if no auth detected
2347///
2348/// # Security Purpose
2349///
2350/// This function prevents accidental credential exposure in:
2351/// - Log files and console output
2352/// - Error messages shown to users
2353/// - Debug information and stack traces
2354/// - Documentation and examples
2355///
2356/// # Supported Authentication Formats
2357///
2358/// ## HTTPS with Tokens
2359/// - `https://token@github.com/user/repo.git` → `https://github.com/user/repo.git`
2360/// - `https://user:pass@gitlab.com/repo.git` → `https://gitlab.com/repo.git`
2361/// - `https://oauth2:token@bitbucket.org/repo.git` → `https://bitbucket.org/repo.git`
2362///
2363/// ## Preserved Formats
2364/// - `git@github.com:user/repo.git` → `git@github.com:user/repo.git` (unchanged)
2365/// - `https://github.com/user/repo.git` → `https://github.com/user/repo.git` (no auth)
2366/// - `file:///path/to/repo` → `file:///path/to/repo` (unchanged)
2367///
2368/// # Examples
2369///
2370/// ```rust,no_run
2371/// use agpm_cli::git::strip_auth_from_url;
2372///
2373/// # fn example() -> anyhow::Result<()> {
2374/// // Strip token from HTTPS URL
2375/// let clean_url = strip_auth_from_url("https://ghp_token123@github.com/user/repo.git")?;
2376/// assert_eq!(clean_url, "https://github.com/user/repo.git");
2377///
2378/// // Strip user:password authentication
2379/// let clean_url = strip_auth_from_url("https://user:secret@gitlab.com/project.git")?;
2380/// assert_eq!(clean_url, "https://gitlab.com/project.git");
2381///
2382/// // URLs without auth are unchanged
2383/// let clean_url = strip_auth_from_url("https://github.com/public/repo.git")?;
2384/// assert_eq!(clean_url, "https://github.com/public/repo.git");
2385/// # Ok(())
2386/// # }
2387/// ```
2388///
2389/// # Safe Logging Pattern
2390///
2391/// ```rust,no_run
2392/// use agpm_cli::git::strip_auth_from_url;
2393/// use anyhow::Result;
2394///
2395/// fn log_repository_operation(url: &str, operation: &str) -> Result<()> {
2396///     let safe_url = strip_auth_from_url(url)?;
2397///     println!("Performing {} on repository: {}", operation, safe_url);
2398///     // Logs: "Performing clone on repository: https://github.com/user/repo.git"
2399///     // Instead of exposing: "https://token:secret@github.com/user/repo.git"
2400///     Ok(())
2401/// }
2402/// ```
2403///
2404/// # Error Context Integration
2405///
2406/// ```rust,no_run
2407/// use agpm_cli::git::strip_auth_from_url;
2408/// use agpm_cli::core::AgpmError;
2409///
2410/// # async fn operation_example(url: &str) -> anyhow::Result<()> {
2411/// match some_git_operation(url).await {
2412///     Ok(result) => Ok(result),
2413///     Err(e) => {
2414///         let safe_url = strip_auth_from_url(url)?;
2415///         eprintln!("Git operation failed for repository: {}", safe_url);
2416///         Err(e)
2417///     }
2418/// }
2419/// # }
2420/// # async fn some_git_operation(url: &str) -> anyhow::Result<()> { Ok(()) }
2421/// ```
2422///
2423/// # Implementation Details
2424///
2425/// The function uses careful parsing to distinguish between:
2426/// - Authentication `@` symbols (before the hostname)
2427/// - Email address `@` symbols in commit information (preserved)
2428/// - Path components that might contain `@` symbols (preserved)
2429///
2430/// # Edge Cases Handled
2431///
2432/// - URLs with multiple `@` symbols (only strips auth prefix)
2433/// - URLs with no authentication (returned unchanged)
2434/// - Malformed URLs (best-effort processing)
2435/// - Non-HTTP protocols (returned unchanged)
2436///
2437/// # Security Note
2438///
2439/// This function is for **display/logging safety only**. The original authenticated
2440/// URL should still be used for actual Git operations. Never use the stripped URL
2441/// for authentication-required operations.
2442pub fn strip_auth_from_url(url: &str) -> Result<String> {
2443    if url.starts_with("https://") || url.starts_with("http://") {
2444        // Find the @ symbol that marks the end of authentication
2445        if let Some(at_pos) = url.find('@') {
2446            let protocol_end = if url.starts_with("https://") {
2447                "https://".len()
2448            } else {
2449                "http://".len()
2450            };
2451
2452            // Check if @ is part of auth (comes before first /)
2453            let first_slash = url[protocol_end..].find('/').map(|p| p + protocol_end);
2454            if first_slash.is_none() || at_pos < first_slash.unwrap() {
2455                // Extract protocol and the part after @
2456                let protocol = &url[..protocol_end];
2457                let after_auth = &url[at_pos + 1..];
2458                return Ok(format!("{protocol}{after_auth}"));
2459            }
2460        }
2461    }
2462
2463    // Return URL as-is if no auth found or not HTTP(S)
2464    Ok(url.to_string())
2465}