Repository: orhun/git-cliff (rust)

Issue:
Allow special handling for "skipped" breaking changes
On many of my projects, I choose to skip some scopes in the changelog, for example `chore` and `refactor`. This keeps the noise down and allows people to see features and fixes without the distraction of all the moving cheese.

However sometimes one of those commits will be marked as a breaking change (`chore!: ....`). These commits should not be skipped in the changelog. I found I catch these with a commit parser added early in the list. Unfortunately the other rules setting group titles then don't get applied.

```toml
commit_parsers = [
    { message = ".*!:", skip = false },
    { message = "^chore", group = "Miscellaneous Tasks", skip = true },
]
```

I can work it out by having duplicate rules for each group with breaking changes not being skipped and others being skipped, but it means a lot more rules that I would have expected.

I think there should probably be some top level support for handling breaking changes such that they can always override skip values.

Pull request description:
feat(changelog): do not skip breaking changes if configured
Hi there! Trying to contribute to this project first-time.

## Description
Trying to resolve #106 by option to protect breaking changes, even when matched by a skipping commit parser. The option `protect_breaking_commits` is introduced in the config.
Note that during testing, one caveat was discovered: If a commit is matched by one parser, it can't be matched by any other. That means if a breaking commit was prevented from being skipped, it won't become part of any other group. Maybe it is worth to think about allowing multiple parsers for commits in an hierarchical order?

## Motivation and Context
<!--- Why is this change required? What problem does it solve? -->
Breaking changes are highly relevant for stakeholders, therefore should not be skipped in a changelog, even when matched by commit parsers.
<!--- If it fixes an open issue, please link to the issue here. -->
Fixes #106 

## How Has This Been Tested?
<!--- Please describe in detail how you tested your changes. -->
Extended the test cases by breaking change commits. Added a usual breaking change commit and one that matches a skipped commit parser regex. The latter is not skipped even it matches a commit parser that has `skip` set to true, if `protect_breaking_commits` is true.
<!--- Include details of your testing environment, and the tests you ran to -->
nightly-aarch64-apple-darwin / rustc 1.66.0-nightly (8ce3204af 2022-09-30)
<!--- see how your change affects other areas of the code, etc. -->
Backward compatibility is provided.

## Screenshots / Output (if appropriate):
See tests in `src/changelog.rs`

## Types of changes
<!--- What types of changes does your code introduce? Put an `x` in all the boxes that apply: -->
- [ ] 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:
<!--- Go over all the following points, and put an `x` in all the boxes that apply. -->
<!--- If you're unsure about any of these, don't hesitate to ask. We're here to help! -->
- [x] My code follows the code style of this project.
- [x] I have updated the documentation accordingly.
- [x] I have added tests to cover my changes.
- [x] All new and existing tests passed.


Patch:
diff --git a/README.md b/README.md
index 2957a0f..6dac923 100644
--- a/README.md
+++ b/README.md
@@ -458,6 +458,7 @@ commit_parsers = [
     { message = "^style", group = "Styling"},
     { message = "^test", group = "Testing"},
 ]
+protect_breaking_commits = false
 filter_commits = false
 tag_pattern = "v[0-9]*"
 skip_tags = "v0.1.0-beta.1"
@@ -593,6 +594,12 @@ Examples:
   - If the commit starts with "doc", group the commit as "Documentation" and set the default scope to "other". (e.g. `docs: xyz` will be processed as `docs(other): xyz`)
 - `{ message = "(www)", scope = "Application"}`
   - If the commit contains "(www)", override the scope with "Application". Scoping order is: scope specification, conventional commit's scope and default scope.
