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

baoyachi / shadow-rs / 20374927762

19 Dec 2025 03:44PM UTC coverage: 76.036% (-1.1%) from 77.182%
20374927762

push

github

web-flow
Merge pull request #250 from baoyachi/commit_data

fix: preserve commit timezone in COMMIT_DATE constants

20 of 33 new or added lines in 2 files covered. (60.61%)

514 of 676 relevant lines covered (76.04%)

1.12 hits per line

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

74.37
/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_AUTHOR_DOC: &str = r#"
79
The author of the Git commit that this project was built from.
80

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

84
const COMMIT_EMAIL_DOC: &str = r#"
85
The e-mail address of 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_EMAIL: ShadowConst = "COMMIT_EMAIL";
89

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

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

96
const GIT_STATUS_FILE_DOC: &str = r#"
97
The Git working tree status as a list of files with their status, similar to `git status`.
98
Each line of the list is preceded with `  * `, followed by the file name.
99
Files marked `(dirty)` have unstaged changes.
100
Files marked `(staged)` have staged changes.
101

102
This constant will be empty if the working tree status cannot be determined."#;
103
pub const GIT_STATUS_FILE: ShadowConst = "GIT_STATUS_FILE";
104

105
#[derive(Default, Debug)]
106
pub struct Git {
107
    map: BTreeMap<ShadowConst, ConstVal>,
108
    ci_type: CiType,
109
}
110

