agpm_cli/git/
mod.rs

1//! Git operations wrapper for AGPM.
2//!
3//! This module provides an async wrapper around the system `git` command. Uses system Git
4//! (not libgit2) for maximum compatibility with authentication, configurations, and platforms.
5//!
6//! # Core Features
7//!
8//! - **Async operations**: Non-blocking I/O using Tokio
9//! - **Worktree support**: Parallel package installation via Git worktrees
10//! - **Authentication**: HTTPS tokens, SSH keys, credential helpers
11//! - **Cross-platform**: Windows, macOS, Linux support
12//! - **Progress reporting**: User feedback during long operations
13//! - **Tag caching**: Per-instance caching for performance (v0.4.11+)
14//!
15//! # Security
16//!
17//! - Command injection prevention via proper argument passing
18//! - Credentials never logged or exposed in errors
19//! - HTTPS verification enabled by default
20
21pub mod command_builder;
22#[cfg(test)]
23mod tests;
24
25use crate::core::AgpmError;
26use crate::git::command_builder::GitCommand;
27use anyhow::{Context, Result};
28use std::path::{Path, PathBuf};
29use std::sync::OnceLock;
30
31/// A Git repository handle providing async operations via CLI commands.
32///
33#[derive(Debug, Clone)]
34pub struct GitRepo {
35    /// The local filesystem path to the Git repository.
36    ///
37    /// This path should point to the root directory of a Git repository
38    /// (the directory containing `.git/` subdirectory).
39    path: PathBuf,
40
41    /// Cached list of tags for performance optimization.
42    ///
43    /// Tags are cached after the first `list_tags()` call to avoid repeated
44    /// `git tag -l` operations within a single command execution. This is
45    /// particularly important for version constraint resolution where the same
46    /// tag list may be queried hundreds of times.
47    ///
48    /// Uses Arc to enable sharing the cache across cloned instances, which is
49    /// critical for parallel dependency resolution where multiple tasks access
50    /// the same repository.
51    tag_cache: std::sync::Arc<OnceLock<Vec<String>>>,
52}
53
54impl GitRepo {
55    /// Creates a new `GitRepo` instance for an existing local repository.
56    ///
57    /// # Arguments
58    ///
59    /// * `path` - The filesystem path to the Git repository root directory
60    pub fn new(path: impl AsRef<Path>) -> Self {
61        Self {
62            path: path.as_ref().to_path_buf(),
63            tag_cache: std::sync::Arc::new(OnceLock::new()),
64        }
65    }
66
67    /// Clones a Git repository from a remote URL to a local path.
68    ///
69    /// # Arguments
70    ///
71    /// * `url` - The remote repository URL (HTTPS, SSH, or file://)
72    /// * `target` - The local directory where the repository will be cloned
73    /// * `progress` - Optional progress bar for user feedback
74    ///
75    /// # Errors
76    ///
77    /// - The URL is invalid or unreachable
78    /// - Authentication fails
79    /// - The target directory already exists and is not empty
80    /// - Network connectivity issues
81    /// - Insufficient disk space
82    pub async fn clone(url: &str, target: impl AsRef<Path>) -> Result<Self> {
83        let target_path = target.as_ref();
84
85        // Use command builder for consistent clone operations
86        let mut cmd = GitCommand::clone(url, target_path);
87
88        // For file:// URLs, clone with all branches to ensure commit availability
89        if url.starts_with("file://") {
90            cmd = GitCommand::clone_local(url, target_path);
91        }
92
93        // Execute will handle error context properly
94        cmd.execute().await?;
95
96        Ok(Self::new(target_path))
97    }
98
99    /// Fetches updates from the remote repository without modifying the working tree.
100    ///
101    /// # Arguments
102    ///
103    /// * `auth_url` - Optional URL with authentication for private repositories
104    /// * `progress` - Optional progress bar for network operation feedback
105    ///
106    /// # Errors
107    ///
108    /// - Network connectivity fails
109    /// - Authentication is rejected
110    /// - The remote repository is unavailable
111    /// - The local repository is in an invalid state
112    pub async fn fetch(&self, auth_url: Option<&str>) -> Result<()> {
113        // Note: file:// URLs are local repositories, but we still need to fetch
114        // from them to get updates from the source repository
115
116        // Use git fetch with authentication from global config URL if provided
117        if let Some(url) = auth_url {
118            // Temporarily update the remote URL with auth for this fetch
119            GitCommand::set_remote_url(url).current_dir(&self.path).execute_success().await?;
120        }
121
122        // Now fetch with the potentially updated URL
123        GitCommand::fetch().current_dir(&self.path).execute_success().await?;
124
125        Ok(())
126    }
127
128    /// Checks out a specific Git reference (branch, tag, or commit hash).
129    ///
130    /// # Arguments
131    ///
132    /// * `ref_name` - The Git reference to checkout (branch, tag, or commit)
133    ///
134    /// # Errors
135    ///
136    /// - The reference doesn't exist in the repository
137    /// - The repository is in an invalid state
138    /// - File system permissions prevent checkout
139    /// - The working directory is locked by another process
140    pub async fn checkout(&self, ref_name: &str) -> Result<()> {
141        // Reset to clean state before checkout
142        let reset_result = GitCommand::reset_hard().current_dir(&self.path).execute().await;
143
144        if let Err(e) = reset_result {
145            // Only warn if it's not a detached HEAD situation (which is normal)
146            let error_str = e.to_string();
147            if !error_str.contains("HEAD detached") {
148                eprintln!("Warning: git reset failed: {error_str}");
149            }
150        }
151
152        // Check if this ref exists as a remote branch
153        // If it does, always use -B to ensure we get the latest
154        let remote_ref = format!("origin/{ref_name}");
155        let check_remote =
156            GitCommand::verify_ref(&remote_ref).current_dir(&self.path).execute().await;
157
158        if check_remote.is_ok() {
159            // Remote branch exists, use -B to force update to latest
160            if GitCommand::checkout_branch(ref_name, &remote_ref)
161                .current_dir(&self.path)
162                .execute_success()
163                .await
164                .is_ok()
165            {
166                return Ok(());
167            }
168        }
169
170        // Not a remote branch, try direct checkout (works for tags and commits)
171        GitCommand::checkout(ref_name).current_dir(&self.path).execute_success().await.map_err(
172            |e| {
173                // If it's already a GitCheckoutFailed error, return as-is
174                // Otherwise wrap it
175                if let Some(agpm_err) = e.downcast_ref::<AgpmError>()
176                    && matches!(agpm_err, AgpmError::GitCheckoutFailed { .. })
177                {
178                    return e;
179                }
180                AgpmError::GitCheckoutFailed {
181                    reference: ref_name.to_string(),
182                    reason: e.to_string(),
183                }
184                .into()
185            },
186        )
187    }
188
189    /// Lists all tags in the repository, sorted by Git's default ordering.
190    ///
191    /// # Return Value
192    ///
193    /// # Errors
194    ///
195    /// - The repository path doesn't exist
196    /// - The directory is not a valid Git repository
197    /// - Git command execution fails
198    /// - File system permissions prevent access
199    /// - Lock conflicts persist after retry attempts
200    pub async fn list_tags(&self) -> Result<Vec<String>> {
201        if let Some(cached_tags) = self.tag_cache.get() {
202            return Ok(cached_tags.clone());
203        }
204
205        if !self.path.exists() {
206            return Err(anyhow::anyhow!("Repository path does not exist: {:?}", self.path));
207        }
208        if !self.path.join(".git").exists() && !self.path.join("HEAD").exists() {
209            return Err(anyhow::anyhow!("Not a git repository: {:?}", self.path));
210        }
211
212        const MAX_RETRIES: u32 = 3;
213        const RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(150);
214        let mut last_error = None;
215
216        for attempt in 0..MAX_RETRIES {
217            let result = GitCommand::list_tags().current_dir(&self.path).execute_stdout().await;
218
219            match result {
220                Ok(stdout) => {
221                    let tags: Vec<String> = stdout
222                        .lines()
223                        .filter(|line| !line.is_empty())
224                        .map(std::string::ToString::to_string)
225                        .collect();
226                    let _ = self.tag_cache.set(tags.clone());
227                    return Ok(tags);
228                }
229                Err(e) => {
230                    let error_str = e.to_string();
231                    if error_str.contains("lock") {
232                        last_error = Some(e);
233                        tokio::time::sleep(RETRY_DELAY * (attempt + 1)).await; // Exponential backoff
234                        continue;
235                    }
236                    // For non-lock errors, fail immediately
237                    return Err(e).context(format!("Failed to list git tags in {:?}", self.path));
238                }
239            }
240        }
241
242        Err(last_error.unwrap_or_else(|| anyhow::anyhow!("Exhausted retries for list_tags")))
243            .context(format!(
244                "Failed to list git tags in {:?} after {} retries",
245                self.path, MAX_RETRIES
246            ))
247    }
248
249    /// Retrieves the URL of the remote 'origin' repository.
250    ///
251    /// # Return Value
252    ///
253    /// - HTTPS: `https://github.com/user/repo.git`
254    /// - SSH: `git@github.com:user/repo.git`
255    /// - File: `file:///path/to/repo.git`
256    ///
257    /// # Errors
258    ///
259    /// - No 'origin' remote is configured
260    /// - The repository is not a valid Git repository
261    /// - Git command execution fails
262    /// - File system access is denied
263    pub async fn get_remote_url(&self) -> Result<String> {
264        GitCommand::remote_url().current_dir(&self.path).execute_stdout().await
265    }
266
267    /// Checks if the directory contains a valid Git repository.\n    ///
268    ///
269    #[must_use]
270    pub fn is_git_repo(&self) -> bool {
271        is_git_repository(&self.path)
272    }
273
274    /// Returns the filesystem path to the Git repository.
275    ///
276    /// # Return Value
277    ///
278    #[must_use]
279    pub fn path(&self) -> &Path {
280        &self.path
281    }
282
283    /// Verifies that a Git repository URL is accessible without performing a full clone.
284    ///
285    /// # Arguments
286    ///
287    /// * `url` - The repository URL to verify
288    ///
289    /// # Errors
290    ///
291    /// - **Network issues**: DNS resolution, connectivity, timeouts
292    /// - **Authentication failures**: Invalid credentials, expired tokens
293    /// - **Repository issues**: Repository doesn't exist, access denied
294    /// - **Local path issues**: File doesn't exist (for `file://` URLs)
295    /// - **URL format issues**: Malformed or unsupported URL schemes
296    pub async fn verify_url(url: &str) -> Result<()> {
297        // For file:// URLs, just check if the path exists
298        if url.starts_with("file://") {
299            let path = url.strip_prefix("file://").unwrap();
300            return if std::path::Path::new(path).exists() {
301                Ok(())
302            } else {
303                Err(anyhow::anyhow!("Local path does not exist: {path}"))
304            };
305        }
306
307        // For all other URLs, use ls-remote to verify
308        GitCommand::ls_remote(url)
309            .execute_success()
310            .await
311            .context("Failed to verify remote repository")
312    }
313
314    /// Fetch updates for a bare repository with logging context.
315    async fn ensure_bare_repo_has_refs_with_context(&self, context: Option<&str>) -> Result<()> {
316        // Try to fetch to ensure we have refs
317        let mut fetch_cmd = GitCommand::fetch().current_dir(&self.path);
318
319        if let Some(ctx) = context {
320            fetch_cmd = fetch_cmd.with_context(ctx);
321        }
322
323        let fetch_result = fetch_cmd.execute_success().await;
324
325        if fetch_result.is_err() {
326            // If fetch fails, it might be because there's no remote
327            // Just check if we have any refs at all
328            let mut check_cmd =
329                GitCommand::new().args(["show-ref", "--head"]).current_dir(&self.path);
330
331            if let Some(ctx) = context {
332                check_cmd = check_cmd.with_context(ctx);
333            }
334
335            check_cmd
336                .execute_success()
337                .await
338                .map_err(|e| anyhow::anyhow!("Bare repository has no refs available: {e}"))?;
339        }
340
341        Ok(())
342    }
343
344    /// Clone a repository as a bare repository (no working directory).
345    ///
346    /// # Arguments
347    ///
348    /// * `url` - The remote repository URL
349    /// * `target` - The local directory where the bare repository will be stored
350    /// * `progress` - Optional progress bar for user feedback
351    /// # Returns
352    ///
353    pub async fn clone_bare(url: &str, target: impl AsRef<Path>) -> Result<Self> {
354        Self::clone_bare_with_context(url, target, None).await
355    }
356
357    /// Clone a repository as a bare repository with logging context.
358    ///
359    /// # Arguments
360    ///
361    /// * `url` - The remote repository URL
362    /// * `target` - The local directory where the bare repository will be stored
363    /// * `progress` - Optional progress bar for user feedback
364    /// * `context` - Optional context for logging (e.g., dependency name)
365    /// # Returns
366    ///
367    pub async fn clone_bare_with_context(
368        url: &str,
369        target: impl AsRef<Path>,
370        context: Option<&str>,
371    ) -> Result<Self> {
372        let target_path = target.as_ref();
373
374        let mut cmd = GitCommand::clone_bare(url, target_path);
375
376        if let Some(ctx) = context {
377            cmd = cmd.with_context(ctx);
378        }
379
380        cmd.execute_success().await?;
381
382        let repo = Self::new(target_path);
383
384        // Configure the fetch refspec to ensure all branches are fetched as remote tracking branches
385        // This is crucial for file:// URLs and ensures we can resolve origin/branch after fetching
386        let _ = GitCommand::new()
387            .args(["config", "remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*"])
388            .current_dir(repo.path())
389            .execute_success()
390            .await;
391
392        // Ensure bare repo has refs available for worktree creation
393        // This fetch is necessary after clone to set up remote tracking branches
394        // Note: The cache layer tracks this fetch so worktree creation won't re-fetch
395        repo.ensure_bare_repo_has_refs_with_context(context).await.ok();
396
397        Ok(repo)
398    }
399
400    /// Create a new worktree from this repository.
401    ///
402    /// # Arguments
403    ///
404    /// * `worktree_path` - The path where the worktree will be created
405    /// * `reference` - Optional Git reference (branch/tag/commit) to checkout
406    /// # Returns
407    ///
408    pub async fn create_worktree(
409        &self,
410        worktree_path: impl AsRef<Path>,
411        reference: Option<&str>,
412    ) -> Result<Self> {
413        self.create_worktree_with_context(worktree_path, reference, None).await
414    }
415
416    /// Create a new worktree from this repository with logging context.
417    ///
418    /// # Arguments
419    ///
420    /// * `worktree_path` - The path where the worktree will be created
421    /// * `reference` - Optional Git reference (branch/tag/commit) to checkout
422    /// * `context` - Optional context for logging (e.g., dependency name)
423    /// # Returns
424    ///
425    pub async fn create_worktree_with_context(
426        &self,
427        worktree_path: impl AsRef<Path>,
428        reference: Option<&str>,
429        context: Option<&str>,
430    ) -> Result<Self> {
431        let worktree_path = worktree_path.as_ref();
432
433        // Ensure parent directory exists
434        if let Some(parent) = worktree_path.parent() {
435            tokio::fs::create_dir_all(parent).await.with_context(|| {
436                format!("Failed to create parent directory for worktree: {parent:?}")
437            })?;
438        }
439
440        // Retry logic for worktree creation to handle concurrent operations
441        let max_retries = 3;
442        let mut retry_count = 0;
443
444        loop {
445            // For bare repositories, we may need to handle the case where no default branch exists yet
446            // If no reference provided, try to use the default branch
447            let default_branch = if reference.is_none() && retry_count == 0 {
448                // Try to get the default branch
449                GitCommand::new()
450                    .args(["symbolic-ref", "refs/remotes/origin/HEAD"])
451                    .current_dir(&self.path)
452                    .execute_stdout()
453                    .await
454                    .ok()
455                    .and_then(|s| s.strip_prefix("refs/remotes/origin/").map(String::from))
456                    .or_else(|| Some("main".to_string()))
457            } else {
458                None
459            };
460
461            let effective_ref = if let Some(ref branch) = default_branch {
462                Some(branch.as_str())
463            } else {
464                reference
465            };
466
467            let mut cmd =
468                GitCommand::worktree_add(worktree_path, effective_ref).current_dir(&self.path);
469
470            if let Some(ctx) = context {
471                cmd = cmd.with_context(ctx);
472            }
473
474            let result = cmd.execute_success().await;
475
476            match result {
477                Ok(()) => {
478                    // Initialize and update submodules in the new worktree
479                    let worktree_repo = Self::new(worktree_path);
480
481                    // Initialize submodules
482                    let mut init_cmd =
483                        GitCommand::new().args(["submodule", "init"]).current_dir(worktree_path);
484
485                    if let Some(ctx) = context {
486                        init_cmd = init_cmd.with_context(ctx);
487                    }
488
489                    if let Err(e) = init_cmd.execute_success().await {
490                        let error_str = e.to_string();
491                        // Only ignore errors indicating no submodules are present
492                        if !error_str.contains("No submodule mapping found")
493                            && !error_str.contains("no submodule")
494                        {
495                            // For other errors, return them
496                            return Err(e).context("Failed to initialize submodules");
497                        }
498                    }
499
500                    // Update submodules
501                    let mut update_cmd = GitCommand::new()
502                        .args(["submodule", "update", "--recursive"])
503                        .current_dir(worktree_path);
504
505                    if let Some(ctx) = context {
506                        update_cmd = update_cmd.with_context(ctx);
507                    }
508
509                    if let Err(e) = update_cmd.execute_success().await {
510                        let error_str = e.to_string();
511                        // Ignore errors related to no submodules
512                        if !error_str.contains("No submodule mapping found")
513                            && !error_str.contains("no submodule")
514                        {
515                            return Err(e).context("Failed to update submodules");
516                        }
517                    }
518
519                    return Ok(worktree_repo);
520                }
521                Err(e) => {
522                    let error_str = e.to_string();
523
524                    // Check if this is a concurrent access issue
525                    // The "commondir" error occurs when Git scans existing worktrees during
526                    // concurrent creation - another thread's worktree entry may be partially
527                    // written, causing "failed to read worktrees/<name>/commondir: Undefined error: 0"
528                    if error_str.contains("already exists")
529                        || error_str.contains("is already checked out")
530                        || error_str.contains("fatal: could not create directory")
531                        || (error_str.contains("failed to read") && error_str.contains("commondir"))
532                    {
533                        retry_count += 1;
534                        if retry_count >= max_retries {
535                            return Err(e).with_context(|| {
536                                format!(
537                                    "Failed to create worktree at {} from {} after {} retries",
538                                    worktree_path.display(),
539                                    self.path.display(),
540                                    max_retries
541                                )
542                            });
543                        }
544
545                        // Wait a bit before retrying
546                        tokio::time::sleep(tokio::time::Duration::from_millis(100 * retry_count))
547                            .await;
548                        continue;
549                    }
550
551                    // Handle stale registration: "missing but already registered worktree"
552                    // This can happen in Docker containers, CI environments, or after unclean
553                    // shutdowns where git's worktree metadata gets out of sync with filesystem.
554                    // Recovery strategy:
555                    // 1. Remove invalid worktree directory if it exists without .git file
556                    // 2. Run `git worktree prune` to clean stale registrations
557                    // 3. Retry with `git worktree add --force`
558                    //
559                    // NOTE: We only run prune in this error recovery path (not speculatively)
560                    // to minimize race conditions with concurrent worktree operations.
561                    if error_str.contains("missing but already registered worktree") {
562                        // Git reports "missing but already registered" when the worktree
563                        // state is inconsistent. This can happen when:
564                        // - The .git file exists but is broken/empty
565                        // - The worktree was partially created
566                        // - Docker/CI environments had filesystem state corruption
567                        //
568                        // Since git explicitly tells us this worktree is INVALID, we can
569                        // safely remove it. Git wouldn't report this error for a valid
570                        // worktree that other processes might be using.
571                        if worktree_path.exists() {
572                            let _ = tokio::fs::remove_dir_all(worktree_path).await;
573                        }
574
575                        // Prune stale worktree registrations. This is safe in the recovery path
576                        // since we already failed once. In Docker/CI environments, --force alone
577                        // may not be sufficient to override stale registrations.
578                        let mut prune_cmd =
579                            GitCommand::new().args(["worktree", "prune"]).current_dir(&self.path);
580                        if let Some(ctx) = context {
581                            prune_cmd = prune_cmd.with_context(ctx);
582                        }
583                        let _ = prune_cmd.execute_success().await;
584
585                        // Ensure parent directory exists before force add.
586                        // This handles the case where the temp directory was partially cleaned up,
587                        // leaving Git's worktree metadata pointing to a non-existent path.
588                        if let Some(parent) = worktree_path.parent() {
589                            let _ = tokio::fs::create_dir_all(parent).await;
590                        }
591
592                        // Use `git worktree add --force` after pruning stale entries
593                        let worktree_path_str = worktree_path.display().to_string();
594                        let mut args = vec![
595                            "worktree".to_string(),
596                            "add".to_string(),
597                            "--force".to_string(),
598                            worktree_path_str,
599                        ];
600                        if let Some(r) = effective_ref {
601                            args.push(r.to_string());
602                        }
603
604                        let mut force_cmd = GitCommand::new().args(args).current_dir(&self.path);
605                        if let Some(ctx) = context {
606                            force_cmd = force_cmd.with_context(ctx);
607                        }
608
609                        match force_cmd.execute_success().await {
610                            Ok(()) => {
611                                // Initialize and update submodules in the new worktree
612                                let worktree_repo = Self::new(worktree_path);
613
614                                let mut init_cmd = GitCommand::new()
615                                    .args(["submodule", "init"])
616                                    .current_dir(worktree_path);
617                                if let Some(ctx) = context {
618                                    init_cmd = init_cmd.with_context(ctx);
619                                }
620                                let _ = init_cmd.execute_success().await;
621
622                                let mut update_cmd = GitCommand::new()
623                                    .args(["submodule", "update", "--recursive"])
624                                    .current_dir(worktree_path);
625                                if let Some(ctx) = context {
626                                    update_cmd = update_cmd.with_context(ctx);
627                                }
628                                let _ = update_cmd.execute_success().await;
629
630                                return Ok(worktree_repo);
631                            }
632                            Err(e2) => {
633                                // Fall through to other recovery paths with the original error context
634                                // but include the forced attempt error as context
635                                return Err(e).with_context(|| {
636                                    format!(
637                                        "Failed to create worktree at {} from {} (forced add failed: {})",
638                                        worktree_path.display(),
639                                        self.path.display(),
640                                        e2
641                                    )
642                                });
643                            }
644                        }
645                    }
646
647                    // If no reference was provided and the command failed, it might be because
648                    // the bare repo doesn't have a default branch set. Try with explicit HEAD
649                    if reference.is_none() && retry_count == 0 {
650                        let mut head_cmd = GitCommand::worktree_add(worktree_path, Some("HEAD"))
651                            .current_dir(&self.path);
652
653                        if let Some(ctx) = context {
654                            head_cmd = head_cmd.with_context(ctx);
655                        }
656
657                        let head_result = head_cmd.execute_success().await;
658
659                        match head_result {
660                            Ok(()) => {
661                                // Initialize and update submodules in the new worktree
662                                let worktree_repo = Self::new(worktree_path);
663
664                                // Initialize submodules
665                                let mut init_cmd = GitCommand::new()
666                                    .args(["submodule", "init"])
667                                    .current_dir(worktree_path);
668
669                                if let Some(ctx) = context {
670                                    init_cmd = init_cmd.with_context(ctx);
671                                }
672
673                                if let Err(e) = init_cmd.execute_success().await {
674                                    let error_str = e.to_string();
675                                    // Only ignore errors indicating no submodules are present
676                                    if !error_str.contains("No submodule mapping found")
677                                        && !error_str.contains("no submodule")
678                                    {
679                                        // For other errors, return them
680                                        return Err(e).context("Failed to initialize submodules");
681                                    }
682                                }
683
684                                // Update submodules
685                                let mut update_cmd = GitCommand::new()
686                                    .args(["submodule", "update", "--recursive"])
687                                    .current_dir(worktree_path);
688
689                                if let Some(ctx) = context {
690                                    update_cmd = update_cmd.with_context(ctx);
691                                }
692
693                                if let Err(e) = update_cmd.execute_success().await {
694                                    let error_str = e.to_string();
695                                    // Ignore errors related to no submodules
696                                    if !error_str.contains("No submodule mapping found")
697                                        && !error_str.contains("no submodule")
698                                    {
699                                        return Err(e).context("Failed to update submodules");
700                                    }
701                                }
702
703                                return Ok(worktree_repo);
704                            }
705                            Err(head_err) => {
706                                // If HEAD also fails, return the original error
707                                return Err(e).with_context(|| {
708                                    format!(
709                                        "Failed to create worktree at {} from {} (also tried HEAD: {})",
710                                        worktree_path.display(),
711                                        self.path.display(),
712                                        head_err
713                                    )
714                                });
715                            }
716                        }
717                    }
718
719                    // Check if the error is likely due to an invalid reference
720                    let error_str = e.to_string();
721                    if let Some(ref_name) = reference
722                        && (error_str.contains("pathspec")
723                            || error_str.contains("not found")
724                            || error_str.contains("ambiguous")
725                            || error_str.contains("invalid")
726                            || error_str.contains("unknown revision"))
727                    {
728                        return Err(anyhow::anyhow!(
729                            "Invalid version or reference '{ref_name}': Failed to checkout reference - the specified version/tag/branch does not exist in the repository"
730                        ));
731                    }
732
733                    return Err(e).with_context(|| {
734                        format!(
735                            "Failed to create worktree at {} from {}",
736                            worktree_path.display(),
737                            self.path.display()
738                        )
739                    });
740                }
741            }
742        }
743    }
744
745    /// Remove a worktree associated with this repository.
746    ///
747    /// # Arguments
748    ///
749    /// * `worktree_path` - The path to the worktree to remove
750    pub async fn remove_worktree(&self, worktree_path: impl AsRef<Path>) -> Result<()> {
751        let worktree_path = worktree_path.as_ref();
752
753        GitCommand::worktree_remove(worktree_path)
754            .current_dir(&self.path)
755            .execute_success()
756            .await
757            .with_context(|| format!("Failed to remove worktree at {}", worktree_path.display()))?;
758
759        // Also try to remove the directory if it still exists
760        if worktree_path.exists() {
761            tokio::fs::remove_dir_all(worktree_path).await.ok(); // Ignore errors as git worktree remove may have already cleaned it
762        }
763
764        Ok(())
765    }
766
767    /// List all worktrees associated with this repository.
768    ///
769    pub async fn list_worktrees(&self) -> Result<Vec<PathBuf>> {
770        let output = GitCommand::worktree_list().current_dir(&self.path).execute_stdout().await?;
771
772        let mut worktrees = Vec::new();
773        let mut current_worktree: Option<PathBuf> = None;
774
775        for line in output.lines() {
776            if line.starts_with("worktree ") {
777                if let Some(path) = line.strip_prefix("worktree ") {
778                    current_worktree = Some(PathBuf::from(path));
779                }
780            } else if line == "bare" {
781                // Skip bare repository entry
782                current_worktree = None;
783            } else if line.is_empty()
784                && current_worktree.is_some()
785                && let Some(path) = current_worktree.take()
786            {
787                worktrees.push(path);
788            }
789        }
790
791        // Add the last worktree if there is one
792        if let Some(path) = current_worktree {
793            worktrees.push(path);
794        }
795
796        Ok(worktrees)
797    }
798
799    /// Prune stale worktree administrative files.
800    ///
801    pub async fn prune_worktrees(&self) -> Result<()> {
802        GitCommand::worktree_prune()
803            .current_dir(&self.path)
804            .execute_success()
805            .await
806            .with_context(|| "Failed to prune worktrees")?;
807
808        Ok(())
809    }
810
811    /// Check if this repository is a bare repository.
812    ///
813    pub async fn is_bare(&self) -> Result<bool> {
814        let output = GitCommand::new()
815            .args(["config", "--get", "core.bare"])
816            .current_dir(&self.path)
817            .execute_stdout()
818            .await?;
819
820        Ok(output.trim() == "true")
821    }
822
823    /// Get the current commit SHA of the repository.
824    ///
825    /// # Returns
826    ///
827    /// # Errors
828    ///
829    /// - The repository is not valid
830    /// - HEAD is not pointing to a valid commit
831    /// - Git command fails
832    pub async fn get_current_commit(&self) -> Result<String> {
833        GitCommand::current_commit()
834            .current_dir(&self.path)
835            .execute_stdout()
836            .await
837            .context("Failed to get current commit")
838    }
839
840    /// Batch resolve multiple refs to SHAs in a single git process.
841    ///
842    /// Uses `git rev-parse <ref1> <ref2> ...` to resolve all refs at once, reducing
843    /// process spawn overhead from O(n) to O(1). This is significantly faster
844    /// for Windows where process spawning has high overhead.
845    ///
846    /// # Arguments
847    ///
848    /// * `refs` - Slice of ref specifications to resolve
849    ///
850    /// # Returns
851    ///
852    /// HashMap mapping each input ref to its resolved SHA (or None if not found)
853    ///
854    /// # Performance
855    ///
856    /// - Single process for all refs vs one per ref
857    /// - Reduces 100 refs from ~5-10 seconds to ~0.5 seconds on Windows
858    ///
859    /// # Examples
860    ///
861    /// ```rust,ignore
862    /// use agpm_cli::git::GitRepo;
863    ///
864    /// # async fn example() -> anyhow::Result<()> {
865    /// let repo = GitRepo::new("/path/to/repo");
866    /// let refs = vec!["v1.0.0", "main", "abc1234"];
867    /// let results = repo.resolve_refs_batch(&refs).await?;
868    ///
869    /// for (ref_name, sha) in results {
870    ///     if let Some(sha) = sha {
871    ///         println!("{} -> {}", ref_name, sha);
872    ///     } else {
873    ///         println!("{} not found", ref_name);
874    ///     }
875    /// }
876    /// # Ok(())
877    /// # }
878    /// ```
879    pub async fn resolve_refs_batch(
880        &self,
881        refs: &[&str],
882    ) -> Result<std::collections::HashMap<String, Option<String>>> {
883        use std::collections::HashMap;
884
885        if refs.is_empty() {
886            return Ok(HashMap::new());
887        }
888
889        // Partition refs: already-SHAs vs need-resolution
890        let (already_shas, to_resolve): (Vec<&str>, Vec<&str>) =
891            refs.iter().partition(|r| r.len() == 40 && r.chars().all(|c| c.is_ascii_hexdigit()));
892
893        let mut results: HashMap<String, Option<String>> = HashMap::new();
894
895        // Add already-resolved SHAs directly
896        for sha in already_shas {
897            results.insert(sha.to_string(), Some(sha.to_string()));
898        }
899
900        if to_resolve.is_empty() {
901            return Ok(results);
902        }
903
904        // Build arguments for git rev-parse: ["rev-parse", "ref1", "ref2", ...]
905        // This resolves all refs in a single git process
906        let mut args = vec!["rev-parse"];
907        args.extend(to_resolve.iter().copied());
908
909        // Execute batch resolution
910        let output = GitCommand::new().args(args).current_dir(&self.path).execute().await;
911
912        match output {
913            Ok(cmd_output) => {
914                // Parse output (one SHA per line, in order)
915                let shas: Vec<&str> = cmd_output.stdout.lines().collect();
916
917                for (i, ref_name) in to_resolve.iter().enumerate() {
918                    let sha = shas.get(i).and_then(|s| {
919                        let trimmed = s.trim();
920                        // Only accept valid SHA output (40 hex chars)
921                        if trimmed.len() == 40 && trimmed.chars().all(|c| c.is_ascii_hexdigit()) {
922                            Some(trimmed.to_string())
923                        } else {
924                            None
925                        }
926                    });
927                    results.insert(ref_name.to_string(), sha);
928                }
929            }
930            Err(e) => {
931                // If batch fails (e.g., one ref is invalid), fall back to individual resolution
932                tracing::debug!(
933                    target: "git",
934                    "Batch rev-parse failed, falling back to individual resolution: {}",
935                    e
936                );
937
938                for ref_name in to_resolve {
939                    let sha = GitCommand::rev_parse(ref_name)
940                        .current_dir(&self.path)
941                        .execute_stdout()
942                        .await
943                        .ok();
944                    results.insert(ref_name.to_string(), sha);
945                }
946            }
947        }
948
949        Ok(results)
950    }
951
952    /// Resolves a Git reference (tag, branch, commit) to its full SHA-1 hash.
953    ///
954    /// # Arguments
955    ///
956    /// * `ref_spec` - The Git reference to resolve (tag, branch, short/full SHA, or None for HEAD)
957    /// # Returns
958    ///
959    /// # Errors
960    ///
961    /// - The reference doesn't exist in the repository
962    /// - The repository is invalid or corrupted
963    /// - Git command execution fails
964    pub async fn resolve_to_sha(&self, ref_spec: Option<&str>) -> Result<String> {
965        let reference = ref_spec.unwrap_or("HEAD");
966
967        // Optimization: if it's already a full SHA, return it directly
968        if reference.len() == 40 && reference.chars().all(|c| c.is_ascii_hexdigit()) {
969            return Ok(reference.to_string());
970        }
971
972        // Determine the reference to resolve based on type (tag vs branch)
973        let ref_to_resolve = if !reference.contains('/') && reference != "HEAD" {
974            // Check if this is a tag (uses cached tag list for performance)
975            let is_tag = self
976                .list_tags()
977                .await
978                .map(|tags| tags.contains(&reference.to_string()))
979                .unwrap_or(false);
980
981            if is_tag {
982                // It's a tag - use it directly
983                reference.to_string()
984            } else {
985                // Assume it's a branch name - try to resolve origin/branch first to get the latest from remote
986                // This ensures we get the most recent commit after a fetch
987                let origin_ref = format!("origin/{reference}");
988                if GitCommand::rev_parse(&origin_ref)
989                    .current_dir(&self.path)
990                    .execute_stdout()
991                    .await
992                    .is_ok()
993                {
994                    origin_ref
995                } else {
996                    // Fallback to the original reference (might be a local branch)
997                    reference.to_string()
998                }
999            }
1000        } else {
1001            reference.to_string()
1002        };
1003
1004        // Use rev-parse to get the full SHA
1005        let sha = GitCommand::rev_parse(&ref_to_resolve)
1006            .current_dir(&self.path)
1007            .execute_stdout()
1008            .await
1009            .with_context(|| format!("Failed to resolve reference '{reference}' to SHA"))?;
1010
1011        // Ensure we have a full SHA (sometimes rev-parse can return short SHAs)
1012        if sha.len() < 40 {
1013            // Request the full SHA explicitly
1014            let full_sha = GitCommand::new()
1015                .args(["rev-parse", "--verify", &format!("{reference}^{{commit}}")])
1016                .current_dir(&self.path)
1017                .execute_stdout()
1018                .await
1019                .with_context(|| format!("Failed to get full SHA for reference '{reference}'"))?;
1020            Ok(full_sha)
1021        } else {
1022            Ok(sha)
1023        }
1024    }
1025
1026    pub async fn get_current_branch(&self) -> Result<String> {
1027        let branch = GitCommand::current_branch()
1028            .current_dir(&self.path)
1029            .execute_stdout()
1030            .await
1031            .context("Failed to get current branch")?;
1032
1033        if branch.is_empty() {
1034            // Fallback for very old Git or repos without commits
1035            Ok("master".to_string())
1036        } else {
1037            Ok(branch)
1038        }
1039    }
1040
1041    /// Gets the default branch name for the repository.
1042    ///
1043    /// # Returns
1044    ///
1045    /// # Errors
1046    ///
1047    /// - Git commands fail with non-recoverable errors
1048    /// - Lock conflicts occur (propagated for caller to retry)
1049    /// - Default branch cannot be determined
1050    pub async fn get_default_branch(&self) -> Result<String> {
1051        let result = GitCommand::new()
1052            .args(["symbolic-ref", "refs/remotes/origin/HEAD"])
1053            .current_dir(&self.path)
1054            .execute_stdout()
1055            .await;
1056
1057        match result {
1058            Ok(symbolic_ref) => {
1059                if let Some(branch) = symbolic_ref.strip_prefix("refs/remotes/origin/") {
1060                    return Ok(branch.to_string());
1061                }
1062                // If parsing fails, fall through to the next method.
1063            }
1064            Err(e) => {
1065                let error_str = e.to_string();
1066                // If the ref is not found, it's not a fatal error, just fall back.
1067                // Any other error (like a lock file) should be propagated.
1068                if !error_str.contains("not a symbolic ref") && !error_str.contains("not found") {
1069                    return Err(e).context("Failed to get default branch via symbolic-ref");
1070                }
1071            }
1072        }
1073
1074        // Fallback: try to get current branch (for non-bare repos or if symbolic-ref fails)
1075        self.get_current_branch().await
1076    }
1077}
1078
1079// Module-level helper functions for Git environment management and URL processing
1080
1081/// Checks if Git is installed and accessible on the system.
1082///
1083/// # Return Value
1084///
1085/// - `true` if Git is installed and responding to `--version` commands
1086/// - `false` if Git is not found, not in PATH, or not executable
1087///
1088#[must_use]
1089pub fn is_git_installed() -> bool {
1090    // For synchronous checking, we still use std::process::Command directly
1091    std::process::Command::new(crate::utils::platform::get_git_command())
1092        .arg("--version")
1093        .output()
1094        .map(|output| output.status.success())
1095        .unwrap_or(false)
1096}
1097
1098/// Ensures Git is available on the system or returns a detailed error.
1099///
1100/// # Return Value
1101///
1102/// - `Ok(())` if Git is properly installed and accessible
1103/// - `Err(AgpmError::GitNotFound)` if Git is not available
1104///
1105pub fn ensure_git_available() -> Result<()> {
1106    if !is_git_installed() {
1107        return Err(AgpmError::GitNotFound.into());
1108    }
1109    Ok(())
1110}
1111
1112/// Checks if a path contains a Git repository (regular or bare).
1113///
1114/// # Arguments
1115///
1116/// * `path` - The path to check for a Git repository
1117/// # Returns
1118///
1119/// * `true` if the path is a valid Git repository (regular or bare)
1120/// * `false` if neither repository marker exists
1121#[must_use]
1122pub fn is_git_repository(path: &Path) -> bool {
1123    // Check for regular repository (.git directory) or bare repository (HEAD file)
1124    path.join(".git").exists() || path.join("HEAD").exists()
1125}
1126
1127/// Checks if a directory contains a valid Git repository.
1128///
1129/// # Arguments
1130///
1131/// * `path` - The directory path to check for Git repository validity
1132/// # Return Value
1133///
1134/// - `true` if the path contains a `.git` subdirectory
1135/// - `false` if the `.git` subdirectory is missing or the path doesn't exist
1136///
1137#[must_use]
1138pub fn is_valid_git_repo(path: &Path) -> bool {
1139    is_git_repository(path)
1140}
1141
1142/// Ensures a directory contains a valid Git repository or returns a detailed error.
1143///
1144/// # Arguments
1145///
1146/// * `path` - The directory path to validate as a Git repository
1147/// # Return Value
1148///
1149/// - `Ok(())` if the path contains a valid `.git` directory
1150/// - `Err(AgpmError::GitRepoInvalid)` if the path is not a Git repository
1151///
1152pub fn ensure_valid_git_repo(path: &Path) -> Result<()> {
1153    if !is_valid_git_repo(path) {
1154        return Err(AgpmError::GitRepoInvalid {
1155            path: path.display().to_string(),
1156        }
1157        .into());
1158    }
1159    Ok(())
1160}
1161
1162/// Parses a Git URL into owner and repository name components.
1163///
1164/// # Arguments
1165///
1166/// * `url` - The Git repository URL to parse
1167/// # Return Value
1168///
1169/// - `owner` is the user, organization, or "local" for local repositories
1170/// - `repository_name` is the repository name (with `.git` suffix removed)
1171///
1172/// # Errors
1173///
1174/// - The URL format is not recognized
1175/// - The URL doesn't contain sufficient path components
1176/// - The URL structure doesn't match expected patterns
1177///
1178pub fn parse_git_url(url: &str) -> Result<(String, String)> {
1179    use std::path::Path;
1180
1181    // Handle file:// URLs
1182    if url.starts_with("file://") {
1183        let path_str = url.trim_start_matches("file://");
1184        let path = Path::new(path_str);
1185        let repo_name = path
1186            .file_name()
1187            .and_then(|n| n.to_str())
1188            .map(|s| s.trim_end_matches(".git"))
1189            .unwrap_or(path_str);
1190        return Ok(("local".to_string(), repo_name.to_string()));
1191    }
1192
1193    // Handle plain local paths (absolute or relative)
1194    if url.starts_with('/') || url.starts_with("./") || url.starts_with("../") {
1195        let path = Path::new(url);
1196        let repo_name = path
1197            .file_name()
1198            .and_then(|n| n.to_str())
1199            .map(|s| s.trim_end_matches(".git"))
1200            .unwrap_or(url);
1201        return Ok(("local".to_string(), repo_name.to_string()));
1202    }
1203
1204    // Handle SSH URLs like git@github.com:user/repo.git
1205    if url.contains('@')
1206        && url.contains(':')
1207        && !url.starts_with("ssh://")
1208        && let Some(colon_pos) = url.find(':')
1209    {
1210        let path = &url[colon_pos + 1..];
1211        let path = path.trim_end_matches(".git");
1212        if let Some(slash_pos) = path.find('/') {
1213            return Ok((path[..slash_pos].to_string(), path[slash_pos + 1..].to_string()));
1214        }
1215    }
1216
1217    // Handle HTTPS URLs
1218    if url.contains("github.com") || url.contains("gitlab.com") || url.contains("bitbucket.org") {
1219        let parts: Vec<&str> = url.split('/').collect();
1220        if parts.len() >= 2 {
1221            let repo = parts[parts.len() - 1].trim_end_matches(".git");
1222            let owner = parts[parts.len() - 2];
1223            return Ok((owner.to_string(), repo.to_string()));
1224        }
1225    }
1226
1227    Err(anyhow::anyhow!("Could not parse repository owner and name from URL"))
1228}
1229
1230/// Strips authentication information from a Git URL for safe display or logging.
1231///
1232/// # Arguments
1233///
1234/// * `url` - The Git URL that may contain authentication information
1235/// # Return Value
1236///
1237/// - HTTPS URLs: Removes `user:token@` prefix
1238/// - SSH URLs: Returned unchanged (no embedded auth to strip)
1239/// - Other formats: Returned unchanged if no auth detected
1240///
1241pub fn strip_auth_from_url(url: &str) -> Result<String> {
1242    if url.starts_with("https://") || url.starts_with("http://") {
1243        // Find the @ symbol that marks the end of authentication
1244        if let Some(at_pos) = url.find('@') {
1245            let protocol_end = if url.starts_with("https://") {
1246                "https://".len()
1247            } else {
1248                "http://".len()
1249            };
1250
1251            // Check if @ is part of auth (comes before first /)
1252            let first_slash = url[protocol_end..].find('/').map(|p| p + protocol_end);
1253            if first_slash.is_none() || at_pos < first_slash.unwrap() {
1254                // Extract protocol and the part after @
1255                let protocol = &url[..protocol_end];
1256                let after_auth = &url[at_pos + 1..];
1257                return Ok(format!("{protocol}{after_auth}"));
1258            }
1259        }
1260    }
1261
1262    // Return URL as-is if no auth found or not HTTP(S)
1263    Ok(url.to_string())
1264}