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}