bumpversion/
hooks.rs

1//! Hook execution for setup, pre-commit, and post-commit scripts.
2//!
3//! Runs user-defined shell commands with enriched environment variables.
4use crate::{
5    command::{self, Error as CommandError, Output},
6    logging::LogExt,
7    vcs::{RevisionInfo, TagAndRevision},
8    version::Version,
9};
10use async_process::Command;
11use std::collections::HashMap;
12use std::path::Path;
13
14/// Prefix applied to environment variables for hook scripts.
15pub const ENV_PREFIX: &str = "BVHOOK_";
16
17/// Provide the base environment variables
18fn base_env() -> impl Iterator<Item = (String, String)> {
19    vec![
20        (
21            format!("{ENV_PREFIX}NOW"),
22            chrono::Local::now().to_rfc3339(),
23        ),
24        (
25            format!("{ENV_PREFIX}UTCNOW"),
26            chrono::Utc::now().to_rfc3339(),
27        ),
28    ]
29    .into_iter()
30}
31
32/// Provide the VCS environment variables.
33fn vcs_env(tag_and_revision: &TagAndRevision) -> impl Iterator<Item = (String, String)> {
34    let TagAndRevision { tag, revision } = tag_and_revision;
35    let tag = tag.clone().unwrap_or(crate::vcs::TagInfo {
36        dirty: false,
37        commit_sha: String::new(),
38        distance_to_latest_tag: 0,
39        current_tag: String::new(),
40        current_version: String::new(),
41    });
42    let revision = revision.clone().unwrap_or(RevisionInfo {
43        branch_name: String::new(),
44        short_branch_name: String::new(),
45        repository_root: std::path::PathBuf::default(),
46    });
47    vec![
48        (format!("{ENV_PREFIX}COMMIT_SHA"), tag.commit_sha),
49        (
50            format!("{ENV_PREFIX}DISTANCE_TO_LATEST_TAG"),
51            tag.distance_to_latest_tag.to_string(),
52        ),
53        (format!("{ENV_PREFIX}IS_DIRTY"), tag.dirty.to_string()),
54        (format!("{ENV_PREFIX}CURRENT_VERSION"), tag.current_version),
55        (format!("{ENV_PREFIX}CURRENT_TAG"), tag.current_tag),
56        (format!("{ENV_PREFIX}BRANCH_NAME"), revision.branch_name),
57        (
58            format!("{ENV_PREFIX}SHORT_BRANCH_NAME"),
59            revision.short_branch_name,
60        ),
61    ]
62    .into_iter()
63}
64
65/// Provide the environment variables for each version component with a prefix
66fn version_env<'a>(
67    version: Option<&'a Version>,
68    version_prefix: &'a str,
69) -> impl Iterator<Item = (String, String)> + use<'a> {
70    let iter = version.map(|version| version.iter()).unwrap_or_default();
71    iter.map(move |(comp_name, comp)| {
72        (
73            format!("{ENV_PREFIX}{version_prefix}{}", comp_name.to_uppercase()),
74            comp.value().unwrap_or_default().to_string(),
75        )
76    })
77}
78
79/// Provide the environment dictionary for `new_version` serialized and tag name.
80fn new_version_env<'a>(
81    new_version_serialized: &str,
82    tag: Option<&str>,
83) -> impl Iterator<Item = (String, String)> + use<'a> {
84    vec![
85        (
86            format!("{ENV_PREFIX}NEW_VERSION"),
87            new_version_serialized.to_string(),
88        ),
89        (
90            format!("{ENV_PREFIX}NEW_VERSION_TAG"),
91            tag.unwrap_or_default().to_string(),
92        ),
93    ]
94    .into_iter()
95}
96
97/// Provide the environment dictionary for `setup_hook`s.
98fn setup_hook_env<'a>(
99    tag_and_revision: &'a TagAndRevision,
100    current_version: Option<&'a Version>,
101) -> impl Iterator<Item = (String, String)> + use<'a> {
102    std::env::vars()
103        .chain(base_env())
104        .chain(vcs_env(tag_and_revision))
105        .chain(version_env(current_version, "CURRENT_"))
106}
107
108/// Provide the environment dictionary for `pre_commit_hook` and `post_commit_hook`s
109fn pre_and_post_commit_hook_env<'a>(
110    tag_and_revision: &'a TagAndRevision,
111    current_version: Option<&'a Version>,
112    new_version: Option<&'a Version>,
113    new_version_serialized: &str,
114) -> impl Iterator<Item = (String, String)> + use<'a> {
115    let tag = tag_and_revision
116        .tag
117        .as_ref()
118        .map(|tag| tag.current_tag.as_str());
119    std::env::vars()
120        .chain(base_env())
121        .chain(vcs_env(tag_and_revision))
122        .chain(version_env(current_version, "CURRENT_"))
123        .chain(version_env(new_version, "NEW_"))
124        .chain(new_version_env(new_version_serialized, tag))
125}
126
127impl<VCS, L> crate::BumpVersion<VCS, L>
128where
129    VCS: crate::vcs::VersionControlSystem,
130    L: crate::logging::Log,
131{
132    /// Run the setup hooks
133    ///
134    /// # Errors
135    /// When one of the user-provided setup hooks exits with a non-zero exit code.
136    pub async fn run_setup_hooks(&self, current_version: Option<&Version>) -> Result<(), Error> {
137        let env = setup_hook_env(&self.tag_and_revision, current_version);
138
139        let setup_hooks = &self.config.global.setup_hooks;
140        self.logger.log_hooks("setup", setup_hooks);
141
142        run_hooks(
143            setup_hooks,
144            self.repo.path(),
145            env,
146            self.config.global.dry_run,
147        )
148        .await
149    }
150
151    /// Run the pre-commit hooks
152    ///
153    /// # Errors
154    /// When one of the user-provided pre-commit hooks exits with a non-zero exit code.
155    pub async fn run_pre_commit_hooks(
156        &self,
157        current_version: Option<&Version>,
158        new_version: Option<&Version>,
159        new_version_serialized: &str,
160    ) -> Result<(), Error> {
161        let env = pre_and_post_commit_hook_env(
162            &self.tag_and_revision,
163            current_version,
164            new_version,
165            new_version_serialized,
166        );
167
168        let pre_commit_hooks = &self.config.global.pre_commit_hooks;
169        self.logger.log_hooks("pre-commit", pre_commit_hooks);
170
171        run_hooks(
172            pre_commit_hooks,
173            self.repo.path(),
174            env,
175            self.config.global.dry_run,
176        )
177        .await
178    }
179
180    /// Run the post-commit hooks
181    ///
182    /// # Errors
183    /// When one of the user-provided post-commit hooks exits with a non-zero exit code.
184    pub async fn run_post_commit_hooks(
185        &self,
186        current_version: Option<&Version>,
187        new_version: Option<&Version>,
188        new_version_serialized: &str,
189    ) -> Result<(), Error> {
190        let env = pre_and_post_commit_hook_env(
191            &self.tag_and_revision,
192            current_version,
193            new_version,
194            new_version_serialized,
195        );
196
197        let post_commit_hooks = &self.config.global.post_commit_hooks;
198        self.logger.log_hooks("post-commit", post_commit_hooks);
199
200        run_hooks(
201            post_commit_hooks,
202            self.repo.path(),
203            env,
204            self.config.global.dry_run,
205        )
206        .await
207    }
208}
209
210/// Errors that can occur during hook execution.
211#[derive(thiserror::Error, Debug)]
212pub enum Error {
213    /// Error running an external command.
214    #[error(transparent)]
215    Command(#[from] CommandError),
216    /// Failed to parse the hook script into shell tokens.
217    #[error("failed to split shell script {0:?}")]
218    Shell(String),
219}
220
221/// Runs command-line programs using the shell
222async fn run_hook(
223    script: &str,
224    working_dir: &Path,
225    env: &HashMap<String, String>,
226) -> Result<Output, Error> {
227    let args = shlex::split(script).ok_or_else(|| Error::Shell(script.to_string()))?;
228    let mut cmd = Command::new("sh");
229    cmd.args(["-c".to_string()].into_iter().chain(args));
230    cmd.envs(env);
231    cmd.current_dir(working_dir);
232    let output = command::run_command(&mut cmd).await?;
233    Ok(output)
234}
235
236/// Run command-line hooks using the shell.
237async fn run_hooks(
238    hooks: &[String],
239    working_dir: &Path,
240    env: impl Iterator<Item = (String, String)>,
241    dry_run: bool,
242) -> Result<(), Error> {
243    let env = env.collect();
244    for script in hooks {
245        if dry_run {
246            tracing::info!(?script, "would run hook");
247            continue;
248        }
249        tracing::info!(?script, "running");
250        match run_hook(script, working_dir, &env).await {
251            Ok(output) => {
252                tracing::debug!(code = output.status.code(), "hook completed");
253                tracing::debug!(output.stdout);
254                tracing::debug!(output.stderr);
255            }
256            Err(err) => {
257                if let Error::Command(CommandError::Failed { ref output, .. }) = err {
258                    tracing::warn!(output.stdout);
259                    tracing::warn!(output.stderr);
260                }
261                return Err(err);
262            }
263        }
264    }
265    Ok(())
266}
267
268#[cfg(test)]
269mod tests {
270    // def assert_os_environ_items_included(result_env: dict) -> None:
271    //     """Assert that the OS environment variables are in the result."""
272    //     for var, value in os.environ.items():
273    //         assert var in result_env
274    //         assert result_env[var] == value
275    //
276    //
277    // def assert_scm_info_included(result_env: dict):
278    //     """Assert the SCM information is included in the result."""
279    //     assert f"{PREFIX}COMMIT_SHA" in result_env
280    //     assert f"{PREFIX}DISTANCE_TO_LATEST_TAG" in result_env
281    //     assert f"{PREFIX}IS_DIRTY" in result_env
282    //     assert f"{PREFIX}BRANCH_NAME" in result_env
283    //     assert f"{PREFIX}SHORT_BRANCH_NAME" in result_env
284    //     assert f"{PREFIX}CURRENT_VERSION" in result_env
285    //     assert f"{PREFIX}CURRENT_TAG" in result_env
286    //
287    //
288    // def assert_current_version_info_included(result_env: dict):
289    //     """Assert the current version information is included in the result."""
290    //     assert f"{PREFIX}CURRENT_MAJOR" in result_env
291    //     assert f"{PREFIX}CURRENT_MINOR" in result_env
292    //     assert f"{PREFIX}CURRENT_PATCH" in result_env
293    //
294    //
295    // def assert_new_version_info_included(result_env: dict):
296    //     """Assert the new version information is included in the result."""
297    //     assert f"{PREFIX}NEW_MAJOR" in result_env
298    //     assert f"{PREFIX}NEW_MINOR" in result_env
299    //     assert f"{PREFIX}NEW_PATCH" in result_env
300    //     assert f"{PREFIX}NEW_VERSION" in result_env
301    //     assert f"{PREFIX}NEW_VERSION_TAG" in result_env
302    //
303    //
304    // def test_scm_env_returns_correct_info(git_repo: Path):
305    //     """Should return information about the latest tag."""
306    //     readme = git_repo.joinpath("readme.md")
307    //     readme.touch()
308    //     tag_prefix = "v"
309    //     overrides = {"current_version": "0.1.0", "commit": True, "tag": True, "tag_name": f"{tag_prefix}{{new_version}}"}
310    //
311    //     with inside_dir(git_repo):
312    //         # Add a file and tag
313    //         subprocess.run(["git", "add", "readme.md"])
314    //         subprocess.run(["git", "commit", "-m", "first"])
315    //         subprocess.run(["git", "tag", f"{tag_prefix}0.1.0"])
316    //         conf, _, _ = get_config_data(overrides)
317    //
318    //     result = scm_env(conf)
319    //     assert result[f"{PREFIX}BRANCH_NAME"] == "master"
320    //     assert len(result[f"{PREFIX}COMMIT_SHA"]) == 40
321    //     assert result[f"{PREFIX}CURRENT_TAG"] == "v0.1.0"
322    //     assert result[f"{PREFIX}CURRENT_VERSION"] == "0.1.0"
323    //     assert result[f"{PREFIX}DISTANCE_TO_LATEST_TAG"] == "0"
324    //     assert result[f"{PREFIX}IS_DIRTY"] == "False"
325    //     assert result[f"{PREFIX}SHORT_BRANCH_NAME"] == "master"
326    //
327    //
328    // class MockDatetime(datetime.datetime):
329    //     @classmethod
330    //     def now(cls, tz=None):
331    //         return cls(2022, 2, 1, 17) if tz else cls(2022, 2, 1, 12)
332    //
333    //
334    // class TestBaseEnv:
335    //     """Tests for base_env function."""
336    //
337    //     def test_includes_now_and_utcnow(self, mocker):
338    //         """The output includes NOW and UTCNOW."""
339    //         mocker.patch("datetime.datetime", new=MockDatetime)
340    //         config, _, _ = get_config_data({"current_version": "0.1.0"})
341    //         result_env = base_env(config)
342    //
343    //         assert f"{PREFIX}NOW" in result_env
344    //         assert f"{PREFIX}UTCNOW" in result_env
345    //         assert result_env[f"{PREFIX}NOW"] == "2022-02-01T12:00:00"
346    //         assert result_env[f"{PREFIX}UTCNOW"] == "2022-02-01T17:00:00"
347    //
348    //     def test_includes_os_environ(self):
349    //         """The output includes the current process' environment."""
350    //         config, _, _ = get_config_data({"current_version": "0.1.0"})
351    //         result_env = base_env(config)
352    //
353    //         assert_os_environ_items_included(result_env)
354    //
355    //     def test_includes_scm_info(self):
356    //         """The output includes SCM information."""
357    //         config, _, _ = get_config_data({"current_version": "0.1.0"})
358    //         result_env = base_env(config)
359    //
360    //         assert_scm_info_included(result_env)
361    //
362    //
363
364    /// The `version_env` for a version should include all its parts"""
365    #[test]
366    fn test_current_version_env_includes_correct_info() {
367        // config, _, current_version = get_config_data(
368        //     {"current_version": "0.1.0", "parse": r"(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)"}
369        // )
370        // let current_version = Version::from_components([("")]);
371        // let env = super::version_env(Some(current_version), "CURRENT_")
372
373        // assert result[f"{PREFIX}CURRENT_MAJOR"] == "0"
374        // assert result[f"{PREFIX}CURRENT_MINOR"] == "1"
375        // assert result[f"{PREFIX}CURRENT_PATCH"] == "0"
376    }
377
378    // def test_new_version_env_includes_correct_info():
379    //     """The new_version_env should return the serialized version and tag name."""
380    //
381    //     config, _, current_version = get_config_data(
382    //         {"current_version": "0.1.0", "parse": r"(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)"}
383    //     )
384    //     new_version = current_version.bump("minor")
385    //     result = new_version_env(config, current_version, new_version)
386    //
387    //     assert result[f"{PREFIX}NEW_VERSION"] == "0.2.0"
388    //     assert result[f"{PREFIX}NEW_VERSION_TAG"] == "v0.2.0"
389    //
390    //
391    // def test_get_setup_hook_env_includes_correct_info():
392    //     """The setup hook environment should contain specific information."""
393    //     config, _, current_version = get_config_data({"current_version": "0.1.0"})
394    //     result_env = get_setup_hook_env(config, current_version)
395    //
396    //     assert_os_environ_items_included(result_env)
397    //     assert_scm_info_included(result_env)
398    //     assert_current_version_info_included(result_env)
399    //
400    //
401    // def test_get_pre_commit_hook_env_includes_correct_info():
402    //     """The pre-commit hook environment should contain specific information."""
403    //     config, _, current_version = get_config_data({"current_version": "0.1.0"})
404    //     new_version = current_version.bump("minor")
405    //     result_env = get_pre_commit_hook_env(config, current_version, new_version)
406    //
407    //     assert_os_environ_items_included(result_env)
408    //     assert_scm_info_included(result_env)
409    //     assert_current_version_info_included(result_env)
410    //     assert_new_version_info_included(result_env)
411}