1use crate::build::{ConstType, ConstVal, ShadowConst};
2use crate::ci::CiType;
3use crate::err::*;
4use crate::{DateTime, Format};
5use std::collections::BTreeMap;
6use std::io::{BufReader, Read};
7use std::path::Path;
8use std::process::{Command, Stdio};
9
10const BRANCH_DOC: &str = r#"
11The name of the Git branch that this project was built from.
12This constant will be empty if the branch cannot be determined."#;
13pub const BRANCH: ShadowConst = "BRANCH";
14const TAG_DOC: &str = r#"
15The name of the Git tag that this project was built from.
16Note that this will be empty if there is no tag for the HEAD at the time of build."#;
17pub const TAG: ShadowConst = "TAG";
18const LAST_TAG_DOC: &str = r#"
19The name of the last Git tag on the branch that this project was built from.
20As opposed to [`TAG`], this does not require the current commit to be tagged, just one of its parents.
21
22This constant will be empty if the last tag cannot be determined."#;
23pub const LAST_TAG: ShadowConst = "LAST_TAG";
24const SHORT_COMMIT_DOC: &str = r#"
25The short hash of the Git commit that this project was built from.
26Note that this will always truncate [`COMMIT_HASH`] to 8 characters if necessary.
27Depending on the amount of commits in your project, this may not yield a unique Git identifier
28([see here for more details on hash abbreviation](https://git-scm.com/docs/git-describe#_examples)).
29
30This constant will be empty if the last commit cannot be determined."#;
31pub const SHORT_COMMIT: ShadowConst = "SHORT_COMMIT";
32const COMMIT_HASH_DOC: &str = r#"
33The full commit hash of the Git commit that this project was built from.
34An abbreviated, but not necessarily unique, version of this is [`SHORT_COMMIT`].
35
36This constant will be empty if the last commit cannot be determined."#;
37pub const COMMIT_HASH: ShadowConst = "COMMIT_HASH";
38const COMMIT_DATE_DOC: &str = r#"The time of the Git commit that this project was built from.
39The time is formatted in modified ISO 8601 format (`YYYY-MM-DD HH-MM ±hh-mm` where hh-mm is the offset from UTC).
40
41This constant will be empty if the last commit cannot be determined."#;
42pub const COMMIT_DATE: ShadowConst = "COMMIT_DATE";
43const COMMIT_DATE_2822_DOC: &str = r#"
44The name of the Git branch that this project was built from.
45The time is formatted according to [RFC 2822](https://datatracker.ietf.org/doc/html/rfc2822#section-3.3) (e.g. HTTP Headers).
46
47This constant will be empty if the last commit cannot be determined."#;
48pub const COMMIT_DATE_2822: ShadowConst = "COMMIT_DATE_2822";
49const COMMIT_DATE_3339_DOC: &str = r#"
50The name of the Git branch that this project was built from.
51The time is formatted according to [RFC 3339 and ISO 8601](https://datatracker.ietf.org/doc/html/rfc3339#section-5.6).
52
53This constant will be empty if the last commit cannot be determined."#;
54pub const COMMIT_DATE_3339: ShadowConst = "COMMIT_DATE_3339";
55const COMMIT_AUTHOR_DOC: &str = r#"
56The author of the Git commit that this project was built from.
57
58This constant will be empty if the last commit cannot be determined."#;
59pub const COMMIT_AUTHOR: ShadowConst = "COMMIT_AUTHOR";
60const COMMIT_EMAIL_DOC: &str = r#"
61The e-mail address of the author of the Git commit that this project was built from.
62
63This constant will be empty if the last commit cannot be determined."#;
64pub const COMMIT_EMAIL: ShadowConst = "COMMIT_EMAIL";
65const GIT_CLEAN_DOC: &str = r#"
66Whether the Git working tree was clean at the time of project build (`true`), or not (`false`).
67
68This constant will be `false` if the last commit cannot be determined."#;
69pub const GIT_CLEAN: ShadowConst = "GIT_CLEAN";
70const GIT_STATUS_FILE_DOC: &str = r#"
71The Git working tree status as a list of files with their status, similar to `git status`.
72Each line of the list is preceded with ` * `, followed by the file name.
73Files marked `(dirty)` have unstaged changes.
74Files marked `(staged)` have staged changes.
75
76This constant will be empty if the working tree status cannot be determined."#;
77pub const GIT_STATUS_FILE: ShadowConst = "GIT_STATUS_FILE";
78
79#[derive(Default, Debug)]
80pub struct Git {
81 map: BTreeMap<ShadowConst, ConstVal>,
82 ci_type: CiType,
83}
84
85impl Git {
86 fn update_str(&mut self, c: ShadowConst, v: String) {
87 if let Some(val) = self.map.get_mut(c) {
88 *val = ConstVal {
89 desc: val.desc.clone(),
90 v,
91 t: ConstType::Str,
92 }
93 }
94 }
95
96 fn update_bool(&mut self, c: ShadowConst, v: bool) {
97 if let Some(val) = self.map.get_mut(c) {
98 *val = ConstVal {
99 desc: val.desc.clone(),
100 v: v.to_string(),
101 t: ConstType::Bool,
102 }
103 }
104 }
105
106 fn init(&mut self, path: &Path, std_env: &BTreeMap<String, String>) -> SdResult<()> {
107 if let Err(err) = self.init_git() {
109 println!("{err}");
110 }
111
112 self.init_git2(path)?;
114
115 if let Some(x) = find_branch_in(path) {
117 self.update_str(BRANCH, x)
118 };
119
120 if let Some(x) = command_current_tag() {
122 self.update_str(TAG, x)
123 }
124
125 if let Some(x) = command_last_tag() {
127 self.update_str(LAST_TAG, x)
128 }
129
130 self.ci_branch_tag(std_env);
132 Ok(())
133 }
134
135 fn init_git(&mut self) -> SdResult<()> {
136 let x = command_git_clean();
138 self.update_bool(GIT_CLEAN, x);
139
140 let x = command_git_status_file();
141 self.update_str(GIT_STATUS_FILE, x);
142
143 let git_info = command_git_head();
144
145 self.update_str(COMMIT_EMAIL, git_info.email);
146 self.update_str(COMMIT_AUTHOR, git_info.author);
147 self.update_str(SHORT_COMMIT, git_info.short_commit);
148 self.update_str(COMMIT_HASH, git_info.commit);
149
150 let time_stamp = git_info.date.parse::<i64>()?;
151 if let Ok(date_time) = DateTime::timestamp_2_utc(time_stamp) {
152 self.update_str(COMMIT_DATE, date_time.human_format());
153 self.update_str(COMMIT_DATE_2822, date_time.to_rfc2822());
154 self.update_str(COMMIT_DATE_3339, date_time.to_rfc3339());
155 }
156
157 Ok(())
158 }
159
160 #[allow(unused_variables)]
161 fn init_git2(&mut self, path: &Path) -> SdResult<()> {
162 #[cfg(feature = "git2")]
163 {
164 use crate::date_time::DateTime;
165 use crate::git::git2_mod::git_repo;
166 use crate::Format;
167
168 let repo = git_repo(path).map_err(ShadowError::new)?;
169 let reference = repo.head().map_err(ShadowError::new)?;
170
171 let branch = reference
173 .shorthand()
174 .map(|x| x.trim().to_string())
175 .or_else(command_current_branch)
176 .unwrap_or_default();
177
178 let tag = command_current_tag().unwrap_or_default();
180 let last_tag = command_last_tag().unwrap_or_default();
181 self.update_str(BRANCH, branch);
182 self.update_str(TAG, tag);
183 self.update_str(LAST_TAG, last_tag);
184
185 if let Some(v) = reference.target() {
186 let commit = v.to_string();
187 self.update_str(COMMIT_HASH, commit.clone());
188 let mut short_commit = commit.as_str();
189
190 if commit.len() > 8 {
191 short_commit = short_commit.get(0..8).unwrap();
192 }
193 self.update_str(SHORT_COMMIT, short_commit.to_string());
194 }
195
196 let commit = reference.peel_to_commit().map_err(ShadowError::new)?;
197
198 let author = commit.author();
199 if let Some(v) = author.email() {
200 self.update_str(COMMIT_EMAIL, v.to_string());
201 }
202
203 if let Some(v) = author.name() {
204 self.update_str(COMMIT_AUTHOR, v.to_string());
205 }
206 let status_file = Self::git2_dirty_stage(&repo);
207 if status_file.trim().is_empty() {
208 self.update_bool(GIT_CLEAN, true);
209 } else {
210 self.update_bool(GIT_CLEAN, false);
211 }
212 self.update_str(GIT_STATUS_FILE, status_file);
213
214 let time_stamp = commit.time().seconds().to_string().parse::<i64>()?;
215 if let Ok(date_time) = DateTime::timestamp_2_utc(time_stamp) {
216 self.update_str(COMMIT_DATE, date_time.human_format());
217
218 self.update_str(COMMIT_DATE_2822, date_time.to_rfc2822());
219
220 self.update_str(COMMIT_DATE_3339, date_time.to_rfc3339());
221 }
222 }
223 Ok(())
224 }
225
226 #[cfg(feature = "git2")]
228 pub fn git2_dirty_stage(repo: &git2::Repository) -> String {
229 let mut repo_opts = git2::StatusOptions::new();
230 repo_opts.include_ignored(false);
231 if let Ok(statue) = repo.statuses(Some(&mut repo_opts)) {
232 let mut dirty_files = Vec::new();
233 let mut staged_files = Vec::new();
234
235 for status in statue.iter() {
236 if let Some(path) = status.path() {
237 match status.status() {
238 git2::Status::CURRENT => (),
239 git2::Status::INDEX_NEW
240 | git2::Status::INDEX_MODIFIED
241 | git2::Status::INDEX_DELETED
242 | git2::Status::INDEX_RENAMED
243 | git2::Status::INDEX_TYPECHANGE => staged_files.push(path.to_string()),
244 _ => dirty_files.push(path.to_string()),
245 };
246 }
247 }
248 filter_git_dirty_stage(dirty_files, staged_files)
249 } else {
250 "".into()
251 }
252 }
253
254 #[allow(clippy::manual_strip)]
255 fn ci_branch_tag(&mut self, std_env: &BTreeMap<String, String>) {
256 let mut branch: Option<String> = None;
257 let mut tag: Option<String> = None;
258 match self.ci_type {
259 CiType::Gitlab => {
260 if let Some(v) = std_env.get("CI_COMMIT_TAG") {
261 tag = Some(v.to_string());
262 } else if let Some(v) = std_env.get("CI_COMMIT_REF_NAME") {
263 branch = Some(v.to_string());
264 }
265 }
266 CiType::Github => {
267 if let Some(v) = std_env.get("GITHUB_REF") {
268 let ref_branch_prefix: &str = "refs/heads/";
269 let ref_tag_prefix: &str = "refs/tags/";
270
271 if v.starts_with(ref_branch_prefix) {
272 branch = Some(
273 v.get(ref_branch_prefix.len()..)
274 .unwrap_or_default()
275 .to_string(),
276 )
277 } else if v.starts_with(ref_tag_prefix) {
278 tag = Some(
279 v.get(ref_tag_prefix.len()..)
280 .unwrap_or_default()
281 .to_string(),
282 )
283 }
284 }
285 }
286 _ => {}
287 }
288 if let Some(x) = branch {
289 self.update_str(BRANCH, x);
290 }
291
292 if let Some(x) = tag {
293 self.update_str(TAG, x.clone());
294 self.update_str(LAST_TAG, x);
295 }
296 }
297}
298
299pub(crate) fn new_git(
300 path: &Path,
301 ci: CiType,
302 std_env: &BTreeMap<String, String>,
303) -> BTreeMap<ShadowConst, ConstVal> {
304 let mut git = Git {
305 map: Default::default(),
306 ci_type: ci,
307 };
308 git.map.insert(BRANCH, ConstVal::new(BRANCH_DOC));
309
310 git.map.insert(TAG, ConstVal::new(TAG_DOC));
311
312 git.map.insert(LAST_TAG, ConstVal::new(LAST_TAG_DOC));
313
314 git.map.insert(COMMIT_HASH, ConstVal::new(COMMIT_HASH_DOC));
315
316 git.map
317 .insert(SHORT_COMMIT, ConstVal::new(SHORT_COMMIT_DOC));
318
319 git.map
320 .insert(COMMIT_AUTHOR, ConstVal::new(COMMIT_AUTHOR_DOC));
321 git.map
322 .insert(COMMIT_EMAIL, ConstVal::new(COMMIT_EMAIL_DOC));
323 git.map.insert(COMMIT_DATE, ConstVal::new(COMMIT_DATE_DOC));
324
325 git.map
326 .insert(COMMIT_DATE_2822, ConstVal::new(COMMIT_DATE_2822_DOC));
327
328 git.map
329 .insert(COMMIT_DATE_3339, ConstVal::new(COMMIT_DATE_3339_DOC));
330
331 git.map.insert(GIT_CLEAN, ConstVal::new_bool(GIT_CLEAN_DOC));
332
333 git.map
334 .insert(GIT_STATUS_FILE, ConstVal::new(GIT_STATUS_FILE_DOC));
335
336 if let Err(e) = git.init(path, std_env) {
337 println!("{e}");
338 }
339
340 git.map
341}
342
343#[cfg(feature = "git2")]
344pub mod git2_mod {
345 use git2::Error as git2Error;
346 use git2::Repository;
347 use std::path::Path;
348
349 pub fn git_repo<P: AsRef<Path>>(path: P) -> Result<Repository, git2Error> {
350 Repository::discover(path)
351 }
352
353 pub fn git2_current_branch(repo: &Repository) -> Option<String> {
354 repo.head()
355 .map(|x| x.shorthand().map(|x| x.to_string()))
356 .unwrap_or(None)
357 }
358}
359
360pub fn branch() -> String {
367 #[cfg(feature = "git2")]
368 {
369 use crate::git::git2_mod::{git2_current_branch, git_repo};
370 git_repo(".")
371 .map(|x| git2_current_branch(&x))
372 .unwrap_or_else(|_| command_current_branch())
373 .unwrap_or_default()
374 }
375 #[cfg(not(feature = "git2"))]
376 {
377 command_current_branch().unwrap_or_default()
378 }
379}
380
381pub fn tag() -> String {
386 command_current_tag().unwrap_or_default()
387}
388
389pub fn git_clean() -> bool {
393 #[cfg(feature = "git2")]
394 {
395 use crate::git::git2_mod::git_repo;
396 git_repo(".")
397 .map(|x| Git::git2_dirty_stage(&x))
398 .map(|x| x.trim().is_empty())
399 .unwrap_or(true)
400 }
401 #[cfg(not(feature = "git2"))]
402 {
403 command_git_clean()
404 }
405}
406
407pub fn git_status_file() -> String {
413 #[cfg(feature = "git2")]
414 {
415 use crate::git::git2_mod::git_repo;
416 git_repo(".")
417 .map(|x| Git::git2_dirty_stage(&x))
418 .unwrap_or_default()
419 }
420 #[cfg(not(feature = "git2"))]
421 {
422 command_git_status_file()
423 }
424}
425
426struct GitHeadInfo {
427 commit: String,
428 short_commit: String,
429 email: String,
430 author: String,
431 date: String,
432}
433
434struct GitCommandExecutor<'a> {
435 path: &'a Path,
436}
437
438impl Default for GitCommandExecutor<'_> {
439 fn default() -> Self {
440 Self::new(Path::new("."))
441 }
442}
443
444impl<'a> GitCommandExecutor<'a> {
445 fn new(path: &'a Path) -> Self {
446 GitCommandExecutor { path }
447 }
448
449 fn exec(&self, args: &[&str]) -> Option<String> {
450 Command::new("git")
451 .current_dir(self.path)
452 .args(args)
453 .output()
454 .map(|x| {
455 String::from_utf8(x.stdout)
456 .map(|x| x.trim().to_string())
457 .ok()
458 })
459 .unwrap_or(None)
460 }
461}
462
463fn command_git_head() -> GitHeadInfo {
464 let cli = |args: &[&str]| GitCommandExecutor::default().exec(args).unwrap_or_default();
465 GitHeadInfo {
466 commit: cli(&["rev-parse", "HEAD"]),
467 short_commit: cli(&["rev-parse", "--short", "HEAD"]),
468 author: cli(&["log", "-1", "--pretty=format:%an"]),
469 email: cli(&["log", "-1", "--pretty=format:%ae"]),
470 date: cli(&["show", "--pretty=format:%ct", "--date=raw", "-s"]),
471 }
472}
473
474fn command_current_tag() -> Option<String> {
476 GitCommandExecutor::default().exec(&["tag", "-l", "--contains", "HEAD"])
477}
478
479fn command_last_tag() -> Option<String> {
482 GitCommandExecutor::default().exec(&["describe", "--tags", "--abbrev=0", "HEAD"])
483}
484
485fn command_git_clean() -> bool {
488 GitCommandExecutor::default()
489 .exec(&["status", "--porcelain"])
490 .map(|x| x.is_empty())
491 .unwrap_or(true)
492}
493
494fn command_git_status_file() -> String {
498 let git_status_files =
499 move |args: &[&str], grep: &[&str], awk: &[&str]| -> SdResult<Vec<String>> {
500 let git_shell = Command::new("git")
501 .args(args)
502 .stdin(Stdio::piped())
503 .stdout(Stdio::piped())
504 .spawn()?;
505 let git_out = git_shell.stdout.ok_or("Failed to exec git stdout")?;
506
507 let grep_shell = Command::new("grep")
508 .args(grep)
509 .stdin(Stdio::from(git_out))
510 .stdout(Stdio::piped())
511 .spawn()?;
512 let grep_out = grep_shell.stdout.ok_or("Failed to exec grep stdout")?;
513
514 let mut awk_shell = Command::new("awk")
515 .args(awk)
516 .stdin(Stdio::from(grep_out))
517 .stdout(Stdio::piped())
518 .spawn()?;
519 let mut awk_out = BufReader::new(
520 awk_shell
521 .stdout
522 .as_mut()
523 .ok_or("Failed to exec awk stdout")?,
524 );
525 let mut line = String::new();
526 awk_out.read_to_string(&mut line)?;
527 Ok(line.lines().map(|x| x.into()).collect())
528 };
529
530 let dirty = git_status_files(&["status", "--porcelain"], &[r"^\sM."], &["{print $2}"])
531 .unwrap_or_default();
532
533 let stage = git_status_files(
534 &["status", "--porcelain", "--untracked-files=all"],
535 &[r#"^[A|M|D|R]"#],
536 &["{print $2}"],
537 )
538 .unwrap_or_default();
539 filter_git_dirty_stage(dirty, stage)
540}
541
542fn command_current_branch() -> Option<String> {
544 find_branch_in(Path::new("."))
545}
546
547fn find_branch_in(path: &Path) -> Option<String> {
548 GitCommandExecutor::new(path).exec(&["symbolic-ref", "--short", "HEAD"])
549}
550
551fn filter_git_dirty_stage(dirty_files: Vec<String>, staged_files: Vec<String>) -> String {
552 let mut concat_file = String::new();
553 for file in dirty_files {
554 concat_file.push_str(" * ");
555 concat_file.push_str(&file);
556 concat_file.push_str(" (dirty)\n");
557 }
558 for file in staged_files {
559 concat_file.push_str(" * ");
560 concat_file.push_str(&file);
561 concat_file.push_str(" (staged)\n");
562 }
563 concat_file
564}
565
566#[cfg(test)]
567mod tests {
568 use super::*;
569 use crate::get_std_env;
570
571 #[test]
572 fn test_git() {
573 let env_map = get_std_env();
574 let map = new_git(Path::new("./"), CiType::Github, &env_map);
575 for (k, v) in map {
576 println!("k:{},v:{:?}", k, v);
577 assert!(!v.desc.is_empty());
578 if !k.eq(TAG) && !k.eq(LAST_TAG) && !k.eq(BRANCH) && !k.eq(GIT_STATUS_FILE) {
579 assert!(!v.v.is_empty());
580 continue;
581 }
582
583 if let Some(github_ref) = env_map.get("GITHUB_REF") {
585 if github_ref.starts_with("refs/tags/") && k.eq(TAG) {
586 assert!(!v.v.is_empty(), "not empty");
587 } else if github_ref.starts_with("refs/heads/") && k.eq(BRANCH) {
588 assert!(!v.v.is_empty());
589 }
590 }
591 }
592 }
593
594 #[test]
595 fn test_current_branch() {
596 if get_std_env().contains_key("GITHUB_REF") {
597 return;
598 }
599 #[cfg(feature = "git2")]
600 {
601 use crate::git::git2_mod::{git2_current_branch, git_repo};
602 let git2_branch = git_repo(".")
603 .map(|x| git2_current_branch(&x))
604 .unwrap_or(None);
605 let command_branch = command_current_branch();
606 assert!(git2_branch.is_some());
607 assert!(command_branch.is_some());
608 assert_eq!(command_branch, git2_branch);
609 }
610
611 assert_eq!(Some(branch()), command_current_branch());
612 }
613
614 #[test]
615 fn test_command_last_tag() {
616 let opt_last_tag = command_last_tag();
617 assert!(opt_last_tag.is_some())
618 }
619}