Skip to content
Open
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
1 change: 1 addition & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,7 @@ cchk --help # Verify alias works
- **Conventional Branches**: Enforces patterns like `feature/`, `bugfix/`, etc.
- **Default prefixes**: feature, bugfix, hotfix, release, chore, feat, fix, build, ci, docs, perf, refactor, test, style, ai, claude, codex, copilot, cursor, dependabot, renovate (branch.allow_branch_types)
- **Special branches**: master, main, HEAD, PR-* are allowed
- **Description grammar**: opt-in `branch.require_description_grammar` holds the part after `<type>/` to the Conventional Branch grammar (lowercase words joined by single hyphens, dots inside a word); off by default because bot branches such as `dependabot/npm_and_yarn/...` do not follow it

### Author Validation
- **Author name**: Checks for valid name format
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ Priority: CLI > env > TOML > default. Booleans accept `true/false`, `yes/no`,
| `commit.author_email_pattern` | `"^.+@.+$"` | `--author-email-pattern` | `CCHK_AUTHOR_EMAIL_PATTERN` | Regex the author email must match (CC102, with `--author-email`) |
| `commit.author_name_pattern` | `""` | `--author-name-pattern` | `CCHK_AUTHOR_NAME_PATTERN` | Regex the author name must match (CC101, with `--author-name`) |
| `branch.conventional_branch` | `true` | `--conventional-branch` | `CCHK_CONVENTIONAL_BRANCH` | Enforce `<type>/<description>` branch names (CC201) |
| `branch.require_description_grammar` | `false` | `--require-description-grammar` | `CCHK_REQUIRE_DESCRIPTION_GRAMMAR` | Also hold the description after `<type>/` to the Conventional Branch grammar: lowercase alphanumeric words joined by single hyphens, dots allowed inside a word (CC201). Off by default, since bot branches such as `dependabot/npm_and_yarn/lodash-4.17.21` do not follow it |
| `branch.allow_branch_types` | `feature, bugfix, hotfix, release, chore, feat, fix, build, ci, docs, perf, refactor, test, style, ai, claude, codex, copilot, cursor, dependabot, renovate` | `--allow-branch-types` | `CCHK_ALLOW_BRANCH_TYPES` | Allowed branch `<type>` prefixes; each entry is a regex matched against the whole type |
| `branch.allow_branch_names` | `[]` | `--allow-branch-names` | `CCHK_ALLOW_BRANCH_NAMES` | Extra branch names allowed besides `main`, `master`, `HEAD`, `PR-.+`; each entry is a regex matched against the whole branch name, so `create-pull-request/.+` allows a family of them |
| `branch.require_rebase_target` | `""` | `--require-rebase-target` | `CCHK_REQUIRE_REBASE_TARGET` | Branch the current branch must be rebased onto (CC202); empty disables |
Expand Down
7 changes: 7 additions & 0 deletions commit_check/config_merger.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ def get_default_config() -> dict[str, Any]:
},
"branch": {
"conventional_branch": True,
"require_description_grammar": False,
"allow_branch_types": DEFAULT_BRANCH_TYPES.copy(),
"allow_branch_names": DEFAULT_BRANCH_NAMES.copy(),
"require_rebase_target": "",
Expand Down Expand Up @@ -140,6 +141,11 @@ class ConfigMerger:
"CCHK_AUTHOR_NAME_PATTERN": ("commit", "author_name_pattern", str),
# Branch section
"CCHK_CONVENTIONAL_BRANCH": ("branch", "conventional_branch", parse_bool),
"CCHK_REQUIRE_DESCRIPTION_GRAMMAR": (
"branch",
"require_description_grammar",
parse_bool,
),
"CCHK_ALLOW_BRANCH_TYPES": ("branch", "allow_branch_types", parse_list),
"CCHK_ALLOW_BRANCH_NAMES": ("branch", "allow_branch_names", parse_list),
"CCHK_REQUIRE_REBASE_TARGET": ("branch", "require_rebase_target", str),
Expand Down Expand Up @@ -178,6 +184,7 @@ class ConfigMerger:
"author_name_pattern": ("commit", "author_name_pattern"),
# Branch section
"conventional_branch": ("branch", "conventional_branch"),
"require_description_grammar": ("branch", "require_description_grammar"),
"allow_branch_types": ("branch", "allow_branch_types"),
"allow_branch_names": ("branch", "allow_branch_names"),
"require_rebase_target": ("branch", "require_rebase_target"),
Expand Down
9 changes: 9 additions & 0 deletions commit_check/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,15 @@ def _get_parser() -> argparse.ArgumentParser:
help="enforce conventional branch naming (true/false)",
)

branch_group.add_argument(
"--require-description-grammar",
type=parse_bool,
default=None,
metavar="BOOL",
help="hold the description after '/' to the Conventional Branch grammar "
"(true/false)",
)

