From 936ce878bb2bff25ced41e07c3188295edb46110 Mon Sep 17 00:00:00 2001 From: Colin Marc Date: Fri, 22 Aug 2025 18:42:58 +0200 Subject: [PATCH] Add more options for CT1 This adds two options to the conventional commit contrib linter: - An optional whitelist of scopes (nouns) - An option to require a scope to be specified --- docs/rules/contrib_rules.md | 3 +- .../contrib/rules/conventional_commit.py | 20 +++++++-- .../gitlint/tests/config/test_config.py | 29 +++++++++---- .../contrib/rules/test_conventional_commit.py | 41 ++++++++++++++++++- 4 files changed, 81 insertions(+), 12 deletions(-) diff --git a/docs/rules/contrib_rules.md b/docs/rules/contrib_rules.md index cf622b37..2ddc8633 100644 --- a/docs/rules/contrib_rules.md +++ b/docs/rules/contrib_rules.md @@ -55,7 +55,8 @@ Enforces [Conventional Commits](https://www.conventionalcommits.org/) commit mes | Name | Type | Default | gitlint version | Description | | ------------- | -------------- | ------------- | ---------------------------------- | ----------------------------- | | `types` | `#!python str` | `fix,feat,chore,docs,style,refactor,perf,test,revert,ci,build` | [:octicons-tag-24: v0.12.0][v0.12.0] | Comma separated list of allowed commit types. | - +| `scopes` | `#!python str` | | | Comma separated list of allowed scopes. An empty list will allow anything. | +| `require-scope` | `#!python bool` | `False` | | Whether to require a scope. | === ":octicons-file-code-16: .gitlint" diff --git a/gitlint-core/gitlint/contrib/rules/conventional_commit.py b/gitlint-core/gitlint/contrib/rules/conventional_commit.py index 705b0839..935141f5 100644 --- a/gitlint-core/gitlint/contrib/rules/conventional_commit.py +++ b/gitlint-core/gitlint/contrib/rules/conventional_commit.py @@ -1,9 +1,9 @@ import re -from gitlint.options import ListOption +from gitlint.options import ListOption, BoolOption from gitlint.rules import CommitMessageTitle, LineRule, RuleViolation -RULE_REGEX = re.compile(r"([^(]+?)(\([^)]+?\))?!?: .+") +RULE_REGEX = re.compile(r"([^(]+?)(\(([^)]+?)\))?!?: .+") class ConventionalCommit(LineRule): @@ -18,7 +18,13 @@ class ConventionalCommit(LineRule): "types", ["fix", "feat", "chore", "docs", "style", "refactor", "perf", "test", "revert", "ci", "build"], "Comma separated list of allowed commit types.", - ) + ), + ListOption( + "scopes", + [], + "Comma separated list of allowed scopes. An empty list will allow anything.", + ), + BoolOption("require-scope", False, "Whether to require a scope."), ] def validate(self, line, _commit): @@ -34,4 +40,12 @@ def validate(self, line, _commit): opt_str = ", ".join(self.options["types"].value) violations.append(RuleViolation(self.id, f"Title does not start with one of {opt_str}", line)) + line_scope = match.group(3) + allowed_scopes = self.options["scopes"].value + if line_scope and allowed_scopes and line_scope not in allowed_scopes: + opt_str = ", ".join(self.options["scopes"].value) + violations.append(RuleViolation(self.id, f"Scope is not one of {opt_str}", line)) + elif not line_scope and self.options["require-scope"].value: + violations.append(RuleViolation(self.id, "Scope is required", line)) + return violations diff --git a/gitlint-core/gitlint/tests/config/test_config.py b/gitlint-core/gitlint/tests/config/test_config.py index 422bb33c..d4a961f6 100644 --- a/gitlint-core/gitlint/tests/config/test_config.py +++ b/gitlint-core/gitlint/tests/config/test_config.py @@ -135,14 +135,29 @@ def test_contrib(self): self.assertEqual(actual_rule.name, "contrib-title-conventional-commits") self.assertEqual(actual_rule.target, rules.CommitMessageTitle) - expected_rule_option = options.ListOption( - "types", - ["fix", "feat", "chore", "docs", "style", "refactor", "perf", "test", "revert", "ci", "build"], - "Comma separated list of allowed commit types.", - ) + expected_rule_options = [ + options.ListOption( + "types", + ["fix", "feat", "chore", "docs", "style", "refactor", "perf", "test", "revert", "ci", "build"], + "Comma separated list of allowed commit types.", + ), + options.ListOption( + "scopes", + [], + "Comma separated list of allowed scopes. An empty list will allow anything.", + ), + options.BoolOption("require-scope", False, "Whether to require a scope."), + ] - self.assertListEqual(actual_rule.options_spec, [expected_rule_option]) - self.assertDictEqual(actual_rule.options, {"types": expected_rule_option}) + self.assertListEqual(actual_rule.options_spec, expected_rule_options) + self.assertDictEqual( + actual_rule.options, + { + "types": expected_rule_options[0], + "scopes": expected_rule_options[1], + "require-scope": expected_rule_options[2], + }, + ) # Check contrib-body-requires-signed-off-by contrib rule actual_rule = config.rules.find_rule("contrib-body-requires-signed-off-by") diff --git a/gitlint-core/gitlint/tests/contrib/rules/test_conventional_commit.py b/gitlint-core/gitlint/tests/contrib/rules/test_conventional_commit.py index cbab6842..c86cbc95 100644 --- a/gitlint-core/gitlint/tests/contrib/rules/test_conventional_commit.py +++ b/gitlint-core/gitlint/tests/contrib/rules/test_conventional_commit.py @@ -16,7 +16,19 @@ def test_conventional_commits(self): rule = ConventionalCommit() # No violations when using a correct type and format - for type in ["fix", "feat", "chore", "docs", "style", "refactor", "perf", "test", "revert", "ci", "build"]: + for type in [ + "fix", + "feat", + "chore", + "docs", + "style", + "refactor", + "perf", + "test", + "revert", + "ci", + "build", + ]: violations = rule.validate(type + ": föo", None) self.assertListEqual([], violations) @@ -80,3 +92,30 @@ def test_conventional_commits(self): for typ in ["föo123", "123bär"]: violations = rule.validate(typ + ": hür dur", None) self.assertListEqual([], violations) + + # assert violation if scope is not in scopes + rule = ConventionalCommit({"scopes": ["foo", "bar"]}) + violations = rule.validate("fix(baz): hellö", None) + expected_violation = RuleViolation("CT1", "Scope is not one of foo, bar", "fix(baz): hellö") + self.assertListEqual([expected_violation], violations) + + # assert no violation if scope is in scopes + rule = ConventionalCommit({"scopes": ["foo", "bar"]}) + violations = rule.validate("fix(foo): hellö", None) + self.assertListEqual([], violations) + + # assert no violation if scopes is empty + rule = ConventionalCommit({"scopes": []}) + violations = rule.validate("fix(scope): hellö", None) + self.assertListEqual([], violations) + + # assert violation if scope is required but not specified + rule = ConventionalCommit({"require-scope": True}) + violations = rule.validate("fix: hellö", None) + expected_violation = RuleViolation("CT1", "Scope is required", "fix: hellö") + self.assertListEqual([expected_violation], violations) + + # assert no violation if scope is not required and not specified + rule = ConventionalCommit({"require-scope": False}) + violations = rule.validate("fix: hellö", None) + self.assertListEqual([], violations)