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";
14
15const TAG_DOC: &str = r#"
16The name of the Git tag that this project was built from.
17Note that this will be empty if there is no tag for the HEAD at the time of build."#;
18pub const TAG: ShadowConst = "TAG";
19
20const LAST_TAG_DOC: &str = r#"
21The name of the last Git tag on the branch that this project was built from.
22As opposed to [`TAG`], this does not require the current commit to be tagged, just one of its parents.
23
24This constant will be empty if the last tag cannot be determined."#;
25pub const LAST_TAG: ShadowConst = "LAST_TAG";
26
27pub const COMMITS_SINCE_TAG_DOC: &str = r#"
28The number of commits since the last Git tag on the branch that this project was built from.
29This value indicates how many commits have been made after the last tag and before the current commit.
30
31If there are no additional commits after the last tag (i.e., the current commit is exactly at a tag),
32this value will be `0`.
33
34This constant will be empty or `0` if the last tag cannot be determined or if there are no commits after it.
35"#;
36
37pub const COMMITS_SINCE_TAG: &str = "COMMITS_SINCE_TAG";
38
39const SHORT_COMMIT_DOC: &str = r#"
40The short hash of the Git commit that this project was built from.
41Note that this will always truncate [`COMMIT_HASH`] to 8 characters if necessary.
42Depending on the amount of commits in your project, this may not yield a unique Git identifier
43([see here for more details on hash abbreviation](https://git-scm.com/docs/git-describe#_examples)).
44
45This constant will be empty if the last commit cannot be determined."#;
46pub const SHORT_COMMIT: ShadowConst = "SHORT_COMMIT";
47
48const COMMIT_HASH_DOC: &str = r#"
49The full commit hash of the Git commit that this project was built from.
50An abbreviated, but not necessarily unique, version of this is [`SHORT_COMMIT`].
51
52This constant will be empty if the last commit cannot be determined."#;
53pub const COMMIT_HASH: ShadowConst = "COMMIT_HASH";
54
55const COMMIT_DATE_DOC: &str = r#"The time of the Git commit that this project was built from.
56The time is formatted in modified ISO 8601 format (`YYYY-MM-DD HH-MM ±hh-mm` where hh-mm is the offset from UTC).
57The timezone information from the original commit is preserved.
58
59This constant will be empty if the last commit cannot be determined."#;
60pub const COMMIT_DATE: ShadowConst = "COMMIT_DATE";
61
62const COMMIT_DATE_2822_DOC: &str = r#"
63The time of the Git commit that this project was built from.
64The time is formatted according to [RFC 2822](https://datatracker.ietf.org/doc/html/rfc2822#section-3.3) (e.g. HTTP Headers).
65The timezone information from the original commit is preserved.
66
67This constant will be empty if the last commit cannot be determined."#;
68pub const COMMIT_DATE_2822: ShadowConst = "COMMIT_DATE_2822";
69
70const COMMIT_DATE_3339_DOC: &str = r#"
71The time of the Git commit that this project was built from.
72The time is formatted according to [RFC 3339 and ISO 8601](https://datatracker.ietf.org/doc/html/rfc3339#section-5.6).
73The timezone information from the original commit is preserved.
74
75This constant will be empty if the last commit cannot be determined."#;
76pub const COMMIT_DATE_3339: ShadowConst = "COMMIT_DATE_3339";
77
78const COMMIT_TIMESTAMP_DOC: &str = r#"
79The time of the Git commit as a Unix timestamp (seconds since Unix epoch).
80
81This constant will be empty if the last commit cannot be determined."#;
82pub const COMMIT_TIMESTAMP: ShadowConst = "COMMIT_TIMESTAMP";
83
84const COMMIT_AUTHOR_DOC: &str = r#"
85The author of the Git commit that this project was built from.
86
87This constant will be empty if the last commit cannot be determined."#;
88pub const COMMIT_AUTHOR: ShadowConst = "COMMIT_AUTHOR";
89
90const COMMIT_EMAIL_DOC: &str = r#"
91The e-mail address of the author of the Git commit that this project was built from.
92
93This constant will be empty if the last commit cannot be determined."#;
94pub const COMMIT_EMAIL: ShadowConst = "COMMIT_EMAIL";
95
96const GIT_CLEAN_DOC: &str = r#"
97Whether the Git working tree was clean at the time of project build (`true`), or not (`false`).
98
99This constant will be `false` if the last commit cannot be determined."#;
100pub const GIT_CLEAN: ShadowConst = "GIT_CLEAN";
101
102const GIT_STATUS_FILE_DOC: &str = r#"
103The Git working tree status as a list of files with their status, similar to `git status`.
104Each line of the list is preceded with `  * `, followed by the file name.
105Files marked `(dirty)` have unstaged changes.
106Files marked `(staged)` have staged changes.
107
108This constant will be empty if the working tree status cannot be determined."#;
109pub const GIT_STATUS_FILE: ShadowConst = "GIT_STATUS_FILE";
110
111#[derive(Default, Debug)]
112pub struct Git {
113    map: BTreeMap<ShadowConst, ConstVal>,
114    ci_type: CiType,
115}
116
117impl Git {
118    fn update_str(&mut self, c: ShadowConst, v: String) {
119        if let Some(val) = self.map.get_mut(c) {
120            *val = ConstVal {
121                desc: val.desc.clone(),
122                v,
123                t: ConstType::Str,
124            }
125        }
126    }
127
128    fn update_bool(&mut self, c: ShadowConst, v: bool) {
129        if let Some(val) = self.map.get_mut(c) {
130            *val = ConstVal {
131                desc: val.desc.clone(),
132                v: v.to_string(),
133                t: ConstType::Bool,
134            }
135        }
136    }
137
138    fn update_usize(&mut self, c: ShadowConst, v: usize) {
139        if let Some(val) = self.map.get_mut(c) {
140            *val = ConstVal {
141                desc: val.desc.clone(),
142                v: v.to_string(),
143                t: ConstType::Usize,
144            }
145        }
146    }
147
148    fn update_int(&mut self, c: ShadowConst, v: i64) {
149        if let Some(val) = self.map.get_mut(c) {
150            *val = ConstVal {
151                desc: val.desc.clone(),
152                v: v.to_string(),
153                t: ConstType::Int,
154            }
155        }
156    }
157
158    fn init(&mut self, path: &Path, std_env: &BTreeMap<String, String>) -> SdResult<()> {
159        // First, try executing using the git command.
160        if let Err(err) = self.init_git() {
161            println!("{err}");
162        }
163
164        // If the git2 feature is enabled, then replace the corresponding values with git2.
165        self.init_git2(path)?;
166
167        // use command branch
168        if let Some(x) = find_branch_in(path) {
169            self.update_str(BRANCH, x)
170        };
171
172        // use command tag
173        if let Some(x) = command_current_tag() {
174            self.update_str(TAG, x)
175        }
176
177        // use command get last tag
178        let describe = command_git_describe();
179        if let Some(x) = describe.0 {
180            self.update_str(LAST_TAG, x)
181        }
182
183        if let Some(x) = describe.1 {
184            self.update_usize(COMMITS_SINCE_TAG, x)
185        }
186
187        // try use ci branch,tag
188        self.ci_branch_tag(std_env);
189        Ok(())
190    }
191
192    fn init_git(&mut self) -> SdResult<()> {
193        // check git status
194        let x = command_git_clean();
195        self.update_bool(GIT_CLEAN, x);
196
197        let x = command_git_status_file();
198        self.update_str(GIT_STATUS_FILE, x);
199
200        let git_info = command_git_head();
201
202        self.update_str(COMMIT_EMAIL, git_info.email);
203        self.update_str(COMMIT_AUTHOR, git_info.author);
204        self.update_str(SHORT_COMMIT, git_info.short_commit);
205        self.update_str(COMMIT_HASH, git_info.commit);
206
207        // Try to parse ISO format with timezone first, fallback to UTC timestamp
208        if !git_info.date_iso.is_empty() {
209            if let Ok(date_time) = DateTime::from_iso8601_string(&git_info.date_iso) {
210                self.update_str(COMMIT_DATE, date_time.human_format());
211                self.update_str(COMMIT_DATE_2822, date_time.to_rfc2822());
212                self.update_str(COMMIT_DATE_3339, date_time.to_rfc3339());
213                self.update_int(COMMIT_TIMESTAMP, date_time.timestamp());
214            } else if let Ok(time_stamp) = git_info.date.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                    self.update_str(COMMIT_DATE_2822, date_time.to_rfc2822());
218                    self.update_str(COMMIT_DATE_3339, date_time.to_rfc3339());
219                    self.update_int(COMMIT_TIMESTAMP, date_time.timestamp());
220                }
221            }
222        } else if let Ok(time_stamp) = git_info.date.parse::<i64>() {
223            if let Ok(date_time) = DateTime::timestamp_2_utc(time_stamp) {
224                self.update_str(COMMIT_DATE, date_time.human_format());
225                self.update_str(COMMIT_DATE_2822, date_time.to_rfc2822());
226                self.update_str(COMMIT_DATE_3339, date_time.to_rfc3339());
227                self.update_int(COMMIT_TIMESTAMP, date_time.timestamp());
228            }
229        }
230
231        Ok(())
232    }
233
234    #[allow(unused_variables)]
235    fn init_git2(&mut self, path: &Path) -> SdResult<()> {
236        #[cfg(feature = "git2")]
237        {
238            use crate::date_time::DateTime;
239            use crate::git::git2_mod::git_repo;
240            use crate::Format;
241
242            let repo = git_repo(path).map_err(ShadowError::new)?;
243            let reference = repo.head().map_err(ShadowError::new)?;
244
245            //get branch
246            let branch = reference
247                .shorthand()
248                .map(|x| x.trim().to_string())
249                .or_else(command_current_branch)
250                .unwrap_or_default();
251
252            //get HEAD branch
253            let tag = command_current_tag().unwrap_or_default();
254            self.update_str(BRANCH, branch);
255            self.update_str(TAG, tag);
256
257            // use command get last tag
258            let describe = command_git_describe();
259            if let Some(x) = describe.0 {
260                self.update_str(LAST_TAG, x)
261            }
262
263            if let Some(x) = describe.1 {
264                self.update_usize(COMMITS_SINCE_TAG, x)
265            }
266
267            if let Some(v) = reference.target() {
268                let commit = v.to_string();
269                self.update_str(COMMIT_HASH, commit.clone());
270                let mut short_commit = commit.as_str();
271
272                if commit.len() > 8 {
273                    short_commit = short_commit.get(0..8).unwrap();
274                }
275                self.update_str(SHORT_COMMIT, short_commit.to_string());
276            }
277
278            let commit = reference.peel_to_commit().map_err(ShadowError::new)?;
279
280            let author = commit.author();
281            if let Some(v) = author.email() {
282                self.update_str(COMMIT_EMAIL, v.to_string());
283            }
284
285            if let Some(v) = author.name() {
286                self.update_str(COMMIT_AUTHOR, v.to_string());
287            }
288            let status_file = Self::git2_dirty_stage(&repo);
289            if status_file.trim().is_empty() {
290                self.update_bool(GIT_CLEAN, true);
291            } else {
292                self.update_bool(GIT_CLEAN, false);
293            }
294            self.update_str(GIT_STATUS_FILE, status_file);
295
296            let commit_time = commit.time();
297            let time_stamp = commit_time.seconds();
298            let offset_minutes = commit_time.offset_minutes();
299
300            // Create OffsetDateTime with the commit's timezone
301            if let Ok(utc_time) = time::OffsetDateTime::from_unix_timestamp(time_stamp) {
302                if let Ok(offset) = time::UtcOffset::from_whole_seconds(offset_minutes * 60) {
303                    let local_time = utc_time.to_offset(offset);
304                    let date_time = DateTime::Local(local_time);
305
306                    self.update_str(COMMIT_DATE, date_time.human_format());
307                    self.update_str(COMMIT_DATE_2822, date_time.to_rfc2822());
308                    self.update_str(COMMIT_DATE_3339, date_time.to_rfc3339());
309                } else {
310                    // Fallback to UTC if offset parsing fails
311                    let date_time = DateTime::Utc(utc_time);
312                    self.update_str(COMMIT_DATE, date_time.human_format());
313                    self.update_str(COMMIT_DATE_2822, date_time.to_rfc2822());
314                    self.update_str(COMMIT_DATE_3339, date_time.to_rfc3339());
315                }
316            }
317        }
318        Ok(())
319    }
320
321    //use git2 crates git repository 'dirty or stage' status files.
322    #[cfg(feature = "git2")]
323    pub fn git2_dirty_stage(repo: &git2::Repository) -> String {
324        let mut repo_opts = git2::StatusOptions::new();
325        repo_opts.include_ignored(false);
326        if let Ok(statue) = repo.statuses(Some(&mut repo_opts)) {
327            let mut dirty_files = Vec::new();
328            let mut staged_files = Vec::new();
329
330            for status in statue.iter() {
331                if let Some(path) = status.path() {
332                    match status.status() {
333                        git2::Status::CURRENT => (),
334                        git2::Status::INDEX_NEW
335                        | git2::Status::INDEX_MODIFIED
336                        | git2::Status::INDEX_DELETED
337                        | git2::Status::INDEX_RENAMED
338                        | git2::Status::INDEX_TYPECHANGE => staged_files.push(path.to_string()),
339                        _ => dirty_files.push(path.to_string()),
340                    };
341                }
342            }
343            filter_git_dirty_stage(dirty_files, staged_files)
344        } else {
345            "".into()
346        }
347    }
348
349    #[allow(clippy::manual_strip)]
350    fn ci_branch_tag(&mut self, std_env: &BTreeMap<String, String>) {
351        let mut branch: Option<String> = None;
352        let mut tag: Option<String> = None;
353        match self.ci_type {
354            CiType::Gitlab => {
355                if let Some(v) = std_env.get("CI_COMMIT_TAG") {
356                    tag = Some(v.to_string());
357                } else if let Some(v) = std_env.get("CI_COMMIT_REF_NAME") {
358                    branch = Some(v.to_string());
359                }
360            }
361            CiType::Github => {
362                if let Some(v) = std_env.get("GITHUB_REF") {
363                    let ref_branch_prefix: &str = "refs/heads/";
364                    let ref_tag_prefix: &str = "refs/tags/";
365
366                    if v.starts_with(ref_branch_prefix) {
367                        branch = Some(
368                            v.get(ref_branch_prefix.len()..)
369                                .unwrap_or_default()
370                                .to_string(),
371                        )
372                    } else if v.starts_with(ref_tag_prefix) {
373                        tag = Some(
374                            v.get(ref_tag_prefix.len()..)
375                                .unwrap_or_default()
376                                .to_string(),
377                        )
378                    }
379                }
380            }
381            _ => {}
382        }
383        if let Some(x) = branch {
384            self.update_str(BRANCH, x);
385        }
386
387        if let Some(x) = tag {
388            self.update_str(TAG, x.clone());
389            self.update_str(LAST_TAG, x);
390        }
391    }
392}
393
394pub(crate) fn new_git(
395    path: &Path,
396    ci: CiType,
397    std_env: &BTreeMap<String, String>,
398) -> BTreeMap<ShadowConst, ConstVal> {
399    let mut git = Git {
400        map: Default::default(),
401        ci_type: ci,
402    };
403    git.map.insert(BRANCH, ConstVal::new(BRANCH_DOC));
404
405    git.map.insert(TAG, ConstVal::new(TAG_DOC));
406
407    git.map.insert(LAST_TAG, ConstVal::new(LAST_TAG_DOC));
408
409    git.map.insert(
410        COMMITS_SINCE_TAG,
411        ConstVal::new_usize(COMMITS_SINCE_TAG_DOC),
412    );
413
414    git.map.insert(COMMIT_HASH, ConstVal::new(COMMIT_HASH_DOC));
415
416    git.map
417        .insert(SHORT_COMMIT, ConstVal::new(SHORT_COMMIT_DOC));
418
419    git.map
420        .insert(COMMIT_AUTHOR, ConstVal::new(COMMIT_AUTHOR_DOC));
421    git.map
422        .insert(COMMIT_EMAIL, ConstVal::new(COMMIT_EMAIL_DOC));
423    git.map.insert(COMMIT_DATE, ConstVal::new(COMMIT_DATE_DOC));
424
425    git.map
426        .insert(COMMIT_DATE_2822, ConstVal::new(COMMIT_DATE_2822_DOC));
427
428    git.map
429        .insert(COMMIT_DATE_3339, ConstVal::new(COMMIT_DATE_3339_DOC));
430
431    git.map
432        .insert(COMMIT_TIMESTAMP, ConstVal::new(COMMIT_TIMESTAMP_DOC));
433
434    git.map.insert(GIT_CLEAN, ConstVal::new_bool(GIT_CLEAN_DOC));
435
436    git.map
437        .insert(GIT_STATUS_FILE, ConstVal::new(GIT_STATUS_FILE_DOC));
438
439    if let Err(e) = git.init(path, std_env) {
440        println!("{e}");
441    }
442
443    git.map
444}
445
446#[cfg(feature = "git2")]
447pub mod git2_mod {
448    use git2::Error as git2Error;
449    use git2::Repository;
450    use std::path::Path;
451
452    pub fn git_repo<P: AsRef<Path>>(path: P) -> Result<Repository, git2Error> {
453        Repository::discover(path)
454    }
455
456    pub fn git2_current_branch(repo: &Repository) -> Option<String> {
457        repo.head()
458            .map(|x| x.shorthand().map(|x| x.to_string()))
459            .unwrap_or(None)
460    }
461}
462
463/// get current repository git branch.
464///
465/// When current repository exists git folder.
466///
467/// It's use default feature.This function try use [git2] crates get current branch.
468/// If not use git2 feature,then try use [Command] to get.
469pub fn branch() -> String {
470    #[cfg(feature = "git2")]
471    {
472        use crate::git::git2_mod::{git2_current_branch, git_repo};
473        git_repo(".")
474            .map(|x| git2_current_branch(&x))
475            .unwrap_or_else(|_| command_current_branch())
476            .unwrap_or_default()
477    }
478    #[cfg(not(feature = "git2"))]
479    {
480        command_current_branch().unwrap_or_default()
481    }
482}
483
484/// get current repository git tag.
485///
486/// When current repository exists git folder.
487/// I's use [Command] to get.
488pub fn tag() -> String {
489    command_current_tag().unwrap_or_default()
490}
491
492/// Check current git Repository status without nothing(dirty or stage)
493///
494/// if nothing,It means clean:true. On the contrary, it is 'dirty':false
495pub fn git_clean() -> bool {
496    #[cfg(feature = "git2")]
497    {
498        use crate::git::git2_mod::git_repo;
499        git_repo(".")
500            .map(|x| Git::git2_dirty_stage(&x))
501            .map(|x| x.trim().is_empty())
502            .unwrap_or(true)
503    }
504    #[cfg(not(feature = "git2"))]
505    {
506        command_git_clean()
507    }
508}
509
510/// List current git Repository statue(dirty or stage) contain file changed
511///
512/// Refer to the 'cargo fix' result output when git statue(dirty or stage) changed.
513///
514/// Example output:`   * examples/builtin_fn.rs (dirty)`
515pub fn git_status_file() -> String {
516    #[cfg(feature = "git2")]
517    {
518        use crate::git::git2_mod::git_repo;
519        git_repo(".")
520            .map(|x| Git::git2_dirty_stage(&x))
521            .unwrap_or_default()
522    }
523    #[cfg(not(feature = "git2"))]
524    {
525        command_git_status_file()
526    }
527}
528
529struct GitHeadInfo {
530    commit: String,
531    short_commit: String,
532    email: String,
533    author: String,
534    date: String,
535    date_iso: String,
536}
537
538struct GitCommandExecutor<'a> {
539    path: &'a Path,
540}
541
542impl Default for GitCommandExecutor<'_> {
543    fn default() -> Self {
544        Self::new(Path::new("."))
545    }
546}
547
548impl<'a> GitCommandExecutor<'a> {
549    fn new(path: &'a Path) -> Self {
550        GitCommandExecutor { path }
551    }
552
553    fn exec(&self, args: &[&str]) -> Option<String> {
554        Command::new("git")
555            .env("GIT_OPTIONAL_LOCKS", "0")
556            .current_dir(self.path)
557            .args(args)
558            .output()
559            .map(|x| {
560                String::from_utf8(x.stdout)
561                    .map(|x| x.trim().to_string())
562                    .ok()
563            })
564            .unwrap_or(None)
565    }
566}
567
568fn command_git_head() -> GitHeadInfo {
569    let cli = |args: &[&str]| GitCommandExecutor::default().exec(args).unwrap_or_default();
570    GitHeadInfo {
571        commit: cli(&["rev-parse", "HEAD"]),
572        short_commit: cli(&["rev-parse", "--short", "HEAD"]),
573        author: cli(&["log", "-1", "--pretty=format:%an"]),
574        email: cli(&["log", "-1", "--pretty=format:%ae"]),
575        date: cli(&["show", "--pretty=format:%ct", "--date=raw", "-s"]),
576        date_iso: cli(&["log", "-1", "--pretty=format:%cI"]),
577    }
578}
579
580/// Command exec git current tag
581fn command_current_tag() -> Option<String> {
582    GitCommandExecutor::default().exec(&["tag", "-l", "--contains", "HEAD"])
583}
584
585/// git describe --tags HEAD
586/// Command exec git describe
587fn command_git_describe() -> (Option<String>, Option<usize>, Option<String>) {
588    let last_tag =
589        GitCommandExecutor::default().exec(&["describe", "--tags", "--abbrev=0", "HEAD"]);
590    if last_tag.is_none() {
591        return (None, None, None);
592    }
593
594    let tag = last_tag.unwrap();
595
596    let describe = GitCommandExecutor::default().exec(&["describe", "--tags", "HEAD"]);
597    if let Some(desc) = describe {
598        match parse_git_describe(&tag, &desc) {
599            Ok((tag, commits, hash)) => {
600                return (Some(tag), commits, hash);
601            }
602            Err(_) => {
603                return (Some(tag), None, None);
604            }
605        }
606    }
607    (Some(tag), None, None)
608}
609
610fn parse_git_describe(
611    last_tag: &str,
612    describe: &str,
613) -> SdResult<(String, Option<usize>, Option<String>)> {
614    if !describe.starts_with(last_tag) {
615        return Err(ShadowError::String("git describe result error".to_string()));
616    }
617
618    if last_tag == describe {
619        return Ok((describe.to_string(), None, None));
620    }
621
622    let parts: Vec<&str> = describe.rsplit('-').collect();
623
624    if parts.is_empty() || parts.len() == 2 {
625        return Err(ShadowError::String(
626            "git describe result error,expect:<tag>-<num_commits>-g<hash>".to_string(),
627        ));
628    }
629
630    if parts.len() > 2 {
631        let short_hash = parts[0]; // last part
632
633        if !short_hash.starts_with('g') {
634            return Err(ShadowError::String(
635                "git describe result error,expect commit hash end with:-g<hash>".to_string(),
636            ));
637        }
638        let short_hash = short_hash.trim_start_matches('g');
639
640        // Full example:v1.0.0-alpha0-5-ga1b2c3d
641        let num_commits_str = parts[1];
642        let num_commits = num_commits_str
643            .parse::<usize>()
644            .map_err(|e| ShadowError::String(e.to_string()))?;
645        let last_tag = parts[2..]
646            .iter()
647            .rev()
648            .copied()
649            .collect::<Vec<_>>()
650            .join("-");
651        return Ok((last_tag, Some(num_commits), Some(short_hash.to_string())));
652    }
653    Ok((describe.to_string(), None, None))
654}
655
656/// git clean:git status --porcelain
657/// check repository git status is clean
658fn command_git_clean() -> bool {
659    GitCommandExecutor::default()
660        .exec(&["status", "--porcelain"])
661        .map(|x| x.is_empty())
662        .unwrap_or(true)
663}
664
665/// check git repository 'dirty or stage' status files.
666/// git dirty:git status  --porcelain | grep '^\sM.' |awk '{print $2}'
667/// git stage:git status --porcelain --untracked-files=all | grep '^[A|M|D|R]'|awk '{print $2}'
668fn command_git_status_file() -> String {
669    let git_status_files =
670        move |args: &[&str], grep: &[&str], awk: &[&str]| -> SdResult<Vec<String>> {
671            let git_shell = Command::new("git")
672                .env("GIT_OPTIONAL_LOCKS", "0")
673                .args(args)
674                .stdin(Stdio::piped())
675                .stdout(Stdio::piped())
676                .spawn()?;
677            let git_out = git_shell.stdout.ok_or("Failed to exec git stdout")?;
678
679            let grep_shell = Command::new("grep")
680                .args(grep)
681                .stdin(Stdio::from(git_out))
682                .stdout(Stdio::piped())
683                .spawn()?;
684            let grep_out = grep_shell.stdout.ok_or("Failed to exec grep stdout")?;
685
686            let mut awk_shell = Command::new("awk")
687                .args(awk)
688                .stdin(Stdio::from(grep_out))
689                .stdout(Stdio::piped())
690                .spawn()?;
691            let mut awk_out = BufReader::new(
692                awk_shell
693                    .stdout
694                    .as_mut()
695                    .ok_or("Failed to exec awk stdout")?,
696            );
697            let mut line = String::new();
698            awk_out.read_to_string(&mut line)?;
699            Ok(line.lines().map(|x| x.into()).collect())
700        };
701
702    let dirty = git_status_files(&["status", "--porcelain"], &[r"^\sM."], &["{print $2}"])
703        .unwrap_or_default();
704
705    let stage = git_status_files(
706        &["status", "--porcelain", "--untracked-files=all"],
707        &[r#"^[A|M|D|R]"#],
708        &["{print $2}"],
709    )
710    .unwrap_or_default();
711    filter_git_dirty_stage(dirty, stage)
712}
713
714/// Command exec git current branch
715fn command_current_branch() -> Option<String> {
716    find_branch_in(Path::new("."))
717}
718
719fn find_branch_in(path: &Path) -> Option<String> {
720    GitCommandExecutor::new(path).exec(&["symbolic-ref", "--short", "HEAD"])
721}
722
723fn filter_git_dirty_stage(dirty_files: Vec<String>, staged_files: Vec<String>) -> String {
724    let mut concat_file = String::new();
725    for file in dirty_files {
726        concat_file.push_str("  * ");
727        concat_file.push_str(&file);
728        concat_file.push_str(" (dirty)\n");
729    }
730    for file in staged_files {
731        concat_file.push_str("  * ");
732        concat_file.push_str(&file);
733        concat_file.push_str(" (staged)\n");
734    }
735    concat_file
736}
737
738#[cfg(test)]
739mod tests {
740    use super::*;
741    use crate::get_std_env;
742
743    #[test]
744    fn test_git() {
745        let env_map = get_std_env();
746        let map = new_git(Path::new("./"), CiType::Github, &env_map);
747        for (k, v) in map {
748            assert!(!v.desc.is_empty());
749            if !k.eq(TAG)
750                && !k.eq(LAST_TAG)
751                && !k.eq(COMMITS_SINCE_TAG)
752                && !k.eq(BRANCH)
753                && !k.eq(GIT_STATUS_FILE)
754            {
755                assert!(!v.v.is_empty());
756                continue;
757            }
758
759            //assert github tag always exist value
760            if let Some(github_ref) = env_map.get("GITHUB_REF") {
761                if github_ref.starts_with("refs/tags/") && k.eq(TAG) {
762                    assert!(!v.v.is_empty(), "not empty");
763                } else if github_ref.starts_with("refs/heads/") && k.eq(BRANCH) {
764                    assert!(!v.v.is_empty());
765                }
766            }
767        }
768    }
769
770    #[test]
771    fn test_current_branch() {
772        if get_std_env().contains_key("GITHUB_REF") {
773            return;
774        }
775        #[cfg(feature = "git2")]
776        {
777            use crate::git::git2_mod::{git2_current_branch, git_repo};
778            let git2_branch = git_repo(".")
779                .map(|x| git2_current_branch(&x))
780                .unwrap_or(None);
781            let command_branch = command_current_branch();
782            assert!(git2_branch.is_some());
783            assert!(command_branch.is_some());
784            assert_eq!(command_branch, git2_branch);
785        }
786
787        assert_eq!(Some(branch()), command_current_branch());
788    }
789
790    #[test]
791    fn test_parse_git_describe() {
792        let commit_hash = "24skp4489";
793        let describe = "v1.0.0";
794        assert_eq!(
795            parse_git_describe("v1.0.0", describe).unwrap(),
796            (describe.into(), None, None)
797        );
798
799        let describe = "v1.0.0-0-g24skp4489";
800        assert_eq!(
801            parse_git_describe("v1.0.0", describe).unwrap(),
802            ("v1.0.0".into(), Some(0), Some(commit_hash.into()))
803        );
804
805        let describe = "v1.0.0-1-g24skp4489";
806        assert_eq!(
807            parse_git_describe("v1.0.0", describe).unwrap(),
808            ("v1.0.0".into(), Some(1), Some(commit_hash.into()))
809        );
810
811        let describe = "v1.0.0-alpha-0-g24skp4489";
812        assert_eq!(
813            parse_git_describe("v1.0.0-alpha", describe).unwrap(),
814            ("v1.0.0-alpha".into(), Some(0), Some(commit_hash.into()))
815        );
816
817        let describe = "v1.0.0.alpha-0-g24skp4489";
818        assert_eq!(
819            parse_git_describe("v1.0.0.alpha", describe).unwrap(),
820            ("v1.0.0.alpha".into(), Some(0), Some(commit_hash.into()))
821        );
822
823        let describe = "v1.0.0-alpha";
824        assert_eq!(
825            parse_git_describe("v1.0.0-alpha", describe).unwrap(),
826            ("v1.0.0-alpha".into(), None, None)
827        );
828
829        let describe = "v1.0.0-alpha-99-0-g24skp4489";
830        assert_eq!(
831            parse_git_describe("v1.0.0-alpha-99", describe).unwrap(),
832            ("v1.0.0-alpha-99".into(), Some(0), Some(commit_hash.into()))
833        );
834
835        let describe = "v1.0.0-alpha-99-024skp4489";
836        assert!(parse_git_describe("v1.0.0-alpha-99", describe).is_err());
837
838        let describe = "v1.0.0-alpha-024skp4489";
839        assert!(parse_git_describe("v1.0.0-alpha", describe).is_err());
840
841        let describe = "v1.0.0-alpha-024skp4489";
842        assert!(parse_git_describe("v1.0.0-alpha", describe).is_err());
843
844        let describe = "v1.0.0-alpha-g024skp4489";
845        assert!(parse_git_describe("v1.0.0-alpha", describe).is_err());
846
847        let describe = "v1.0.0----alpha-g024skp4489";
848        assert!(parse_git_describe("v1.0.0----alpha", describe).is_err());
849    }
850
851    #[test]
852    fn test_commit_date_timezone_preservation() {
853        use crate::DateTime;
854
855        // Test timezone-aware parsing
856        let iso_date = "2021-08-04T12:34:03+08:00";
857        let date_time = DateTime::from_iso8601_string(iso_date).unwrap();
858        assert_eq!(date_time.human_format(), "2021-08-04 12:34:03 +08:00");
859        assert!(date_time.to_rfc3339().contains("+08:00"));
860
861        // Test UTC timezone
862        let iso_date_utc = "2021-08-04T12:34:03Z";
863        let date_time_utc = DateTime::from_iso8601_string(iso_date_utc).unwrap();
864        assert_eq!(date_time_utc.human_format(), "2021-08-04 12:34:03 +00:00");
865
866        // Test negative timezone
867        let iso_date_neg = "2021-08-04T12:34:03-05:00";
868        let date_time_neg = DateTime::from_iso8601_string(iso_date_neg).unwrap();
869        assert_eq!(date_time_neg.human_format(), "2021-08-04 12:34:03 -05:00");
870    }
871}