Skip to main content

gitversion_rs/
exec.rs

1//! External command execution hooks (similar to the semantic-release exec plugin).
2//!
3//! Exposes computed version variables as `GitVersion_*` environment variables and
4//! `{Variable}`/`{env:VAR}` template tokens, then runs lifecycle hook commands.
5//! The `version` hook can modify the version by writing to stdout
6//! (which overwrites `next-version` and triggers a recalculation).
7
8use crate::output::VersionVariables;
9use anyhow::{bail, Context, Result};
10use regex::Regex;
11use rust_i18n::t;
12use std::collections::BTreeMap;
13use std::path::Path;
14use std::process::{Command, Stdio};
15
16/// Execution order for side-effect hooks.
17pub const HOOK_ORDER: [&str; 4] = ["verify", "prepare", "publish", "success"];
18
19/// Substitute `{Variable}` / `{env:VAR}` tokens in a command string (unknown tokens are left as-is).
20fn render(cmd: &str, map: &BTreeMap<String, String>) -> String {
21    let re = Regex::new(r"\{(?<t>[A-Za-z0-9_:]+)\}").unwrap();
22    re.replace_all(cmd, |c: &regex::Captures| {
23        let t = &c["t"];
24        if let Some(env_var) = t.strip_prefix("env:") {
25            std::env::var(env_var).unwrap_or_default()
26        } else if let Some(v) = map.get(t) {
27            v.clone()
28        } else {
29            format!("{{{t}}}") // Unknown tokens are preserved as-is.
30        }
31    })
32    .into_owned()
33}
34
35/// Convert version variables to `GitVersion_*` environment variable pairs.
36fn env_vars(vars: &VersionVariables) -> Vec<(String, String)> {
37    vars.to_map()
38        .into_iter()
39        .map(|(k, v)| (format!("GitVersion_{k}"), v))
40        .collect()
41}
42
43/// Run a command via the shell. If `capture` is true, collect stdout and return it; otherwise inherit.
44fn run_command(
45    cmd: &str,
46    vars: &VersionVariables,
47    work_dir: &Path,
48    capture: bool,
49    dry_run: bool,
50) -> Result<Option<String>> {
51    let rendered = render(cmd, &vars.to_map());
52    if dry_run {
53        log::info!("{}", t!("exec.dry_run", cmd = rendered));
54        eprintln!("[dry-run] {rendered}");
55        return Ok(None);
56    }
57    log::info!("{}", t!("exec.running", cmd = rendered));
58
59    let (program, flag) = if cfg!(windows) {
60        ("cmd", "/C")
61    } else {
62        ("sh", "-c")
63    };
64    let mut command = Command::new(program);
65    command
66        .arg(flag)
67        .arg(&rendered)
68        .current_dir(work_dir)
69        .envs(env_vars(vars));
70    if capture {
71        command.stdout(Stdio::piped()).stderr(Stdio::inherit());
72    }
73
74    if capture {
75        let output = command
76            .output()
77            .with_context(|| t!("exec.run_failed", cmd = rendered))?;
78        if !output.status.success() {
79            bail!(
80                "{}",
81                t!(
82                    "exec.cmd_failed",
83                    code = format!("{:?}", output.status.code()),
84                    cmd = rendered
85                )
86            );
87        }
88        Ok(Some(String::from_utf8_lossy(&output.stdout).into_owned()))
89    } else {
90        let status = command
91            .status()
92            .with_context(|| t!("exec.run_failed", cmd = rendered))?;
93        if !status.success() {
94            bail!(
95                "{}",
96                t!(
97                    "exec.cmd_failed",
98                    code = format!("{:?}", status.code()),
99                    cmd = rendered
100                )
101            );
102        }
103        Ok(None)
104    }
105}
106
107/// Run the `version` hook (or `--exec-version`). Returns the first non-empty line from stdout.
108/// The caller applies the result as `next-version` and recalculates.
109pub fn run_version_hook(
110    cmd: &str,
111    vars: &VersionVariables,
112    work_dir: &Path,
113    dry_run: bool,
114) -> Result<Option<String>> {
115    let out = run_command(cmd, vars, work_dir, true, dry_run)?;
116    Ok(out.and_then(|s| {
117        s.lines()
118            .map(str::trim)
119            .find(|l| !l.is_empty())
120            .map(String::from)
121    }))
122}
123
124/// Run side-effect hooks (verify/prepare/publish/success) in order.
125/// On failure, runs the `fail` hook if present and then propagates the error.
126/// `extra_prepare` is the temporary prepare command supplied via `--exec` (run after the config's prepare).
127pub fn run_hooks(
128    hooks: &BTreeMap<String, String>,
129    extra_prepare: Option<&str>,
130    vars: &VersionVariables,
131    work_dir: &Path,
132    dry_run: bool,
133) -> Result<()> {
134    let mut result = Ok(());
135    'outer: for &name in &HOOK_ORDER {
136        if let Some(cmd) = hooks.get(name) {
137            if let Err(e) = run_command(cmd, vars, work_dir, false, dry_run) {
138                result = Err(e.context(t!("exec.hook_failed", name = name).to_string()));
139                break 'outer;
140            }
141        }
142        if name == "prepare" {
143            if let Some(cmd) = extra_prepare {
144                if let Err(e) = run_command(cmd, vars, work_dir, false, dry_run) {
145                    result = Err(e.context(t!("exec.exec_prepare_failed").to_string()));
146                    break 'outer;
147                }
148            }
149        }
150    }
151
152    if result.is_err() {
153        if let Some(fail_cmd) = hooks.get("fail") {
154            log::warn!("{}", t!("exec.running_fail_hook"));
155            let _ = run_command(fail_cmd, vars, work_dir, false, dry_run);
156        }
157    }
158    result
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164
165    #[test]
166    fn render_substitutes_and_preserves() {
167        let mut m = BTreeMap::new();
168        m.insert("SemVer".to_string(), "1.2.3".to_string());
169        assert_eq!(render("echo {SemVer}", &m), "echo 1.2.3");
170        // Unknown tokens are preserved.
171        assert_eq!(render("echo {Unknown}", &m), "echo {Unknown}");
172        // Shell variables ($) are not affected.
173        assert_eq!(render("echo $HOME {SemVer}", &m), "echo $HOME 1.2.3");
174    }
175}