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
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions src/git_commit_guard/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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(","))
Expand Down Expand Up @@ -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",
Expand All @@ -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")
Expand Down Expand Up @@ -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,
)


Expand Down Expand Up @@ -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)

Expand Down
165 changes: 165 additions & 0 deletions tests/test_git_commit_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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 <a@b.com>"
)
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 <a@b.com>")
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 <a@b.com>"
)
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 <a@b.com>")
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 <a@b.com>")
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
Loading