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 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("--") .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}