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, Clone)]
56pub struct DefaultGitRunner {
57    timeout: Duration,
58}
59
60/// Floor applied to every configured timeout so a `git_timeout_secs = 0`
61/// misconfiguration cannot produce an instantly-expiring command timeout
62/// (spec-063 NEVER: `git_timeout_secs = 0` is disallowed).
63const MIN_TIMEOUT: Duration = Duration::from_secs(1);
64
65impl DefaultGitRunner {
66    /// Creates a runner with the default 30-second command timeout.
67    #[must_use]
68    pub fn new() -> Self {
69        Self::with_timeout(Duration::from_secs(30))
70    }
71
72    /// Creates a runner with a custom command timeout.
73    ///
74    /// `timeout` is clamped to a minimum of one second — a zero (or
75    /// sub-second) timeout would make every `git` invocation fail
76    /// immediately, which is never the caller's intent.
77    #[must_use]
78    pub fn with_timeout(timeout: Duration) -> Self {
79        Self {
80            timeout: timeout.max(MIN_TIMEOUT),
81        }
82    }
83}
84
85impl Default for DefaultGitRunner {
86    fn default() -> Self {
87        Self::new()
88    }
89}
90
91impl GitRunner for DefaultGitRunner {
92    async fn run(&self, args: &[&str], cwd: &Path) -> Result<Output, WorktreeError> {
93        let timeout = self.timeout;
94        let mut cmd = tokio::process::Command::new("git");
95        cmd.args(args).current_dir(cwd);
96        // Capture both streams so callers can inspect them without noise on the terminal.
97        cmd.stdout(std::process::Stdio::piped())
98            .stderr(std::process::Stdio::piped());
99
100        let run_fut = async move { cmd.output().await.map_err(WorktreeError::Io) };
101
102        tokio::time::timeout(timeout, run_fut)
103            .await
104            .map_err(|_| WorktreeError::GitCommand {
105                op: args.first().copied().unwrap_or("git").to_string(),
106                stderr: format!("timed out after {}s", timeout.as_secs()),
107            })?
108    }
109}
110
111/// A scripted fake [`GitRunner`] for unit tests.
112///
113/// Each call pops the next entry from the response queue.  If the queue is empty
114/// the call panics, which surfaces as a test failure with a clear backtrace.
115///
116/// The `calls` field records every `(args, cwd)` pair passed to [`run`][Self::run]
117/// for post-test assertion (e.g. that `--` was always present).
118#[cfg(test)]
119pub struct FakeGitRunner {
120    /// Queued responses, front = next response.
121    responses: std::sync::Mutex<std::collections::VecDeque<FakeResponse>>,
122    /// All calls recorded in order.
123    pub calls: std::sync::Mutex<Vec<(Vec<String>, std::path::PathBuf)>>,
124}
125
126#[cfg(test)]
127pub struct FakeResponse {
128    pub stdout: Vec<u8>,
129    pub stderr: Vec<u8>,
130    pub exit_code: i32,
131}
132
133#[cfg(test)]
134impl FakeGitRunner {
135    /// Creates a new `FakeGitRunner` with an empty response queue.
136    #[must_use]
137    pub fn new() -> Self {
138        Self {
139            responses: std::sync::Mutex::new(std::collections::VecDeque::new()),
140            calls: std::sync::Mutex::new(Vec::new()),
141        }
142    }
143
144    /// Enqueues a successful response with the given stdout bytes.
145    ///
146    /// # Panics
147    ///
148    /// Panics if the internal mutex is poisoned.
149    pub fn push_ok(&self, stdout: impl Into<Vec<u8>>) {
150        self.responses.lock().unwrap().push_back(FakeResponse {
151            stdout: stdout.into(),
152            stderr: vec![],
153            exit_code: 0,
154        });
155    }
156
157    /// Enqueues a failing response with the given stderr bytes.
158    ///
159    /// # Panics
160    ///
161    /// Panics if the internal mutex is poisoned.
162    pub fn push_err(&self, stderr: impl Into<Vec<u8>>) {
163        self.responses.lock().unwrap().push_back(FakeResponse {
164            stdout: vec![],
165            stderr: stderr.into(),
166            exit_code: 1,
167        });
168    }
169}
170
171#[cfg(test)]
172impl Default for FakeGitRunner {
173    fn default() -> Self {
174        Self::new()
175    }
176}
177
178/// Blanket implementation so `Arc<FakeGitRunner>` can be passed as a runner in tests.
179#[cfg(test)]
180impl GitRunner for std::sync::Arc<FakeGitRunner> {
181    async fn run(&self, args: &[&str], cwd: &Path) -> Result<Output, WorktreeError> {
182        (**self).run(args, cwd).await
183    }
184}
185
186#[cfg(test)]
187impl GitRunner for FakeGitRunner {
188    async fn run(&self, args: &[&str], cwd: &Path) -> Result<Output, WorktreeError> {
189        // Record the call for post-test assertions.
190        self.calls.lock().unwrap().push((
191            args.iter().map(ToString::to_string).collect(),
192            cwd.to_path_buf(),
193        ));
194
195        let response =
196            self.responses.lock().unwrap().pop_front().expect(
197                "FakeGitRunner: no more scripted responses (add more with push_ok/push_err)",
198            );
199
200        let exit_status = if response.exit_code == 0 {
201            #[cfg(unix)]
202            {
203                use std::os::unix::process::ExitStatusExt;
204                std::process::ExitStatus::from_raw(0)
205            }
206            #[cfg(not(unix))]
207            {
208                // On non-unix platforms we build a real process to get an ExitStatus.
209                std::process::Command::new("true")
210                    .status()
211                    .unwrap_or_else(|_| {
212                        std::process::Command::new("cmd")
213                            .args(["/c", "exit", "0"])
214                            .status()
215                            .unwrap()
216                    })
217            }
218        } else {
219            #[cfg(unix)]
220            {
221                use std::os::unix::process::ExitStatusExt;
222                // Shift by 8 to set the exit code in the wait-status.
223                std::process::ExitStatus::from_raw(response.exit_code << 8)
224            }
225            #[cfg(not(unix))]
226            {
227                std::process::Command::new("cmd")
228                    .args(["/c", "exit", "1"])
229                    .status()
230                    .unwrap()
231            }
232        };
233
234        Ok(Output {
235            status: exit_status,
236            stdout: response.stdout,
237            stderr: response.stderr,
238        })
239    }
240}
241
242#[cfg(test)]
243mod runner_tests {
244    use super::*;
245
246    /// Regression test for #5939: `git_timeout_secs = 0` (surfaced as
247    /// `Duration::ZERO`) must not produce a runner whose every `git`
248    /// invocation times out instantly.
249    #[test]
250    fn with_timeout_clamps_zero_to_one_second() {
251        let runner = DefaultGitRunner::with_timeout(Duration::ZERO);
252        assert_eq!(runner.timeout, Duration::from_secs(1));
253    }
254
255    #[test]
256    fn with_timeout_preserves_values_above_the_floor() {
257        let runner = DefaultGitRunner::with_timeout(Duration::from_mins(1));
258        assert_eq!(runner.timeout, Duration::from_mins(1));
259    }
260
261    #[test]
262    fn default_and_new_are_never_zero() {
263        assert_eq!(DefaultGitRunner::default().timeout, Duration::from_secs(30));
264        assert_eq!(DefaultGitRunner::new().timeout, Duration::from_secs(30));
265    }
266}