shadow_rs/
git.rs

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        // First, try executing using the git command.
108        if let Err(err) = self.init_git() {
109            println!("{err}");
110        }
111
112        // If the git2 feature is enabled, then replace the corresponding values with git2.
113        self.init_git2(path)?;
114
115        // use command branch
116        if let Some(x) = find_branch_in(path) {
117            self.update_str(BRANCH, x)
118        };
119
120        // use command tag
121        if let Some(x) = command_current_tag() {
122            self.update_str(TAG, x)
123        }
124
125        // use command get last tag
126        if let Some(x) = command_last_tag() {
127            self.update_str(LAST_TAG, x)
128        }
129
130        // try use ci branch,tag
131        self.ci_branch_tag(std_env);
132        Ok(())
133    }
134
135    fn init_git(&mut self) -> SdResult<()> {
136        // check git status
137        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            //get branch
172            let branch = reference
173                .shorthand()
174                .map(|x| x.trim().to_string())
175                .or_else(command_current_branch)
176                .unwrap_or_default();
177
178            //get HEAD branch
179            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    //use git2 crates git repository 'dirty or stage' status files.
227    #[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
360/// get current repository git branch.
361///
362/// When current repository exists git folder.
363///
364/// It's use default feature.This function try use [git2] crates get current branch.
365/// If not use git2 feature,then try use [Command] to get.
366pub 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
381/// get current repository git tag.
382///
383/// When current repository exists git folder.
384/// I's use [Command] to get.
385pub fn tag() -> String {
386    command_current_tag().unwrap_or_default()
387}
388
389/// Check current git Repository status without nothing(dirty or stage)
390///
391/// if nothing,It means clean:true. On the contrary, it is 'dirty':false
392pub 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
407/// List current git Repository statue(dirty or stage) contain file changed
408///
409/// Refer to the 'cargo fix' result output when git statue(dirty or stage) changed.
410///
411/// Example output:`   * examples/builtin_fn.rs (dirty)`
412pub 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
474/// Command exec git current tag
475fn command_current_tag() -> Option<String> {
476    GitCommandExecutor::default().exec(&["tag", "-l", "--contains", "HEAD"])
477}
478
479/// git describe --tags --abbrev=0 HEAD
480/// Command exec git last tag
481fn command_last_tag() -> Option<String> {
482    GitCommandExecutor::default().exec(&["describe", "--tags", "--abbrev=0", "HEAD"])
483}
484
485/// git clean:git status --porcelain
486/// check repository git status is clean
487fn command_git_clean() -> bool {
488    GitCommandExecutor::default()
489        .exec(&["status", "--porcelain"])
490        .map(|x| x.is_empty())
491        .unwrap_or(true)
492}
493
494/// check git repository 'dirty or stage' status files.
495/// git dirty:git status  --porcelain | grep '^\sM.' |awk '{print $2}'
496/// git stage:git status --porcelain --untracked-files=all | grep '^[A|M|D|R]'|awk '{print $2}'
497fn 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
542/// Command exec git current branch
543fn 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            //assert github tag always exist value
584            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}