agpm_cli/git/
command_builder.rs

1//! Type-safe Git command builder for consistent command execution
2//!
3//! This module provides a fluent API for building and executing Git commands,
4//! eliminating duplication and ensuring consistent error handling across the codebase.
5
6use anyhow::{Context, Result};
7use std::path::Path;
8use std::process::Stdio;
9use std::time::Duration;
10use tokio::process::Command;
11use tokio::time::timeout;
12
13use crate::core::AgpmError;
14use crate::utils::platform::get_git_command;
15
16/// Type-safe builder for constructing and executing Git commands with consistent error handling.
17///
18/// This builder provides a fluent API for Git command construction that ensures
19/// consistent behavior across AGPM's Git operations. It handles platform-specific
20/// differences, timeout management, error context, and output capture in a unified way.
21///
22/// # Features
23///
24/// - **Fluent API**: Chainable methods for building commands
25/// - **Error Context**: Automatic error message enhancement with context
26/// - **Timeout Management**: Configurable timeouts with sensible defaults
27/// - **Platform Independence**: Works across Windows, macOS, and Linux
28/// - **Output Capture**: Flexible handling of command output and errors
29/// - **Environment Variables**: Support for Git-specific environment settings
30///
31/// # Examples
32///
33/// ```rust,ignore
34/// use agpm_cli::git::command_builder::GitCommand;
35/// use std::path::Path;
36///
37/// # async fn example() -> anyhow::Result<()> {
38/// // Simple command with output capture
39/// let output = GitCommand::new()
40///     .args(["status", "--porcelain"])
41///     .current_dir(Path::new("/path/to/repo"))
42///     .execute()
43///     .await?;
44///
45/// // Command with timeout and error context
46/// let result = GitCommand::new()
47///     .args(["clone", "https://github.com/example/repo.git"])
48///     .timeout(std::time::Duration::from_secs(60))
49///     .with_context("Cloning community repository")
50///     .execute_success()
51///     .await?;
52///
53/// // Interactive command (no output capture)
54/// GitCommand::new()
55///     .args(["merge", "--interactive"])
56///     .inherit_stdio()
57///     .execute_success()
58///     .await?;
59/// # Ok(())
60/// # }
61/// ```
62///
63/// # Default Configuration
64///
65/// New commands are created with sensible defaults:
66/// - **Timeout**: 5 minutes (300 seconds)
67/// - **Output capture**: Enabled
68/// - **Working directory**: Current process directory
69/// - **Environment**: Inherits from parent process
70///
71/// # Platform Compatibility
72///
73/// The builder automatically handles platform-specific Git command location:
74/// - **Windows**: Uses `git.exe` from PATH or common installation directories
75/// - **Unix-like**: Uses `git` from PATH
76/// - **Error handling**: Provides clear messages if Git is not installed
77pub struct GitCommand {
78    /// Command arguments to pass to Git (e.g., ["clone", "url", "path"])
79    args: Vec<String>,
80
81    /// Working directory for command execution (defaults to current directory)
82    current_dir: Option<std::path::PathBuf>,
83
84    /// Whether to capture command output (true) or inherit stdio (false)
85    capture_output: bool,
86
87    /// Environment variables to set for the Git process
88    env_vars: Vec<(String, String)>,
89
90    /// Maximum duration to wait for command completion (None = no timeout)
91    timeout_duration: Option<Duration>,
92
93    /// Optional context string for enhanced error messages
94    context: Option<String>,
95
96    /// For clone commands, store the URL for better error messages
97    clone_url: Option<String>,
98}
99
100impl Default for GitCommand {
101    fn default() -> Self {
102        Self {
103            args: Vec::new(),
104            clone_url: None,
105            current_dir: None,
106            capture_output: true,
107            env_vars: Vec::new(),
108            // Default timeout of 5 minutes for most git operations
109            timeout_duration: Some(Duration::from_secs(300)),
110            context: None,
111        }
112    }
113}
114
115impl GitCommand {
116    /// Creates a new Git command builder with default settings.
117    ///
118    /// The new builder is initialized with:
119    /// - Empty argument list
120    /// - Current process directory as working directory
121    /// - Output capture enabled
122    /// - 5-minute timeout
123    /// - No additional environment variables
124    ///
125    /// # Examples
126    ///
127    /// ```rust,ignore
128    /// use agpm_cli::git::command_builder::GitCommand;
129    ///
130    /// let cmd = GitCommand::new()
131    ///     .args(["status", "--short"])
132    ///     .current_dir("/path/to/repo");
133    /// ```
134    pub fn new() -> Self {
135        Self::default()
136    }
137
138    /// Sets the working directory for Git command execution.
139    ///
140    /// The command will be executed in the specified directory, which should
141    /// typically be a Git repository root or subdirectory. If not set, the
142    /// command executes in the current process working directory.
143    ///
144    /// # Arguments
145    ///
146    /// * `dir` - Path to the directory where the Git command should run
147    ///
148    /// # Examples
149    ///
150    /// ```rust,ignore
151    /// use agpm_cli::git::command_builder::GitCommand;
152    /// use std::path::Path;
153    ///
154    /// let cmd = GitCommand::new()
155    ///     .current_dir("/path/to/repository")
156    ///     .args(["log", "--oneline"]);
157    ///
158    /// // Can also use Path references
159    /// let repo_path = Path::new("/path/to/repo");
160    /// let cmd2 = GitCommand::new()
161    ///     .current_dir(repo_path)
162    ///     .args(["status"]);
163    /// ```
164    pub fn current_dir(mut self, dir: impl AsRef<Path>) -> Self {
165        self.current_dir = Some(dir.as_ref().to_path_buf());
166        self
167    }
168
169    /// Adds a single argument to the Git command.
170    ///
171    /// Arguments are passed to Git in the order they are added. This method
172    /// is useful when building commands dynamically or when you need to add
173    /// arguments conditionally.
174    ///
175    /// # Arguments
176    ///
177    /// * `arg` - The argument to add (will be converted to String)
178    ///
179    /// # Examples
180    ///
181    /// ```rust,ignore
182    /// use agpm_cli::git::command_builder::GitCommand;
183    ///
184    /// let cmd = GitCommand::new()
185    ///     .arg("clone")
186    ///     .arg("--depth")
187    ///     .arg("1")
188    ///     .arg("https://github.com/example/repo.git");
189    /// ```
190    ///
191    /// # Note
192    ///
193    /// For adding multiple arguments at once, consider using [`args`](Self::args)
194    /// which is more efficient for static argument lists.
195    pub fn arg(mut self, arg: impl Into<String>) -> Self {
196        self.args.push(arg.into());
197        self
198    }
199
200    /// Adds multiple arguments to the Git command.
201    ///
202    /// This is the preferred method for adding multiple arguments at once,
203    /// as it's more efficient than multiple calls to [`arg`](Self::arg).
204    /// Arguments can be provided as any iterable of string-like types.
205    ///
206    /// # Arguments
207    ///
208    /// * `args` - An iterable of arguments to add to the command
209    ///
210    /// # Examples
211    ///
212    /// ```rust,ignore
213    /// use agpm_cli::git::command_builder::GitCommand;
214    ///
215    /// // Using array literals
216    /// let cmd = GitCommand::new()
217    ///     .args(["clone", "--recursive", "https://github.com/example/repo.git"]);
218    ///
219    /// // Using vectors
220    /// let clone_args = vec!["clone", "--depth", "1"];
221    /// let cmd2 = GitCommand::new().args(clone_args);
222    ///
223    /// // Mixing with other methods
224    /// let cmd3 = GitCommand::new()
225    ///     .args(["fetch", "origin"])
226    ///     .arg("--prune");
227    /// ```
228    pub fn args<I, S>(mut self, args: I) -> Self
229    where
230        I: IntoIterator<Item = S>,
231        S: Into<String>,
232    {
233        self.args.extend(args.into_iter().map(Into::into));
234        self
235    }
236
237    /// Adds an environment variable for the Git command execution.
238    ///
239    /// Environment variables are useful for configuring Git behavior without
240    /// modifying global Git configuration. Common use cases include setting
241    /// credentials, configuration overrides, and locale settings.
242    ///
243    /// # Arguments
244    ///
245    /// * `key` - Environment variable name
246    /// * `value` - Environment variable value
247    ///
248    /// # Examples
249    ///
250    /// ```rust,ignore
251    /// use agpm_cli::git::command_builder::GitCommand;
252    ///
253    /// // Set Git configuration for this command only
254    /// let cmd = GitCommand::new()
255    ///     .args(["clone", "https://github.com/example/repo.git"])
256    ///     .env("GIT_CONFIG_GLOBAL", "/dev/null")  // Ignore global config
257    ///     .env("GIT_CONFIG_SYSTEM", "/dev/null"); // Ignore system config
258    ///
259    /// // Set locale for consistent output parsing
260    /// let cmd2 = GitCommand::new()
261    ///     .args(["log", "--oneline"])
262    ///     .env("LC_ALL", "C");
263    /// ```
264    ///
265    /// # Common Environment Variables
266    ///
267    /// - `GIT_DIR`: Override Git directory location
268    /// - `GIT_WORK_TREE`: Override working tree location
269    /// - `GIT_CONFIG_*`: Override configuration file locations
270    /// - `LC_ALL`: Set locale for consistent output parsing
271    pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
272        self.env_vars.push((key.into(), value.into()));
273        self
274    }
275
276    /// Disables output capture, allowing the command to inherit parent stdio.
277    ///
278    /// By default, Git commands have their output captured for processing.
279    /// This method disables capture, allowing the command to write directly
280    /// to the terminal. This is useful for interactive commands or when you
281    /// want to show Git output directly to the user.
282    ///
283    /// # Use Cases
284    ///
285    /// - Interactive Git commands (merge conflicts, rebase, etc.)
286    /// - Commands where you want real-time output streaming
287    /// - Debugging Git operations by seeing output immediately
288    ///
289    /// # Examples
290    ///
291    /// ```rust,no_run
292    /// use agpm_cli::git::command_builder::GitCommand;
293    ///
294    /// // Interactive merge that may require user input
295    /// # async fn example() -> anyhow::Result<()> {
296    /// GitCommand::new()
297    ///     .args(["merge", "feature-branch"])
298    ///     .inherit_stdio()  // Allow user interaction
299    ///     .execute_success()
300    ///     .await?;
301    ///
302    /// // Show clone progress to user
303    /// GitCommand::new()
304    ///     .args(["clone", "--progress", "https://github.com/large/repo.git"])
305    ///     .inherit_stdio()  // Show progress bars
306    ///     .execute_success()
307    ///     .await?;
308    /// # Ok(())
309    /// # }
310    /// ```
311    ///
312    /// # Note
313    ///
314    /// When stdio is inherited, you cannot use [`execute`](Self::execute) to
315    /// capture output. Use [`execute_success`](Self::execute_success) instead.
316    pub const fn inherit_stdio(mut self) -> Self {
317        self.capture_output = false;
318        self
319    }
320
321    /// Set a custom timeout for the command (None for no timeout)
322    pub const fn with_timeout(mut self, duration: Option<Duration>) -> Self {
323        self.timeout_duration = duration;
324        self
325    }
326
327    /// Set a context for logging (e.g., dependency name)
328    ///
329    /// The context is included in debug log messages to help distinguish between
330    /// concurrent operations when processing multiple dependencies in parallel.
331    /// This is especially useful when using worktrees for parallel Git operations.
332    ///
333    /// # Arguments
334    ///
335    /// * `context` - A string identifier for this operation (typically dependency name)
336    ///
337    /// # Examples
338    ///
339    /// ```rust,no_run
340    /// use agpm_cli::git::command_builder::GitCommand;
341    ///
342    /// # async fn example() -> anyhow::Result<()> {
343    /// let output = GitCommand::fetch()
344    ///     .with_context("my-dependency")
345    ///     .current_dir("/path/to/repo")
346    ///     .execute()
347    ///     .await?;
348    /// # Ok(())
349    /// # }
350    /// ```
351    ///
352    /// # Logging Output
353    ///
354    /// With context, log messages will include the context identifier:
355    /// ```text
356    /// (my-dependency) Executing command: git -C /path/to/repo fetch --all --tags
357    /// (my-dependency) Command completed successfully
358    /// ```
359    pub fn with_context(mut self, context: impl Into<String>) -> Self {
360        self.context = Some(context.into());
361        self
362    }
363
364    /// Execute the command and return the output
365    pub async fn execute(self) -> Result<GitCommandOutput> {
366        let start = std::time::Instant::now();
367        let git_command = get_git_command();
368        let mut cmd = Command::new(git_command);
369
370        // Build the full arguments list including -C flag if needed
371        let mut full_args = Vec::new();
372        if let Some(ref dir) = self.current_dir {
373            // Use -C flag to specify working directory
374            // This makes git operations independent of the process's current directory
375            full_args.push("-C".to_string());
376            // Use the path as-is to avoid symlink resolution issues on macOS
377            // (e.g., /var vs /private/var)
378            full_args.push(dir.display().to_string());
379        }
380        full_args.extend(self.args.clone());
381
382        cmd.args(&full_args);
383
384        if let Some(ref ctx) = self.context {
385            tracing::debug!(
386                target: "git",
387                "({}) Executing command: {} {}",
388                ctx,
389                git_command,
390                full_args.join(" ")
391            );
392        } else {
393            tracing::debug!(
394                target: "git",
395                "Executing command: {} {}",
396                git_command,
397                full_args.join(" ")
398            );
399        }
400
401        for (key, value) in &self.env_vars {
402            tracing::trace!(target: "git", "Setting env var: {}={}", key, value);
403            cmd.env(key, value);
404        }
405
406        if self.capture_output {
407            cmd.stdout(Stdio::piped());
408            cmd.stderr(Stdio::piped());
409        } else {
410            cmd.stdout(Stdio::inherit());
411            cmd.stderr(Stdio::inherit());
412        }
413
414        // Timeout is set but we don't need to log it every time
415
416        let output_future = cmd.output();
417
418        let output = if let Some(duration) = self.timeout_duration {
419            if let Ok(result) = timeout(duration, output_future).await {
420                tracing::trace!(target: "git", "Command completed within timeout");
421                result.context(format!("Failed to execute git {}", full_args.join(" ")))?
422            } else {
423                tracing::warn!(
424                    target: "git",
425                    "Command timed out after {} seconds: git {}",
426                    duration.as_secs(),
427                    full_args.join(" ")
428                );
429                // Extract the actual git operation (skip -C and path if present)
430                let git_operation =
431                    if full_args.first() == Some(&"-C".to_string()) && full_args.len() > 2 {
432                        full_args.get(2).cloned().unwrap_or_else(|| "unknown".to_string())
433                    } else {
434                        full_args.first().cloned().unwrap_or_else(|| "unknown".to_string())
435                    };
436                return Err(AgpmError::GitCommandError {
437                    operation: git_operation,
438                    stderr: format!(
439                        "Git command timed out after {} seconds. This may indicate:\n\
440                        - Network connectivity issues\n\
441                        - Authentication prompts waiting for input\n\
442                        - Large repository operations taking too long\n\
443                        Try running the command manually: git {}",
444                        duration.as_secs(),
445                        full_args.join(" ")
446                    ),
447                }
448                .into());
449            }
450        } else {
451            tracing::trace!(target: "git", "Executing command without timeout");
452            output_future.await.context(format!("Failed to execute git {}", full_args.join(" ")))?
453        };
454
455        if !output.status.success() {
456            let stderr = String::from_utf8_lossy(&output.stderr);
457            let stdout = String::from_utf8_lossy(&output.stdout);
458
459            tracing::debug!(
460                target: "git",
461                "Command failed with exit code: {:?}",
462                output.status.code()
463            );
464            if !stderr.is_empty() {
465                tracing::debug!(target: "git", "Error: {}", stderr);
466            }
467            if !stdout.is_empty() && stderr.is_empty() {
468                tracing::debug!(target: "git", "Error output: {}", stdout);
469            }
470
471            // Provide context-specific error messages
472            // Skip -C flag arguments when checking command type
473            let args_start = if full_args.first() == Some(&"-C".to_string()) && full_args.len() > 2
474            {
475                2
476            } else {
477                0
478            };
479            let effective_args = &full_args[args_start..];
480
481            let error = if effective_args.first().is_some_and(|arg| arg == "clone") {
482                // Use the URL we stored when building the command, not by parsing args
483                let url = self.clone_url.unwrap_or_else(|| "unknown".to_string());
484                AgpmError::GitCloneFailed {
485                    url,
486                    reason: stderr.to_string(),
487                }
488            } else if effective_args.first().is_some_and(|arg| arg == "checkout") {
489                let reference = effective_args.get(1).cloned().unwrap_or_default();
490                AgpmError::GitCheckoutFailed {
491                    reference,
492                    reason: stderr.to_string(),
493                }
494            } else if effective_args.first().is_some_and(|arg| arg == "worktree") {
495                let subcommand = effective_args.get(1).cloned().unwrap_or_default();
496                AgpmError::GitCommandError {
497                    operation: format!("worktree {subcommand}"),
498                    stderr: if stderr.is_empty() {
499                        stdout.to_string()
500                    } else {
501                        stderr.to_string()
502                    },
503                }
504            } else {
505                AgpmError::GitCommandError {
506                    operation: effective_args
507                        .first()
508                        .cloned()
509                        .unwrap_or_else(|| "unknown".to_string()),
510                    stderr: stderr.to_string(),
511                }
512            };
513
514            return Err(error.into());
515        }
516
517        let stdout = String::from_utf8_lossy(&output.stdout).to_string();
518        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
519
520        // Log stdout/stderr without prefixes if they're not empty
521        if !stdout.is_empty() {
522            if let Some(ref ctx) = self.context {
523                tracing::debug!(target: "git", "({}) {}", ctx, stdout.trim());
524            } else {
525                tracing::debug!(target: "git", "{}", stdout.trim());
526            }
527        }
528        if !stderr.is_empty() {
529            if let Some(ref ctx) = self.context {
530                tracing::debug!(target: "git", "({}) {}", ctx, stderr.trim());
531            } else {
532                tracing::debug!(target: "git", "{}", stderr.trim());
533            }
534        }
535
536        let elapsed = start.elapsed();
537
538        // Log performance for expensive operations
539        if elapsed.as_secs() > 1 {
540            let operation = if full_args.first() == Some(&"-C".to_string()) && full_args.len() > 2 {
541                full_args.get(2).cloned().unwrap_or_else(|| "unknown".to_string())
542            } else {
543                full_args.first().cloned().unwrap_or_else(|| "unknown".to_string())
544            };
545
546            if let Some(ref ctx) = self.context {
547                tracing::info!(target: "git::perf", "({}) Git {} took {:.2}s", ctx, operation, elapsed.as_secs_f64());
548            } else {
549                tracing::info!(target: "git::perf", "Git {} took {:.2}s", operation, elapsed.as_secs_f64());
550            }
551        } else if elapsed.as_millis() > 100 {
552            // Log debug for moderately slow operations
553            let operation = if full_args.first() == Some(&"-C".to_string()) && full_args.len() > 2 {
554                full_args.get(2).cloned().unwrap_or_else(|| "unknown".to_string())
555            } else {
556                full_args.first().cloned().unwrap_or_else(|| "unknown".to_string())
557            };
558
559            if let Some(ref ctx) = self.context {
560                tracing::debug!(target: "git::perf", "({}) Git {} took {}ms", ctx, operation, elapsed.as_millis());
561            } else {
562                tracing::debug!(target: "git::perf", "Git {} took {}ms", operation, elapsed.as_millis());
563            }
564        }
565
566        Ok(GitCommandOutput {
567            stdout,
568            stderr,
569        })
570    }
571
572    /// Execute the command and return only stdout as a trimmed string
573    pub async fn execute_stdout(self) -> Result<String> {
574        let output = self.execute().await?;
575        Ok(output.stdout.trim().to_string())
576    }
577
578    /// Execute the command and check for success without capturing output
579    pub async fn execute_success(self) -> Result<()> {
580        self.execute().await?;
581        Ok(())
582    }
583}
584
585/// Output from a Git command
586pub struct GitCommandOutput {
587    /// Standard output from the Git command
588    pub stdout: String,
589    /// Standard error output from the Git command
590    pub stderr: String,
591}
592
593// Convenience builders for common Git operations
594
595impl GitCommand {
596    /// Create a clone command
597    pub fn clone(url: &str, target: impl AsRef<Path>) -> Self {
598        let mut cmd = Self::new();
599        cmd.args.push("clone".to_string());
600        cmd.args.push("--progress".to_string());
601        cmd.args.push("--filter=blob:none".to_string());
602        cmd.args.push("--recurse-submodules".to_string());
603        cmd.args.push(url.to_string());
604        cmd.args.push(target.as_ref().display().to_string());
605        cmd.clone_url = Some(url.to_string());
606        cmd
607    }
608
609    /// Create a clone command with specific depth
610    ///
611    /// Create a fetch command
612    pub fn fetch() -> Self {
613        // Use --all to fetch from all remotes and --tags to get tags
614        // For bare repositories, we need to ensure remote tracking branches are created
615        Self::new().args(["fetch", "--all", "--tags", "--force"])
616    }
617
618    /// Create a checkout command
619    pub fn checkout(ref_name: &str) -> Self {
620        Self::new().args(["checkout", ref_name])
621    }
622
623    /// Create a checkout command that forces branch creation/update
624    pub fn checkout_branch(branch_name: &str, remote_ref: &str) -> Self {
625        Self::new().args(["checkout", "-B", branch_name, remote_ref])
626    }
627
628    /// Create a reset command
629    pub fn reset_hard() -> Self {
630        Self::new().args(["reset", "--hard", "HEAD"])
631    }
632
633    /// Create a tag list command
634    pub fn list_tags() -> Self {
635        Self::new().args(["tag", "-l"])
636    }
637
638    /// Create a branch list command
639    pub fn list_branches() -> Self {
640        Self::new().args(["branch", "-r"])
641    }
642
643    /// Create a rev-parse command
644    pub fn rev_parse(ref_name: &str) -> Self {
645        Self::new().args(["rev-parse", ref_name])
646    }
647
648    /// Create a command to get the current commit hash
649    pub fn current_commit() -> Self {
650        Self::new().args(["rev-parse", "HEAD"])
651    }
652
653    /// Create a command to get the remote URL
654    pub fn remote_url() -> Self {
655        Self::new().args(["remote", "get-url", "origin"])
656    }
657
658    /// Create a command to set the remote URL
659    pub fn set_remote_url(url: &str) -> Self {
660        Self::new().args(["remote", "set-url", "origin", url])
661    }
662
663    /// Create a ls-remote command for repository verification
664    pub fn ls_remote(url: &str) -> Self {
665        Self::new().args(["ls-remote", "--heads", url])
666    }
667
668    /// Create a command to verify a reference exists
669    pub fn verify_ref(ref_name: &str) -> Self {
670        Self::new().args(["rev-parse", "--verify", ref_name])
671    }
672
673    /// Create a command to get the current branch
674    pub fn current_branch() -> Self {
675        Self::new().args(["branch", "--show-current"])
676    }
677
678    /// Create an init command
679    pub fn init() -> Self {
680        Self::new().arg("init")
681    }
682
683    /// Create an add command
684    pub fn add(pathspec: &str) -> Self {
685        Self::new().args(["add", pathspec])
686    }
687
688    /// Create a commit command
689    pub fn commit(message: &str) -> Self {
690        Self::new().args(["commit", "-m", message])
691    }
692
693    /// Create a push command
694    pub fn push() -> Self {
695        Self::new().arg("push")
696    }
697
698    /// Create a status command
699    pub fn status() -> Self {
700        Self::new().arg("status")
701    }
702
703    /// Create a diff command
704    pub fn diff() -> Self {
705        Self::new().arg("diff")
706    }
707
708    /// Create a git clone --bare command for bare repository.
709    ///
710    /// Bare repositories are optimized for use as a source for worktrees,
711    /// allowing multiple concurrent checkouts without conflicts. This is
712    /// the preferred method for parallel operations in AGPM.
713    ///
714    /// # Arguments
715    ///
716    /// * `url` - The remote repository URL to clone
717    /// * `target` - The local directory where the bare repository will be stored
718    ///
719    /// # Returns
720    ///
721    /// A configured `GitCommand` ready for execution.
722    ///
723    /// # Examples
724    ///
725    /// ```rust,no_run
726    /// use agpm_cli::git::command_builder::GitCommand;
727    ///
728    /// # async fn example() -> anyhow::Result<()> {
729    /// GitCommand::clone_bare(
730    ///     "https://github.com/example/repo.git",
731    ///     "/tmp/repo.git"
732    /// )
733    /// .execute_success()
734    /// .await?;
735    /// # Ok(())
736    /// # }
737    /// ```
738    ///
739    /// # Worktree Usage
740    ///
741    /// Bare repositories created with this command are designed to be used
742    /// with [`worktree_add`](#method.worktree_add) for parallel operations:
743    ///
744    /// ```rust,no_run
745    /// use agpm_cli::git::command_builder::GitCommand;
746    ///
747    /// # async fn worktree_example() -> anyhow::Result<()> {
748    /// // Clone bare repository
749    /// GitCommand::clone_bare("https://github.com/example/repo.git", "/tmp/repo.git")
750    ///     .execute_success()
751    ///     .await?;
752    ///
753    /// // Create working directory from bare repo
754    /// GitCommand::worktree_add("/tmp/work1", Some("v1.0.0"))
755    ///     .current_dir("/tmp/repo.git")
756    ///     .execute_success()
757    ///     .await?;
758    /// # Ok(())
759    /// # }
760    /// ```
761    pub fn clone_bare(url: &str, target: impl AsRef<Path>) -> Self {
762        let mut cmd = Self::new();
763        let mut args = vec!["clone".to_string(), "--bare".to_string(), "--progress".to_string()];
764
765        // Only use partial clone for remote repositories
766        // Local repositories (file://, absolute paths, relative paths) need full clones
767        // to work properly with worktrees, especially when they're bare repositories
768        let is_local = url.starts_with("file://")
769            || url.starts_with('/')
770            || url.starts_with('.')
771            || url.starts_with('~')
772            || (url.len() > 1 && url.chars().nth(1) == Some(':')); // Windows paths like C:
773
774        if !is_local {
775            args.push("--filter=blob:none".to_string());
776        }
777
778        args.extend(vec![
779            "--recurse-submodules".to_string(),
780            url.to_string(),
781            target.as_ref().display().to_string(),
782        ]);
783
784        cmd.args.extend(args);
785        cmd.clone_url = Some(url.to_string());
786        cmd
787    }
788
789    /// Create a worktree add command for parallel-safe Git operations.
790    ///
791    /// Worktrees allow multiple working directories to be checked out from
792    /// a single bare repository, enabling safe parallel operations on different
793    /// versions of the same repository without conflicts.
794    ///
795    /// # Arguments
796    ///
797    /// * `worktree_path` - The path where the new worktree will be created
798    /// * `reference` - Optional Git reference (branch, tag, or commit) to checkout
799    ///
800    /// # Returns
801    ///
802    /// A configured `GitCommand` that must be executed from a bare repository directory.
803    ///
804    /// # Parallel Safety
805    ///
806    /// Each worktree is independent and can be safely accessed concurrently:
807    /// - Different dependencies can use different worktrees simultaneously
808    /// - No conflicts between parallel checkout operations
809    /// - Each worktree maintains its own working directory state
810    ///
811    /// # Examples
812    ///
813    /// ```rust,no_run
814    /// use agpm_cli::git::command_builder::GitCommand;
815    ///
816    /// # async fn example() -> anyhow::Result<()> {
817    /// // Create worktree with specific version
818    /// GitCommand::worktree_add("/tmp/work-v1", Some("v1.0.0"))
819    ///     .current_dir("/tmp/bare-repo.git")
820    ///     .execute_success()
821    ///     .await?;
822    ///
823    /// // Create worktree with default branch
824    /// GitCommand::worktree_add("/tmp/work-main", None)
825    ///     .current_dir("/tmp/bare-repo.git")
826    ///     .execute_success()
827    ///     .await?;
828    /// # Ok(())
829    /// # }
830    /// ```
831    ///
832    /// # Concurrency Control
833    ///
834    /// AGPM uses a global semaphore to limit concurrent Git operations and
835    /// prevent resource exhaustion. This is handled automatically by the
836    /// cache layer when using worktrees for parallel installations.
837    ///
838    /// # Reference Types
839    ///
840    /// The `reference` parameter supports various Git reference types:
841    /// - **Tags**: `"v1.0.0"` (most common for package versions)
842    /// - **Branches**: `"main"`, `"develop"`
843    /// - **Commits**: `"abc123"` (specific commit hashes)
844    /// - **None**: Uses repository's default branch
845    pub fn worktree_add(worktree_path: impl AsRef<Path>, reference: Option<&str>) -> Self {
846        let mut cmd = Self::new();
847        cmd.args.push("worktree".to_string());
848        cmd.args.push("add".to_string());
849
850        // Add the worktree path
851        cmd.args.push(worktree_path.as_ref().display().to_string());
852
853        // Add the reference if provided
854        if let Some(ref_name) = reference {
855            cmd.args.push(ref_name.to_string());
856        }
857
858        cmd
859    }
860
861    /// Remove a worktree and clean up associated files.
862    ///
863    /// This command removes a worktree that was created with [`worktree_add`]
864    /// and cleans up Git's internal bookkeeping. The `--force` flag is used
865    /// to ensure removal even if the worktree has local modifications.
866    ///
867    /// # Arguments
868    ///
869    /// * `worktree_path` - The path to the worktree to remove
870    ///
871    /// # Returns
872    ///
873    /// A configured `GitCommand` that must be executed from the bare repository.
874    ///
875    /// # Examples
876    ///
877    /// ```rust,no_run
878    /// use agpm_cli::git::command_builder::GitCommand;
879    ///
880    /// # async fn example() -> anyhow::Result<()> {
881    /// // Remove a worktree
882    /// GitCommand::worktree_remove("/tmp/work-v1")
883    ///     .current_dir("/tmp/bare-repo.git")
884    ///     .execute_success()
885    ///     .await?;
886    /// # Ok(())
887    /// # }
888    /// ```
889    ///
890    /// # Force Removal
891    ///
892    /// The command uses `--force` to ensure removal succeeds even when:
893    /// - The worktree has uncommitted changes
894    /// - Files are locked or in use
895    /// - The worktree directory structure has been modified
896    ///
897    /// This is appropriate for AGPM's use case where worktrees are temporary
898    /// and any local changes should be discarded.
899    ///
900    /// [`worktree_add`]: #method.worktree_add
901    pub fn worktree_remove(worktree_path: impl AsRef<Path>) -> Self {
902        Self::new().args([
903            "worktree",
904            "remove",
905            "--force",
906            &worktree_path.as_ref().display().to_string(),
907        ])
908    }
909
910    /// List all worktrees associated with a repository.
911    ///
912    /// This command returns information about all worktrees linked to the
913    /// current bare repository. The `--porcelain` flag provides machine-readable
914    /// output that's easier to parse programmatically.
915    ///
916    /// # Returns
917    ///
918    /// A configured `GitCommand` that must be executed from a bare repository.
919    ///
920    /// # Output Format
921    ///
922    /// The porcelain output format provides structured information:
923    /// ```text
924    /// worktree /path/to/worktree1
925    /// HEAD abc123def456...
926    /// branch refs/heads/main
927    ///
928    /// worktree /path/to/worktree2
929    /// HEAD def456abc123...
930    /// detached
931    /// ```
932    ///
933    /// # Examples
934    ///
935    /// ```rust,no_run
936    /// use agpm_cli::git::command_builder::GitCommand;
937    ///
938    /// # async fn example() -> anyhow::Result<()> {
939    /// let output = GitCommand::worktree_list()
940    ///     .current_dir("/tmp/bare-repo.git")
941    ///     .execute_stdout()
942    ///     .await?;
943    ///
944    /// // Parse output to find worktree paths
945    /// for line in output.lines() {
946    ///     if line.starts_with("worktree ") {
947    ///         let path = line.strip_prefix("worktree ").unwrap();
948    ///         println!("Found worktree: {}", path);
949    ///     }
950    /// }
951    /// # Ok(())
952    /// # }
953    /// ```
954    pub fn worktree_list() -> Self {
955        Self::new().args(["worktree", "list", "--porcelain"])
956    }
957
958    /// Prune stale worktree administrative files.
959    ///
960    /// This command cleans up worktree entries that no longer have corresponding
961    /// directories on disk. It's useful for maintenance after worktrees have been
962    /// manually deleted or when cleaning up after failed operations.
963    ///
964    /// # Returns
965    ///
966    /// A configured `GitCommand` that must be executed from a bare repository.
967    ///
968    /// # When to Use
969    ///
970    /// Prune worktrees when:
971    /// - Worktree directories have been manually deleted
972    /// - After bulk cleanup operations
973    /// - During cache maintenance
974    /// - When Git reports stale worktree references
975    ///
976    /// # Examples
977    ///
978    /// ```rust,no_run
979    /// use agpm_cli::git::command_builder::GitCommand;
980    ///
981    /// # async fn example() -> anyhow::Result<()> {
982    /// // Clean up stale worktree references
983    /// GitCommand::worktree_prune()
984    ///     .current_dir("/tmp/bare-repo.git")
985    ///     .execute_success()
986    ///     .await?;
987    /// # Ok(())
988    /// # }
989    /// ```
990    ///
991    /// # Performance
992    ///
993    /// This is a lightweight operation that only updates Git's internal
994    /// bookkeeping. It doesn't remove actual worktree directories.
995    pub fn worktree_prune() -> Self {
996        Self::new().args(["worktree", "prune"])
997    }
998}
999
1000#[cfg(test)]
1001mod tests {
1002    use super::*;
1003
1004    #[test]
1005    fn test_command_builder_basic() {
1006        let cmd = GitCommand::new().arg("status").arg("--short");
1007        assert_eq!(cmd.args, vec!["status", "--short"]);
1008    }
1009
1010    #[tokio::test]
1011    async fn test_git_command_logging() {
1012        // This test verifies that git stdout/stderr are logged at debug level
1013        // Run with RUST_LOG=debug to see the output
1014        let result = GitCommand::new().args(["--version"]).execute().await;
1015
1016        assert!(result.is_ok(), "Git --version should succeed");
1017        let output = result.unwrap();
1018        assert!(!output.stdout.is_empty(), "Git version should produce stdout");
1019        // When run with RUST_LOG=debug, this should produce:
1020        // - "Executing git command: git --version"
1021        // - "Git command completed successfully"
1022        // - "Git stdout (raw): git version X.Y.Z"
1023    }
1024
1025    #[test]
1026    fn test_command_builder_with_dir() {
1027        let cmd = GitCommand::new().current_dir("/tmp/repo").arg("status");
1028        assert_eq!(cmd.current_dir, Some(std::path::PathBuf::from("/tmp/repo")));
1029    }
1030
1031    #[test]
1032    fn test_clone_builder() {
1033        let cmd = GitCommand::clone("https://example.com/repo.git", "/tmp/target");
1034        assert_eq!(cmd.args[0], "clone");
1035        assert_eq!(cmd.args[1], "--progress");
1036        assert!(cmd.args.contains(&"https://example.com/repo.git".to_string()));
1037    }
1038
1039    #[test]
1040    fn test_checkout_branch_builder() {
1041        let cmd = GitCommand::checkout_branch("main", "origin/main");
1042        assert_eq!(cmd.args, vec!["checkout", "-B", "main", "origin/main"]);
1043    }
1044}