111
impl Git {
112
    fn update_str(&mut self, c: ShadowConst, v: String) {
1✔
113
        if let Some(val) = self.map.get_mut(c) {
3✔
114
            *val = ConstVal {
1✔
115
                desc: val.desc.clone(),
1✔
116
                v,
1✔
117
                t: ConstType::Str,
118
            }
119
        }
120
    }
121

122
    fn update_bool(&mut self, c: ShadowConst, v: bool) {
1✔
123
        if let Some(val) = self.map.get_mut(c) {
4✔
124
            *val = ConstVal {
2✔
125
                desc: val.desc.clone(),
2✔
126
                v: v.to_string(),
2✔
127
                t: ConstType::Bool,
128
            }
129
        }
130
    }
131

132
    fn update_usize(&mut self, c: ShadowConst, v: usize) {
×
133
        if let Some(val) = self.map.get_mut(c) {
×
134
            *val = ConstVal {
×
135
                desc: val.desc.clone(),
×
136
                v: v.to_string(),
×
137
                t: ConstType::Usize,
138
            }
139
        }
140
    }
141

142
    fn init(&mut self, path: &Path, std_env: &BTreeMap<String, String>) -> SdResult<()> {
1✔
143
        // First, try executing using the git command.
144
        if let Err(err) = self.init_git() {
1✔
145
            println!("{err}");
×
146
        }
147

148
        // If the git2 feature is enabled, then replace the corresponding values with git2.
149
        self.init_git2(path)?;
1✔
150

151
        // use command branch
152
        if let Some(x) = find_branch_in(path) {
1✔
153
            self.update_str(BRANCH, x)
1✔
154
        };
155

156
        // use command tag
157
        if let Some(x) = command_current_tag() {
1✔
158
            self.update_str(TAG, x)
2✔
159
        }
160

161
        // use command get last tag
162
        let describe = command_git_describe();
2✔
163
        if let Some(x) = describe.0 {
1✔
164
            self.update_str(LAST_TAG, x)
2✔
165
        }
166

167
        if let Some(x) = describe.1 {
1✔
168
            self.update_usize(COMMITS_SINCE_TAG, x)
×
169
        }
170

171
        // try use ci branch,tag
172
        self.ci_branch_tag(std_env);
1✔
173
        Ok(())
2✔
174
    }
175

176
    fn init_git(&mut self) -> SdResult<()> {
1✔
177
        // check git status
178
        let x = command_git_clean();
1✔
179
        self.update_bool(GIT_CLEAN, x);
1✔
180

181
        let x = command_git_status_file();
2✔
182
        self.update_str(GIT_STATUS_FILE, x);
1✔
183

184
        let git_info = command_git_head();
1✔
185

186
        self.update_str(COMMIT_EMAIL, git_info.email);
1✔
187
        self.update_str(COMMIT_AUTHOR, git_info.author);
1✔
188
        self.update_str(SHORT_COMMIT, git_info.short_commit);
1✔
189
        self.update_str(COMMIT_HASH, git_info.commit);
1✔
190

191
        // Try to parse ISO format with timezone first, fallback to UTC timestamp
192
        if !git_info.date_iso.is_empty() {
1✔
193
            if let Ok(date_time) = DateTime::from_iso8601_string(&git_info.date_iso) {
3✔
194
                self.update_str(COMMIT_DATE, date_time.human_format());
2✔
195
                self.update_str(COMMIT_DATE_2822, date_time.to_rfc2822());
1✔
196
                self.update_str(COMMIT_DATE_3339, date_time.to_rfc3339());
1✔
NEW
197
            } else if let Ok(time_stamp) = git_info.date.parse::<i64>() {
×
NEW
198
                if let Ok(date_time) = DateTime::timestamp_2_utc(time_stamp) {
×
NEW
199
                    self.update_str(COMMIT_DATE, date_time.human_format());
×
NEW
200
                    self.update_str(COMMIT_DATE_2822, date_time.to_rfc2822());
×
NEW
201
                    self.update_str(COMMIT_DATE_3339, date_time.to_rfc3339());
×
202
                }
203
            }
204
        } else if let Ok(time_stamp) = git_info.date.parse::<i64>() {
1✔
NEW
205
            if let Ok(date_time) = DateTime::timestamp_2_utc(time_stamp) {
×
NEW
206
                self.update_str(COMMIT_DATE, date_time.human_format());
×
NEW
207
                self.update_str(COMMIT_DATE_2822, date_time.to_rfc2822());
×
NEW
208
                self.update_str(COMMIT_DATE_3339, date_time.to_rfc3339());
×
209
            }
210
        }
211

212
        Ok(())
1✔
213
    }
214

215
    #[allow(unused_variables)]
216
    fn init_git2(&mut self, path: &Path) -> SdResult<()> {
1✔
217
        #[cfg(feature = "git2")]
218
        {
219
            use crate::date_time::DateTime;
220
            use crate::git::git2_mod::git_repo;
221
            use crate::Format;
222

223
            let repo = git_repo(path).map_err(ShadowError::new)?;
1✔
224
            let reference = repo.head().map_err(ShadowError::new)?;
2✔
225

226
            //get branch
227
            let branch = reference
228
                .shorthand()
229
                .map(|x| x.trim().to_string())
3✔
230
                .or_else(command_current_branch)
1✔
231
                .unwrap_or_default();
232

233
            //get HEAD branch
234
            let tag = command_current_tag().unwrap_or_default();
2✔
235
            self.update_str(BRANCH, branch);
1✔
236
            self.update_str(TAG, tag);
1✔
237

238
            // use command get last tag
239
            let describe = command_git_describe();
1✔
240
            if let Some(x) = describe.0 {
1✔
241
                self.update_str(LAST_TAG, x)
2✔
242
            }
243

244
            if let Some(x) = describe.1 {
1✔
245
                self.update_usize(COMMITS_SINCE_TAG, x)
×
246
            }
247

248
            if let Some(v) = reference.target() {
2✔
249
                let commit = v.to_string();
1✔
250
                self.update_str(COMMIT_HASH, commit.clone());
2✔
251
                let mut short_commit = commit.as_str();
1✔
252

253
                if commit.len() > 8 {
2✔
254
                    short_commit = short_commit.get(0..8).unwrap();
1✔
255
                }
256
                self.update_str(SHORT_COMMIT, short_commit.to_string());
2✔
257
            }
258

259
            let commit = reference.peel_to_commit().map_err(ShadowError::new)?;
3✔
260

261
            let author = commit.author();
4✔
262
            if let Some(v) = author.email() {
4✔
263
                self.update_str(COMMIT_EMAIL, v.to_string());
4✔
264
            }
265

266
            if let Some(v) = author.name() {
4✔
267
                self.update_str(COMMIT_AUTHOR, v.to_string());
4✔
268
            }
269
            let status_file = Self::git2_dirty_stage(&repo);
4✔
270
            if status_file.trim().is_empty() {
4✔
271
                self.update_bool(GIT_CLEAN, true);
4✔
272
            } else {
273
                self.update_bool(GIT_CLEAN, false);
×
274
            }
275
            self.update_str(GIT_STATUS_FILE, status_file);
2✔
276

277
            let commit_time = commit.time();
2✔
278
            let time_stamp = commit_time.seconds();
2✔
279
            let offset_minutes = commit_time.offset_minutes();
2✔
280

281
            // Create OffsetDateTime with the commit's timezone
282
            if let Ok(utc_time) = time::OffsetDateTime::from_unix_timestamp(time_stamp) {
2✔
283
                if let Ok(offset) = time::UtcOffset::from_whole_seconds(offset_minutes * 60) {
2✔
284
                    let local_time = utc_time.to_offset(offset);
1✔
285
                    let date_time = DateTime::Local(local_time);
1✔
286

287
                    self.update_str(COMMIT_DATE, date_time.human_format());
1✔
288
                    self.update_str(COMMIT_DATE_2822, date_time.to_rfc2822());
2✔
289
                    self.update_str(COMMIT_DATE_3339, date_time.to_rfc3339());
1✔
290
                } else {
291
                    // Fallback to UTC if offset parsing fails
NEW
292
                    let date_time = DateTime::Utc(utc_time);
×
NEW
293
                    self.update_str(COMMIT_DATE, date_time.human_format());
×
NEW
294
                    self.update_str(COMMIT_DATE_2822, date_time.to_rfc2822());
×
NEW
295
                    self.update_str(COMMIT_DATE_3339, date_time.to_rfc3339());
×
296
                }
297
            }
298
        }
299
        Ok(())
2✔
300
    }
301

302
    //use git2 crates git repository 'dirty or stage' status files.
303
    #[cfg(feature = "git2")]
304
    pub fn git2_dirty_stage(repo: &git2::Repository) -> String {
2✔
305
        let mut repo_opts = git2::StatusOptions::new();
2✔
306
        repo_opts.include_ignored(false);
2✔
307
        if let Ok(statue) = repo.statuses(Some(&mut repo_opts)) {
4✔
308
            let mut dirty_files = Vec::new();
2✔
309
            let mut staged_files = Vec::new();
2✔
310

311
            for status in statue.iter() {
4✔
312
                if let Some(path) = status.path() {
×
313
                    match status.status() {
×
314
                        git2::Status::CURRENT => (),
315
                        git2::Status::INDEX_NEW
×
316
                        | git2::Status::INDEX_MODIFIED
317
                        | git2::Status::INDEX_DELETED
318
                        | git2::Status::INDEX_RENAMED
319
                        | git2::Status::INDEX_TYPECHANGE => staged_files.push(path.to_string()),
320
                        _ => dirty_files.push(path.to_string()),
×
321
                    };
322
                }
323
            }
324
            filter_git_dirty_stage(dirty_files, staged_files)
2✔
325
        } else {
326
            "".into()
×
327
        }
328
    }
329

330
    #[allow(clippy::manual_strip)]
331
    fn ci_branch_tag(&mut self, std_env: &BTreeMap<String, String>) {
1✔
332
        let mut branch: Option<String> = None;
1✔
333
        let mut tag: Option<String> = None;
1✔
334
        match self.ci_type {
1✔
335
            CiType::Gitlab => {
336
                if let Some(v) = std_env.get("CI_COMMIT_TAG") {
×
337
                    tag = Some(v.to_string());
×
338
                } else if let Some(v) = std_env.get("CI_COMMIT_REF_NAME") {
×
339
                    branch = Some(v.to_string());
×
340
                }
341
            }
342
            CiType::Github => {
343
                if let Some(v) = std_env.get("GITHUB_REF") {
2✔
344
                    let ref_branch_prefix: &str = "refs/heads/";
1✔
345
                    let ref_tag_prefix: &str = "refs/tags/";
1✔
346

347
                    if v.starts_with(ref_branch_prefix) {
2✔
348
                        branch = Some(
1✔
349
                            v.get(ref_branch_prefix.len()..)
2✔
350
                                .unwrap_or_default()
1✔
351
                                .to_string(),
1✔
352
                        )
353
                    } else if v.starts_with(ref_tag_prefix) {
×
354
                        tag = Some(
×
355
                            v.get(ref_tag_prefix.len()..)
×
356
                                .unwrap_or_default()
×
357
                                .to_string(),
×
358
                        )
359
                    }
360
                }
361
            }
362
            _ => {}
363
        }
364
        if let Some(x) = branch {
3✔
365
            self.update_str(BRANCH, x);
4✔
366
        }
367

368
        if let Some(x) = tag {
2✔
369
            self.update_str(TAG, x.clone());
×
370
            self.update_str(LAST_TAG, x);
×
371
        }
372
    }
373
}
374

