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