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}