375
pub(crate) fn new_git(
1✔
376
    path: &Path,
377
    ci: CiType,
378
    std_env: &BTreeMap<String, String>,
379
) -> BTreeMap<ShadowConst, ConstVal> {
380
    let mut git = Git {
381
        map: Default::default(),
1✔
382
        ci_type: ci,
383
    };
384
    git.map.insert(BRANCH, ConstVal::new(BRANCH_DOC));
2✔
385

386
    git.map.insert(TAG, ConstVal::new(TAG_DOC));
1✔
387

388
    git.map.insert(LAST_TAG, ConstVal::new(LAST_TAG_DOC));
1✔
389

390
    git.map.insert(
1✔
391
        COMMITS_SINCE_TAG,
392
        ConstVal::new_usize(COMMITS_SINCE_TAG_DOC),
1✔
393
    );
394

395
    git.map.insert(COMMIT_HASH, ConstVal::new(COMMIT_HASH_DOC));
1✔
396

397
    git.map
398
        .insert(SHORT_COMMIT, ConstVal::new(SHORT_COMMIT_DOC));
1✔
399

400
    git.map
401
        .insert(COMMIT_AUTHOR, ConstVal::new(COMMIT_AUTHOR_DOC));
1✔
402
    git.map
403
        .insert(COMMIT_EMAIL, ConstVal::new(COMMIT_EMAIL_DOC));
1✔
404
    git.map.insert(COMMIT_DATE, ConstVal::new(COMMIT_DATE_DOC));
1✔
405

406
    git.map
407
        .insert(COMMIT_DATE_2822, ConstVal::new(COMMIT_DATE_2822_DOC));
1✔
408

409
    git.map
410
        .insert(COMMIT_DATE_3339, ConstVal::new(COMMIT_DATE_3339_DOC));
1✔
411

412
    git.map.insert(GIT_CLEAN, ConstVal::new_bool(GIT_CLEAN_DOC));
1✔
413

414
    git.map
415
        .insert(GIT_STATUS_FILE, ConstVal::new(GIT_STATUS_FILE_DOC));
1✔
416

417
    if let Err(e) = git.init(path, std_env) {
1✔
418
        println!("{e}");
×
419
    }
420

421
    git.map
2✔
422
}
423

