Opinionated conventional commit message linter with imperative mood detection.
- NLP imperative detection. Descriptions must start with an imperative verb, verified via nltk POS tagging — not a hand-coded regex of "bad" words.
- Signature verification without a local keyring. Resolves the commit
author via the GitHub API and verifies GPG/SSH against their published
.gpg/.keys— no per-runner key management. - Strict by default. Subject format, body, trailers,
Signed-off-by, and signature all enforced out of the box; opt out with--disable.
$ commit-guard
✗ [subject] subject does not match 'type(scope): description': WIP
✗ [signed-off] missing 'Signed-off-by' trailer — use 'git commit -s'
✗ [signature] signature could not be verified — commit may be
unsigned, or signed with a key not uploaded as a
Signing key on https://github.com/settings/keysFrom PyPI:
uv tool install git-commit-guardor:
pipx install git-commit-guardFrom a local clone:
uv tool install -e .During development:
uv run commit-guard# check HEAD
commit-guard
# check specific commit
commit-guard abc1234
# check commit message file (for git hooks)
commit-guard --message-file .git/COMMIT_EDITMSG
# pipe message via stdin
echo "fix(auth): add token refresh" | commit-guardAll checks run by default. Use --enable or --disable with
comma-separated values:
# only check subject format and imperative mood
commit-guard --enable subject,imperative
# skip body and signature checks
commit-guard --disable body,signed-off,signatureAvailable checks:
subject- Format matchestype(scope): description, valid type, lowercase start, no trailing.!?or space, max 72 charsimperative- First word is an imperative verb (for exampleaddnotadded)body- Blank line separates subject from body, and body is non-emptysigned-off-Signed-off-by:trailer existssignature- Verify GPG or SSH signature via the GitHub Commits API or public key lookup
The default maximum subject line length is 72 characters. Override with
--max-subject-length:
commit-guard --max-subject-length 100By default there is no minimum description length. Enforce one with
--min-description-length:
commit-guard --min-description-length 10By default the description must start with a lowercase letter. To allow uppercase descriptions:
commit-guard --no-require-lowercaseIn .commit-guard.toml:
require-lowercase = falseBy default ., !, ?, and space are forbidden as trailing characters.
To change the set (any character is valid):
commit-guard --no-trailing-chars ".,"
commit-guard --no-trailing-chars ".,!"In .commit-guard.toml:
no-trailing-chars = [".", "!"]Pass an empty list to disable the check entirely:
no-trailing-chars = []By default the standard conventional commit types are accepted. Use --types
to replace the allowed set entirely:
# restrict to a subset
commit-guard --types feat,fix,chore
# add a project-specific type
commit-guard --types feat,fix,docs,style,refactor,perf,test,build,ci,chore,revert,wipBy default any scope is accepted and scope is optional. Use --scopes to
restrict allowed values and --require-scope to enforce that a scope is always
present:
# only allow known scopes
commit-guard --scopes auth,api,db
# require a scope
commit-guard --require-scope
# combine both
commit-guard --scopes auth,api --require-scopeRequire the commit subject to match a regular expression. Useful for enforcing ticket references or any custom naming convention:
commit-guard --require-subject-pattern "[A-Z]+-[0-9]+"
commit-guard --require-subject-pattern "#[0-9]+"In .commit-guard.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.
Require arbitrary trailers to be present in the commit message. Multiple trailers can be specified as a comma-separated list:
commit-guard --require-trailer Closes
commit-guard --require-trailer "Closes,Reviewed-by"In .commit-guard.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.
The signature check verifies the commit without any local keyring setup:
- If the repo has a GitHub remote, call the Commits API
(
GET /repos/{owner}/{repo}/commits/{sha}) to resolve the author's GitHub username — this works for corporate emails, noreply addresses, or any email not listed publicly on a GitHub profile. - If the Commits API is unavailable (no GitHub remote, commit not yet pushed,
or API error), parse the username directly from a GitHub noreply address
(
{id}+{username}@users.noreply.github.comor{username}@users.noreply.github.com) — no API call needed. - If neither of the above resolves a username, fall back to searching GitHub by the commit author's email.
- Fetch the resolved user's public keys from
github.com/{username}.gpg(GPG) and the/users/{username}/ssh_signing_keysAPI (SSH keys tagged with the Signing key role). Auth-only SSH keys are deliberately not accepted — this mirrors GitHub's "Verified" badge semantics. - Try GPG verification: import the fetched key into a temporary keyring and
run
git verify-commit. - Try SSH verification: write a temporary
allowed_signersfile and rungit verify-commitwith the SSH allowed-signers config. - If any key verifies, the check passes. If none do, it fails.
If the author cannot be resolved via either method, or the GitHub API is unreachable, the check fails with a clear error.
For private repositories, set GITHUB_TOKEN or GH_TOKEN so the Commits API
can authenticate. The official GitHub Action wires the workflow's automatic
token via the github-token input, so no manual env: is required; override
with a PAT only for cross-repo lookups.
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, require-subject-pattern, and require-trailers.
commit-guard searches upward from the working directory and uses the first
file found.
# .commit-guard.toml
disable = ["signature", "body"]
scopes = ["auth", "api", "db"]
require-scope = true
types = ["feat", "fix", "chore", "wip"]
max-subject-length = 100
min-description-length = 10
require-lowercase = false
no-trailing-chars = [".", "!"]
require-trailers = ["Closes", "Reviewed-by"]# .commit-guard.toml
enable = ["subject", "imperative"]CLI flags (--enable, --disable, --scopes, --require-scope, --types,
--max-subject-length, --min-description-length, --no-require-lowercase,
--no-trailing-chars, --require-trailer) take
full precedence and ignore config file values when provided.
| Variable | Default | Description |
|---|---|---|
COMMIT_GUARD_GIT_TIMEOUT |
10 |
Timeout in seconds for git subprocess calls. |
GITHUB_TOKEN |
— | GitHub token for Commits API access on private repos (signature check). |
GH_TOKEN |
— | Alias for GITHUB_TOKEN; used when GITHUB_TOKEN is not set. |
COMMIT_GUARD_GIT_TIMEOUT=30 commit-guard --range origin/main..HEADIn GitHub Actions, set it at the step or job level:
- uses: benner/commit-guard@v0.22.1
env:
COMMIT_GUARD_GIT_TIMEOUT: 30
with:
range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}Use --range to check all commits in a revision range. All commits are
checked and a single non-zero exit code is returned if any fail:
# check all commits in a PR
commit-guard --range origin/main..HEAD
# check between two tags
commit-guard --range v1.0..v2.0
# only subject checks on a range
commit-guard --range origin/main..HEAD --enable subject,imperativeMerge commits are excluded by default. Use --include-merges to check them:
commit-guard --range origin/main..HEAD --include-mergesAn empty range (no commits) exits non-zero by default — this catches
misconfigured range specs in CI. Use --allow-empty to exit 0 instead:
commit-guard --range origin/main..HEAD --allow-emptyUse --output jsonl to emit one JSON line per commit to stdout instead of the
default human-readable text:
commit-guard --range origin/main..HEAD --output jsonlEach line is a JSON object:
{
"sha": "abc1234...",
"subject": "feat: add thing",
"ok": false,
"results": [{"check": "body", "level": "error", "message": "missing body"}]
}sha is null when reading from a file or stdin. results is empty when all
checks pass. Pipe to jq for filtering:
commit-guard --range origin/main..HEAD --output jsonl | jq 'select(.ok == false)'Use --output-file FILE to write JSONL to a file while keeping human-readable
text on stdout:
commit-guard --range origin/main..HEAD --output-file results.jsonl--output-file is independent of --output: combining both writes JSONL to
both stdout and the file.
In GitHub Actions, output-file is the recommended way to get machine-readable
results — text stays in the CI log and the file is accessible to subsequent steps
via steps.<id>.outputs.output-file.
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: benner/commit-guard@v0.22.1Check all commits in a pull request:
jobs:
lint-commits:
runs-on: ubuntu-latest
env:
PR_BASE: ${{ github.event.pull_request.base.sha }}
PR_HEAD: ${{ github.event.pull_request.head.sha }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: benner/commit-guard@v0.22.1
with:
range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}Check a specific commit SHA (mirrors the positional CLI argument):
- uses: benner/commit-guard@v0.22.1
with:
rev: ${{ github.sha }}All inputs are optional and mirror the CLI flags:
jobs:
lint-commits:
runs-on: ubuntu-latest
env:
PR_BASE: ${{ github.event.pull_request.base.sha }}
PR_HEAD: ${{ github.event.pull_request.head.sha }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: benner/commit-guard@v0.22.1
with:
range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}
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'
no-require-lowercase: 'true'
no-trailing-chars: '.,!'
allow-empty: 'true'
include-merges: 'true'
output-file: results.jsonlWhen output-file is set the action exposes the path as an output:
- uses: benner/commit-guard@v0.22.1
id: cg
with:
range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}
output-file: results.jsonl
- run: jq 'select(.ok == false)' "${{ steps.cg.outputs.output-file }}"Add to your .pre-commit-config.yaml:
---
repos:
- repo: https://github.com/benner/commit-guard
rev: v0.22.1
hooks:
- id: commit-guard
- id: commit-guard-signatureInstall the hooks:
pre-commit install --hook-type commit-msg --hook-type post-commitcommit-guard runs at the commit-msg stage and checks message format.
commit-guard-signature runs at the post-commit stage and verifies
the GPG/SSH signature after the commit object is created.
To selectively enable or disable checks, pass args:
- id: commit-guard
args: ["--enable", "subject,imperative"]commit-guard combines three strategies to detect non-imperative descriptions:
- nltk POS tagging — flags words tagged as past tense (
VBD), gerund (VBG), third person (VBZ), etc. - WordNet morphology as a fallback for words the tagger misclassifies.
- Hyphenated verb prefixes — accepts
re-enable,auto-detect,pre-process,co-locate,under-mineand similar<prefix>-<verb>compounds the POS tagger misclassifies.
This catches common mistakes like added logging or fixes bug while
keeping false positives low.
type(scope): description
body
trailers
Default types: feat, fix, docs, style, refactor, perf, test,
build, ci, chore, revert. Override with --types or the types config
key.
Scope is optional. Mark breaking changes with ! before
the colon.
GPLv2