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