commit-guard
Conventional commit linting with imperative mood detection.
$ commit-guard
✗ [subject] subject does not match 'type(scope): description': WIP
✗ [signed-off] missing 'Signed-off-by' trailer — use 'git commit -s'
✗ [signature] commit is not signed (GPG/SSH)
Why commit-guard? #
- 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.
Install #
uv tool install git-commit-guard
pipx install git-commit-guard
pip install git-commit-guard
Or via pre-commit.
Quick start #
# check HEAD
$ commit-guard
# check a specific commit
$ commit-guard abc1234
# check all commits in a PR range
$ commit-guard --range origin/main..HEAD
# read from a file (for git hooks)
$ commit-guard --message-file .git/COMMIT_EDITMSG
# pipe message via stdin
$ echo "fix(auth): add token refresh" | commit-guard
Checks
All checks run by default. Enable or disable individually with
--enable / --disable:
| Check | What it verifies |
|---|---|
subject |
Conventional Commits
format: valid type, lowercase start, no trailing . ! ? or space,
max length (default 72). All limits are configurable. Use
! before the colon for breaking changes:
feat!: remove endpoint
|
imperative |
First word is an imperative verb — uses NLP, not just a regex |
body |
Blank line separates subject from body, and body is non-empty |
signed-off |
Signed-off-by: trailer is present |
signature |
GPG or SSH signature is valid — verified via GitHub Commits API or public key lookup |
Configuration #
Place .commit-guard.toml in your project root or any
parent directory — commit-guard searches upward and uses the first
file found. CLI flags always take precedence.
# .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"]
require-subject-pattern = "[A-Z]+-[0-9]+"
Required subject pattern
Require 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]+"
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.
Required trailers
Require arbitrary trailers in the commit message. Accepts a
comma-separated list; matching is case-sensitive and requires a
non-empty value after the colon (e.g. Closes: #42):
commit-guard --require-trailer "Closes,Reviewed-by"
Signature verification
The signature check verifies commits without requiring a
pre-configured local keyring:
- If the repo has a GitHub remote, call the Commits API
(
GET /repos/{owner}/{repo}/commits/{sha}) to resolve the author's GitHub username — 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.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}.gpgandgithub.com/{username}.keys. - Try GPG verification using a temporary keyring.
- Try SSH verification using a temporary
allowed_signersfile. - Pass if any key verifies; fail if none do.
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. Disable the
signature check if GitHub API access is unavailable:
commit-guard --disable signature
Range options
When using --range, merge commits are excluded by
default. Use --include-merges to check them. An empty
range exits non-zero by default — use --allow-empty to
exit 0 instead:
commit-guard --range origin/main..HEAD --include-merges --allow-empty
Environment variables
| 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 |
Output #
Use --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 jsonl | jq 'select(.ok == false)'
Each 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.
Use --output-file FILE to write JSONL to a file while
keeping human-readable text on stdout — useful in CI where you want
readable logs and structured results for downstream steps:
commit-guard --range origin/main..HEAD --output-file results.jsonl
GitHub Actions #
Check 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.21.0
with:
range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}
disable: signed-off,signature
Check a specific commit SHA:
- uses: benner/commit-guard@v0.21.0
with:
rev: ${{ github.sha }}
All inputs mirror the CLI flags: rev,
range, enable, disable,
scopes, require-scope, types,
max-subject-length, min-description-length,
no-require-lowercase, no-trailing-chars,
require-trailer,
allow-empty, include-merges,
output-file.
When output-file is set the action exposes the path as
a step output, making JSONL results available to subsequent steps:
- uses: benner/commit-guard@v0.21.0
id: cg
with:
range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}
output-file: results.jsonl
- run: jq 'select(.ok == false)' "${{ steps.cg.outputs.output-file }}"
pre-commit #
Add to .pre-commit-config.yaml:
repos:
- repo: https://github.com/benner/commit-guard
rev: v0.21.0
hooks:
- id: commit-guard
- id: commit-guard-signature