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}