From b0ec658404ddac2e97b99ab6878d241ec8001da6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nerijus=20Bend=C5=BEi=C5=ABnas?= Date: Tue, 5 May 2026 22:50:03 +0300 Subject: [PATCH 1/3] feat(signed-off): hint at 'git commit -s' in error message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Most users hitting this don't know about -s. Signed-off-by: Nerijus Bendžiūnas --- src/git_commit_guard/__init__.py | 5 ++++- tests/test_git_commit_guard.py | 5 +++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/git_commit_guard/__init__.py b/src/git_commit_guard/__init__.py index 801a218..3b3b9ab 100644 --- a/src/git_commit_guard/__init__.py +++ b/src/git_commit_guard/__init__.py @@ -249,7 +249,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..d496fe7 100644 --- a/tests/test_git_commit_guard.py +++ b/tests/test_git_commit_guard.py @@ -325,6 +325,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) From 0f28b78acb8a70ba225136238e426f1d5e0c049c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nerijus=20Bend=C5=BEi=C5=ABnas?= Date: Tue, 5 May 2026 22:54:18 +0300 Subject: [PATCH 2/3] feat(subject): list allowed types in unknown-type error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Append the allowed list inline when the configured set is at most len(TYPES) (= 11, the default count) so users don't have to re-read docs to see what's valid; fall back to "see configured types" when the list is bigger than the default. Signed-off-by: Nerijus Bendžiūnas --- src/git_commit_guard/__init__.py | 10 +++++++++- tests/test_git_commit_guard.py | 19 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/git_commit_guard/__init__.py b/src/git_commit_guard/__init__.py index 3b3b9ab..12c409c 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,7 +184,9 @@ 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: diff --git a/tests/test_git_commit_guard.py b/tests/test_git_commit_guard.py index d496fe7..7bdde41 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) From 0db7f9d70f4258a3ca05f056ce0f1c105e7e5aa1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nerijus=20Bend=C5=BEi=C5=ABnas?= Date: Tue, 5 May 2026 22:55:22 +0300 Subject: [PATCH 3/3] feat(subject): list allowed scopes in unknown-scope error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same threshold as types — list inline when configured set is at most len(TYPES), else point at configuration. Reuses _format_allowed_hint. Signed-off-by: Nerijus Bendžiūnas --- src/git_commit_guard/__init__.py | 3 ++- tests/test_git_commit_guard.py | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/git_commit_guard/__init__.py b/src/git_commit_guard/__init__.py index 12c409c..df7b7f2 100644 --- a/src/git_commit_guard/__init__.py +++ b/src/git_commit_guard/__init__.py @@ -192,7 +192,8 @@ def check_subject( # noqa: PLR0913 Too many arguments in function definition (9 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(): diff --git a/tests/test_git_commit_guard.py b/tests/test_git_commit_guard.py index 7bdde41..d58046f 100644 --- a/tests/test_git_commit_guard.py +++ b/tests/test_git_commit_guard.py @@ -203,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"]))