Repository: orhun/git-cliff (rust)

Issue:
add option to fail on unconventional commits
### Is there an existing issue or pull request for this?

- [X] I have searched the existing issues and pull requests

### Feature description

An option to fail generating the changelog if the git history contains commits that do not comply to the conventional commits format would allow using git-cliff as a quality gate. Currently additional tools are needed to implement such behaviour.

### Desired solution

Add a new configuration parameter:
```toml
# fail if the git history contains commits that are not conventional (default: false)
fail_on_unconventional = true
```

If git-cliff encounters any unconventional commits, it should print the hashes of all unconventional commits to stderr and exit with a non-zero return code. Outputting the commits gives developers a clear indication as to what they need to fix.
```sh
$ git-cliff
Encountered unconventional commits: 12345678, abcdefg
```

### Alternatives considered

There exist other tools that can check compliance with conventional commits. However having to use multiple tools bloats the pipeline.

### Additional context

_No response_

Pull request description:
feature: add option git.require-conventional
## Description

closes #536

This PR adds a new option `git.require-conventional`, which requires that all commits in the changelog are conventional. If the history contains any unconventional commits, an error message listing the offending commits is emitted and the changelog generation fails.

The new option takes precedence over `git.filter-unconventional`.

Example output:
```
ERROR git_cliff_core::changelog > The following commits are not conventional: ["b5da60869701cb51f77282fa426a4d458152b02a", "2a9b8746b6398f287e6d44b3a2343e1aecfe9252", "b6d90f01839716df7402f51c1ff0b65c224d099f"]
```

## Motivation and Context

I am looking for a way to enforce commit guidelines prior to merging contributions. This serves to maintain a satisfactory level of quality in the git history and subsequently the changelog.

## How Has This Been Tested?

Added new test fixtures.

## Screenshots / Logs (if applicable)

## Types of Changes

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to change)
- [ ] Documentation (no code change)
- [ ] Refactor (refactoring production code)
- [ ] Other <!--- (provide information) -->

## Checklist:

