Skip to main content

anodizer_core/git/
detect.rs

1use anyhow::Result;
2use std::path::Path;
3
4use super::git_output_in;
5use super::semver::{SemVer, parse_semver_tag};
6use super::status::{is_git_dirty_in, is_git_repo_in};
7use super::tags::get_first_commit_in;
8use crate::redact::redact_url_credentials;
9
10/// How the tag carried by a [`GitInfo`] was arrived at.
11///
12/// The two are not interchangeable to a gate that reasons about *which*
13/// version a run targets: an inferred tag is a guess about intent that the
14/// repository's own history can contradict, while a declared one is the
15/// operator stating the target outright.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
17pub enum TagSource {
18    /// Discovered by scanning the repository for the newest ref matching the
19    /// crate's tag template — or the synthetic `v0.0.0` stand-in when the
20    /// repository has no matching tag at all.
21    #[default]
22    Inferred,
23    /// Supplied verbatim by the operator through the current-tag override
24    /// chain (`ANODIZER_CURRENT_TAG`, the `GORELEASER_CURRENT_TAG` compat
25    /// alias, or a tag-push `GITHUB_REF_NAME`).
26    Declared,
27}
28
29#[derive(Debug, Clone)]
30pub struct GitInfo {
31    pub tag: String,
32    /// Whether [`Self::tag`] was declared by the operator or inferred from the
33    /// repository. Defaults to [`TagSource::Inferred`]; the tag-resolution
34    /// layer promotes it to [`TagSource::Declared`] when an override supplied
35    /// the name.
36    pub tag_source: TagSource,
37    pub commit: String,
38    pub short_commit: String,
39    pub branch: String,
40    pub dirty: bool,
41    pub semver: SemVer,
42    /// ISO 8601 committer date of HEAD commit (from `git log -1 --format=%cI`)
43    pub commit_date: String,
44    /// Unix timestamp of HEAD commit (from `git log -1 --format=%at`)
45    pub commit_timestamp: String,
46    /// Previous tag matching the same pattern, if any.
47    /// Populated externally by the release command once the tag_template is known.
48    pub previous_tag: Option<String>,
49    /// Remote URL from `git remote get-url origin`.
50    pub remote_url: String,
51    /// Git describe summary (e.g. `v1.0.0-10-g34f56g3`) from `git describe --tags --always`.
52    pub summary: String,
53    /// Annotated tag subject (first line of tag message) or commit subject.
54    pub tag_subject: String,
55    /// Full annotated tag message or full commit message.
56    pub tag_contents: String,
57    /// Tag message body (everything after first line) or commit message body.
58    pub tag_body: String,
59    /// First commit hash in the repository (for changelog range when no previous tag).
60    pub first_commit: Option<String>,
61}
62
63/// Detect git info for a given tag.
64///
65/// When `skip_validate` is true and the tag is not valid semver, a warning is
66/// logged and a default `SemVer { 0, 0, 0 }` is used instead of returning an error.
67///
68/// When `snapshot` is true and the working directory is not inside a git
69/// repository, a synthetic `GitInfo` is returned (commit/branch/etc. left
70/// empty) so users can run `anodizer release --snapshot` from a fresh tarball
71/// or scratch directory without git ever having been initialized. Outside
72/// snapshot mode, the missing repo bubbles as an error.
73pub fn detect_git_info(tag: &str, skip_validate: bool) -> Result<GitInfo> {
74    detect_git_info_in(&std::env::current_dir()?, tag, skip_validate)
75}
76
77/// Detect git info for a given tag against a repository at `cwd`.
78///
79/// Path-taking sibling of [`detect_git_info`] so callers (tests, library
80/// consumers) can target an explicit repository without mutating the
81/// process-wide cwd.
82pub fn detect_git_info_in(cwd: &Path, tag: &str, skip_validate: bool) -> Result<GitInfo> {
83    if !is_git_repo_in(cwd) {
84        // Synthetic GitInfo for non-repo snapshot/scratch builds. Lets users
85        // run `anodizer release --snapshot` from a fresh tarball or scratch
86        // directory without `git init` first. Caller is responsible for only
87        // accepting this in snapshot/dry-run mode.
88        return Ok(GitInfo {
89            tag: tag.to_string(),
90            tag_source: TagSource::default(),
91            commit: String::new(),
92            short_commit: String::new(),
93            branch: String::new(),
94            dirty: false,
95            semver: SemVer {
96                major: 0,
97                minor: 0,
98                patch: 0,
99                prerelease: None,
100                build_metadata: None,
101            },
102            commit_date: String::new(),
103            commit_timestamp: String::new(),
104            previous_tag: None,
105            remote_url: String::new(),
106            summary: String::new(),
107            tag_subject: String::new(),
108            tag_contents: String::new(),
109            tag_body: String::new(),
110            first_commit: None,
111        });
112    }
113    let commit = git_output_in(cwd, &["rev-parse", "HEAD"])?;
114    let short_commit = git_output_in(cwd, &["rev-parse", "--short", "HEAD"])?;
115    let branch = git_output_in(cwd, &["rev-parse", "--abbrev-ref", "HEAD"]).unwrap_or_default();
116    let dirty = is_git_dirty_in(cwd);
117    let commit_date = git_output_in(
118        cwd,
119        &["-c", "log.showSignature=false", "log", "-1", "--format=%cI"],
120    )
121    .unwrap_or_default();
122    let commit_timestamp = git_output_in(
123        cwd,
124        &["-c", "log.showSignature=false", "log", "-1", "--format=%at"],
125    )
126    .unwrap_or_default();
127    // Use ls-remote --get-url.
128    // Without an explicit remote name this defaults to "origin".
129    //
130    // A truly missing remote (no `origin` configured) is a legitimate state —
131    // local-only repos, fresh `git init` — so detect must not fail.
132    // But a *git error* during this lookup (broken config, transient SSH
133    // failure, permission issue) used to be silently swallowed by
134    // `unwrap_or_default()`, leaving `remote_url=""` with no diagnostic.
135    // Preserve the
136    // underlying error rather than replacing it with an empty sentinel.
137    let remote_url_raw = match git_output_in(cwd, &["ls-remote", "--get-url"]) {
138        Ok(url) => url,
139        Err(e) => {
140            // `e` already begins with `git ls-remote --get-url failed: …`
141            // (from `git_output_in`), so a second "failed" lead-in would
142            // double up; render the wrapped error verbatim with a trailing
143            // note about the empty fallback.
144            tracing::warn!("git remote URL detection: {e}; remote_url left empty");
145            String::new()
146        }
147    };
148    // Strip credentials from URLs of any scheme
149    // (e.g. https://user:token@github.com/... → https://<redacted>@github.com/...).
150    let remote_url = redact_url_credentials(&remote_url_raw);
151    let summary = git_output_in(
152        cwd,
153        &[
154            "-c",
155            "log.showSignature=false",
156            "describe",
157            "--tags",
158            "--always",
159            "--dirty",
160        ],
161    )
162    .unwrap_or_default();
163
164    // Try annotated tag message fields first; fall back to commit message fields.
165    let tag_subject = git_output_in(cwd, &["tag", "-l", "--format=%(contents:subject)", tag])
166        .ok()
167        .filter(|s| !s.is_empty())
168        .unwrap_or_else(|| {
169            git_output_in(
170                cwd,
171                &["-c", "log.showSignature=false", "log", "-1", "--format=%s"],
172            )
173            .unwrap_or_default()
174        });
175    let tag_contents = git_output_in(cwd, &["tag", "-l", "--format=%(contents)", tag])
176        .ok()
177        .filter(|s| !s.is_empty())
178        .unwrap_or_else(|| {
179            git_output_in(
180                cwd,
181                &["-c", "log.showSignature=false", "log", "-1", "--format=%B"],
182            )
183            .unwrap_or_default()
184        });
185    let tag_body = git_output_in(cwd, &["tag", "-l", "--format=%(contents:body)", tag])
186        .ok()
187        .filter(|s| !s.is_empty())
188        .unwrap_or_else(|| {
189            git_output_in(
190                cwd,
191                &["-c", "log.showSignature=false", "log", "-1", "--format=%b"],
192            )
193            .unwrap_or_default()
194        });
195
196    let semver = match parse_semver_tag(tag) {
197        Ok(sv) => sv,
198        Err(e) => {
199            if skip_validate {
200                tracing::warn!("skipped validation — current tag is not semver");
201                SemVer {
202                    major: 0,
203                    minor: 0,
204                    patch: 0,
205                    prerelease: None,
206                    build_metadata: None,
207                }
208            } else {
209                return Err(e);
210            }
211        }
212    };
213    let first_commit = get_first_commit_in(cwd).ok();
214    Ok(GitInfo {
215        tag: tag.to_string(),
216        tag_source: TagSource::default(),
217        commit,
218        short_commit,
219        branch,
220        dirty,
221        semver,
222        commit_date,
223        commit_timestamp,
224        previous_tag: None,
225        remote_url,
226        summary,
227        tag_subject,
228        tag_contents,
229        tag_body,
230        first_commit,
231    })
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237
238    #[test]
239    fn detect_git_info_in_bails_outside_repo_when_tag_not_semver() {
240        let tmp = tempfile::tempdir().unwrap();
241        // Non-repo path returns synthetic GitInfo with default SemVer, so
242        // a non-semver tag is harmless. To force the error path, pass a
243        // valid temp dir but with a non-semver tag AND a repo that exists.
244        // Here cover the synthetic path: non-repo + arbitrary tag is Ok.
245        let info = detect_git_info_in(tmp.path(), "v1.0.0", false).unwrap();
246        assert_eq!(info.commit, "");
247        assert_eq!(info.branch, "");
248        assert_eq!(info.semver.major, 0);
249    }
250
251    #[test]
252    fn detect_git_info_in_returns_synthetic_for_non_repo() {
253        let tmp = tempfile::tempdir().unwrap();
254        let info = detect_git_info_in(tmp.path(), "v9.9.9", true).unwrap();
255        assert_eq!(info.tag, "v9.9.9");
256        assert_eq!(info.commit, "");
257        assert!(info.first_commit.is_none());
258    }
259
260    #[test]
261    fn detect_git_info_in_resolves_head_inside_real_repo() {
262        use std::process::Command;
263
264        let tmp = tempfile::tempdir().unwrap();
265        let dir = tmp.path();
266        let run = |args: &[&str]| {
267            let out = anodizer_core::test_helpers::output_with_spawn_retry(
268                || {
269                    let mut cmd = Command::new("git");
270                    cmd.args(args)
271                        .current_dir(dir)
272                        .env("GIT_AUTHOR_NAME", "t")
273                        .env("GIT_AUTHOR_EMAIL", "t@t.com")
274                        .env("GIT_COMMITTER_NAME", "t")
275                        .env("GIT_COMMITTER_EMAIL", "t@t.com");
276                    cmd
277                },
278                "git",
279            );
280            assert!(out.status.success(), "git {args:?} failed");
281        };
282        run(&["init"]);
283        run(&["config", "user.email", "t@t.com"]);
284        run(&["config", "user.name", "t"]);
285        std::fs::write(dir.join("a"), "1").unwrap();
286        run(&["add", "."]);
287        run(&["commit", "-m", "c1"]);
288
289        let capture = |args: &[&str]| -> String {
290            let out = anodizer_core::test_helpers::output_with_spawn_retry(
291                || {
292                    let mut cmd = Command::new("git");
293                    cmd.args(args).current_dir(dir);
294                    cmd
295                },
296                "git",
297            );
298            assert!(out.status.success(), "git {args:?} failed");
299            String::from_utf8(out.stdout).unwrap().trim().to_string()
300        };
301        let expected_head = capture(&["rev-parse", "HEAD"]);
302        let expected_root = capture(&["rev-list", "--max-parents=0", "HEAD"]);
303
304        let info = detect_git_info_in(dir, "v1.0.0", false).unwrap();
305        assert_eq!(info.commit, expected_head);
306        assert_eq!(info.semver.major, 1);
307        assert_eq!(info.first_commit.as_deref(), Some(expected_root.as_str()));
308    }
309}