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}