diff --git a/src/git_commit_guard/__init__.py b/src/git_commit_guard/__init__.py index 801a218..df7b7f2 100644 --- a/src/git_commit_guard/__init__.py +++ b/src/git_commit_guard/__init__.py @@ -151,6 +151,12 @@ def _download_if_missing(resource): nltk.download(resource.rsplit("/", maxsplit=1)[-1], quiet=True) +def _format_allowed_hint(allowed, kind): + if len(allowed) <= len(TYPES): + return f"(allowed: {', '.join(sorted(allowed))})" + return f"(see configured {kind})" + + def _strip_comments(message): return "\n".join( line for line in message.split("\n") if not line.lstrip().startswith("#") @@ -178,13 +184,16 @@ def check_subject( # noqa: PLR0913 Too many arguments in function definition (9 return None if m.group("type") not in allowed_types: - result.error(f"unknown type: {m.group('type')}", check=Check.SUBJECT) + bad_type = m.group("type") + hint = _format_allowed_hint(allowed_types, "types") + result.error(f"unknown type: {bad_type} {hint}", check=Check.SUBJECT) scope = m.group("scope") if require_scope and scope is None: result.error("scope is required", check=Check.SUBJECT) if allowed_scopes and scope is not None and scope not in allowed_scopes: - result.error(f"unknown scope: {scope}", check=Check.SUBJECT) + hint = _format_allowed_hint(allowed_scopes, "scopes") + result.error(f"unknown scope: {scope} {hint}", check=Check.SUBJECT) desc = m.group("desc") if require_lowercase and desc[0].isupper(): @@ -249,7 +258,10 @@ def check_body(lines, result): def check_signed_off(message, result): if not SIGNED_OFF_RE.search(message): - result.error("missing 'Signed-off-by' trailer", check=Check.SIGNED_OFF) + result.error( + "missing 'Signed-off-by' trailer — use 'git commit -s'", + check=Check.SIGNED_OFF, + ) def check_subject_pattern(subject, pattern, result): diff --git a/tests/test_git_commit_guard.py b/tests/test_git_commit_guard.py index f3d3517..d58046f 100644 --- a/tests/test_git_commit_guard.py +++ b/tests/test_git_commit_guard.py @@ -124,6 +124,25 @@ def test_unknown_type(self): check_subject("unknown: add thing", r) assert not r.ok + def test_unknown_type_with_default_lists_all_allowed(self): + r = Result() + check_subject("unknown: add thing", r) + assert any( + "allowed: " in m and "feat" in m and "fix" in m for _, _, m in r.errors + ) + + def test_unknown_type_with_smaller_set_lists_them(self): + r = Result() + check_subject("foo: add thing", r, allowed_types=frozenset({"feat", "fix"})) + assert any("allowed: feat, fix" in m for _, _, m in r.errors) + + def test_unknown_type_with_larger_than_default_points_at_config(self): + r = Result() + oversized = frozenset({f"t{i}" for i in range(20)}) + check_subject("foo: add thing", r, allowed_types=oversized) + assert any("see configured types" in m for _, _, m in r.errors) + assert not any("allowed:" in m for _, _, m in r.errors) + def test_uppercase_description(self): r = Result() check_subject("fix: Add token", r) @@ -184,6 +203,22 @@ def test_scope_not_in_allowlist_fails(self): check_subject("fix(api): add token", r, allowed_scopes=frozenset(["auth"])) assert not r.ok + def test_unknown_scope_with_small_set_lists_them(self): + r = Result() + check_subject( + "fix(api): add token", + r, + allowed_scopes=frozenset({"auth", "db"}), + ) + assert any("allowed: auth, db" in m for _, _, m in r.errors) + + def test_unknown_scope_with_larger_than_default_points_at_config(self): + r = Result() + oversized = frozenset({f"s{i}" for i in range(20)}) + check_subject("fix(foo): add token", r, allowed_scopes=oversized) + assert any("see configured scopes" in m for _, _, m in r.errors) + assert not any("allowed:" in m for _, _, m in r.errors) + def test_no_scope_with_allowlist_passes(self): r = Result() check_subject("fix: add token", r, allowed_scopes=frozenset(["auth"])) @@ -325,6 +360,11 @@ def test_missing(self): check_signed_off("fix: add thing\n\nbody", r) assert not r.ok + def test_missing_message_hints_at_git_commit_dash_s(self): + r = Result() + check_signed_off("fix: add thing\n\nbody", r) + assert any("git commit -s" in m for _, _, m in r.errors) + def test_malformed_no_email(self): r = Result() check_signed_off("fix: add thing\n\nSigned-off-by: John Doe", r)