release_utils/
git.rs

1// Copyright 2024 Google LLC
2//
3// Licensed under the Apache License, Version 2.0 <LICENSE-APACHE or
4// https://www.apache.org/licenses/LICENSE-2.0> or the MIT license
5// <LICENSE-MIT or https://opensource.org/licenses/MIT>, at your
6// option. This file may not be copied, modified, or distributed
7// except according to those terms.
8
9//! Utilities for running `git` commands.
10
11use crate::cmd::{get_cmd_stdout_utf8, run_cmd, RunCommandError};
12use std::ffi::OsStr;
13use std::fmt::{self, Display, Formatter};
14use std::path::{Path, PathBuf};
15use std::process::Command;
16use std::{env, io};
17
18/// Error returned by [`Repo::open`] and [`Repo::open_path`].
19#[derive(Debug)]
20pub enum RepoOpenError {
21    /// Failed to get current directory.
22    CurrentDir(io::Error),
23
24    /// The directory does not have a `.git` subdirectory.
25    GitDirMissing(PathBuf),
26}
27
28impl Display for RepoOpenError {
29    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
30        write!(f, "failed to open git repo: ")?;
31        match self {
32            Self::CurrentDir(err) => {
33                write!(f, "failed to get current dir: {err}")
34            }
35            Self::GitDirMissing(path) => {
36                write!(f, "{} does not exist", path.display())
37            }
38        }
39    }
40}
41
42impl std::error::Error for RepoOpenError {}
43
44/// Git repo.
45#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
46pub struct Repo(PathBuf);
47
48impl Repo {
49    /// Get a `Repo` for the current directory.
50    ///
51    /// This will fail if the current directory does not contain a
52    /// `.git` subdirectory.
53    pub fn open() -> Result<Self, RepoOpenError> {
54        let path = env::current_dir().map_err(RepoOpenError::CurrentDir)?;
55        Self::open_path(path)
56    }
57
58    /// Get a `Repo` for the given path.
59    ///
60    /// This will fail if the `path` does not contain a `.git`
61    /// subdirectory.
62    pub fn open_path<P>(path: P) -> Result<Self, RepoOpenError>
63    where
64        P: Into<PathBuf>,
65    {
66        let path = path.into();
67
68        // Check that this is a git repo. This is just to help fail
69        // quickly if the path is wrong; it isn't checking that the git
70        // repo is valid or anything.
71        //
72        // Also, there are various special types of git checkouts so
73        // it's quite possible this check is wrong for special
74        // circumstances, but for a typical CI release process it should
75        // be fine.
76        let git_dir = path.join(".git");
77        if !git_dir.exists() {
78            return Err(RepoOpenError::GitDirMissing(git_dir));
79        }
80
81        Ok(Self(path))
82    }
83
84    /// Get the repo path.
85    pub fn path(&self) -> &Path {
86        &self.0
87    }
88
89    /// Create a git command with the given args.
90    fn get_git_command<I, S>(&self, args: I) -> Command
91    where
92        I: IntoIterator<Item = S>,
93        S: AsRef<OsStr>,
94    {
95        let mut cmd = Command::new("git");
96        cmd.arg("-C");
97        cmd.arg(self.path());
98        cmd.args(args);
99        cmd
100    }
101
102    /// Get the subject of the commit message for the given commit.
103    pub fn get_commit_message_body(
104        &self,
105        commit_sha: &str,
106    ) -> Result<String, RunCommandError> {
107        let cmd = self.get_git_command([
108            "log",
109            "-1",
110            // Only get the body of the commit message.
111            "--format=format:%b",
112            commit_sha,
113        ]);
114        let output = get_cmd_stdout_utf8(cmd)?;
115        Ok(output)
116    }
117
118    /// Get the subject of the commit message for the given commit.
119    pub fn get_commit_message_subject(
120        &self,
121        commit_sha: &str,
122    ) -> Result<String, RunCommandError> {
123        let cmd = self.get_git_command([
124            "log",
125            "-1",
126            // Only get the subject of the commit message.
127            "--format=format:%s",
128            commit_sha,
129        ]);
130        let output = get_cmd_stdout_utf8(cmd)?;
131        Ok(output)
132    }
133
134    /// Fetch git tags from the remote.
135    pub fn fetch_git_tags(&self) -> Result<(), RunCommandError> {
136        let cmd = self.get_git_command(["fetch", "--tags"]);
137        run_cmd(cmd)?;
138        Ok(())
139    }
140
141    /// Check if a git tag exists locally.
142    ///
143    /// All git tags were fetched at the start of auto-release, so checking locally
144    /// is sufficient.
145    pub fn does_git_tag_exist(
146        &self,
147        tag: &str,
148    ) -> Result<bool, RunCommandError> {
149        let cmd = self.get_git_command(["tag", "--list", tag]);
150        let output = get_cmd_stdout_utf8(cmd)?;
151
152        Ok(output.lines().any(|line| line == tag))
153    }
154
155    /// Create a git tag locally and push it.
156    pub fn make_and_push_git_tag(
157        &self,
158        tag: &str,
159        commit_sha: &str,
160    ) -> Result<(), RunCommandError> {
161        // Create the tag.
162        let cmd = self.get_git_command(["tag", tag, commit_sha]);
163        run_cmd(cmd)?;
164
165        // Push it.
166        let cmd = self.get_git_command(["push", "--tags"]);
167        run_cmd(cmd)?;
168
169        Ok(())
170    }
171}