use crate::error::{Error, Result};
use async_trait::async_trait;
use std::collections::HashMap;
use std::ffi::{OsStr, OsString};
use std::path::PathBuf;
use std::process::Stdio;
use std::time::Duration;
use tokio::process::Command as TokioCommand;
use tracing::{debug, error, instrument, trace, warn};
pub mod add;
pub mod bisect;
pub mod branch;
pub mod cat_file;
pub mod checkout;
pub mod cherry_pick;
pub mod clone;
pub mod commit;
pub mod config;
pub mod describe;
pub mod diff;
pub mod fetch;
pub mod for_each_ref;
pub mod grep;
pub mod hash_object;
pub mod init;
pub mod log;
pub mod ls_files;
pub mod ls_tree;
pub mod merge;
pub mod mv;
pub mod pull;
pub mod push;
pub mod rebase;
pub mod reflog;
pub mod remote;
pub mod reset;
pub mod restore;
pub mod rev_parse;
pub mod rm;
pub mod show;
pub mod show_ref;
pub mod stash;
pub mod status;
pub mod submodule;
pub mod switch;
pub mod symbolic_ref;
pub mod tag;
pub mod update_ref;
pub mod worktree;
pub const DEFAULT_COMMAND_TIMEOUT: Option<Duration> = None;
#[async_trait]
pub trait GitCommand {
type Output;
fn get_executor(&self) -> &CommandExecutor;
fn get_executor_mut(&mut self) -> &mut CommandExecutor;
fn build_command_args(&self) -> Vec<String>;
async fn execute(&self) -> Result<Self::Output>;
async fn execute_raw(&self) -> Result<CommandOutput> {
let args = self.build_command_args();
self.get_executor().execute_command(args).await
}
fn arg<S: AsRef<OsStr>>(&mut self, arg: S) -> &mut Self {
self.get_executor_mut().add_arg(arg);
self
}
fn args<I, S>(&mut self, args: I) -> &mut Self
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
self.get_executor_mut().add_args(args);
self
}
fn flag(&mut self, flag: &str) -> &mut Self {
self.get_executor_mut().add_flag(flag);
self
}
fn option(&mut self, key: &str, value: &str) -> &mut Self {
self.get_executor_mut().add_option(key, value);
self
}
fn current_dir<P: Into<PathBuf>>(&mut self, dir: P) -> &mut Self {
self.get_executor_mut().cwd = Some(dir.into());
self
}
fn env<K: Into<OsString>, V: Into<OsString>>(&mut self, key: K, value: V) -> &mut Self {
self.get_executor_mut().env.insert(key.into(), value.into());
self
}
fn with_timeout(&mut self, timeout: Duration) -> &mut Self {
self.get_executor_mut().timeout = Some(timeout);
self
}
fn with_timeout_secs(&mut self, seconds: u64) -> &mut Self {
self.get_executor_mut().timeout = Some(Duration::from_secs(seconds));
self
}
}
#[derive(Debug, Clone, Default)]
pub struct CommandExecutor {
pub raw_args: Vec<String>,
pub cwd: Option<PathBuf>,
pub env: HashMap<OsString, OsString>,
pub timeout: Option<Duration>,
}
impl CommandExecutor {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn cwd(mut self, path: impl Into<PathBuf>) -> Self {
self.cwd = Some(path.into());
self
}
#[must_use]
pub fn with_env(mut self, key: impl Into<OsString>, value: impl Into<OsString>) -> Self {
self.env.insert(key.into(), value.into());
self
}
#[must_use]
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = Some(timeout);
self
}
pub fn add_arg<S: AsRef<OsStr>>(&mut self, arg: S) {
self.raw_args
.push(arg.as_ref().to_string_lossy().into_owned());
}
pub fn add_args<I, S>(&mut self, args: I)
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
for a in args {
self.add_arg(a);
}
}
pub fn add_flag(&mut self, flag: &str) {
let normalized = if flag.starts_with('-') {
flag.to_string()
} else if flag.len() == 1 {
format!("-{flag}")
} else {
format!("--{flag}")
};
self.raw_args.push(normalized);
}
pub fn add_option(&mut self, key: &str, value: &str) {
let normalized = if key.starts_with('-') {
key.to_string()
} else if key.len() == 1 {
format!("-{key}")
} else {
format!("--{key}")
};
self.raw_args.push(normalized);
self.raw_args.push(value.to_string());
}
#[instrument(
name = "git.command",
skip(self, args),
fields(
cwd = self.cwd.as_ref().map(|p| p.display().to_string()),
timeout_secs = self.timeout.map(|t| t.as_secs()),
)
)]
pub async fn execute_command(&self, args: Vec<String>) -> Result<CommandOutput> {
let mut all_args = args;
all_args.extend(self.raw_args.iter().cloned());
trace!(args = ?all_args, "executing git command");
let result = if let Some(t) = self.timeout {
self.execute_with_timeout(&all_args, t).await
} else {
self.execute_internal(&all_args).await
};
match &result {
Ok(output) => debug!(
exit_code = output.exit_code,
stdout_len = output.stdout.len(),
stderr_len = output.stderr.len(),
"command completed"
),
Err(e) => error!(error = %e, "command failed"),
}
result
}
async fn execute_internal(&self, all_args: &[String]) -> Result<CommandOutput> {
let mut cmd = TokioCommand::new("git");
cmd.args(all_args)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if let Some(dir) = &self.cwd {
cmd.current_dir(dir);
}
for (k, v) in &self.env {
cmd.env(k, v);
}
let output = cmd.output().await.map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
Error::GitNotFound
} else {
Error::Io {
message: format!("failed to spawn git: {e}"),
source: e,
}
}
})?;
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
let exit_code = output.status.code().unwrap_or(-1);
let success = output.status.success();
if !success {
return Err(Error::command_failed(
format!("git {}", all_args.join(" ")),
exit_code,
stdout,
stderr,
));
}
Ok(CommandOutput {
stdout,
stderr,
exit_code,
success,
})
}
async fn execute_with_timeout(
&self,
all_args: &[String],
timeout_duration: Duration,
) -> Result<CommandOutput> {
match tokio::time::timeout(timeout_duration, self.execute_internal(all_args)).await {
Ok(r) => r,
Err(_) => {
warn!(
timeout_secs = timeout_duration.as_secs(),
"command timed out"
);
Err(Error::timeout(timeout_duration.as_secs()))
}
}
}
}
#[derive(Debug, Clone)]
pub struct CommandOutput {
pub stdout: String,
pub stderr: String,
pub exit_code: i32,
pub success: bool,
}
impl CommandOutput {
#[must_use]
pub fn stdout_lines(&self) -> Vec<&str> {
self.stdout.lines().collect()
}
#[must_use]
pub fn stderr_lines(&self) -> Vec<&str> {
self.stderr.lines().collect()
}
#[must_use]
pub fn stdout_trimmed(&self) -> &str {
self.stdout.trim_end()
}
}
pub fn find_git() -> Result<PathBuf> {
which::which("git").map_err(|_| Error::GitNotFound)
}
pub async fn git_version() -> Result<String> {
let output = CommandExecutor::new()
.execute_command(vec!["--version".into()])
.await?;
Ok(output.stdout_trimmed().to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn executor_args() {
let mut e = CommandExecutor::new();
e.add_arg("foo");
e.add_args(["a", "b"]);
e.add_flag("verbose");
e.add_flag("v");
e.add_option("name", "bar");
assert_eq!(
e.raw_args,
vec!["foo", "a", "b", "--verbose", "-v", "--name", "bar"]
);
}
#[test]
fn executor_timeout_builder() {
let e = CommandExecutor::new().timeout(Duration::from_secs(5));
assert_eq!(e.timeout, Some(Duration::from_secs(5)));
}
#[test]
fn command_output_helpers() {
let o = CommandOutput {
stdout: "a\nb\n".into(),
stderr: String::new(),
exit_code: 0,
success: true,
};
assert_eq!(o.stdout_lines(), vec!["a", "b"]);
assert_eq!(o.stdout_trimmed(), "a\nb");
}
}