From 5b41b422c694320c8ad46256986dfbd0633087eb Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Sat, 13 Jun 2026 07:40:51 +0300 Subject: [PATCH 1/3] feat: support custom regex via message_pattern config option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `message_pattern` to the `[commit]` config section — a custom regex that replaces the auto-generated Conventional Commits regex when set. Only available via TOML and env var (CCHK_MESSAGE_PATTERN), not as a CLI flag, since regex belongs in config files not command lines. Closes #426 --- commit_check/config_merger.py | 2 ++ commit_check/rule_builder.py | 17 +++++++++- docs/configuration.rst | 9 +++++ docs/migration.rst | 15 +++++++++ tests/engine_test.py | 36 ++++++++++++++++++++ tests/rule_builder_test.py | 62 +++++++++++++++++++++++++++++++++++ 6 files changed, 140 insertions(+), 1 deletion(-) diff --git a/commit_check/config_merger.py b/commit_check/config_merger.py index d8b4abdd..7db19f13 100644 --- a/commit_check/config_merger.py +++ b/commit_check/config_merger.py @@ -61,6 +61,7 @@ def get_default_config() -> Dict[str, Any]: return { "commit": { "conventional_commits": True, + "message_pattern": "", "subject_capitalized": DEFAULT_BOOLEAN_RULES["subject_capitalized"], "subject_imperative": DEFAULT_BOOLEAN_RULES["subject_imperative"], "subject_max_length": 80, @@ -104,6 +105,7 @@ class ConfigMerger: ENV_VAR_MAPPING: Dict[str, Tuple[str, str, Callable[[Any], Any]]] = { # Commit section "CCHK_CONVENTIONAL_COMMITS": ("commit", "conventional_commits", parse_bool), + "CCHK_MESSAGE_PATTERN": ("commit", "message_pattern", str), "CCHK_SUBJECT_CAPITALIZED": ("commit", "subject_capitalized", parse_bool), "CCHK_SUBJECT_IMPERATIVE": ("commit", "subject_imperative", parse_bool), "CCHK_SUBJECT_MAX_LENGTH": ("commit", "subject_max_length", parse_int), diff --git a/commit_check/rule_builder.py b/commit_check/rule_builder.py index b072f185..f6c3015b 100644 --- a/commit_check/rule_builder.py +++ b/commit_check/rule_builder.py @@ -145,7 +145,22 @@ def _build_single_rule( def _build_conventional_commit_rule( self, catalog_entry: RuleCatalogEntry ) -> Optional[ValidationRule]: - """Build conventional commit message rule.""" + """Build conventional commit message rule. + + When ``message_pattern`` is set in config, it takes precedence over + the auto-generated conventional-commits regex. This allows teams to + enforce custom formats such as JIRA smart commits + (``PROJ-123: description``). + """ + custom_pattern = self.commit_config.get("message_pattern", "").strip() + if custom_pattern: + return ValidationRule( + check=catalog_entry.check, + regex=custom_pattern, + error=catalog_entry.error, + suggest="Commit message does not match the required pattern", + ) + if not self.commit_config.get("conventional_commits", True): return None diff --git a/docs/configuration.rst b/docs/configuration.rst index f746daf4..b3eb77ca 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -102,6 +102,7 @@ Example Configuration [commit] # https://www.conventionalcommits.org conventional_commits = true + # message_pattern = "" # Optional - custom regex (overrides conventional_commits) subject_capitalized = false subject_imperative = false # subject_max_length = 50 # Optional - no limit by default @@ -222,6 +223,9 @@ Configuration can also be set via environment variables with the ``CCHK_`` prefi * - ``conventional_commits = true`` - ``CCHK_CONVENTIONAL_COMMITS=true`` - ``--conventional-commits=true`` + * - ``message_pattern = "^PROJ-\\d+: .+"`` + - ``CCHK_MESSAGE_PATTERN=^PROJ-\\d+: .+`` + - N/A (config file only) * - ``subject_capitalized = false`` - ``CCHK_SUBJECT_CAPITALIZED=false`` - ``--subject-capitalized=false`` @@ -316,6 +320,11 @@ Options Table Description - bool - true - Enforce Conventional Commits specification. + * - commit + - message_pattern + - str + - "" (disabled) + - Custom regex pattern for commit message validation. When set, this pattern replaces the auto-generated Conventional Commits regex entirely, making it possible to enforce custom formats such as JIRA smart commits (e.g., ``"^PROJ-\\d+: .+"``). When ``message_pattern`` is set (non-empty) it takes precedence over ``conventional_commits``. * - commit - subject_capitalized - bool diff --git a/docs/migration.rst b/docs/migration.rst index 12200c92..99e05fdc 100644 --- a/docs/migration.rst +++ b/docs/migration.rst @@ -143,6 +143,21 @@ The command-line interface has been simplified: commit-check --message --branch +Custom Regex (``message_pattern``) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If you relied on the custom ``regex`` field in v1.x to enforce a non-Conventional-Commits +format (e.g. JIRA smart commits ``PROJ-123: description``), use the ``message_pattern`` +option in the ``[commit]`` section: + +.. code-block:: toml + + [commit] + message_pattern = "^PROJ-\\d+: .+" + +When ``message_pattern`` is set (non-empty), it replaces the auto-generated Conventional +Commits regex entirely, giving you full control over the accepted message format. + Troubleshooting --------------- diff --git a/tests/engine_test.py b/tests/engine_test.py index b3e3121e..a6701b74 100644 --- a/tests/engine_test.py +++ b/tests/engine_test.py @@ -139,6 +139,42 @@ def test_commit_message_validator_from_git(self, mock_get_commit_info): # Should call get_commit_info three times: subject, body, and author assert mock_get_commit_info.call_count == 3 + @pytest.mark.benchmark + def test_commit_message_validator_custom_pattern_jira(self): + """Test CommitMessageValidator with a custom JIRA-style regex.""" + rule = ValidationRule( + check="message", + regex=r"^PROJ-\d+: .+", + ) + validator = CommitMessageValidator(rule) + + # Valid JIRA-style message + context = ValidationContext(stdin_text="PROJ-123: Fix login bug") + result = validator.validate(context) + assert result == ValidationResult.PASS + + # Invalid message (no issue key) + context = ValidationContext(stdin_text="fix: login bug") + result = validator.validate(context) + assert result == ValidationResult.FAIL + + @pytest.mark.benchmark + def test_commit_message_validator_custom_pattern_github_issue(self): + """Test CommitMessageValidator with a GitHub issue reference pattern.""" + rule = ValidationRule( + check="message", + regex=r".+#\d+.*", + ) + validator = CommitMessageValidator(rule) + + context = ValidationContext(stdin_text="Fix login bug #123") + result = validator.validate(context) + assert result == ValidationResult.PASS + + context = ValidationContext(stdin_text="Fix login bug") + result = validator.validate(context) + assert result == ValidationResult.FAIL + class TestBranchValidator: @patch("commit_check.engine.has_commits") diff --git a/tests/rule_builder_test.py b/tests/rule_builder_test.py index 97df8bb6..2663b951 100644 --- a/tests/rule_builder_test.py +++ b/tests/rule_builder_test.py @@ -227,6 +227,68 @@ def test_rule_builder_allow_branch_names_with_duplicates(self): # Should deduplicate while preserving order assert allowed_names == ["develop", "staging"] + @pytest.mark.benchmark + def test_message_pattern_takes_precedence(self): + """When message_pattern is set, it replaces the auto-generated regex.""" + config = { + "commit": { + "conventional_commits": True, + "message_pattern": r"^PROJ-\d+: .+", + } + } + + builder = RuleBuilder(config) + catalog_entry = RuleCatalogEntry( + check="message", regex="", error="Bad format", suggest="Use JIRA format" + ) + + rule = builder._build_conventional_commit_rule(catalog_entry) + assert rule is not None + assert rule.regex == r"^PROJ-\d+: .+" + assert rule.error == "Bad format" + assert "required pattern" in rule.suggest + + @pytest.mark.benchmark + def test_message_pattern_overrides_conventional_commits(self): + """message_pattern works even when conventional_commits is false.""" + config = { + "commit": { + "conventional_commits": False, + "message_pattern": r"^\[ISSUE-\d+\] .+", + } + } + + builder = RuleBuilder(config) + catalog_entry = RuleCatalogEntry( + check="message", regex="", error="Bad format", suggest="Use correct format" + ) + + rule = builder._build_conventional_commit_rule(catalog_entry) + assert rule is not None + assert rule.regex == r"^\[ISSUE-\d+\] .+" + + @pytest.mark.benchmark + def test_message_pattern_empty_falls_back(self): + """When message_pattern is empty string, fall back to conventional commits.""" + config = { + "commit": { + "conventional_commits": True, + "message_pattern": "", + "allow_commit_types": ["feat", "fix"], + } + } + + builder = RuleBuilder(config) + catalog_entry = RuleCatalogEntry( + check="message", regex="", error="Bad format", suggest="..." + ) + + rule = builder._build_conventional_commit_rule(catalog_entry) + assert rule is not None + # Should use auto-generated regex, not empty string + assert "feat" in rule.regex + assert "fix" in rule.regex + class TestPushRuleBuilder: """Tests for push rule building.""" From f87f6d6255b68a54e36e002cc859b21ce9ad845a Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Sat, 13 Jun 2026 08:01:15 +0300 Subject: [PATCH 2/3] test: add coverage for CCHK_MESSAGE_PATTERN env var parsing --- tests/config_merger_test.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/config_merger_test.py b/tests/config_merger_test.py index d165344e..fa41060f 100644 --- a/tests/config_merger_test.py +++ b/tests/config_merger_test.py @@ -194,6 +194,11 @@ def test_no_env_vars_returns_empty_sections(self, monkeypatch): # Should return empty dict or dict with empty sections assert not config or all(not v for v in config.values()) + def test_parse_message_pattern_env_var(self, monkeypatch): + monkeypatch.setenv("CCHK_MESSAGE_PATTERN", r"^PROJ-\d+: .+") + config = ConfigMerger.parse_env_vars() + assert config["commit"]["message_pattern"] == r"^PROJ-\d+: .+" + class TestConfigMergerParseCliArgs: """Tests for ConfigMerger.parse_cli_args method.""" From 371bd517c54dfb2ca03c5d83d0acd2f3a66e67ca Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Sat, 13 Jun 2026 08:17:31 +0300 Subject: [PATCH 3/3] test: cover empty message/subject early-return paths in validators --- tests/engine_test.py | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/tests/engine_test.py b/tests/engine_test.py index a6701b74..3143c9f4 100644 --- a/tests/engine_test.py +++ b/tests/engine_test.py @@ -139,6 +139,27 @@ def test_commit_message_validator_from_git(self, mock_get_commit_info): # Should call get_commit_info three times: subject, body, and author assert mock_get_commit_info.call_count == 3 + @patch("commit_check.engine.has_commits") + @patch("commit_check.engine.get_commit_info") + @pytest.mark.benchmark + def test_commit_message_validator_empty_message_passes( + self, mock_get_commit_info, mock_has_commits + ): + """CommitMessageValidator returns PASS when message is empty.""" + mock_has_commits.return_value = True + mock_get_commit_info.side_effect = lambda fmt: { + "s": "", + "b": "", + "an": "author", + }.get(fmt, "") + + rule = ValidationRule(check="message", regex=r"^feat:") + validator = CommitMessageValidator(rule) + context = ValidationContext() + + result = validator.validate(context) + assert result == ValidationResult.PASS + @pytest.mark.benchmark def test_commit_message_validator_custom_pattern_jira(self): """Test CommitMessageValidator with a custom JIRA-style regex.""" @@ -939,6 +960,27 @@ def test_get_subject_with_file_not_found(self): subject = validator._get_subject(context) assert subject == "fallback message" + @patch("commit_check.engine.has_commits") + @patch("commit_check.engine.get_commit_info") + @pytest.mark.benchmark + def test_validate_empty_subject_passes( + self, mock_get_commit_info, mock_has_commits + ): + """SubjectValidator returns PASS when subject is empty.""" + mock_has_commits.return_value = True + mock_get_commit_info.side_effect = lambda fmt: { + "s": "", + "b": "", + "an": "author", + }.get(fmt, "") + + rule = ValidationRule(check="subject_capitalized") + validator = SubjectCapitalizationValidator(rule) + context = ValidationContext() + + result = validator.validate(context) + assert result == ValidationResult.PASS + class TestSubjectImperativeValidator: """Test SubjectImperativeValidator edge cases."""