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
25 changes: 20 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.

Expand All @@ -109,15 +122,16 @@ found.
disable = ["signature", "body"]
scopes = ["auth", "api", "db"]
require-scope = true
types = ["feat", "fix", "chore", "wip"]
```

```toml
# .commit-guard.toml
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

Expand Down Expand Up @@ -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.
Expand Down
33 changes: 30 additions & 3 deletions src/git_commit_guard/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -223,6 +230,7 @@ class Args:
enabled: frozenset
allowed_scopes: frozenset
require_scope: bool
allowed_types: frozenset


def _resolve_enabled(args, config, parser):
Expand All @@ -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(","))
Expand Down Expand Up @@ -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
Expand All @@ -316,6 +338,7 @@ def _parse_args():
enabled=enabled,
allowed_scopes=allowed_scopes,
require_scope=require_scope,
allowed_types=allowed_types,
)


Expand All @@ -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:
Expand Down
95 changes: 94 additions & 1 deletion tests/test_git_commit_guard.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -13,6 +14,7 @@
_parse_checks,
_parse_config_checks,
_report,
_resolve_types,
_strip_comments,
check_body,
check_imperative,
Expand Down Expand Up @@ -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_",
[
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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 <a@b.com>")
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 <a@b.com>")
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 <a@b.com>")
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 <a@b.com>")
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
Loading