grit/
git.rs

1use std::{ffi::OsStr, fmt, process::ExitStatus};
2
3use tokio::process::Command;
4
5/// Git returned bad status, and printed the supplied text to standard error.
6#[derive(Debug)]
7pub struct Error(String);
8
9pub type Result<T> = std::result::Result<T, Error>;
10
11pub const HEAD: &str = "HEAD";
12
13impl fmt::Display for Error {
14    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
15        // If the Git error already ends with a newline, remove it.
16        self.0.strip_suffix('\n').unwrap_or(&self.0).fmt(f)
17    }
18}
19
20fn format_git_command<S, I>(args: I) -> String
21where
22    I: IntoIterator<Item = S>,
23    S: AsRef<OsStr>,
24{
25    let args: Vec<_> = args.into_iter().collect();
26    let strs: Vec<_> = args.iter().map(|s| s.as_ref().to_string_lossy()).collect();
27    format!("git {}", strs.join(" "))
28}
29
30/// Returns (success, stdout, stderr).
31///
32/// TODO: Should this return [`std::ffi::OsString`] or [`Vec<u8>`] rather than
33///  [`String`]? What is the narrowest type of plausible `git(1)` output?
34async fn run_git<S, I>(args: I) -> (ExitStatus, String, String)
35where
36    I: IntoIterator<Item = S>,
37    S: AsRef<OsStr>,
38{
39    let output = Command::new("git")
40        .args(args)
41        .output()
42        .await
43        .expect("git should be executable");
44    (
45        output.status,
46        String::from_utf8_lossy(&output.stdout).to_string(),
47        String::from_utf8_lossy(&output.stderr).to_string(),
48    )
49}
50
51/// Runs Git, printing the command and its stdout. Primarily useful for
52/// debugging.
53///
54/// # Errors
55///
56/// Returns an error if the `git` command fails.
57pub async fn git_loud<S, I>(args: I) -> Result<String>
58where
59    I: IntoIterator<Item = S>,
60    S: AsRef<OsStr>,
61{
62    let args: Vec<_> = args.into_iter().collect();
63    println!("> {}", format_git_command(&args));
64    let (status, stdout, stderr) = run_git(args).await;
65    if !status.success() {
66        return Err(Error(stderr));
67    }
68    stderr.lines().for_each(|s| println!("! {s}"));
69    stdout.lines().for_each(|s| println!("< {s}"));
70    Ok(stderr + &stdout)
71}
72
73/// Runs a git command and returns its combined output.
74///
75/// # Errors
76///
77/// Returns an error if the `git` command fails.
78pub async fn git<S, I>(args: I) -> Result<String>
79where
80    I: IntoIterator<Item = S>,
81    S: AsRef<OsStr>,
82{
83    let (status, stdout, stderr) = run_git(args).await;
84    if !status.success() {
85        return Err(Error(stderr));
86    }
87    Ok(stderr + &stdout)
88}
89
90/// Returns the merge base of two refs.
91///
92/// # Errors
93///
94/// Returns an error if `git merge-base` fails.
95pub async fn merge_base(ref1: impl AsRef<OsStr>, ref2: impl AsRef<OsStr>) -> Result<String> {
96    let mut base = git(["merge-base".as_ref(), ref1.as_ref(), ref2.as_ref()]).await?;
97    base.truncate(base.trim_end().len());
98    Ok(base)
99}
100
101/// Returns the merge base of a ref and HEAD.
102///
103/// # Errors
104///
105/// Returns an error if `git merge-base` fails.
106pub async fn merge_base_head(base: impl AsRef<OsStr>) -> Result<String> {
107    merge_base(base, HEAD).await
108}
109
110/// Returns the qualified name of the remote branch being tracked by the
111/// specified tracking branch, or [`None`] if the `branch` is not a tracking
112/// branch.
113///
114/// # Examples
115///
116/// Assuming the local `main` branch is tracking `origin/main`:
117///
118/// ```no_run
119/// use grit::git;
120///
121/// async fn assert_main() {
122///     assert_eq!(git::upstream("main").await.as_deref(), Some("origin/main"));
123/// }
124/// ```
125pub async fn upstream(branch: impl AsRef<OsStr>) -> Option<String> {
126    git([
127        "rev-parse",
128        "--abbrev-ref",
129        "--symbolic-full-name",
130        &format!("{}@{{u}}", branch.as_ref().display()),
131    ])
132    .await
133    .ok()
134    .map(|s| s.trim().to_owned())
135}