From 62b78f1c21f2516d44fffe36054bb810276907d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nerijus=20Bend=C5=BEi=C5=ABnas?= Date: Fri, 24 Apr 2026 14:13:42 +0300 Subject: [PATCH] feat: add required custom trailers support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds --require-trailer CLI flag and require-trailers config key so projects can enforce arbitrary trailers (e.g. Closes:, Reviewed-by:) beyond the built-in Signed-off-by check. Matching is case-sensitive and requires a non-empty value after the colon. Signed-off-by: Nerijus Bendžiūnas --- README.md | 20 ++++ src/git_commit_guard/__init__.py | 25 +++++ tests/test_git_commit_guard.py | 165 +++++++++++++++++++++++++++++++ 3 files changed, 210 insertions(+) diff --git a/README.md b/README.md index 88766d4..5a702da 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,26 @@ commit-guard --require-scope commit-guard --scopes auth,api --require-scope ``` +### Required custom trailers + +Require arbitrary trailers to be present in the commit message. Multiple +trailers can be specified as a comma-separated list: + +```bash +commit-guard --require-trailer Closes +commit-guard --require-trailer "Closes,Reviewed-by" +``` + +In `.commit-guard.toml`: + +```toml +require-trailers = ["Closes", "Reviewed-by"] +``` + +Trailer matching is case-sensitive and requires at least one non-space +character after the colon (e.g. `Closes: #42`). This check runs +independently of `--enable`/`--disable`. + ### Configuration file Place `.commit-guard.toml` in your project root (or any parent directory) to diff --git a/src/git_commit_guard/__init__.py b/src/git_commit_guard/__init__.py index d254432..cb83565 100644 --- a/src/git_commit_guard/__init__.py +++ b/src/git_commit_guard/__init__.py @@ -196,6 +196,13 @@ def check_signed_off(message, result): result.error("missing 'Signed-off-by' trailer") +def check_required_trailers(message, required, result): + for trailer in required: + pattern = re.compile(rf"^{re.escape(trailer)}:\s+\S", re.MULTILINE) + if not pattern.search(message): + result.error(f"missing required trailer: {trailer}") + + def check_signature(rev, result): proc = subprocess.run( # noqa: S603 ["git", "verify-commit", rev], # noqa: S607 @@ -258,6 +265,7 @@ class Args: rev_range: str | None allow_empty: bool include_merges: bool + required_trailers: list def _resolve_enabled(args, config, parser): @@ -292,6 +300,14 @@ def _resolve_min_description_length(args, config): return 0 +def _resolve_required_trailers(args, config): + if args.require_trailer: + return [t.strip() for t in args.require_trailer.split(",")] + if config.get("require-trailers"): + return list(config["require-trailers"]) + return [] + + def _resolve_types(args, config): if args.types: return frozenset(t.strip() for t in args.types.split(",")) @@ -382,6 +398,11 @@ def _parse_args(): default=False, help="exit 0 when --range yields no commits (default: exit 1)", ) + parser.add_argument( + "--require-trailer", + metavar="TRAILER[,TRAILER,...]", + help="require these trailers in the commit message", + ) parser.add_argument( "--include-merges", action="store_true", @@ -395,6 +416,7 @@ def _parse_args(): allowed_types = _resolve_types(args, config) max_subject_length = _resolve_max_subject_length(args, config) min_description_length = _resolve_min_description_length(args, config) + required_trailers = _resolve_required_trailers(args, config) if args.allow_empty and not args.rev_range: parser.error("--allow-empty requires --range") @@ -431,6 +453,7 @@ def _parse_args(): rev_range=args.rev_range, allow_empty=args.allow_empty, include_merges=args.include_merges, + required_trailers=required_trailers, ) @@ -467,6 +490,8 @@ def _run_checks(args, rev, message, result): check_body(lines, result) if Check.SIGNED_OFF in args.enabled: check_signed_off(message, result) + if args.required_trailers: + check_required_trailers(message, args.required_trailers, result) if Check.SIGNATURE in args.enabled and rev: check_signature(rev, result) diff --git a/tests/test_git_commit_guard.py b/tests/test_git_commit_guard.py index cc3e85c..fb6e14e 100644 --- a/tests/test_git_commit_guard.py +++ b/tests/test_git_commit_guard.py @@ -18,10 +18,12 @@ _report, _resolve_max_subject_length, _resolve_min_description_length, + _resolve_required_trailers, _resolve_types, _strip_comments, check_body, check_imperative, + check_required_trailers, check_signature, check_signed_off, check_subject, @@ -273,6 +275,83 @@ def test_malformed_no_email(self): assert not r.ok +class TestCheckRequiredTrailers: + def test_present_passes(self): + r = Result() + check_required_trailers("fix: add x\n\nbody\n\nCloses: #42", ["Closes"], r) + assert r.ok + + def test_missing_fails(self): + r = Result() + check_required_trailers("fix: add x\n\nbody", ["Closes"], r) + assert not r.ok + assert "missing required trailer: Closes" in r.errors[0][1] + + def test_multiple_all_present_passes(self): + r = Result() + check_required_trailers( + "fix: add x\n\nbody\n\nCloses: #42\nReviewed-by: Jane", + ["Closes", "Reviewed-by"], + r, + ) + assert r.ok + + def test_multiple_one_missing_fails(self): + r = Result() + check_required_trailers( + "fix: add x\n\nbody\n\nCloses: #42", + ["Closes", "Reviewed-by"], + r, + ) + assert not r.ok + assert any("Reviewed-by" in msg for _, msg in r.errors) + + def test_case_sensitive(self): + r = Result() + check_required_trailers("fix: add x\n\nbody\n\ncloses: #42", ["Closes"], r) + assert not r.ok + + def test_empty_required_list_always_passes(self): + r = Result() + check_required_trailers("fix: add x", [], r) + assert r.ok + + +class TestResolveRequiredTrailers: + def test_defaults_to_empty(self): + assert _resolve_required_trailers(Namespace(require_trailer=None), {}) == [] + + def test_cli_flag_single(self): + result = _resolve_required_trailers(Namespace(require_trailer="Closes"), {}) + assert result == ["Closes"] + + def test_cli_flag_multiple(self): + result = _resolve_required_trailers( + Namespace(require_trailer="Closes,Reviewed-by"), {} + ) + assert result == ["Closes", "Reviewed-by"] + + def test_cli_flag_strips_spaces(self): + result = _resolve_required_trailers( + Namespace(require_trailer="Closes, Reviewed-by"), {} + ) + assert result == ["Closes", "Reviewed-by"] + + def test_config(self): + result = _resolve_required_trailers( + Namespace(require_trailer=None), + {"require-trailers": ["Closes", "Reviewed-by"]}, + ) + assert result == ["Closes", "Reviewed-by"] + + def test_cli_overrides_config(self): + result = _resolve_required_trailers( + Namespace(require_trailer="Fixes"), + {"require-trailers": ["Closes"]}, + ) + assert result == ["Fixes"] + + class TestCheckImperative: def test_imperative_verb_passes(self): r = Result() @@ -1103,3 +1182,89 @@ def test_invalid_range_exits(self): pytest.raises(SystemExit, match="git error"), ): _get_range_revs("bogus") + + +class TestRequireTrailerIntegration: + def test_require_trailer_flag_passes(self, tmp_path): + f = tmp_path / "msg" + f.write_text( + "fix: add thing\n\nbody\n\nCloses: #42\nSigned-off-by: A " + ) + argv = [ + "cg", + "--message-file", + str(f), + "--disable", + "signature,imperative", + "--require-trailer", + "Closes", + ] + with patch("sys.argv", argv): + assert main() == 0 + + def test_require_trailer_flag_fails(self, tmp_path): + f = tmp_path / "msg" + f.write_text("fix: add thing\n\nbody\n\nSigned-off-by: A ") + argv = [ + "cg", + "--message-file", + str(f), + "--disable", + "signature,imperative", + "--require-trailer", + "Closes", + ] + with patch("sys.argv", argv): + assert main() == 1 + + def test_require_trailer_multiple_passes(self, tmp_path): + f = tmp_path / "msg" + f.write_text( + "fix: add thing\n\nbody\n\n" + "Closes: #42\nReviewed-by: Jane\nSigned-off-by: A " + ) + argv = [ + "cg", + "--message-file", + str(f), + "--disable", + "signature,imperative", + "--require-trailer", + "Closes,Reviewed-by", + ] + with patch("sys.argv", argv): + assert main() == 0 + + def test_require_trailer_from_config(self, tmp_path): + f = tmp_path / "msg" + f.write_text("fix: add thing\n\nbody\n\nSigned-off-by: A ") + argv = ["cg", "--message-file", str(f), "--disable", "signature,imperative"] + with ( + patch("sys.argv", argv), + patch( + "git_commit_guard._load_config", + return_value={"require-trailers": ["Closes"]}, + ), + ): + assert main() == 1 + + def test_require_trailer_cli_overrides_config(self, tmp_path): + f = tmp_path / "msg" + f.write_text("fix: add thing\n\nbody\n\nFixes: #99\nSigned-off-by: A ") + argv = [ + "cg", + "--message-file", + str(f), + "--disable", + "signature,imperative", + "--require-trailer", + "Fixes", + ] + with ( + patch("sys.argv", argv), + patch( + "git_commit_guard._load_config", + return_value={"require-trailers": ["Closes"]}, + ), + ): + assert main() == 0