Skip to main content

abbaye/version_extractors/
git.rs

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