424
#[cfg(feature = "git2")]
425
pub mod git2_mod {
426
    use git2::Error as git2Error;
427
    use git2::Repository;
428
    use std::path::Path;
429

430
    pub fn git_repo<P: AsRef<Path>>(path: P) -> Result<Repository, git2Error> {
1✔
431
        Repository::discover(path)
1✔
432
    }
433

434
    pub fn git2_current_branch(repo: &Repository) -> Option<String> {
×
435
        repo.head()
×
436
            .map(|x| x.shorthand().map(|x| x.to_string()))
×
437
            .unwrap_or(None)
×
438
    }
439
}
440

441
/// get current repository git branch.
442
///
443
/// When current repository exists git folder.
444
///
445
/// It's use default feature.This function try use [git2] crates get current branch.
446
/// If not use git2 feature,then try use [Command] to get.
447
pub fn branch() -> String {
×
448
    #[cfg(feature = "git2")]
449
    {
450
        use crate::git::git2_mod::{git2_current_branch, git_repo};
451
        git_repo(".")
×
452
            .map(|x| git2_current_branch(&x))
×
453
            .unwrap_or_else(|_| command_current_branch())
×
454
            .unwrap_or_default()
455
    }
456
    #[cfg(not(feature = "git2"))]
457
    {
458
        command_current_branch().unwrap_or_default()
459
    }
460
}
461

