Skip to main content

git_spawn/
command.rs

1//! Command execution primitives.
2//!
3//! Every git subcommand wrapper is a struct that implements [`GitCommand`].
4//! The trait gives each command:
5//!
6//! - [`execute()`](GitCommand::execute) — run and return a typed output
7//! - [`arg()`](GitCommand::arg) / [`args()`](GitCommand::args) — append raw
8//!   CLI arguments (escape hatch)
9//! - [`with_timeout()`](GitCommand::with_timeout) — cap execution time
10//! - [`current_dir()`](GitCommand::current_dir) / [`env()`](GitCommand::env) —
11//!   control the subprocess environment
12//!
13//! Under the hood, each command delegates to a shared [`CommandExecutor`] that
14//! spawns `git` via [`tokio::process::Command`], captures stdout/stderr, and
15//! maps non-zero exits to [`Error::CommandFailed`].
16//!
17//! # The two-tier output model
18//!
19//! Commands with unstructured output — porcelain that varies by git version,
20//! locale, and config — return [`CommandOutput`]. Callers can treat stdout as
21//! bytes or pass it through a parser in [`crate::parse`].
22//!
23//! Commands whose output is stable enough to decode return typed values
24//! directly. Examples:
25//!
26//! - [`InitCommand`](init::InitCommand) and [`CloneCommand`](clone::CloneCommand)
27//!   return [`Repository`](crate::Repository).
28//! - [`RevParseCommand`](rev_parse::RevParseCommand) returns a trimmed
29//!   [`String`] (typically a SHA or a boolean-ish literal).
30//! - [`CatFileCommand`](cat_file::CatFileCommand) returns the object body as
31//!   a [`String`].
32//! - [`HashObjectCommand`](hash_object::HashObjectCommand) returns the computed
33//!   SHA.
34//!
35//! # Escape hatches
36//!
37//! Every command supports [`arg`](GitCommand::arg), [`args`](GitCommand::args),
38//! [`flag`](GitCommand::flag), and [`option`](GitCommand::option). Raw args are
39//! appended **after** the command's typed flags, so they compose naturally:
40//!
41//! ```no_run
42//! # async fn ex() -> git_spawn::Result<()> {
43//! # use git_spawn::{GitCommand, Repository};
44//! let repo = Repository::open("/repo")?;
45//! // `--shortstat` isn't on DiffCommand yet — fine, append it raw:
46//! let out = repo.diff().cached().arg("--shortstat").execute().await?;
47//! println!("{}", out.stdout);
48//! # Ok(())
49//! # }
50//! ```
51
52use crate::error::{Error, Result};
53use async_trait::async_trait;
54use std::collections::HashMap;
55use std::ffi::{OsStr, OsString};
56use std::path::PathBuf;
57use std::process::Stdio;
58use std::time::Duration;
59use tokio::process::Command as TokioCommand;
60use tracing::{debug, error, instrument, trace, warn};
61
62pub mod add;
63pub mod bisect;
64pub mod branch;
65pub mod cat_file;
66pub mod checkout;
67pub mod cherry_pick;
68pub mod clone;
69pub mod commit;
70pub mod config;
71pub mod describe;
72pub mod diff;
73pub mod fetch;
74pub mod for_each_ref;
75pub mod grep;
76pub mod hash_object;
77pub mod init;
78pub mod log;
79pub mod ls_files;
80pub mod ls_tree;
81pub mod merge;
82pub mod mv;
83pub mod pull;
84pub mod push;
85pub mod rebase;
86pub mod reflog;
87pub mod remote;
88pub mod reset;
89pub mod restore;
90pub mod rev_parse;
91pub mod rm;
92pub mod show;
93pub mod show_ref;
94pub mod stash;
95pub mod status;
96pub mod submodule;
97pub mod switch;
98pub mod symbolic_ref;
99pub mod tag;
100pub mod update_ref;
101pub mod worktree;
102
103/// Default timeout applied when none is configured on the executor.
104///
105/// Set to `None` by default — callers opt in to timeouts explicitly.
106pub const DEFAULT_COMMAND_TIMEOUT: Option<Duration> = None;
107
108/// Trait implemented by every git subcommand wrapper.
109#[async_trait]
110pub trait GitCommand {
111    /// The typed output produced by this command.
112    type Output;
113
114    /// Borrow the shared executor.
115    fn get_executor(&self) -> &CommandExecutor;
116
117    /// Mutably borrow the shared executor.
118    fn get_executor_mut(&mut self) -> &mut CommandExecutor;
119
120    /// Build the full argument vector (subcommand + flags + positionals)
121    /// excluding the leading `git` program.
122    fn build_command_args(&self) -> Vec<String>;
123
124    /// Run the command and decode its output into [`Self::Output`].
125    async fn execute(&self) -> Result<Self::Output>;
126
127    /// Spawn `git` with the given arguments and return the raw output.
128    ///
129    /// Command implementations call this from `execute()` and then decode
130    /// stdout into their typed output.
131    async fn execute_raw(&self) -> Result<CommandOutput> {
132        let args = self.build_command_args();
133        self.get_executor().execute_command(args).await
134    }
135
136    /// Append a single raw argument.
137    fn arg<S: AsRef<OsStr>>(&mut self, arg: S) -> &mut Self {
138        self.get_executor_mut().add_arg(arg);
139        self
140    }
141
142    /// Append several raw arguments.
143    fn args<I, S>(&mut self, args: I) -> &mut Self
144    where
145        I: IntoIterator<Item = S>,
146        S: AsRef<OsStr>,
147    {
148        self.get_executor_mut().add_args(args);
149        self
150    }
151
152    /// Append a `--flag` (or `-f` if a single character).
153    fn flag(&mut self, flag: &str) -> &mut Self {
154        self.get_executor_mut().add_flag(flag);
155        self
156    }
157
158    /// Append a `--key value` pair.
159    fn option(&mut self, key: &str, value: &str) -> &mut Self {
160        self.get_executor_mut().add_option(key, value);
161        self
162    }
163
164    /// Run `git` in the given working directory.
165    fn current_dir<P: Into<PathBuf>>(&mut self, dir: P) -> &mut Self {
166        self.get_executor_mut().cwd = Some(dir.into());
167        self
168    }
169
170    /// Set an environment variable for this invocation.
171    fn env<K: Into<OsString>, V: Into<OsString>>(&mut self, key: K, value: V) -> &mut Self {
172        self.get_executor_mut().env.insert(key.into(), value.into());
173        self
174    }
175
176    /// Cap execution time. On expiry the process is killed and
177    /// [`Error::Timeout`] is returned.
178    fn with_timeout(&mut self, timeout: Duration) -> &mut Self {
179        self.get_executor_mut().timeout = Some(timeout);
180        self
181    }
182
183    /// Convenience: set timeout in whole seconds.
184    fn with_timeout_secs(&mut self, seconds: u64) -> &mut Self {
185        self.get_executor_mut().timeout = Some(Duration::from_secs(seconds));
186        self
187    }
188}
189
190/// Shared machinery used by every [`GitCommand`] to spawn `git`.
191#[derive(Debug, Clone, Default)]
192pub struct CommandExecutor {
193    /// Raw arguments appended via the escape hatch.
194    pub raw_args: Vec<String>,
195    /// Working directory for the subprocess.
196    pub cwd: Option<PathBuf>,
197    /// Extra environment variables.
198    pub env: HashMap<OsString, OsString>,
199    /// Optional execution timeout.
200    pub timeout: Option<Duration>,
201}
202
203impl CommandExecutor {
204    /// Create an empty executor.
205    #[must_use]
206    pub fn new() -> Self {
207        Self::default()
208    }
209
210    /// Builder: set the working directory.
211    #[must_use]
212    pub fn cwd(mut self, path: impl Into<PathBuf>) -> Self {
213        self.cwd = Some(path.into());
214        self
215    }
216
217    /// Builder: set an environment variable.
218    #[must_use]
219    pub fn with_env(mut self, key: impl Into<OsString>, value: impl Into<OsString>) -> Self {
220        self.env.insert(key.into(), value.into());
221        self
222    }
223
224    /// Builder: set the timeout.
225    #[must_use]
226    pub fn timeout(mut self, timeout: Duration) -> Self {
227        self.timeout = Some(timeout);
228        self
229    }
230
231    /// Append a raw argument.
232    pub fn add_arg<S: AsRef<OsStr>>(&mut self, arg: S) {
233        self.raw_args
234            .push(arg.as_ref().to_string_lossy().into_owned());
235    }
236
237    /// Append several raw arguments.
238    pub fn add_args<I, S>(&mut self, args: I)
239    where
240        I: IntoIterator<Item = S>,
241        S: AsRef<OsStr>,
242    {
243        for a in args {
244            self.add_arg(a);
245        }
246    }
247
248    /// Append a flag, normalizing to `-x` for single chars and `--word` otherwise.
249    pub fn add_flag(&mut self, flag: &str) {
250        let normalized = if flag.starts_with('-') {
251            flag.to_string()
252        } else if flag.len() == 1 {
253            format!("-{flag}")
254        } else {
255            format!("--{flag}")
256        };
257        self.raw_args.push(normalized);
258    }
259
260    /// Append a `--key value` pair (or `-k value` for single chars).
261    pub fn add_option(&mut self, key: &str, value: &str) {
262        let normalized = if key.starts_with('-') {
263            key.to_string()
264        } else if key.len() == 1 {
265            format!("-{key}")
266        } else {
267            format!("--{key}")
268        };
269        self.raw_args.push(normalized);
270        self.raw_args.push(value.to_string());
271    }
272
273    /// Spawn `git` with `args` followed by any raw args, returning captured output.
274    ///
275    /// Non-zero exit codes become [`Error::CommandFailed`].
276    #[instrument(
277        name = "git.command",
278        skip(self, args),
279        fields(
280            cwd = self.cwd.as_ref().map(|p| p.display().to_string()),
281            timeout_secs = self.timeout.map(|t| t.as_secs()),
282        )
283    )]
284    pub async fn execute_command(&self, args: Vec<String>) -> Result<CommandOutput> {
285        let mut all_args = args;
286        all_args.extend(self.raw_args.iter().cloned());
287
288        trace!(args = ?all_args, "executing git command");
289
290        let result = if let Some(t) = self.timeout {
291            self.execute_with_timeout(&all_args, t).await
292        } else {
293            self.execute_internal(&all_args).await
294        };
295
296        match &result {
297            Ok(output) => debug!(
298                exit_code = output.exit_code,
299                stdout_len = output.stdout.len(),
300                stderr_len = output.stderr.len(),
301                "command completed"
302            ),
303            Err(e) => error!(error = %e, "command failed"),
304        }
305
306        result
307    }
308
309    async fn execute_internal(&self, all_args: &[String]) -> Result<CommandOutput> {
310        let mut cmd = TokioCommand::new("git");
311        cmd.args(all_args)
312            .stdout(Stdio::piped())
313            .stderr(Stdio::piped());
314
315        if let Some(dir) = &self.cwd {
316            cmd.current_dir(dir);
317        }
318        for (k, v) in &self.env {
319            cmd.env(k, v);
320        }
321
322        let output = cmd.output().await.map_err(|e| {
323            if e.kind() == std::io::ErrorKind::NotFound {
324                Error::GitNotFound
325            } else {
326                Error::Io {
327                    message: format!("failed to spawn git: {e}"),
328                    source: e,
329                }
330            }
331        })?;
332
333        let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
334        let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
335        let exit_code = output.status.code().unwrap_or(-1);
336        let success = output.status.success();
337
338        if !success {
339            return Err(Error::command_failed(
340                format!("git {}", all_args.join(" ")),
341                exit_code,
342                stdout,
343                stderr,
344            ));
345        }
346
347        Ok(CommandOutput {
348            stdout,
349            stderr,
350            exit_code,
351            success,
352        })
353    }
354
355    async fn execute_with_timeout(
356        &self,
357        all_args: &[String],
358        timeout_duration: Duration,
359    ) -> Result<CommandOutput> {
360        match tokio::time::timeout(timeout_duration, self.execute_internal(all_args)).await {
361            Ok(r) => r,
362            Err(_) => {
363                warn!(
364                    timeout_secs = timeout_duration.as_secs(),
365                    "command timed out"
366                );
367                Err(Error::timeout(timeout_duration.as_secs()))
368            }
369        }
370    }
371}
372
373/// Captured output from running a git command.
374#[derive(Debug, Clone)]
375pub struct CommandOutput {
376    /// Captured stdout.
377    pub stdout: String,
378    /// Captured stderr.
379    pub stderr: String,
380    /// Exit code (`-1` if the process was terminated by a signal).
381    pub exit_code: i32,
382    /// Whether the process exited with status 0.
383    pub success: bool,
384}
385
386impl CommandOutput {
387    /// stdout split into lines.
388    #[must_use]
389    pub fn stdout_lines(&self) -> Vec<&str> {
390        self.stdout.lines().collect()
391    }
392
393    /// stderr split into lines.
394    #[must_use]
395    pub fn stderr_lines(&self) -> Vec<&str> {
396        self.stderr.lines().collect()
397    }
398
399    /// stdout with trailing whitespace trimmed.
400    #[must_use]
401    pub fn stdout_trimmed(&self) -> &str {
402        self.stdout.trim_end()
403    }
404}
405
406/// Locate the `git` binary, returning [`Error::GitNotFound`] if missing.
407///
408/// Commands don't call this on every execution — tokio's `Command::new("git")`
409/// already reports a helpful IO error we translate. This helper is for callers
410/// that want to verify availability up front.
411pub fn find_git() -> Result<PathBuf> {
412    which::which("git").map_err(|_| Error::GitNotFound)
413}
414
415/// Run `git --version` and return the raw version string.
416pub async fn git_version() -> Result<String> {
417    let output = CommandExecutor::new()
418        .execute_command(vec!["--version".into()])
419        .await?;
420    Ok(output.stdout_trimmed().to_string())
421}
422
423#[cfg(test)]
424mod tests {
425    use super::*;
426
427    #[test]
428    fn executor_args() {
429        let mut e = CommandExecutor::new();
430        e.add_arg("foo");
431        e.add_args(["a", "b"]);
432        e.add_flag("verbose");
433        e.add_flag("v");
434        e.add_option("name", "bar");
435        assert_eq!(
436            e.raw_args,
437            vec!["foo", "a", "b", "--verbose", "-v", "--name", "bar"]
438        );
439    }
440
441    #[test]
442    fn executor_timeout_builder() {
443        let e = CommandExecutor::new().timeout(Duration::from_secs(5));
444        assert_eq!(e.timeout, Some(Duration::from_secs(5)));
445    }
446
447    #[test]
448    fn command_output_helpers() {
449        let o = CommandOutput {
450            stdout: "a\nb\n".into(),
451            stderr: String::new(),
452            exit_code: 0,
453            success: true,
454        };
455        assert_eq!(o.stdout_lines(), vec!["a", "b"]);
456        assert_eq!(o.stdout_trimmed(), "a\nb");
457    }
458}