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
22 changes: 21 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,25 @@ commit-guard --require-scope
commit-guard --scopes auth,api --require-scope
```

### Required subject pattern

Require the commit subject to match a regular expression. Useful for
enforcing ticket references or any custom naming convention:

```bash
commit-guard --require-subject-pattern "[A-Z]+-[0-9]+"
commit-guard --require-subject-pattern "#[0-9]+"
```

In `.commit-guard.toml`:

```toml
require-subject-pattern = "[A-Z]+-[0-9]+"
```

An invalid regex causes an immediate error at startup (exit 2). This
check runs independently of `--enable`/`--disable`.

### Required custom trailers

Require arbitrary trailers to be present in the commit message. Multiple
Expand All @@ -185,7 +204,7 @@ independently of `--enable`/`--disable`.
Place `.commit-guard.toml` in your project root (or any parent directory) to
set defaults for `enable`, `disable`, `scopes`, `require-scope`, `types`,
`max-subject-length`, `min-description-length`, `require-lowercase`,
`no-trailing-chars`, and `require-trailers`.
`no-trailing-chars`, `require-subject-pattern`, and `require-trailers`.
commit-guard searches upward from the working directory and uses the first
file found.

Expand Down Expand Up @@ -357,6 +376,7 @@ jobs:
disable: signed-off,signature
scopes: auth,api,db
require-scope: 'true'
require-subject-pattern: '[A-Z]+-[0-9]+'
require-trailer: 'Closes,Reviewed-by'
max-subject-length: '100'
min-description-length: '10'
Expand Down
6 changes: 6 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ inputs:
description: Include merge commits when checking a range
required: false
default: 'false'
require-subject-pattern:
description: Regex the subject line must match (e.g. '[A-Z]+-[0-9]+')
required: false
require-trailer:
description: Comma-separated list of required trailers (e.g. Closes,Reviewed-by)
required: false
Expand Down Expand Up @@ -86,6 +89,7 @@ runs:
CG_NO_TRAILING_CHARS: ${{ inputs.no-trailing-chars }}
CG_ALLOW_EMPTY: ${{ inputs.allow-empty }}
CG_INCLUDE_MERGES: ${{ inputs.include-merges }}
CG_REQUIRE_SUBJECT_PATTERN: ${{ inputs.require-subject-pattern }}
CG_REQUIRE_TRAILER: ${{ inputs.require-trailer }}
CG_OUTPUT_FILE: ${{ inputs.output-file }}
run: |
Expand All @@ -105,6 +109,8 @@ runs:
[[ -n "$CG_NO_TRAILING_CHARS" ]] && ARGS+=(--no-trailing-chars "$CG_NO_TRAILING_CHARS")
[[ "$CG_ALLOW_EMPTY" == "true" ]] && ARGS+=(--allow-empty)
[[ "$CG_INCLUDE_MERGES" == "true" ]] && ARGS+=(--include-merges)
[[ -n "$CG_REQUIRE_SUBJECT_PATTERN" ]] && \
ARGS+=(--require-subject-pattern "$CG_REQUIRE_SUBJECT_PATTERN")
[[ -n "$CG_REQUIRE_TRAILER" ]] && ARGS+=(--require-trailer "$CG_REQUIRE_TRAILER")
[[ -n "$CG_OUTPUT_FILE" ]] && ARGS+=(--output-file "$CG_OUTPUT_FILE")
commit-guard "${ARGS[@]}"
Expand Down
18 changes: 17 additions & 1 deletion docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -400,7 +400,23 @@ <h2>Configuration <a href="#configuration" class="anchor">#</a></h2>
min-description-length = 10
require-lowercase = false
no-trailing-chars = [".", "!"]
require-trailers = ["Closes", "Reviewed-by"]</code></pre>
require-trailers = ["Closes", "Reviewed-by"]
require-subject-pattern = "[A-Z]+-[0-9]+"</code></pre>

<h3>Required subject pattern</h3>
<p>
Require the commit subject to match a regular expression. Useful for
enforcing ticket references or any custom naming convention:
</p>
<pre><code class="language-bash">commit-guard --require-subject-pattern "[A-Z]+-[0-9]+"</code></pre>
<p>
In <code>.commit-guard.toml</code>:
</p>
<pre><code class="language-toml">require-subject-pattern = "[A-Z]+-[0-9]+"</code></pre>
<p>
An invalid regex causes an immediate error at startup (exit 2). This
check runs independently of <code>--enable</code>/<code>--disable</code>.
</p>

<h3>Required trailers</h3>
<p>
Expand Down
36 changes: 35 additions & 1 deletion src/git_commit_guard/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,14 @@ def check_signed_off(message, result):
result.error("missing 'Signed-off-by' trailer", check=Check.SIGNED_OFF)


def check_subject_pattern(subject, pattern, result):
if not pattern.search(subject):
result.error(
f"subject must match pattern '{pattern.pattern}'",
check=Check.SUBJECT,
)


def check_required_trailers(message, required, result):
for trailer in required:
pattern = re.compile(rf"^{re.escape(trailer)}:\s+\S", re.MULTILINE)
Expand Down Expand Up @@ -313,6 +321,7 @@ class Args:
allow_empty: bool
include_merges: bool
required_trailers: list
subject_pattern: re.Pattern | None
output: OutputFormat
output_file: Path | None

Expand Down Expand Up @@ -373,6 +382,12 @@ def _resolve_required_trailers(args, config):
return []


def _resolve_subject_pattern(args, config):
if args.require_subject_pattern is not None:
return args.require_subject_pattern
return config.get("require-subject-pattern")


def _resolve_types(args, config):
if args.types:
return frozenset(t.strip() for t in args.types.split(","))
Expand Down Expand Up @@ -406,7 +421,7 @@ def _parse_checks(parser, value):
parser.error(str(e))


def _parse_args():
def _parse_args(): # noqa: PLR0915 Too many statements (59 > 50)
checks_list = ",".join(sorted(Check))
parser = ArgumentParser(description="conventional commit checker")
parser.add_argument("rev", nargs="?", default=None)
Expand Down Expand Up @@ -476,6 +491,12 @@ def _parse_args():
default=False,
help="exit 0 when --range yields no commits (default: exit 1)",
)
parser.add_argument(
"--require-subject-pattern",
default=None,
metavar="REGEX",
help="require subject line to match this regular expression",
)
parser.add_argument(
"--require-trailer",
metavar="TRAILER[,TRAILER,...]",
Expand Down Expand Up @@ -509,6 +530,16 @@ def _parse_args():
require_lowercase = _resolve_require_lowercase(args, config)
no_trailing_chars = _resolve_no_trailing_chars(args, config)
required_trailers = _resolve_required_trailers(args, config)
subject_pattern_str = _resolve_subject_pattern(args, config)
if subject_pattern_str is not None:
try:
subject_pattern = re.compile(subject_pattern_str)
except re.error as e:
parser.error(
f"invalid regex for --require-subject-pattern {subject_pattern_str!r}: {e}" # noqa: E501 Line too long
)
else:
subject_pattern = None

if args.allow_empty and not args.rev_range:
parser.error("--allow-empty requires --range")
Expand Down Expand Up @@ -548,6 +579,7 @@ def _parse_args():
allow_empty=args.allow_empty,
include_merges=args.include_merges,
required_trailers=required_trailers,
subject_pattern=subject_pattern,
output=OutputFormat(args.output),
output_file=args.output_file,
)
Expand Down Expand Up @@ -613,6 +645,8 @@ def _run_checks(args, rev, message, result):
check_signed_off(message, result)
if args.required_trailers:
check_required_trailers(message, args.required_trailers, result)
if args.subject_pattern:
check_subject_pattern(lines[0], args.subject_pattern, result)
if Check.SIGNATURE in args.enabled and rev:
check_signature(rev, result)

Expand Down
115 changes: 115 additions & 0 deletions tests/test_git_commit_guard.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import json
import re
import subprocess
from argparse import ArgumentParser, Namespace
from unittest.mock import MagicMock, patch
Expand All @@ -25,6 +26,7 @@
_resolve_no_trailing_chars,
_resolve_require_lowercase,
_resolve_required_trailers,
_resolve_subject_pattern,
_resolve_types,
_strip_comments,
check_body,
Expand All @@ -33,6 +35,7 @@
check_signature,
check_signed_off,
check_subject,
check_subject_pattern,
main,
)

Expand Down Expand Up @@ -394,6 +397,55 @@ def test_cli_overrides_config(self):
assert result == ["Fixes"]


class TestCheckSubjectPattern:
def test_matching_subject_passes(self):
r = Result()
check_subject_pattern("feat: add PROJ-123 login", re.compile(r"[A-Z]+-\d+"), r)
assert r.ok

def test_non_matching_subject_fails(self):
r = Result()
check_subject_pattern(
"feat: implement OAuth login flow", re.compile(r"[A-Z]+-\d+"), r
)
assert not r.ok
assert "must match pattern" in r.errors[0][2]
assert "[A-Z]+-\\d+" in r.errors[0][2]

def test_error_includes_pattern(self):
r = Result()
check_subject_pattern("fix: oops", re.compile(r"#\d+"), r)
assert "#\\d+" in r.errors[0][2]


class TestResolveSubjectPattern:
def test_defaults_to_none(self):
assert (
_resolve_subject_pattern(Namespace(require_subject_pattern=None), {})
is None
)

def test_cli_flag(self):
result = _resolve_subject_pattern(
Namespace(require_subject_pattern="[A-Z]+-\\d+"), {}
)
assert result == "[A-Z]+-\\d+"

def test_config(self):
result = _resolve_subject_pattern(
Namespace(require_subject_pattern=None),
{"require-subject-pattern": "#\\d+"},
)
assert result == "#\\d+"

def test_cli_overrides_config(self):
result = _resolve_subject_pattern(
Namespace(require_subject_pattern="[A-Z]+-\\d+"),
{"require-subject-pattern": "#\\d+"},
)
assert result == "[A-Z]+-\\d+"


class TestCheckImperative:
def test_imperative_verb_passes(self):
r = Result()
Expand Down Expand Up @@ -1438,6 +1490,69 @@ def test_require_trailer_cli_overrides_config(self, tmp_path):
assert main() == 0


class TestRequireSubjectPatternIntegration:
def test_matching_pattern_passes(self, tmp_path):
f = tmp_path / "msg"
f.write_text(
"fix: resolve PROJ-42 auth timeout\n\nbody\n\nSigned-off-by: A <a@b.com>"
)
argv = [
"cg",
"--message-file",
str(f),
"--disable",
"signature,imperative",
"--require-subject-pattern",
"[A-Z]+-[0-9]+",
]
with patch("sys.argv", argv):
assert main() == 0

def test_non_matching_pattern_fails(self, tmp_path):
f = tmp_path / "msg"
f.write_text("fix: resolve auth timeout\n\nbody\n\nSigned-off-by: A <a@b.com>")
argv = [
"cg",
"--message-file",
str(f),
"--disable",
"signature,imperative",
"--require-subject-pattern",
"[A-Z]+-[0-9]+",
]
with patch("sys.argv", argv):
assert main() == 1

def test_invalid_regex_exits(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-subject-pattern",
"[unclosed",
]
with patch("sys.argv", argv), pytest.raises(SystemExit) as exc:
main()
assert exc.value.code == 2

def test_pattern_from_config(self, tmp_path):
f = tmp_path / "msg"
f.write_text("fix: resolve auth timeout\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-subject-pattern": "[A-Z]+-[0-9]+"},
),
):
assert main() == 1


class TestOutputJsonl:
def test_single_commit_ok(self, tmp_path, capsys):

Expand Down
Loading