Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions commit_check/config_merger.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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),
Expand Down
17 changes: 16 additions & 1 deletion commit_check/rule_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Comment on lines +155 to +156

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard message_pattern type before calling .strip() to avoid runtime crash.

On Line 155, self.commit_config.get("message_pattern", "").strip() will raise if TOML provides a non-string value (e.g., number/list), causing rule building to fail instead of falling back safely.

Suggested fix
-        custom_pattern = self.commit_config.get("message_pattern", "").strip()
+        raw_pattern = self.commit_config.get("message_pattern", "")
+        custom_pattern = raw_pattern.strip() if isinstance(raw_pattern, str) else ""
         if custom_pattern:
             return ValidationRule(
                 check=catalog_entry.check,
                 regex=custom_pattern,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@commit_check/rule_builder.py` around lines 155 - 156, The code currently
calls self.commit_config.get("message_pattern", "").strip() which crashes if
message_pattern is not a string; change it to first retrieve the raw value
(e.g., raw = self.commit_config.get("message_pattern", None)), check
isinstance(raw, str) and only then call strip() (assign custom_pattern =
raw.strip()), otherwise set custom_pattern = "" (or otherwise fallback safely)
so non-string TOML values don't raise in rule_builder.py; update any downstream
uses that assume custom_pattern is a string accordingly.

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

Expand Down
9 changes: 9 additions & 0 deletions docs/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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``
Expand Down Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions docs/migration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
---------------

Expand Down
5 changes: 5 additions & 0 deletions tests/config_merger_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
78 changes: 78 additions & 0 deletions tests/engine_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,63 @@ 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."""
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")
Expand Down Expand Up @@ -903,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."""
Expand Down
62 changes: 62 additions & 0 deletions tests/rule_builder_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,68 @@
# 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"

Check failure on line 242 in tests/rule_builder_test.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "Bad format" 4 times.

See more on https://sonarcloud.io/project/issues?id=commit-check_commit-check&issues=AZ6_SPoaUs3jAr3sQIgm&open=AZ6_SPoaUs3jAr3sQIgm&pullRequest=427
)

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."""
Expand Down
Loading