use anyhow::{Context, Result};
use std::path::Path;
use std::process::Stdio;
use std::time::Duration;
use tokio::process::Command;
use tokio::time::timeout;
use crate::core::AgpmError;
use crate::utils::platform::get_git_command;
pub struct GitCommand {
args: Vec<String>,
current_dir: Option<std::path::PathBuf>,
capture_output: bool,
env_vars: Vec<(String, String)>,
timeout_duration: Option<Duration>,
context: Option<String>,
clone_url: Option<String>,
}
impl Default for GitCommand {
fn default() -> Self {
Self {
args: Vec::new(),
clone_url: None,
current_dir: None,
capture_output: true,
env_vars: Vec::new(),
timeout_duration: Some(Duration::from_secs(300)),
context: None,
}
}
}
impl GitCommand {
pub fn new() -> Self {
Self::default()
}
pub fn current_dir(mut self, dir: impl AsRef<Path>) -> Self {
self.current_dir = Some(dir.as_ref().to_path_buf());
self
}
pub fn arg(mut self, arg: impl Into<String>) -> Self {
self.args.push(arg.into());
self
}
pub fn args<I, S>(mut self, args: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.args.extend(args.into_iter().map(Into::into));
self
}
pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.env_vars.push((key.into(), value.into()));
self
}
pub const fn inherit_stdio(mut self) -> Self {
self.capture_output = false;
self
}
pub const fn with_timeout(mut self, duration: Option<Duration>) -> Self {
self.timeout_duration = duration;
self
}
pub fn with_context(mut self, context: impl Into<String>) -> Self {
self.context = Some(context.into());
self
}
pub async fn execute(self) -> Result<GitCommandOutput> {
let start = std::time::Instant::now();
let git_command = get_git_command();
let mut cmd = Command::new(git_command);
cmd.current_dir(std::env::temp_dir());
let mut full_args = Vec::new();
if let Some(ref dir) = self.current_dir {
full_args.push("-C".to_string());
full_args.push(dir.display().to_string());
}
full_args.extend(self.args.clone());
cmd.args(&full_args);
if let Some(ref ctx) = self.context {
tracing::debug!(
target: "git",
"({}) Executing command: {} {}",
ctx,
git_command,
full_args.join(" ")
);
} else {
tracing::debug!(
target: "git",
"Executing command: {} {}",
git_command,
full_args.join(" ")
);
}
for (key, value) in &self.env_vars {
tracing::trace!(target: "git", "Setting env var: {}={}", key, value);
cmd.env(key, value);
}
if self.capture_output {
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
} else {
cmd.stdout(Stdio::inherit());
cmd.stderr(Stdio::inherit());
}
cmd.stdin(Stdio::null());
cmd.env("GIT_TERMINAL_PROMPT", "0");
cmd.kill_on_drop(true);
let child = cmd.spawn().context(format!("Failed to spawn git {}", full_args.join(" ")))?;
let output_future = child.wait_with_output();
let output = if let Some(duration) = self.timeout_duration {
if let Ok(result) = timeout(duration, output_future).await {
tracing::trace!(target: "git", "Command completed within timeout");
result.context(format!("Failed to execute git {}", full_args.join(" ")))?
} else {
tracing::warn!(
target: "git",
"Command timed out after {} seconds: git {}",
duration.as_secs(),
full_args.join(" ")
);
let git_operation =
if full_args.first() == Some(&"-C".to_string()) && full_args.len() > 2 {
full_args.get(2).cloned().unwrap_or_else(|| "unknown".to_string())
} else {
full_args.first().cloned().unwrap_or_else(|| "unknown".to_string())
};
return Err(AgpmError::GitCommandError {
operation: git_operation,
stderr: format!(
"Git command timed out after {} seconds. This may indicate:\n\
- Network connectivity issues\n\
- Authentication prompts waiting for input\n\
- Large repository operations taking too long\n\
Try running the command manually: git {}",
duration.as_secs(),
full_args.join(" ")
),
}
.into());
}
} else {
tracing::trace!(target: "git", "Executing command without timeout");
output_future.await.context(format!("Failed to execute git {}", full_args.join(" ")))?
};
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
tracing::debug!(
target: "git",
"Command failed with exit code: {:?}",
output.status.code()
);
if !stderr.is_empty() {
tracing::debug!(target: "git", "Error: {}", stderr);
}
if !stdout.is_empty() && stderr.is_empty() {
tracing::debug!(target: "git", "Error output: {}", stdout);
}
let args_start = if full_args.first() == Some(&"-C".to_string()) && full_args.len() > 2
{
2
} else {
0
};
let effective_args = &full_args[args_start..];
let error = if effective_args.first().is_some_and(|arg| arg == "clone") {
let url = self.clone_url.unwrap_or_else(|| "unknown".to_string());
AgpmError::GitCloneFailed {
url,
reason: stderr.to_string(),
}
} else if effective_args.first().is_some_and(|arg| arg == "checkout") {
let reference = effective_args.get(1).cloned().unwrap_or_default();
AgpmError::GitCheckoutFailed {
reference,
reason: stderr.to_string(),
}
} else if effective_args.first().is_some_and(|arg| arg == "worktree") {
let subcommand = effective_args.get(1).cloned().unwrap_or_default();
AgpmError::GitCommandError {
operation: format!("worktree {subcommand}"),
stderr: if stderr.is_empty() {
stdout.to_string()
} else {
stderr.to_string()
},
}
} else {
AgpmError::GitCommandError {
operation: effective_args
.first()
.cloned()
.unwrap_or_else(|| "unknown".to_string()),
stderr: stderr.to_string(),
}
};
return Err(error.into());
}
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
if !stdout.is_empty() {
if let Some(ref ctx) = self.context {
tracing::debug!(target: "git", "({}) {}", ctx, stdout.trim());
} else {
tracing::debug!(target: "git", "{}", stdout.trim());
}
}
if !stderr.is_empty() {
if let Some(ref ctx) = self.context {
tracing::debug!(target: "git", "({}) {}", ctx, stderr.trim());
} else {
tracing::debug!(target: "git", "{}", stderr.trim());
}
}
let elapsed = start.elapsed();
if elapsed.as_secs() > 1 {
let operation = if full_args.first() == Some(&"-C".to_string()) && full_args.len() > 2 {
full_args.get(2).cloned().unwrap_or_else(|| "unknown".to_string())
} else {
full_args.first().cloned().unwrap_or_else(|| "unknown".to_string())
};
if let Some(ref ctx) = self.context {
tracing::info!(target: "git::perf", "({}) Git {} took {:.2}s", ctx, operation, elapsed.as_secs_f64());
} else {
tracing::info!(target: "git::perf", "Git {} took {:.2}s", operation, elapsed.as_secs_f64());
}
} else if elapsed.as_millis() > 100 {
let operation = if full_args.first() == Some(&"-C".to_string()) && full_args.len() > 2 {
full_args.get(2).cloned().unwrap_or_else(|| "unknown".to_string())
} else {
full_args.first().cloned().unwrap_or_else(|| "unknown".to_string())
};
if let Some(ref ctx) = self.context {
tracing::debug!(target: "git::perf", "({}) Git {} took {}ms", ctx, operation, elapsed.as_millis());
} else {
tracing::debug!(target: "git::perf", "Git {} took {}ms", operation, elapsed.as_millis());
}
}
Ok(GitCommandOutput {
stdout,
stderr,
})
}
pub async fn execute_stdout(self) -> Result<String> {
let output = self.execute().await?;
Ok(output.stdout.trim().to_string())
}
pub async fn execute_with_stdin(self, stdin_data: &str) -> Result<GitCommandOutput> {
use tokio::io::AsyncWriteExt;
let start = std::time::Instant::now();
let git_command = get_git_command();
let mut cmd = Command::new(git_command);
cmd.current_dir(std::env::temp_dir());
let mut full_args = Vec::new();
if let Some(ref dir) = self.current_dir {
full_args.push("-C".to_string());
full_args.push(dir.display().to_string());
}
full_args.extend(self.args.clone());
cmd.args(&full_args);
if let Some(ref ctx) = self.context {
tracing::debug!(
target: "git",
"({}) Executing command with stdin: {} {}",
ctx,
git_command,
full_args.join(" ")
);
} else {
tracing::debug!(
target: "git",
"Executing command with stdin: {} {}",
git_command,
full_args.join(" ")
);
}
for (key, value) in &self.env_vars {
tracing::trace!(target: "git", "Setting env var: {}={}", key, value);
cmd.env(key, value);
}
cmd.stdin(Stdio::piped());
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
cmd.env("GIT_TERMINAL_PROMPT", "0");
cmd.kill_on_drop(true);
let mut child =
cmd.spawn().context(format!("Failed to spawn git {}", full_args.join(" ")))?;
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(stdin_data.as_bytes()).await.context("Failed to write to git stdin")?;
}
let output_future = child.wait_with_output();
let output = if let Some(duration) = self.timeout_duration {
if let Ok(result) = timeout(duration, output_future).await {
result.context(format!("Failed to execute git {}", full_args.join(" ")))?
} else {
let git_operation =
if full_args.first() == Some(&"-C".to_string()) && full_args.len() > 2 {
full_args.get(2).cloned().unwrap_or_else(|| "unknown".to_string())
} else {
full_args.first().cloned().unwrap_or_else(|| "unknown".to_string())
};
return Err(AgpmError::GitCommandError {
operation: git_operation,
stderr: format!("Git command timed out after {} seconds", duration.as_secs()),
}
.into());
}
} else {
output_future.await.context(format!("Failed to execute git {}", full_args.join(" ")))?
};
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
tracing::debug!(
target: "git",
"Command failed with exit code: {:?}",
output.status.code()
);
let args_start = if full_args.first() == Some(&"-C".to_string()) && full_args.len() > 2
{
2
} else {
0
};
let effective_args = &full_args[args_start..];
return Err(AgpmError::GitCommandError {
operation: effective_args.first().cloned().unwrap_or_else(|| "unknown".to_string()),
stderr: if stderr.is_empty() {
stdout.to_string()
} else {
stderr.to_string()
},
}
.into());
}
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let elapsed = start.elapsed();
if elapsed.as_secs() > 1 {
let operation = if full_args.first() == Some(&"-C".to_string()) && full_args.len() > 2 {
full_args.get(2).cloned().unwrap_or_else(|| "unknown".to_string())
} else {
full_args.first().cloned().unwrap_or_else(|| "unknown".to_string())
};
if let Some(ref ctx) = self.context {
tracing::info!(target: "git::perf", "({}) Git {} with stdin took {:.2}s", ctx, operation, elapsed.as_secs_f64());
} else {
tracing::info!(target: "git::perf", "Git {} with stdin took {:.2}s", operation, elapsed.as_secs_f64());
}
}
Ok(GitCommandOutput {
stdout,
stderr,
})
}
pub async fn execute_success(self) -> Result<()> {
self.execute().await?;
Ok(())
}
}
pub struct GitCommandOutput {
pub stdout: String,
pub stderr: String,
}
impl GitCommand {
pub fn clone(url: &str, target: impl AsRef<Path>) -> Self {
let mut cmd = Self::new();
cmd.args.push("clone".to_string());
cmd.args.push("--progress".to_string());
cmd.args.push("--filter=blob:none".to_string());
cmd.args.push("--recurse-submodules".to_string());
cmd.args.push(url.to_string());
cmd.args.push(target.as_ref().display().to_string());
cmd.clone_url = Some(url.to_string());
cmd
}
pub fn fetch() -> Self {
Self::new().args(["fetch", "--all", "--tags", "--force"])
}
pub fn checkout(ref_name: &str) -> Self {
Self::new().args(["checkout", ref_name])
}
pub fn checkout_branch(branch_name: &str, remote_ref: &str) -> Self {
Self::new().args(["checkout", "-B", branch_name, remote_ref])
}
pub fn reset_hard() -> Self {
Self::new().args(["reset", "--hard", "HEAD"])
}
pub fn list_tags() -> Self {
Self::new().args(["tag", "-l", "--sort=version:refname"])
}
pub fn list_branches() -> Self {
Self::new().args(["branch", "-r"])
}
pub fn rev_parse(ref_name: &str) -> Self {
Self::new().args(["rev-parse", ref_name])
}
pub fn current_commit() -> Self {
Self::new().args(["rev-parse", "HEAD"])
}
pub fn remote_url() -> Self {
Self::new().args(["remote", "get-url", "origin"])
}
pub fn set_remote_url(url: &str) -> Self {
Self::new().args(["remote", "set-url", "origin", url])
}
pub fn ls_remote(url: &str) -> Self {
Self::new().args(["ls-remote", "--heads", url])
}
pub fn verify_ref(ref_name: &str) -> Self {
Self::new().args(["rev-parse", "--verify", ref_name])
}
pub fn current_branch() -> Self {
Self::new().args(["branch", "--show-current"])
}
pub fn init() -> Self {
Self::new().arg("init")
}
pub fn add(pathspec: &str) -> Self {
Self::new().args(["add", pathspec])
}
pub fn commit(message: &str) -> Self {
Self::new().args(["commit", "-m", message])
}
pub fn push() -> Self {
Self::new().arg("push")
}
pub fn status() -> Self {
Self::new().arg("status")
}
pub fn diff() -> Self {
Self::new().arg("diff")
}
pub fn clone_bare(url: &str, target: impl AsRef<Path>) -> Self {
let mut cmd = Self::new();
let mut args = vec!["clone".to_string(), "--bare".to_string(), "--progress".to_string()];
let is_local = url.starts_with("file://")
|| url.starts_with('/')
|| url.starts_with('.')
|| url.starts_with('~')
|| (url.len() > 1 && url.chars().nth(1) == Some(':'));
if !is_local {
args.push("--filter=blob:none".to_string());
}
args.extend(vec![
"--recurse-submodules".to_string(),
url.to_string(),
target.as_ref().display().to_string(),
]);
cmd.args.extend(args);
cmd.clone_url = Some(url.to_string());
cmd
}
pub fn clone_local(url: &str, target: impl AsRef<Path>) -> Self {
let mut cmd = Self::new();
cmd.args = vec![
"clone".to_string(),
"--progress".to_string(),
"--no-single-branch".to_string(),
"--recurse-submodules".to_string(),
url.to_string(),
target.as_ref().display().to_string(),
];
cmd.clone_url = Some(url.to_string()); cmd
}
pub fn worktree_add(worktree_path: impl AsRef<Path>, reference: Option<&str>) -> Self {
let mut cmd = Self::new();
cmd.args.push("worktree".to_string());
cmd.args.push("add".to_string());
cmd.args.push(worktree_path.as_ref().display().to_string());
if let Some(ref_name) = reference {
cmd.args.push(ref_name.to_string());
}
cmd
}
pub fn worktree_remove(worktree_path: impl AsRef<Path>) -> Self {
Self::new().args([
"worktree",
"remove",
"--force",
&worktree_path.as_ref().display().to_string(),
])
}
pub fn worktree_list() -> Self {
Self::new().args(["worktree", "list", "--porcelain"])
}
pub fn worktree_prune() -> Self {
Self::new().args(["worktree", "prune"])
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_command_builder_basic() {
let cmd = GitCommand::new().arg("status").arg("--short");
assert_eq!(cmd.args, vec!["status", "--short"]);
}
#[tokio::test]
async fn test_git_command_logging() {
let result = GitCommand::new().args(["--version"]).execute().await;
assert!(result.is_ok(), "Git --version should succeed");
let output = result.unwrap();
assert!(!output.stdout.is_empty(), "Git version should produce stdout");
}
#[test]
fn test_command_builder_with_dir() {
let cmd = GitCommand::new().current_dir("/tmp/repo").arg("status");
assert_eq!(cmd.current_dir, Some(std::path::PathBuf::from("/tmp/repo")));
}
#[test]
fn test_clone_builder() {
let cmd = GitCommand::clone("https://example.com/repo.git", "/tmp/target");
assert_eq!(cmd.args[0], "clone");
assert_eq!(cmd.args[1], "--progress");
assert!(cmd.args.contains(&"https://example.com/repo.git".to_string()));
}
#[test]
fn test_checkout_branch_builder() {
let cmd = GitCommand::checkout_branch("main", "origin/main");
assert_eq!(cmd.args, vec!["checkout", "-B", "main", "origin/main"]);
}
}