diff --git a/.github/fixtures/test-commit-footers/cliff.toml b/.github/fixtures/test-commit-footers/cliff.toml new file mode 100644 index 0000000000..1cd862a283 --- /dev/null +++ b/.github/fixtures/test-commit-footers/cliff.toml @@ -0,0 +1,30 @@ +[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 commit in commits %} + - {{ commit.message | upper_first }} ([{{ commit.id | truncate(length=7, end="") }}]({{ commit.id }}))\ + {% for footer in commit.footers -%} + , {{ footer.token }}{{ footer.separator }}{{ footer.value }}\ + {% endfor %}\ + {% endfor %} +{% endfor %}\n +""" +# remove the leading and trailing whitespace from the template +trim = true +# changelog footer +footer = """ + +""" diff --git a/.github/fixtures/test-commit-footers/commit.sh b/.github/fixtures/test-commit-footers/commit.sh new file mode 100755 index 0000000000..d19473d0d9 --- /dev/null +++ b/.github/fixtures/test-commit-footers/commit.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -e + +GIT_COMMITTER_DATE="2021-01-23 01:23:45" git commit --allow-empty -m "feat: add feature 1" -m "footer: test" + +GIT_COMMITTER_DATE="2021-01-23 01:23:46" git commit --allow-empty -m "feat: add feature 2" -m "Signed-off-by: bot" +git tag v0.1.0 + +GIT_COMMITTER_DATE="2021-01-23 01:23:47" git commit --allow-empty -m "fix: fix feature 1" -m "footer1: xyz" -m "footer2: abc" diff --git a/.github/fixtures/test-commit-footers/expected.md b/.github/fixtures/test-commit-footers/expected.md new file mode 100644 index 0000000000..6a08cda770 --- /dev/null +++ b/.github/fixtures/test-commit-footers/expected.md @@ -0,0 +1,18 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [unreleased] + +### Fix + +- Fix feature 1 ([540f28b](540f28b88861802ca6c196482c5c70933593561b)), footer1:xyz, footer2:abc + +## [0.1.0] - 2021-01-23 + +### Feat + +- Add feature 1 ([376fd60](376fd6043cb27af83973f31dd6aab87486d8e554)), footer:test +- Add feature 2 ([fc086fa](fc086faec7a5bd4429f62f01c4a871631f63be68)), Signed-off-by:bot + + diff --git a/README.md b/README.md index cba03a7f06..93b5f00592 100644 --- a/README.md +++ b/README.md @@ -669,7 +669,14 @@ following context is generated to use for templating: "scope": "[scope]", "message": "", "body": "[body]", - "footers": ["[footer]", "[footer]"], + "footers": [ + { + "token": "", + "separator": "", + "value": "", "breaking": false, "conventional": true, @@ -684,6 +691,24 @@ following context is generated to use for templating: } ``` +##### Footers + +A conventional commit's body may end with any number of structured key-value pairs known as [_footers_](https://www.conventionalcommits.org/en/v1.0.0/#specification). These consist of a string token naming the footer, a separator (which is either `: ` or ` #`), and a value, similar to [the git trailers convention](https://git-scm.com/docs/git-interpret-trailers). + +For example: + +- `Signed-off-by: User Name ` +- `Reviewed-by: User Name ` +- `Fixes #1234` +- `BREAKING CHANGE: breaking change description` + +When a conventional commit contains footers, the footers are passed to the template in a `footers` array in the commit object. Each footer is represented by an object with the following fields: + +- `"token"`, the name of the footer (preceeding the separator character) +- `separator`, the footer's separator string (either `: ` or ` #`) +- `value`, the value following the separator character +- `breaking`, which is `true` if this is a `BREAKING CHANGE:` footer, and `false` otherwise + ##### Breaking Changes `breaking` flag is set to `true` when the commit has an exclamation mark after the commit type and scope, e.g.: @@ -702,6 +727,9 @@ BREAKING CHANGE: this is a breaking change `breaking_description` is set to the explanation of the breaking change. This description is expected to be present in the `BREAKING CHANGE` footer. However, if it's not provided, the `message` is expected to describe the breaking change. +If the `BREAKING CHANGE:` footer is present, the footer will also be included in +`commit.footers`. + #### Non-Conventional Commits > conventional_commits = **false** diff --git a/examples/detailed.toml b/examples/detailed.toml index b6b57fdd35..bd355c12b9 100644 --- a/examples/detailed.toml +++ b/examples/detailed.toml @@ -24,6 +24,9 @@ body = """ ### {{ group | upper_first }} {% for commit in commits %} - {{ commit.message | upper_first }} ([{{ commit.id | truncate(length=7, end="") }}]({{ commit.id }}))\ + {% for footer in commit.footers -%} + , {{ footer.token }}{{ footer.separator }}{{ footer.value }}\ + {% endfor %}\ {% endfor %} {% endfor %}\n """ diff --git a/git-cliff-core/src/commit.rs b/git-cliff-core/src/commit.rs index adb192f1fa..be87679dd6 100644 --- a/git-cliff-core/src/commit.rs +++ b/git-cliff-core/src/commit.rs @@ -49,6 +49,35 @@ pub struct Link { pub href: String, } +/// A conventional commit footer. +#[derive(Debug, Clone, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +struct Footer<'a> { + /// Token of the footer. + /// + /// This is the part of the footer preceding the separator. For example, for + /// the `Signed-off-by: ` footer, this would be `Signed-off-by`. + token: &'a str, + /// The separator between the footer token and its value. + /// + /// This is typically either `:` or `#`. + separator: &'a str, + /// The value of the footer. + value: &'a str, + /// A flag to signal that the footer describes a breaking change. + breaking: bool, +} + +impl<'a> From<&'a git_conventional::Footer<'a>> for Footer<'a> { + fn from(footer: &'a git_conventional::Footer<'a>) -> Self { + Self { + token: footer.token().as_str(), + separator: footer.separator().as_str(), + value: footer.value(), + breaking: footer.breaking(), + } + } +} + impl<'a> From<&GitCommit<'a>> for Commit<'a> { fn from(commit: &GitCommit<'a>) -> Self { Self::new( @@ -208,6 +237,16 @@ impl Commit<'_> { } Ok(self) } + + /// Returns an iterator over this commit's [`Footer`]s, if this is a + /// conventional commit. + /// + /// If this commit is not conventional, the returned iterator will be empty. + fn footers(&self) -> impl Iterator> { + self.conv + .iter() + .flat_map(|conv| conv.footers().iter().map(Footer::from)) + } } impl Serialize for Commit<'_> { @@ -215,21 +254,29 @@ impl Serialize for Commit<'_> { where S: Serializer, { + /// A wrapper to serialize commit footers from an iterator using + /// `Serializer::collect_seq` without having to allocate in order to + /// `collect` the footers into a new to `Vec`. + struct SerializeFooters<'a>(&'a Commit<'a>); + impl Serialize for SerializeFooters<'_> { + fn serialize( + &self, + serializer: S, + ) -> std::result::Result + where + S: Serializer, + { + serializer.collect_seq(self.0.footers()) + } + } + let mut commit = serializer.serialize_struct("Commit", 9)?; commit.serialize_field("id", &self.id)?; match &self.conv { Some(conv) => { commit.serialize_field("message", conv.description())?; commit.serialize_field("body", &conv.body())?; - commit.serialize_field( - "footers", - &conv - .footers() - .to_vec() - .iter() - .map(|f| f.value()) - .collect::>(), - )?; + commit.serialize_field("footers", &SerializeFooters(self))?; commit.serialize_field( "group", self.group.as_ref().unwrap_or(&conv.type_().to_string()), @@ -304,6 +351,58 @@ mod test { assert_eq!(Some(String::from("test_scope")), commit.default_scope); } + #[test] + fn conventional_footers() { + let cfg = crate::config::GitConfig { + conventional_commits: Some(true), + ..Default::default() + }; + let test_cases = vec![ + ( + Commit::new( + String::from("123123"), + String::from( + "test(commit): add test\n\nSigned-off-by: Test User \ + ", + ), + ), + vec![Footer { + token: "Signed-off-by", + separator: ":", + value: "Test User ", + breaking: false, + }], + ), + ( + Commit::new( + String::from("123124"), + String::from( + "fix(commit): break stuff\n\nBREAKING CHANGE: This commit \ + breaks stuff\nSigned-off-by: Test User ", + ), + ), + vec![ + Footer { + token: "BREAKING CHANGE", + separator: ":", + value: "This commit breaks stuff", + breaking: true, + }, + Footer { + token: "Signed-off-by", + separator: ":", + value: "Test User ", + breaking: false, + }, + ], + ), + ]; + for (commit, footers) in &test_cases { + let commit = commit.process(&cfg).expect("commit should process"); + assert_eq!(&commit.footers().collect::>(), footers); + } + } + #[test] fn parse_link() { let test_cases = vec![