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, Copy, PartialEq, Eq, Default)]
17pub enum TagSource {
18 #[default]
22 Inferred,
23 Declared,
27}
28
29#[derive(Debug, Clone)]
30pub struct GitInfo {
31 pub tag: String,
32 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 pub commit_date: String,
44 pub commit_timestamp: String,
46 pub previous_tag: Option<String>,
49 pub remote_url: String,
51 pub summary: String,
53 pub tag_subject: String,
55 pub tag_contents: String,
57 pub tag_body: String,
59 pub first_commit: Option<String>,
61}
62
63pub 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
77pub fn detect_git_info_in(cwd: &Path, tag: &str, skip_validate: bool) -> Result<GitInfo> {
83 if !is_git_repo_in(cwd) {
84 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 let remote_url_raw = match git_output_in(cwd, &["ls-remote", "--get-url"]) {
138 Ok(url) => url,
139 Err(e) => {
140 tracing::warn!("git remote URL detection: {e}; remote_url left empty");
145 String::new()
146 }
147 };
148 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 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 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}