use std::{path::Path, process::Output, time::Duration};
use crate::error::WorktreeError;
pub trait GitRunner: Send + Sync {
fn run(
&self,
args: &[&str],
cwd: &Path,
) -> impl std::future::Future<Output = Result<Output, WorktreeError>> + Send;
}
#[derive(Debug, Clone)]
pub struct DefaultGitRunner {
timeout: Duration,
}
const MIN_TIMEOUT: Duration = Duration::from_secs(1);
impl DefaultGitRunner {
#[must_use]
pub fn new() -> Self {
Self::with_timeout(Duration::from_secs(30))
}
#[must_use]
pub fn with_timeout(timeout: Duration) -> Self {
Self {
timeout: timeout.max(MIN_TIMEOUT),
}
}
}
impl Default for DefaultGitRunner {
fn default() -> Self {
Self::new()
}
}
impl GitRunner for DefaultGitRunner {
async fn run(&self, args: &[&str], cwd: &Path) -> Result<Output, WorktreeError> {
let timeout = self.timeout;
let mut cmd = tokio::process::Command::new("git");
cmd.args(args).current_dir(cwd);
cmd.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
let run_fut = async move { cmd.output().await.map_err(WorktreeError::Io) };
tokio::time::timeout(timeout, run_fut)
.await
.map_err(|_| WorktreeError::GitCommand {
op: args.first().copied().unwrap_or("git").to_string(),
stderr: format!("timed out after {}s", timeout.as_secs()),
})?
}
}
#[cfg(test)]
pub struct FakeGitRunner {
responses: std::sync::Mutex<std::collections::VecDeque<FakeResponse>>,
pub calls: std::sync::Mutex<Vec<(Vec<String>, std::path::PathBuf)>>,
}
#[cfg(test)]
pub struct FakeResponse {
pub stdout: Vec<u8>,
pub stderr: Vec<u8>,
pub exit_code: i32,
}
#[cfg(test)]
impl FakeGitRunner {
#[must_use]
pub fn new() -> Self {
Self {
responses: std::sync::Mutex::new(std::collections::VecDeque::new()),
calls: std::sync::Mutex::new(Vec::new()),
}
}
pub fn push_ok(&self, stdout: impl Into<Vec<u8>>) {
self.responses.lock().unwrap().push_back(FakeResponse {
stdout: stdout.into(),
stderr: vec![],
exit_code: 0,
});
}
pub fn push_err(&self, stderr: impl Into<Vec<u8>>) {
self.responses.lock().unwrap().push_back(FakeResponse {
stdout: vec![],
stderr: stderr.into(),
exit_code: 1,
});
}
}
#[cfg(test)]
impl Default for FakeGitRunner {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
impl GitRunner for std::sync::Arc<FakeGitRunner> {
async fn run(&self, args: &[&str], cwd: &Path) -> Result<Output, WorktreeError> {
(**self).run(args, cwd).await
}
}
#[cfg(test)]
impl GitRunner for FakeGitRunner {
async fn run(&self, args: &[&str], cwd: &Path) -> Result<Output, WorktreeError> {
self.calls.lock().unwrap().push((
args.iter().map(ToString::to_string).collect(),
cwd.to_path_buf(),
));
let response =
self.responses.lock().unwrap().pop_front().expect(
"FakeGitRunner: no more scripted responses (add more with push_ok/push_err)",
);
let exit_status = if response.exit_code == 0 {
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt;
std::process::ExitStatus::from_raw(0)
}
#[cfg(not(unix))]
{
std::process::Command::new("true")
.status()
.unwrap_or_else(|_| {
std::process::Command::new("cmd")
.args(["/c", "exit", "0"])
.status()
.unwrap()
})
}
} else {
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt;
std::process::ExitStatus::from_raw(response.exit_code << 8)
}
#[cfg(not(unix))]
{
std::process::Command::new("cmd")
.args(["/c", "exit", "1"])
.status()
.unwrap()
}
};
Ok(Output {
status: exit_status,
stdout: response.stdout,
stderr: response.stderr,
})
}
}
#[cfg(test)]
mod runner_tests {
use super::*;
#[test]
fn with_timeout_clamps_zero_to_one_second() {
let runner = DefaultGitRunner::with_timeout(Duration::ZERO);
assert_eq!(runner.timeout, Duration::from_secs(1));
}
#[test]
fn with_timeout_preserves_values_above_the_floor() {
let runner = DefaultGitRunner::with_timeout(Duration::from_mins(1));
assert_eq!(runner.timeout, Duration::from_mins(1));
}
#[test]
fn default_and_new_are_never_zero() {
assert_eq!(DefaultGitRunner::default().timeout, Duration::from_secs(30));
assert_eq!(DefaultGitRunner::new().timeout, Duration::from_secs(30));
}
}