• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

baoyachi / shadow-rs / 20375259562

19 Dec 2025 03:57PM UTC coverage: 75.645% (-0.4%) from 76.036%
20375259562

Pull #251

github

web-flow
Merge 5c8807f33 into 9ee5fc761
Pull Request #251: feat: add Unix timestamp support for build and commit times

18 of 20 new or added lines in 5 files covered. (90.0%)

8 existing lines in 3 files now uncovered.

528 of 698 relevant lines covered (75.64%)

1.22 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

73.08
/src/git.rs
1
use crate::build::{ConstType, ConstVal, ShadowConst};
2
use crate::ci::CiType;
3
use crate::err::*;
4
use crate::{DateTime, Format};
5
use std::collections::BTreeMap;
6
use std::io::{BufReader, Read};
7
use std::path::Path;
8
use std::process::{Command, Stdio};
9

10
const BRANCH_DOC: &str = r#"
11
The name of the Git branch that this project was built from.
12
This constant will be empty if the branch cannot be determined."#;
13
pub const BRANCH: ShadowConst = "BRANCH";
14

15
const TAG_DOC: &str = r#"
16
The name of the Git tag that this project was built from.
17
Note that this will be empty if there is no tag for the HEAD at the time of build."#;
18
pub const TAG: ShadowConst = "TAG";
19

20
const LAST_TAG_DOC: &str = r#"
21
The name of the last Git tag on the branch that this project was built from.
22
As opposed to [`TAG`], this does not require the current commit to be tagged, just one of its parents.
23

24
This constant will be empty if the last tag cannot be determined."#;
25
pub const LAST_TAG: ShadowConst = "LAST_TAG";
26

27
pub const COMMITS_SINCE_TAG_DOC: &str = r#"
28
The number of commits since the last Git tag on the branch that this project was built from.
29
This value indicates how many commits have been made after the last tag and before the current commit.
30

31
If there are no additional commits after the last tag (i.e., the current commit is exactly at a tag),
32
this value will be `0`.
33

34
This constant will be empty or `0` if the last tag cannot be determined or if there are no commits after it.
35
"#;
36

37
pub const COMMITS_SINCE_TAG: &str = "COMMITS_SINCE_TAG";
38

39
const SHORT_COMMIT_DOC: &str = r#"
40
The short hash of the Git commit that this project was built from.
41
Note that this will always truncate [`COMMIT_HASH`] to 8 characters if necessary.
42
Depending 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

45
This constant will be empty if the last commit cannot be determined."#;
46
pub const SHORT_COMMIT: ShadowConst = "SHORT_COMMIT";
47

48
const COMMIT_HASH_DOC: &str = r#"
49
The full commit hash of the Git commit that this project was built from.
50
An abbreviated, but not necessarily unique, version of this is [`SHORT_COMMIT`].
51

52
This constant will be empty if the last commit cannot be determined."#;
53
pub const COMMIT_HASH: ShadowConst = "COMMIT_HASH";
54

55
const COMMIT_DATE_DOC: &str = r#"The time of the Git commit that this project was built from.
56
The time is formatted in modified ISO 8601 format (`YYYY-MM-DD HH-MM ±hh-mm` where hh-mm is the offset from UTC).
57
The timezone information from the original commit is preserved.
58

59
This constant will be empty if the last commit cannot be determined."#;
60
pub const COMMIT_DATE: ShadowConst = "COMMIT_DATE";
61

