diff --git a/README.md b/README.md index 9889831..2df0736 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,19 @@ Available checks: * `signed-off` - `Signed-off-by:` trailer exists * `signature` - Verify GPG or SSH signature +### Type validation + +By default the standard conventional commit types are accepted. Use `--types` +to replace the allowed set entirely: + +```bash +# restrict to a subset +commit-guard --types feat,fix,chore + +# add a project-specific type +commit-guard --types feat,fix,docs,style,refactor,perf,test,build,ci,chore,revert,wip +``` + ### Scope validation By default any scope is accepted and scope is optional. Use `--scopes` to @@ -100,7 +113,7 @@ commit-guard --scopes auth,api --require-scope ### Configuration file Place `.commit-guard.toml` in your project root (or any parent directory) to -set defaults for `enable`, `disable`, `scopes`, and `require-scope`. +set defaults for `enable`, `disable`, `scopes`, `require-scope`, and `types`. commit-guard searches upward from the working directory and uses the first file found. @@ -109,6 +122,7 @@ found. disable = ["signature", "body"] scopes = ["auth", "api", "db"] require-scope = true +types = ["feat", "fix", "chore", "wip"] ``` ```toml @@ -116,8 +130,8 @@ require-scope = true enable = ["subject", "imperative"] ``` -CLI flags (`--enable`, `--disable`, `--scopes`, `--require-scope`) take full -precedence and ignore config file values when provided. +CLI flags (`--enable`, `--disable`, `--scopes`, `--require-scope`, `--types`) +take full precedence and ignore config file values when provided. ### Checking a range of commits @@ -185,8 +199,9 @@ body trailers ``` -Supported types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, -`build`, `ci`, `chore`, `revert`. +Default types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, +`build`, `ci`, `chore`, `revert`. Override with `--types` or the `types` config +key. Scope is optional. Mark breaking changes with `!` before the colon. diff --git a/src/git_commit_guard/__init__.py b/src/git_commit_guard/__init__.py index 2219da6..21f12a5 100644 --- a/src/git_commit_guard/__init__.py +++ b/src/git_commit_guard/__init__.py @@ -120,13 +120,20 @@ def _strip_comments(message): ) -def check_subject(line, result, allowed_scopes=frozenset(), *, require_scope=False): +def check_subject( + line, + result, + allowed_scopes=frozenset(), + allowed_types=TYPES, + *, + require_scope=False, +): m = SUBJECT_RE.match(line) if not m: result.error(f"subject does not match 'type(scope): description': {line}") return None - if m.group("type") not in TYPES: + if m.group("type") not in allowed_types: result.error(f"unknown type: {m.group('type')}") scope = m.group("scope") @@ -223,6 +230,7 @@ class Args: enabled: frozenset allowed_scopes: frozenset require_scope: bool + allowed_types: frozenset def _resolve_enabled(args, config, parser): @@ -241,6 +249,14 @@ def _resolve_enabled(args, config, parser): return enabled +def _resolve_types(args, config): + if args.types: + return frozenset(t.strip() for t in args.types.split(",")) + if config.get("types"): + return frozenset(config["types"]) + return TYPES + + def _resolve_scopes(args, config): if args.scopes: allowed_scopes = frozenset(s.strip() for s in args.scopes.split(",")) @@ -292,10 +308,16 @@ def _parse_args(): default=False, help="require a scope in the subject line", ) + parser.add_argument( + "--types", + metavar="TYPE[,TYPE,...]", + help="allowed commit types (replaces defaults when set)", + ) args = parser.parse_args() config = _load_config() enabled = _resolve_enabled(args, config, parser) allowed_scopes, require_scope = _resolve_scopes(args, config) + allowed_types = _resolve_types(args, config) if args.message_file: rev = None @@ -316,6 +338,7 @@ def _parse_args(): enabled=enabled, allowed_scopes=allowed_scopes, require_scope=require_scope, + allowed_types=allowed_types, ) @@ -341,7 +364,11 @@ def main(): desc = None if Check.SUBJECT in args.enabled: desc = check_subject( - lines[0], result, args.allowed_scopes, require_scope=args.require_scope + lines[0], + result, + args.allowed_scopes, + args.allowed_types, + require_scope=args.require_scope, ) if Check.IMPERATIVE in args.enabled: if desc is None: diff --git a/tests/test_git_commit_guard.py b/tests/test_git_commit_guard.py index 80935d6..3fb3d7d 100644 --- a/tests/test_git_commit_guard.py +++ b/tests/test_git_commit_guard.py @@ -1,10 +1,11 @@ import subprocess -from argparse import ArgumentParser +from argparse import ArgumentParser, Namespace from unittest.mock import MagicMock, patch import pytest from git_commit_guard import ( + TYPES, Result, _download_if_missing, _ensure_nltk_data, @@ -13,6 +14,7 @@ _parse_checks, _parse_config_checks, _report, + _resolve_types, _strip_comments, check_body, check_imperative, @@ -146,6 +148,16 @@ def test_empty_allowlist_accepts_any_scope(self): check_subject("fix(anything): add token", r, allowed_scopes=frozenset()) assert r.ok + def test_custom_type_passes(self): + r = Result() + check_subject("wip: add thing", r, allowed_types=frozenset(["wip"])) + assert r.ok + + def test_type_not_in_custom_list_fails(self): + r = Result() + check_subject("feat: add thing", r, allowed_types=frozenset(["wip"])) + assert not r.ok + @pytest.mark.parametrize( "type_", [ @@ -408,6 +420,23 @@ def test_invalid_check_name_exits(self): _parse_config_checks({"disable": ["bogus"]}, "disable") +class TestResolveTypes: + def test_defaults_when_no_config_or_flag(self): + assert _resolve_types(Namespace(types=None), {}) == TYPES + + def test_cli_flag_replaces_defaults(self): + result = _resolve_types(Namespace(types="wip,deploy"), {}) + assert result == frozenset({"wip", "deploy"}) + + def test_config_replaces_defaults(self): + result = _resolve_types(Namespace(types=None), {"types": ["wip", "deploy"]}) + assert result == frozenset({"wip", "deploy"}) + + def test_cli_overrides_config(self): + result = _resolve_types(Namespace(types="wip"), {"types": ["deploy"]}) + assert result == frozenset({"wip"}) + + class TestParseChecks: def test_invalid_check_name(self): parser = ArgumentParser() @@ -640,3 +669,67 @@ def test_cli_overrides_config(self, tmp_path): ), ): assert main() == 0 + + def test_types_flag_valid(self, tmp_path): + f = tmp_path / "msg" + f.write_text("wip: add thing\n\nbody\n\nSigned-off-by: A User ") + argv = [ + "cg", + "--message-file", + str(f), + "--disable", + "signature", + "--types", + "wip,feat,fix", + ] + with patch("sys.argv", argv): + assert main() == 0 + + def test_types_flag_invalid(self, tmp_path): + f = tmp_path / "msg" + f.write_text("chore: add thing\n\nbody\n\nSigned-off-by: A User ") + argv = [ + "cg", + "--message-file", + str(f), + "--disable", + "signature", + "--types", + "feat,fix", + ] + with patch("sys.argv", argv): + assert main() == 1 + + def test_types_from_config(self, tmp_path): + f = tmp_path / "msg" + f.write_text("wip: add thing\n\nbody\n\nSigned-off-by: A User ") + argv = ["cg", "--message-file", str(f), "--disable", "signature"] + with ( + patch("sys.argv", argv), + patch( + "git_commit_guard._load_config", + return_value={"types": ["wip", "feat"]}, + ), + ): + assert main() == 0 + + def test_types_cli_overrides_config(self, tmp_path): + f = tmp_path / "msg" + f.write_text("wip: add thing\n\nbody\n\nSigned-off-by: A User ") + argv = [ + "cg", + "--message-file", + str(f), + "--disable", + "signature", + "--types", + "wip", + ] + with ( + patch("sys.argv", argv), + patch( + "git_commit_guard._load_config", + return_value={"types": ["deploy"]}, + ), + ): + assert main() == 0