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}