From 0a226635fb50c243fe43315ebe154c68f7fc46e7 Mon Sep 17 00:00:00 2001 From: "Chris (ChrisJr404)" <11917633+ChrisJr404@users.noreply.github.com> Date: Tue, 18 Aug 2026 01:14:35 -0400 Subject: [PATCH 1/5] feat(parser): allow commits to be parsed by multiple parsers Add an opt-in `continue` field to commit parsers. When set, parsing keeps going after a parser matches, so a commit can be handled by more than one parser in order (e.g. one sets the scope, the next sets the group). Default behavior is unchanged: the first matching parser wins and short-circuits. --- git-cliff-core/src/changelog.rs | 11 ++ git-cliff-core/src/commit.rs | 135 ++++++++++++++++++++++- git-cliff-core/src/config.rs | 3 + git-cliff-core/src/process.rs | 4 + git-cliff-core/tests/integration_test.rs | 5 + website/docs/configuration/git.md | 5 + 6 files changed, 162 insertions(+), 1 deletion(-) diff --git a/git-cliff-core/src/changelog.rs b/git-cliff-core/src/changelog.rs index fe2e9bd68f..1b18ac6c2d 100644 --- a/git-cliff-core/src/changelog.rs +++ b/git-cliff-core/src/changelog.rs @@ -837,6 +837,7 @@ mod test { default_scope: None, scope: None, skip: None, + r#continue: None, field: None, pattern: None, }, @@ -849,6 +850,7 @@ mod test { default_scope: None, scope: None, skip: Some(true), + r#continue: None, field: None, pattern: None, }, @@ -861,6 +863,7 @@ mod test { default_scope: None, scope: None, skip: Some(true), + r#continue: None, field: None, pattern: None, }, @@ -873,6 +876,7 @@ mod test { default_scope: None, scope: None, skip: Some(true), + r#continue: None, field: None, pattern: None, }, @@ -885,6 +889,7 @@ mod test { default_scope: Some(String::from("other")), scope: None, skip: None, + r#continue: None, field: None, pattern: None, }, @@ -897,6 +902,7 @@ mod test { default_scope: None, scope: None, skip: None, + r#continue: None, field: None, pattern: None, }, @@ -909,6 +915,7 @@ mod test { default_scope: None, scope: Some(String::from("documentation")), skip: None, + r#continue: None, field: None, pattern: None, }, @@ -921,6 +928,7 @@ mod test { default_scope: None, scope: Some(String::from("documentation")), skip: None, + r#continue: None, field: None, pattern: None, }, @@ -933,6 +941,7 @@ mod test { default_scope: None, scope: None, skip: None, + r#continue: None, field: None, pattern: None, }, @@ -945,6 +954,7 @@ mod test { default_scope: None, scope: Some(String::from("footer")), skip: None, + r#continue: None, field: None, pattern: None, }, @@ -957,6 +967,7 @@ mod test { default_scope: Some(String::from("other")), scope: None, skip: None, + r#continue: None, field: None, pattern: None, }, diff --git a/git-cliff-core/src/commit.rs b/git-cliff-core/src/commit.rs index 6722c106e8..5948adce19 100644 --- a/git-cliff-core/src/commit.rs +++ b/git-cliff-core/src/commit.rs @@ -329,6 +329,9 @@ impl Commit<'_> { let lookup_context = serde_json::to_value(&self).map_err(|e| { AppError::FieldError(format!("failed to convert context into value: {e}",)) })?; + // Set when a `continue` parser matches, so the commit isn't filtered out + // at the end even though no parser returned early. + let mut matched = false; for parser in parsers { let mut regex_checks = Vec::new(); if let Some(message_regex) = parser.message.as_ref() { @@ -400,6 +403,10 @@ impl Commit<'_> { 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); + if parser.r#continue.unwrap_or(false) { + matched = true; + continue; + } return Ok(self); } } @@ -414,6 +421,21 @@ impl Commit<'_> { } value }; + if parser.r#continue.unwrap_or(false) { + // Only override the fields this parser sets, so later + // parsers can fill in the rest. + if let Some(group) = parser.group.clone() { + self.group = Some(regex_replace(group)); + } + if let Some(scope) = parser.scope.clone() { + self.scope = Some(regex_replace(scope)); + } + if parser.default_scope.is_some() { + self.default_scope.clone_from(&parser.default_scope); + } + matched = true; + break; + } self.group = parser.group.clone().map(regex_replace); self.scope = parser.scope.clone().map(regex_replace); self.default_scope.clone_from(&parser.default_scope); @@ -422,7 +444,7 @@ impl Commit<'_> { } } } - if filter { + if filter && !matched { Err(AppError::GroupError(String::from( "Commit does not belong to any group", ))) @@ -604,6 +626,7 @@ mod test { default_scope: Some(String::from("test_scope")), scope: None, skip: None, + r#continue: None, field: None, pattern: None, }], @@ -812,6 +835,7 @@ Refs: #123 default_scope: None, scope: None, skip: None, + r#continue: None, field: None, pattern: None, }], @@ -874,6 +898,7 @@ Refs: #123 default_scope: None, scope: None, skip: None, + r#continue: None, field: Some(String::from("author.name")), pattern: Regex::new("John Doe").ok(), }], @@ -892,6 +917,7 @@ Refs: #123 default_scope: None, scope: None, skip: None, + r#continue: None, field: Some(String::from("remote.pr_title")), pattern: Regex::new("feat: do something").ok(), }], @@ -910,6 +936,7 @@ Refs: #123 default_scope: None, scope: None, skip: None, + r#continue: None, field: Some(String::from("body")), pattern: Regex::new("something great").ok(), }], @@ -928,6 +955,7 @@ Refs: #123 default_scope: None, scope: None, skip: None, + r#continue: None, field: Some(String::from("remote.pr_labels")), pattern: Regex::new("feature|deprecation").ok(), }], @@ -946,6 +974,7 @@ Refs: #123 default_scope: None, scope: None, skip: None, + r#continue: None, field: Some(String::from("links")), pattern: Regex::new(".*").ok(), }], @@ -964,6 +993,7 @@ Refs: #123 default_scope: None, scope: None, skip: None, + r#continue: None, field: Some(String::from("remote")), pattern: Regex::new(".*").ok(), }], @@ -978,6 +1008,105 @@ Refs: #123 Ok(()) } + #[test] + fn parse_commit_multiple_parsers() -> Result<()> { + let commit = Commit::new( + String::from("8f55e69eba6e6ce811ace32bd84cc82215673cb6"), + String::from("feat(deep): support multiple parsers"), + ); + let commit = commit.into_conventional()?; + + // Without `continue`, the first matching parser wins and short-circuits: + // the scope-only parser matches, so the group from the later parser is + // never applied. + let parsers = vec![ + CommitParser { + sha: None, + message: Regex::new("\\(deep\\)").ok(), + body: None, + footer: None, + group: None, + default_scope: None, + scope: Some(String::from("Deep Scope")), + skip: None, + r#continue: None, + field: None, + pattern: None, + }, + CommitParser { + sha: None, + message: Regex::new("^feat").ok(), + body: None, + footer: None, + group: Some(String::from("Features")), + default_scope: None, + scope: None, + skip: None, + r#continue: None, + field: None, + pattern: None, + }, + ]; + let parsed = commit.clone().parse(&parsers, false, false)?; + assert_eq!(Some(String::from("Deep Scope")), parsed.scope); + assert_eq!(None, parsed.group); + + // With `continue = true` on the composing parsers, the commit picks up + // the scope from the first and the group from the second. + let parsers = vec![ + CommitParser { + sha: None, + message: Regex::new("\\(deep\\)").ok(), + body: None, + footer: None, + group: None, + default_scope: None, + scope: Some(String::from("Deep Scope")), + skip: None, + r#continue: Some(true), + field: None, + pattern: None, + }, + CommitParser { + sha: None, + message: Regex::new("^feat").ok(), + body: None, + footer: None, + group: Some(String::from("Features")), + default_scope: None, + scope: None, + skip: None, + r#continue: Some(true), + field: None, + pattern: None, + }, + ]; + let parsed = commit.clone().parse(&parsers, false, true)?; + assert_eq!(Some(String::from("Deep Scope")), parsed.scope); + assert_eq!(Some(String::from("Features")), parsed.group); + + // A `continue` parser that matches keeps the commit even when filtering + // is on and it only set a scope (no group). + let scope_only = vec![CommitParser { + sha: None, + message: Regex::new("^feat").ok(), + body: None, + footer: None, + group: None, + default_scope: None, + scope: Some(String::from("Deep Scope")), + skip: None, + r#continue: Some(true), + field: None, + pattern: None, + }]; + let parsed = commit.clone().parse(&scope_only, false, true)?; + assert_eq!(Some(String::from("Deep Scope")), parsed.scope); + assert_eq!(None, parsed.group); + + Ok(()) + } + #[test] fn commit_sha() { let commit = Commit::new( @@ -995,6 +1124,7 @@ Refs: #123 default_scope: None, scope: None, skip: Some(true), + r#continue: None, field: None, pattern: None, }], @@ -1037,6 +1167,7 @@ Refs: #123 default_scope: None, scope: None, skip: None, + r#continue: None, field: Some(String::from("author.name")), pattern: Regex::new("^John Doe$").ok(), }], @@ -1055,6 +1186,7 @@ Refs: #123 default_scope: None, scope: None, skip: None, + r#continue: None, field: Some(String::from("remote.pr_title")), pattern: Regex::new("^feat(\\([^)]+\\))?").ok(), }], @@ -1073,6 +1205,7 @@ Refs: #123 default_scope: None, scope: None, skip: None, + r#continue: None, field: Some(String::from("author.name")), pattern: Regex::new("Something else").ok(), }], diff --git a/git-cliff-core/src/config.rs b/git-cliff-core/src/config.rs index 44cf86d7a9..67147a0cf1 100644 --- a/git-cliff-core/src/config.rs +++ b/git-cliff-core/src/config.rs @@ -458,6 +458,9 @@ pub struct CommitParser { pub scope: Option, /// Whether to skip this commit group. pub skip: Option, + /// Whether to keep parsing with the following parsers after this one + /// matches, letting a commit be processed by multiple parsers in order. + pub r#continue: Option, /// Field name of the commit to match the regex against. pub field: Option, /// Regex for matching the field value. diff --git a/git-cliff-core/src/process.rs b/git-cliff-core/src/process.rs index 18c3b377c9..74b6ef1aa7 100644 --- a/git-cliff-core/src/process.rs +++ b/git-cliff-core/src/process.rs @@ -285,6 +285,7 @@ mod test { default_scope: None, scope: None, skip: Some(true), + r#continue: None, field: None, pattern: None, }, @@ -297,6 +298,7 @@ mod test { default_scope: None, scope: None, skip: None, + r#continue: None, field: None, pattern: None, }, @@ -337,6 +339,7 @@ mod test { default_scope: None, scope: None, skip: Some(true), + r#continue: None, field: None, pattern: None, }, @@ -349,6 +352,7 @@ mod test { default_scope: None, scope: None, skip: None, + r#continue: None, field: None, pattern: None, }, diff --git a/git-cliff-core/tests/integration_test.rs b/git-cliff-core/tests/integration_test.rs index 8885221354..7e31c1597c 100644 --- a/git-cliff-core/tests/integration_test.rs +++ b/git-cliff-core/tests/integration_test.rs @@ -59,6 +59,7 @@ fn generate_changelog() -> Result<()> { default_scope: None, scope: None, skip: None, + r#continue: None, field: None, pattern: None, }, @@ -71,6 +72,7 @@ fn generate_changelog() -> Result<()> { default_scope: None, scope: None, skip: None, + r#continue: None, field: None, pattern: None, }, @@ -83,6 +85,7 @@ fn generate_changelog() -> Result<()> { default_scope: None, scope: None, skip: None, + r#continue: None, field: None, pattern: None, }, @@ -95,6 +98,7 @@ fn generate_changelog() -> Result<()> { default_scope: None, scope: Some(String::from("tests")), skip: None, + r#continue: None, field: None, pattern: None, }, @@ -107,6 +111,7 @@ fn generate_changelog() -> Result<()> { default_scope: None, scope: None, skip: None, + r#continue: None, field: Some(String::from("author.name")), pattern: Regex::new("John Doe").ok(), }, diff --git a/website/docs/configuration/git.md b/website/docs/configuration/git.md index e58327b00a..2a807e0945 100644 --- a/website/docs/configuration/git.md +++ b/website/docs/configuration/git.md @@ -245,6 +245,11 @@ Examples: - `body` is a special field which contains the body of a conventional commit, if applicable. - Be aware that all fields are converted to JSON strings before they are parsed by the given regex, especially when dealing with arrays. +By default a commit is handled by the first parser that matches it and the rest are skipped. Set `continue = true` on a parser to keep going after it matches, so the commit can be processed by more than one parser in order. A parser with `continue = true` only overrides the fields it sets (e.g. just `scope`), leaving the others for later parsers to fill in. + +- `{ message = '\(www\)', scope = "Application", continue = true }`, `{ message = "^feat", group = "Features" }` + - Set the scope to "Application" from the first parser, then let the second parser set the group to "Features". Without `continue = true` on the first parser, only the scope would be applied and no group would be set. + ### protect_breaking_commits If set to `true`, any breaking changes will be protected against being skipped From d3e1364a7ddd7adabafc925340a1c6a2f8abdadc Mon Sep 17 00:00:00 2001 From: "Chris (ChrisJr404)" <11917633+ChrisJr404@users.noreply.github.com> Date: Mon, 24 Aug 2026 14:22:56 -0400 Subject: [PATCH 2/5] fix(parser): keep fields set by earlier parsers when a later one matches A terminal parser (one without continue) used to overwrite group, scope and default_scope wholesale, so a scope set by a preceding continue parser was reset to None when a later parser only matched to set the group. Make terminal parsers override just the fields they set, matching the sha-based match path, so composing parsers augment instead of clobbering each other. Add a unit case and a test-multiple-commit-parsers fixture that derive a scope from a git footer and then group by conventional type, and update the docs with that real-world use case. --- .../test-multiple-commit-parsers/cliff.toml | 32 ++++++++++++ .../test-multiple-commit-parsers/commit.sh | 8 +++ .../test-multiple-commit-parsers/expected.md | 11 ++++ .github/workflows/test-fixtures.yml | 1 + git-cliff-core/src/commit.rs | 50 +++++++++++++++++-- website/docs/configuration/git.md | 25 ++++++++-- 6 files changed, 121 insertions(+), 6 deletions(-) create mode 100644 .github/fixtures/test-multiple-commit-parsers/cliff.toml create mode 100755 .github/fixtures/test-multiple-commit-parsers/commit.sh create mode 100644 .github/fixtures/test-multiple-commit-parsers/expected.md diff --git a/.github/fixtures/test-multiple-commit-parsers/cliff.toml b/.github/fixtures/test-multiple-commit-parsers/cliff.toml new file mode 100644 index 0000000000..cff210d7fc --- /dev/null +++ b/.github/fixtures/test-multiple-commit-parsers/cliff.toml @@ -0,0 +1,32 @@ +# git-cliff ~ configuration file +# https://git-cliff.org/docs/configuration + +[changelog] +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 }} + {% for commit in commits %} + - {% if commit.scope %}({{ commit.scope }}) {% endif %}{{ commit.message }}\ + {% endfor %} +{% endfor %}\n +""" + +[git] +# Real-world case: the component a commit touches is recorded in a +# `Component:` trailer (git footer), while the change type follows +# conventional commits in the subject. The first two parsers read the +# component out of the footer and set it as the scope with `continue = true`, +# so parsing keeps going. The last two parsers then group the commit by its +# conventional type. Because the grouping parsers only set the fields they +# match, the scope picked up from the footer is preserved. +commit_parsers = [ + { footer = "^Component:Billing$", scope = "billing", continue = true }, + { footer = "^Component:Auth$", scope = "auth", continue = true }, + { message = "^feat", group = "Features" }, + { message = "^fix", group = "Bug Fixes" }, +] diff --git a/.github/fixtures/test-multiple-commit-parsers/commit.sh b/.github/fixtures/test-multiple-commit-parsers/commit.sh new file mode 100755 index 0000000000..08cfa89e6c --- /dev/null +++ b/.github/fixtures/test-multiple-commit-parsers/commit.sh @@ -0,0 +1,8 @@ +#!/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 invoices" -m "Component: Billing" +GIT_COMMITTER_DATE="2022-04-06 01:25:10" git commit --allow-empty -m "fix: correct totals rounding" -m "Component: Billing" +GIT_COMMITTER_DATE="2022-04-06 01:25:11" git commit --allow-empty -m "feat: add login page" -m "Component: Auth" +git tag v0.1.0 diff --git a/.github/fixtures/test-multiple-commit-parsers/expected.md b/.github/fixtures/test-multiple-commit-parsers/expected.md new file mode 100644 index 0000000000..dd3ee61d8d --- /dev/null +++ b/.github/fixtures/test-multiple-commit-parsers/expected.md @@ -0,0 +1,11 @@ +## [0.1.0] - 2022-04-06 + +### Bug Fixes + +- (billing) correct totals rounding + +### Features + +- (billing) add invoices +- (auth) add login page + diff --git a/.github/workflows/test-fixtures.yml b/.github/workflows/test-fixtures.yml index 2365a78a52..5a30534aed 100644 --- a/.github/workflows/test-fixtures.yml +++ b/.github/workflows/test-fixtures.yml @@ -144,6 +144,7 @@ jobs: - fixtures-name: test-commit-range-with-given-range command: a140cef^..a9d4050 --ignore-tags "." - fixtures-name: test-override-scope + - fixtures-name: test-multiple-commit-parsers - fixtures-name: test-regex-json-array - fixtures-name: test-release-statistics previous-release-timestamp: "2022-04-06 01:25:12" diff --git a/git-cliff-core/src/commit.rs b/git-cliff-core/src/commit.rs index 5948adce19..c37971b43b 100644 --- a/git-cliff-core/src/commit.rs +++ b/git-cliff-core/src/commit.rs @@ -436,9 +436,17 @@ impl Commit<'_> { matched = true; break; } - self.group = parser.group.clone().map(regex_replace); - self.scope = parser.scope.clone().map(regex_replace); - self.default_scope.clone_from(&parser.default_scope); + // A terminal parser stops the loop, but it should only + // overwrite the fields it actually sets. Otherwise a + // preceding `continue` parser that set, say, the scope + // would get blanked out just because this parser only + // sets the group. This mirrors the sha-based match + // above, which already preserves previously-set fields. + self.group = parser.group.clone().map(regex_replace).or(self.group); + self.scope = parser.scope.clone().map(regex_replace).or(self.scope); + if parser.default_scope.is_some() { + self.default_scope.clone_from(&parser.default_scope); + } return Ok(self); } } @@ -1104,6 +1112,42 @@ Refs: #123 assert_eq!(Some(String::from("Deep Scope")), parsed.scope); assert_eq!(None, parsed.group); + // A `continue` parser can set the scope and a following terminal parser + // (no `continue`) can set the group without wiping the scope. The + // terminal parser only overwrites the fields it actually sets, so the + // scope from the first parser is kept instead of being reset to None. + let parsers = vec![ + CommitParser { + sha: None, + message: Regex::new("\\(deep\\)").ok(), + body: None, + footer: None, + group: None, + default_scope: None, + scope: Some(String::from("Deep Scope")), + skip: None, + r#continue: Some(true), + field: None, + pattern: None, + }, + CommitParser { + sha: None, + message: Regex::new("^feat").ok(), + body: None, + footer: None, + group: Some(String::from("Features")), + default_scope: None, + scope: None, + skip: None, + r#continue: None, + field: None, + pattern: None, + }, + ]; + let parsed = commit.clone().parse(&parsers, false, false)?; + assert_eq!(Some(String::from("Deep Scope")), parsed.scope); + assert_eq!(Some(String::from("Features")), parsed.group); + Ok(()) } diff --git a/website/docs/configuration/git.md b/website/docs/configuration/git.md index 2a807e0945..269b0e5f15 100644 --- a/website/docs/configuration/git.md +++ b/website/docs/configuration/git.md @@ -245,10 +245,29 @@ Examples: - `body` is a special field which contains the body of a conventional commit, if applicable. - Be aware that all fields are converted to JSON strings before they are parsed by the given regex, especially when dealing with arrays. -By default a commit is handled by the first parser that matches it and the rest are skipped. Set `continue = true` on a parser to keep going after it matches, so the commit can be processed by more than one parser in order. A parser with `continue = true` only overrides the fields it sets (e.g. just `scope`), leaving the others for later parsers to fill in. +By default a commit is handled by the first parser that matches it and the rest are skipped. Set `continue = true` on a parser to keep going after it matches, so the commit can be processed by more than one parser in order. Every parser only writes the fields it actually sets (for example just `scope`), so a value set by an earlier parser is kept unless a later parser sets that same field again. This is true whether the later parser has `continue = true` or is a regular terminal parser. -- `{ message = '\(www\)', scope = "Application", continue = true }`, `{ message = "^feat", group = "Features" }` - - Set the scope to "Application" from the first parser, then let the second parser set the group to "Features". Without `continue = true` on the first parser, only the scope would be applied and no group would be set. +A real-world use case is deriving the scope from information that lives outside the conventional-commit type. Say your team records which component a change touches in a `Component:` git trailer (footer) and keeps the type in the subject: + +``` +feat: add invoices + +Component: Billing +``` + +You can read the component into the scope with `continue = true`, then group the commit by its conventional type in a separate parser: + +```toml +[git] +commit_parsers = [ + { footer = "^Component:Billing$", scope = "billing", continue = true }, + { footer = "^Component:Auth$", scope = "auth", continue = true }, + { message = "^feat", group = "Features" }, + { message = "^fix", group = "Bug Fixes" }, +] +``` + +The first matching footer parser sets the scope and parsing continues, then the `^feat` parser sets the group. Because the grouping parser only sets `group`, the scope picked up from the footer is preserved. This keeps the component list in one place instead of writing out every component-and-type combination as its own parser. ### protect_breaking_commits From 445eb78dc3a8e3466495cb04ce54be0a94769848 Mon Sep 17 00:00:00 2001 From: "Chris (ChrisJr404)" <11917633+ChrisJr404@users.noreply.github.com> Date: Sun, 30 Aug 2026 20:21:46 -0400 Subject: [PATCH 3/5] docs(parser): shorten the multiple-parser section and note the match caveat Trim the git.md write-up to a short example and move the fuller walkthrough to tips and tricks. Document that parser matching evaluates against the original commit, so a later parser cannot match on a field set by an earlier one. --- website/docs/configuration/git.md | 16 ++-------------- website/docs/tips-and-tricks.md | 30 ++++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 14 deletions(-) diff --git a/website/docs/configuration/git.md b/website/docs/configuration/git.md index c55a0935f7..bb246926ed 100644 --- a/website/docs/configuration/git.md +++ b/website/docs/configuration/git.md @@ -246,29 +246,17 @@ Examples: - `body` is a special field which contains the body of a conventional commit, if applicable. - Be aware that all fields are converted to JSON strings before they are parsed by the given regex, especially when dealing with arrays. -By default a commit is handled by the first parser that matches it and the rest are skipped. Set `continue = true` on a parser to keep going after it matches, so the commit can be processed by more than one parser in order. Every parser only writes the fields it actually sets (for example just `scope`), so a value set by an earlier parser is kept unless a later parser sets that same field again. This is true whether the later parser has `continue = true` or is a regular terminal parser. - -A real-world use case is deriving the scope from information that lives outside the conventional-commit type. Say your team records which component a change touches in a `Component:` git trailer (footer) and keeps the type in the subject: - -``` -feat: add invoices - -Component: Billing -``` - -You can read the component into the scope with `continue = true`, then group the commit by its conventional type in a separate parser: +By default a commit is handled by the first parser that matches it. Set `continue = true` to keep applying the following parsers to the same commit, each one only overwriting the fields it sets. This lets you derive a value such as the scope in one parser and group by type in another: ```toml [git] commit_parsers = [ { footer = "^Component:Billing$", scope = "billing", continue = true }, - { footer = "^Component:Auth$", scope = "auth", continue = true }, { message = "^feat", group = "Features" }, - { message = "^fix", group = "Bug Fixes" }, ] ``` -The first matching footer parser sets the scope and parsing continues, then the `^feat` parser sets the group. Because the grouping parser only sets `group`, the scope picked up from the footer is preserved. This keeps the component list in one place instead of writing out every component-and-type combination as its own parser. +The footer parser sets the scope and parsing continues, then the `^feat` parser sets the group without clearing the scope. Note that `field`/`pattern` matching always evaluates against the original commit, so a later parser cannot match on a `scope` (or other field) set by an earlier one. See [tips and tricks](/docs/tips-and-tricks#parsing-commits-with-multiple-parsers) for a fuller example. ### protect_breaking_commits diff --git a/website/docs/tips-and-tricks.md b/website/docs/tips-and-tricks.md index 554f93e4ea..11837d2dbe 100644 --- a/website/docs/tips-and-tricks.md +++ b/website/docs/tips-and-tricks.md @@ -160,3 +160,33 @@ This will generate the changelog using only local Git commit information. Note that PR titles, labels, and other remote metadata will not be included in offline mode. ::: + +## Parsing commits with multiple parsers + +By default the first matching [`commit_parser`](/docs/configuration/git#commit_parsers) wins and the rest are skipped. With `continue = true` a commit keeps flowing through the parsers, each one overwriting only the fields it sets. This is handy when the scope lives outside the conventional-commit type, for example in a `Component:` git trailer: + +``` +feat: add invoices + +Component: Billing +``` + +You can read the component into the scope, then group by type in a separate parser: + +```toml +[git] +commit_parsers = [ + { footer = "^Component:Billing$", scope = "billing", continue = true }, + { footer = "^Component:Auth$", scope = "auth", continue = true }, + { message = "^feat", group = "Features" }, + { message = "^fix", group = "Bug Fixes" }, +] +``` + +The footer parser sets the scope and parsing continues; the `^feat` parser then sets the group, and because it only sets `group` the scope is preserved. This keeps the component list in one place instead of writing out every component-and-type combination. + +:::note + +Parser matching (`field`/`pattern`) always runs against the original commit, not against fields set by earlier parsers in the same run. So a later parser cannot match on a `scope` that an earlier parser just assigned; match on the underlying commit data (such as the footer) instead. + +::: From 1f7777118b36749772b4250046630821d288a074 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Orhun=20Parmaks=C4=B1z?= Date: Sun, 6 Sep 2026 13:59:26 +0200 Subject: [PATCH 4/5] fix(parser): preserve legacy terminal parser behavior --- .../test-multiple-commit-parsers/cliff.toml | 2 +- .../test-multiple-commit-parsers/expected.md | 1 - git-cliff-core/src/commit.rs | 37 ++++++++++++++----- website/docs/configuration/git.md | 2 +- website/docs/tips-and-tricks.md | 20 +--------- 5 files changed, 31 insertions(+), 31 deletions(-) diff --git a/.github/fixtures/test-multiple-commit-parsers/cliff.toml b/.github/fixtures/test-multiple-commit-parsers/cliff.toml index cff210d7fc..8a82f66557 100644 --- a/.github/fixtures/test-multiple-commit-parsers/cliff.toml +++ b/.github/fixtures/test-multiple-commit-parsers/cliff.toml @@ -13,7 +13,7 @@ body = """ {% for commit in commits %} - {% if commit.scope %}({{ commit.scope }}) {% endif %}{{ commit.message }}\ {% endfor %} -{% endfor %}\n +{% endfor %} """ [git] diff --git a/.github/fixtures/test-multiple-commit-parsers/expected.md b/.github/fixtures/test-multiple-commit-parsers/expected.md index dd3ee61d8d..b2f5447c52 100644 --- a/.github/fixtures/test-multiple-commit-parsers/expected.md +++ b/.github/fixtures/test-multiple-commit-parsers/expected.md @@ -8,4 +8,3 @@ - (billing) add invoices - (auth) add login page - diff --git a/git-cliff-core/src/commit.rs b/git-cliff-core/src/commit.rs index da6cbfb230..91a0650ad4 100644 --- a/git-cliff-core/src/commit.rs +++ b/git-cliff-core/src/commit.rs @@ -441,15 +441,18 @@ impl Commit<'_> { matched = true; break; } - // A terminal parser stops the loop, but it should only - // overwrite the fields it actually sets. Otherwise a - // preceding `continue` parser that set, say, the scope - // would get blanked out just because this parser only - // sets the group. This mirrors the sha-based match - // above, which already preserves previously-set fields. - self.group = parser.group.clone().map(regex_replace).or(self.group); - self.scope = parser.scope.clone().map(regex_replace).or(self.scope); - if parser.default_scope.is_some() { + if matched { + // Preserve fields contributed by preceding parsers. + self.group = parser.group.clone().map(regex_replace).or(self.group); + self.scope = parser.scope.clone().map(regex_replace).or(self.scope); + if parser.default_scope.is_some() { + self.default_scope.clone_from(&parser.default_scope); + } + } else { + // Keep the original first-match-wins behavior when + // no preceding parser continued. + self.group = parser.group.clone().map(regex_replace); + self.scope = parser.scope.clone().map(regex_replace); self.default_scope.clone_from(&parser.default_scope); } return Ok(self); @@ -1155,6 +1158,22 @@ Refs: #123 assert_eq!(Some(String::from("Deep Scope")), parsed.scope); assert_eq!(Some(String::from("Features")), parsed.group); + // Without a preceding `continue` match, terminal parsers retain the + // original behavior of clearing fields they do not set. + let mut populated_commit = commit; + populated_commit.group = Some(String::from("Old Group")); + populated_commit.scope = Some(String::from("Old Scope")); + populated_commit.default_scope = Some(String::from("Old Default Scope")); + let terminal = vec![CommitParser { + message: Regex::new("^feat").ok(), + group: Some(String::from("Features")), + ..Default::default() + }]; + let parsed = populated_commit.parse(&terminal, false, false)?; + assert_eq!(Some(String::from("Features")), parsed.group); + assert_eq!(None, parsed.scope); + assert_eq!(None, parsed.default_scope); + Ok(()) } diff --git a/website/docs/configuration/git.md b/website/docs/configuration/git.md index bb246926ed..3ff19c1488 100644 --- a/website/docs/configuration/git.md +++ b/website/docs/configuration/git.md @@ -256,7 +256,7 @@ commit_parsers = [ ] ``` -The footer parser sets the scope and parsing continues, then the `^feat` parser sets the group without clearing the scope. Note that `field`/`pattern` matching always evaluates against the original commit, so a later parser cannot match on a `scope` (or other field) set by an earlier one. See [tips and tricks](/docs/tips-and-tricks#parsing-commits-with-multiple-parsers) for a fuller example. +The footer parser sets the scope before the `^feat` parser sets the group. `field`/`pattern` always matches the original commit, not values set by earlier parsers. ### protect_breaking_commits diff --git a/website/docs/tips-and-tricks.md b/website/docs/tips-and-tricks.md index 11837d2dbe..9bebe84a93 100644 --- a/website/docs/tips-and-tricks.md +++ b/website/docs/tips-and-tricks.md @@ -163,30 +163,12 @@ Note that PR titles, labels, and other remote metadata will not be included in o ## Parsing commits with multiple parsers -By default the first matching [`commit_parser`](/docs/configuration/git#commit_parsers) wins and the rest are skipped. With `continue = true` a commit keeps flowing through the parsers, each one overwriting only the fields it sets. This is handy when the scope lives outside the conventional-commit type, for example in a `Component:` git trailer: - -``` -feat: add invoices - -Component: Billing -``` - -You can read the component into the scope, then group by type in a separate parser: +Use `continue = true` to apply more than one [`commit_parser`](/docs/configuration/git#commit_parsers) to a commit: ```toml [git] commit_parsers = [ { footer = "^Component:Billing$", scope = "billing", continue = true }, - { footer = "^Component:Auth$", scope = "auth", continue = true }, { message = "^feat", group = "Features" }, - { message = "^fix", group = "Bug Fixes" }, ] ``` - -The footer parser sets the scope and parsing continues; the `^feat` parser then sets the group, and because it only sets `group` the scope is preserved. This keeps the component list in one place instead of writing out every component-and-type combination. - -:::note - -Parser matching (`field`/`pattern`) always runs against the original commit, not against fields set by earlier parsers in the same run. So a later parser cannot match on a `scope` that an earlier parser just assigned; match on the underlying commit data (such as the footer) instead. - -::: From 5acdb7e32155c9537de068e4f682a44d89c8b17d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Orhun=20Parmaks=C4=B1z?= Date: Sun, 6 Sep 2026 14:15:01 +0200 Subject: [PATCH 5/5] docs: polish documentation --- website/docs/configuration/git.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/website/docs/configuration/git.md b/website/docs/configuration/git.md index 3ff19c1488..2d6b560156 100644 --- a/website/docs/configuration/git.md +++ b/website/docs/configuration/git.md @@ -246,7 +246,7 @@ Examples: - `body` is a special field which contains the body of a conventional commit, if applicable. - Be aware that all fields are converted to JSON strings before they are parsed by the given regex, especially when dealing with arrays. -By default a commit is handled by the first parser that matches it. Set `continue = true` to keep applying the following parsers to the same commit, each one only overwriting the fields it sets. This lets you derive a value such as the scope in one parser and group by type in another: +Set `continue = true` to apply following parsers to the same commit: ```toml [git] @@ -256,8 +256,6 @@ commit_parsers = [ ] ``` -The footer parser sets the scope before the `^feat` parser sets the group. `field`/`pattern` always matches the original commit, not values set by earlier parsers. - ### protect_breaking_commits If set to `true`, any breaking changes will be protected against being skipped