462
/// get current repository git tag.
463
///
464
/// When current repository exists git folder.
465
/// I's use [Command] to get.
466
pub fn tag() -> String {
×
467
    command_current_tag().unwrap_or_default()
×
468
}
469

470
/// Check current git Repository status without nothing(dirty or stage)
471
///
472
/// if nothing,It means clean:true. On the contrary, it is 'dirty':false
473
pub fn git_clean() -> bool {
×
474
    #[cfg(feature = "git2")]
475
    {
476
        use crate::git::git2_mod::git_repo;
477
        git_repo(".")
×
478
            .map(|x| Git::git2_dirty_stage(&x))
×
479
            .map(|x| x.trim().is_empty())
×
480
            .unwrap_or(true)
481
    }
482
    #[cfg(not(feature = "git2"))]
483
    {
484
        command_git_clean()
485
    }
486
}
487

488
/// List current git Repository statue(dirty or stage) contain file changed
489
///
490
/// Refer to the 'cargo fix' result output when git statue(dirty or stage) changed.
491
///
492
/// Example output:`   * examples/builtin_fn.rs (dirty)`
493
pub fn git_status_file() -> String {
×
494
    #[cfg(feature = "git2")]
495
    {
496
        use crate::git::git2_mod::git_repo;
497
        git_repo(".")
×
498
            .map(|x| Git::git2_dirty_stage(&x))
×
499
            .unwrap_or_default()
500
    }
501
    #[cfg(not(feature = "git2"))]
502
    {
503
        command_git_status_file()
504
    }
505
}
506

507
struct GitHeadInfo {
508
    commit: String,
509
    short_commit: String,
510
    email: String,
511
    author: String,
512
    date: String,
513
    date_iso: String,
514
}
515

516
struct GitCommandExecutor<'a> {
517
    path: &'a Path,
518
}
519

520
impl Default for GitCommandExecutor<'_> {
521
    fn default() -> Self {
1✔
522
        Self::new(Path::new("."))
1✔
523
    }
524
}
525

526
impl<'a> GitCommandExecutor<'a> {
527
    fn new(path: &'a Path) -> Self {
1✔
528
        GitCommandExecutor { path }
529
    }
530

531
    fn exec(&self, args: &[&str]) -> Option<String> {
1✔
532
        Command::new("git")
1✔
533
            .env("GIT_OPTIONAL_LOCKS", "0")
534
            .current_dir(self.path)
1✔
535
            .args(args)
1✔
536
            .output()
537
            .map(|x| {
2✔
538
                String::from_utf8(x.stdout)
1✔
539
                    .map(|x| x.trim().to_string())
3✔
540
                    .ok()
1✔
541
            })
542
            .unwrap_or(None)
1✔
543
    }
544
}
545

546
fn command_git_head() -> GitHeadInfo {
1✔
547
    let cli = |args: &[&str]| GitCommandExecutor::default().exec(args).unwrap_or_default();
2✔
548
    GitHeadInfo {
549
        commit: cli(&["rev-parse", "HEAD"]),
1✔
550
        short_commit: cli(&["rev-parse", "--short", "HEAD"]),
1✔
551
        author: cli(&["log", "-1", "--pretty=format:%an"]),
1✔
552
        email: cli(&["log", "-1", "--pretty=format:%ae"]),
1✔
553
        date: cli(&["show", "--pretty=format:%ct", "--date=raw", "-s"]),
1✔
554
        date_iso: cli(&["log", "-1", "--pretty=format:%cI"]),
1✔
555
    }
556
}
557

558
/// Command exec git current tag
559
fn command_current_tag() -> Option<String> {
1✔
560
    GitCommandExecutor::default().exec(&["tag", "-l", "--contains", "HEAD"])
1✔
561
}
562

