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/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@
"feat",
"fix",
]
# Additional allowed branch names (e.g., develop, staging)
DEFAULT_BRANCH_NAMES: list[str] = []

# Handle different default values for different rules
DEFAULT_BOOLEAN_RULES = {
Expand Down
19 changes: 16 additions & 3 deletions commit_check/rule_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from commit_check import (
DEFAULT_COMMIT_TYPES,
DEFAULT_BRANCH_TYPES,
DEFAULT_BRANCH_NAMES,
DEFAULT_BOOLEAN_RULES,
)

Expand Down Expand Up @@ -125,7 +126,8 @@ def _build_conventional_branch_rule(
return None

allowed_types = self._get_allowed_branch_types()
regex = self._build_conventional_branch_regex(allowed_types)
allowed_names = self._get_allowed_branch_names()
regex = self._build_conventional_branch_regex(allowed_types, allowed_names)

return ValidationRule(
check=catalog_entry.check,
Expand Down Expand Up @@ -222,12 +224,23 @@ def _get_allowed_branch_types(self) -> List[str]:
types = self.branch_config.get("allow_branch_types", DEFAULT_BRANCH_TYPES)
return list(dict.fromkeys(types)) # Preserve order, remove duplicates

def _get_allowed_branch_names(self) -> List[str]:
"""Get deduplicated list of allowed branch names."""
names = self.branch_config.get("allow_branch_names", DEFAULT_BRANCH_NAMES)
return list(dict.fromkeys(names)) # Preserve order, remove duplicates

def _build_conventional_commit_regex(self, allowed_types: List[str]) -> str:
"""Build regex for conventional commit messages."""
types_pattern = "|".join(sorted(set(allowed_types)))
return rf"^({types_pattern}){{1}}(\([\w\-\.]+\))?(!)?: ([\w ])+([\s\S]*)|(Merge).*|(fixup!.*)"

def _build_conventional_branch_regex(self, allowed_types: List[str]) -> str:
def _build_conventional_branch_regex(
self, allowed_types: List[str], allowed_names: List[str]
) -> str:
"""Build regex for conventional branch names."""
types_pattern = "|".join(allowed_types)
return rf"^({types_pattern})\/.+|(master)|(main)|(HEAD)|(PR-.+)"
# Build pattern for additional allowed branch names
base_names = ["master", "main", "HEAD", "PR-.+"]
all_names = base_names + allowed_names
names_pattern = ")|(".join(all_names)
return rf"^({types_pattern})\/.+|({names_pattern})"
2 changes: 1 addition & 1 deletion commit_check/rules_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ class RuleCatalogEntry:
check="branch",
regex=None, # Built dynamically from config
error="The branch should follow Conventional Branch. See https://conventional-branch.github.io/",
suggest="Use <type>/<description> with allowed types or ignore_authors in config branch section to bypass",
suggest="Use <type>/<description> with allowed types or add branch name to allow_branch_names in config, or use ignore_authors in config branch section to bypass",
),
RuleCatalogEntry(
check="merge_base",
Expand Down
6 changes: 6 additions & 0 deletions docs/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ Example Configuration
# https://conventional-branch.github.io/
conventional_branch = true
allow_branch_types = ["feature", "bugfix", "hotfix", "release", "chore", "feat", "fix"]
# allow_branch_names = [] # Optional - additional standalone branch names (e.g., ["develop", "staging"])
# require_rebase_target = "main" # Optional - no rebase requirement by default
# ignore_authors = [] # Optional - no authors ignored by default

Expand Down Expand Up @@ -140,6 +141,11 @@ Options Table Description
- list[str]
- ["feature", "bugfix", "hotfix", "release", "chore", "feat", "fix"]
- Allowed branch types when conventional_branch is true.
* - branch
- allow_branch_names
- list[str]
- [] (empty list)
- Additional standalone branch names allowed when conventional_branch is true (e.g., ["develop", "staging"]). By default, master, main, HEAD, and PR-* are always allowed.
* - branch
- require_rebase_target
- str
Expand Down
59 changes: 59 additions & 0 deletions tests/engine_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,9 +197,68 @@ def test_validate_with_stdin_text(self):
validator = BranchValidator(rule)
context = ValidationContext(stdin_text="feature/new-feature")

validator.validate(context)

@patch("commit_check.engine.has_commits")
@patch("commit_check.engine.get_branch_name")
@pytest.mark.benchmark
def test_branch_validator_develop_branch_allowed(
self, mock_get_branch_name, mock_has_commits
):
"""Test BranchValidator with develop branch when it's in allow_branch_names."""
mock_has_commits.return_value = True
mock_get_branch_name.return_value = "develop"
# Regex pattern that includes develop as an allowed branch name
rule = ValidationRule(
check="branch",
regex=r"^(feature|bugfix|hotfix)\/.+|(master)|(main)|(HEAD)|(PR-.+)|(develop)",
)
validator = BranchValidator(rule)
config = {"branch": {"ignore_authors": []}}
context = ValidationContext(config=config)
result = validator.validate(context)
assert result == ValidationResult.PASS

@patch("commit_check.engine.has_commits")
@patch("commit_check.engine.get_branch_name")
@pytest.mark.benchmark
def test_branch_validator_staging_branch_allowed(
self, mock_get_branch_name, mock_has_commits
):
"""Test BranchValidator with staging branch when it's in allow_branch_names."""
mock_has_commits.return_value = True
mock_get_branch_name.return_value = "staging"
# Regex pattern that includes staging as an allowed branch name
rule = ValidationRule(
check="branch",
regex=r"^(feature|bugfix|hotfix)\/.+|(master)|(main)|(HEAD)|(PR-.+)|(staging)|(develop)",
)
validator = BranchValidator(rule)
config = {"branch": {"ignore_authors": []}}
context = ValidationContext(config=config)
result = validator.validate(context)
assert result == ValidationResult.PASS

@patch("commit_check.engine.has_commits")
@patch("commit_check.engine.get_branch_name")
@pytest.mark.benchmark
def test_branch_validator_develop_branch_not_allowed(
self, mock_get_branch_name, mock_has_commits
):
"""Test BranchValidator with develop branch when it's NOT in allow_branch_names."""
mock_has_commits.return_value = True
mock_get_branch_name.return_value = "develop"
# Regex pattern that does NOT include develop as an allowed branch name
rule = ValidationRule(
check="branch",
regex=r"^(feature|bugfix|hotfix)\/.+|(master)|(main)|(HEAD)|(PR-.+)",
)
validator = BranchValidator(rule)
config = {"branch": {"ignore_authors": []}}
context = ValidationContext(config=config)
result = validator.validate(context)
assert result == ValidationResult.FAIL

@pytest.mark.benchmark
def test_validate_without_regex(self):
"""Test branch validation without regex (should pass)."""
Expand Down
71 changes: 71 additions & 0 deletions tests/rule_builder_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,3 +155,74 @@ def test_rule_builder_boolean_rule_subject_disabled(self):
# This should return None when subject_capitalized is False (line 232)
rule = builder._build_boolean_rule(catalog_entry, builder.commit_config)
assert rule is None

@pytest.mark.benchmark
def test_rule_builder_allow_branch_names_default(self):
"""Test RuleBuilder with default branch names (no allow_branch_names configured)."""
config = {"branch": {"conventional_branch": True}}

builder = RuleBuilder(config)
catalog_entry = RuleCatalogEntry(check="branch", regex="", error="", suggest="")

rule = builder._build_conventional_branch_rule(catalog_entry)
assert rule is not None
# Should include default branch names: master, main, HEAD, PR-*
assert "(master)" in rule.regex
assert "(main)" in rule.regex
assert "(HEAD)" in rule.regex
assert "(PR-.+)" in rule.regex

@pytest.mark.benchmark
def test_rule_builder_allow_branch_names_custom(self):
"""Test RuleBuilder with custom branch names (allow_branch_names configured)."""
config = {
"branch": {
"conventional_branch": True,
"allow_branch_names": ["develop", "staging", "production"],
}
}

builder = RuleBuilder(config)
catalog_entry = RuleCatalogEntry(check="branch", regex="", error="", suggest="")

rule = builder._build_conventional_branch_rule(catalog_entry)
assert rule is not None
# Should include both default and custom branch names
assert "(master)" in rule.regex
assert "(main)" in rule.regex
assert "(HEAD)" in rule.regex
assert "(PR-.+)" in rule.regex
assert "(develop)" in rule.regex
assert "(staging)" in rule.regex
assert "(production)" in rule.regex

@pytest.mark.benchmark
def test_rule_builder_allow_branch_names_empty_list(self):
"""Test RuleBuilder with empty allow_branch_names list."""
config = {"branch": {"conventional_branch": True, "allow_branch_names": []}}

builder = RuleBuilder(config)
catalog_entry = RuleCatalogEntry(check="branch", regex="", error="", suggest="")

rule = builder._build_conventional_branch_rule(catalog_entry)
assert rule is not None
# Should only include default branch names
assert "(master)" in rule.regex
assert "(main)" in rule.regex
assert "(HEAD)" in rule.regex
assert "(PR-.+)" in rule.regex

@pytest.mark.benchmark
def test_rule_builder_allow_branch_names_with_duplicates(self):
"""Test RuleBuilder with duplicate branch names in allow_branch_names."""
config = {
"branch": {
"conventional_branch": True,
"allow_branch_names": ["develop", "develop", "staging", "develop"],
}
}

builder = RuleBuilder(config)
allowed_names = builder._get_allowed_branch_names()
# Should deduplicate while preserving order
assert allowed_names == ["develop", "staging"]
Loading