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