+
+#### protect_breaking_commits
+
+If set to `true`, any breaking changes will be protected against being skipped
+due to any commit parser.
+
 #### filter_commits
 
 If set to `true`, commits that are not matched by [commit parsers](#commit_parsers) are filtered out.
@@ -778,6 +785,8 @@ BREAKING CHANGE: this is a breaking change
 If the `BREAKING CHANGE:` footer is present, the footer will also be included in
 `commit.footers`.
 
+Breaking changes will be skipped if [`protect_breaking_commits`](#protect_breaking_commits) is set to `true`, even when matched by a skipping [commit_parser](#commit_parsers).
+
 ##### Committer vs Author
 
 From [Git docs](https://git-scm.com/book/en/v2/Git-Basics-Viewing-the-Commit-History):
diff --git a/config/cliff.toml b/config/cliff.toml
index a12f1af..a4c50a0 100644
--- a/config/cliff.toml
+++ b/config/cliff.toml
@@ -52,6 +52,8 @@ commit_parsers = [
     { message = "^chore", group = "Miscellaneous Tasks"},
     { body = ".*security", group = "Security"},
 ]
+# protect breaking changes from being skipped due to matching a skipping commit_parser
+protect_breaking_commits = false
 # filter out the commits that are not matched by commit parsers
 filter_commits = false
 # glob pattern for matching git tags
diff --git a/examples/detailed.toml b/examples/detailed.toml
index 9891483..fed8f90 100644
--- a/examples/detailed.toml
+++ b/examples/detailed.toml
@@ -57,6 +57,8 @@ commit_parsers = [
     { message = "^chore", group = "Miscellaneous Tasks"},
     { body = ".*security", group = "Security"},
 ]
+# protect breaking changes from being skipped due to matching a skipping commit_parser
+protect_breaking_commits = false
 # filter out the commits that are not matched by commit parsers
 filter_commits = false
 # glob pattern for matching git tags
diff --git a/examples/keepachangelog.toml b/examples/keepachangelog.toml
index c95d083..cc17b73 100644
--- a/examples/keepachangelog.toml
+++ b/examples/keepachangelog.toml
@@ -49,6 +49,8 @@ commit_parsers = [
     { message = "^.*: fix", group = "Fixed"},
     { message = "^.*", group = "Changed"},
 ]
+# protect breaking changes from being skipped due to matching a skipping commit_parser
+protect_breaking_commits = false
 # filter out the commits that are not matched by commit parsers
 filter_commits = true
 # glob pattern for matching git tags
diff --git a/examples/scoped.toml b/examples/scoped.toml
index dd7d0b7..1a0befb 100644
--- a/examples/scoped.toml
+++ b/examples/scoped.toml
@@ -51,6 +51,8 @@ commit_parsers = [
     { message = "^chore", group = "Miscellaneous Tasks"},
     { body = ".*security", group = "Security"},
 ]
+# protect breaking changes from being skipped due to matching a skipping commit_parser
+protect_breaking_commits = false
 # filter out the commits that are not matched by commit parsers
 filter_commits = false
 # glob pattern for matching git tags
diff --git a/examples/scopesorted.toml b/examples/scopesorted.toml
index 4ffcaaa..8aef924 100644
--- a/examples/scopesorted.toml
+++ b/examples/scopesorted.toml
@@ -63,6 +63,8 @@ commit_parsers = [
     { message = "^chore", group = "Miscellaneous Tasks"},
     { body = ".*security", group = "Security"},
 ]
+# protect breaking changes from being skipped due to matching a skipping commit_parser
+protect_breaking_commits = false
 # filter out the commits that are not matched by commit parsers
 filter_commits = false
 # glob pattern for matching git tags
diff --git a/examples/unconventional.toml b/examples/unconventional.toml
index f1be35b..bcff674 100644
--- a/examples/unconventional.toml
+++ b/examples/unconventional.toml
@@ -49,6 +49,8 @@ commit_parsers = [
     { body = ".*security", group = "Security"},
     { body = ".*", group = "Other (unconventional)"},
 ]
+# protect breaking changes from being skipped due to matching a skipping commit_parser
+protect_breaking_commits = false
 # filter out the commits that are not matched by commit parsers
 filter_commits = false
 # glob pattern for matching git tags
diff --git a/git-cliff-core/src/commit.rs b/git-cliff-core/src/commit.rs
index 0df2914..804efa8 100644
--- a/git-cliff-core/src/commit.rs
+++ b/git-cliff-core/src/commit.rs
@@ -182,8 +182,11 @@ impl Commit<'_> {
 			}
 		}
 		if let Some(parsers) = &config.commit_parsers {
-			commit =
-				commit.parse(parsers, config.filter_commits.unwrap_or(false))?;
+			commit = commit.parse(
+				parsers,
+				config.protect_breaking_commits.unwrap_or(false),
+				config.filter_commits.unwrap_or(false),
+			)?;
 		}
 		if let Some(parsers) = &config.link_parsers {
 			commit = commit.parse_links(parsers)?;
@@ -233,13 +236,29 @@ impl Commit<'_> {
 		Ok(self)
 	}
 
+	/// States if the commit is skipped in the provided `CommitParser`.
+	///
+	/// Returns `false` if `protect_breaking_commits` is enabled in the config
+	/// and the commit is breaking, or the parser's `skip` field is None or
+	/// `false`. Returns `true` otherwise.
+	fn skip_commit(&self, parser: &CommitParser, protect_breaking: bool) -> bool {
+		parser.skip.unwrap_or(false) &&
+			!(self.conv.as_ref().map(|c| c.breaking()).unwrap_or(false) &&
+				protect_breaking)
+	}
+
 	/// Parses the commit using [`CommitParser`]s.
 	///
 	/// Sets the [`group`] and [`scope`] of the commit.
 	///
 	/// [`group`]: Commit::group
 	/// [`scope`]: Commit::scope
-	pub fn parse(mut self, parsers: &[CommitParser], filter: bool) -> Result<Self> {
+	pub fn parse(
+		mut self,
+		parsers: &[CommitParser],
+		protect_breaking: bool,
+		filter: bool,
+	) -> Result<Self> {
 		for parser in parsers {
 			let mut regex_checks = Vec::new();
 			if let Some(message_regex) = parser.message.as_ref() {
@@ -253,15 +272,15 @@ impl Commit<'_> {
 			}
 			for (regex, text) in regex_checks {
 				if regex.is_match(&text) {
-					if parser.skip != Some(true) {
+					if self.skip_commit(parser, protect_breaking) {
+						return Err(AppError::GroupError(String::from(
+							"Skipping commit",
+						)));
+					} else {
 						self.group = parser.group.as_ref().cloned();
 						self.scope = parser.scope.as_ref().cloned();
 						self.default_scope = parser.default_scope.as_ref().cloned();
 						return Ok(self);
-					} else {
-						return Err(AppError::GroupError(String::from(
-							"Skipping commit",
-						)));
 					}
 				}
 			}
@@ -407,6 +426,7 @@ mod test {
 				skip:          None,
 			}],
 			false,
+			false,
 		)?;
 		assert_eq!(Some(String::from("test_group")), commit.group);
 		assert_eq!(Some(String::from("test_scope")), commit.default_scope);
diff --git a/git-cliff-core/src/config.rs b/git-cliff-core/src/config.rs
index 46eb964..ca29cfd 100644
--- a/git-cliff-core/src/config.rs
+++ b/git-cliff-core/src/config.rs
@@ -47,25 +47,28 @@ pub struct GitConfig {
 	pub split_commits:         Option<bool>,
 
 	/// Git commit preprocessors.
-	pub commit_preprocessors: Option<Vec<CommitPreprocessor>>,
+	pub commit_preprocessors:     Option<Vec<CommitPreprocessor>>,
 	/// Git commit parsers.
-	pub commit_parsers:       Option<Vec<CommitParser>>,
+	pub commit_parsers:           Option<Vec<CommitParser>>,
+	/// Whether to protect all breaking changes from being skipped by a commit
+	/// parser.
+	pub protect_breaking_commits: Option<bool>,
 	/// Link parsers.
-	pub link_parsers:         Option<Vec<LinkParser>>,
+	pub link_parsers:             Option<Vec<LinkParser>>,
 	/// Whether to filter out commits.
-	pub filter_commits:       Option<bool>,
+	pub filter_commits:           Option<bool>,
 	/// Blob pattern for git tags.
-	pub tag_pattern:          Option<String>,
+	pub tag_pattern:              Option<String>,
 	#[serde(with = "serde_regex", default)]
 	/// Regex to skip matched tags.
-	pub skip_tags:            Option<Regex>,
+	pub skip_tags:                Option<Regex>,
 	#[serde(with = "serde_regex", default)]
 	/// Regex to ignore matched tags.
-	pub ignore_tags:          Option<Regex>,
+	pub ignore_tags:              Option<Regex>,
 	/// Whether to sort tags chronologically.
-	pub date_order:           Option<bool>,
+	pub date_order:               Option<bool>,
 	/// Sorting of the commits inside sections.
-	pub sort_commits:         Option<String>,
+	pub sort_commits:             Option<String>,
 }
 
 /// Parser for grouping commits.
diff --git a/git-cliff/src/changelog.rs b/git-cliff/src/changelog.rs
index f56b2fa..39ce6f3 100644
--- a/git-cliff/src/changelog.rs
+++ b/git-cliff/src/changelog.rs
@@ -205,17 +205,25 @@ mod test {
 				trim:   Some(true),
 			},
 			git:       GitConfig {
-				conventional_commits:  Some(true),
-				filter_unconventional: Some(false),
-				split_commits:         Some(false),
-				commit_preprocessors:  Some(vec![CommitPreprocessor {
+				conventional_commits:     Some(true),
+				filter_unconventional:    Some(false),
+				split_commits:            Some(false),
+				commit_preprocessors:     Some(vec![CommitPreprocessor {
 					pattern:         Regex::new("<preprocess>").unwrap(),
 					replace:         Some(String::from(
 						"this commit is preprocessed",
 					)),
 					replace_command: None,
 				}]),
-				commit_parsers:        Some(vec![
+				commit_parsers:           Some(vec![
+					CommitParser {
+						message:       Regex::new(r".*merge.*").ok(),
+						body:          None,
+						group:         None,
+						default_scope: None,
+						scope:         None,
+						skip:          Some(true),
+					},
 					CommitParser {
 						message:       Regex::new("feat*").ok(),
 						body:          None,
@@ -232,14 +240,6 @@ mod test {
 						scope:         None,
 						skip:          None,
 					},
-					CommitParser {
-						message:       Regex::new("merge*").ok(),
-						body:          None,
-						group:         None,
-						default_scope: None,
-						scope:         None,
-						skip:          Some(true),
-					},
 					CommitParser {
 						message:       Regex::new("doc:").ok(),
 						body:          None,
@@ -257,13 +257,14 @@ mod test {
 						skip:          None,
 					},
 				]),
-				filter_commits:        Some(false),
-				tag_pattern:           None,
-				skip_tags:             Regex::new("v3.*").ok(),
-				ignore_tags:           None,
-				date_order:            Some(false),
-				sort_commits:          Some(String::from("oldest")),
-				link_parsers:          None,
+				protect_breaking_commits: None,
+				filter_commits:           Some(false),
+				tag_pattern:              None,
+				skip_tags:                Regex::new("v3.*").ok(),
+				ignore_tags:              None,
+				date_order:               Some(false),
+				sort_commits:             Some(String::from("oldest")),
+				link_parsers:             None,
 			},
 		};
 		let test_release = Release {
@@ -301,6 +302,10 @@ mod test {
 					String::from("qwerty"),
 					String::from("chore: <preprocess>"),
 				),
+				Commit::new(
+					String::from("qwertz"),
+					String::from("feat!: support breaking commits"),
+				),
 			],
 			commit_id: Some(String::from("0bc123")),
 			timestamp: 50000000,
@@ -328,6 +333,10 @@ mod test {
 						String::from("docs(app): document zyx"),
 					),
 					Commit::new(String::from("def789"), String::from("merge #4")),
+					Commit::new(
+						String::from("dev063"),
+						String::from("feat(app)!: merge #5"),
+					),
 					Commit::new(
 						String::from("qwerty"),
 						String::from("fix(app): fix abc"),
@@ -388,6 +397,7 @@ mod test {
 
 			#### other
 			- support unscoped commits
+			- support breaking commits
 
 			### Other
 			#### app
@@ -412,6 +422,7 @@ mod test {
 		let (mut config, mut releases) = get_test_data();
 		config.git.split_commits = Some(true);
 		config.git.filter_unconventional = Some(false);
+		config.git.protect_breaking_commits = Some(true);
 		releases[0].commits.push(Commit::new(
 			String::from("0bc123"),
 			String::from(
@@ -467,6 +478,10 @@ chore(deps): fix broken deps
 			#### ui
 			- do boring stuff
 
+			### feat
+			#### app
+			- merge #5
+
 			## Release [v1.0.0] - 1971-08-02
 			(0bc123)
 
@@ -490,6 +505,7 @@ chore(deps): fix broken deps
 
 			#### other
 			- support unscoped commits
+			- support breaking commits
 			- add awesome stuff
 
 			### Other


Test patch:
diff --git a/.github/fixtures/test-skip-breaking-changes/cliff.toml b/.github/fixtures/test-skip-breaking-changes/cliff.toml
new file mode 100644
index 00000000..e83ed7b
--- /dev/null
+++ b/.github/fixtures/test-skip-breaking-changes/cliff.toml
@@ -0,0 +1,39 @@
+[changelog]
+# changelog header
+header = """
+# Changelog\n
+All notable changes to this project will be documented in this file.\n
+"""
+# template for the changelog body
+# https://tera.netlify.app/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
+"""
+# remove the leading and trailing whitespaces from the template
+trim = true
+# changelog footer
+footer = """
+<!-- generated by git-cliff -->
+"""
+
+[git]
+# regex for parsing and grouping commits
+commit_parsers = [
+    { message = "^feat", group = "Features", default_scope = "app", skip = true},
+    { message = "^fix", group = "Bug Fixes", scope = "cli", skip = true},
+]
+# protect breaking changes from being skipped
+protect_breaking_commits = true
diff --git a/.github/fixtures/test-skip-breaking-changes/commit.sh b/.github/fixtures/test-skip-breaking-changes/commit.sh
new file mode 100755
index 00000000..43e9d5d
--- /dev/null
+++ b/.github/fixtures/test-skip-breaking-changes/commit.sh
@@ -0,0 +1,9 @@
+#!/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-skip-breaking-changes/expected.md b/.github/fixtures/test-skip-breaking-changes/expected.md
new file mode 100644
index 00000000..a3a4dd2
--- /dev/null
+++ b/.github/fixtures/test-skip-breaking-changes/expected.md
@@ -0,0 +1,20 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+## [0.1.0] - 2022-04-05
+
+### Bug Fixes
+
+#### Cli
+
+- Fix feature 1
+- Fix feature 2
+
+### Features
+
+#### Gui
+
+- Add feature 2
+
+<!-- generated by git-cliff -->
diff --git a/git-cliff-core/tests/integration_test.rs b/git-cliff-core/tests/integration_test.rs
index 14de2da..d9e77a4 100644
--- a/git-cliff-core/tests/integration_test.rs
+++ b/git-cliff-core/tests/integration_test.rs
@@ -38,15 +38,15 @@ fn generate_changelog() -> Result<()> {
 		trim:   None,
 	};
 	let git_config = GitConfig {
-		conventional_commits:  Some(true),
-		filter_unconventional: Some(true),
-		split_commits:         Some(false),
-		commit_preprocessors:  Some(vec![CommitPreprocessor {
+		conventional_commits:     Some(true),
+		filter_unconventional:    Some(true),
+		split_commits:            Some(false),
+		commit_preprocessors:     Some(vec![CommitPreprocessor {
 			pattern:         Regex::new(r#"\(fixes (#[1-9]+)\)"#).unwrap(),
 			replace:         Some(String::from("[closes Issue${1}]")),
 			replace_command: None,
 		}]),
-		commit_parsers:        Some(vec![
+		commit_parsers:           Some(vec![
 			CommitParser {
 				message:       Regex::new("^feat").ok(),
 				body:          None,
@@ -72,13 +72,14 @@ fn generate_changelog() -> Result<()> {
 				skip:          None,
 			},
 		]),
-		filter_commits:        Some(true),
-		tag_pattern:           None,
-		skip_tags:             None,
-		ignore_tags:           None,
-		date_order:            None,
-		sort_commits:          None,
-		link_parsers:          Some(vec![
+		protect_breaking_commits: None,
+		filter_commits:           Some(true),
+		tag_pattern:              None,
+		skip_tags:                None,
+		ignore_tags:              None,
+		date_order:               None,
+		sort_commits:             None,
+		link_parsers:             Some(vec![
 			LinkParser {
 				pattern: Regex::new("#(\\d+)").unwrap(),
 				href:    String::from("https://github.com/$1"),