Repository: orhun/git-cliff
Pull request #569 (2024-03-22 12:42:28)

feat(changelog): support parsing commits by footer
## Description

Enable footer-based commit parsing. The idea is to add functionality like

```toml
commit_parsers = [
  { footer = "Hello: world", skip = true },
]
```

I would appreciate some guidance – is this useful, desired, worth it, …?

## Motivation and Context

<!--- Why is this change required? What problem does it solve? -->
<!--- If it fixes an open issue, please link to the issue here. -->

In order to keep my changelogs concise, I would like to ignore certain commits. I would like to specify whether a commit should be ignored in the commit message. A somewhat elegant way to achieve this (I think) would be to include a footer like `changelog: ignore`.
(If you have a better way to approach this, I'm happy to learn of it.)

## How Has This Been Tested?

<!--- Please describe in detail how you tested your changes. -->
<!--- Include details of your testing environment, and the tests you ran to -->
<!--- see how your change affects other areas of the code, etc. -->

Append existing tests that create sample changelogs.

The test `changelog_generator_split_commits` is failing. It appears that a footer's value (but not the key) makes its way into the changelog. This might be because the footer's key is interpreted as a conventional commit type. I would appreciate some guidance here.

## Possible Limitations

The conventional commits' specification and [the reference implementation of its parser](https://github.com/conventional-commits/parser) are currently out of sync. This might mean that the commit parser `{footer = "Hello: world"}` does not work (even though it should), whereas the commit parser `{footer = "Hello:world"}` does work (even though omitting the space goes against the specification).

Some relevant issues concerning footer parsing are: 
- https://github.com/conventional-commits/parser/issues/47
- https://github.com/conventional-commits/parser/issues/41
- https://github.com/crate-ci/git-conventional/pull/54

## 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 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! ⛰️  -->


orhun: Thanks for the PR!

> I would appreciate some guidance – is this useful, desired, worth it, …?
> A somewhat elegant way to achieve this (I think) would be to include a footer like changelog: ignore.

I'm wondering if the same can be achieved with `body` which `git-cliff` already supports filtering.

I'm also wondering if this is useful for #382. But I'm guessing "This commit reverts" is not a part of the footer.

> The test changelog_generator_split_commits is failing. It appears that a footer's value (but not the key) makes its way into the changelog. This might be because the footer's key is interpreted as a conventional commit type. I would appreciate some guidance here.

That is the expected behavior so I recommend you to either add a new `CommitParser` to `changelog_generator_split_commits` to skip that commit or update the expected result accordingly to include that commit in the changelog.

Overall, I think this might be useful. Can you update the documentation about the existence of `footer` filter and also [add a test fixture](https://github.com/orhun/git-cliff/tree/main/.github/fixtures) to verify it's behavior?
orhun: ping @jan-ferdinand 🙂 
jan-ferdinand: Other things have come up and are more pressing right now; I'll come back to this. Apologies if that creates inconveniencies.
orhun: No worries!
codecov-commenter: ## [Codecov](https://app.codecov.io/gh/orhun/git-cliff/pull/569?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Orhun+Parmaks%C4%B1z) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 37.20%. Comparing base [(`eac2195`)](https://app.codecov.io/gh/orhun/git-cliff/commit/eac2195963e1688cd865969a01cc944d6a4af7b4?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Orhun+Parmaks%C4%B1z) to head [(`0685c86`)](https://app.codecov.io/gh/orhun/git-cliff/commit/0685c8652372b2160bee6861ed0496fb1e7cad05?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Orhun+Parmaks%C4%B1z).


:exclamation: Your organization needs to install the [Codecov GitHub app](https://github.com/apps/codecov/installations/select_target) to enable full functionality.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main     #569      +/-   ##
==========================================
+ Coverage   36.89%   37.20%   +0.31%     
==========================================
  Files          18       18              
  Lines        1350     1355       +5     
==========================================
+ Hits          498      504       +6     
+ Misses        852      851       -1     
```

| [Flag](https://app.codecov.io/gh/orhun/git-cliff/pull/569/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Orhun+Parmaks%C4%B1z) | Coverage Δ | |
|---|---|---|
| [unit-tests](https://app.codecov.io/gh/orhun/git-cliff/pull/569/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Orhun+Parmaks%C4%B1z) | `37.20% <100.00%> (+0.31%)` | :arrow_up: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Orhun+Parmaks%C4%B1z#carryforward-flags-in-the-pull-request-comment) to find out more.


</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/orhun/git-cliff/pull/569?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Orhun+Parmaks%C4%B1z).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Orhun+Parmaks%C4%B1z).

jan-ferdinand: Thanks for ping & patience. I have made the following changes, most of which were requested or guided towards:

- rebase onto current `main`
- fix test `changelog_generator_split_commits`
- add fixture
- add some documentation

I would like to have some additional guidance on the following points:

- Is the amount of documentation enough? It seems to be very little, but I couldn't readily identify more places where it makes sense to add additional documentation.
- The conventional commit reference parser being [out of sync with the specification](https://github.com/conventional-commits/parser/issues/47) is annoying for this PR. It requires users that want to use the suggested feature to write parsers of the form `{ footer = "^key:value }` (which goes against the spec), or, to be a bit more future proof `{ footer = ^key: ?value }` (which is confusing). Is this acceptable?
orhun: Thanks for picking up on this!

> Is the amount of documentation enough? It seems to be very little, but I couldn't readily identify more places where it makes sense to add additional documentation.

I think it is good enough! I will mention this feature in the [news](https://git-cliff.org/blog) so it should be visible enough.

>  Does it still make sense to go ahead with the suggested feature despite the possible confusion?

Yes, we can go ahead with it. Alternatively, I'm not sure if there are any actions needed but, you can report this to [git-conventional](https://github.com/crate-ci/git-conventional) (which `git-cliff` uses for parsing the commits) FWIW.

> I have left comments briefly discussing the out-of-syncedness of spec & parser in two places, one in the fixture and one in the spec. Does this make sense, or would you rather I remove or merge these? 

I would rather have them as comment like they are now. Good to have the references 👍🏼 


jan-ferdinand: > Thanks for picking up on this!

Took me long enough. :shrug: Thanks for your patience!

> I think it is good enough! I will mention this feature in the [news](https://git-cliff.org/blog) so it should be visible enough.

:ok_hand:

> Yes, we can go ahead with it. Alternatively, I'm not sure if there are any actions needed but, you can report this to [git-conventional](https://github.com/crate-ci/git-conventional) (which `git-cliff` uses for parsing the commits) FWIW.

I had [opened a PR](https://github.com/crate-ci/git-conventional/pull/54) in trying to resolve this issue. The maintainer chose to stay consistent with the reference implemenation rather than deviating from it, a decision I can understand.

> I would rather have them as comment like they are now. Good to have the references 👍🏼

:+1:

Thanks for your guidance. :blush: I'm going to rebase & force-push in hopse of removing the blocking conflict.

Patch:
diff --git a/git-cliff-core/src/changelog.rs b/git-cliff-core/src/changelog.rs
index 7707f37..1b6676b 100644
--- a/git-cliff-core/src/changelog.rs
+++ b/git-cliff-core/src/changelog.rs
@@ -632,6 +632,7 @@ mod test {
 						sha:           Some(String::from("tea")),
 						message:       None,
 						body:          None,
+						footer:        None,
 						group:         Some(String::from("I love tea")),
 						default_scope: None,
 						scope:         None,
@@ -643,6 +644,7 @@ mod test {
 						sha:           Some(String::from("coffee")),
 						message:       None,
 						body:          None,
+						footer:        None,
 						group:         None,
 						default_scope: None,
 						scope:         None,
@@ -654,6 +656,7 @@ mod test {
 						sha:           Some(String::from("coffee2")),
 						message:       None,
 						body:          None,
+						footer:        None,
 						group:         None,
 						default_scope: None,
 						scope:         None,
@@ -665,6 +668,7 @@ mod test {
 						sha:           None,
 						message:       Regex::new(r".*merge.*").ok(),
 						body:          None,
+						footer:        None,
 						group:         None,
 						default_scope: None,
 						scope:         None,
@@ -676,6 +680,7 @@ mod test {
 						sha:           None,
 						message:       Regex::new("feat*").ok(),
 						body:          None,
+						footer:        None,
 						group:         Some(String::from("New features")),
 						default_scope: Some(String::from("other")),
 						scope:         None,
@@ -687,6 +692,7 @@ mod test {
 						sha:           None,
 						message:       Regex::new("^fix*").ok(),
 						body:          None,
+						footer:        None,
 						group:         Some(String::from("Bug Fixes")),
 						default_scope: None,
 						scope:         None,
@@ -698,6 +704,7 @@ mod test {
 						sha:           None,
 						message:       Regex::new("doc:").ok(),
 						body:          None,
+						footer:        None,
 						group:         Some(String::from("Documentation")),
 						default_scope: None,
 						scope:         Some(String::from("documentation")),
@@ -709,6 +716,7 @@ mod test {
 						sha:           None,
 						message:       Regex::new("docs:").ok(),
 						body:          None,
+						footer:        None,
 						group:         Some(String::from("Documentation")),
 						default_scope: None,
 						scope:         Some(String::from("documentation")),
@@ -720,6 +728,7 @@ mod test {
 						sha:           None,
 						message:       Regex::new(r"match\((.*)\):.*").ok(),
 						body:          None,
+						footer:        None,
 						group:         Some(String::from("Matched ($1)")),
 						default_scope: None,
 						scope:         None,
@@ -727,10 +736,23 @@ mod test {
 						field:         None,
 						pattern:       None,
 					},
+					CommitParser {
+						sha:           None,
+						message:       None,
+						body:          None,
+						footer:        Regex::new("Footer:.*").ok(),
+						group:         Some(String::from("Footer")),
+						default_scope: None,
+						scope:         Some(String::from("footer")),
+						skip:          None,
+						field:         None,
+						pattern:       None,
+					},
 					CommitParser {
 						sha:           None,
 						message:       Regex::new(".*").ok(),
 						body:          None,
+						footer:        None,
 						group:         Some(String::from("Other")),
 						default_scope: Some(String::from("other")),
 						scope:         None,
@@ -832,6 +854,10 @@ mod test {
 					String::from("coffee"),
 					String::from("revert(app): skip this commit"),
 				),
+				Commit::new(
+					String::from("footer"),
+					String::from("misc: use footer\n\nFooter: footer text"),
+				),
 			],
 			commit_id: Some(String::from("0bc123")),
 			timestamp: 50000000,
@@ -957,6 +983,10 @@ mod test {
 			- update docs
 			- add some documentation
 
+			### Footer
+			#### footer
+			- use footer
+
 			### I love tea
 			#### app
 			- damn right
@@ -998,6 +1028,13 @@ mod test {
 		config.git.split_commits = Some(true);
 		config.git.filter_unconventional = Some(false);
 		config.git.protect_breaking_commits = Some(true);
+
+		if let Some(parsers) = config.git.commit_parsers.as_mut() {
+			for parser in parsers.iter_mut().filter(|p| p.footer.is_some()) {
+				parser.skip = Some(true)
+			}
+		}
+
 		releases[0].commits.push(Commit::new(
 			String::from("0bc123"),
 			String::from(
@@ -1164,6 +1201,10 @@ chore(deps): fix broken deps
     - update docs
     - add some documentation
 
+    ### Footer
+    #### footer
+    - use footer
+
     ### I love tea
     #### app
     - damn right
diff --git a/git-cliff-core/src/commit.rs b/git-cliff-core/src/commit.rs
index 1a74d1e..f609284 100644
--- a/git-cliff-core/src/commit.rs
+++ b/git-cliff-core/src/commit.rs
@@ -277,6 +277,13 @@ impl Commit<'_> {
 			if let Some(body_regex) = parser.body.as_ref() {
 				regex_checks.push((body_regex, body.clone().unwrap_or_default()))
 			}
+			if let (Some(footer_regex), Some(footers)) = (
+				parser.footer.as_ref(),
+				self.conv.as_ref().map(|v| v.footers()),
+			) {
+				regex_checks
+					.extend(footers.iter().map(|f| (footer_regex, f.to_string())));
+			}
 			if let (Some(field_name), Some(pattern_regex)) =
 				(parser.field.as_ref(), parser.pattern.as_ref())
 			{
@@ -483,6 +490,7 @@ mod test {
 				sha:           None,
 				message:       Regex::new("test*").ok(),
 				body:          None,
+				footer:        None,
 				group:         Some(String::from("test_group")),
 				default_scope: Some(String::from("test_scope")),
 				scope:         None,
@@ -656,6 +664,7 @@ mod test {
 				sha:           None,
 				message:       None,
 				body:          None,
+				footer:        None,
 				group:         Some(String::from("Test group")),
 				default_scope: None,
 				scope:         None,
@@ -684,6 +693,7 @@ mod test {
 				)),
 				message:       None,
 				body:          None,
+				footer:        None,
 				group:         None,
 				default_scope: None,
 				scope:         None,
@@ -703,6 +713,7 @@ mod test {
 				)),
 				message:       None,
 				body:          None,
+				footer:        None,
 				group:         Some(String::from("Test group")),
 				default_scope: None,
 				scope:         None,
diff --git a/git-cliff-core/src/config.rs b/git-cliff-core/src/config.rs
index 0d2a855..1a142ab 100644
--- a/git-cliff-core/src/config.rs
+++ b/git-cliff-core/src/config.rs
@@ -213,6 +213,9 @@ pub struct CommitParser {
 	/// Regex for matching the commit body.
 	#[serde(with = "serde_regex", default)]
 	pub body:          Option<Regex>,
+	/// Regex for matching the commit footer.
+	#[serde(with = "serde_regex", default)]
+	pub footer:        Option<Regex>,
 	/// Group of the commit.
 	pub group:         Option<String>,
 	/// Default scope of the commit.
diff --git a/website/docs/configuration/git.md b/website/docs/configuration/git.md
index b7d7145..2c84f9a 100644
--- a/website/docs/configuration/git.md
+++ b/website/docs/configuration/git.md
@@ -146,6 +146,10 @@ Examples:
   - Group the commit as "Features" if the commit message (description) starts with "feat".
 - `{ body = ".*security", group = "Security" }`
   - Group the commit as "Security" if the commit body contains "security".
+    <!-- Conventional commits parser is out of sync with spec, parsing only separator ":", not ": "; see: -->
+    <!-- https://github.com/conventional-commits/parser/issues/47 -->
+- `{ footer = "^changelog: ?ignore", skip = true }`
+  - Skip processing the commit if the commit footer contains "changelog: ignore".
 - `{ message = '^fix\((.*)\)', group = 'Fix (${1})' }`
   - Use the matched scope value from the commit message in the group name.
 - `{ message = ".*deprecated", body = ".*deprecated", group = "Deprecation" }`


Test patch:
diff --git a/.github/fixtures/test-footer-filter/cliff.toml b/.github/fixtures/test-footer-filter/cliff.toml
new file mode 100644
index 00000000..392bb0a
--- /dev/null
+++ b/.github/fixtures/test-footer-filter/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://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 commit in commits %}
+        - {{ commit.message | upper_first }}\
+    {% endfor %}
+{% endfor %}\n
+"""
+# template for the changelog footer
+footer = """
+<!-- generated by git-cliff -->
+"""
+# remove the leading and trailing whitespace from the templates
+trim = true
+
+[git]
+# regex for parsing and grouping commits
+commit_parsers = [
+    { message = "^feat", group = "Features", default_scope = "app" },
+    { message = "^fix", group = "Bug Fixes", scope = "cli" },
+    # Accept both separators, ": " and ":".
+    # Conventional commits require the separator to be ": ", but the reference implementation of the
+    # conventional commits parser currently does not adhere to the specification. See also:
+    # https://github.com/conventional-commits/parser/issues/47
+    { footer = "^changelog: ?ignore", skip = true },
+]
diff --git a/.github/fixtures/test-footer-filter/commit.sh b/.github/fixtures/test-footer-filter/commit.sh
new file mode 100755
index 00000000..61da077
--- /dev/null
+++ b/.github/fixtures/test-footer-filter/commit.sh
@@ -0,0 +1,11 @@
+#!/usr/bin/env bash
+set -e
+
+GIT_COMMITTER_DATE="2022-04-06 00:00:00" git commit --allow-empty -m "Initial commit"
+GIT_COMMITTER_DATE="2022-04-06 00:10:00" git commit --allow-empty -m "feat: add feature 1"
+git tag v0.1.0
+GIT_COMMITTER_DATE="2022-04-06 01:00:00" git commit --allow-empty -m "refactor: change feature 1" -m "BREAKING CHANGE: feature 1 is now different"
+GIT_COMMITTER_DATE="2022-04-06 01:10:00" git commit --allow-empty -m "chore: upgrade dependencies" -m "changelog: ignore"
+git tag v0.2.0
+GIT_COMMITTER_DATE="2022-04-06 02:00:00" git commit --allow-empty -m "test: add tests" -m "footer: some more info"
+GIT_COMMITTER_DATE="2022-04-06 02:10:00" git commit --allow-empty -m "test: add more tests" -m "changelog: ignore"
diff --git a/.github/fixtures/test-footer-filter/expected.md b/.github/fixtures/test-footer-filter/expected.md
new file mode 100644
index 00000000..df28a3d
--- /dev/null
+++ b/.github/fixtures/test-footer-filter/expected.md
@@ -0,0 +1,23 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+## [unreleased]
+
+### Test
+
+- Add tests
+
+## [0.2.0] - 2022-04-06
+
+### Refactor
+
+- Change feature 1
+
+## [0.1.0] - 2022-04-06
+
+### Features
+
+- Add feature 1
+
+<!-- generated by git-cliff -->
diff --git a/git-cliff-core/tests/integration_test.rs b/git-cliff-core/tests/integration_test.rs
index 99d790d..95fd991 100644
--- a/git-cliff-core/tests/integration_test.rs
+++ b/git-cliff-core/tests/integration_test.rs
@@ -56,6 +56,7 @@ fn generate_changelog() -> Result<()> {
 				sha:           Some(String::from("coffee")),
 				message:       None,
 				body:          None,
+				footer:        None,
 				group:         Some(String::from("I love coffee")),
 				default_scope: None,
 				scope:         None,
@@ -67,6 +68,7 @@ fn generate_changelog() -> Result<()> {
 				sha:           None,
 				message:       Regex::new("^feat").ok(),
 				body:          None,
+				footer:        None,
 				group:         Some(String::from("shiny features")),
 				default_scope: None,
 				scope:         None,
@@ -78,6 +80,7 @@ fn generate_changelog() -> Result<()> {
 				sha:           None,
 				message:       Regex::new("^fix").ok(),
 				body:          None,
+				footer:        None,
 				group:         Some(String::from("fix bugs")),
 				default_scope: None,
 				scope:         None,
@@ -89,6 +92,7 @@ fn generate_changelog() -> Result<()> {
 				sha:           None,
 				message:       Regex::new("^test").ok(),
 				body:          None,
+				footer:        None,
 				group:         None,
 				default_scope: None,
 				scope:         Some(String::from("tests")),
@@ -100,6 +104,7 @@ fn generate_changelog() -> Result<()> {
 				sha:           None,
 				message:       None,
 				body:          None,
+				footer:        None,
 				group:         Some(String::from("docs")),
 				default_scope: None,
 				scope:         None,