Repository: orhun/git-cliff
Pull request #385 (2023-12-14 21:08:33)

feat(parser): support using SHA1 of the commit
## Description

See #382

## Motivation and Context

This PR makes it possible to use the SHA1 of a commit with `commit_parsers`.

For example:

```toml
# skip a specific commit
commit_parsers = [
    { sha = "f6f2472bdf0bbb5f9fcaf2d72c1fa9f98f772bb2", skip = true }
]
```

Another example:

```toml
# override the group of a specific commit
commit_parsers = [
    { sha = "f6f2472bdf0bbb5f9fcaf2d72c1fa9f98f772bb2", group = "Stuff" }
]
```

## How Has This Been Tested?

Unit/integration tests.
I didn't add fixture tests since it requires more work to find out the SHA1 of the created commit and substitue in `expected.md`.

## 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.


schoetbi: Awesome that you implemented this feature. It really helps me! What I noted when trying this out is that the order of the commit parsers matter. If I placed the sha skips at the very beginning it worked. When I moved them to the last position in the list it did not filter out these commits even though the git hashes matched. Is there a reason for this?

Also, the shas should match even if the casing of the letters is not the same as git delivers it.
These two lines should behave the same

```toml
commit_parsers = [
  { sha = "7ff168fbac5bf5dfc772e14e1ff23e62a338aab8", skip = true },
  { sha = "7FF168FBAC5BF5DFC772E14E1FF23E62A338AAB8", skip = true },
]
```  

orhun: Hey, thanks for testing it!

> When I moved them to the last position in the list, it did not filter out these commits, even though the git hashes matched. Is there a reason for this?

It is simply because the `commit_parsers` are processed in order and if a commit is matched by a parser it can't be parsed by another one. There is a related issue here: #117

Maybe I can note that the order matters in the documentation, or even better, support multiple parsers to be used.

> Also, the SHAs should match, even if the casing of the letters is not the same as git delivers it.

Good catch! Should be fixed in d698809a7a8ce1ce8d1341eacfd3135df26c8884

> Since this list might get lengthy over time, it might make sense to add a skiplist file to the configuration. 

Can you create an issue for discussing this feature? We can potentially have this configuration:

```toml
# cliff.toml
[git]
commit_parsers_file = "parsers.toml"
```

```toml
# parsers.toml
commit_parsers = [
    { sha = "f6f2472bdf0bbb5f9fcaf2d72c1fa9f98f772bb2", group = "Stuff" }
]
```

