git_prole/git/refs/
mod.rs

1use std::fmt::Debug;
2use std::str::FromStr;
3
4use command_error::CommandExt;
5use command_error::OutputContext;
6use miette::miette;
7use miette::Context;
8use tap::Tap;
9use tracing::instrument;
10use utf8_command::Utf8Output;
11
12use super::commit_hash::CommitHash;
13use super::commitish::ResolvedCommitish;
14use super::head_state::HeadKind;
15use super::GitLike;
16
17mod branch;
18mod local_branch;
19mod name;
20mod remote_branch;
21
22pub use branch::BranchRef;
23pub use local_branch::LocalBranchRef;
24pub use name::Ref;
25pub use remote_branch::RemoteBranchRef;
26
27/// Git methods for dealing with refs.
28#[repr(transparent)]
29pub struct GitRefs<'a, G>(&'a G);
30
31impl<G> Debug for GitRefs<'_, G>
32where
33    G: GitLike,
34{
35    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36        f.debug_tuple("GitRefs")
37            .field(&self.0.get_current_dir().as_ref())
38            .finish()
39    }
40}
41
42impl<'a, G> GitRefs<'a, G>
43where
44    G: GitLike,
45{
46    pub fn new(git: &'a G) -> Self {
47        Self(git)
48    }
49
50    #[expect(dead_code)] // #[instrument(level = "trace")]
51    pub(crate) fn commit_message(&self, commit: &str) -> miette::Result<String> {
52        Ok(self
53            .0
54            .command()
55            .args(["show", "--no-patch", "--format=%B", commit])
56            .output_checked_utf8()
57            .wrap_err("Failed to get commit message")?
58            .stdout)
59    }
60
61    /// Get the `HEAD` commit hash.
62    #[instrument(level = "trace")]
63    pub fn get_head(&self) -> miette::Result<CommitHash> {
64        Ok(self.parse("HEAD")?.expect("HEAD always exists"))
65    }
66
67    /// Parse a `commitish` into a commit hash.
68    #[instrument(level = "trace")]
69    pub fn parse(&self, commitish: &str) -> miette::Result<Option<CommitHash>> {
70        Ok(self
71            .0
72            .as_git()
73            .rev_parse_command()
74            .args(["--verify", "--quiet", "--end-of-options", commitish])
75            .output_checked_as(|context: OutputContext<Utf8Output>| {
76                if context.status().success() {
77                    Ok::<_, command_error::Error>(Some(CommitHash::new(
78                        context.output().stdout.trim().to_owned(),
79                    )))
80                } else {
81                    Ok(None)
82                }
83            })?)
84    }
85
86    /// `git rev-parse --symbolic-full-name`
87    #[instrument(level = "trace")]
88    pub fn rev_parse_symbolic_full_name(&self, commitish: &str) -> miette::Result<Option<Ref>> {
89        Ok(self
90            .0
91            .as_git()
92            .rev_parse_command()
93            .args([
94                "--symbolic-full-name",
95                "--verify",
96                "--quiet",
97                "--end-of-options",
98                commitish,
99            ])
100            .output_checked_as(|context: OutputContext<Utf8Output>| {
101                if context.status().success() {
102                    let trimmed = context.output().stdout.trim();
103                    if trimmed.is_empty() {
104                        Ok(None)
105                    } else {
106                        match Ref::from_str(trimmed) {
107                            Ok(parsed) => Ok(Some(parsed)),
108                            Err(err) => {
109                                if commitish.ends_with("HEAD") && trimmed == commitish {
110                                    tracing::debug!("{commitish} is detached");
111                                    Ok(None)
112                                } else {
113                                    Err(context.error_msg(err))
114                                }
115                            }
116                        }
117                    }
118                } else {
119                    Ok(None)
120                }
121            })?)
122    }
123
124    /// Determine if a given `<commit-ish>` refers to a commit or a symbolic ref name.
125    #[instrument(level = "trace")]
126    pub fn resolve_commitish(&self, commitish: &str) -> miette::Result<ResolvedCommitish> {
127        match self.rev_parse_symbolic_full_name(commitish)? {
128            Some(ref_name) => Ok(ResolvedCommitish::Ref(ref_name)),
129            None => Ok(ResolvedCommitish::Commit(
130                self.parse(commitish)?.ok_or_else(|| {
131                    miette!("Commitish could not be resolved to a ref or commit hash: {commitish}")
132                })?,
133            )),
134        }
135    }
136
137    #[instrument(level = "trace")]
138    pub fn is_head_detached(&self) -> miette::Result<bool> {
139        let output = self
140            .0
141            .command()
142            .args(["symbolic-ref", "--quiet", "HEAD"])
143            .output_checked_with_utf8::<String>(|_output| Ok(()))?;
144
145        Ok(!output.status.success())
146    }
147
148    /// Figure out what's going on with `HEAD`.
149    #[instrument(level = "trace")]
150    pub fn head_kind(&self) -> miette::Result<HeadKind> {
151        Ok(if self.is_head_detached()? {
152            HeadKind::Detached(self.get_head()?)
153        } else {
154            HeadKind::Branch(
155                LocalBranchRef::try_from(
156                    self.rev_parse_symbolic_full_name("HEAD")?
157                        .expect("Non-detached HEAD should always be a valid ref"),
158                )
159                .expect("Non-detached HEAD should always be a local branch"),
160            )
161        })
162    }
163
164    #[instrument(level = "trace")]
165    pub fn for_each_ref(&self, globs: Option<&[&str]>) -> miette::Result<Vec<Ref>> {
166        self.0
167            .command()
168            .args(["for-each-ref", "--format=%(refname)"])
169            .tap_mut(|c| {
170                globs.map(|globs| c.args(globs));
171            })
172            .output_checked_utf8()?
173            .stdout
174            .lines()
175            .map(Ref::from_str)
176            .collect()
177    }
178}