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}