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