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 pub commit_date: String,
20 pub commit_timestamp: String,
22 pub previous_tag: Option<String>,
25 pub remote_url: String,
27 pub summary: String,
29 pub tag_subject: String,
31 pub tag_contents: String,
33 pub tag_body: String,
35 pub first_commit: Option<String>,
37}
38
39pub 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
53pub fn detect_git_info_in(cwd: &Path, tag: &str, skip_validate: bool) -> Result<GitInfo> {
59 if !is_git_repo_in(cwd) {
60 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 let remote_url_raw = match git_output_in(cwd, &["ls-remote", "--get-url"]) {
113 Ok(url) => url,
114 Err(e) => {
115 tracing::warn!("git remote URL detection: {e}; remote_url left empty");
120 String::new()
121 }
122 };
123 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 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!("current tag is not semver, skipping validation");
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 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 = Command::new("git")
242 .args(args)
243 .current_dir(dir)
244 .env("GIT_AUTHOR_NAME", "t")
245 .env("GIT_AUTHOR_EMAIL", "t@t.com")
246 .env("GIT_COMMITTER_NAME", "t")
247 .env("GIT_COMMITTER_EMAIL", "t@t.com")
248 .output()
249 .unwrap();
250 assert!(out.status.success(), "git {args:?} failed");
251 };
252 run(&["init"]);
253 run(&["config", "user.email", "t@t.com"]);
254 run(&["config", "user.name", "t"]);
255 std::fs::write(dir.join("a"), "1").unwrap();
256 run(&["add", "."]);
257 run(&["commit", "-m", "c1"]);
258
259 let capture = |args: &[&str]| -> String {
260 let out = Command::new("git")
261 .args(args)
262 .current_dir(dir)
263 .output()
264 .unwrap();
265 assert!(out.status.success(), "git {args:?} failed");
266 String::from_utf8(out.stdout).unwrap().trim().to_string()
267 };
268 let expected_head = capture(&["rev-parse", "HEAD"]);
269 let expected_root = capture(&["rev-list", "--max-parents=0", "HEAD"]);
270
271 let info = detect_git_info_in(dir, "v1.0.0", false).unwrap();
272 assert_eq!(info.commit, expected_head);
273 assert_eq!(info.semver.major, 1);
274 assert_eq!(info.first_commit.as_deref(), Some(expected_root.as_str()));
275 }
276}