From 7d0786ca55423950f0779de4e6a907fc25ae203a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Orhun=20Parmaks=C4=B1z?= Date: Wed, 8 Jun 2022 01:44:10 +0300 Subject: [PATCH] feat(changelog): support external commands for commit preprocessors (#86) --- README.md | 13 ++++ git-cliff-core/src/command.rs | 81 ++++++++++++++++++++++++ git-cliff-core/src/commit.rs | 35 +++++++--- git-cliff-core/src/config.rs | 6 +- git-cliff-core/src/lib.rs | 2 + git-cliff-core/tests/integration_test.rs | 5 +- git-cliff/src/changelog.rs | 7 +- 7 files changed, 133 insertions(+), 16 deletions(-) create mode 100644 git-cliff-core/src/command.rs diff --git a/README.md b/README.md index 36d2529a84..3efd5a266a 100644 --- a/README.md +++ b/README.md @@ -523,6 +523,19 @@ Examples: - `{ pattern = "([ \\n])(([a-f0-9]{7})[a-f0-9]*)", replace = "${1}commit # [${3}](https://github.com/orhun/git-cliff/commit/${2})"}` - Hyperlink bare commit hashes like "abcd1234" in commit logs, with short commit hash as description. +Custom OS commands can also be used for modifying the commit messages: + +- `{ pattern = "foo", replace_command = "pandoc -t commonmark"}` + +This is useful when you want to filter/encode messages using external commands. In the example above, [pandoc](https://pandoc.org/) is used to convert each commit message that matches the given `pattern` to the [CommonMark](https://commonmark.org/) format. + +A more fun example would be reversing the each commit message: + +- `{ pattern = '.*', replace_command = 'rev | xargs echo "reversed: $@"' }` + +`$COMMIT_SHA` environment variable is set during execution of the command so you can do fancier things like reading the commit itself: + +- `{ pattern = '.*', replace_command = 'git show -s --format=%B $COMMIT_SHA' }` #### commit_parsers diff --git a/git-cliff-core/src/command.rs b/git-cliff-core/src/command.rs new file mode 100644 index 0000000000..8b153d9909 --- /dev/null +++ b/git-cliff-core/src/command.rs @@ -0,0 +1,81 @@ +use crate::error::Result; +use std::io::{ + Error as IoError, + ErrorKind as IoErrorKind, + Write, +}; +use std::process::{ + Command, + Stdio, +}; +use std::str; +use std::thread; + +/// Runs the given OS command and returns the output as string. +/// +/// Use `input` parameter to specify a text to write to stdin. +/// Environment variables are set accordingly to `envs`. +pub fn run( + command: &str, + input: Option, + envs: Vec<(&str, &str)>, +) -> Result { + let mut child = if cfg!(target_os = "windows") { + Command::new("cmd") + .args(&["/C", command]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + } else { + Command::new("sh") + .envs(envs) + .args(&["-c", command]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + }?; + if let Some(input) = input { + let mut stdin = child.stdin.take().ok_or_else(|| { + IoError::new(IoErrorKind::Other, "stdin is not captured") + })?; + thread::spawn(move || { + stdin + .write_all(input.as_bytes()) + .expect("Failed to write to stdin"); + }); + } + let output = child.wait_with_output()?; + if output.status.success() { + Ok(str::from_utf8(&output.stdout)?.to_string()) + } else { + Err(IoError::new( + IoErrorKind::Other, + format!("command exited with {:?}", output.status), + ) + .into()) + } +} + +#[cfg(test)] +mod test { + use super::*; + #[test] + #[cfg(target_family = "unix")] + fn run_os_command() -> Result<()> { + assert_eq!( + "eroc-ffilc-tig", + run("echo $APP_NAME | rev", None, vec![( + "APP_NAME", + env!("CARGO_PKG_NAME") + )])? + .trim() + ); + assert_eq!( + "eroc-ffilc-tig", + run("rev", Some(env!("CARGO_PKG_NAME").to_string()), vec![])?.trim() + ); + assert_eq!("testing", run("echo 'testing'", None, vec![])?.trim()); + assert!(run("some_command", None, vec![]).is_err()); + Ok(()) + } +} diff --git a/git-cliff-core/src/commit.rs b/git-cliff-core/src/commit.rs index dfda3d1da0..f2dcd41332 100644 --- a/git-cliff-core/src/commit.rs +++ b/git-cliff-core/src/commit.rs @@ -1,3 +1,4 @@ +use crate::command; use crate::config::{ CommitParser, CommitPreprocessor, @@ -75,7 +76,7 @@ impl Commit<'_> { pub fn process(&self, config: &GitConfig) -> Result { let mut commit = self.clone(); if let Some(preprocessors) = &config.commit_preprocessors { - commit = commit.preprocess(preprocessors); + commit = commit.preprocess(preprocessors)?; } if config.conventional_commits.unwrap_or(true) { if config.filter_unconventional.unwrap_or(true) { @@ -109,17 +110,31 @@ impl Commit<'_> { /// Preprocesses the commit using [`CommitPreprocessor`]s. /// - /// Modifies the commit [`message`] using regex. + /// Modifies the commit [`message`] using regex or custom OS command. /// /// [`message`]: Commit::message - pub fn preprocess(mut self, preprocessors: &[CommitPreprocessor]) -> Self { - preprocessors.iter().for_each(|preprocessor| { - self.message = preprocessor - .pattern - .replace_all(&self.message, &preprocessor.replace) - .to_string(); - }); - self + pub fn preprocess( + mut self, + preprocessors: &[CommitPreprocessor], + ) -> Result { + preprocessors.iter().try_for_each(|preprocessor| { + if let Some(text) = &preprocessor.replace { + self.message = preprocessor + .pattern + .replace_all(&self.message, text) + .to_string(); + } else if let Some(command) = &preprocessor.replace_command { + if preprocessor.pattern.is_match(&self.message) { + self.message = command::run( + command, + Some(self.message.to_string()), + vec![("COMMIT_SHA", &self.id)], + )?; + } + } + Ok::<(), AppError>(()) + })?; + Ok(self) } /// Parses the commit using [`CommitParser`]s. diff --git a/git-cliff-core/src/config.rs b/git-cliff-core/src/config.rs index 750c008235..489d7ede9e 100644 --- a/git-cliff-core/src/config.rs +++ b/git-cliff-core/src/config.rs @@ -86,9 +86,11 @@ pub struct CommitParser { pub struct CommitPreprocessor { /// Regex for matching a text to replace. #[serde(with = "serde_regex")] - pub pattern: Regex, + pub pattern: Regex, /// Replacement text. - pub replace: String, + pub replace: Option, + /// Command that will be run for replacing the commit message. + pub replace_command: Option, } /// Parser for extracting links in commits. diff --git a/git-cliff-core/src/lib.rs b/git-cliff-core/src/lib.rs index c3dd01da5f..6d9905afc1 100644 --- a/git-cliff-core/src/lib.rs +++ b/git-cliff-core/src/lib.rs @@ -6,6 +6,8 @@ pub use glob; /// Export `regex` crate. pub use regex; +/// Command runner. +pub mod command; /// Git commit. pub mod commit; /// Config file parser. diff --git a/git-cliff-core/tests/integration_test.rs b/git-cliff-core/tests/integration_test.rs index 46dc592de9..493c2fe566 100644 --- a/git-cliff-core/tests/integration_test.rs +++ b/git-cliff-core/tests/integration_test.rs @@ -41,8 +41,9 @@ fn generate_changelog() -> Result<()> { conventional_commits: Some(true), filter_unconventional: Some(true), commit_preprocessors: Some(vec![CommitPreprocessor { - pattern: Regex::new(r#"\(fixes (#[1-9]+)\)"#).unwrap(), - replace: String::from("[closes Issue${1}]"), + pattern: Regex::new(r#"\(fixes (#[1-9]+)\)"#).unwrap(), + replace: Some(String::from("[closes Issue${1}]")), + replace_command: None, }]), commit_parsers: Some(vec![ CommitParser { diff --git a/git-cliff/src/changelog.rs b/git-cliff/src/changelog.rs index 0f3ddc1c9d..dc871df438 100644 --- a/git-cliff/src/changelog.rs +++ b/git-cliff/src/changelog.rs @@ -192,8 +192,11 @@ mod test { conventional_commits: Some(true), filter_unconventional: Some(false), commit_preprocessors: Some(vec![CommitPreprocessor { - pattern: Regex::new("").unwrap(), - replace: String::from("this commit is preprocessed"), + pattern: Regex::new("").unwrap(), + replace: Some(String::from( + "this commit is preprocessed", + )), + replace_command: None, }]), commit_parsers: Some(vec![ CommitParser {