git_glimpse/
lib.rs

1use std::{
2    ffi::OsStr,
3    io::{self, Cursor},
4    process::{exit, Command, ExitStatus, Output},
5};
6
7use anyhow::{anyhow, Context};
8use ezcmd::{EasyCommand, ExecuteError, RunErrorKind};
9
10pub fn run<F>(f: F)
11where
12    F: FnOnce() -> Result<()>,
13{
14    init_logger();
15    match f() {
16        Ok(()) => (),
17        Err(e) => match e {
18            Error::SubprocessFailedWithExplanation { code } => exit(code.unwrap_or(255)),
19            Error::Other { source } => {
20                log::error!("{source:?}");
21                exit(254);
22            }
23        },
24    }
25}
26
27fn init_logger() {
28    env_logger::builder()
29        .filter_level(log::LevelFilter::Info)
30        .parse_default_env()
31        .init()
32}
33
34#[derive(Debug)]
35pub enum Error {
36    SubprocessFailedWithExplanation { code: Option<i32> },
37    Other { source: anyhow::Error },
38}
39
40impl Error {
41    fn other(source: anyhow::Error) -> Self {
42        Self::Other { source }
43    }
44
45    fn from_status(status: ExitStatus) -> Result<()> {
46        match status.code() {
47            Some(0) => Ok(()),
48            Some(_) | None => Err(Self::SubprocessFailedWithExplanation {
49                code: status.code(),
50            }),
51        }
52    }
53
54    fn from_run(source: ExecuteError<RunErrorKind>) -> Self {
55        let ExecuteError { source, .. } = source;
56        // TODO: Not super happy about basically cloning this.
57        match source {
58            RunErrorKind::SpawnAndWait(e) => Self::other(e.into()),
59            RunErrorKind::UnsuccessfulExitCode { code } => {
60                Self::SubprocessFailedWithExplanation { code }
61            }
62        }
63    }
64}
65
66impl From<anyhow::Error> for Error {
67    fn from(value: anyhow::Error) -> Self {
68        Self::other(value)
69    }
70}
71
72impl From<ExecuteError<RunErrorKind>> for Error {
73    fn from(value: ExecuteError<RunErrorKind>) -> Self {
74        Self::from_run(value)
75    }
76}
77
78pub type Result<T> = std::result::Result<T, Error>;
79
80pub fn show_graph<'a, Os, Fs>(format: Option<String>, object_names: Os, files: Fs) -> Result<()>
81where
82    Os: IntoIterator<Item = &'a str> + Clone,
83    Fs: IntoIterator<Item = &'a OsStr> + Clone,
84{
85    let merge_base = {
86        let mut output = stdout_lines(EasyCommand::new_with("git", |cmd| {
87            cmd.args(["merge-base", "--octopus"])
88                .args(object_names.clone().into_iter())
89        }))?;
90        if output.len() != 1 {
91            return Err(Error::other(anyhow!(
92                "expected a single line of output, but got {}; \
93                output: {output:#?}",
94                output.len()
95            )));
96        }
97        output.pop().unwrap()
98    };
99    let format = format
100        .map(Ok)
101        .or_else(|| {
102            git_config("glimpse.pretty")
103                .map(|configged| {
104                    if configged.is_some() {
105                        log::trace!(
106                            "no format specified, using format from `glimpse.pretty` config: \
107                            {configged:?}"
108                        );
109                    } else {
110                        log::trace!(
111                            "no format specified, no format found in `glimpse.pretty` config"
112                        );
113                    }
114                    configged
115                })
116                .transpose()
117        })
118        .transpose()?;
119    EasyCommand::new_with("git", |cmd| {
120        cmd.args(["log", "--graph", "--decorate"]);
121        if let Some(format) = format {
122            cmd.arg(format!("--format={format}"));
123        }
124        cmd.arg("--ancestry-path")
125            .arg(format!("^{merge_base}^@"))
126            .args(object_names.clone().into_iter())
127            .arg("--") // Make it unambiguous that we're specifying branches first
128            .args(files)
129    })
130    .spawn_and_wait()
131    .map_err(Into::into)
132    .map_err(Error::other)
133    .and_then(Error::from_status)
134}
135
136pub fn list_branches_cmd(config: impl FnOnce(&mut Command) -> &mut Command) -> EasyCommand {
137    EasyCommand::new_with("git", |cmd| {
138        config(cmd.args(["branch", "--list", "--format=%(refname:short)"]))
139    })
140}
141
142pub fn stdout_lines(mut cmd: EasyCommand) -> Result<Vec<String>> {
143    let output = cmd.output().map_err(Into::into).map_err(Error::other)?;
144    let Output {
145        stdout,
146        stderr,
147        status,
148    } = output;
149
150    let status_res = Error::from_status(status);
151    if status_res.is_err() {
152        io::copy(&mut Cursor::new(stderr), &mut io::stderr()).unwrap();
153    }
154    status_res?;
155
156    let stdout = String::from_utf8(stdout)
157        .context("`stdout` was not UTF-8 (!?)")
158        .map_err(Error::other)?;
159    Ok(stdout.lines().map(|line| line.trim().to_owned()).collect())
160}
161
162pub fn git_config(path: &str) -> Result<Option<String>> {
163    let mut cmd = EasyCommand::new_with("git", |cmd| cmd.arg("config").arg(path));
164    let output = cmd.output().map_err(Into::into).map_err(Error::other)?;
165    let Output {
166        stdout,
167        stderr,
168        status,
169    } = output;
170
171    match status.code() {
172        Some(0) => (),
173        Some(1) => return Ok(None),
174        _ => {
175            io::copy(&mut Cursor::new(stderr), &mut io::stderr()).unwrap();
176            return Err(Error::from_status(status).unwrap_err());
177        }
178    };
179
180    let stdout = String::from_utf8(stdout)
181        .context("`stdout` was not UTF-8 (!?)")
182        .map_err(Error::other)?;
183
184    let mut lines = stdout.lines().map(|line| line.trim().to_owned());
185    log::trace!("`stdout` of {cmd}: {lines:?}");
186
187    let first_line = lines.next();
188    assert!(
189        lines.next().is_none(),
190        "{cmd} returned more than a single line of output"
191    );
192    Ok(first_line)
193}