62
const COMMIT_DATE_2822_DOC: &str = r#"
63
The time of the Git commit that this project was built from.
64
The time is formatted according to [RFC 2822](https://datatracker.ietf.org/doc/html/rfc2822#section-3.3) (e.g. HTTP Headers).
65
The timezone information from the original commit is preserved.
66

67
This constant will be empty if the last commit cannot be determined."#;
68
pub const COMMIT_DATE_2822: ShadowConst = "COMMIT_DATE_2822";
69

70
const COMMIT_DATE_3339_DOC: &str = r#"
71
The time of the Git commit that this project was built from.
72
The time is formatted according to [RFC 3339 and ISO 8601](https://datatracker.ietf.org/doc/html/rfc3339#section-5.6).
73
The timezone information from the original commit is preserved.
74

75
This constant will be empty if the last commit cannot be determined."#;
76
pub const COMMIT_DATE_3339: ShadowConst = "COMMIT_DATE_3339";
77

78
const COMMIT_TIMESTAMP_DOC: &str = r#"
79
The time of the Git commit as a Unix timestamp (seconds since Unix epoch).
80

81
This constant will be empty if the last commit cannot be determined."#;
82
pub const COMMIT_TIMESTAMP: ShadowConst = "COMMIT_TIMESTAMP";
83

84
const COMMIT_AUTHOR_DOC: &str = r#"
85
The author of the Git commit that this project was built from.
86

87
This constant will be empty if the last commit cannot be determined."#;
88
pub const COMMIT_AUTHOR: ShadowConst = "COMMIT_AUTHOR";
89

90
const COMMIT_EMAIL_DOC: &str = r#"
91
The e-mail address of the author of the Git commit that this project was built from.
92

93
This constant will be empty if the last commit cannot be determined."#;
94
pub const COMMIT_EMAIL: ShadowConst = "COMMIT_EMAIL";
95

96
const GIT_CLEAN_DOC: &str = r#"
97
Whether the Git working tree was clean at the time of project build (`true`), or not (`false`).
98

99
This constant will be `false` if the last commit cannot be determined."#;
100
pub const GIT_CLEAN: ShadowConst = "GIT_CLEAN";
101

102
const GIT_STATUS_FILE_DOC: &str = r#"
103
The Git working tree status as a list of files with their status, similar to `git status`.
104
Each line of the list is preceded with `  * `, followed by the file name.
105
Files marked `(dirty)` have unstaged changes.
106
Files marked `(staged)` have staged changes.
107

108
This constant will be empty if the working tree status cannot be determined."#;
109
pub const GIT_STATUS_FILE: ShadowConst = "GIT_STATUS_FILE";
110

111
#[derive(Default, Debug)]
112
pub struct Git {
113
    map: BTreeMap<ShadowConst, ConstVal>,
114
    ci_type: CiType,
115
}
116

117
impl Git {
118
    fn update_str(&mut self, c: ShadowConst, v: String) {
2✔
119
        if let Some(val) = self.map.get_mut(c) {
6✔
120
            *val = ConstVal {
2✔
121
                desc: val.desc.clone(),
2✔
122
                v,
2✔
123
                t: ConstType::Str,
124
            }
125
        }
126
    }
127

128
    fn update_bool(&mut self, c: ShadowConst, v: bool) {
2✔
129
        if let Some(val) = self.map.get_mut(c) {
4✔
130
            *val = ConstVal {
2✔
131
                desc: val.desc.clone(),
2✔
132
                v: v.to_string(),
2✔
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) {
2✔
149
        if let Some(val) = self.map.get_mut(c) {
4✔
150
            *val = ConstVal {
2✔
151
                desc: val.desc.clone(),
2✔
152
                v: v.to_string(),
2✔
153
                t: ConstType::Int,
154
            }
155
        }
156
    }
157

158
    fn init(&mut self, path: &Path, std_env: &BTreeMap<String, String>) -> SdResult<()> {
1✔
159
        // First, try executing using the git command.
160
        if let Err(err) = self.init_git() {
1✔
161
            println!("{err}");
×
162
        }
163

164
        // If the git2 feature is enabled, then replace the corresponding values with git2.
165
        self.init_git2(path)?;
2✔
166

167
        // use command branch
168
        if let Some(x) = find_branch_in(path) {
2✔
169
            self.update_str(BRANCH, x)
1✔
170
        };
171

172
        // use command tag
173
        if let Some(x) = command_current_tag() {
1✔
174
            self.update_str(TAG, x)
1✔
175
        }
176

177
        // use command get last tag
178
        let describe = command_git_describe();
1✔
179
        if let Some(x) = describe.0 {
1✔
180
            self.update_str(LAST_TAG, x)
2✔
181
        }
182

183
        if let Some(x) = describe.1 {
1✔
184
            self.update_usize(COMMITS_SINCE_TAG, x)
×
185
        }
186

187
        // try use ci branch,tag
188
        self.ci_branch_tag(std_env);
1✔
189
        Ok(())
1✔
190
    }
191

192
    fn init_git(&mut self) -> SdResult<()> {
1✔
193
        // check git status
194
        let x = command_git_clean();
1✔
195
        self.update_bool(GIT_CLEAN, x);
2✔
196

197
        let x = command_git_status_file();
2✔
198
        self.update_str(GIT_STATUS_FILE, x);
2✔
199

200
        let git_info = command_git_head();
2✔
201

202
        self.update_str(COMMIT_EMAIL, git_info.email);
1✔
203
        self.update_str(COMMIT_AUTHOR, git_info.author);
1✔
204
        self.update_str(SHORT_COMMIT, git_info.short_commit);
1✔
205
        self.update_str(COMMIT_HASH, git_info.commit);
1✔
206

207
        // Try to parse ISO format with timezone first, fallback to UTC timestamp
208
        if !git_info.date_iso.is_empty() {
1✔
209
            if let Ok(date_time) = DateTime::from_iso8601_string(&git_info.date_iso) {
3✔
210
                self.update_str(COMMIT_DATE, date_time.human_format());
3✔
211
                self.update_str(COMMIT_DATE_2822, date_time.to_rfc2822());
2✔
212
                self.update_str(COMMIT_DATE_3339, date_time.to_rfc3339());
2✔
213
                self.update_int(COMMIT_TIMESTAMP, date_time.timestamp());
2✔
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());
×
NEW
219
                    self.update_int(COMMIT_TIMESTAMP, date_time.timestamp());
×
220
                }
221
            }
222
        } else if let Ok(time_stamp) = git_info.date.parse::<i64>() {
2✔
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());
×
NEW
227
                self.update_int(COMMIT_TIMESTAMP, date_time.timestamp());
×
228
            }
229
        }
230

231
        Ok(())
2✔
232
    }
233

234
    #[allow(unused_variables)]
235
    fn init_git2(&mut self, path: &Path) -> SdResult<()> {
2✔
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)?;
2✔
243
            let reference = repo.head().map_err(ShadowError::new)?;
4✔
244

245
            //get branch
246
            let branch = reference
247
                .shorthand()
248
                .map(|x| x.trim().to_string())
6✔
249
                .or_else(command_current_branch)
2✔
250
                .unwrap_or_default();
251

252
            //get HEAD branch
253
            let tag = command_current_tag().unwrap_or_default();
4✔
254
            self.update_str(BRANCH, branch);
2✔
255
            self.update_str(TAG, tag);
2✔
256

257
            // use command get last tag
258
            let describe = command_git_describe();
2✔
259
            if let Some(x) = describe.0 {
1✔
260
                self.update_str(LAST_TAG, x)
2✔
261
            }
262

263
            if let Some(x) = describe.1 {
1✔
264
                self.update_usize(COMMITS_SINCE_TAG, x)
×
265
            }
266

267
            if let Some(v) = reference.target() {
2✔
268
                let commit = v.to_string();
1✔
269
                self.update_str(COMMIT_HASH, commit.clone());
2✔
270
                let mut short_commit = commit.as_str();
1✔
271

272
                if commit.len() > 8 {
2✔
273
                    short_commit = short_commit.get(0..8).unwrap();
1✔
274
                }
275
                self.update_str(SHORT_COMMIT, short_commit.to_string());
2✔
276
            }
277

278
            let commit = reference.peel_to_commit().map_err(ShadowError::new)?;
2✔
279

280
            let author = commit.author();
3✔
281
            if let Some(v) = author.email() {
4✔
282
                self.update_str(COMMIT_EMAIL, v.to_string());
4✔
283
            }
284

285
            if let Some(v) = author.name() {
4✔
286
                self.update_str(COMMIT_AUTHOR, v.to_string());
4✔
287
            }
288
            let status_file = Self::git2_dirty_stage(&repo);
4✔
289
            if status_file.trim().is_empty() {
4✔
290
                self.update_bool(GIT_CLEAN, true);
4✔
291
            } else {
292
                self.update_bool(GIT_CLEAN, false);
×
293
            }
294
            self.update_str(GIT_STATUS_FILE, status_file);
2✔
295

296
            let commit_time = commit.time();
2✔
297
            let time_stamp = commit_time.seconds();
2✔
298
            let offset_minutes = commit_time.offset_minutes();
2✔
299

300
            // Create OffsetDateTime with the commit's timezone
301
            if let Ok(utc_time) = time::OffsetDateTime::from_unix_timestamp(time_stamp) {
4✔
302
                if let Ok(offset) = time::UtcOffset::from_whole_seconds(offset_minutes * 60) {
4✔
303
                    let local_time = utc_time.to_offset(offset);
2✔
304
                    let date_time = DateTime::Local(local_time);
2✔
305

306
                    self.update_str(COMMIT_DATE, date_time.human_format());
2✔
307
                    self.update_str(COMMIT_DATE_2822, date_time.to_rfc2822());
2✔
308
                    self.update_str(COMMIT_DATE_3339, date_time.to_rfc3339());
2✔
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(())
2✔
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 {
2✔
324
        let mut repo_opts = git2::StatusOptions::new();
2✔
325
        repo_opts.include_ignored(false);
2✔
326
        if let Ok(statue) = repo.statuses(Some(&mut repo_opts)) {
4✔
327
            let mut dirty_files = Vec::new();
2✔
328
            let mut staged_files = Vec::new();
2✔
329

330
            for status in statue.iter() {
4✔
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)
2✔
344
        } else {
345
            "".into()
×
346
        }
347
    }
348

349
    #[allow(clippy::manual_strip)]
350
    fn ci_branch_tag(&mut self, std_env: &BTreeMap<String, String>) {
1✔
351
        let mut branch: Option<String> = None;
1✔
352
        let mut tag: Option<String> = None;
1✔
353
        match self.ci_type {
1✔
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") {
2✔
363
                    let ref_branch_prefix: &str = "refs/heads/";
1✔
364
                    let ref_tag_prefix: &str = "refs/tags/";
1✔
365

366
                    if v.starts_with(ref_branch_prefix) {
1✔
UNCOV
367
                        branch = Some(
×
UNCOV
368
                            v.get(ref_branch_prefix.len()..)
×
UNCOV
369
                                .unwrap_or_default()
×
UNCOV
370
                                .to_string(),
×
371
                        )
372
                    } else if v.starts_with(ref_tag_prefix) {
2✔
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 {
1✔
UNCOV
384
            self.update_str(BRANCH, x);
×
385
        }
386

387
        if let Some(x) = tag {
1✔
388
            self.update_str(TAG, x.clone());
×
389
            self.update_str(LAST_TAG, x);
×
390
        }
391
    }
392
}
393

394
pub(crate) fn new_git(
1✔
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(),
1✔
401
        ci_type: ci,
402
    };
403
    git.map.insert(BRANCH, ConstVal::new(BRANCH_DOC));
2✔
404

405
    git.map.insert(TAG, ConstVal::new(TAG_DOC));
1✔
406

407
    git.map.insert(LAST_TAG, ConstVal::new(LAST_TAG_DOC));
1✔
408

409
    git.map.insert(
1✔
410
        COMMITS_SINCE_TAG,
411
        ConstVal::new_usize(COMMITS_SINCE_TAG_DOC),
1✔
412
    );
413

414
    git.map.insert(COMMIT_HASH, ConstVal::new(COMMIT_HASH_DOC));
1✔
415

416
    git.map
417
        .insert(SHORT_COMMIT, ConstVal::new(SHORT_COMMIT_DOC));
1✔
418

419
    git.map
420
        .insert(COMMIT_AUTHOR, ConstVal::new(COMMIT_AUTHOR_DOC));
1✔
421
    git.map
422
        .insert(COMMIT_EMAIL, ConstVal::new(COMMIT_EMAIL_DOC));
1✔
423
    git.map.insert(COMMIT_DATE, ConstVal::new(COMMIT_DATE_DOC));
1✔
424

425
    git.map
426
        .insert(COMMIT_DATE_2822, ConstVal::new(COMMIT_DATE_2822_DOC));
1✔
427

428
    git.map
429
        .insert(COMMIT_DATE_3339, ConstVal::new(COMMIT_DATE_3339_DOC));
1✔
430

431
    git.map
432
        .insert(COMMIT_TIMESTAMP, ConstVal::new(COMMIT_TIMESTAMP_DOC));
1✔
433

434
    git.map.insert(GIT_CLEAN, ConstVal::new_bool(GIT_CLEAN_DOC));
1✔
435

436
    git.map
437
        .insert(GIT_STATUS_FILE, ConstVal::new(GIT_STATUS_FILE_DOC));
1✔
438

439
    if let Err(e) = git.init(path, std_env) {
1✔
440
        println!("{e}");
×
441
    }
442

443
    git.map
1✔
444
}
445

446
#[cfg(feature = "git2")]
447
pub 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> {
2✔
453
        Repository::discover(path)
2✔
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.
469
pub 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.
488
pub 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
495
pub 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)`
515
pub 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

529
struct GitHeadInfo {
530
    commit: String,
531
    short_commit: String,
532
    email: String,
533
    author: String,
534
    date: String,
535
    date_iso: String,
536
}
537

538
struct GitCommandExecutor<'a> {
539
    path: &'a Path,
540
}
541

542
impl Default for GitCommandExecutor<'_> {
543
    fn default() -> Self {
1✔
544
        Self::new(Path::new("."))
1✔
545
    }
546
}
547

548
impl<'a> GitCommandExecutor<'a> {
549
    fn new(path: &'a Path) -> Self {
1✔
550
        GitCommandExecutor { path }
551
    }
552

553
    fn exec(&self, args: &[&str]) -> Option<String> {
1✔
554
        Command::new("git")
1✔
555
            .env("GIT_OPTIONAL_LOCKS", "0")
556
            .current_dir(self.path)
1✔
557
            .args(args)
1✔
558
            .output()
559
            .map(|x| {
4✔
560
                String::from_utf8(x.stdout)
2✔
561
                    .map(|x| x.trim().to_string())
6✔
562
                    .ok()
2✔
563
            })
564
            .unwrap_or(None)
2✔
565
    }
566
}
567

568
fn command_git_head() -> GitHeadInfo {
2✔
569
    let cli = |args: &[&str]| GitCommandExecutor::default().exec(args).unwrap_or_default();
4✔
570
    GitHeadInfo {
571
        commit: cli(&["rev-parse", "HEAD"]),
2✔
572
        short_commit: cli(&["rev-parse", "--short", "HEAD"]),
1✔
573
        author: cli(&["log", "-1", "--pretty=format:%an"]),
1✔
574
        email: cli(&["log", "-1", "--pretty=format:%ae"]),
1✔
575
        date: cli(&["show", "--pretty=format:%ct", "--date=raw", "-s"]),
1✔
576
        date_iso: cli(&["log", "-1", "--pretty=format:%cI"]),
2✔
577
    }
578
}
579

580
/// Command exec git current tag
581
fn command_current_tag() -> Option<String> {
2✔
582
    GitCommandExecutor::default().exec(&["tag", "-l", "--contains", "HEAD"])
2✔
583
}
584

585
/// git describe --tags HEAD
586
/// Command exec git describe
587
fn command_git_describe() -> (Option<String>, Option<usize>, Option<String>) {
2✔
588
    let last_tag =
2✔
589
        GitCommandExecutor::default().exec(&["describe", "--tags", "--abbrev=0", "HEAD"]);
590
    if last_tag.is_none() {
2✔
591
        return (None, None, None);
×
592
    }
593

594
    let tag = last_tag.unwrap();
2✔
595

596
    let describe = GitCommandExecutor::default().exec(&["describe", "--tags", "HEAD"]);
2✔
597
    if let Some(desc) = describe {
1✔
598
        match parse_git_describe(&tag, &desc) {
2✔
599
            Ok((tag, commits, hash)) => {
1✔
600
                return (Some(tag), commits, hash);
1✔
601
            }
602
            Err(_) => {
603
                return (Some(tag), None, None);
×
604
            }
605
        }
606
    }
607
    (Some(tag), None, None)
×
608
}
609

610
fn parse_git_describe(
1✔
611
    last_tag: &str,
612
    describe: &str,
613
) -> SdResult<(String, Option<usize>, Option<String>)> {
614
    if !describe.starts_with(last_tag) {
1✔
615
        return Err(ShadowError::String("git describe result error".to_string()));
×
616
    }
617

618
    if last_tag == describe {
1✔
619
        return Ok((describe.to_string(), None, None));
1✔
620
    }
621

622
    let parts: Vec<&str> = describe.rsplit('-').collect();
1✔
623

624
    if parts.is_empty() || parts.len() == 2 {
3✔
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 {
1✔
631
        let short_hash = parts[0]; // last part
2✔
632

633
        if !short_hash.starts_with('g') {
1✔
634
            return Err(ShadowError::String(
1✔
635
                "git describe result error,expect commit hash end with:-g<hash>".to_string(),
1✔
636
            ));
637
        }
638
        let short_hash = short_hash.trim_start_matches('g');
2✔
639

640
        // Full example:v1.0.0-alpha0-5-ga1b2c3d
641
        let num_commits_str = parts[1];
1✔
642
        let num_commits = num_commits_str
3✔
643
            .parse::<usize>()
644
            .map_err(|e| ShadowError::String(e.to_string()))?;
4✔
645
        let last_tag = parts[2..]
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())));
1✔
652
    }
653
    Ok((describe.to_string(), None, None))
×
654
}
655

656
/// git clean:git status --porcelain
657
/// check repository git status is clean
658
fn command_git_clean() -> bool {
1✔
659
    GitCommandExecutor::default()
1✔
660
        .exec(&["status", "--porcelain"])
1✔
661
        .map(|x| x.is_empty())
6✔
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}'
668
fn command_git_status_file() -> String {
2✔
669
    let git_status_files =
4✔
670
        move |args: &[&str], grep: &[&str], awk: &[&str]| -> SdResult<Vec<String>> {
671
            let git_shell = Command::new("git")
3✔
672
                .env("GIT_OPTIONAL_LOCKS", "0")
673
                .args(args)
2✔
674
                .stdin(Stdio::piped())
2✔
675
                .stdout(Stdio::piped())
2✔
676
                .spawn()?;
677
            let git_out = git_shell.stdout.ok_or("Failed to exec git stdout")?;
1✔
678

679
            let grep_shell = Command::new("grep")
3✔
680
                .args(grep)
2✔
681
                .stdin(Stdio::from(git_out))
2✔
682
                .stdout(Stdio::piped())
2✔
683
                .spawn()?;
684
            let grep_out = grep_shell.stdout.ok_or("Failed to exec grep stdout")?;
1✔
685

686
            let mut awk_shell = Command::new("awk")
3✔
687
                .args(awk)
1✔
688
                .stdin(Stdio::from(grep_out))
1✔
689
                .stdout(Stdio::piped())
1✔
690
                .spawn()?;
691
            let mut awk_out = BufReader::new(
692
                awk_shell
4✔
693
                    .stdout
694
                    .as_mut()
2✔
695
                    .ok_or("Failed to exec awk stdout")?,
2✔
696
            );
697
            let mut line = String::new();
2✔
698
            awk_out.read_to_string(&mut line)?;
3✔
699
            Ok(line.lines().map(|x| x.into()).collect())
1✔
700
        };
701

702
    let dirty = git_status_files(&["status", "--porcelain"], &[r"^\sM."], &["{print $2}"])
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)
1✔
712
}
713

714
/// Command exec git current branch
715
fn command_current_branch() -> Option<String> {
×
716
    find_branch_in(Path::new("."))
×
717
}
718

719
fn find_branch_in(path: &Path) -> Option<String> {
2✔
720
    GitCommandExecutor::new(path).exec(&["symbolic-ref", "--short", "HEAD"])
2✔
721
}
722

723
fn filter_git_dirty_stage(dirty_files: Vec<String>, staged_files: Vec<String>) -> String {
2✔
724
    let mut concat_file = String::new();
2✔
725
    for file in dirty_files {
6✔
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 {
4✔
731
        concat_file.push_str("  * ");
×
732
        concat_file.push_str(&file);
×
733
        concat_file.push_str(" (staged)\n");
×
734
    }
735
    concat_file
2✔
736
}
737

738
#[cfg(test)]
739
mod 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
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc