Skip to main content

anodizer_core/git/
detect.rs

1use anyhow::Result;
2
3use super::git_output;
4use super::semver::{SemVer, parse_semver_tag};
5use super::status::{is_git_dirty, is_git_repo};
6use super::tags::get_first_commit;
7use crate::redact::redact_url_credentials;
8
9#[derive(Debug, Clone)]
10pub struct GitInfo {
11    pub tag: String,
12    pub commit: String,
13    pub short_commit: String,
14    pub branch: String,
15    pub dirty: bool,
16    pub semver: SemVer,
17    /// ISO 8601 committer date of HEAD commit (from `git log -1 --format=%cI`)
18    pub commit_date: String,
19    /// Unix timestamp of HEAD commit (from `git log -1 --format=%at`)
20    pub commit_timestamp: String,
21    /// Previous tag matching the same pattern, if any.
22    /// Populated externally by the release command once the tag_template is known.
23    pub previous_tag: Option<String>,
24    /// Remote URL from `git remote get-url origin`.
25    pub remote_url: String,
26    /// Git describe summary (e.g. `v1.0.0-10-g34f56g3`) from `git describe --tags --always`.
27    pub summary: String,
28    /// Annotated tag subject (first line of tag message) or commit subject.
29    pub tag_subject: String,
30    /// Full annotated tag message or full commit message.
31    pub tag_contents: String,
32    /// Tag message body (everything after first line) or commit message body.
33    pub tag_body: String,
34    /// First commit hash in the repository (for changelog range when no previous tag).
35    pub first_commit: Option<String>,
36}
37
38/// Detect git info for a given tag.
39///
40/// When `skip_validate` is true and the tag is not valid semver, a warning is
41/// logged and a default `SemVer { 0, 0, 0 }` is used instead of returning an error.
42///
43/// When `snapshot` is true and the working directory is not inside a git
44/// repository, a synthetic `GitInfo` is returned (commit/branch/etc. left
45/// empty) so users can run `anodizer release --snapshot` from a fresh tarball
46/// or scratch directory without git ever having been initialized. Outside
47/// snapshot mode, the missing repo bubbles as an error.
48pub fn detect_git_info(tag: &str, skip_validate: bool) -> Result<GitInfo> {
49    if !is_git_repo() {
50        // Synthetic GitInfo for non-repo snapshot/scratch builds. Lets users
51        // run `anodizer release --snapshot` from a fresh tarball or scratch
52        // directory without `git init` first. Caller is responsible for only
53        // accepting this in snapshot/dry-run mode.
54        return Ok(GitInfo {
55            tag: tag.to_string(),
56            commit: String::new(),
57            short_commit: String::new(),
58            branch: String::new(),
59            dirty: false,
60            semver: SemVer {
61                major: 0,
62                minor: 0,
63                patch: 0,
64                prerelease: None,
65                build_metadata: None,
66            },
67            commit_date: String::new(),
68            commit_timestamp: String::new(),
69            previous_tag: None,
70            remote_url: String::new(),
71            summary: String::new(),
72            tag_subject: String::new(),
73            tag_contents: String::new(),
74            tag_body: String::new(),
75            first_commit: None,
76        });
77    }
78    let commit = git_output(&["rev-parse", "HEAD"])?;
79    let short_commit = git_output(&["rev-parse", "--short", "HEAD"])?;
80    let branch = git_output(&["rev-parse", "--abbrev-ref", "HEAD"]).unwrap_or_default();
81    let dirty = is_git_dirty();
82    let commit_date = git_output(&["-c", "log.showSignature=false", "log", "-1", "--format=%cI"])
83        .unwrap_or_default();
84    let commit_timestamp =
85        git_output(&["-c", "log.showSignature=false", "log", "-1", "--format=%at"])
86            .unwrap_or_default();
87    // Use ls-remote --get-url (matches GoReleaser git.go:355).
88    // Without an explicit remote name this defaults to "origin".
89    //
90    // A truly missing remote (no `origin` configured) is a legitimate state —
91    // local-only repos, fresh `git init` — so we don't want to fail detect.
92    // But a *git error* during this lookup (broken config, transient SSH
93    // failure, permission issue) used to be silently swallowed by
94    // `unwrap_or_default()`, leaving `remote_url=""` with no diagnostic.
95    // Mirrors the spirit of GoReleaser commit 5042b84 (Q12): preserve the
96    // underlying error rather than replacing it with an empty sentinel.
97    let remote_url_raw = match git_output(&["ls-remote", "--get-url"]) {
98        Ok(url) => url,
99        Err(e) => {
100            tracing::warn!(
101                error = %e,
102                "git ls-remote --get-url failed; remote_url left empty"
103            );
104            String::new()
105        }
106    };
107    // Strip credentials from URLs of any scheme
108    // (e.g. https://user:token@github.com/... → https://<redacted>@github.com/...).
109    let remote_url = redact_url_credentials(&remote_url_raw);
110    let summary = git_output(&[
111        "-c",
112        "log.showSignature=false",
113        "describe",
114        "--tags",
115        "--always",
116        "--dirty",
117    ])
118    .unwrap_or_default();
119
120    // Try annotated tag message fields first; fall back to commit message fields.
121    let tag_subject = git_output(&["tag", "-l", "--format=%(contents:subject)", tag])
122        .ok()
123        .filter(|s| !s.is_empty())
124        .unwrap_or_else(|| {
125            git_output(&["-c", "log.showSignature=false", "log", "-1", "--format=%s"])
126                .unwrap_or_default()
127        });
128    let tag_contents = git_output(&["tag", "-l", "--format=%(contents)", tag])
129        .ok()
130        .filter(|s| !s.is_empty())
131        .unwrap_or_else(|| {
132            git_output(&["-c", "log.showSignature=false", "log", "-1", "--format=%B"])
133                .unwrap_or_default()
134        });
135    let tag_body = git_output(&["tag", "-l", "--format=%(contents:body)", tag])
136        .ok()
137        .filter(|s| !s.is_empty())
138        .unwrap_or_else(|| {
139            git_output(&["-c", "log.showSignature=false", "log", "-1", "--format=%b"])
140                .unwrap_or_default()
141        });
142
143    let semver = match parse_semver_tag(tag) {
144        Ok(sv) => sv,
145        Err(e) => {
146            if skip_validate {
147                tracing::warn!("current tag is not semver, skipping validation");
148                SemVer {
149                    major: 0,
150                    minor: 0,
151                    patch: 0,
152                    prerelease: None,
153                    build_metadata: None,
154                }
155            } else {
156                return Err(e);
157            }
158        }
159    };
160    let first_commit = get_first_commit().ok();
161    Ok(GitInfo {
162        tag: tag.to_string(),
163        commit,
164        short_commit,
165        branch,
166        dirty,
167        semver,
168        commit_date,
169        commit_timestamp,
170        previous_tag: None,
171        remote_url,
172        summary,
173        tag_subject,
174        tag_contents,
175        tag_body,
176        first_commit,
177    })
178}