Skip to main content

zeph_worktree/
git_runner.rs

1// SPDX-License-Identifier: MIT
2//! [`GitRunner`] trait and [`DefaultGitRunner`] production implementation.
3//!
4//! The trait is the primary testability seam in `zeph-worktree`.  Unit tests
5//! inject a `FakeGitRunner` (defined in the test module) that verifies argument
6//! hygiene without touching the file system.  Production code uses
7//! [`DefaultGitRunner`], which delegates to [`tokio::process::Command`] with a
8//! default timeout of 30 seconds.
9
10use std::{path::Path, process::Output, time::Duration};
11
12use crate::error::WorktreeError;
13
14/// Runs `git` sub-commands on behalf of [`WorktreeManager`][crate::WorktreeManager].
15///
16/// The `cwd` parameter is an explicit directory argument, making it safe to use
17/// from contexts where the process working directory has already been mutated by
18/// a `CwdRestoreGuard` (in `zeph-subagent`).
19///
20/// Implementors must be `Send + Sync` so they can be shared across async tasks.
21pub trait GitRunner: Send + Sync {
22    /// Run `git` with the given `args` from the directory `cwd`.
23    ///
24    /// Returns the raw [`Output`] so callers can inspect both stdout and stderr.
25    ///
26    /// # Errors
27    ///
28    /// Returns [`WorktreeError`] on I/O failure or timeout.  Exit-code checking
29    /// is the caller's responsibility.
30    fn run(
31        &self,
32        args: &[&str],
33        cwd: &Path,
34    ) -> impl std::future::Future<Output = Result<Output, WorktreeError>> + Send;
35}
36
37/// Production [`GitRunner`] that invokes the system `git` binary.
38///
39/// A 30-second timeout is applied to every invocation to prevent indefinite
40/// hangs caused by credential prompts, slow networks, or stalled lock files.
41///
42/// # Examples
43///
44/// ```no_run
45/// use std::path::Path;
46/// use zeph_worktree::git_runner::{DefaultGitRunner, GitRunner};
47///
48/// # async fn example() -> Result<(), zeph_worktree::WorktreeError> {
49/// let runner = DefaultGitRunner::default();
50/// let out = runner.run(&["--version"], Path::new("/tmp")).await?;
51/// assert!(out.status.success());
52/// # Ok(())
53/// # }
54/// ```
55#[derive(Debug, Default, Clone)]
56pub struct DefaultGitRunner {
57    timeout: Duration,
58}
59
60impl DefaultGitRunner {
61    /// Creates a runner with the default 30-second command timeout.
62    #[must_use]
63    pub fn new() -> Self {
64        Self {
65            timeout: Duration::from_secs(30),
66        }
67    }
68
69    /// Creates a runner with a custom command timeout.
70    #[must_use]
71    pub fn with_timeout(timeout: Duration) -> Self {
72        Self { timeout }
73    }
74}
75
76impl GitRunner for DefaultGitRunner {
77    async fn run(&self, args: &[&str], cwd: &Path) -> Result<Output, WorktreeError> {
78        let timeout = self.timeout;
79        let mut cmd = tokio::process::Command::new("git");
80        cmd.args(args).current_dir(cwd);
81        // Capture both streams so callers can inspect them without noise on the terminal.
82        cmd.stdout(std::process::Stdio::piped())
83            .stderr(std::process::Stdio::piped());
84
85        let run_fut = async move { cmd.output().await.map_err(WorktreeError::Io) };
86
87        tokio::time::timeout(timeout, run_fut)
88            .await
89            .map_err(|_| WorktreeError::GitCommand {
90                op: args.first().copied().unwrap_or("git").to_string(),
91                stderr: format!("timed out after {}s", timeout.as_secs()),
92            })?
93    }
94}
95
96/// A scripted fake [`GitRunner`] for unit tests.
97///
98/// Each call pops the next entry from the response queue.  If the queue is empty
99/// the call panics, which surfaces as a test failure with a clear backtrace.
100///
101/// The `calls` field records every `(args, cwd)` pair passed to [`run`][Self::run]
102/// for post-test assertion (e.g. that `--` was always present).
103#[cfg(test)]
104pub struct FakeGitRunner {
105    /// Queued responses, front = next response.
106    responses: std::sync::Mutex<std::collections::VecDeque<FakeResponse>>,
107    /// All calls recorded in order.
108    pub calls: std::sync::Mutex<Vec<(Vec<String>, std::path::PathBuf)>>,
109}
110
111#[cfg(test)]
112pub struct FakeResponse {
113    pub stdout: Vec<u8>,
114    pub stderr: Vec<u8>,
115    pub exit_code: i32,
116}
117
118#[cfg(test)]
119impl FakeGitRunner {
120    /// Creates a new `FakeGitRunner` with an empty response queue.
121    #[must_use]
122    pub fn new() -> Self {
123        Self {
124            responses: std::sync::Mutex::new(std::collections::VecDeque::new()),
125            calls: std::sync::Mutex::new(Vec::new()),
126        }
127    }
128
129    /// Enqueues a successful response with the given stdout bytes.
130    ///
131    /// # Panics
132    ///
133    /// Panics if the internal mutex is poisoned.
134    pub fn push_ok(&self, stdout: impl Into<Vec<u8>>) {
135        self.responses.lock().unwrap().push_back(FakeResponse {
136            stdout: stdout.into(),
137            stderr: vec![],
138            exit_code: 0,
139        });
140    }
141
142    /// Enqueues a failing response with the given stderr bytes.
143    ///
144    /// # Panics
145    ///
146    /// Panics if the internal mutex is poisoned.
147    pub fn push_err(&self, stderr: impl Into<Vec<u8>>) {
148        self.responses.lock().unwrap().push_back(FakeResponse {
149            stdout: vec![],
150            stderr: stderr.into(),
151            exit_code: 1,
152        });
153    }
154}
155
156#[cfg(test)]
157impl Default for FakeGitRunner {
158    fn default() -> Self {
159        Self::new()
160    }
161}
162
163/// Blanket implementation so `Arc<FakeGitRunner>` can be passed as a runner in tests.
164#[cfg(test)]
165impl GitRunner for std::sync::Arc<FakeGitRunner> {
166    async fn run(&self, args: &[&str], cwd: &Path) -> Result<Output, WorktreeError> {
167        (**self).run(args, cwd).await
168    }
169}
170
171#[cfg(test)]
172impl GitRunner for FakeGitRunner {
173    async fn run(&self, args: &[&str], cwd: &Path) -> Result<Output, WorktreeError> {
174        // Record the call for post-test assertions.
175        self.calls.lock().unwrap().push((
176            args.iter().map(ToString::to_string).collect(),
177            cwd.to_path_buf(),
178        ));
179
180        let response =
181            self.responses.lock().unwrap().pop_front().expect(
182                "FakeGitRunner: no more scripted responses (add more with push_ok/push_err)",
183            );
184
185        let exit_status = if response.exit_code == 0 {
186            #[cfg(unix)]
187            {
188                use std::os::unix::process::ExitStatusExt;
189                std::process::ExitStatus::from_raw(0)
190            }
191            #[cfg(not(unix))]
192            {
193                // On non-unix platforms we build a real process to get an ExitStatus.
194                std::process::Command::new("true")
195                    .status()
196                    .unwrap_or_else(|_| {
197                        std::process::Command::new("cmd")
198                            .args(["/c", "exit", "0"])
199                            .status()
200                            .unwrap()
201                    })
202            }
203        } else {
204            #[cfg(unix)]
205            {
206                use std::os::unix::process::ExitStatusExt;
207                // Shift by 8 to set the exit code in the wait-status.
208                std::process::ExitStatus::from_raw(response.exit_code << 8)
209            }
210            #[cfg(not(unix))]
211            {
212                std::process::Command::new("cmd")
213                    .args(["/c", "exit", "1"])
214                    .status()
215                    .unwrap()
216            }
217        };
218
219        Ok(Output {
220            status: exit_status,
221            stdout: response.stdout,
222            stderr: response.stderr,
223        })
224    }
225}