Skip to main content

cargo_uv/git/
git.rs

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/// Used to indicate if the Root Dir is Set and can be used.
15#[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    /// Manually set the root directory of the project i.e. where .git lives.
38    pub fn root_directory(self, path: PathBuf) -> GitBuilder<PathBuf> {
39        GitBuilder {
40            root_directory: path,
41        }
42    }
43
44    /// Use git to locate the root directory using:
45    ///
46    /// ```shell
47    /// git rev-parse --show-toplevel
48    /// ```
49    #[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    /// Base git command run in cwd.
83    fn command(quiet: bool) -> Command {
84        let mut cmd = Command::new("git");
85        if !quiet {
86            cmd.stdout(Stdio::inherit());
87        }
88        // cmd.stderr(Stdio::piped());
89        cmd
90    }
91}
92
93impl Git<PathBuf> {
94    /// Base git command run in set root path.
95    #[instrument(name = "Git::command", skip_all)]
96    fn command(&self, quiet: bool) -> Command {
97        let mut cmd = Command::new("git");
98        // cmd.current_dir(&self.root_directory);
99        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    /// Adds all cargo files (Cargo.toml, Cargo.lock) in whole project to git.
114    ///
115    /// Equivilent to: `git add Cargo.toml Cargo.lock`
116    ///
117    /// TODO: Confirm if file is in git ignore it doesn't add them.
118    /// BUG: #28 Git add fetal if doesn't match path spec. Change to generate adds of known files.
119    /// add 'Cargo.lock'
120    /// add 'Cargo.toml'
121    /// add 'pack1/Cargo.toml'
122    /// add 'pack2/Cargo.toml'
123    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    /// Generates a list of dirty files.
138    #[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    /// Pushed just the tag to the remotes
207    #[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                // let _ = dbg!(git_push.get_args());
224                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    /// Returns a list of remotes for the current branch.
238    ///
239    /// Returns an error if the list is empty
240    #[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}