Patch:
diff --git a/git-cliff-core/src/changelog.rs b/git-cliff-core/src/changelog.rs
index 29569c3..6731ba3 100644
--- a/git-cliff-core/src/changelog.rs
+++ b/git-cliff-core/src/changelog.rs
@@ -249,6 +249,40 @@ mod test {
 				}]),
 				commit_parsers:           Some(vec![
 					CommitParser {
+						sha:           Some(String::from("tea")),
+						message:       None,
+						body:          None,
+						group:         Some(String::from("I love tea")),
+						default_scope: None,
+						scope:         None,
+						skip:          None,
+						field:         None,
+						pattern:       None,
+					},
+					CommitParser {
+						sha:           Some(String::from("coffee")),
+						message:       None,
+						body:          None,
+						group:         None,
+						default_scope: None,
+						scope:         None,
+						skip:          Some(true),
+						field:         None,
+						pattern:       None,
+					},
+					CommitParser {
+						sha:           Some(String::from("coffee2")),
+						message:       None,
+						body:          None,
+						group:         None,
+						default_scope: None,
+						scope:         None,
+						skip:          Some(true),
+						field:         None,
+						pattern:       None,
+					},
+					CommitParser {
+						sha:           None,
 						message:       Regex::new(r".*merge.*").ok(),
 						body:          None,
 						group:         None,
@@ -259,6 +293,7 @@ mod test {
 						pattern:       None,
 					},
 					CommitParser {
+						sha:           None,
 						message:       Regex::new("feat*").ok(),
 						body:          None,
 						group:         Some(String::from("New features")),
@@ -269,6 +304,7 @@ mod test {
 						pattern:       None,
 					},
 					CommitParser {
+						sha:           None,
 						message:       Regex::new("^fix*").ok(),
 						body:          None,
 						group:         Some(String::from("Bug Fixes")),
@@ -279,6 +315,7 @@ mod test {
 						pattern:       None,
 					},
 					CommitParser {
+						sha:           None,
 						message:       Regex::new("doc:").ok(),
 						body:          None,
 						group:         Some(String::from("Documentation")),
@@ -289,6 +326,7 @@ mod test {
 						pattern:       None,
 					},
 					CommitParser {
+						sha:           None,
 						message:       Regex::new("docs:").ok(),
 						body:          None,
 						group:         Some(String::from("Documentation")),
@@ -299,6 +337,7 @@ mod test {
 						pattern:       None,
 					},
 					CommitParser {
+						sha:           None,
 						message:       Regex::new(r"match\((.*)\):.*").ok(),
 						body:          None,
 						group:         Some(String::from("Matched ($1)")),
@@ -309,6 +348,7 @@ mod test {
 						pattern:       None,
 					},
 					CommitParser {
+						sha:           None,
 						message:       Regex::new(".*").ok(),
 						body:          None,
 						group:         Some(String::from("Other")),
@@ -333,6 +373,14 @@ mod test {
 		let test_release = Release {
 			version:   Some(String::from("v1.0.0")),
 			commits:   vec![
+				Commit::new(
+					String::from("coffee"),
+					String::from("revert(app): skip this commit"),
+				),
+				Commit::new(
+					String::from("tea"),
+					String::from("feat(app): damn right"),
+				),
 				Commit::new(
 					String::from("0bc123"),
 					String::from("feat(app): add cool features"),
@@ -377,6 +425,10 @@ mod test {
 					String::from("qwert0"),
 					String::from("match(group): support regex-replace for groups"),
 				),
+				Commit::new(
+					String::from("coffee"),
+					String::from("revert(app): skip this commit"),
+				),
 			],
 			commit_id: Some(String::from("0bc123")),
 			timestamp: 50000000,
@@ -416,6 +468,10 @@ mod test {
 						String::from("hjkl12"),
 						String::from("chore(ui): do boring stuff"),
 					),
+					Commit::new(
+						String::from("coffee2"),
+						String::from("revert(app): skip this commit"),
+					),
 				],
 				commit_id: None,
 				timestamp: 1000,
@@ -463,6 +519,10 @@ mod test {
 			- update docs
 			- add some documentation
 
+			### I love tea
+			#### app
+			- damn right
+
 			### Matched (group)
 			#### group
 			- support regex-replace for groups
@@ -574,6 +634,10 @@ chore(deps): fix broken deps
 			- update docs
 			- add some documentation
 
+			### I love tea
+			#### app
+			- damn right
+
 			### Matched (group)
 			#### group
 			- support regex-replace for groups
diff --git a/git-cliff-core/src/commit.rs b/git-cliff-core/src/commit.rs
index ca1c0ee..06e0fb3 100644
--- a/git-cliff-core/src/commit.rs
+++ b/git-cliff-core/src/commit.rs
@@ -120,6 +120,8 @@ pub struct Commit<'a> {
 	pub author:        Signature,
 	/// Committer.
 	pub committer:     Signature,
+	/// Whether if the commit has two or more parents.
+	pub merge_commit:  bool,
 }
 
 impl<'a> From<String> for Commit<'a> {
@@ -152,6 +154,7 @@ impl<'a> From<&GitCommit<'a>> for Commit<'a> {
 			message: commit.message().unwrap_or_default().to_string(),
 			author: commit.author().into(),
 			committer: commit.committer().into(),
+			merge_commit: commit.parent_count() > 1,
 			..Default::default()
 		}
 	}
@@ -285,6 +288,21 @@ impl Commit<'_> {
 					})?,
 				));
 			}
+			if parser.sha.clone().map(|v| v.to_lowercase()).as_deref() ==
+				Some(&self.id)
+			{
+				if self.skip_commit(parser, protect_breaking) {
+					return Err(AppError::GroupError(String::from(
+						"Skipping commit",
+					)));
+				} else {
+					self.group = parser.group.clone().or(self.group);
+					self.scope = parser.scope.clone().or(self.scope);
+					self.default_scope =
+						parser.default_scope.clone().or(self.default_scope);
+					return Ok(self);
+				}
+			}
 			for (regex, text) in regex_checks {
 				if regex.is_match(&text) {
 					if self.skip_commit(parser, protect_breaking) {
@@ -414,6 +432,7 @@ impl Serialize for Commit<'_> {
 		commit.serialize_field("author", &self.author)?;
 		commit.serialize_field("committer", &self.committer)?;
 		commit.serialize_field("conventional", &self.conv.is_some())?;
+		commit.serialize_field("merge_commit", &self.merge_commit)?;
 		commit.end()
 	}
 }
@@ -442,6 +461,7 @@ mod test {
 		}
 		let commit = test_cases[0].0.clone().parse(
 			&[CommitParser {
+				sha:           None,
 				message:       Regex::new("test*").ok(),
 				body:          None,
 				group:         Some(String::from("test_group")),
@@ -614,6 +634,7 @@ mod test {
 
 		let parsed_commit = commit.parse(
 			&[CommitParser {
+				sha:           None,
 				message:       None,
 				body:          None,
 				group:         Some(String::from("Test group")),
@@ -630,4 +651,51 @@ mod test {
 		assert_eq!(Some(String::from("Test group")), parsed_commit.group);
 		Ok(())
 	}
+
+	#[test]
+	fn commit_sha() -> Result<()> {
+		let commit = Commit::new(
+			String::from("8f55e69eba6e6ce811ace32bd84cc82215673cb6"),
+			String::from("feat: do something"),
+		);
+		let parsed_commit = commit.clone().parse(
+			&[CommitParser {
+				sha:           Some(String::from(
+					"8f55e69eba6e6ce811ace32bd84cc82215673cb6",
+				)),
+				message:       None,
+				body:          None,
+				group:         None,
+				default_scope: None,
+				scope:         None,
+				skip:          Some(true),
+				field:         None,
+				pattern:       None,
+			}],
+			false,
+			false,
+		);
+		assert!(parsed_commit.is_err());
+
+		let parsed_commit = commit.parse(
+			&[CommitParser {
+				sha:           Some(String::from(
+					"8f55e69eba6e6ce811ace32bd84cc82215673cb6",
+				)),
+				message:       None,
+				body:          None,
+				group:         Some(String::from("Test group")),
+				default_scope: None,
+				scope:         None,
+				skip:          None,
+				field:         None,
+				pattern:       None,
+			}],
+			false,
+			false,
+		)?;
+		assert_eq!(Some(String::from("Test group")), parsed_commit.group);
+
+		Ok(())
+	}
 }
diff --git a/git-cliff-core/src/config.rs b/git-cliff-core/src/config.rs
index cfbd926..c9f6a6c 100644
--- a/git-cliff-core/src/config.rs
+++ b/git-cliff-core/src/config.rs
@@ -87,6 +87,8 @@ pub struct GitConfig {
 /// Parser for grouping commits.
 #[derive(Debug, Clone, Serialize, Deserialize)]
 pub struct CommitParser {
+	/// SHA1 of the commit.
+	pub sha:           Option<String>,
 	/// Regex for matching the commit message.
 	#[serde(with = "serde_regex", default)]
 	pub message:       Option<Regex>,
diff --git a/website/docs/configuration.md b/website/docs/configuration.md
index 0d72867..a670e11 100644
--- a/website/docs/configuration.md
+++ b/website/docs/configuration.md
@@ -1,6 +1,7 @@
 ---
 sidebar_position: 4
 ---
+
 # Configuration
 
 **git-cliff** configuration file supports [TOML](https://github.com/toml-lang/toml) (preferred) and [YAML](https://yaml.org) formats.
@@ -213,21 +214,25 @@ An array of commit parsers for determining the commit groups by using regex.
 
 Examples:
 
-- `{ message = "^feat", group = "Features"}`
+- `{ message = "^feat", group = "Features" }`
   - Group the commit as "Features" if the commit message (description) starts with "feat".
-- `{ body = ".*security", group = "Security"}`
+- `{ body = ".*security", group = "Security" }`
   - Group the commit as "Security" if the commit body contains "security".
 - `{ message = '^fix\((.*)\)', group = 'Fix (${1})' }`
   - Use the matched scope value from the commit message in the group name.
-- `{ message = ".*deprecated", body = ".*deprecated", group = "Deprecation"}`
+- `{ message = ".*deprecated", body = ".*deprecated", group = "Deprecation" }`
   - Group the commit as "Deprecation" if the commit body and message contains "deprecated".
-- `{ message = "^revert", skip = true}`
+- `{ message = "^revert", skip = true }`
   - Skip processing the commit if the commit message (description) starts with "revert".
-- `{ message = "^doc", group = "Documentation", default_scope = "other"},`
+- `{ message = "^doc", group = "Documentation", default_scope = "other" },`
   - 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"}`
+- `{ 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.
-- `{ field = "author.name", pattern = "John Doe", group = "John's stuff"}`
+- `{ sha = "f6f2472bdf0bbb5f9fcaf2d72c1fa9f98f772bb2", skip = true }`
+  - Skip a specific commit by using its SHA1.
+- `{ sha = "f6f2472bdf0bbb5f9fcaf2d72c1fa9f98f772bb2", group = "Stuff" }`
+  - Set the group of the commit by using its SHA1.
+- `{ field = "author.name", pattern = "John Doe", group = "John's stuff" }`
   - If the author's name attribute of the commit matches the pattern "John Doe" (as a regex), override the scope with "John' stuff". Supported commit attributes are:
     - `id`
     - `message`
diff --git a/website/docs/templating/context.md b/website/docs/templating/context.md
index e1620f8..b88a06b 100644
--- a/website/docs/templating/context.md
+++ b/website/docs/templating/context.md
@@ -43,6 +43,7 @@ following context is generated to use for templating:
       "breaking_description": "<description>",
       "breaking": false,
       "conventional": true,
+      "merge_commit": false,
       "links": [
         { "text": "(set by link_parsers)", "href": "(set by link_parsers)" }
       ],
@@ -129,6 +130,7 @@ If [`conventional_commits`](/docs/configuration#conventional_commits) is set to
       "scope": "(overridden by commit_parsers)",
       "message": "(full commit message including description, footers, etc.)",
       "conventional": false,
+      "merge_commit": false,
       "links": [
         { "text": "(set by link_parsers)", "href": "(set by link_parsers)" }
       ],


Test patch:
diff --git a/git-cliff-core/tests/integration_test.rs b/git-cliff-core/tests/integration_test.rs
index 31096f6..b0bead6 100644
--- a/git-cliff-core/tests/integration_test.rs
+++ b/git-cliff-core/tests/integration_test.rs
@@ -52,6 +52,18 @@ fn generate_changelog() -> Result<()> {
 		}]),
 		commit_parsers:           Some(vec![
 			CommitParser {
+				sha:           Some(String::from("coffee")),
+				message:       None,
+				body:          None,
+				group:         Some(String::from("I love coffee")),
+				default_scope: None,
+				scope:         None,
+				skip:          None,
+				field:         None,
+				pattern:       None,
+			},
+			CommitParser {
+				sha:           None,
 				message:       Regex::new("^feat").ok(),
 				body:          None,
 				group:         Some(String::from("shiny features")),
@@ -62,6 +74,7 @@ fn generate_changelog() -> Result<()> {
 				pattern:       None,
 			},
 			CommitParser {
+				sha:           None,
 				message:       Regex::new("^fix").ok(),
 				body:          None,
 				group:         Some(String::from("fix bugs")),
@@ -72,6 +85,7 @@ fn generate_changelog() -> Result<()> {
 				pattern:       None,
 			},
 			CommitParser {
+				sha:           None,
 				message:       Regex::new("^test").ok(),
 				body:          None,
 				group:         None,
@@ -82,6 +96,7 @@ fn generate_changelog() -> Result<()> {
 				pattern:       None,
 			},
 			CommitParser {
+				sha:           None,
 				message:       None,
 				body:          None,
 				group:         Some(String::from("docs")),
@@ -129,6 +144,7 @@ fn generate_changelog() -> Result<()> {
 		Release {
 			version:   Some(String::from("v2.0.0")),
 			commits:   vec![
+
 				Commit::new(
 					String::from("000abc"),
 					String::from("Add unconventional commit"),