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        // Always set git's CWD to system temp directory to prevent issues when
371        // test directories are deleted. Git may access CWD even with -C flag.
372        cmd.current_dir(std::env::temp_dir());
373
374        // Build the full arguments list including -C flag if needed
375        let mut full_args = Vec::new();
376        if let Some(ref dir) = self.current_dir {
377            // Use -C flag to specify working directory
378            // This makes git operations independent of the process's current directory
379            full_args.push("-C".to_string());
380            // Use the path as-is to avoid symlink resolution issues on macOS
381            // (e.g., /var vs /private/var)
382            full_args.push(dir.display().to_string());
383        }
384        full_args.extend(self.args.clone());
385
386        cmd.args(&full_args);
387
388        if let Some(ref ctx) = self.context {
389            tracing::debug!(
390                target: "git",
391                "({}) Executing command: {} {}",
392                ctx,
393                git_command,
394                full_args.join(" ")
395            );
396        } else {
397            tracing::debug!(
398                target: "git",
399                "Executing command: {} {}",
400                git_command,
401                full_args.join(" ")
402            );
403        }
404
405        for (key, value) in &self.env_vars {
406            tracing::trace!(target: "git", "Setting env var: {}={}", key, value);
407            cmd.env(key, value);
408        }
409
410        if self.capture_output {
411            cmd.stdout(Stdio::piped());
412            cmd.stderr(Stdio::piped());
413        } else {
414            cmd.stdout(Stdio::inherit());
415            cmd.stderr(Stdio::inherit());
416        }
417
418        // CRITICAL: Always close stdin to prevent Git from hanging on credential prompts.
419        // In CI environments (non-TTY), Git may wait indefinitely for input if stdin is
420        // inherited. This is the root cause of CI test hangs.
421        cmd.stdin(Stdio::null());
422
423        // Disable Git terminal prompts to prevent hanging on authentication requests.
424        // This ensures Git fails fast instead of waiting for user input.
425        cmd.env("GIT_TERMINAL_PROMPT", "0");
426
427        // CRITICAL: kill_on_drop ensures the child process is killed when the future is
428        // dropped (e.g., on timeout). Without this, timed-out git processes become zombies
429        // that hold file locks and cause deadlocks in concurrent operations.
430        cmd.kill_on_drop(true);
431
432        // Use spawn() + wait_with_output() instead of output() to ensure kill_on_drop works.
433        // The output() method doesn't guarantee child process cleanup on future cancellation.
434        let child = cmd.spawn().context(format!("Failed to spawn git {}", full_args.join(" ")))?;
435        let output_future = child.wait_with_output();
436
437        let output = if let Some(duration) = self.timeout_duration {
438            if let Ok(result) = timeout(duration, output_future).await {
439                tracing::trace!(target: "git", "Command completed within timeout");
440                result.context(format!("Failed to execute git {}", full_args.join(" ")))?
441            } else {
442                tracing::warn!(
443                    target: "git",
444                    "Command timed out after {} seconds: git {}",
445                    duration.as_secs(),
446                    full_args.join(" ")
447                );
448                // Extract the actual git operation (skip -C and path if present)
449                let git_operation =
450                    if full_args.first() == Some(&"-C".to_string()) && full_args.len() > 2 {
451                        full_args.get(2).cloned().unwrap_or_else(|| "unknown".to_string())
452                    } else {
453                        full_args.first().cloned().unwrap_or_else(|| "unknown".to_string())
454                    };
455                return Err(AgpmError::GitCommandError {
456                    operation: git_operation,
457                    stderr: format!(
458                        "Git command timed out after {} seconds. This may indicate:\n\
459                        - Network connectivity issues\n\
460                        - Authentication prompts waiting for input\n\
461                        - Large repository operations taking too long\n\
462                        Try running the command manually: git {}",
463                        duration.as_secs(),
464                        full_args.join(" ")
465                    ),
466                }
467                .into());
468            }
469        } else {
470            tracing::trace!(target: "git", "Executing command without timeout");
471            output_future.await.context(format!("Failed to execute git {}", full_args.join(" ")))?
472        };
473
474        if !output.status.success() {
475            let stderr = String::from_utf8_lossy(&output.stderr);
476            let stdout = String::from_utf8_lossy(&output.stdout);
477
478            tracing::debug!(
479                target: "git",
480                "Command failed with exit code: {:?}",
481                output.status.code()
482            );
483            if !stderr.is_empty() {
484                tracing::debug!(target: "git", "Error: {}", stderr);
485            }
486            if !stdout.is_empty() && stderr.is_empty() {
487                tracing::debug!(target: "git", "Error output: {}", stdout);
488            }
489
490            // Provide context-specific error messages
491            // Skip -C flag arguments when checking command type
492            let args_start = if full_args.first() == Some(&"-C".to_string()) && full_args.len() > 2
493            {
494                2
495            } else {
496                0
497            };
498            let effective_args = &full_args[args_start..];
499
500            let error = if effective_args.first().is_some_and(|arg| arg == "clone") {
501                // Use the URL we stored when building the command, not by parsing args
502                let url = self.clone_url.unwrap_or_else(|| "unknown".to_string());
503                AgpmError::GitCloneFailed {
504                    url,
505                    reason: stderr.to_string(),
506                }
507            } else if effective_args.first().is_some_and(|arg| arg == "checkout") {
508                let reference = effective_args.get(1).cloned().unwrap_or_default();
509                AgpmError::GitCheckoutFailed {
510                    reference,
511                    reason: stderr.to_string(),
512                }
513            } else if effective_args.first().is_some_and(|arg| arg == "worktree") {
514                let subcommand = effective_args.get(1).cloned().unwrap_or_default();
515                AgpmError::GitCommandError {
516                    operation: format!("worktree {subcommand}"),
517                    stderr: if stderr.is_empty() {
518                        stdout.to_string()
519                    } else {
520                        stderr.to_string()
521                    },
522                }
523            } else {
524                AgpmError::GitCommandError {
525                    operation: effective_args
526                        .first()
527                        .cloned()
528                        .unwrap_or_else(|| "unknown".to_string()),
529                    stderr: stderr.to_string(),
530                }
531            };
532
533            return Err(error.into());
534        }
535
536        let stdout = String::from_utf8_lossy(&output.stdout).to_string();
537        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
538
539        // Log stdout/stderr without prefixes if they're not empty
540        if !stdout.is_empty() {
541            if let Some(ref ctx) = self.context {
542                tracing::debug!(target: "git", "({}) {}", ctx, stdout.trim());
543            } else {
544                tracing::debug!(target: "git", "{}", stdout.trim());
545            }
546        }
547        if !stderr.is_empty() {
548            if let Some(ref ctx) = self.context {
549                tracing::debug!(target: "git", "({}) {}", ctx, stderr.trim());
550            } else {
551                tracing::debug!(target: "git", "{}", stderr.trim());
552            }
553        }
554
555        let elapsed = start.elapsed();
556
557        // Log performance for expensive operations
558        if elapsed.as_secs() > 1 {
559            let operation = if full_args.first() == Some(&"-C".to_string()) && full_args.len() > 2 {
560                full_args.get(2).cloned().unwrap_or_else(|| "unknown".to_string())
561            } else {
562                full_args.first().cloned().unwrap_or_else(|| "unknown".to_string())
563            };
564
565            if let Some(ref ctx) = self.context {
566                tracing::info!(target: "git::perf", "({}) Git {} took {:.2}s", ctx, operation, elapsed.as_secs_f64());
567            } else {
568                tracing::info!(target: "git::perf", "Git {} took {:.2}s", operation, elapsed.as_secs_f64());
569            }
570        } else if elapsed.as_millis() > 100 {
571            // Log debug for moderately slow operations
572            let operation = if full_args.first() == Some(&"-C".to_string()) && full_args.len() > 2 {
573                full_args.get(2).cloned().unwrap_or_else(|| "unknown".to_string())
574            } else {
575                full_args.first().cloned().unwrap_or_else(|| "unknown".to_string())
576            };
577
578            if let Some(ref ctx) = self.context {
579                tracing::debug!(target: "git::perf", "({}) Git {} took {}ms", ctx, operation, elapsed.as_millis());
580            } else {
581                tracing::debug!(target: "git::perf", "Git {} took {}ms", operation, elapsed.as_millis());
582            }
583        }
584
585        Ok(GitCommandOutput {
586            stdout,
587            stderr,
588        })
589    }
590
591    /// Execute the command and return only stdout as a trimmed string
592    pub async fn execute_stdout(self) -> Result<String> {
593        let output = self.execute().await?;
594        Ok(output.stdout.trim().to_string())
595    }
596
597    /// Execute a Git command with stdin input.
598    ///
599    /// This method is designed for commands that accept input via stdin, such as
600    /// `git rev-parse --stdin` for batch SHA resolution. It reduces process spawn
601    /// overhead by allowing multiple refs to be resolved in a single Git process.
602    ///
603    /// # Arguments
604    ///
605    /// * `stdin_data` - The data to write to the command's stdin
606    ///
607    /// # Returns
608    ///
609    /// The command output wrapped in `GitCommandOutput`
610    ///
611    /// # Examples
612    ///
613    /// ```rust,ignore
614    /// use agpm_cli::git::command_builder::GitCommand;
615    ///
616    /// # async fn example() -> anyhow::Result<()> {
617    /// // Resolve multiple refs in a single git process
618    /// let refs = "v1.0.0\nmain\norigin/develop";
619    /// let output = GitCommand::new()
620    ///     .args(["rev-parse", "--stdin"])
621    ///     .current_dir("/path/to/repo")
622    ///     .execute_with_stdin(refs)
623    ///     .await?;
624    /// # Ok(())
625    /// # }
626    /// ```
627    pub async fn execute_with_stdin(self, stdin_data: &str) -> Result<GitCommandOutput> {
628        use tokio::io::AsyncWriteExt;
629
630        let start = std::time::Instant::now();
631        let git_command = get_git_command();
632        let mut cmd = Command::new(git_command);
633
634        // Always set git's CWD to system temp directory to prevent issues when
635        // test directories are deleted. Git may access CWD even with -C flag.
636        cmd.current_dir(std::env::temp_dir());
637
638        // Build the full arguments list including -C flag if needed
639        let mut full_args = Vec::new();
640        if let Some(ref dir) = self.current_dir {
641            full_args.push("-C".to_string());
642            full_args.push(dir.display().to_string());
643        }
644        full_args.extend(self.args.clone());
645
646        cmd.args(&full_args);
647
648        if let Some(ref ctx) = self.context {
649            tracing::debug!(
650                target: "git",
651                "({}) Executing command with stdin: {} {}",
652                ctx,
653                git_command,
654                full_args.join(" ")
655            );
656        } else {
657            tracing::debug!(
658                target: "git",
659                "Executing command with stdin: {} {}",
660                git_command,
661                full_args.join(" ")
662            );
663        }
664
665        for (key, value) in &self.env_vars {
666            tracing::trace!(target: "git", "Setting env var: {}={}", key, value);
667            cmd.env(key, value);
668        }
669
670        // Set up piped stdin for writing
671        cmd.stdin(Stdio::piped());
672        cmd.stdout(Stdio::piped());
673        cmd.stderr(Stdio::piped());
674
675        // Disable Git terminal prompts
676        cmd.env("GIT_TERMINAL_PROMPT", "0");
677
678        // kill_on_drop ensures the child process is killed on timeout
679        cmd.kill_on_drop(true);
680
681        let mut child =
682            cmd.spawn().context(format!("Failed to spawn git {}", full_args.join(" ")))?;
683
684        // Write to stdin and close it
685        if let Some(mut stdin) = child.stdin.take() {
686            stdin.write_all(stdin_data.as_bytes()).await.context("Failed to write to git stdin")?;
687            // stdin is dropped here, closing the pipe
688        }
689
690        let output_future = child.wait_with_output();
691
692        let output = if let Some(duration) = self.timeout_duration {
693            if let Ok(result) = timeout(duration, output_future).await {
694                result.context(format!("Failed to execute git {}", full_args.join(" ")))?
695            } else {
696                let git_operation =
697                    if full_args.first() == Some(&"-C".to_string()) && full_args.len() > 2 {
698                        full_args.get(2).cloned().unwrap_or_else(|| "unknown".to_string())
699                    } else {
700                        full_args.first().cloned().unwrap_or_else(|| "unknown".to_string())
701                    };
702                return Err(AgpmError::GitCommandError {
703                    operation: git_operation,
704                    stderr: format!("Git command timed out after {} seconds", duration.as_secs()),
705                }
706                .into());
707            }
708        } else {
709            output_future.await.context(format!("Failed to execute git {}", full_args.join(" ")))?
710        };
711
712        if !output.status.success() {
713            let stderr = String::from_utf8_lossy(&output.stderr);
714            let stdout = String::from_utf8_lossy(&output.stdout);
715
716            tracing::debug!(
717                target: "git",
718                "Command failed with exit code: {:?}",
719                output.status.code()
720            );
721
722            let args_start = if full_args.first() == Some(&"-C".to_string()) && full_args.len() > 2
723            {
724                2
725            } else {
726                0
727            };
728            let effective_args = &full_args[args_start..];
729
730            return Err(AgpmError::GitCommandError {
731                operation: effective_args.first().cloned().unwrap_or_else(|| "unknown".to_string()),
732                stderr: if stderr.is_empty() {
733                    stdout.to_string()
734                } else {
735                    stderr.to_string()
736                },
737            }
738            .into());
739        }
740
741        let stdout = String::from_utf8_lossy(&output.stdout).to_string();
742        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
743
744        let elapsed = start.elapsed();
745        if elapsed.as_secs() > 1 {
746            let operation = if full_args.first() == Some(&"-C".to_string()) && full_args.len() > 2 {
747                full_args.get(2).cloned().unwrap_or_else(|| "unknown".to_string())
748            } else {
749                full_args.first().cloned().unwrap_or_else(|| "unknown".to_string())
750            };
751
752            if let Some(ref ctx) = self.context {
753                tracing::info!(target: "git::perf", "({}) Git {} with stdin took {:.2}s", ctx, operation, elapsed.as_secs_f64());
754            } else {
755                tracing::info!(target: "git::perf", "Git {} with stdin took {:.2}s", operation, elapsed.as_secs_f64());
756            }
757        }
758
759        Ok(GitCommandOutput {
760            stdout,
761            stderr,
762        })
763    }
764
765    /// Execute the command and check for success without capturing output
766    pub async fn execute_success(self) -> Result<()> {
767        self.execute().await?;
768        Ok(())
769    }
770}
771
772/// Output from a Git command
773pub struct GitCommandOutput {
774    /// Standard output from the Git command
775    pub stdout: String,
776    /// Standard error output from the Git command
777    pub stderr: String,
778}
779
780// Convenience builders for common Git operations
781
782impl GitCommand {
783    /// Create a clone command
784    pub fn clone(url: &str, target: impl AsRef<Path>) -> Self {
785        let mut cmd = Self::new();
786        cmd.args.push("clone".to_string());
787        cmd.args.push("--progress".to_string());
788        cmd.args.push("--filter=blob:none".to_string());
789        cmd.args.push("--recurse-submodules".to_string());
790        cmd.args.push(url.to_string());
791        cmd.args.push(target.as_ref().display().to_string());
792        cmd.clone_url = Some(url.to_string());
793        cmd
794    }
795
796    /// Create a clone command with specific depth
797    ///
798    /// Create a fetch command
799    pub fn fetch() -> Self {
800        // Use --all to fetch from all remotes and --tags to get tags
801        // For bare repositories, we need to ensure remote tracking branches are created
802        Self::new().args(["fetch", "--all", "--tags", "--force"])
803    }
804
805    /// Create a checkout command
806    pub fn checkout(ref_name: &str) -> Self {
807        Self::new().args(["checkout", ref_name])
808    }
809
810    /// Create a checkout command that forces branch creation/update
811    pub fn checkout_branch(branch_name: &str, remote_ref: &str) -> Self {
812        Self::new().args(["checkout", "-B", branch_name, remote_ref])
813    }
814
815    /// Create a reset command
816    pub fn reset_hard() -> Self {
817        Self::new().args(["reset", "--hard", "HEAD"])
818    }
819
820    /// Create a tag list command
821    pub fn list_tags() -> Self {
822        Self::new().args(["tag", "-l", "--sort=version:refname"])
823    }
824
825    /// Create a branch list command
826    pub fn list_branches() -> Self {
827        Self::new().args(["branch", "-r"])
828    }
829
830    /// Create a rev-parse command
831    pub fn rev_parse(ref_name: &str) -> Self {
832        Self::new().args(["rev-parse", ref_name])
833    }
834
835    /// Create a command to get the current commit hash
836    pub fn current_commit() -> Self {
837        Self::new().args(["rev-parse", "HEAD"])
838    }
839
840    /// Create a command to get the remote URL
841    pub fn remote_url() -> Self {
842        Self::new().args(["remote", "get-url", "origin"])
843    }
844
845    /// Create a command to set the remote URL
846    pub fn set_remote_url(url: &str) -> Self {
847        Self::new().args(["remote", "set-url", "origin", url])
848    }
849
850    /// Create a ls-remote command for repository verification
851    pub fn ls_remote(url: &str) -> Self {
852        Self::new().args(["ls-remote", "--heads", url])
853    }
854
855    /// Create a command to verify a reference exists
856    pub fn verify_ref(ref_name: &str) -> Self {
857        Self::new().args(["rev-parse", "--verify", ref_name])
858    }
859
860    /// Create a command to get the current branch
861    pub fn current_branch() -> Self {
862        Self::new().args(["branch", "--show-current"])
863    }
864
865    /// Create an init command
866    pub fn init() -> Self {
867        Self::new().arg("init")
868    }
869
870    /// Create an add command
871    pub fn add(pathspec: &str) -> Self {
872        Self::new().args(["add", pathspec])
873    }
874
875    /// Create a commit command
876    pub fn commit(message: &str) -> Self {
877        Self::new().args(["commit", "-m", message])
878    }
879
880    /// Create a push command
881    pub fn push() -> Self {
882        Self::new().arg("push")
883    }
884
885    /// Create a status command
886    pub fn status() -> Self {
887        Self::new().arg("status")
888    }
889
890    /// Create a diff command
891    pub fn diff() -> Self {
892        Self::new().arg("diff")
893    }
894
895    /// Create a git clone --bare command for bare repository.
896    ///
897    /// Bare repositories are optimized for use as a source for worktrees,
898    /// allowing multiple concurrent checkouts without conflicts. This is
899    /// the preferred method for parallel operations in AGPM.
900    ///
901    /// # Arguments
902    ///
903    /// * `url` - The remote repository URL to clone
904    /// * `target` - The local directory where the bare repository will be stored
905    ///
906    /// # Returns
907    ///
908    /// A configured `GitCommand` ready for execution.
909    ///
910    /// # Examples
911    ///
912    /// ```rust,no_run
913    /// use agpm_cli::git::command_builder::GitCommand;
914    ///
915    /// # async fn example() -> anyhow::Result<()> {
916    /// GitCommand::clone_bare(
917    ///     "https://github.com/example/repo.git",
918    ///     "/tmp/repo.git"
919    /// )
920    /// .execute_success()
921    /// .await?;
922    /// # Ok(())
923    /// # }
924    /// ```
925    ///
926    /// # Worktree Usage
927    ///
928    /// Bare repositories created with this command are designed to be used
929    /// with [`worktree_add`](#method.worktree_add) for parallel operations:
930    ///
931    /// ```rust,no_run
932    /// use agpm_cli::git::command_builder::GitCommand;
933    ///
934    /// # async fn worktree_example() -> anyhow::Result<()> {
935    /// // Clone bare repository
936    /// GitCommand::clone_bare("https://github.com/example/repo.git", "/tmp/repo.git")
937    ///     .execute_success()
938    ///     .await?;
939    ///
940    /// // Create working directory from bare repo
941    /// GitCommand::worktree_add("/tmp/work1", Some("v1.0.0"))
942    ///     .current_dir("/tmp/repo.git")
943    ///     .execute_success()
944    ///     .await?;
945    /// # Ok(())
946    /// # }
947    /// ```
948    pub fn clone_bare(url: &str, target: impl AsRef<Path>) -> Self {
949        let mut cmd = Self::new();
950        let mut args = vec!["clone".to_string(), "--bare".to_string(), "--progress".to_string()];
951
952        // Only use partial clone for remote repositories
953        // Local repositories (file://, absolute paths, relative paths) need full clones
954        // to work properly with worktrees, especially when they're bare repositories
955        let is_local = url.starts_with("file://")
956            || url.starts_with('/')
957            || url.starts_with('.')
958            || url.starts_with('~')
959            || (url.len() > 1 && url.chars().nth(1) == Some(':')); // Windows paths like C:
960
961        if !is_local {
962            args.push("--filter=blob:none".to_string());
963        }
964
965        args.extend(vec![
966            "--recurse-submodules".to_string(),
967            url.to_string(),
968            target.as_ref().display().to_string(),
969        ]);
970
971        cmd.args.extend(args);
972        cmd.clone_url = Some(url.to_string());
973        cmd
974    }
975
976    /// Create a clone command for local file:// URLs with proper arguments and error context.
977    ///
978    /// This method is specifically designed for cloning local repositories via file:// URLs.
979    /// It clones with all branches to ensure commit availability and sets proper error context.
980    ///
981    /// # Arguments
982    ///
983    /// * `url` - The file:// URL to clone from
984    /// * `target` - The target directory where the repository will be cloned
985    ///
986    /// # Returns
987    ///
988    /// A configured `GitCommand` that clones the local repository with all branches.
989    ///
990    /// # Examples
991    ///
992    /// ```rust,ignore
993    /// use agpm_cli::git::command_builder::GitCommand;
994    /// use std::path::Path;
995    ///
996    /// let cmd = GitCommand::clone_local(
997    ///     "file:///path/to/local/repo.git",
998    ///     Path::new("/tmp/cloned-repo")
999    /// );
1000    /// ```
1001    pub fn clone_local(url: &str, target: impl AsRef<Path>) -> Self {
1002        let mut cmd = Self::new();
1003        cmd.args = vec![
1004            "clone".to_string(),
1005            "--progress".to_string(),
1006            "--no-single-branch".to_string(),
1007            "--recurse-submodules".to_string(),
1008            url.to_string(),
1009            target.as_ref().display().to_string(),
1010        ];
1011        cmd.clone_url = Some(url.to_string()); // Properly set for error reporting
1012        cmd
1013    }
1014
1015    /// Create a worktree add command for parallel-safe Git operations.
1016    ///
1017    /// Worktrees allow multiple working directories to be checked out from
1018    /// a single bare repository, enabling safe parallel operations on different
1019    /// versions of the same repository without conflicts.
1020    ///
1021    /// # Arguments
1022    ///
1023    /// * `worktree_path` - The path where the new worktree will be created
1024    /// * `reference` - Optional Git reference (branch, tag, or commit) to checkout
1025    ///
1026    /// # Returns
1027    ///
1028    /// A configured `GitCommand` that must be executed from a bare repository directory.
1029    ///
1030    /// # Parallel Safety
1031    ///
1032    /// Each worktree is independent and can be safely accessed concurrently:
1033    /// - Different dependencies can use different worktrees simultaneously
1034    /// - No conflicts between parallel checkout operations
1035    /// - Each worktree maintains its own working directory state
1036    ///
1037    /// # Examples
1038    ///
1039    /// ```rust,no_run
1040    /// use agpm_cli::git::command_builder::GitCommand;
1041    ///
1042    /// # async fn example() -> anyhow::Result<()> {
1043    /// // Create worktree with specific version
1044    /// GitCommand::worktree_add("/tmp/work-v1", Some("v1.0.0"))
1045    ///     .current_dir("/tmp/bare-repo.git")
1046    ///     .execute_success()
1047    ///     .await?;
1048    ///
1049    /// // Create worktree with default branch
1050    /// GitCommand::worktree_add("/tmp/work-main", None)
1051    ///     .current_dir("/tmp/bare-repo.git")
1052    ///     .execute_success()
1053    ///     .await?;
1054    /// # Ok(())
1055    /// # }
1056    /// ```
1057    ///
1058    /// # Concurrency Control
1059    ///
1060    /// AGPM uses a global semaphore to limit concurrent Git operations and
1061    /// prevent resource exhaustion. This is handled automatically by the
1062    /// cache layer when using worktrees for parallel installations.
1063    ///
1064    /// # Reference Types
1065    ///
1066    /// The `reference` parameter supports various Git reference types:
1067    /// - **Tags**: `"v1.0.0"` (most common for package versions)
1068    /// - **Branches**: `"main"`, `"develop"`
1069    /// - **Commits**: `"abc123"` (specific commit hashes)
1070    /// - **None**: Uses repository's default branch
1071    pub fn worktree_add(worktree_path: impl AsRef<Path>, reference: Option<&str>) -> Self {
1072        let mut cmd = Self::new();
1073        cmd.args.push("worktree".to_string());
1074        cmd.args.push("add".to_string());
1075
1076        // Add the worktree path
1077        cmd.args.push(worktree_path.as_ref().display().to_string());
1078
1079        // Add the reference if provided
1080        if let Some(ref_name) = reference {
1081            cmd.args.push(ref_name.to_string());
1082        }
1083
1084        cmd
1085    }
1086
1087    /// Remove a worktree and clean up associated files.
1088    ///
1089    /// This command removes a worktree that was created with [`worktree_add`]
1090    /// and cleans up Git's internal bookkeeping. The `--force` flag is used
1091    /// to ensure removal even if the worktree has local modifications.
1092    ///
1093    /// # Arguments
1094    ///
1095    /// * `worktree_path` - The path to the worktree to remove
1096    ///
1097    /// # Returns
1098    ///
1099    /// A configured `GitCommand` that must be executed from the bare repository.
1100    ///
1101    /// # Examples
1102    ///
1103    /// ```rust,no_run
1104    /// use agpm_cli::git::command_builder::GitCommand;
1105    ///
1106    /// # async fn example() -> anyhow::Result<()> {
1107    /// // Remove a worktree
1108    /// GitCommand::worktree_remove("/tmp/work-v1")
1109    ///     .current_dir("/tmp/bare-repo.git")
1110    ///     .execute_success()
1111    ///     .await?;
1112    /// # Ok(())
1113    /// # }
1114    /// ```
1115    ///
1116    /// # Force Removal
1117    ///
1118    /// The command uses `--force` to ensure removal succeeds even when:
1119    /// - The worktree has uncommitted changes
1120    /// - Files are locked or in use
1121    /// - The worktree directory structure has been modified
1122    ///
1123    /// This is appropriate for AGPM's use case where worktrees are temporary
1124    /// and any local changes should be discarded.
1125    ///
1126    /// [`worktree_add`]: #method.worktree_add
1127    pub fn worktree_remove(worktree_path: impl AsRef<Path>) -> Self {
1128        Self::new().args([
1129            "worktree",
1130            "remove",
1131            "--force",
1132            &worktree_path.as_ref().display().to_string(),
1133        ])
1134    }
1135
1136    /// List all worktrees associated with a repository.
1137    ///
1138    /// This command returns information about all worktrees linked to the
1139    /// current bare repository. The `--porcelain` flag provides machine-readable
1140    /// output that's easier to parse programmatically.
1141    ///
1142    /// # Returns
1143    ///
1144    /// A configured `GitCommand` that must be executed from a bare repository.
1145    ///
1146    /// # Output Format
1147    ///
1148    /// The porcelain output format provides structured information:
1149    /// ```text
1150    /// worktree /path/to/worktree1
1151    /// HEAD abc123def456...
1152    /// branch refs/heads/main
1153    ///
1154    /// worktree /path/to/worktree2
1155    /// HEAD def456abc123...
1156    /// detached
1157    /// ```
1158    ///
1159    /// # Examples
1160    ///
1161    /// ```rust,no_run
1162    /// use agpm_cli::git::command_builder::GitCommand;
1163    ///
1164    /// # async fn example() -> anyhow::Result<()> {
1165    /// let output = GitCommand::worktree_list()
1166    ///     .current_dir("/tmp/bare-repo.git")
1167    ///     .execute_stdout()
1168    ///     .await?;
1169    ///
1170    /// // Parse output to find worktree paths
1171    /// for line in output.lines() {
1172    ///     if line.starts_with("worktree ") {
1173    ///         let path = line.strip_prefix("worktree ").unwrap();
1174    ///         println!("Found worktree: {}", path);
1175    ///     }
1176    /// }
1177    /// # Ok(())
1178    /// # }
1179    /// ```
1180    pub fn worktree_list() -> Self {
1181        Self::new().args(["worktree", "list", "--porcelain"])
1182    }
1183
1184    /// Prune stale worktree administrative files.
1185    ///
1186    /// This command cleans up worktree entries that no longer have corresponding
1187    /// directories on disk. It's useful for maintenance after worktrees have been
1188    /// manually deleted or when cleaning up after failed operations.
1189    ///
1190    /// # Returns
1191    ///
1192    /// A configured `GitCommand` that must be executed from a bare repository.
1193    ///
1194    /// # When to Use
1195    ///
1196    /// Prune worktrees when:
1197    /// - Worktree directories have been manually deleted
1198    /// - After bulk cleanup operations
1199    /// - During cache maintenance
1200    /// - When Git reports stale worktree references
1201    ///
1202    /// # Examples
1203    ///
1204    /// ```rust,no_run
1205    /// use agpm_cli::git::command_builder::GitCommand;
1206    ///
1207    /// # async fn example() -> anyhow::Result<()> {
1208    /// // Clean up stale worktree references
1209    /// GitCommand::worktree_prune()
1210    ///     .current_dir("/tmp/bare-repo.git")
1211    ///     .execute_success()
1212    ///     .await?;
1213    /// # Ok(())
1214    /// # }
1215    /// ```
1216    ///
1217    /// # Performance
1218    ///
1219    /// This is a lightweight operation that only updates Git's internal
1220    /// bookkeeping. It doesn't remove actual worktree directories.
1221    pub fn worktree_prune() -> Self {
1222        Self::new().args(["worktree", "prune"])
1223    }
1224}
1225
1226#[cfg(test)]
1227mod tests {
1228    use super::*;
1229
1230    #[test]
1231    fn test_command_builder_basic() {
1232        let cmd = GitCommand::new().arg("status").arg("--short");
1233        assert_eq!(cmd.args, vec!["status", "--short"]);
1234    }
1235
1236    #[tokio::test]
1237    async fn test_git_command_logging() {
1238        // This test verifies that git stdout/stderr are logged at debug level
1239        // Run with RUST_LOG=debug to see the output
1240        let result = GitCommand::new().args(["--version"]).execute().await;
1241
1242        assert!(result.is_ok(), "Git --version should succeed");
1243        let output = result.unwrap();
1244        assert!(!output.stdout.is_empty(), "Git version should produce stdout");
1245        // When run with RUST_LOG=debug, this should produce:
1246        // - "Executing git command: git --version"
1247        // - "Git command completed successfully"
1248        // - "Git stdout (raw): git version X.Y.Z"
1249    }
1250
1251    #[test]
1252    fn test_command_builder_with_dir() {
1253        let cmd = GitCommand::new().current_dir("/tmp/repo").arg("status");
1254        assert_eq!(cmd.current_dir, Some(std::path::PathBuf::from("/tmp/repo")));
1255    }
1256
1257    #[test]
1258    fn test_clone_builder() {
1259        let cmd = GitCommand::clone("https://example.com/repo.git", "/tmp/target");
1260        assert_eq!(cmd.args[0], "clone");
1261        assert_eq!(cmd.args[1], "--progress");
1262        assert!(cmd.args.contains(&"https://example.com/repo.git".to_string()));
1263    }
1264
1265    #[test]
1266    fn test_checkout_branch_builder() {
1267        let cmd = GitCommand::checkout_branch("main", "origin/main");
1268        assert_eq!(cmd.args, vec!["checkout", "-B", "main", "origin/main"]);
1269    }
1270}