branch_group.add_argument(
"--allow-branch-types",
type=parse_list,
Expand Down
19 changes: 17 additions & 2 deletions commit_check/rule_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,15 @@
#: A git trailer token: letters, digits and hyphens, and no colon.
_TRAILER_KEY = re.compile(r"^[A-Za-z][A-Za-z0-9-]*$")

#: The description grammar from conventionalbranch.org's spec.json, used when
#: [branch] require_description_grammar is on; otherwise the description is ".+".
_STRICT_BRANCH_DESCRIPTION = r"[a-z0-9]+(?:\.[a-z0-9]+)*(?:-[a-z0-9]+(?:\.[a-z0-9]+)*)*"
_STRICT_BRANCH_SUGGEST = (
"Use <type>/<description> with an allowed type and a lowercase, "
"hyphen-separated description (e.g. feature/add-login), "
"or add the branch to allow_branch_names in config"
)


# Lookup tables for the top-level ``warn`` list, built once at import: a
# check name or rule ID in any case maps to the catalog's check name.
Expand Down Expand Up @@ -481,12 +490,13 @@ def _build_conventional_branch_rule(
allowed_types = self._get_allowed_branch_types()
allowed_names = self._get_allowed_branch_names()
regex = self._build_conventional_branch_regex(allowed_types, allowed_names)
strict = self.branch_config.get("require_description_grammar", False)

return ValidationRule(
check=catalog_entry.check,
regex=regex,
error=catalog_entry.error,
suggest=catalog_entry.suggest,
suggest=_STRICT_BRANCH_SUGGEST if strict else catalog_entry.suggest,
allowed=allowed_types,
)

Expand Down Expand Up @@ -735,4 +745,9 @@ def _build_conventional_branch_regex(
names_pattern = "|".join(["master", "main", "HEAD", r"PR-.+"])
if allowed_names:
names_pattern += "|" + "|".join(allowed_names)
return rf"^(?:{types_pattern})/.+$|^(?:{names_pattern})$"
description_pattern = (
_STRICT_BRANCH_DESCRIPTION
if self.branch_config.get("require_description_grammar", False)
else ".+"
)
return rf"^(?:{types_pattern})/{description_pattern}$|^(?:{names_pattern})$"
5 changes: 5 additions & 0 deletions tests/config_merger_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ def test_branch_defaults(self):
config = get_default_config()
branch = config["branch"]
assert branch["conventional_branch"] is True
assert branch["require_description_grammar"] is False
assert isinstance(branch["allow_branch_types"], list)
assert "feature" in branch["allow_branch_types"]

Expand Down Expand Up @@ -176,9 +177,11 @@ def test_parse_multiple_env_vars(self, monkeypatch):

def test_parse_branch_env_vars(self, monkeypatch):
monkeypatch.setenv("CCHK_CONVENTIONAL_BRANCH", "false")
monkeypatch.setenv("CCHK_REQUIRE_DESCRIPTION_GRAMMAR", "true")
monkeypatch.setenv("CCHK_ALLOW_BRANCH_TYPES", "feature,bugfix")
config = ConfigMerger.parse_env_vars()
assert config["branch"]["conventional_branch"] is False
assert config["branch"]["require_description_grammar"] is True
assert config["branch"]["allow_branch_types"] == ["feature", "bugfix"]

def test_parse_author_pattern_env_vars(self, monkeypatch):
Expand Down Expand Up @@ -259,11 +262,13 @@ def test_missing_attributes_are_ignored(self):
def test_branch_cli_args(self):
args = argparse.Namespace(
conventional_branch=False,
require_description_grammar=True,
allow_branch_types=["feature", "bugfix"],
require_rebase_target="main",
)
config = ConfigMerger.parse_cli_args(args)
assert config["branch"]["conventional_branch"] is False
assert config["branch"]["require_description_grammar"] is True
assert config["branch"]["allow_branch_types"] == ["feature", "bugfix"]
assert config["branch"]["require_rebase_target"] == "main"

Expand Down
55 changes: 55 additions & 0 deletions tests/rule_builder_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -938,6 +938,61 @@
assert not re.match(regex, "renovate-bot")


class TestBranchDescriptionGrammar:
"""Opt-in ``require_description_grammar`` holds the part after "/" to the spec."""

@pytest.mark.parametrize(
"branch",
[
"feature/add-login-page",
"release/v1.2.0",
"fix/issue-123",
"renovate/lodash-5.x",
],
)
def test_conformant_descriptions_pass(self, branch):
assert re.match(_branch_regex(require_description_grammar=True), branch)

@pytest.mark.parametrize(
"branch",
[
"feature/Add-Login",
"fix/header_bug",
"feature/new--login",
"feature/-new-login",
"feature/new-login-",
"release/v1.-2.0",
"dependabot/npm_and_yarn/lodash-4.17.21",
],
)
def test_non_conformant_descriptions_are_rejected(self, branch):
assert not re.match(_branch_regex(require_description_grammar=True), branch)

def test_off_by_default_the_regex_is_unchanged(self):
assert _branch_regex(require_description_grammar=False) == _branch_regex()
assert re.match(_branch_regex(), "dependabot/npm_and_yarn/lodash-4.17.21")

def test_allow_branch_names_readmits_a_bot(self):
regex = _branch_regex(
require_description_grammar=True, allow_branch_names=[r"dependabot/.+"]
)
assert re.match(regex, "dependabot/npm_and_yarn/lodash-4.17.21")

def test_the_suggestion_names_the_description_format(self):
builder = RuleBuilder(
{
"branch": {
"conventional_branch": True,
"require_description_grammar": True,
}
}
)
rule = builder._build_conventional_branch_rule(BRANCH_ENTRY)
assert rule is not None and "hyphen-separated description" in (
rule.suggest or ""
)

Check warning on line 993 in tests/rule_builder_test.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Split this composite assertion into separate assertions.

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


class TestConventionalCommitGitPrefixes:
"""CC001 exempts exactly the subjects git writes itself.

Expand Down
Loading