Skip to content

Commit

Permalink
feat(commit)!: pass footer token and separator to template (#97)
Browse files Browse the repository at this point in the history
* fix(commit): pass footer token and separator to template

Currently, when a conventional commit has footers, only the footers'
values (the part after the separator token, such as `:`) are passed to
the template. This means that when multiple footers, such as
`Signed-off-by:` and `Co-authored-by:` are present, it isn't currently
possible for the template to determine the name of the footer. This
makes actually using data from footers in templates impractical in most
cases.

This commit fixes this by changing the `Serialize` impl for `Commit` to
pass the commit's footers as a structured object rather than a string.
The structured `Footer` type includes the footer's token (which is what
`git_conventional` calls the name preceding the separator token), the
separator, and the value.

I didn't make the new `Footer` type and `Commit::footers` method public,
because it isn't strictly necessary to add them to the `git-cliff-core`
public API to fix this issue. However, we can make them public in a
follow-up PR if this is considered useful.

Fixes #96

BREAKING CHANGE:

This changes type of the `commit.footers` array exposed to templates.
Currently, when a template uses `commit.footers`, it can treat the
values as strings. After this change, the footer object will need to
have its fields unpacked in order to use them.

However, the impact of this breakage is probably not that severe, since
it's not really practical to use footers in templates with the current
system.

* docs(README): discuss footers in README

Signed-off-by: Eliza Weisman <eliza@buoyant.io>

* docs(examples): Add footers to `detailed.toml`

Signed-off-by: Eliza Weisman <eliza@buoyant.io>

* refac(commit): address review feedback

Signed-off-by: Eliza Weisman <eliza@buoyant.io>

* docs(README): address README review feedback

Signed-off-by: Eliza Weisman <eliza@buoyant.io>

* refactor(example): update detailed example about newline issues

* test(fixture): add test fixture for commit footers

Co-authored-by: Orhun Parmaksız <orhunparmaksiz@gmail.com>
  • Loading branch information
hawkw and orhun authored Jul 12, 2022
1 parent 2289199 commit 0bf499e
Show file tree
Hide file tree
Showing 6 changed files with 197 additions and 10 deletions.
30 changes: 30 additions & 0 deletions .github/fixtures/test-commit-footers/cliff.toml
Original file line number Diff line number Diff line change
@@ -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 = """
<!-- generated by git-cliff -->
"""
9 changes: 9 additions & 0 deletions .github/fixtures/test-commit-footers/commit.sh
Original file line number Diff line number Diff line change
@@ -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"
18 changes: 18 additions & 0 deletions .github/fixtures/test-commit-footers/expected.md
Original file line number Diff line number Diff line change
@@ -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

<!-- generated by git-cliff -->
30 changes: 29 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -669,7 +669,14 @@ following context is generated to use for templating:
"scope": "[scope]",
"message": "<description>",
"body": "[body]",
"footers": ["[footer]", "[footer]"],
"footers": [
{
"token": "<name of the footer, such as 'Signed-off-by'>",
"separator": "<the separator between the token and value, such as ':'>",
"value": "<the value following the separator",
"breaking": false
}
],
"breaking_description": "<description>",
"breaking": false,
"conventional": true,
Expand All @@ -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 <user.email@example.com>`
- `Reviewed-by: User Name <user.email@example.com>`
- `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.:
Expand All @@ -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**
Expand Down
3 changes: 3 additions & 0 deletions examples/detailed.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
"""
Expand Down
117 changes: 108 additions & 9 deletions git-cliff-core/src/commit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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: <user.name>` 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(
Expand Down Expand Up @@ -208,28 +237,46 @@ 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<Item = Footer<'_>> {
self.conv
.iter()
.flat_map(|conv| conv.footers().iter().map(Footer::from))
}
}

impl Serialize for Commit<'_> {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
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<S>(
&self,
serializer: S,
) -> std::result::Result<S::Ok, S::Error>
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::<Vec<&str>>(),
)?;
commit.serialize_field("footers", &SerializeFooters(self))?;
commit.serialize_field(
"group",
self.group.as_ref().unwrap_or(&conv.type_().to_string()),
Expand Down Expand Up @@ -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 \
<test@example.com>",
),
),
vec![Footer {
token: "Signed-off-by",
separator: ":",
value: "Test User <test@example.com>",
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 <test@example.com>",
),
),
vec![
Footer {
token: "BREAKING CHANGE",
separator: ":",
value: "This commit breaks stuff",
breaking: true,
},
Footer {
token: "Signed-off-by",
separator: ":",
value: "Test User <test@example.com>",
breaking: false,
},
],
),
];
for (commit, footers) in &test_cases {
let commit = commit.process(&cfg).expect("commit should process");
assert_eq!(&commit.footers().collect::<Vec<_>>(), footers);
}
}

#[test]
fn parse_link() {
let test_cases = vec![
Expand Down

0 comments on commit 0bf499e

Please sign in to comment.