563
/// git describe --tags HEAD
564
/// Command exec git describe
565
fn command_git_describe() -> (Option<String>, Option<usize>, Option<String>) {
1✔
566
    let last_tag =
1✔
567
        GitCommandExecutor::default().exec(&["describe", "--tags", "--abbrev=0", "HEAD"]);
568
    if last_tag.is_none() {
4✔
569
        return (None, None, None);
×
570
    }
571

572
    let tag = last_tag.unwrap();
4✔
573

574
    let describe = GitCommandExecutor::default().exec(&["describe", "--tags", "HEAD"]);
4✔
575
    if let Some(desc) = describe {
1✔
576
        match parse_git_describe(&tag, &desc) {
2✔
577
            Ok((tag, commits, hash)) => {
1✔
578
                return (Some(tag), commits, hash);
1✔
579
            }
580
            Err(_) => {
581
                return (Some(tag), None, None);
×
582
            }
583
        }
584
    }
585
    (Some(tag), None, None)
×
586
}
587

588
fn parse_git_describe(
1✔
589
    last_tag: &str,
590
    describe: &str,
591
) -> SdResult<(String, Option<usize>, Option<String>)> {
592
    if !describe.starts_with(last_tag) {
1✔
593
        return Err(ShadowError::String("git describe result error".to_string()));
×
594
    }
595

596
    if last_tag == describe {
1✔
597
        return Ok((describe.to_string(), None, None));
1✔
598
    }
599

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

602
    if parts.is_empty() || parts.len() == 2 {
3✔
603
        return Err(ShadowError::String(
×
604
            "git describe result error,expect:<tag>-<num_commits>-g<hash>".to_string(),
×
605
        ));
606
    }
607

608
    if parts.len() > 2 {
1✔
609
        let short_hash = parts[0]; // last part
2✔
610

611
        if !short_hash.starts_with('g') {
1✔
612
            return Err(ShadowError::String(
1✔
613
                "git describe result error,expect commit hash end with:-g<hash>".to_string(),
1✔
614
            ));
615
        }
616
        let short_hash = short_hash.trim_start_matches('g');
2✔
617

618
        // Full example:v1.0.0-alpha0-5-ga1b2c3d
619
        let num_commits_str = parts[1];
1✔
620
        let num_commits = num_commits_str
3✔
621
            .parse::<usize>()
622
            .map_err(|e| ShadowError::String(e.to_string()))?;
4✔
623
        let last_tag = parts[2..]
2✔
624
            .iter()
625
            .rev()
626
            .copied()
627
            .collect::<Vec<_>>()
628
            .join("-");
629
        return Ok((last_tag, Some(num_commits), Some(short_hash.to_string())));
1✔
630
    }
631
    Ok((describe.to_string(), None, None))
×
632
}
633

634
/// git clean:git status --porcelain
635
/// check repository git status is clean
636
fn command_git_clean() -> bool {
1✔
637
    GitCommandExecutor::default()
1✔
638
        .exec(&["status", "--porcelain"])
1✔
639
        .map(|x| x.is_empty())
3✔
640
        .unwrap_or(true)
641
}
642

