From 4d8408332c5752a617905d477c088099af53f34f Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Mon, 15 Dec 2025 20:50:19 +0200 Subject: [PATCH] feat: support additional allow_branch_names in configuration --- commit_check/__init__.py | 2 + commit_check/rule_builder.py | 19 ++++++++-- commit_check/rules_catalog.py | 2 +- docs/configuration.rst | 6 +++ tests/engine_test.py | 59 +++++++++++++++++++++++++++++ tests/rule_builder_test.py | 71 +++++++++++++++++++++++++++++++++++ 6 files changed, 155 insertions(+), 4 deletions(-) diff --git a/commit_check/__init__.py b/commit_check/__init__.py index 2066ba85..1e169609 100644 --- a/commit_check/__init__.py +++ b/commit_check/__init__.py @@ -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 = { diff --git a/commit_check/rule_builder.py b/commit_check/rule_builder.py index 55981742..46c57322 100644 --- a/commit_check/rule_builder.py +++ b/commit_check/rule_builder.py @@ -6,6 +6,7 @@ from commit_check import ( DEFAULT_COMMIT_TYPES, DEFAULT_BRANCH_TYPES, + DEFAULT_BRANCH_NAMES, DEFAULT_BOOLEAN_RULES, ) @@ -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, @@ -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})" diff --git a/commit_check/rules_catalog.py b/commit_check/rules_catalog.py index 64af216c..d48d366a 100644 --- a/commit_check/rules_catalog.py +++ b/commit_check/rules_catalog.py @@ -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 / with allowed types or ignore_authors in config branch section to bypass", + suggest="Use / 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", diff --git a/docs/configuration.rst b/docs/configuration.rst index ee37bcc7..144bb434 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -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 @@ -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 diff --git a/tests/engine_test.py b/tests/engine_test.py index 8cdabf7d..81e714c6 100644 --- a/tests/engine_test.py +++ b/tests/engine_test.py @@ -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).""" diff --git a/tests/rule_builder_test.py b/tests/rule_builder_test.py index 09daa0e2..460e4719 100644 --- a/tests/rule_builder_test.py +++ b/tests/rule_builder_test.py @@ -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"]