- [x] My code follows the code style of this project.
- [x] I have updated the documentation accordingly.
- [x] I have formatted the code with [rustfmt](https://github.com/rust-lang/rustfmt).
- [x] I checked the lints with [clippy](https://github.com/rust-lang/rust-clippy).
- [x] I have added tests to cover my changes.
- [x] All new and existing tests passed.

<!--- Thank you for contributing to git-cliff! ⛰️  -->


Patch:
diff --git a/config/cliff.toml b/config/cliff.toml
index 5287b66..bc311f2 100644
--- a/config/cliff.toml
+++ b/config/cliff.toml
@@ -49,6 +49,9 @@ postprocessors = [
 conventional_commits = true
 # Exclude commits that do not match the conventional commits specification.
 filter_unconventional = true
+# Require all commits to be conventional.
+# Takes precedence over filter_unconventional.
+require_conventional = false
 # Split commits on newlines, treating each line as an individual commit.
 split_commits = false
 # An array of regex based parsers to modify commit messages prior to further processing.
diff --git a/git-cliff-core/src/changelog.rs b/git-cliff-core/src/changelog.rs
index ead53b2..bdae154 100644
--- a/git-cliff-core/src/changelog.rs
+++ b/git-cliff-core/src/changelog.rs
@@ -3,7 +3,10 @@ use crate::config::{
 	Config,
 	GitConfig,
 };
-use crate::error::Result;
+use crate::error::{
+	Error,
+	Result,
+};
 use crate::release::{
 	Release,
 	Releases,
@@ -44,7 +47,7 @@ impl<'a> Changelog<'a> {
 	pub fn new(releases: Vec<Release<'a>>, config: &'a Config) -> Result<Self> {
 		let mut changelog = Changelog::build(releases, config)?;
 		changelog.add_remote_data()?;
-		changelog.process_commits();
+		changelog.process_commits()?;
 		changelog.process_releases();
 		Ok(changelog)
 	}
@@ -104,7 +107,7 @@ impl<'a> Changelog<'a> {
 			Err(e) => {
 				trace!(
 					"{} - {} ({})",
-					commit.id[..7].to_string(),
+					commit.id.chars().take(7).collect::<String>(),
 					e,
 					commit.message.lines().next().unwrap_or_default().trim()
 				);
@@ -115,7 +118,7 @@ impl<'a> Changelog<'a> {
 
 	/// Processes the commits and omits the ones that doesn't match the
 	/// criteria set by configuration file.
-	fn process_commits(&mut self) {
+	fn process_commits(&mut self) -> Result<()> {
 		debug!("Processing the commits...");
 		self.releases.iter_mut().for_each(|release| {
 			release.commits = release
@@ -144,6 +147,43 @@ impl<'a> Changelog<'a> {
 				})
 				.collect::<Vec<Commit>>();
 		});
+
+		if self.config.git.require_conventional.unwrap_or(false) {
+			self.check_conventional_commits()?;
+		}
+
+		Ok(())
+	}
+
+	/// Checks the commits and returns an error if any unconventional commits
+	/// are found.
+	fn check_conventional_commits(&mut self) -> Result<()> {
+		debug!("Verifying that all commits are conventional.");
+		let mut unconventional_count = 0;
+
+		self.releases.iter().for_each(|release| {
+			release.commits.iter().for_each(|commit| {
+				if commit.conv.is_none() {
+					error!(
+						"Commit {id} is not conventional:\n{message}",
+						id = &commit.id[..7],
+						message = commit
+							.message
+							.lines()
+							.map(|line| { format!("    | {}", line.trim()) })
+							.collect::<Vec<String>>()
+							.join("\n")
+					);
+					unconventional_count += 1;
+				}
+			});
+		});
+
+		if unconventional_count > 0 {
+			return Err(Error::UnconventionalCommitsError(unconventional_count));
+		}
+
+		Ok(())
 	}
 
 	/// Processes the releases and filters them out based on the configuration.
@@ -680,6 +720,7 @@ mod test {
 			},
 			git:       GitConfig {
 				conventional_commits:     Some(true),
+				require_conventional:     Some(false),
 				filter_unconventional:    Some(false),
 				split_commits:            Some(false),
 				commit_preprocessors:     Some(vec![TextProcessor {
diff --git a/git-cliff-core/src/commit.rs b/git-cliff-core/src/commit.rs
index 57f117d..0bca735 100644
--- a/git-cliff-core/src/commit.rs
+++ b/git-cliff-core/src/commit.rs
@@ -216,7 +216,8 @@ impl Commit<'_> {
 			commit = commit.preprocess(preprocessors)?;
 		}
 		if config.conventional_commits.unwrap_or(true) {
-			if config.filter_unconventional.unwrap_or(true) &&
+			if !config.require_conventional.unwrap_or(false) &&
+				config.filter_unconventional.unwrap_or(true) &&
 				!config.split_commits.unwrap_or(false)
 			{
 				commit = commit.into_conventional()?;
diff --git a/git-cliff-core/src/config.rs b/git-cliff-core/src/config.rs
index dc1e621..dcd5180 100644
--- a/git-cliff-core/src/config.rs
+++ b/git-cliff-core/src/config.rs
@@ -90,6 +90,9 @@ pub struct ChangelogConfig {
 pub struct GitConfig {
 	/// Parse commits according to the conventional commits specification.
 	pub conventional_commits:  Option<bool>,
+	/// Require all commits to be conventional.
+	/// Takes precedence over filter_unconventional.
+	pub require_conventional:  Option<bool>,
 	/// Exclude commits that do not match the conventional commits specification
 	/// from the changelog.
 	pub filter_unconventional: Option<bool>,
diff --git a/git-cliff-core/src/error.rs b/git-cliff-core/src/error.rs
index 13cf760..fbbe8ad 100644
--- a/git-cliff-core/src/error.rs
+++ b/git-cliff-core/src/error.rs
@@ -111,6 +111,13 @@ pub enum Error {
 	/// Error that may occur while constructing patterns.
 	#[error("Pattern error: `{0}`")]
 	PatternError(#[from] glob::PatternError),
+	/// Error that may occur when unconventional commits are found.
+	/// See `require_conventional` option for more information.
+	#[error(
+		"Requiring all commits be conventional but found {0} unconventional \
+		 commits."
+	)]
+	UnconventionalCommitsError(i32),
 }
 
 /// Result type of the core library.
diff --git a/website/docs/configuration/git.md b/website/docs/configuration/git.md
index d2db3bc..1fa0317 100644
--- a/website/docs/configuration/git.md
+++ b/website/docs/configuration/git.md
@@ -6,6 +6,7 @@ This section contains the parsing and git related configuration options.
 [git]
 conventional_commits = true
 filter_unconventional = true
+require_conventional = false
 split_commits = false
 commit_parsers = [
     { message = "^feat", group = "Features"},
@@ -78,6 +79,23 @@ conventional_commits = false
 filter_unconventional = false
 ```
 
+### require_conventional
+
+If set to `true`, all commits included in the changelog must be conventional. If any unconventional commits are found, an error will be logged and changelog generation fails.
+
+```toml
+conventional_commits = true
+require_conventional = false
+commit_parsers = [
+  { message = ".*", group = "Other", default_scope = "other"},
+  { message = "^Merging", skip = true }
+]
+```
+
+If set to `true`, this option takes precedence over `filter_unconventional`.
+
+Checking takes place after `commit_parsers`. Thus commits can be skipped by matching parsers.
+
 ### split_commits
 
 > This flag violates "conventional commits". It should remain off by default if conventional commits is to be respected.
@@ -103,7 +121,7 @@ a commit being treated as a changelog entry.
 
 An array of commit preprocessors for manipulating the commit messages before parsing/grouping them. These regex-based preprocessors can be used for removing or selecting certain parts of the commit message/body to be used in the following processes.
 
-:::note 
+:::note
 The `replace` or `replace_command` will take into account of the entire log of commit messages where the specified `pattern` is matched.
 :::
 
@@ -182,7 +200,6 @@ Examples:
   - `body` is a special field which contains the body of a convetional commit, if applicable.
   - Be aware that all fields are converted to JSON strings before they are parsed by the given regex, especially when dealing with arrays.
 
-
 ### protect_breaking_commits
 
 If set to `true`, any breaking changes will be protected against being skipped


Test patch:
diff --git a/.github/actions/run-fixtures-test/action.yml b/.github/actions/run-fixtures-test/action.yml
index ad9774b..632b8fd 100644
--- a/.github/actions/run-fixtures-test/action.yml
+++ b/.github/actions/run-fixtures-test/action.yml
@@ -39,7 +39,12 @@ runs:
 
     - name: Generate a changelog
       working-directory: ${{ inputs.fixtures-dir }}
-      run: git cliff --config cliff.toml ${{ inputs.command }} > output.md
+      run: |
+        git cliff --config cliff.toml ${{ inputs.command }} > output.md || true
+        echo "$?" > output.rc
+        if [ -f output.md ]; then
+          cat output.md
+        fi
       shell: bash
 
     - name: Compare the output with the expected output
@@ -47,8 +52,21 @@ runs:
       env:
         IN_DATE_FORMAT: ${{ inputs.date-format }}
       run: |
-        cat output.md
-        current_date=$(date +"$IN_DATE_FORMAT")
-        sed -i "s/<<DATE>>/$current_date/g" expected.md
-        diff --strip-trailing-cr output.md expected.md
+        if [ -f expected.md ]; then
+          current_date=$(date +"$IN_DATE_FORMAT")
+          sed -i "s/<<DATE>>/$current_date/g" expected.md
+          if [ ! diff --strip-trailing-cr output.md expected.md ]; then
+            echo "The generated changelog does not match the expected changelog."
+            exit 1
+          fi
+        fi
+      shell: bash
+
+    - name: Compare the return code with the expected return code
+      working-directory: ${{ inputs.fixtures-dir }}
+      run: |
+        if [ -f expected.rc ] && [ ! diff --skip-trailing-cr output.rc expected.rc ]; then
+          echo "The return code does not match the expected return code."
+          exit 1
+        fi
       shell: bash
diff --git a/.github/fixtures/test-require-conventional-negative/cliff.toml b/.github/fixtures/test-require-conventional-negative/cliff.toml
new file mode 100644
index 00000000..c1f81e2
--- /dev/null
+++ b/.github/fixtures/test-require-conventional-negative/cliff.toml
@@ -0,0 +1,44 @@
+[changelog]
+# A Tera template to be rendered as the changelog's header.
+# See https://keats.github.io/tera/docs/#introduction
+header = """
+# Changelog\n
+All notable changes to this project will be documented in this file.
+"""
+# A Tera template to be rendered for each release in the changelog.
+# See https://keats.github.io/tera/docs/#introduction
+body = """
+{% for group, commits in commits | group_by(attribute="group") %}
+    ## {{ group | striptags | trim | upper_first }}
+    {% for commit in commits %}
+          {% if commit.scope %}*({{ commit.scope }})* {% endif %}\
+          {% if commit.breaking %}[**breaking**]: {{ commit.breaking_description }}{% endif %}
+          {{ commit.message }}: {{ commit.body }}\
+          {% for footer in commit.footers %}
+            - {{ footer.token }}{{ footer.separator }} {{ footer.value }}\
+          {% endfor %}
+    {% endfor %}\
+{% endfor %}\n
+"""
+# A Tera template to be rendered as the changelog's footer.
+# See https://keats.github.io/tera/docs/#introduction
+footer = """
+<!-- generated by git-cliff -->
+"""
+
+[git]
+# Parse commits according to the conventional commits specification.
+# See https://www.conventionalcommits.org
+conventional_commits = true
+# Require all commits to be conventional.
+# Takes precedence over filter_unconventional.
+require_conventional = true
+# Split commits on newlines, treating each line as an individual commit.
+split_commits = false
+# An array of regex based parsers for extracting data from the commit message.
+# Assigns commits to groups.
+# Optionally sets the commit's scope and can decide to exclude commits from further processing.
+commit_parsers = [
+  { message = "^feat", group = "Features", default_scope = "app" },
+  { message = "^fix" },
+]
diff --git a/.github/fixtures/test-require-conventional-negative/commit.sh b/.github/fixtures/test-require-conventional-negative/commit.sh
new file mode 100755
index 00000000..ee6d55c
--- /dev/null
+++ b/.github/fixtures/test-require-conventional-negative/commit.sh
@@ -0,0 +1,16 @@
+#!/usr/bin/env bash
+set -e
+
+GIT_COMMITTER_DATE="2022-04-06 01:25:08" git commit --allow-empty -m "Initial commit"
+GIT_COMMITTER_DATE="2022-04-06 01:25:09" git commit --allow-empty -m "feat: add feature 1"
+GIT_COMMITTER_DATE="2022-04-06 01:25:10" git commit --allow-empty -m "This is unconventional."
+GIT_COMMITTER_DATE="2022-04-06 01:25:11"  git commit --allow-empty -m \
+    "This is unconventional on multiple lines.
+
+Line 2
+
+Some Footer: user3 <user3@example.com>
+"
+GIT_COMMITTER_DATE="2022-04-06 01:25:12" git commit --allow-empty -m "fix(gui)!: fix feature 2"
+
+git tag v0.1.0
diff --git a/.github/fixtures/test-require-conventional-negative/expected.rc b/.github/fixtures/test-require-conventional-negative/expected.rc
new file mode 100644
index 00000000..56a6051
--- /dev/null
+++ b/.github/fixtures/test-require-conventional-negative/expected.rc
@@ -0,0 +1,1 @@
+1
\ No newline at end of file
diff --git a/.github/fixtures/test-require-conventional-skipped/cliff.toml b/.github/fixtures/test-require-conventional-skipped/cliff.toml
new file mode 100644
index 00000000..f61b035
--- /dev/null
+++ b/.github/fixtures/test-require-conventional-skipped/cliff.toml
@@ -0,0 +1,52 @@
+# git-cliff ~ configuration file
+# https://git-cliff.org/docs/configuration
+
+[changelog]
+# A Tera template to be rendered as the changelog's header.
+# See https://keats.github.io/tera/docs/#introduction
+header = """
+# Changelog\n
+All notable changes to this project will be documented in this file.\n
+"""
+# A Tera template to be rendered for each release in the changelog.
+# See https://keats.github.io/tera/docs/#introduction
+body = """
+{% if version %}\
+    ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
+{% else %}\
+    ## [unreleased]
+{% endif %}\
+{% for group, commits in commits | group_by(attribute="group") %}
+    ### {{ group | upper_first }}
+    {% for group, commits in commits | group_by(attribute="scope") %}
+        #### {{ group | upper_first }}
+        {% for commit in commits %}
+            - {{ commit.message | upper_first }}\
+        {% endfor %}
+    {% endfor %}\
+{% endfor %}\n
+"""
+# A Tera template to be rendered as the changelog's footer.
+# See <https://keats.github.io/tera/docs/#introduction>
+footer = """
+<!-- generated by git-cliff -->
+"""
+# Remove leading and trailing whitespaces from the changelog's body.
+trim = true
+
+[git]
+# Parse commits according to the conventional commits specification.
+# See https://www.conventionalcommits.org
+conventional_commits = true
+# Require all commits to be conventional.
+# Takes precedence over filter_unconventional.
+require_conventional = true
+# An array of regex based parsers for extracting data from the commit message.
+# Assigns commits to groups.
+# Optionally sets the commit's scope and can decide to exclude commits from further processing.
+commit_parsers = [
+    { message = "^feat", group = "Features", default_scope = "app", skip = true },
+    { message = "Initial commit", skip = true },
+]
+# Prevent commits that are breaking from being excluded by commit parsers.
+protect_breaking_commits = true
diff --git a/.github/fixtures/test-require-conventional-skipped/commit.sh b/.github/fixtures/test-require-conventional-skipped/commit.sh
new file mode 100755
index 00000000..cbbb644
--- /dev/null
+++ b/.github/fixtures/test-require-conventional-skipped/commit.sh
@@ -0,0 +1,10 @@
+#!/usr/bin/env bash
+set -e
+
+GIT_COMMITTER_DATE="2022-04-06 01:25:08" git commit --allow-empty -m "Initial commit"
+GIT_COMMITTER_DATE="2022-04-06 01:25:09" git commit --allow-empty -m "feat: add feature 1"
+GIT_COMMITTER_DATE="2022-04-06 01:25:10" git commit --allow-empty -m "fix!: fix feature 1"
+GIT_COMMITTER_DATE="2022-04-06 01:25:11" git commit --allow-empty -m "feat(gui)!: add feature 2"
+GIT_COMMITTER_DATE="2022-04-06 01:25:12" git commit --allow-empty -m "fix(gui)!: fix feature 2"
+
+git tag v0.1.0
diff --git a/.github/fixtures/test-require-conventional-skipped/expected.md b/.github/fixtures/test-require-conventional-skipped/expected.md
new file mode 100644
index 00000000..74a4d73
--- /dev/null
+++ b/.github/fixtures/test-require-conventional-skipped/expected.md
@@ -0,0 +1,20 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+## [0.1.0] - 2022-04-06
+
+### Bug Fixes
+
+#### Cli
+
+- Fix feature 1
+- Fix feature 2
+
+### Features
+
+#### Gui
+
+- Add feature 2
+
+<!-- generated by git-cliff -->
diff --git a/.github/workflows/test-fixtures.yml b/.github/workflows/test-fixtures.yml
index 9df1326..0e8c1d9 100644
--- a/.github/workflows/test-fixtures.yml
+++ b/.github/workflows/test-fixtures.yml
@@ -106,6 +106,8 @@ jobs:
             command: v1.4.0..v1.4.1
           - fixtures-name: test-monorepo-include-path
             command: v2.6.1..v2.7.0 --include-path .github/fixtures/
+          - fixtures-name: test-require-conventional-negative
+          - fixtures-name: test-require-conventional-skipped
 
     steps:
       - name: Checkout
diff --git a/git-cliff-core/tests/integration_test.rs b/git-cliff-core/tests/integration_test.rs
index e2f99b0..aacf8a4 100644
--- a/git-cliff-core/tests/integration_test.rs
+++ b/git-cliff-core/tests/integration_test.rs
@@ -46,6 +46,7 @@ fn generate_changelog() -> Result<()> {
 	};
 	let git_config = GitConfig {
 		conventional_commits:     Some(true),
+		require_conventional:     Some(false),
 		filter_unconventional:    Some(true),
 		split_commits:            Some(false),
 		commit_preprocessors:     Some(vec![TextProcessor {