1use std::{
2 fmt::Debug,
3 path::{Path, PathBuf},
4 process::{Child, Command, Output, Stdio},
5 str::FromStr,
6};
7
8use miette::{Context, IntoDiagnostic, bail};
9use semver::Version;
10use tracing::{debug, info, instrument, warn};
11
12use crate::{Task, cli::Cli, current_span, git::git_file::GitFiles};
13
14#[derive(Debug)]
16pub struct NoRootDirSet;
17
18#[derive(Debug, Default)]
19pub struct GitBuilder<T: Debug> {
20 root_directory: T,
21}
22impl GitBuilder<NoRootDirSet> {
23 pub fn new() -> Self {
24 Self {
25 root_directory: NoRootDirSet,
26 }
27 }
28}
29impl std::fmt::Display for GitBuilder<NoRootDirSet> {
30 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31 write!(f, "No root directory set for GitBuilder.")
32 }
33}
34impl std::error::Error for GitBuilder<NoRootDirSet> {}
35
36impl<T: Debug> GitBuilder<T> {
37 pub fn root_directory(self, path: PathBuf) -> GitBuilder<PathBuf> {
39 GitBuilder {
40 root_directory: path,
41 }
42 }
43
44 #[instrument]
50 pub fn find_root_directory(self) -> Result<GitBuilder<PathBuf>, Self> {
51 let mut git = Git::<NoRootDirSet>::command(true);
52 git.arg("rev-parse").arg("--show-toplevel");
53 let out = match git.output() {
54 Ok(o) => o.stdout(),
55 Err(_) => {
56 tracing::error!("Could not find git root dir.");
57 return Err(self);
58 }
59 };
60 let path = PathBuf::from_str(&out).map_err(|_| self)?;
61 Ok(GitBuilder {
62 root_directory: path,
63 })
64 }
65}
66
67impl GitBuilder<PathBuf> {
68 pub fn build(self) -> Git<PathBuf> {
69 Git {
70 root_directory: self.root_directory,
71 }
72 }
73}
74
75#[derive(Debug)]
76pub struct Git<T: Debug> {
77 root_directory: T,
78}
79
80impl Git<NoRootDirSet> {
81 #[instrument(name = "Git::command")]
82 fn command(quiet: bool) -> Command {
84 let mut cmd = Command::new("git");
85 if !quiet {
86 cmd.stdout(Stdio::inherit());
87 }
88 cmd
90 }
91}
92
93impl Git<PathBuf> {
94 #[instrument(name = "Git::command", skip_all)]
96 fn command(&self, quiet: bool) -> Command {
97 let mut cmd = Command::new("git");
98 cmd.arg("-C")
100 .arg(self.root_directory.clone().into_os_string());
101 tracing::trace!("Command: {:#?}", &cmd);
102 if !quiet {
103 cmd.stdout(Stdio::inherit());
104 }
105 cmd
106 }
107
108 pub fn root_directory(&self) -> &Path {
109 &self.root_directory
110 }
111
112 #[instrument(skip_all)]
113 pub fn add_cargo_files(&self) -> miette::Result<()> {
124 let mut git = self.command(false);
125 let cargo_toml = "Cargo.toml";
126 let all_cargo_toml = "./**/Cargo.toml";
127 let cargo_lock = "Cargo.lock";
128
129 info!("Staging cargo files: {}, {}", cargo_toml, cargo_lock);
130 git.args(["add", "-v", cargo_toml, cargo_lock, all_cargo_toml]);
131 tracing::debug!("Running: {:?}", git);
132 git.output().map(|_| ()).into_diagnostic()
133 }
134}
135
136impl Git<PathBuf> {
137 #[instrument(skip_all)]
139 pub fn dirty_files(&self) -> miette::Result<GitFiles> {
140 let mut git_status = self.command(true);
141 git_status.args(["status", "--short"]);
142 tracing::debug!("Running: {:?}", git_status);
143 let stdout = git_status.output().into_diagnostic()?.stdout();
144 if stdout.lines().count() == 0 {
145 return Ok(GitFiles::new());
146 };
147 match GitFiles::parse(stdout) {
148 Some(files) => Ok(files),
149 None => Ok(GitFiles::new()),
150 }
151 }
152
153 #[instrument(skip_all)]
154 pub fn commit(&self, cli_args: &Cli, new_version: &Version) -> miette::Result<()> {
155 let mut git = self.command(cli_args.suppress.includes_git());
156 info!("Creating commit");
157 git.args(["commit"]);
158
159 if cli_args.dry_run() {
160 git.arg("--dry-run");
161 }
162 match cli_args.git_message() {
163 Some(msg) => {
164 git.args(["--message", &msg]);
165 }
166 None => {
167 git.args(["--message", &new_version.to_string()]);
168 }
169 }
170
171 tracing::debug!("Running: {:?}", git);
172 let _stdout = git.output().into_diagnostic()?;
173 self.dirty_files().context("After Commit")?;
174 Ok(())
175 }
176
177 #[instrument(skip_all)]
178 pub fn tag(
179 &self,
180 cli_args: &Cli,
181 version: &Version,
182 args: Option<Vec<&str>>,
183 ) -> miette::Result<()> {
184 let mut git = self.command(cli_args.suppress.includes_git());
185 git.arg("tag");
186 if let Some(a) = args {
187 git.args(a);
188 }
189 git.args([&self.generate_tag(version)]);
190 tracing::debug!("Running: {:?}", git);
191 let output = git.output().into_diagnostic()?;
192 if !output.status.success() {
193 tracing::debug!("stderr: {}", output.stderr());
194 bail!("Failed to tag repository.")
195 }
196 Ok(())
197 }
198
199 #[instrument(skip_all)]
200 pub fn generate_tag(&self, version: &Version) -> String {
201 let tag = version.to_string();
202 debug! {"Tag: {tag}", };
203 tag
204 }
205
206 #[instrument(skip_all, fields(dry_run))]
208 pub fn push(&self, cli_args: &Cli, version: &Version) -> miette::Result<Vec<(Task, Child)>> {
209 current_span!().record("dry_run", cli_args.dry_run());
210 let tag_string = String::from("tags/") + &self.generate_tag(version);
211 let join = self
212 .remotes()?
213 .iter()
214 .map(|remote| {
215 let task = Task::Push(remote.clone());
216 info!("Pushing to remote: {remote}");
217 let mut git_push = self.command(cli_args.suppress.includes_git());
218 git_push.arg("push");
219 if cli_args.dry_run() {
220 git_push.arg("--dry-run");
221 }
222 git_push.args([remote.as_str(), &tag_string, "--porcelain"]);
223 tracing::debug!("Running: {:?}", git_push);
225 (task, git_push.spawn().into_diagnostic())
226 })
227 .collect::<Vec<_>>();
228 let mut ret = vec![];
229
230 for (t, c) in join {
231 ret.push((t, c?));
232 }
233
234 Ok(ret)
235 }
236
237 #[instrument(skip_all)]
241 pub fn remotes(&self) -> miette::Result<Vec<String>> {
242 let mut git = self.command(true);
243 git.args(["remote"]);
244 tracing::debug!("Running: {:?}", git);
245 let remotes: Vec<String> = git
246 .output()
247 .into_diagnostic()?
248 .stdout()
249 .lines()
250 .map(String::from)
251 .collect();
252
253 let mut branch_remotes = Vec::new();
254
255 for line in self.branch(vec!["--remotes"])?.lines() {
256 let valid_remote = match line.split_once('/') {
257 Some((remote, _branch)) => remote.trim().to_string(),
258 None => {
259 warn!("Ensure you only run command on a branch with a remote.");
260 bail!("Failed to find remote for current branch.")
261 }
262 };
263 assert!(remotes.contains(&valid_remote));
264
265 branch_remotes.push(valid_remote);
266 }
267 info!("Remotes: {:?}", branch_remotes);
268
269 assert!(!branch_remotes.is_empty());
270 assert!(remotes.len() >= branch_remotes.len());
271 if branch_remotes.is_empty() {
272 warn!("Ensure you only run command on a branch with a remote.");
273 bail!("Failed to find remote for current branch.")
274 }
275 Ok(branch_remotes)
276 }
277
278 #[instrument(skip_all)]
279 pub fn branch(&self, args: Vec<&str>) -> miette::Result<String> {
280 let mut git = self.command(true);
281 git.arg("branch");
282 args.iter().for_each(|&arg| {
283 git.arg(arg);
284 });
285 tracing::debug!("Running: {:?}", git);
286 git.output().map(|output| output.stdout()).into_diagnostic()
287 }
288}
289
290#[allow(dead_code)]
291pub trait OutputExt {
292 fn stderr(&self) -> String;
293 fn stdout(&self) -> String;
294}
295
296impl OutputExt for Output {
297 fn stderr(&self) -> String {
298 String::from_iter(self.stderr.iter().map(|&c| char::from(c)))
299 }
300
301 fn stdout(&self) -> String {
302 String::from_iter(self.stdout.iter().map(|&c| char::from(c)))
303 }
304}