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

baoyachi / shadow-rs / 24725141756

21 Apr 2026 01:29PM UTC coverage: 76.989% (+0.1%) from 76.89%
24725141756

Pull #259

github

web-flow
Merge 2fd74c8ba into 544adb473
Pull Request #259: refactor: use jiff as the datetime lib

34 of 41 new or added lines in 2 files covered. (82.93%)

5 existing lines in 1 file now uncovered.

542 of 704 relevant lines covered (76.99%)

1.08 hits per line

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

74.3
/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) {
1✔
119
        if let Some(val) = self.map.get_mut(c) {
3✔
120
            *val = ConstVal {
1✔
121
                desc: val.desc.clone(),
1✔
122
                v,
1✔
123
                t: ConstType::Str,
124
            }
125
        }
126
    }
127

128
    fn update_bool(&mut self, c: ShadowConst, v: bool) {
1✔
129
        if let Some(val) = self.map.get_mut(c) {
2✔
130
            *val = ConstVal {
1✔
131
                desc: val.desc.clone(),
1✔
132
                v: v.to_string(),
1✔
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) {
1✔
149
        if let Some(val) = self.map.get_mut(c) {
3✔
150
            *val = ConstVal {
2✔
151
                desc: val.desc.clone(),
1✔
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);
1✔
196

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

200
        let git_info = command_git_head();
1✔
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());
2✔
211
                self.update_str(COMMIT_DATE_2822, date_time.to_rfc2822());
1✔
212
                self.update_str(COMMIT_DATE_3339, date_time.to_rfc3339());
1✔
213
                self.update_int(COMMIT_TIMESTAMP, date_time.timestamp());
1✔
214
            } else if let Ok(time_stamp) = git_info.date.parse::<i64>() {
2✔
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>() {
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());
×
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();
3✔
254
            self.update_str(BRANCH, branch);
1✔
255
            self.update_str(TAG, tag);
1✔
256

257
            // use command get last tag
258
            let describe = command_git_describe();
1✔
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();
2✔
281
            if let Some(v) = author.email() {
2✔
282
                self.update_str(COMMIT_EMAIL, v.to_string());
2✔
283
            }
284

285
            if let Some(v) = author.name() {
2✔
286
                self.update_str(COMMIT_AUTHOR, v.to_string());
2✔
287
            }
288
            let status_file = Self::git2_dirty_stage(&repo);
3✔
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 DateTime with the commit's timezone
301
            if let Ok(utc_time) = jiff::Timestamp::from_second(time_stamp) {
4✔
302
                let date_time =
6✔
303
                    if let Ok(offset) = jiff::tz::Offset::from_seconds(offset_minutes * 60) {
304
                        let tz = jiff::tz::TimeZone::fixed(offset);
4✔
305
                        DateTime::new(utc_time.to_zoned(tz))
2✔
306
                    } else {
307
                        // Fallback to UTC if offset parsing fails
308
                        let tz = jiff::tz::TimeZone::UTC;
2✔
NEW
309
                        DateTime::new(utc_time.to_zoned(tz))
×
310
                    };
311

312
                self.update_str(COMMIT_DATE, date_time.human_format());
2✔
313
                self.update_str(COMMIT_DATE_2822, date_time.to_rfc2822());
2✔
314
                self.update_str(COMMIT_DATE_3339, date_time.to_rfc3339());
2✔
315
            }
316
        }
317
        Ok(())
2✔
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 {
1✔
323
        let mut repo_opts = git2::StatusOptions::new();
1✔
324
        repo_opts.include_ignored(false);
1✔
325
        if let Ok(statue) = repo.statuses(Some(&mut repo_opts)) {
2✔
326
            let mut dirty_files = Vec::new();
1✔
327
            let mut staged_files = Vec::new();
2✔
328

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

442
    git.map
1✔
443
}
444

445
#[cfg(feature = "git2")]
446
pub 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> {
2✔
452
        Repository::discover(path)
2✔
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.
468
pub 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.
487
pub 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
494
pub 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)`
514
pub 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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

737
#[cfg(test)]
738
mod 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
}
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