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                    // IMPORTANT: Do NOT call prune_worktrees() here - it scans ALL worktrees
553                    // and causes race conditions when multiple processes create worktrees
554                    // concurrently from the same bare repo. Instead, use targeted recovery:
555                    // 1. Only remove worktree if it's INVALID (missing .git file)
556                    // 2. Use `git worktree add --force` which handles stale registrations
557                    if error_str.contains("missing but already registered worktree") {
558                        // Only remove the worktree if it's INVALID (missing .git file).
559                        // A valid worktree has a .git file pointing back to the bare repo.
560                        // Never remove a valid worktree - other processes may be reading from it!
561                        let worktree_git_file = worktree_path.join(".git");
562                        let is_invalid_worktree =
563                            worktree_path.exists() && !worktree_git_file.exists();
564
565                        if is_invalid_worktree {
566                            let _ = tokio::fs::remove_dir_all(worktree_path).await;
567                        }
568
569                        // Ensure parent directory exists before force add.
570                        // This handles the case where the temp directory was partially cleaned up,
571                        // leaving Git's worktree metadata pointing to a non-existent path.
572                        if let Some(parent) = worktree_path.parent() {
573                            let _ = tokio::fs::create_dir_all(parent).await;
574                        }
575
576                        // Use `git worktree add --force` which can handle stale registrations
577                        // by overwriting them. No need to prune first.
578                        let worktree_path_str = worktree_path.display().to_string();
579                        let mut args = vec![
580                            "worktree".to_string(),
581                            "add".to_string(),
582                            "--force".to_string(),
583                            worktree_path_str,
584                        ];
585                        if let Some(r) = effective_ref {
586                            args.push(r.to_string());
587                        }
588
589                        let mut force_cmd = GitCommand::new().args(args).current_dir(&self.path);
590                        if let Some(ctx) = context {
591                            force_cmd = force_cmd.with_context(ctx);
592                        }
593
594                        match force_cmd.execute_success().await {
595                            Ok(()) => {
596                                // Initialize and update submodules in the new worktree
597                                let worktree_repo = Self::new(worktree_path);
598
599                                let mut init_cmd = GitCommand::new()
600                                    .args(["submodule", "init"])
601                                    .current_dir(worktree_path);
602                                if let Some(ctx) = context {
603                                    init_cmd = init_cmd.with_context(ctx);
604                                }
605                                let _ = init_cmd.execute_success().await;
606
607                                let mut update_cmd = GitCommand::new()
608                                    .args(["submodule", "update", "--recursive"])
609                                    .current_dir(worktree_path);
610                                if let Some(ctx) = context {
611                                    update_cmd = update_cmd.with_context(ctx);
612                                }
613                                let _ = update_cmd.execute_success().await;
614
615                                return Ok(worktree_repo);
616                            }
617                            Err(e2) => {
618                                // Fall through to other recovery paths with the original error context
619                                // but include the forced attempt error as context
620                                return Err(e).with_context(|| {
621                                    format!(
622                                        "Failed to create worktree at {} from {} (forced add failed: {})",
623                                        worktree_path.display(),
624                                        self.path.display(),
625                                        e2
626                                    )
627                                });
628                            }
629                        }
630                    }
631
632                    // If no reference was provided and the command failed, it might be because
633                    // the bare repo doesn't have a default branch set. Try with explicit HEAD
634                    if reference.is_none() && retry_count == 0 {
635                        let mut head_cmd = GitCommand::worktree_add(worktree_path, Some("HEAD"))
636                            .current_dir(&self.path);
637
638                        if let Some(ctx) = context {
639                            head_cmd = head_cmd.with_context(ctx);
640                        }
641
642                        let head_result = head_cmd.execute_success().await;
643
644                        match head_result {
645                            Ok(()) => {
646                                // Initialize and update submodules in the new worktree
647                                let worktree_repo = Self::new(worktree_path);
648
649                                // Initialize submodules
650                                let mut init_cmd = GitCommand::new()
651                                    .args(["submodule", "init"])
652                                    .current_dir(worktree_path);
653
654                                if let Some(ctx) = context {
655                                    init_cmd = init_cmd.with_context(ctx);
656                                }
657
658                                if let Err(e) = init_cmd.execute_success().await {
659                                    let error_str = e.to_string();
660                                    // Only ignore errors indicating no submodules are present
661                                    if !error_str.contains("No submodule mapping found")
662                                        && !error_str.contains("no submodule")
663                                    {
664                                        // For other errors, return them
665                                        return Err(e).context("Failed to initialize submodules");
666                                    }
667                                }
668
669                                // Update submodules
670                                let mut update_cmd = GitCommand::new()
671                                    .args(["submodule", "update", "--recursive"])
672                                    .current_dir(worktree_path);
673
674                                if let Some(ctx) = context {
675                                    update_cmd = update_cmd.with_context(ctx);
676                                }
677
678                                if let Err(e) = update_cmd.execute_success().await {
679                                    let error_str = e.to_string();
680                                    // Ignore errors related to no submodules
681                                    if !error_str.contains("No submodule mapping found")
682                                        && !error_str.contains("no submodule")
683                                    {
684                                        return Err(e).context("Failed to update submodules");
685                                    }
686                                }
687
688                                return Ok(worktree_repo);
689                            }
690                            Err(head_err) => {
691                                // If HEAD also fails, return the original error
692                                return Err(e).with_context(|| {
693                                    format!(
694                                        "Failed to create worktree at {} from {} (also tried HEAD: {})",
695                                        worktree_path.display(),
696                                        self.path.display(),
697                                        head_err
698                                    )
699                                });
700                            }
701                        }
702                    }
703
704                    // Check if the error is likely due to an invalid reference
705                    let error_str = e.to_string();
706                    if let Some(ref_name) = reference
707                        && (error_str.contains("pathspec")
708                            || error_str.contains("not found")
709                            || error_str.contains("ambiguous")
710                            || error_str.contains("invalid")
711                            || error_str.contains("unknown revision"))
712                    {
713                        return Err(anyhow::anyhow!(
714                            "Invalid version or reference '{ref_name}': Failed to checkout reference - the specified version/tag/branch does not exist in the repository"
715                        ));
716                    }
717
718                    return Err(e).with_context(|| {
719                        format!(
720                            "Failed to create worktree at {} from {}",
721                            worktree_path.display(),
722                            self.path.display()
723                        )
724                    });
725                }
726            }
727        }
728    }
729
730    /// Remove a worktree associated with this repository.
731    ///
732    /// # Arguments
733    ///
734    /// * `worktree_path` - The path to the worktree to remove
735    pub async fn remove_worktree(&self, worktree_path: impl AsRef<Path>) -> Result<()> {
736        let worktree_path = worktree_path.as_ref();
737
738        GitCommand::worktree_remove(worktree_path)
739            .current_dir(&self.path)
740            .execute_success()
741            .await
742            .with_context(|| format!("Failed to remove worktree at {}", worktree_path.display()))?;
743
744        // Also try to remove the directory if it still exists
745        if worktree_path.exists() {
746            tokio::fs::remove_dir_all(worktree_path).await.ok(); // Ignore errors as git worktree remove may have already cleaned it
747        }
748
749        Ok(())
750    }
751
752    /// List all worktrees associated with this repository.
753    ///
754    pub async fn list_worktrees(&self) -> Result<Vec<PathBuf>> {
755        let output = GitCommand::worktree_list().current_dir(&self.path).execute_stdout().await?;
756
757        let mut worktrees = Vec::new();
758        let mut current_worktree: Option<PathBuf> = None;
759
760        for line in output.lines() {
761            if line.starts_with("worktree ") {
762                if let Some(path) = line.strip_prefix("worktree ") {
763                    current_worktree = Some(PathBuf::from(path));
764                }
765            } else if line == "bare" {
766                // Skip bare repository entry
767                current_worktree = None;
768            } else if line.is_empty()
769                && current_worktree.is_some()
770                && let Some(path) = current_worktree.take()
771            {
772                worktrees.push(path);
773            }
774        }
775
776        // Add the last worktree if there is one
777        if let Some(path) = current_worktree {
778            worktrees.push(path);
779        }
780
781        Ok(worktrees)
782    }
783
784    /// Prune stale worktree administrative files.
785    ///
786    pub async fn prune_worktrees(&self) -> Result<()> {
787        GitCommand::worktree_prune()
788            .current_dir(&self.path)
789            .execute_success()
790            .await
791            .with_context(|| "Failed to prune worktrees")?;
792
793        Ok(())
794    }
795
796    /// Check if this repository is a bare repository.
797    ///
798    pub async fn is_bare(&self) -> Result<bool> {
799        let output = GitCommand::new()
800            .args(["config", "--get", "core.bare"])
801            .current_dir(&self.path)
802            .execute_stdout()
803            .await?;
804
805        Ok(output.trim() == "true")
806    }
807
808    /// Get the current commit SHA of the repository.
809    ///
810    /// # Returns
811    ///
812    /// # Errors
813    ///
814    /// - The repository is not valid
815    /// - HEAD is not pointing to a valid commit
816    /// - Git command fails
817    pub async fn get_current_commit(&self) -> Result<String> {
818        GitCommand::current_commit()
819            .current_dir(&self.path)
820            .execute_stdout()
821            .await
822            .context("Failed to get current commit")
823    }
824
825    /// Batch resolve multiple refs to SHAs in a single git process.
826    ///
827    /// Uses `git rev-parse <ref1> <ref2> ...` to resolve all refs at once, reducing
828    /// process spawn overhead from O(n) to O(1). This is significantly faster
829    /// for Windows where process spawning has high overhead.
830    ///
831    /// # Arguments
832    ///
833    /// * `refs` - Slice of ref specifications to resolve
834    ///
835    /// # Returns
836    ///
837    /// HashMap mapping each input ref to its resolved SHA (or None if not found)
838    ///
839    /// # Performance
840    ///
841    /// - Single process for all refs vs one per ref
842    /// - Reduces 100 refs from ~5-10 seconds to ~0.5 seconds on Windows
843    ///
844    /// # Examples
845    ///
846    /// ```rust,ignore
847    /// use agpm_cli::git::GitRepo;
848    ///
849    /// # async fn example() -> anyhow::Result<()> {
850    /// let repo = GitRepo::new("/path/to/repo");
851    /// let refs = vec!["v1.0.0", "main", "abc1234"];
852    /// let results = repo.resolve_refs_batch(&refs).await?;
853    ///
854    /// for (ref_name, sha) in results {
855    ///     if let Some(sha) = sha {
856    ///         println!("{} -> {}", ref_name, sha);
857    ///     } else {
858    ///         println!("{} not found", ref_name);
859    ///     }
860    /// }
861    /// # Ok(())
862    /// # }
863    /// ```
864    pub async fn resolve_refs_batch(
865        &self,
866        refs: &[&str],
867    ) -> Result<std::collections::HashMap<String, Option<String>>> {
868        use std::collections::HashMap;
869
870        if refs.is_empty() {
871            return Ok(HashMap::new());
872        }
873
874        // Partition refs: already-SHAs vs need-resolution
875        let (already_shas, to_resolve): (Vec<&str>, Vec<&str>) =
876            refs.iter().partition(|r| r.len() == 40 && r.chars().all(|c| c.is_ascii_hexdigit()));
877
878        let mut results: HashMap<String, Option<String>> = HashMap::new();
879
880        // Add already-resolved SHAs directly
881        for sha in already_shas {
882            results.insert(sha.to_string(), Some(sha.to_string()));
883        }
884
885        if to_resolve.is_empty() {
886            return Ok(results);
887        }
888
889        // Build arguments for git rev-parse: ["rev-parse", "ref1", "ref2", ...]
890        // This resolves all refs in a single git process
891        let mut args = vec!["rev-parse"];
892        args.extend(to_resolve.iter().copied());
893
894        // Execute batch resolution
895        let output = GitCommand::new().args(args).current_dir(&self.path).execute().await;
896
897        match output {
898            Ok(cmd_output) => {
899                // Parse output (one SHA per line, in order)
900                let shas: Vec<&str> = cmd_output.stdout.lines().collect();
901
902                for (i, ref_name) in to_resolve.iter().enumerate() {
903                    let sha = shas.get(i).and_then(|s| {
904                        let trimmed = s.trim();
905                        // Only accept valid SHA output (40 hex chars)
906                        if trimmed.len() == 40 && trimmed.chars().all(|c| c.is_ascii_hexdigit()) {
907                            Some(trimmed.to_string())
908                        } else {
909                            None
910                        }
911                    });
912                    results.insert(ref_name.to_string(), sha);
913                }
914            }
915            Err(e) => {
916                // If batch fails (e.g., one ref is invalid), fall back to individual resolution
917                tracing::debug!(
918                    target: "git",
919                    "Batch rev-parse failed, falling back to individual resolution: {}",
920                    e
921                );
922
923                for ref_name in to_resolve {
924                    let sha = GitCommand::rev_parse(ref_name)
925                        .current_dir(&self.path)
926                        .execute_stdout()
927                        .await
928                        .ok();
929                    results.insert(ref_name.to_string(), sha);
930                }
931            }
932        }
933
934        Ok(results)
935    }
936
937    /// Resolves a Git reference (tag, branch, commit) to its full SHA-1 hash.
938    ///
939    /// # Arguments
940    ///
941    /// * `ref_spec` - The Git reference to resolve (tag, branch, short/full SHA, or None for HEAD)
942    /// # Returns
943    ///
944    /// # Errors
945    ///
946    /// - The reference doesn't exist in the repository
947    /// - The repository is invalid or corrupted
948    /// - Git command execution fails
949    pub async fn resolve_to_sha(&self, ref_spec: Option<&str>) -> Result<String> {
950        let reference = ref_spec.unwrap_or("HEAD");
951
952        // Optimization: if it's already a full SHA, return it directly
953        if reference.len() == 40 && reference.chars().all(|c| c.is_ascii_hexdigit()) {
954            return Ok(reference.to_string());
955        }
956
957        // Determine the reference to resolve based on type (tag vs branch)
958        let ref_to_resolve = if !reference.contains('/') && reference != "HEAD" {
959            // Check if this is a tag (uses cached tag list for performance)
960            let is_tag = self
961                .list_tags()
962                .await
963                .map(|tags| tags.contains(&reference.to_string()))
964                .unwrap_or(false);
965
966            if is_tag {
967                // It's a tag - use it directly
968                reference.to_string()
969            } else {
970                // Assume it's a branch name - try to resolve origin/branch first to get the latest from remote
971                // This ensures we get the most recent commit after a fetch
972                let origin_ref = format!("origin/{reference}");
973                if GitCommand::rev_parse(&origin_ref)
974                    .current_dir(&self.path)
975                    .execute_stdout()
976                    .await
977                    .is_ok()
978                {
979                    origin_ref
980                } else {
981                    // Fallback to the original reference (might be a local branch)
982                    reference.to_string()
983                }
984            }
985        } else {
986            reference.to_string()
987        };
988
989        // Use rev-parse to get the full SHA
990        let sha = GitCommand::rev_parse(&ref_to_resolve)
991            .current_dir(&self.path)
992            .execute_stdout()
993            .await
994            .with_context(|| format!("Failed to resolve reference '{reference}' to SHA"))?;
995
996        // Ensure we have a full SHA (sometimes rev-parse can return short SHAs)
997        if sha.len() < 40 {
998            // Request the full SHA explicitly
999            let full_sha = GitCommand::new()
1000                .args(["rev-parse", "--verify", &format!("{reference}^{{commit}}")])
1001                .current_dir(&self.path)
1002                .execute_stdout()
1003                .await
1004                .with_context(|| format!("Failed to get full SHA for reference '{reference}'"))?;
1005            Ok(full_sha)
1006        } else {
1007            Ok(sha)
1008        }
1009    }
1010
1011    pub async fn get_current_branch(&self) -> Result<String> {
1012        let branch = GitCommand::current_branch()
1013            .current_dir(&self.path)
1014            .execute_stdout()
1015            .await
1016            .context("Failed to get current branch")?;
1017
1018        if branch.is_empty() {
1019            // Fallback for very old Git or repos without commits
1020            Ok("master".to_string())
1021        } else {
1022            Ok(branch)
1023        }
1024    }
1025
1026    /// Gets the default branch name for the repository.
1027    ///
1028    /// # Returns
1029    ///
1030    /// # Errors
1031    ///
1032    /// - Git commands fail with non-recoverable errors
1033    /// - Lock conflicts occur (propagated for caller to retry)
1034    /// - Default branch cannot be determined
1035    pub async fn get_default_branch(&self) -> Result<String> {
1036        let result = GitCommand::new()
1037            .args(["symbolic-ref", "refs/remotes/origin/HEAD"])
1038            .current_dir(&self.path)
1039            .execute_stdout()
1040            .await;
1041
1042        match result {
1043            Ok(symbolic_ref) => {
1044                if let Some(branch) = symbolic_ref.strip_prefix("refs/remotes/origin/") {
1045                    return Ok(branch.to_string());
1046                }
1047                // If parsing fails, fall through to the next method.
1048            }
1049            Err(e) => {
1050                let error_str = e.to_string();
1051                // If the ref is not found, it's not a fatal error, just fall back.
1052                // Any other error (like a lock file) should be propagated.
1053                if !error_str.contains("not a symbolic ref") && !error_str.contains("not found") {
1054                    return Err(e).context("Failed to get default branch via symbolic-ref");
1055                }
1056            }
1057        }
1058
1059        // Fallback: try to get current branch (for non-bare repos or if symbolic-ref fails)
1060        self.get_current_branch().await
1061    }
1062}
1063
1064// Module-level helper functions for Git environment management and URL processing
1065
1066/// Checks if Git is installed and accessible on the system.
1067///
1068/// # Return Value
1069///
1070/// - `true` if Git is installed and responding to `--version` commands
1071/// - `false` if Git is not found, not in PATH, or not executable
1072///
1073#[must_use]
1074pub fn is_git_installed() -> bool {
1075    // For synchronous checking, we still use std::process::Command directly
1076    std::process::Command::new(crate::utils::platform::get_git_command())
1077        .arg("--version")
1078        .output()
1079        .map(|output| output.status.success())
1080        .unwrap_or(false)
1081}
1082
1083/// Ensures Git is available on the system or returns a detailed error.
1084///
1085/// # Return Value
1086///
1087/// - `Ok(())` if Git is properly installed and accessible
1088/// - `Err(AgpmError::GitNotFound)` if Git is not available
1089///
1090pub fn ensure_git_available() -> Result<()> {
1091    if !is_git_installed() {
1092        return Err(AgpmError::GitNotFound.into());
1093    }
1094    Ok(())
1095}
1096
1097/// Checks if a path contains a Git repository (regular or bare).
1098///
1099/// # Arguments
1100///
1101/// * `path` - The path to check for a Git repository
1102/// # Returns
1103///
1104/// * `true` if the path is a valid Git repository (regular or bare)
1105/// * `false` if neither repository marker exists
1106#[must_use]
1107pub fn is_git_repository(path: &Path) -> bool {
1108    // Check for regular repository (.git directory) or bare repository (HEAD file)
1109    path.join(".git").exists() || path.join("HEAD").exists()
1110}
1111
1112/// Checks if a directory contains a valid Git repository.
1113///
1114/// # Arguments
1115///
1116/// * `path` - The directory path to check for Git repository validity
1117/// # Return Value
1118///
1119/// - `true` if the path contains a `.git` subdirectory
1120/// - `false` if the `.git` subdirectory is missing or the path doesn't exist
1121///
1122#[must_use]
1123pub fn is_valid_git_repo(path: &Path) -> bool {
1124    is_git_repository(path)
1125}
1126
1127/// Ensures a directory contains a valid Git repository or returns a detailed error.
1128///
1129/// # Arguments
1130///
1131/// * `path` - The directory path to validate as a Git repository
1132/// # Return Value
1133///
1134/// - `Ok(())` if the path contains a valid `.git` directory
1135/// - `Err(AgpmError::GitRepoInvalid)` if the path is not a Git repository
1136///
1137pub fn ensure_valid_git_repo(path: &Path) -> Result<()> {
1138    if !is_valid_git_repo(path) {
1139        return Err(AgpmError::GitRepoInvalid {
1140            path: path.display().to_string(),
1141        }
1142        .into());
1143    }
1144    Ok(())
1145}
1146
1147/// Parses a Git URL into owner and repository name components.
1148///
1149/// # Arguments
1150///
1151/// * `url` - The Git repository URL to parse
1152/// # Return Value
1153///
1154/// - `owner` is the user, organization, or "local" for local repositories
1155/// - `repository_name` is the repository name (with `.git` suffix removed)
1156///
1157/// # Errors
1158///
1159/// - The URL format is not recognized
1160/// - The URL doesn't contain sufficient path components
1161/// - The URL structure doesn't match expected patterns
1162///
1163pub fn parse_git_url(url: &str) -> Result<(String, String)> {
1164    use std::path::Path;
1165
1166    // Handle file:// URLs
1167    if url.starts_with("file://") {
1168        let path_str = url.trim_start_matches("file://");
1169        let path = Path::new(path_str);
1170        let repo_name = path
1171            .file_name()
1172            .and_then(|n| n.to_str())
1173            .map(|s| s.trim_end_matches(".git"))
1174            .unwrap_or(path_str);
1175        return Ok(("local".to_string(), repo_name.to_string()));
1176    }
1177
1178    // Handle plain local paths (absolute or relative)
1179    if url.starts_with('/') || url.starts_with("./") || url.starts_with("../") {
1180        let path = Path::new(url);
1181        let repo_name = path
1182            .file_name()
1183            .and_then(|n| n.to_str())
1184            .map(|s| s.trim_end_matches(".git"))
1185            .unwrap_or(url);
1186        return Ok(("local".to_string(), repo_name.to_string()));
1187    }
1188
1189    // Handle SSH URLs like git@github.com:user/repo.git
1190    if url.contains('@')
1191        && url.contains(':')
1192        && !url.starts_with("ssh://")
1193        && let Some(colon_pos) = url.find(':')
1194    {
1195        let path = &url[colon_pos + 1..];
1196        let path = path.trim_end_matches(".git");
1197        if let Some(slash_pos) = path.find('/') {
1198            return Ok((path[..slash_pos].to_string(), path[slash_pos + 1..].to_string()));
1199        }
1200    }
1201
1202    // Handle HTTPS URLs
1203    if url.contains("github.com") || url.contains("gitlab.com") || url.contains("bitbucket.org") {
1204        let parts: Vec<&str> = url.split('/').collect();
1205        if parts.len() >= 2 {
1206            let repo = parts[parts.len() - 1].trim_end_matches(".git");
1207            let owner = parts[parts.len() - 2];
1208            return Ok((owner.to_string(), repo.to_string()));
1209        }
1210    }
1211
1212    Err(anyhow::anyhow!("Could not parse repository owner and name from URL"))
1213}
1214
1215/// Strips authentication information from a Git URL for safe display or logging.
1216///
1217/// # Arguments
1218///
1219/// * `url` - The Git URL that may contain authentication information
1220/// # Return Value
1221///
1222/// - HTTPS URLs: Removes `user:token@` prefix
1223/// - SSH URLs: Returned unchanged (no embedded auth to strip)
1224/// - Other formats: Returned unchanged if no auth detected
1225///
1226pub fn strip_auth_from_url(url: &str) -> Result<String> {
1227    if url.starts_with("https://") || url.starts_with("http://") {
1228        // Find the @ symbol that marks the end of authentication
1229        if let Some(at_pos) = url.find('@') {
1230            let protocol_end = if url.starts_with("https://") {
1231                "https://".len()
1232            } else {
1233                "http://".len()
1234            };
1235
1236            // Check if @ is part of auth (comes before first /)
1237            let first_slash = url[protocol_end..].find('/').map(|p| p + protocol_end);
1238            if first_slash.is_none() || at_pos < first_slash.unwrap() {
1239                // Extract protocol and the part after @
1240                let protocol = &url[..protocol_end];
1241                let after_auth = &url[at_pos + 1..];
1242                return Ok(format!("{protocol}{after_auth}"));
1243            }
1244        }
1245    }
1246
1247    // Return URL as-is if no auth found or not HTTP(S)
1248    Ok(url.to_string())
1249}