643
/// check git repository 'dirty or stage' status files.
644
/// git dirty:git status  --porcelain | grep '^\sM.' |awk '{print $2}'
645
/// git stage:git status --porcelain --untracked-files=all | grep '^[A|M|D|R]'|awk '{print $2}'
646
fn command_git_status_file() -> String {
2✔
647
    let git_status_files =
4✔
648
        move |args: &[&str], grep: &[&str], awk: &[&str]| -> SdResult<Vec<String>> {
649
            let git_shell = Command::new("git")
3✔
650
                .env("GIT_OPTIONAL_LOCKS", "0")
651
                .args(args)
2✔
652
                .stdin(Stdio::piped())
2✔
653
                .stdout(Stdio::piped())
2✔
654
                .spawn()?;
655
            let git_out = git_shell.stdout.ok_or("Failed to exec git stdout")?;
1✔
656

657
            let grep_shell = Command::new("grep")
2✔
658
                .args(grep)
1✔
659
                .stdin(Stdio::from(git_out))
1✔
660
                .stdout(Stdio::piped())
1✔
661
                .spawn()?;
662
            let grep_out = grep_shell.stdout.ok_or("Failed to exec grep stdout")?;
2✔
663

664
            let mut awk_shell = Command::new("awk")
3✔
665
                .args(awk)
2✔
666
                .stdin(Stdio::from(grep_out))
2✔
667
                .stdout(Stdio::piped())
2✔
668
                .spawn()?;
669
            let mut awk_out = BufReader::new(
670
                awk_shell
2✔
671
                    .stdout
672
                    .as_mut()
1✔
673
                    .ok_or("Failed to exec awk stdout")?,
1✔
674
            );
675
            let mut line = String::new();
1✔
676
            awk_out.read_to_string(&mut line)?;
2✔
677
            Ok(line.lines().map(|x| x.into()).collect())
1✔
678
        };
679

680
    let dirty = git_status_files(&["status", "--porcelain"], &[r"^\sM."], &["{print $2}"])
2✔
681
        .unwrap_or_default();
682

683
    let stage = git_status_files(
684
        &["status", "--porcelain", "--untracked-files=all"],
685
        &[r#"^[A|M|D|R]"#],
686
        &["{print $2}"],
687
    )
688
    .unwrap_or_default();
689
    filter_git_dirty_stage(dirty, stage)
1✔
690
}
691

692
/// Command exec git current branch
693
fn command_current_branch() -> Option<String> {
×
694
    find_branch_in(Path::new("."))
×
695
}
696

697
fn find_branch_in(path: &Path) -> Option<String> {
1✔
698
    GitCommandExecutor::new(path).exec(&["symbolic-ref", "--short", "HEAD"])
1✔
699
}
700

701
fn filter_git_dirty_stage(dirty_files: Vec<String>, staged_files: Vec<String>) -> String {
1✔
702
    let mut concat_file = String::new();
1✔
703
    for file in dirty_files {
3✔
704
        concat_file.push_str("  * ");
×
705
        concat_file.push_str(&file);
×
706
        concat_file.push_str(" (dirty)\n");
×
707
    }
708
    for file in staged_files {
2✔
709
        concat_file.push_str("  * ");
×
710
        concat_file.push_str(&file);
×
711
        concat_file.push_str(" (staged)\n");
×
712
    }
713
    concat_file
1✔
714
}
715

716
#[cfg(test)]
717
mod tests {
718
    use super::*;
719
    use crate::get_std_env;
720

721
    #[test]
722
    fn test_git() {
723
        let env_map = get_std_env();
724
        let map = new_git(Path::new("./"), CiType::Github, &env_map);
725
        for (k, v) in map {
726
            assert!(!v.desc.is_empty());
727
            if !k.eq(TAG)
728
                && !k.eq(LAST_TAG)
729
                && !k.eq(COMMITS_SINCE_TAG)
730
                && !k.eq(BRANCH)
731
                && !k.eq(GIT_STATUS_FILE)
732
            {
733
                assert!(!v.v.is_empty());
734
                continue;
735
            }
736

737
            //assert github tag always exist value
738
            if let Some(github_ref) = env_map.get("GITHUB_REF") {
739
                if github_ref.starts_with("refs/tags/") && k.eq(TAG) {
740
                    assert!(!v.v.is_empty(), "not empty");
741
                } else if github_ref.starts_with("refs/heads/") && k.eq(BRANCH) {
742
                    assert!(!v.v.is_empty());
743
                }
744
            }
745
        }
746
    }
747

748
    #[test]
749
    fn test_current_branch() {
750
        if get_std_env().contains_key("GITHUB_REF") {
751
            return;
752
        }
753
        #[cfg(feature = "git2")]
754
        {
755
            use crate::git::git2_mod::{git2_current_branch, git_repo};
756
            let git2_branch = git_repo(".")
757
                .map(|x| git2_current_branch(&x))
758
                .unwrap_or(None);
759
            let command_branch = command_current_branch();
760
            assert!(git2_branch.is_some());
761
            assert!(command_branch.is_some());
762
            assert_eq!(command_branch, git2_branch);
763
        }
764

765
        assert_eq!(Some(branch()), command_current_branch());
766
    }
767

768
    #[test]
769
    fn test_parse_git_describe() {
770
        let commit_hash = "24skp4489";
771
        let describe = "v1.0.0";
772
        assert_eq!(
773
            parse_git_describe("v1.0.0", describe).unwrap(),
774
            (describe.into(), None, None)
775
        );
776

777
        let describe = "v1.0.0-0-g24skp4489";
778
        assert_eq!(
779
            parse_git_describe("v1.0.0", describe).unwrap(),
780
            ("v1.0.0".into(), Some(0), Some(commit_hash.into()))
781
        );
782

783
        let describe = "v1.0.0-1-g24skp4489";
784
        assert_eq!(
785
            parse_git_describe("v1.0.0", describe).unwrap(),
786
            ("v1.0.0".into(), Some(1), Some(commit_hash.into()))
787
        );
788

789
        let describe = "v1.0.0-alpha-0-g24skp4489";
790
        assert_eq!(
791
            parse_git_describe("v1.0.0-alpha", describe).unwrap(),
792
            ("v1.0.0-alpha".into(), Some(0), Some(commit_hash.into()))
793
        );
794

795
        let describe = "v1.0.0.alpha-0-g24skp4489";
796
        assert_eq!(
797
            parse_git_describe("v1.0.0.alpha", describe).unwrap(),
798
            ("v1.0.0.alpha".into(), Some(0), Some(commit_hash.into()))
799
        );
800

801
        let describe = "v1.0.0-alpha";
802
        assert_eq!(
803
            parse_git_describe("v1.0.0-alpha", describe).unwrap(),
804
            ("v1.0.0-alpha".into(), None, None)
805
        );
806

807
        let describe = "v1.0.0-alpha-99-0-g24skp4489";
808
        assert_eq!(
809
            parse_git_describe("v1.0.0-alpha-99", describe).unwrap(),
810
            ("v1.0.0-alpha-99".into(), Some(0), Some(commit_hash.into()))
811
        );
812

813
        let describe = "v1.0.0-alpha-99-024skp4489";
814
        assert!(parse_git_describe("v1.0.0-alpha-99", describe).is_err());
815

816
        let describe = "v1.0.0-alpha-024skp4489";
817
        assert!(parse_git_describe("v1.0.0-alpha", describe).is_err());
818

819
        let describe = "v1.0.0-alpha-024skp4489";
820
        assert!(parse_git_describe("v1.0.0-alpha", describe).is_err());
821

822
        let describe = "v1.0.0-alpha-g024skp4489";
823
        assert!(parse_git_describe("v1.0.0-alpha", describe).is_err());
824

825
        let describe = "v1.0.0----alpha-g024skp4489";
826
        assert!(parse_git_describe("v1.0.0----alpha", describe).is_err());
827
    }
828

829
    #[test]
830
    fn test_commit_date_timezone_preservation() {
831
        use crate::DateTime;
832

833
        // Test timezone-aware parsing
834
        let iso_date = "2021-08-04T12:34:03+08:00";
835
        let date_time = DateTime::from_iso8601_string(iso_date).unwrap();
836
        assert_eq!(date_time.human_format(), "2021-08-04 12:34:03 +08:00");
837
        assert!(date_time.to_rfc3339().contains("+08:00"));
838

839
        // Test UTC timezone
840
        let iso_date_utc = "2021-08-04T12:34:03Z";
841
        let date_time_utc = DateTime::from_iso8601_string(iso_date_utc).unwrap();
842
        assert_eq!(date_time_utc.human_format(), "2021-08-04 12:34:03 +00:00");
843

844
        // Test negative timezone
845
        let iso_date_neg = "2021-08-04T12:34:03-05:00";
846
        let date_time_neg = DateTime::from_iso8601_string(iso_date_neg).unwrap();
847
        assert_eq!(date_time_neg.human_format(), "2021-08-04 12:34:03 -05:00");
848
    }
849
}
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