Skip to main content

abbaye/version_extractors/
git.rs

1use chrono::{DateTime, Utc};
2use miette::{IntoDiagnostic, Result, miette};
3use serde::{Deserialize, Serialize};
4
5use super::{VersionExtractor, VersionInfo};
6
7fn default_dirty_suffix() -> String {
8    "-dirty".to_owned()
9}
10
11/// Configuration for [`GitVersion`].
12#[derive(Debug, Clone, Deserialize, Serialize)]
13pub struct GitVersionConfig {
14    /// Strip this prefix from the tag name before returning the version.
15    /// For example, `"v"` turns `"v1.2.3"` into `"1.2.3"`.
16    pub tag_prefix: Option<String>,
17
18    /// Suffix appended to the version when the working tree has uncommitted
19    /// changes. Forwarded verbatim as `--dirty=<suffix>` to `git describe`.
20    /// Defaults to `"-dirty"`.
21    #[serde(default = "default_dirty_suffix")]
22    pub dirty_suffix: String,
23}
24
25impl Default for GitVersionConfig {
26    fn default() -> Self {
27        Self {
28            tag_prefix: None,
29            dirty_suffix: default_dirty_suffix(),
30        }
31    }
32}
33
34/// Extracts the version by running `git describe --tags --always`.
35pub struct GitVersion;
36
37/// Run `git log -1 --format=%cI <refspec>` and parse the result as a UTC
38/// [`DateTime`].  Returns `None` on any failure so callers can treat the date
39/// as optional.
40async fn git_commit_date(refspec: &str) -> Option<DateTime<Utc>> {
41    let output = tokio::process::Command::new("git")
42        .args(["log", "-1", "--format=%cI", refspec])
43        .output()
44        .await
45        .ok()?;
46
47    if !output.status.success() {
48        return None;
49    }
50
51    let raw = String::from_utf8(output.stdout).ok()?;
52    DateTime::parse_from_rfc3339(raw.trim())
53        .ok()
54        .map(|dt| dt.with_timezone(&Utc))
55}
56
57impl VersionExtractor for GitVersion {
58    type ConfigType = GitVersionConfig;
59
60    async fn get_last_version(&self, config: Self::ConfigType) -> Result<VersionInfo> {
61        let dirty_arg = format!("--dirty={}", config.dirty_suffix);
62
63        let output = tokio::process::Command::new("git")
64            .args(["describe", "--tags", "--always", &dirty_arg])
65            .output()
66            .await
67            .into_diagnostic()?;
68
69        if !output.status.success() {
70            let stderr = String::from_utf8_lossy(&output.stderr);
71            return Err(miette!("git describe failed:\n{stderr}"));
72        }
73
74        let raw = String::from_utf8(output.stdout).into_diagnostic()?;
75        let version_raw = raw.trim();
76
77        let version = match &config.tag_prefix {
78            Some(prefix) => version_raw
79                .strip_prefix(prefix.as_str())
80                .unwrap_or(version_raw),
81            None => version_raw,
82        }
83        .to_owned();
84
85        // Use the HEAD commit date as the "release date" for the current build.
86        let date = git_commit_date("HEAD").await;
87
88        Ok(VersionInfo { version, date })
89    }
90
91    /// Return all Git tags as [`VersionInfo`] entries, each carrying the
92    /// tag's creator date (annotated tag date, or commit date for lightweight
93    /// tags).  Uses a single `git for-each-ref` invocation.
94    async fn get_all_versions(&self, config: Self::ConfigType) -> Result<Vec<VersionInfo>> {
95        // %(creatordate:iso-strict) works for both annotated and lightweight tags.
96        let output = tokio::process::Command::new("git")
97            .args([
98                "for-each-ref",
99                "--sort=version:refname",
100                "--format=%(refname:short)\t%(creatordate:iso-strict)",
101                "refs/tags",
102            ])
103            .output()
104            .await
105            .into_diagnostic()?;
106
107        if !output.status.success() {
108            let stderr = String::from_utf8_lossy(&output.stderr);
109            return Err(miette!("git for-each-ref failed:\n{stderr}"));
110        }
111
112        let raw = String::from_utf8(output.stdout).into_diagnostic()?;
113
114        let versions = raw
115            .lines()
116            .filter(|s| !s.is_empty())
117            .map(|line| {
118                // Split on the first tab; anything after is the date string.
119                let (tag, date_str) = line.split_once('\t').unwrap_or((line, ""));
120
121                let version = match &config.tag_prefix {
122                    Some(prefix) => tag.strip_prefix(prefix.as_str()).unwrap_or(tag),
123                    None => tag,
124                }
125                .to_owned();
126
127                let date = DateTime::parse_from_rfc3339(date_str.trim())
128                    .ok()
129                    .map(|dt| dt.with_timezone(&Utc));
130
131                VersionInfo { version, date }
132            })
133            .collect();
134
135        Ok(versions)
136    }
137}