Skip to main content

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 DateTime with the commit's timezone
301            if let Ok(utc_time) = jiff::Timestamp::from_second(time_stamp) {
302                let date_time =
303                    if let Ok(offset) = jiff::tz::Offset::from_seconds(offset_minutes * 60) {
304                        let tz = jiff::tz::TimeZone::fixed(offset);
305                        DateTime::new(utc_time.to_zoned(tz))
306                    } else {
307                        // Fallback to UTC if offset parsing fails
308                        let tz = jiff::tz::TimeZone::UTC;
309                        DateTime::new(utc_time.to_zoned(tz))
310                    };
311
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        Ok(())
318    }
319
320    //use git2 crates git repository 'dirty or stage' status files.
321    #[cfg(feature = "git2")]
322    pub fn git2_dirty_stage(repo: &git2::Repository) -> String {
323        let mut repo_opts = git2::StatusOptions::new();
324        repo_opts.include_ignored(false);
325        if let Ok(statue) = repo.statuses(Some(&mut repo_opts)) {
326            let mut dirty_files = Vec::new();
327            let mut staged_files = Vec::new();
328
329            for status in statue.iter() {
330                if let Some(path) = status.path() {
331                    match status.status() {
332                        git2::Status::CURRENT => (),
333                        git2::Status::INDEX_NEW
334                        | git2::Status::INDEX_MODIFIED
335                        | git2::Status::INDEX_DELETED
336                        | git2::Status::INDEX_RENAMED
337                        | git2::Status::INDEX_TYPECHANGE => staged_files.push(path.to_string()),
338                        _ => dirty_files.push(path.to_string()),
339                    };
340                }
341            }
342            filter_git_dirty_stage(dirty_files, staged_files)
343        } else {
344            "".into()
345        }
346    }
347
348    #[allow(clippy::manual_strip)]
349    fn ci_branch_tag(&mut self, std_env: &BTreeMap<String, String>) {
350        let mut branch: Option<String> = None;
351        let mut tag: Option<String> = None;
352        match self.ci_type {
353            CiType::Gitlab => {
354                if let Some(v) = std_env.get("CI_COMMIT_TAG") {
355                    tag = Some(v.to_string());
356                } else if let Some(v) = std_env.get("CI_COMMIT_REF_NAME") {
357                    branch = Some(v.to_string());
358                }
359            }
360            CiType::Github => {
361                if let Some(v) = std_env.get("GITHUB_REF") {
362                    let ref_branch_prefix: &str = "refs/heads/";
363                    let ref_tag_prefix: &str = "refs/tags/";
364
365                    if v.starts_with(ref_branch_prefix) {
366                        branch = Some(
367                            v.get(ref_branch_prefix.len()..)
368                                .unwrap_or_default()
369                                .to_string(),
370                        )
371                    } else if v.starts_with(ref_tag_prefix) {
372                        tag = Some(
373                            v.get(ref_tag_prefix.len()..)
374                                .unwrap_or_default()
375                                .to_string(),
376                        )
377                    }
378                }
379            }
380            _ => {}
381        }
382        if let Some(x) = branch {
383            self.update_str(BRANCH, x);
384        }
385
386        if let Some(x) = tag {
387            self.update_str(TAG, x.clone());
388            self.update_str(LAST_TAG, x);
389        }
390    }
391}
392
393pub(crate) fn new_git(
394    path: &Path,
395    ci: CiType,
396    std_env: &BTreeMap<String, String>,
397) -> BTreeMap<ShadowConst, ConstVal> {
398    let mut git = Git {
399        map: Default::default(),
400        ci_type: ci,
401    };
402    git.map.insert(BRANCH, ConstVal::new(BRANCH_DOC));
403
404    git.map.insert(TAG, ConstVal::new(TAG_DOC));
405
406    git.map.insert(LAST_TAG, ConstVal::new(LAST_TAG_DOC));
407
408    git.map.insert(
409        COMMITS_SINCE_TAG,
410        ConstVal::new_usize(COMMITS_SINCE_TAG_DOC),
411    );
412
413    git.map.insert(COMMIT_HASH, ConstVal::new(COMMIT_HASH_DOC));
414
415    git.map
416        .insert(SHORT_COMMIT, ConstVal::new(SHORT_COMMIT_DOC));
417
418    git.map
419        .insert(COMMIT_AUTHOR, ConstVal::new(COMMIT_AUTHOR_DOC));
420    git.map
421        .insert(COMMIT_EMAIL, ConstVal::new(COMMIT_EMAIL_DOC));
422    git.map.insert(COMMIT_DATE, ConstVal::new(COMMIT_DATE_DOC));
423
424    git.map
425        .insert(COMMIT_DATE_2822, ConstVal::new(COMMIT_DATE_2822_DOC));
426
427    git.map
428        .insert(COMMIT_DATE_3339, ConstVal::new(COMMIT_DATE_3339_DOC));
429
430    git.map
431        .insert(COMMIT_TIMESTAMP, ConstVal::new(COMMIT_TIMESTAMP_DOC));
432
433    git.map.insert(GIT_CLEAN, ConstVal::new_bool(GIT_CLEAN_DOC));
434
435    git.map
436        .insert(GIT_STATUS_FILE, ConstVal::new(GIT_STATUS_FILE_DOC));
437
438    if let Err(e) = git.init(path, std_env) {
439        println!("{e}");
440    }
441
442    git.map
443}
444
445#[cfg(feature = "git2")]
446pub mod git2_mod {
447    use git2::Error as git2Error;
448    use git2::Repository;
449    use std::path::Path;
450
451    pub fn git_repo<P: AsRef<Path>>(path: P) -> Result<Repository, git2Error> {
452        Repository::discover(path)
453    }
454
455    pub fn git2_current_branch(repo: &Repository) -> Option<String> {
456        repo.head()
457            .map(|x| x.shorthand().map(|x| x.to_string()))
458            .unwrap_or(None)
459    }
460}
461
462/// get current repository git branch.
463///
464/// When current repository exists git folder.
465///
466/// It's use default feature.This function try use [git2] crates get current branch.
467/// If not use git2 feature,then try use [Command] to get.
468pub fn branch() -> String {
469    #[cfg(feature = "git2")]
470    {
471        use crate::git::git2_mod::{git2_current_branch, git_repo};
472        git_repo(".")
473            .map(|x| git2_current_branch(&x))
474            .unwrap_or_else(|_| command_current_branch())
475            .unwrap_or_default()
476    }
477    #[cfg(not(feature = "git2"))]
478    {
479        command_current_branch().unwrap_or_default()
480    }
481}
482
483/// get current repository git tag.
484///
485/// When current repository exists git folder.
486/// I's use [Command] to get.
487pub fn tag() -> String {
488    command_current_tag().unwrap_or_default()
489}
490
491/// Check current git Repository status without nothing(dirty or stage)
492///
493/// if nothing,It means clean:true. On the contrary, it is 'dirty':false
494pub fn git_clean() -> bool {
495    #[cfg(feature = "git2")]
496    {
497        use crate::git::git2_mod::git_repo;
498        git_repo(".")
499            .map(|x| Git::git2_dirty_stage(&x))
500            .map(|x| x.trim().is_empty())
501            .unwrap_or(true)
502    }
503    #[cfg(not(feature = "git2"))]
504    {
505        command_git_clean()
506    }
507}
508
509/// List current git Repository statue(dirty or stage) contain file changed
510///
511/// Refer to the 'cargo fix' result output when git statue(dirty or stage) changed.
512///
513/// Example output:`   * examples/builtin_fn.rs (dirty)`
514pub fn git_status_file() -> String {
515    #[cfg(feature = "git2")]
516    {
517        use crate::git::git2_mod::git_repo;
518        git_repo(".")
519            .map(|x| Git::git2_dirty_stage(&x))
520            .unwrap_or_default()
521    }
522    #[cfg(not(feature = "git2"))]
523    {
524        command_git_status_file()
525    }
526}
527
528struct GitHeadInfo {
529    commit: String,
530    short_commit: String,
531    email: String,
532    author: String,
533    date: String,
534    date_iso: String,
535}
536
537struct GitCommandExecutor<'a> {
538    path: &'a Path,
539}
540
541impl Default for GitCommandExecutor<'_> {
542    fn default() -> Self {
543        Self::new(Path::new("."))
544    }
545}
546
547impl<'a> GitCommandExecutor<'a> {
548    fn new(path: &'a Path) -> Self {
549        GitCommandExecutor { path }
550    }
551
552    fn exec(&self, args: &[&str]) -> Option<String> {
553        Command::new("git")
554            .env("GIT_OPTIONAL_LOCKS", "0")
555            .current_dir(self.path)
556            .args(args)
557            .output()
558            .map(|x| {
559                String::from_utf8(x.stdout)
560                    .map(|x| x.trim().to_string())
561                    .ok()
562            })
563            .unwrap_or(None)
564    }
565}
566
567fn command_git_head() -> GitHeadInfo {
568    let cli = |args: &[&str]| GitCommandExecutor::default().exec(args).unwrap_or_default();
569    GitHeadInfo {
570        commit: cli(&["rev-parse", "HEAD"]),
571        short_commit: cli(&["rev-parse", "--short", "HEAD"]),
572        author: cli(&["log", "-1", "--pretty=format:%an"]),
573        email: cli(&["log", "-1", "--pretty=format:%ae"]),
574        date: cli(&["show", "--pretty=format:%ct", "--date=raw", "-s"]),
575        date_iso: cli(&["log", "-1", "--pretty=format:%cI"]),
576    }
577}
578
579/// Command exec git current tag
580fn command_current_tag() -> Option<String> {
581    GitCommandExecutor::default().exec(&["tag", "-l", "--contains", "HEAD"])
582}
583
584/// git describe --tags HEAD
585/// Command exec git describe
586fn command_git_describe() -> (Option<String>, Option<usize>, Option<String>) {
587    let last_tag =
588        GitCommandExecutor::default().exec(&["describe", "--tags", "--abbrev=0", "HEAD"]);
589    if last_tag.is_none() {
590        return (None, None, None);
591    }
592
593    let tag = last_tag.unwrap();
594
595    let describe = GitCommandExecutor::default().exec(&["describe", "--tags", "HEAD"]);
596    if let Some(desc) = describe {
597        match parse_git_describe(&tag, &desc) {
598            Ok((tag, commits, hash)) => {
599                return (Some(tag), commits, hash);
600            }
601            Err(_) => {
602                return (Some(tag), None, None);
603            }
604        }
605    }
606    (Some(tag), None, None)
607}
608
609fn parse_git_describe(
610    last_tag: &str,
611    describe: &str,
612) -> SdResult<(String, Option<usize>, Option<String>)> {
613    if !describe.starts_with(last_tag) {
614        return Err(ShadowError::String("git describe result error".to_string()));
615    }
616
617    if last_tag == describe {
618        return Ok((describe.to_string(), None, None));
619    }
620
621    let parts: Vec<&str> = describe.rsplit('-').collect();
622
623    if parts.is_empty() || parts.len() == 2 {
624        return Err(ShadowError::String(
625            "git describe result error,expect:<tag>-<num_commits>-g<hash>".to_string(),
626        ));
627    }
628
629    if parts.len() > 2 {
630        let short_hash = parts[0]; // last part
631
632        if !short_hash.starts_with('g') {
633            return Err(ShadowError::String(
634                "git describe result error,expect commit hash end with:-g<hash>".to_string(),
635            ));
636        }
637        let short_hash = short_hash.trim_start_matches('g');
638
639        // Full example:v1.0.0-alpha0-5-ga1b2c3d
640        let num_commits_str = parts[1];
641        let num_commits = num_commits_str
642            .parse::<usize>()
643            .map_err(|e| ShadowError::String(e.to_string()))?;
644        let last_tag = parts[2..]
645            .iter()
646            .rev()
647            .copied()
648            .collect::<Vec<_>>()
649            .join("-");
650        return Ok((last_tag, Some(num_commits), Some(short_hash.to_string())));
651    }
652    Ok((describe.to_string(), None, None))
653}
654
655/// git clean:git status --porcelain
656/// check repository git status is clean
657fn command_git_clean() -> bool {
658    GitCommandExecutor::default()
659        .exec(&["status", "--porcelain"])
660        .map(|x| x.is_empty())
661        .unwrap_or(true)
662}
663
664/// check git repository 'dirty or stage' status files.
665/// git dirty:git status  --porcelain | grep '^\sM.' |awk '{print $2}'
666/// git stage:git status --porcelain --untracked-files=all | grep '^[A|M|D|R]'|awk '{print $2}'
667fn command_git_status_file() -> String {
668    let git_status_files =
669        move |args: &[&str], grep: &[&str], awk: &[&str]| -> SdResult<Vec<String>> {
670            let git_shell = Command::new("git")
671                .env("GIT_OPTIONAL_LOCKS", "0")
672                .args(args)
673                .stdin(Stdio::piped())
674                .stdout(Stdio::piped())
675                .spawn()?;
676            let git_out = git_shell.stdout.ok_or("Failed to exec git stdout")?;
677
678            let grep_shell = Command::new("grep")
679                .args(grep)
680                .stdin(Stdio::from(git_out))
681                .stdout(Stdio::piped())
682                .spawn()?;
683            let grep_out = grep_shell.stdout.ok_or("Failed to exec grep stdout")?;
684
685            let mut awk_shell = Command::new("awk")
686                .args(awk)
687                .stdin(Stdio::from(grep_out))
688                .stdout(Stdio::piped())
689                .spawn()?;
690            let mut awk_out = BufReader::new(
691                awk_shell
692                    .stdout
693                    .as_mut()
694                    .ok_or("Failed to exec awk stdout")?,
695            );
696            let mut line = String::new();
697            awk_out.read_to_string(&mut line)?;
698            Ok(line.lines().map(|x| x.into()).collect())
699        };
700
701    let dirty = git_status_files(&["status", "--porcelain"], &[r"^\sM."], &["{print $2}"])
702        .unwrap_or_default();
703
704    let stage = git_status_files(
705        &["status", "--porcelain", "--untracked-files=all"],
706        &[r#"^[A|M|D|R]"#],
707        &["{print $2}"],
708    )
709    .unwrap_or_default();
710    filter_git_dirty_stage(dirty, stage)
711}
712
713/// Command exec git current branch
714fn command_current_branch() -> Option<String> {
715    find_branch_in(Path::new("."))
716}
717
718fn find_branch_in(path: &Path) -> Option<String> {
719    GitCommandExecutor::new(path).exec(&["symbolic-ref", "--short", "HEAD"])
720}
721
722fn filter_git_dirty_stage(dirty_files: Vec<String>, staged_files: Vec<String>) -> String {
723    let mut concat_file = String::new();
724    for file in dirty_files {
725        concat_file.push_str("  * ");
726        concat_file.push_str(&file);
727        concat_file.push_str(" (dirty)\n");
728    }
729    for file in staged_files {
730        concat_file.push_str("  * ");
731        concat_file.push_str(&file);
732        concat_file.push_str(" (staged)\n");
733    }
734    concat_file
735}
736
737#[cfg(test)]
738mod tests {
739    use super::*;
740    use crate::get_std_env;
741
742    #[test]
743    fn test_git() {
744        let env_map = get_std_env();
745        let map = new_git(Path::new("./"), CiType::Github, &env_map);
746        for (k, v) in map {
747            assert!(!v.desc.is_empty());
748            if !k.eq(TAG)
749                && !k.eq(LAST_TAG)
750                && !k.eq(COMMITS_SINCE_TAG)
751                && !k.eq(BRANCH)
752                && !k.eq(GIT_STATUS_FILE)
753            {
754                assert!(!v.v.is_empty());
755                continue;
756            }
757
758            //assert github tag always exist value
759            if let Some(github_ref) = env_map.get("GITHUB_REF") {
760                if github_ref.starts_with("refs/tags/") && k.eq(TAG) {
761                    assert!(!v.v.is_empty(), "not empty");
762                } else if github_ref.starts_with("refs/heads/") && k.eq(BRANCH) {
763                    assert!(!v.v.is_empty());
764                }
765            }
766        }
767    }
768
769    #[test]
770    fn test_current_branch() {
771        if get_std_env().contains_key("GITHUB_REF") {
772            return;
773        }
774        #[cfg(feature = "git2")]
775        {
776            use crate::git::git2_mod::{git2_current_branch, git_repo};
777            let git2_branch = git_repo(".")
778                .map(|x| git2_current_branch(&x))
779                .unwrap_or(None);
780            let command_branch = command_current_branch();
781            assert!(git2_branch.is_some());
782            assert!(command_branch.is_some());
783            assert_eq!(command_branch, git2_branch);
784        }
785
786        assert_eq!(Some(branch()), command_current_branch());
787    }
788
789    #[test]
790    fn test_parse_git_describe() {
791        let commit_hash = "24skp4489";
792        let describe = "v1.0.0";
793        assert_eq!(
794            parse_git_describe("v1.0.0", describe).unwrap(),
795            (describe.into(), None, None)
796        );
797
798        let describe = "v1.0.0-0-g24skp4489";
799        assert_eq!(
800            parse_git_describe("v1.0.0", describe).unwrap(),
801            ("v1.0.0".into(), Some(0), Some(commit_hash.into()))
802        );
803
804        let describe = "v1.0.0-1-g24skp4489";
805        assert_eq!(
806            parse_git_describe("v1.0.0", describe).unwrap(),
807            ("v1.0.0".into(), Some(1), Some(commit_hash.into()))
808        );
809
810        let describe = "v1.0.0-alpha-0-g24skp4489";
811        assert_eq!(
812            parse_git_describe("v1.0.0-alpha", describe).unwrap(),
813            ("v1.0.0-alpha".into(), Some(0), Some(commit_hash.into()))
814        );
815
816        let describe = "v1.0.0.alpha-0-g24skp4489";
817        assert_eq!(
818            parse_git_describe("v1.0.0.alpha", describe).unwrap(),
819            ("v1.0.0.alpha".into(), Some(0), Some(commit_hash.into()))
820        );
821
822        let describe = "v1.0.0-alpha";
823        assert_eq!(
824            parse_git_describe("v1.0.0-alpha", describe).unwrap(),
825            ("v1.0.0-alpha".into(), None, None)
826        );
827
828        let describe = "v1.0.0-alpha-99-0-g24skp4489";
829        assert_eq!(
830            parse_git_describe("v1.0.0-alpha-99", describe).unwrap(),
831            ("v1.0.0-alpha-99".into(), Some(0), Some(commit_hash.into()))
832        );
833
834        let describe = "v1.0.0-alpha-99-024skp4489";
835        assert!(parse_git_describe("v1.0.0-alpha-99", describe).is_err());
836
837        let describe = "v1.0.0-alpha-024skp4489";
838        assert!(parse_git_describe("v1.0.0-alpha", describe).is_err());
839
840        let describe = "v1.0.0-alpha-024skp4489";
841        assert!(parse_git_describe("v1.0.0-alpha", describe).is_err());
842
843        let describe = "v1.0.0-alpha-g024skp4489";
844        assert!(parse_git_describe("v1.0.0-alpha", describe).is_err());
845
846        let describe = "v1.0.0----alpha-g024skp4489";
847        assert!(parse_git_describe("v1.0.0----alpha", describe).is_err());
848    }
849
850    #[test]
851    fn test_commit_date_timezone_preservation() {
852        use crate::DateTime;
853
854        // Test timezone-aware parsing
855        let iso_date = "2021-08-04T12:34:03+08:00";
856        let date_time = DateTime::from_iso8601_string(iso_date).unwrap();
857        assert_eq!(date_time.human_format(), "2021-08-04 12:34:03 +08:00");
858        assert!(date_time.to_rfc3339().contains("+08:00"));
859
860        // Test UTC timezone
861        let iso_date_utc = "2021-08-04T12:34:03Z";
862        let date_time_utc = DateTime::from_iso8601_string(iso_date_utc).unwrap();
863        assert_eq!(date_time_utc.human_format(), "2021-08-04 12:34:03 +00:00");
864
865        // Test negative timezone
866        let iso_date_neg = "2021-08-04T12:34:03-05:00";
867        let date_time_neg = DateTime::from_iso8601_string(iso_date_neg).unwrap();
868        assert_eq!(date_time_neg.human_format(), "2021-08-04 12:34:03 -05:00");
869    }
870}