Commit Check
Commit policy as code for teams and AI agents: commit message, branch naming, author and sign-off standards in one config file, enforced in hooks, CI and pull requests.
November 5, 2026
Open source · MIT licensed
Catch bad commits before they merge.
Commit policy as code for teams and AI agents. Commit Check validates commit messages, branch names, authors and sign-offs against one cchk.toml — in your commit hook, in CI, on every pull request and inside your AI agent. When the fix is obvious, it hands you the line.
pip install commit-check- MIT licensed, no account needed
- Python 3.10 – 3.15 on Linux, macOS and Windows
- Signed build provenance on every release
- CLI and hook work with any Git host
Where it runs
One config file. Five places to enforce it.
Pick the entry point that matches how your team works. Every one reads the same cchk.toml and reports the same rule IDs — start with one, add the rest later.
-
Any forge, any CI
Command line
The engine itself, with JSON output and a Python API for scripts.
pip install commit-checkGetting started → -
Fastest feedback
pre-commit hook
Rejects a bad message on the laptop, before Git records it.
- id: check-messageHook guide → -
Fastest to set up
Zero YAML
GitHub App
Install once. Every push and pull request gets a check run, no CI minutes.
apps/commit-checkApp guide → -
Enforced in CI
GitHub Action
A required check no one can skip, with a PR comment and a job summary.
commit-check-action@v2Action guide → -
For AI agents
MCP server
Your coding agent checks its own commit before it writes it.
uvx commit-check-mcpMCP guide →
commit-check-action
Every pull request, checked and explained.
The Action reports where your reviewers already look — a PR comment, the job summary, inline annotations — and exposes a JSON result the next step can gate on.
One report on the pull request, edited in place on every push.Turn it on with pr-comments: true
feat: add login page #128Open
alex force-pushed feature/add-login, rewording 2 commits
The same comment was updated. No new comment, no noise in the thread.
The full breakdown lands in the workflow run: each check, the value, the fix.Turn it on with job-summary: true
commit-check summary
Commit Check
2 of 5 checks failed
▾ Show all 5 checks
Squash merges turn the PR title into the commit, so the title is checked too.Turn it on with pr-title: true
add login page #128Open
feat: add login pageSave
feat: add login page #128Open
Checks
Commit Check / commit-check (pull_request)CC001 message · PR title (add login page)Re-running on the edited title…✔ PR title (feat: add login page)
Squash and merge creates this commit on main:
feat: add login page (#128)
Every commit in the pull request, checked against Conventional Commits.On by default: message: true
feat: add login page #128Open
Commit message
d87faca5584f4637d6defAnnotations 2 errors
CC001 message
Commit 2/3 (5584f46): The commit message should follow Conventional Commits.
value: bad msg
Suggest: Use <type>(<scope>): <description>, where <type> is one of: feat, fix, docs, …
CC001 message
Commit 3/3 (37d6def): The commit message should follow Conventional Commits.
value: Fix: handle empty password
Fix: fix: handle empty password
Branch names checked against Conventional Branch, with the rename to run.On by default: branch: true
feat: add login page #131Open
BranchFeature/Add-Login
CC201 The branch should follow Conventional Branch.
value: Feature/Add-Login
Fix: feature/Add-Login
Conventional Branch types
- feature/
- bugfix/
- hotfix/
- release/
- chore/
main and master always pass. Add your own types in cchk.toml.
Make it a required check
Branch protection does the rest: a violating commit cannot merge, however it was made.
Verified before it installs
The Action checks the wheel's signed build provenance and fails the step if it does not match.
Try it without blocking anyone
dry-run: true reports every finding as a warning and always exits 0.
What it checks
Rules grouped by what they protect.
A few are on by default. Turn on what your project needs, rule by rule, or set any rule to warn instead of fail.
-
Commit messages
CC001–CC012Conventional Commits, subject length, imperative mood, sign-off, and merge, revert, fixup or WIP commits.
Rejected: Fix: add streaming support
Accepted: fix: add streaming support
-
Branch names
CC201–CC202Conventional Branch naming, and whether the branch is rebased onto its target.
Rejected: my-new-thing
Accepted: feature/streaming-support
-
AI attribution
CC013–CC016Spots the trailers Claude Code, Cursor and others add. Forbid them, or require a disclosure instead.
Rejected: Co-authored-by: Claude
Accepted: Assisted-by: Claude
-
Author identity
CC101–CC102Names and emails that match the patterns you set — here
author_email_pattern = "@acme\.dev$"— so a build box cannot write itself into the history.Rejected: ec2-user <root@ip-10-0-0-12>
Accepted: Jane Doe <jane@acme.dev>
-
Pushes and files
CC301–CC304No force-pushes, no oversized files, no forbidden paths — caught at pre-push, before they leave the laptop.
Rejected: git push --force origin main
Rejected: assets/model.bin, 240 MB
-
Across an organization
One shared policy for every repository. Each repo inherits it and overrides only what differs.
inherit_from = "github:acme/.github:cchk.toml"
Organization guide →
Every finding
Not just “failed”. What failed, and the fix.
- A stable rule ID that links to its documentation.
- The exact value that was checked.
- Why it failed, in one sentence.
- A fix you can paste, whenever the correction is unambiguous.
$ echo "Fix: add streaming support" | commit-check -m
1CC001 message check failed ==>
2Fix: add streaming support
3The commit message should follow Conventional Commits. See https://www.conventionalcommits.org
4Suggest: Use "fix: add streaming support"
Docs: https://commit-check.com/rules/#cc001
- 1.6M+downloads on PyPI
- 160+repositories run the Action
- Apacheallowlists the Action for its projects' CI
In the CI ofApacheFreeBSDTexas InstrumentsMilaIstio EcosystemOpenDriveLaband more →
Pricing
Free for open source. Team plan, soon.
The CLI, the hook, the Action and the MCP server are MIT licensed — nothing to buy. Plans only cover the hosted GitHub App, the one surface we run for you.
Open Source
Free
Public repositories, on any account.
Install the App →Personal
Free
Private repositories on a personal account.
Install the App →Team
Coming soon
Private repositories in an organization. Until it launches, the App checks them at no charge.
How the plan will work →
Nothing is blocked while you try it: without a config file the App reports its findings but leaves the check run neutral. GitHub's own commit-metadata rules sit behind its Enterprise plan — the arithmetic, and the caveats →
Questions¶
Does it read my source code?
No. The CLI validates commit metadata and never opens your files. The hosted App uses a blob-filtered fetch and a sparse checkout that materializes only the config files, so no other repository content is ever downloaded. Content scanning is deliberately out of scope.
Can a developer bypass it?
The pre-commit hook, yes: git commit --no-verify is one flag, and a local
hook is there for fast feedback. The enforcement boundary is CI. Make the Action or the App a required status check and a
violating change cannot merge, however it was committed.
Will turning it on block everyone tomorrow?
No. Without a config file the App reports in full but leaves the check neutral, and it never rejects a push. Most rules are off until you turn them on — the rules reference marks which start on.
What about the history I already have?
Only new commits are checked. Nothing asks you to rewrite what is already merged.
Does it only work on GitHub?
The CLI and the pre-commit hook run anywhere Git does — GitLab, Gitea, Bitbucket, a local machine. The Action and the App are GitHub-specific because they integrate with GitHub's check runs.
Do I need Node.js?
No. On a modern Python there are no runtime dependencies at all.
Which Python versions are supported?
3.10 through 3.15. On every change, CI builds the wheel and installs it on all six, across Linux, macOS and Windows — eighteen combinations.
How do I know the package I installed is the one you built?
Every release carries a signed
build provenance attestation
naming the workflow in this repository that built it. Check a wheel
yourself with gh attestation verify <file> --repo commit-check/commit-check;
the GitHub Action runs the same check before it installs anything, and
fails the step if verification does not pass.
Who is behind this?
Commit Check is written and maintained by Xianpeng Shen, who also runs the hosted App. The engine, the Action, the App and the MCP server are open source under the commit-check organization — if the hosted App ever stops, the GitHub Action reads the same config and reports the same rule IDs.
Start with two commands.
No config file needed. The defaults check Conventional Commits, Conventional Branch and subject length; tighten them when you are ready.
$ pip install commit-check
$ commit-check --message --branch
Getting started¶
By the end of this page you will have Commit Check installed, rejecting a bad commit message on your machine, and wired into the two places it belongs.
It takes about five minutes and needs nothing but a Git repository.
On GitHub? The App needs no local setup
Install the GitHub App on the repositories you want checked: every push to a branch and every pull request in them gets a Commit Check result, with no workflow file. The App guide covers what it checks. This page sets up the CLI and the hook, which work with any Git host.
1. Install¶
Verify the install:
The CLI is also available as cchk, which is the same program under a shorter
name.
Supported Python versions
Commit Check supports Python 3.10 through 3.15, on Linux, macOS and Windows.
Running it as a pre-commit hook or a GitHub Action needs no installation step at all — both fetch it for you.
2. Watch it reject something¶
Commit Check works with no configuration at all. Make a deliberately bad commit in a scratch repository:
Now check it:
CC001 message check failed ==> updated the parser
The commit message should follow Conventional Commits. See https://www.conventionalcommits.org
Suggest: Use <type>(<scope>): <description>, where <type> is one of: feat, fix, docs, ...
Docs: https://commit-check.com/rules/#cc001
Four things are happening in that output, and each is deliberate:
| Part | What it gives you |
|---|---|
CC001 |
A stable rule ID. It will mean the same thing in five years. |
==> updated the parser |
The exact value that failed, not just "invalid message". |
Suggest: |
What to do about it. |
Docs: |
Why the rule exists, and how to configure or disable it. |
3. Fix it¶
It prints nothing and exits with code 0.
4. Check the branch too¶
CC201 branch check failed ==> my-changes
The branch should follow Conventional Branch. See https://conventionalbranch.org
Suggest: Use <type>/<description> with an allowed type, or add the branch to allow_branch_names in config
Docs: https://commit-check.com/rules/#cc201
Rename it to something structured and it passes:
Checks are opt-in per run
commit-check --message never evaluates branch rules, and vice versa. Each
check is selected by its own flag, so you can run exactly what a given hook
or CI job needs. The rules reference lists which flag activates
each rule.
5. Write down your policy¶
So far you have been running the defaults. Create a cchk.toml in the
repository root — or in .github/ — to make the policy explicit:
- Off by default. Turning it on rejects
fixed a bugin favour offix a bug. allow_*options describe what is permitted. Set tofalseto enforce.
Run it again and the new rules apply:
Defaults are not "nothing"
Even with no config file, Conventional Commits, Conventional Branch, subject length limits of 5–80 characters, and author name/email patterns are enforced. Check the Default column in the rules reference before assuming a rule is off.
6. Make it automatic¶
Running the command by hand does not scale. Wire it into the two places it belongs:
-
{ .lg .middle } Before the commit lands
A pre-commit hook rejects the message as you write it, so nothing bad reaches the branch in the first place.
-
{ .lg .middle } On every pull request
A GitHub Action checks every commit in the PR and can comment on the PR with what needs fixing.
Verifying the download¶
Every release is published with a signed build provenance attestation. To verify a release artifact came from this repository's build pipeline:
Show that you use it¶
Put the badge in your README, so contributors know their commits will be checked before they open a pull request:
Where to go next¶
- Where to run it — pre-commit hook, GitHub Action, GitHub App: which to use, and how they combine.
- Rules reference — every rule, what it does, why it matters, and how to configure it.
- Configuration — every option, its type and default, plus the environment variable and CLI flag that override it.
- Command-line recipes — checking a range, wiring up CI, reading the JSON output.
- Troubleshooting — a check failing that you did not turn on, nothing running at all, and how to get a commit through when you have to.
Where to run it¶
Commit Check is one rule engine with several places to run it, and every one
of them reads the same cchk.toml, so the rules cannot drift between what a
developer sees locally and what is enforced on the pull request.
| Where | Catches problems | Can be bypassed | Set up |
|---|---|---|---|
| Pre-commit hook | As the message is written | Yes — --no-verify |
.pre-commit-config.yaml in each repository |
| GitHub Action | On every push and pull request, in CI | No | A workflow file in each repository |
| GitHub App | On every push and pull request, hosted | No | Install once for the organization |
| GitLab CI, Bitbucket, Azure Pipelines | On every merge or pull request, in CI | No | A job in each repository's pipeline file |
| Command line | Wherever you call it: a range of commits, a CI you write yourself | — | pip install commit-check |
| MCP server | Before the commit exists, inside an AI coding agent | — | One entry in the agent's MCP config |
| Python API | Wherever your own code runs: a bot, a server-side hook, an agent you build | — | pip install commit-check, then import commit_check.api |
| Organization config | Everywhere at once | — | One shared cchk.toml, whichever of the above runs it |
Which one¶
- Every project wants a hook. It is the cheapest place to enforce commit policy: the developer finds out while they are still writing the message, not after a CI round trip.
- A policy needs a check that cannot be skipped. Anyone can pass
--no-verifyto a hook. The Action and the App run where that is not possible, and a required check keeps a violating change from merging. - More than a handful of repositories wants one config.
inherit_fromlets every repository share a base policy and override only what it genuinely needs, whichever of the above runs the checks.
App or Action?¶
| GitHub App | GitHub Action | |
|---|---|---|
| Setup | Install once for the organization | A workflow file in each repository |
| Runs on | GitHub.com | GitHub.com and GitHub Enterprise Server, any runner |
| Cost | No CI minutes | A runner per run; metered minutes on private repositories |
| Output | A check run per commit | Job summary, PR comment, result output |
| Fits | Organization-wide coverage with no per-repository work | Custom workflows, self-hosted runners, GHES |
Both read the same config and report the same rule IDs, so a team can run both: the App for coverage, the Action where a workflow needs the result.
How they combine¶
A hook gives fast feedback to people who want to follow the policy. The Action or the App is what makes it a policy. Most projects want both: the hook on every laptop, and one of the two on the pull request.
Pre-commit hook¶
Runs at commit-msg and pre-commit time on the developer's machine, fetched
by pre-commit itself, so there is nothing to install. Hooks can be skipped, by
design. Set it up →
GitHub Action¶
A workflow file in the repository, on any runner, GitHub Enterprise Server
included. Posts a job summary, can comment on the pull request, and exposes a
result output for the rest of the workflow. Set it up →
GitHub App¶
Hosted. Install it once on an organization and every repository, including ones created later, gets a Commit Check result on each commit of every push and pull request, with no workflow file and no CI minutes. Set it up →
MCP server¶
The same rules as tools for an AI coding agent, so the message is right before the commit exists. One entry in the agent's MCP settings. Set it up →
Python API¶
The same checks from your own code, with no subprocess: each call returns the
JSON result as a dict. Read the API →
Across an organization¶
One cchk.toml in the organization's .github repository; each repository
inherits it with one line and overrides only what it must.
Set it up →
Pre-commit hook¶
A pre-commit hook is the cheapest place to enforce commit policy: the developer finds out while they are still writing the message, not after a CI round trip.
Add Commit Check to .pre-commit-config.yaml:
Then install the hooks. check-message runs at the commit-msg stage, so it
needs its own install step:
That is it. The next malformed commit message is rejected before it exists.
If check-message never runs
It is almost always because pre-commit install --hook-type commit-msg was
not run — a plain pre-commit install only wires up the pre-commit stage.
More in Troubleshooting.
Available hooks¶
| Hook ID | Stage | Rules |
|---|---|---|
check-message |
commit-msg |
CC001–CC016 |
check-branch |
pre-commit |
CC201–CC202 |
check-author-name |
pre-commit |
CC101 |
check-author-email |
pre-commit |
CC102 |
check-no-force-push |
pre-push |
CC301 |
check-files |
pre-push |
CC302–CC304 |
check-tag |
pre-push |
CC401 |
The three pre-push hooks need their own install too:
check-tag checks the tags the push sends: the tag a tag push names, and on
a branch push, such as git push --follow-tags, the tags on every commit the
push carries.
pre-commit works out which commits a push carries from your remote-tracking
refs, such as origin/main, and runs no hook for a push that creates a ref
without carrying new commits by that measure. So a new tag pushed on its own,
when the commit it points at is already in a remote-tracking ref, is never seen
by check-tag. The same holds for a tag that git push --follow-tags sends
along with a branch push in that case.
Configuring without a TOML file¶
Options can be passed as hook arguments, which keeps everything in one file:
A cchk.toml is usually the better choice once you have more than a couple of
options, because CI and the CLI read it too. See
Configuration for the precedence rules.
Fixing the message instead of rejecting it¶
Give check-message the --fix argument and it corrects a message whose
every failure has a mechanical fix, such as a type written Fix or a missing
colon, and lets the commit through:
With that, git commit -m "Fix: add streaming support" records
fix: add streaming support. A message that needs a judgment, such as one
with no type at all, is rejected as before. More in
Fixing a message in place.
Skipping a hook¶
Occasionally you need to get a commit through — a mid-rebase fixup, an
automated migration. pre-commit supports this natively:
Local hooks are not a policy boundary
Anyone can pass --no-verify. Hooks exist to give fast feedback to people
who want to follow the policy, not to stop people who don't. Pair them with
the GitHub Action, which runs where it cannot be
skipped.
GitHub Action¶
A local hook can be skipped with git commit --no-verify. The Action runs in
CI whatever happened locally, and once it is a
required status check a pull request that
fails it cannot merge.
name: Commit Check
on:
push:
pull_request:
branches: [main]
jobs:
commit-check:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v7
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0 # (1)
- uses: commit-check/commit-check-action@v2
with:
message: true
branch: true
author-name: true
author-email: true
- Recommended, not required: the Action then reads the pull request's commits from the clone. With a shallow clone it lists them through the GitHub API instead (up to 250), and if it cannot, it warns and checks the head commit alone.
The Action reads the same cchk.toml as the CLI, so a repository that already
has one needs no Action-specific configuration.
Inputs¶
| Input | Default | What it does |
|---|---|---|
message |
true |
Check every commit message of the pull request |
branch |
true |
Check the branch name |
author-name |
false |
Check each commit's author name |
author-email |
false |
Check each commit's author email |
pr-title |
false |
Check the pull request title against Conventional Commits, the one that matters for squash merges. Pull request events only |
pr-comments |
false |
Post the report as a pull request comment, edited in place on later runs |
job-summary |
true |
Write the report to the job summary |
dry-run |
false |
Report failures as warnings and always exit 0 |
pull_request does not fire when a title is edited. With pr-title: true,
add types: [opened, synchronize, reopened, edited] to re-check a renamed pull
request.
The Action also sets a result output: the check results as JSON, read with
fromJSON(steps.<id>.outputs.result). Its status is pass, warn, skip
or fail, and each scope carries the same per-check fields as
commit-check --format json.
Making it a required check¶
Until it is required, a failing Commit Check does not stop anyone from
merging. Require it in the branch's ruleset or branch protection, where the
repository's plan allows one. GitHub names an Action's check after its job, so
with the workflow above the check to require is commit-check.
Commenting on the pull request¶
Instead of making contributors open the job log, have the Action post what needs fixing directly on the PR:
- uses: commit-check/commit-check-action@v2
with:
message: true
branch: true
pr-comments: ${{ github.event_name == 'pull_request' }}
The Action posts with the workflow's own token, so no GITHUB_TOKEN needs to
be passed in. It does need extra permissions on the job:
Reporting without failing¶
While a team is adopting the policy, it is often better to report problems
without blocking merges. dry-run always exits 0:
Turn it off once the history is clean.
Pull requests from forks¶
A pull_request workflow triggered by a fork receives a read-only
GITHUB_TOKEN, and permissions: pull-requests: write does not override that.
On such a run the Action skips the comment with a warning and says so in the
job summary; the check, the annotations and the summary still work. For
feedback on fork pull requests themselves, use the GitHub App,
which is not bound by the workflow's token.
Do not reach for pull_request_target casually
pull_request_target does get a write token, but it runs in the context of
the base repository with access to its secrets. Checking out and executing
the fork's code under that trigger is the "pwn request" pattern and hands
repository access to anyone who can open a pull request.
If you use it, check out the base branch only and never run code from the pull request.
The checks themselves still run on fork pull requests and still fail the build; only the commenting is affected.
GitHub App¶
The hosted Commit Check App runs the same rule engine as the CLI, against the same config file, with no workflow file and no CI minutes. Install it once, push, and every commit gets a Commit Check result that says what failed, the value that failed, and how to fix it. Not every rule applies where the App runs; the list is below.
Choosing between the App and the GitHub Action: see Where to run it. Both read the same config and report the same rule IDs, so running both is fine.
Install¶
On the App's page, click Install and pick the account to install on. For an organization, choose All repositories and every repository, including ones created later, is covered; with Only select repositories, only those are checked and new ones have to be added by hand. A personal account works the same way.
There is nothing to add to a repository. A repository that already has a
cchk.toml or commit-check.toml is checked against it from the next push. One
without a config file takes the account's shared one, if the organization or
user keeps a cchk.toml in its .github repository (see
Across an organization), and otherwise is checked
with the defaults, advisory only (see Without a config file).
What you get¶
On every push to a branch and on every pull request, each commit gets a check run named Commit Check (a push of more than 50 commits has its newest 50 checked, and the older ones get one neutral "not checked" result) on the Checks tab, in the commit's status and in the merge box. (Tag pushes and branch deletions carry nothing to check; commits by bots are skipped, except for AI coding agents that commit as an app, and squash mode checks a pull request as one message — both below.) The result reads the same way everywhere:
- a one-line verdict —
All 4 checks passed, orCommit message and branch failed (2 of 4 checks); - for a failure, a table of what failed, the value that failed and the rule ID, linked to the rule reference;
- How to fix: the rule's own explanation and suggestion;
- the full listing of everything that was checked, folded away;
- the version of the rule engine that produced it.
Details on the check opens that report; nothing lives on any other site.
Which rules¶
The App runs the commit message, author and branch rules — CC001–CC016, CC101–CC102 and CC201–CC202 — with the commit's own branch as the branch under test. One thing it does not do: the push, file and tag rules (CC301–CC304, CC401) run where a push or a tag happens, in the pre-commit hooks and the CLI.
CC202 (require_rebase_target) is decided from GitHub's
own comparison of the head with the target branch rather than from the clone,
which holds only the commits under test. A failure says how many commits
behind the target the branch is.
Pull requests¶
Every commit of a pull request is checked on its own, fork pull requests included, and pushing more commits checks the new ones. GitHub reads a required check on the newest commit only, so that commit's check also fails when an earlier commit of the pull request failed, and lists the commits to fix. A team that squash-merges keeps none of those commits, so it can check the result instead:
The pull request then gets one result on its head commit, against the message a squash merge would land — built the way GitHub builds it from the repository's merge settings, pull request number included. Editing the title or description re-runs it. The configuration page has the table of what is checked under each merge setting.
Bots, re-runs, and commits checked twice¶
- Commits by bots (any author whose name ends in
[bot]: dependabot, renovate, and the like) are skipped. AI coding agents that commit as an app are the exception: commits by Devin, Jules, Copilot's coding agent, Claude, Codex, Amazon Q, Cursor and Factory are checked like anyone's. For finer control useignore_authors, which the rule engine applies itself. - Re-run on the Checks tab checks a commit again — after a config change, say. In squash mode it re-checks the pull request.
- A commit pushed to a branch that has an open pull request raises both a push and a pull request event; the App checks it once.
Configuration¶
The App reads the same file the CLI reads, as it stands at the head of the
push or pull request, in the same four places: cchk.toml, commit-check.toml, .github/cchk.toml,
.github/commit-check.toml. Every option on the
configuration page that belongs to a rule the App runs
applies, precedence included; the [push], [files] and [tag] sections are
read but have nothing to act on here. Nothing is App-specific to set apart
from [pull_request] (above) and [jira] (below); the CLI and the Action ignore
them.
inherit_from works, and a github: target is fetched with
the App's own credentials, so a shared config in a private .github repository
is reachable from the App even though the CLI, which fetches anonymously,
cannot read it. A target the App cannot fetch either is left to the CLI, where
inheritance fails open.
Without a config file¶
A repository with no config file first looks to its account: a cchk.toml in
the owner's .github repository — organizations and personal accounts both
have one — is applied as if it were the repository's own, and the report says
so in its last lines. Its failures are enforced, because the owner chose those
rules.
A repository with no config file and no shared one has not chosen its
rules, so its result is advisory: failures are reported in full, but the check is neutral — titled,
say, Commit message and branch would fail (2 of 4 checks, not enforced) — and never blocks a merge. Below the
fixes the report says why, and gives the smallest file that turns enforcement
on:
Once the file is pushed, a failure turns the check red instead of neutral. A passing result is the same with or without it.
Required means nothing until there is a config
GitHub treats a neutral check as passing. Making Commit Check a required check on a repository with no config file blocks nothing. Add the file first.
Jira ticket references¶
The App can additionally require a ticket reference in every message. This check is App-side, so the CLI and the Action ignore the section:
It reports as JIRA jira-ticket in the same table as the other rules. It
checks the reference's shape, not whether the ticket exists.
Enforcing it¶
The App marks a commit; it cannot refuse a push. What keeps a violating change
off the protected branch is a required status check named Commit Check
in the branch's ruleset or branch protection, where the repository's plan
allows one. With that in place a failing result blocks the merge button and a
passing one clears it.
Add the config file first and the required check second, for the reason in the box above.
Across an organization¶
Installing on the organization with All repositories covers every
repository. Keeping their rules in step takes one file: a cchk.toml in the
organization's .github repository (root or .github/, either name). Every
repository without a config file of its own is checked against it, new
repositories included, with nothing added to any of them. A personal account's
.github repository works the same way for that account's repositories. A repository that
needs something different commits its own file, which takes the
organization's place, or starts from it with one
inherit_from line and overrides what it must.
The App reads the shared file with its own credentials, so a private
.github repository works. The Across an organization
guide walks through the file, inheritance, and how to roll a policy out
without a wall of red.
Plans¶
Public repositories are free on any account, and everything on a personal account is free, and both stay free. Only an organization's private repositories will be paid, on a Team plan that is not on sale yet:
| Plan | Price | Covers |
|---|---|---|
| Open Source | Free | Public repositories, on any account |
| Personal | Free | Private repositories on a personal account |
| Team | Coming soon | Private repositories in an organization |
The Team plan is not tiered by seat count. Twenty of you and twenty thousand of you pay the same, so growing the team never changes the bill.
Today none of this is enforced: every repository is checked at no charge while the App is new. When the plan goes live, a private organization repository without it gets a neutral check saying so — nothing is blocked, nothing is rejected — and this page will say so before it happens.
The CLI, the pre-commit hook, the GitHub Action and the MCP server are MIT licensed and always free. If you would rather not pay for the App on an organization's private repositories, the Action reads the same config and reports the same rule IDs — it just costs you CI minutes instead.
Privacy¶
The App fetches the commits under test, the configuration file (the repository's own or its account's shared one) and the repository metadata the checks need, such as the squash-merge settings and the comparison with a rebase target, and stores nothing: no database, no copy of any message, author or config. The privacy page lists the permissions it asks for and what each one is used for.
When something is off¶
No check appears. The App is not installed on that repository (check the installation's repository list), the push was a tag or a branch deletion, or every commit was by a bot other than an AI coding agent.
A rule you never turned on is failing. The repository has no config file and the defaults apply — which is why the result is advisory. The Troubleshooting page covers the defaults; the starter config above turns them into a policy.
The check says "Commit Check could not run". That is an error, not a verdict: the App hit a problem checking the commit and says so rather than staying silent. Re-run it; if it keeps failing, open an issue with the text of the report.
The shared config is not being applied. The App has to be installed on
the .github repository too (it is, with All repositories),
the file has to be at one of the four paths, and the repository must have no
config file of its own — one that does uses its own. A github: inherit_from
the App cannot fetch falls back to the CLI, which fails open; check the
organization guide for the sources it accepts.
GitLab CI, Bitbucket and Azure Pipelines¶
On GitHub, the Action and the App do
this work for you. Everywhere else Commit Check runs as one job in the
pipeline: it is a Python package with no runtime dependencies, so any runner
that can pip install can run it, and it reads the same cchk.toml as the
hook and the Action.
Two things are different from running it on your own machine, and each example below handles both:
- The checkout can be a detached HEAD, as it is on GitLab and Azure, and
then git has no branch to report.
commit-check --branchreads the name from the CI's own variables instead, the merge or pull request's source branch first, so the jobs call it as is. - A merge request has more than one commit.
--revchecks one commit, so the job loops over the commits the merge request adds, as in Checking a range of commits. The loop needs the history those commits sit on, so each example turns shallow cloning off. If the range cannot be read, the job fails rather than passing with nothing checked.
Each job runs on merge or pull requests only, because the variables it reads
exist only there. Add --author-name --author-email to the commit-check
call in the loop to check each commit's author as well.
On 2.18.2 or older
Releases before 2.18.3 read only GitHub's variables, so on a detached
checkout --branch judges HEAD, which is always allowed, and passes
whatever the branch is called. Pipe the name in instead: a value piped
into --branch on its own is the name it checks.
GitLab CI¶
commit-check:
image: python:3.13
variables:
GIT_DEPTH: "0"
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
script:
- pip install commit-check
- commit-check --branch
- |
head="${CI_MERGE_REQUEST_SOURCE_BRANCH_SHA:-$CI_COMMIT_SHA}"
shas=$(git rev-list "$CI_MERGE_REQUEST_DIFF_BASE_SHA..$head") || exit 1
status=0
for sha in $shas; do
commit-check --message --rev "$sha" --compact || status=1
done
exit $status
GIT_DEPTH: "0"turns shallow cloning off.- In a merged results pipeline,
HEADis a merge commit GitLab made, andCI_MERGE_REQUEST_SOURCE_BRANCH_SHAnames the merge request's own last commit. In a plain merge request pipeline that variable is empty andCI_COMMIT_SHAis that commit. - The
pythonimage ships with git. A-slimimage does not, and needs it installed first.
Bitbucket Pipelines¶
image: python:3.13
pipelines:
pull-requests:
'**':
- step:
name: Commit Check
clone:
depth: full
script:
- pip install commit-check
- commit-check --branch
- |
shas=$(git rev-list "$BITBUCKET_PR_DESTINATION_COMMIT..$BITBUCKET_COMMIT") || exit 1
status=0
for sha in $shas; do
commit-check --message --rev "$sha" --compact || status=1
done
exit $status
depth: fullturns off the default clone depth of 50 commits.- Bitbucket merges the destination branch into the pull request before the
step runs. The range stops at
BITBUCKET_COMMIT, the pull request's own last commit, so commits that only came in with that merge are not checked.
Azure Pipelines¶
trigger: none
pr:
branches:
include: ["*"]
pool:
vmImage: ubuntu-latest
steps:
- checkout: self
fetchDepth: 0
- task: UsePythonVersion@0
inputs:
versionSpec: "3.13"
- script: pip install commit-check
displayName: Install Commit Check
- script: commit-check --branch
displayName: Check the branch name
- script: |
shas=$(git rev-list HEAD^1..HEAD^2) || exit 1
status=0
for sha in $shas; do
commit-check --message --rev "$sha" --compact || status=1
done
exit $status
displayName: Check the commit messages
trigger: nonekeeps the pipeline to pull requests; on a plain push there is no source branch to check and no merge commit to read the range from.fetchDepth: 0turns off shallow fetch, which new pipelines have on by default.- A pull request build checks out a merge commit whose first parent is the
target branch and second the pull request, so
HEAD^1..HEAD^2is exactly the commits the pull request adds. - The branch name comes from
System.PullRequest.SourceBranch, which isrefs/heads/feature/xin Azure Repos andfeature/xfor a GitHub repository. Commit Check drops therefs/heads/prefix itself. - In Azure Repos the
pr:section is ignored. Pull request builds come from a build validation branch policy on the target branch, andSystem.PullRequest.SourceBranchis only set for builds a branch policy started.
With release tools¶
release-please, semantic-release, git-cliff and cz bump read your commit
messages to decide the next version and write the changelog. A commit they
cannot parse is not an error to them: it is left out of the changelog, or it
does not count toward a release. Commit Check is the gate in front of them,
so what they read always parses.
Check the message the tool will read¶
Which message that is depends on how pull requests are merged.
- Merge commits or rebase. Every commit on the branch lands on
main, and the release tool reads each one. Check each one: the pre-commit hook while it is written, and the Action with its defaultmessage: trueon the pull request. - Squash merge. The branch's commits are thrown away, and the pull
request title becomes the commit on
main. release-please, for one, "highly recommend[s]" squash merges. Check the title:pr-title: trueon the Action, orcheck = "squash"on the App, which checks the full message a squash merge would land. With GitHub's default squash message, a pull request of a single commit lands that commit's message rather than the title. The App follows that setting; with the Action, set the squash commit message to "Pull request title" in the repository settings so the title you check is the one that lands.
The default commit types are the Conventional Commits
ones the tools release on (feat, fix, perf) plus the ones they parse but
do not release on, such as docs and chore; whether those show in the
changelog is the tool's setting. A breaking change written as feat!: or
feat(api)!: passes Commit Check, and so does a BREAKING CHANGE: footer.
semantic-release's default Angular preset does not read the !, though: give
the commit a BREAKING CHANGE: footer, or switch it to the
conventionalcommits preset.
Let the tool's own pull request through¶
release-please and changesets open their release pull request from a branch whose name you cannot choose:
| Tool | Branch | Default title |
|---|---|---|
| release-please | release-please--branches--main, plus --components--<name> in a monorepo |
chore(main): release 1.2.3 |
| changesets/action | changeset-release/main |
Version Packages |
The default branch rule, CC201, rejects both names. Allow the ones you use:
Each entry is a regular expression that has to match the whole branch name, so these allow the tools' branches and nothing that merely contains them.
release-please's title passes CC001 as it is.
changesets' Version Packages does not, so with pr-title: true give it a
Conventional Commits title and commit message:
- uses: changesets/action@v1
with:
commit: "chore: version packages"
title: "chore: version packages"
Per tool¶
| Tool | What it reads | Notes |
|---|---|---|
| release-please | Conventional Commits on the release branch since the last release | Branch name above. Its README recommends squash merges, so check the title. |
| semantic-release | Commits since the last release, with the Angular preset by default | The Angular types are Commit Check's defaults. |
| git-cliff | Commits, parsed as Conventional Commits when conventional_commits = true in cliff.toml |
With filter_unconventional = true, a commit that does not parse is dropped from the changelog without a word. |
Commitizen cz bump |
Conventional Commits since the last tag | Commitizen has its own cz check. Commit Check adds branch names, authors, sign-off and the App, from one config. |
| changesets | Changeset files, not commit messages | Only its branch name and PR title matter here, both above. |
With AI coding agents¶
A coding agent commits more often than a person, and when a hook rejects a
commit it can reach for git commit --no-verify to get past it. Three layers
keep the policy in place, cheapest first. Only the last one cannot be skipped.
Tell the agent¶
Put the rule where agents read their instructions. AGENTS.md at the
repository root is read by Codex, Cursor, Jules, Devin, the Copilot coding
agent and many others. Claude Code reads it too when the
repository has no CLAUDE.md; if it has one, add @AGENTS.md to it.
With the MCP server the agent can check the message before it commits; Make the agent use it has the line to add for that.
An instruction is read, not enforced: the agent can still decide to skip the hook.
Block it in Claude Code¶
Claude Code runs a PreToolUse hook
before each shell command, and a hook that exits with code 2 stops the command
and tells Claude why. This one stops --no-verify, and -n on git commit:
#!/usr/bin/env bash
# Stop git from skipping the hooks: --no-verify, or -n on git commit.
# Quoted text becomes _ first, so a commit message that mentions -n passes.
command -v jq >/dev/null || { echo "This hook needs jq." >&2; exit 2; }
cmd=$(jq -r '.tool_input.command' | sed -E "s/\"[^\"]*\"/_/g; s/'[^']*'/_/g")
any='git [^;&|]*--no-verify'
commit='git( +-C +[^ ]+)* +commit[^;&|]* +-[a-zA-Z]*n'
if grep -Eq -e "$any" -e "$commit" <<<"$cmd"; then
echo "Do not skip the hooks (--no-verify, -n)." \
"Fix what Commit Check reported and commit again." >&2
exit 2
fi
Make the script executable, install jq (without it the
hook stops every command), and commit both files, so everyone who works in the
repository with Claude Code gets the hook. It reads the command as written and
does not catch every way around a hook, such as a command wrapped in bash -c
or pre-commit's SKIP variable.
Check the pull request¶
Agents that work in the cloud, such as the Copilot coding agent, Codex, Jules
and Devin, commit in their own sandbox, where no hook runs unless their setup
installs one. The check that holds for every agent is the one on the pull
request. The GitHub App needs no workflow file and also
checks the agents that commit as an app
(which ones); the
Action does the same job in CI. Make the check required
and a commit that skipped the hook still cannot merge, since both look at every
commit of the pull request. The App's check is named Commit Check
(enforcing it); the Action's takes its job's
name, commit-check in the example.
Across an organization¶
Copying cchk.toml into forty repositories works until the day you want to
change it. One shared config fixes that, two ways: the GitHub App
applies the organization's file to every repository that has none of its own,
with nothing added to any repository; and inherit_from lets a repository that
does have a config start from the shared one and override only what it
genuinely needs.
The shared config¶
Put the policy in a repository every project can read — GitHub's .github
repository is the conventional home:
With the App: nothing to add¶
Where the GitHub App is installed on the organization, that
file is already in force: a repository with no cchk.toml of its own is
checked against the organization's, including repositories created tomorrow,
and the check run's report says so in its last lines. The App reads the file with its own
credentials, so the .github repository can be private. A personal account's
.github repository does the same for that account's repositories. A repository that
commits its own config uses that instead — to build on the shared one rather
than replace it, inherit it, below.
The hook, the CLI and the Action do not know about the organization's file unless a repository points at it, which is what the next section is for.
Inheriting it¶
Each repository then needs one line:
Local settings win, so a project with a different constraint overrides just that one option:
Pinning the version¶
By default the shorthand resolves to the parent repository's default branch, which means a change to the org config takes effect everywhere on the next run. That is usually what you want. When it isn't, pin to a ref:
Other sources¶
Useful in a monorepo, where the shared config is already checked out. A relative path is looked up next to the config file that names it first, so a path written relative to that file works from any directory. Only when nothing is there is it tried from the current directory, then from the top of the repository.
Whatever the source, a parent config is read up to 1 MiB. A larger one, or a
local path that is not a regular file, is treated as unreachable. Only one
level is followed: an inherit_from inside the parent config is reported on
stderr and ignored.
Inheritance fails open
If the parent config is unreachable — a network blip, a renamed file, a
private repository — Commit Check falls back to the local config, with a
one-line notice on stderr, rather than failing the build. This keeps CI green during an outage, but it
also means a typo in inherit_from is easy to miss. Verify the merged
result when you first set it up:
Rolling it out¶
Turning on a strict policy across an organization at once produces a wall of red. A gentler sequence:
- Ship the org config with the new rules listed under
warn, so violations are reported in every hook, Action run and App check, and nothing blocks. (The Action'sdry-rundoes the same for every rule at once, in the Action only.) - Look at what actually fails. Some rules will turn out to be wrong for some teams, and those teams can override them in their own config.
- Drop a rule from
warnonce the repositories are clean. A repository that is already clean can drop it early with awarnlist of its own, which replaces the shared one. - Tighten the shared config over time.
MCP server¶
commit-check-mcp exposes
the same rules to an AI coding agent as
Model Context Protocol tools. An agent that
writes commits can check the message, the branch name and the author before
the commit exists, against the repository's own cchk.toml, and fix what
fails instead of finding out from the hook or the pull request.
Add it to the agent¶
The server runs over stdio and is started by the client. With uv installed nothing else needs installing:
That registers it for you, in this project. Add --scope project to write
it to a .mcp.json at the repository root instead, so the whole team gets
it once the file is committed, or --scope user for every project on your
machine. A hand-written .mcp.json holds the usual block:
Claude Code does not read MCP servers from settings.json.
The key is servers, not mcpServers. Copilot uses it in agent mode.
The mcpServers block from the Cursor tab, in each client's MCP settings
file; Continue takes it as a YAML list. The
README
lists the file and the format for each.
Make the agent use it¶
Tools an agent can call are not tools it will call. One line in the
project's agent instructions — CLAUDE.md, AGENTS.md, a Cursor rule — makes
the check part of every commit:
The result the agent gets back is the same structured verdict the CLI prints
with --format json: a status, and for each check its value, the error, a
suggestion and — when the correction is unambiguous — the corrected value in
fix. A type written Fix comes back as "fix": "fix: add x", ready to
apply; a subject with no type at all leaves fix empty, and the agent works
from the suggestion instead. See Reading the JSON.
The key is always present; an older commit-check leaves it empty.
The tools¶
| Tool | Checks |
|---|---|
validate_commit_message(message, …) |
A message against the commit message rules |
validate_branch_name(branch?, …) |
A branch name, or the repository's current branch, against the branch rules |
validate_author_info(author_name?, author_email?, …) |
A name and email, or the repository's git author config, against the author rules |
validate_push_safety(push_refs?, …) |
That a push is not a force push (CC301) |
validate_commit_context(message?, branch?, author_name?, author_email?, …) |
Any combination of the above in one call |
validate_repository_state(repo_path?, …) |
The latest commit, current branch and author config of a repository, with push safety on request |
describe_validation_rules(…) |
The effective configuration after defaults, the repository's file and any overrides |
server_health() |
Server, rule engine and SDK versions |
Every validation tool takes the same optional arguments:
repo_path— the repository to validate against; the config file is looked up there, in the usual places.config_path— a specific TOML file instead; a relative path resolves fromrepo_path.config— inline overrides, merged on top of the defaults and the file, for a one-off stricter or looser check.
No tool commits, pushes or changes the working tree. validate_push_safety and
validate_repository_state may run git fetch to resolve a SHA, which updates
FETCH_HEAD and the remote-tracking refs. The other six only read, and their
MCP annotations say so for clients that auto-approve read-only tools.
The same rules, one step earlier¶
The MCP server reads the same cchk.toml as the pre-commit hook,
the Action and the App, through the same
rule engine, so an agent that passes here passes there. It changes when the
verdict arrives: before the commit exists, inside the tool that is writing it,
instead of after, from a hook the agent may not be running or from a check on
the pull request. For a repository that forbids AI attribution trailers
(CC013) or requires a sign-off (CC012),
this is where the agent learns it.
Python API¶
commit_check.api runs the same checks as the command line from Python code —
a bot, a server-side hook, an agent you are building — without starting a
subprocess. Each function takes the value to check and returns a plain dict
in the shape commit-check --format json prints, ready to branch on, log or
hand to a model. It ships with the package: pip install commit-check is all
it needs.
from commit_check.api import validate_message
result = validate_message("Fix: add streaming support")
for check in result["checks"]:
if check["status"] == "fail":
print(check["rule_id"], check["suggest"])
print("fix:", check["fix"])
Functions¶
| Function | Checks | When the value is left out |
|---|---|---|
validate_message(message, *, config=None) |
The commit message: Conventional Commits, subject length, case and mood, body, sign-off, AI attribution — whichever rules the config enables | Required |
validate_branch(branch=None, *, config=None) |
The branch name, and its rebase target when one is configured | The current branch, from git branch --show-current |
validate_author(name=None, email=None, *, config=None) |
The author's name and email | Both come from git config |
validate_tag(tag=None, *, config=None) |
Tag names, one per line | The tags pointing at HEAD; with none, the result is skip |
validate_push(push_refs=None, *, config=None) |
That a push is not a force push; the check is always on here | Pass the pre-push lines, <local ref> <local sha> <remote ref> <remote sha>, one per line — without them there is nothing to compare |
validate_all(message=None, branch=None, author_name=None, author_email=None, *, config=None) |
Message, branch and author together, in one result | Whatever is left out is not checked, and nothing is read from git |
The git lookups run in the current working directory, as the CLI's do.
from commit_check.api import validate_all
result = validate_all(message="feat: implement new feature", branch="user-login")
print(result["status"])
for check in result["checks"]:
print(check["rule_id"], check["check"], check["status"])
fail
CC001 message pass
CC004 subject_max_length pass
CC005 subject_min_length pass
CC201 branch fail
The result¶
Every function returns the same shape:
{
"status": "pass" | "fail" | "skip",
"warnings": 0, # how many checks have status "warn"
"checks": [
{
"rule_id": "CC001",
"check": "message",
"status": "pass" | "fail" | "warn" | "skip",
"value": "...", # what was checked
"error": "...", # why it failed
"suggest": "...", # advice for a person
"fix": "...", # the corrected value, or "" when it takes judgment
"docs_url": "https://commit-check.com/rules/#cc001",
"reason": "...", # why a skipped check did not run, when known; else ""
},
],
}
- Only
failis a rejection. Code that branches onstatus == "fail"keeps working whatever else the result holds. skipis notpass. A check skips when it never ran — the author is inignore_authors, or there was nothing to check — and the top-levelstatusisskiponly when every check skipped. A skipped run validated nothing, so do not read it as approval. When the author or a co-author is inignore_authors, the check'sreasonnames them.warnis a rule listed under the config'swarn: reported in full, never a failure. The top-levelstatusstayspass, andwarningscounts them.fixis non-empty only when the correction is mechanical, so it can be applied as it stands; otherwise followsuggest. Reading the JSON lists the cases.
Configuration¶
The API does not read cchk.toml. It starts from the built-in defaults and
merges the config you pass, a dict shaped like the TOML file:
from commit_check.api import validate_message
result = validate_message(
"docs: update readme",
config={"commit": {"allow_commit_types": ["feat", "fix"]}},
)
print(result["status"])
print(result["checks"][0]["suggest"])
A top-level warn works as it does in the file:
from commit_check.api import validate_message
result = validate_message("add streaming support", config={"warn": ["message"]})
print(result["status"], result["warnings"])
To apply a repository's own policy, load its file and pass it along:
import tomllib # on Python 3.10: import tomli as tomllib
from commit_check.api import validate_message
with open("cchk.toml", "rb") as f:
config = tomllib.load(f)
result = validate_message("feat: add streaming support", config=config)
On Python 3.10, tomli is already installed: commit-check depends on it
there. inherit_from is not followed this way; only the file's own keys
apply. Every key is in the configuration reference.
Fixing until it passes¶
An agent can apply fix and check again — the loop the
MCP server teaches its clients:
from commit_check.api import validate_message
message = "Fix: add streaming support"
for _ in range(3):
result = validate_message(message)
fixes = [c["fix"] for c in result["checks"] if c["status"] == "fail" and c["fix"]]
if result["status"] != "fail" or not fixes:
break
message = fixes[0]
print(result["status"], message)
When fix is empty the correction takes judgment — choosing a type for a bare
subject, shortening a long one — and suggest says what is needed.
Policy guides¶
Two rules are off by default because they encode a decision only your project can make: whether contributors must certify the origin of their work, and whether AI assistance is welcome, disclosed, or refused.
Commit Check does not take a side on either. It gives you a way to enforce the position you have already taken, so it stops being relitigated in every review.
Require signoff (DCO)¶
Projects that use the Developer Certificate of Origin
require every commit to carry a Signed-off-by trailer. The Linux kernel and
much of the CNCF work this way.
A DCO bot rejecting a pull request after the fact is a poor experience: the contributor has to rewrite history for every commit in the branch. A local check catches a missing sign-off at commit time, before there is anything to rewrite.
Turn it on¶
This enables CC012, which is off by default.
Signing off¶
The trailer is appended automatically from your user.name and user.email:
Forgot it? Fix the last commit in place:
Fix a whole branch:
Make it automatic
Signing off is easy to forget. Combine this rule with the pre-commit hook so a missing trailer is caught at commit time, not at review time.
Identity matters¶
The DCO is a statement about who wrote the code, so it only means something if the identity is real. CC101 and CC102 check the author name and email, and are enabled by default when their check runs:
To require a company address:
To refuse GitHub's no-reply addresses, which cannot receive mail and can lose their link to the author's account after a username change:
Bots¶
Automation cannot meaningfully sign the DCO, and forcing it to produces meaningless trailers. Exempt bots instead:
ignore_authors matches the commit author and any Co-authored-by: trailers.
AI attribution¶
AI coding tools stamp their own trailers on the commits they help with.
Whether that is welcome, required or unacceptable is a decision each project
makes for itself, and the industry has landed in three places — which are the
three values ai_attribution takes:
ai_attribution |
The project's position | Rules |
|---|---|---|
"ignore" (default) |
no stated position | — |
"forbid" |
no AI attribution in the history | CC013 |
"disclose" |
AI is welcome, said out loud, and not credited as a person | CC014–CC016 |
Whichever you pick, the check reads what the message says. It cannot see assistance that left no trace — see the note below.
The default: no opinion¶
CC013 is off. Commits carrying AI trailers pass, and so do commits without them.
Forbidding AI-attributed commits¶
Commits carrying a recognised AI signature now fail:
CC013 ai-attribution check failed ==> Claude Code
AI attribution is forbidden in this project — detected: Claude Code
Suggest: This project does not accept AI attribution in commit messages. Remove the AI trailer lines and re-commit.
Docs: https://commit-check.com/rules/#cc013
Recognised signatures are co-author and sign-off lines naming Claude Code,
GitHub Copilot, Codex, Gemini, Cursor, Devin, Aider, Windsurf, Tabby, Amp,
OpenHands, Factory Droid and Jules, the disclosure trailers Assisted-by: and
Generated-by:, vendor marks such as 🤖 Generated with Claude Code, Amp's
Amp-Thread-ID:, the Copilot coding agent's Agent-Logs-Url: and Cursor's
Made-with: Cursor, and generic AI model names. This is the
position Kubernetes
takes — no AI trailers in the history, disclosure in the pull request
instead.
These rules read only the message. Jules and OpenHands can commit under their
own bot identity instead of adding a co-author line, and such a commit passes
when its message contains no recognized AI signature. The Copilot coding agent
also commits as its own account, but every commit it makes carries an
Agent-Logs-Url: trailer, so its commits are caught.
This checks disclosure, not authorship
These rules read commit metadata. They detect a commit that says it was AI-assisted; they cannot detect one that was AI-assisted and did not say so.
Set against a policy of "no AI contributions", it is an honesty check on contributors who are already following the rules — not an enforcement mechanism against those who aren't. Be clear with yourself about which of those you are buying.
Asking for disclosure instead¶
Most projects that allow AI assistance ask to be told about it.
Fedora and
FluxCD want an Assisted-by: trailer, as the
Linux kernel did
until its maintainers decided in October 2026 to drop the tag; the
Apache Software Foundation
recommends Generated-by:. The kernel still says an AI agent must
never add a Signed-off-by: line, because only a person can certify the
DCO.
That turns on three rules, each about one of those conditions:
| Rule | Asks that |
|---|---|
| CC014 | the assistance is disclosed with an accepted trailer |
| CC015 | the tool is not credited as a co-author |
| CC016 | the tool did not sign off the commit |
A commit that a vendor stamped and nobody disclosed fails the first two, and the failure carries the disclosure already written:
CC014 ai-disclosure check failed ==> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
AI assistance is not disclosed with Assisted-by or Generated-by — detected: Claude Code
Suggest: Disclose the tool with "Assisted-by: Claude Opus 5"
Docs: https://commit-check.com/rules/#cc014
CC015 ai-co-author check failed ==> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An AI tool is credited as a co-author: Claude Code
Suggest: Use "Assisted-by: Claude Opus 5" in place of the co-author line
Docs: https://commit-check.com/rules/#cc015
Both carry the same correction in --format json: the co-author line rewritten
as the project's disclosure trailer, keeping the name the tool gave itself.
Once the tool is disclosed, CC015 asks only for the co-author line to go, and
a commit in the kernel's Assisted-by: format passes all three:
printf 'feat: add caching layer\n\nAssisted-by: LLM coccinelle sparse\nSigned-off-by: Jane Dev <jane@example.com>' | commit-check -m
Choosing the format¶
ai_disclosure_trailers is the list of trailers that count as a disclosure,
and the first one is what a correction is written with:
Listing Co-authored-by says the project accepts the tool as a co-author —
pytest and
IREE do — and CC015 then passes. Signed-off-by is refused whatever else you
list, so CC016 cannot be configured away.
ai_disclosure_pattern asks for a particular shape, such as FluxCD's
agent/model:
CC014 ai-disclosure check failed ==> Assisted-by: Claude Code
The Assisted-by value does not match the required pattern: ^\S+/\S+$
Suggest: Write the Assisted-by value so that it matches ^\S+/\S+$ (set by ai_disclosure_pattern in the [commit] config)
That failure carries no correction, because the tool should not guess which model or format you meant.
Refusing the tool as a co-author, without asking for disclosure¶
pip and
attrs
close any pull request that lists an LLM as a co-author, but they don't ask
contributors to disclose AI use. Put CC014 in the top-level warn list and
the other two keep enforcing:
CC014 ai-disclosure check warning ==> 🤖 Generated with [Claude
AI assistance is not disclosed with Assisted-by or Generated-by — detected: Claude Code
Suggest: Disclose the tool with "Assisted-by: Claude Code"
Docs: https://commit-check.com/rules/#cc014
This rule is set to warn in the config; it does not fail the run.
The run still passes. The warning still suggests a disclosure that pip and attrs don't ask for: commit-check has no switch that turns off a single rule, so reporting CC014 is as quiet as it gets. See Report a rule without enforcing it.
CPython sets no rule
for commits at all: disclosing AI use is appreciated but not required, and
triagers judge each contribution by whether its author can explain it in their
own words. A policy like that leaves nothing for commit-check to enforce, so
keep ai_attribution at its default, "ignore".
Exempting automation¶
Bots that legitimately carry AI trailers can be excluded:
Documenting the decision¶
Whichever way you go, the config file is not where contributors look. State the
policy where they will see it — CONTRIBUTING.md, the pull request template —
and let Commit Check be the mechanism rather than the announcement.
Enforcing an undocumented policy produces a confusing failure for somebody acting in good faith.
Command-line recipes¶
Ways to invoke the checks directly. For wiring them into a workflow, see Where to run it instead — that covers the setup this page assumes you already have.
Every option, and the environment variable and TOML key that set it, is listed in Configuration.
Checking a commit message¶
The message can come from the repository, a file, standard input, or a named revision.
This is what the check-message pre-commit hook runs, with the message
file git hands it.
Useful in scripts and for trying a message before committing it.
--rev names the commit under test — anything git rev-parse
understands. A revision that does not resolve is a one-line error before
any check runs.
A revision and a message file would name two different subjects for the
same checks, so passing both is rejected; stdin is likewise not consulted
while --rev is set.
Trying a message before you write it¶
$ echo "updated the parser" | commit-check -m
CC001 message check failed ==> updated the parser
The commit message should follow Conventional Commits. See https://www.conventionalcommits.org
Suggest: Use <type>(<scope>): <description>, where <type> is one of: feat, fix, ...
Docs: https://commit-check.com/rules/#cc001
Fix it and it goes quiet:
Multi-line messages¶
A body and trailers survive a heredoc, so you can test the whole thing:
$ cat > /tmp/msg.txt << 'EOF'
fix(auth): resolve login timeout
Users were timing out during login. Raises the session timeout and
reports the failure instead of hanging.
Fixes #123
EOF
$ commit-check -m /tmp/msg.txt
Fixing a message in place¶
--fix corrects the message instead of only rejecting it. When every check
the message failed has a mechanical fix, the corrections that
--format json reports in fix, it rewrites the message
file and the check passes:
$ printf 'Fix: add streaming support\n' > msg.txt
$ commit-check -m --fix msg.txt
✎ fixed the commit message: CC001 message
$ cat msg.txt
fix: add streaming support
A failure that takes a judgment, such as updated the parser, which has no
type at all, leaves the file as it was and fails as before. --fix needs a
message file to rewrite, so with stdin or --rev it is a usage error, and
--dry-run rewrites nothing. In the commit-msg hook it is one argument:
see Fixing the message instead of rejecting it.
Checking the branch¶
Runs CC201, and CC202 if
require_rebase_target is set. main, master, develop, HEAD, PR-* and
gh-readonly-queue/* (GitHub merge queue branches) are always accepted; everything else needs a <type>/<description> shape:
On a detached HEAD, which is how most CI systems check out a commit, git has
no branch to report, so the name comes from the CI's own variables: GitHub
Actions, GitLab CI, Bitbucket Pipelines, Azure Pipelines, and Jenkins
multibranch inside a Jenkins build. A merge or pull request's source branch is
read first. With none of these set, the name is HEAD. The variables other
than GitHub's are read from 2.18.3 on.
Checking the committer¶
Either flag works alone. CC101 and CC102 describe what the built-in patterns accept and how to tighten them.
Without --rev, these validate the local git config — whoever is about to
commit — falling back to HEAD's author only when no identity is configured.
That is the right subject for a hook and the wrong one for CI: an existing
commit's identity is a fact about the commit, not about the operator running
the check. Add --rev and both checks read that commit's recorded author, and
the config is never consulted:
Blocking force pushes¶
Compares the current branch against its upstream and fails if pushing would
require a force. Better as a pre-push hook, which sees the actual refs being
pushed:
Piping git push into it does not prevent anything
git push | commit-check --no-force-push reads too late — the push has
already started — and git push output does not carry the ref lines Git
hands to a pre-push hook. Install the hook instead.
Pointing at a different config¶
Useful for testing a policy change before committing it, or for a monorepo where one directory follows different rules. See Configuration for where the file is looked up by default and how CLI, environment and file settings override each other.
Output for scripts and CI¶
Machine-readable, one object per check, including rule_id and docs_url.
One line per failure. Implies --no-banner.
Color and links¶
Output adapts to where it is going. A terminal gets ANSI color, and on
terminals that render OSC 8 hyperlinks the rule ID is itself a link to its
documentation. Piped or redirected output — a CI log, a file — gets plain
text with a Docs: line instead, so nothing is lost and no escape codes
leak into places that read them as noise.
To override the detection:
| Variable | Effect |
|---|---|
NO_COLOR=1 |
no color, wherever it runs (no-color.org) |
FORCE_COLOR=1 / FORCE_COLOR=0 |
color on or off, outranking everything else |
FORCE_HYPERLINK=1 / FORCE_HYPERLINK=0 |
linked rule IDs on or off |
Whatever the destination, the commit text in a failure is printed inert.
Control characters other than newline and tab appear as \xNN, and a line
that starts with :: is printed as \::, so a commit message cannot send
control sequences to the terminal, or workflow commands to a GitHub Actions
runner.
--format json is not affected; JSON escapes those characters already.
Checking a range of commits¶
--rev makes each commit addressable without checking it out or piping its
message, and it is the only way the author checks apply to the commit rather
than to the local config:
#!/usr/bin/env bash
# Check the last N commits; exits non-zero if any fail.
# Resolved before the loop rather than inside it: an unreadable range or a
# directory that is not a repository would otherwise expand to nothing, and
# a loop that never runs would report success.
shas=$(git rev-list -n "${1:-10}" HEAD) || exit 1
status=0
for sha in $shas; do
if ! commit-check -m --author-name --author-email --rev "$sha" --compact; then
echo " ↑ $sha"
status=1
fi
done
exit $status
On a pull_request checkout the same loop covers exactly the commits the PR
adds — HEAD is GitHub's synthetic merge commit, whose first parent is the
base branch and second the PR branch:
When a check is skipped¶
A check that had nothing to judge reports a skip, not a pass. The common
case is a merge subject: Merge branch 'x' is git's writing, so
CC002, CC003, CC004 and
CC005 decline it rather than grade prose the author never
wrote. Only subjects that merge tools write qualify: git's Merge branch
'…', Merge tag '…' and their relatives, GitLab's Merge branch 'x' into
'y', and GitHub's and Bitbucket's Merge pull request #… (plus fixup! for
CC003). A subject someone typed that starts with Merge is judged like any
other.
Text mode names every skipped check in one line on stderr, leaving stdout and the exit code untouched — a skip is still not a failure:
$ echo "Merge branch 'main' into topic" | commit-check -m --no-banner
⊘ skipped (not validated): subject-max-length, subject-min-length
In JSON each skipped check carries "status": "skip", distinct from "pass".
When a check is skipped because the author or a co-author is on an
ignore_authors list, reason says so. Here HEAD is a Dependabot commit
and cchk.toml lists dependabot[bot] under [commit] ignore_authors:
$ commit-check -m --rev HEAD --format json | jq -c '.checks[] | {rule_id, status, reason}'
{"rule_id":"CC001","status":"skip","reason":"author dependabot[bot] is in [commit].ignore_authors"}
{"rule_id":"CC004","status":"skip","reason":"author dependabot[bot] is in [commit].ignore_authors"}
{"rule_id":"CC005","status":"skip","reason":"author dependabot[bot] is in [commit].ignore_authors"}
For any other skip, such as a merge subject, reason is empty.
A green run can still have validated nothing
On a pull_request checkout, HEAD is the synthetic merge commit — so a
bare commit-check -m exits 0 with every subject rule skipped. The
notice makes that visible; the fix is to check what you actually mean:
the PR title piped on stdin, or each branch commit via --rev as above.
Reading the JSON¶
Each failed check carries the rule ID, the offending value, the suggestion and a link to its documentation — the same information the text output prints, in a form other tools can consume.
When the correction is mechanical — a type written Fix where fix is
allowed, a letter out of place, a missing colon, a WIP: marker, a missing
Signed-off-by trailer, a branch typed Feature/x — the check also carries
the corrected value in fix, and suggest names it:
{
"rule_id": "CC001",
"check": "message",
"status": "fail",
"value": "Fix: add streaming support",
"error": "The commit message should follow Conventional Commits. See https://www.conventionalcommits.org",
"suggest": "Use \"fix: add streaming support\"",
"fix": "fix: add streaming support",
"docs_url": "https://commit-check.com/rules/#cc001",
"reason": ""
}
fix is empty whenever the correction takes a judgment — a subject with no
type at all, one over the length limit, a verb that is not imperative — and
suggest then keeps the rule's general advice. A tool can apply a non-empty
fix as it stands, and --fix does that to a
message file; a person only ever needs suggest.
A rule listed under warn
reports with "status": "warn" instead of "fail". The top-level status is
fail only when an enforced rule failed, and a top-level warnings counts
the warned checks, so a tool that tests status == "fail" is unaffected and
one that wants the warnings knows where to look:
From Python, commit_check.api returns the same
result as a dict, without a subprocess.
Troubleshooting¶
A check fails and you need the commit through anyway¶
Every check can be bypassed. Doing so is sometimes the right call — you are mid-rebase, or the rule is wrong and fixing it properly can wait — but the bypass is the second thing to reach for. The first is reading what failed: every diagnostic names the rule, quotes the value that failed it, and links to the page explaining it.
Take an author name check that rejects a name starting with a digit:
check committer name.....................................................Failed
- hook id: check-author-name
- exit code: 1
Commit rejected by Commit-Check.
CC101 author-name check failed ==> 12
The committer name seems invalid
Suggest: git config user.name 'Your Name'
Docs: https://commit-check.com/rules/#cc101
12 is the configured user.name — usually a sign that
user.name was never set on this machine, or was set by a script. The fix is
the one the Suggest: line gives:
Skipping one hook¶
When the check is genuinely wrong for a single commit, skip that hook by ID and
leave the rest running. SKIP is a
pre-commit feature, so it
takes the hook's id, not the rule ID:
The IDs are check-message, check-branch, check-author-name,
check-author-email, check-no-force-push, check-tag and check-files.
Skipping every hook¶
--no-verify bypasses the whole pre-commit run — Commit Check and everything
else you have configured:
A local bypass is not a CI bypass
SKIP and --no-verify only affect the hooks on your machine. If the same
policy runs in CI — through the
GitHub Action, say — it will
check the commit again when you push, and reject it there. To exempt a
commit everywhere, change the policy rather than the invocation: turn the
rule off in cchk.toml, or add the author to
ignore_authors if the exemption is
permanent.
A rule fires that you never turned on¶
Commit Check is not silent by default. Whichever checks you asked for run with
their defaults already applied, even with no config file present: --message
enforces Conventional Commits, the 5–80 character subject limits and an
allow-list of ten commit types; --branch enforces Conventional Branch and an
allow-list of twenty-one branch types; --author-name and --author-email
apply the built-in patterns; --tag holds tag names to SemVer. The Default column in the
rules reference shows every rule's starting state, and
the configuration page spells the split out.
To see what a given repository is actually enforcing, check which config file
it picked up — the search order is in
where the config file lives, and
--config overrides it:
Nothing is checked at all¶
A check only runs when its own flag is passed. commit-check --message never
evaluates branch rules, and commit-check --branch never reads the commit
message — so a hook wired up with the wrong flag passes silently, forever.
Each rule in the rules reference lists the flag that activates it.
The run failed, but nothing was checked¶
A wrapper that treats every non-zero exit as a rejected commit cannot tell a broken policy from a bad commit. Commit Check keeps them apart:
| Exit code | Meaning |
|---|---|
0 |
Every enforced check passed, or every check was skipped. |
1 |
A check failed. This is a verdict on the commit. |
2 |
The run could not start, so nothing was validated: bad usage, a --rev that does not resolve, a directory git cannot read as a repository (not a repository, or refused for dubious ownership), a setting whose regex does not compile or whose value has the wrong type, or a config file that is missing, is not valid TOML, or names an unknown rule. |
A configuration error names the file it came from:
A setting that can also come from a flag or a CCHK_* variable names itself
instead, and echoes the value it could not use:
Error: [commit] message_pattern is not a valid regex: '^(unclosed' (missing ), unterminated subpattern at position 1)
Error: [commit] subject_max_length must be a whole number, got '50'
Something else¶
If the failure does not match anything above, the JSON output shows exactly what was evaluated and why, which is usually enough to see where the config disagrees with the expectation:
Failing that, open an issue with that output attached.
Migrating from v1¶
Version 2.0 replaced the YAML configuration with TOML. The change is mechanical — nothing about what Commit Check validates went away — but the two formats express policy very differently, and there is no automatic conversion.
In v1 a config file was a list of checks, each carrying its own regex, error message and suggestion. You wrote the pattern; Commit Check ran it. In v2 the patterns are built in and the file selects and tunes them by name. A v1 file was mostly regex; a v2 file is mostly booleans.
So you do not translate a v1 file line by line. You decide which rules you want and write those down, which is usually far shorter.
Converting the file¶
Take a representative v1 config:
checks:
- check: message
regex: '^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test){1}(\([\w\-\.]+\))?(!)?: ([\w ])+([\s\S]*)|(Merge).*|(fixup!.*)'
error: "The commit message should be structured as follows:\n\n
<type>[optional scope]: <description>\n
[optional body]\n
[optional footer(s)]\n\n
More details please refer to https://www.conventionalcommits.org"
suggest: please check your commit message whether matches above regex
- check: branch
regex: ^(bugfix|feature|release|hotfix|task|chore)\/.+|(master)|(main)|(HEAD)|(PR-.+)
error: "Branches must begin with these types: bugfix/ feature/ release/ hotfix/ task/ chore/"
suggest: run command `git checkout -b type/branch_name`
- check: author_name
regex: ^[A-Za-zÀ-ÖØ-öø-ÿĀ-ſƀ-ɏ ,.\'-]+$|.*(\[bot])
error: The committer name seems invalid
suggest: run command `git config user.name "Your Name"`
- check: author_email
regex: ^.+@.+$
error: The committer email seems invalid
suggest: run command `git config user.email yourname@example.com`
- check: commit_signoff
regex: Signed-off-by:.*[A-Za-z0-9]\s+<.+@.+>
error: Signed-off-by not found in latest commit
suggest: run command `git commit -m "conventional commit message" --signoff`
- check: merge_base
regex: main # it can be master, develop, devel etc based on your project.
error: Current branch is not rebased onto target branch
suggest: Please ensure your branch is rebased with the target branch
Every one of those checks still exists. Named rather than spelled out, the whole file becomes:
The regexes are gone because they were restating the built-in behaviour. So are
error and suggest: Commit Check now supplies both, along with a stable rule
ID and a link to the rule's documentation.
Check your type lists before deleting the old file
"Restating the built-in behaviour" is nearly true, not exactly true, and the gap is silent — the shorter file above accepts slightly less than the v1 one it replaces. Two types in that v1 example are missing from the v2 defaults:
revertas a commit type.revert: drop the cachepassed under v1 and is rejected by the defaultallow_commit_types. (Unrelated toallow_revert_commits, which governs Git's ownRevert "..."commits and is on by default.)taskas a branch type.task/CC-42passed under v1 and is rejected by the defaultallow_branch_types.
Keep them by naming the list you want, remembering that setting either option replaces the default rather than adding to it:
[commit]
allow_commit_types = ["build", "chore", "ci", "docs", "feat", "fix",
"perf", "refactor", "revert", "style", "test"]
[branch]
allow_branch_types = ["bugfix", "chore", "feature", "hotfix", "release", "task"]
Compare your own v1 regex against every option before deleting it — this is the one part of the migration that fails quietly, months later, on a commit that used to be fine.
What each v1 check became¶
v1 check: |
v2 option | Rule |
|---|---|---|
message |
[commit] conventional_commits |
CC001 |
branch |
[branch] conventional_branch |
CC201 |
author_name |
[commit] author_name_pattern |
CC101 |
author_email |
[commit] author_email_pattern |
CC102 |
commit_signoff |
[commit] require_signed_off_by |
CC012 |
merge_base |
[branch] require_rebase_target |
CC202 |
imperative |
[commit] subject_imperative |
CC003 |
The two *_pattern options are the exception to "no more regexes": they exist
so an author policy stricter than the default stays expressible.
Keeping a custom message format¶
If the v1 regex enforced something that is not Conventional Commits — JIRA
smart commits, say — that is what message_pattern is for. Set it and it
replaces the generated Conventional Commits pattern entirely:
Converting the command line¶
Only the config flag changed, and only because the file did:
A config file is now optional. With none, the defaults apply immediately:
Doing the migration¶
-
Keep the old file until you are done:
-
Write
cchk.toml— in the repository root or in.github/. Use the table above rather than translating regexes. -
Check it against real commits without failing anything, which is what
--dry-runis for: -
Try a message that should fail, so you know the policy is doing something:
A passing run on a message that ought to fail usually means the config file was not found — see where the config file lives.
-
Delete
.commit-check.ymland the backup.
If something does not work¶
The config file is not found. It has to be named cchk.toml or
commit-check.toml, in the repository root or .github/. Any other name needs
--config.
TOML fails to parse. Usually unquoted strings, or True instead of true.
Editors validate the file against the published schema — see
where the config file lives.
A rule does not behave as expected. Check the option name and its default in
every option; the allow_* options in
particular describe what is permitted, so false is the strict setting.
Something else. Open an issue
— include the output of commit-check --message --format json.
Rules¶
Every check that can report a failure has a stable rule ID. Rule IDs never change once released, so they are safe to reference in commit messages, code review comments, issue templates, and tooling.
Rule IDs appear in commit-check's output and in --format json results:
CC003 subject-imperative check failed ==> docs: revamped the profile
Commit message should use imperative mood (e.g., 'fix bug' not 'fixed bug', 'add feature' not 'adding feature')
Suggest: Change the first verb to imperative form, e.g., 'fix' instead of 'fixed'/'fixes'/'fixing'
Docs: https://commit-check.com/rules/#cc003
--compact prints one line per failure, keeping the rule ID and dropping the
explanation, suggestion, and documentation link:
When the correction is mechanical, the suggestion names it instead of
repeating the rule — and --format json carries the corrected value in a
fix field (see Reading the JSON):
CC001 message check failed ==> Fix: add x
The commit message should follow Conventional Commits. See https://www.conventionalcommits.org
Suggest: Use "fix: add x"
Docs: https://commit-check.com/rules/#cc001
How to read this page¶
Rule IDs are grouped by what they inspect:
| Range | Category | Inspects |
|---|---|---|
CC0xx |
Commit message | The subject, body, and trailers of a commit message |
CC1xx |
Author | The committer's configured name and email |
CC2xx |
Branch | The current branch's name and its position relative to a target branch |
CC3xx |
Push and files | The push operation, and the files a commit touches |
CC4xx |
Tag | The names of the tags pointing at the commit |
Two things determine whether a rule runs:
The check you select. commit-check only evaluates the checks you ask for on
the command line. commit-check --message never reports a branch rule. The
Check column in the tables below shows which flag activates each rule.
Its configuration. Within a selected check, the Default column shows whether the rule is active with no configuration at all:
| Default | Meaning |
|---|---|
| ✅ On | Enforced out of the box. Disable it through the listed option. |
| ⚪ Off | Not enforced until you opt in through the listed option. |
Rules that are off by default are not lesser rules — they encode conventions that are right for some projects and wrong for others. Turning on CC002 makes sense for a project that capitalizes subjects, and is actively harmful for one that does not.
Tip
Every option named below is documented in full — with its type, default, and the matching environment variable and CLI flag — in configuration.
Rule index¶
Commit message rules (CC0xx)¶
Run with -m / --message.
| Code | Name | Message | Check | Default |
|---|---|---|---|---|
| CC001 | message |
The commit message should follow Conventional Commits | -m |
✅ On |
| CC002 | subject-capitalized |
Subject must start with a capital letter | -m |
⚪ Off |
| CC003 | subject-imperative |
Commit message should use imperative mood | -m |
⚪ Off |
| CC004 | subject-max-length |
Subject must be at most {max_len} characters |
-m |
✅ On |
| CC005 | subject-min-length |
Subject must be at least {min_len} characters |
-m |
✅ On |
| CC006 | allow-merge-commits |
Merge commits are not allowed | -m |
⚪ Off |
| CC007 | allow-revert-commits |
Revert commits are not allowed | -m |
⚪ Off |
| CC008 | allow-empty-commits |
Empty commit messages are not allowed | -m |
⚪ Off |
| CC009 | allow-fixup-commits |
Fixup commits are not allowed | -m |
⚪ Off |
| CC010 | allow-wip-commits |
WIP commits are not allowed | -m |
⚪ Off |
| CC011 | require-body |
Commit body is required | -m |
⚪ Off |
| CC012 | require-signed-off-by |
Signed-off-by trailer not found in the latest commit | -m |
⚪ Off |
| CC013 | ai-attribution |
AI attribution policy violation | -m |
⚪ Off |
| CC014 | ai-disclosure |
AI assistance is not disclosed with an accepted trailer | -m |
⚪ Off |
| CC015 | ai-co-author |
An AI tool is credited as a co-author | -m |
⚪ Off |
| CC016 | ai-signoff |
An AI tool signed off the commit | -m |
⚪ Off |
Author rules (CC1xx)¶
Branch rules (CC2xx)¶
Run with -b / --branch.
Push and file rules (CC3xx)¶
The file rules run with -f / --files. They read only the paths and sizes
recorded in a commit — never file contents, which is a
different tool's job.
| Code | Name | Message | Check | Default |
|---|---|---|---|---|
| CC301 | no-force-push |
Force push is not allowed | --no-force-push |
⚪ Off |
| CC302 | file-size |
File exceeds the maximum size of {max_size} |
-f |
⚪ Off |
| CC303 | file-pattern |
File path matches a prohibited pattern | -f |
⚪ Off |
| CC304 | path-length |
File path exceeds {max_len} characters |
-f |
⚪ Off |
Tag rules (CC4xx)¶
Run with -t / --tag.
| Code | Name | Message | Check | Default |
|---|---|---|---|---|
| CC401 | tag |
The tag name does not match the required pattern | -t |
✅ On |
Commit message rules¶
message (CC001)¶
What it does
Checks that the commit message subject follows the
Conventional Commits specification:
<type>(<scope>)!: <description>.
A subject that a merge tool writes, such as git's Merge branch 'main' into
topic or GitHub's Merge pull request #12 from …, is accepted as it is;
whether merge commits are allowed at all is CC006's call. A subject
someone typed that merely starts with Merge, such as Merge the parser
rewrite, is judged like any other.
Why is this bad?
A free-form subject can only be read by a human. A structured one can be read by
tooling: release-drafting can group changes by type, semantic versioning can
infer whether a release is a patch, minor, or major, and git log becomes
filterable by area of the codebase. Once a fraction of the history is
unstructured, every consumer of that history needs a fallback path.
Example
Use instead:
Options
commit.conventional_commits— set tofalseto disable this rule.commit.allow_commit_types— the accepted<type>values.commit.message_pattern— a custom regex that replaces the generated Conventional Commits pattern entirely, for formats such as JIRA smart commits ("^PROJ-\\d+: .+").
subject-capitalized (CC002)¶
What it does
Checks that the description in the subject line starts with a capital letter.
The description is what follows a <type>(<scope>)!: prefix, and the prefix
is skipped only when Conventional Commits is on and the type is one of
allow_commit_types. Otherwise the subject is read from its first word, so
Added: support for X is judged on Added.
Why is this bad?
Nothing is inherently wrong with either casing — but mixing them is. A history
where half the subjects read fix: handle empty input and the other half read
fix: Handle empty input looks careless in git log --oneline, and gives
reviewers a pointless thing to comment on. This rule picks the capitalized
convention and enforces it.
Leave it off if your project deliberately uses lowercase descriptions, which is the more common convention among projects that follow Conventional Commits.
Example
Use instead:
Options
commit.subject_capitalized— set totrueto enable this rule.
subject-imperative (CC003)¶
What it does
Checks that the first word of the description is in the imperative mood —
fix, not fixed, fixes, or fixing.
The description is what follows a <type>(<scope>)!: prefix, and the prefix
is skipped only when Conventional Commits is on and the type is one of
allow_commit_types. Otherwise the subject is read from its first word, so
Added: support for X is judged on Added.
Why is this bad?
This is Git's own convention: a subject should complete the sentence "If
applied, this commit will ___". If applied, this commit will fixed a crash
does not read as English. Beyond grammar, the imperative form is the shortest of
the three, which matters on a line that tooling truncates around 50 characters.
Example
Use instead:
What it does not do
The rule reads the form of the first word, not a vocabulary, so it does not
need to recognise your verb. Anything that is not a past tense, a gerund or a
third-person singular is accepted — including verbs no word list contains, and
subjects that lead with a noun (fix: parser improvements), which have no mood
to get wrong.
The trade is that a word's form does not always tell you how it is being used.
fix: spelling in the docs is reported: spelling is a noun there, but it
wears the -ing of a gerund, and a rule that reads the ending cannot tell the
two apart.
Options
commit.subject_imperative— set totrueto enable this rule.
subject-max-length (CC004)¶
What it does
Checks that the subject line is at most a configured number of characters.
Why is this bad?
Long subjects get truncated by the tools that display them —
git log --oneline, git shortlog, GitHub's commit list, and most Git
GUIs all cut off somewhere between 50 and 72 columns. A subject that carries its
meaning past that point loses it exactly where people skim. Detail belongs in
the body, which nothing truncates.
Example
fix: handle an empty config file and also fix the unrelated crash in the branch parser that happens on Windows
Use instead:
fix: handle an empty config file
Also fixes the branch parser crash on Windows, which shared the
same root cause.
Options
commit.subject_max_length— the limit, in characters. Defaults to80.50and72are the other conventional choices.
subject-min-length (CC005)¶
What it does
Checks that the subject line is at least a configured number of characters.
Why is this bad?
Subjects like fix, wip, or . describe nothing. They are invisible in
a blame view and useless in a bisect session, and they are almost always the
result of a hurried commit rather than a deliberate one.
Example
Use instead:
Options
commit.subject_min_length— the minimum, in characters. Defaults to5.
allow-merge-commits (CC006)¶
What it does
Rejects merge commits — the Merge branch '...' commits that git pull
creates.
Why is this bad?
Merge commits created by git pull carry no information: they record that
someone synced, not that anything was decided. On a busy repository they can
outnumber real commits, which makes git log unreadable, adds branches for
git bisect to walk, and breaks the assumption behind
git log --first-parent. Projects that want a linear history rebase instead.
Example
Use instead:
Options
commit.allow_merge_commits— set tofalseto enable this rule.
allow-revert-commits (CC007)¶
What it does
Rejects the Revert "..." commits that git revert generates.
Why is this bad?
A generated revert subject describes the mechanics of the change and nothing
about the reason for it. Six months later, Revert "feat: add caching layer"
answers "what happened" but not the only question that matters: why the feature
was backed out, and whether it is safe to try again.
Example
Use instead:
fix: remove the caching layer
The cache served stale permissions after a role change (#412).
Reverts 4a1c9f2; re-land once invalidation is keyed on role version.
Options
commit.allow_revert_commits— set tofalseto enable this rule.
allow-empty-commits (CC008)¶
What it does
Rejects commits with an empty message.
Why is this bad?
A commit with no subject cannot be searched for, summarised, or reviewed, and once it is pushed, adding a message means rewriting history.
Options
commit.allow_empty_commits— set tofalseto enable this rule.
allow-fixup-commits (CC009)¶
What it does
Rejects fixup!, squash! and amend! commits, the ones git commit
--fixup, --squash and --fixup=amend: write.
Why is this bad?
These commits exist to be consumed by git rebase --autosquash before a
branch is merged. One that survives to the target branch means the autosquash
was forgotten — leaving behind a commit that, by construction, does not stand on
its own.
Example
Use instead:
Options
commit.allow_fixup_commits— set tofalseto enable this rule.
allow-wip-commits (CC010)¶
What it does
Rejects work-in-progress commits — subjects beginning with the word WIP or
with [WIP], in any case and whatever follows. Words that merely start with
the letters, such as wipe, pass, and so does a body that mentions WIP.
Why is this bad?
A WIP commit is an explicit statement that the change is not finished. That is useful on a local branch and wrong on a shared one, where every commit is something another developer may bisect through or build on.
Example
Use instead:
Options
commit.allow_wip_commits— set tofalseto enable this rule.
require-body (CC011)¶
What it does
Requires a non-empty body after the subject line.
A message file is judged as the message git will store: comment lines, and
everything from the git commit -v scissors line on, do not count. So a
subject-only commit written in the editor fails, although git's comment block
follows the subject when the hook runs.
Why is this bad?
The subject says what changed; the diff already says that too. The body says why — the constraint, the bug report, the rejected alternative. That reasoning is the one thing that cannot be recovered from the code later, and it is exactly what the next person to touch the change needs.
Example
Use instead:
fix: cap the retry backoff at 30s
The unbounded exponential backoff reached 45 minutes during the
incident on 2026-05-11, long after the upstream had recovered.
Options
commit.require_body— set totrueto enable this rule.
require-signed-off-by (CC012)¶
What it does
Requires a Signed-off-by: trailer in the commit message: a line of its own
that names a person and an <address>, as git commit --signoff writes it.
The same text inside the subject or a sentence of the body does not count.
Why is this bad?
Projects that use the Developer Certificate of Origin — the Linux kernel, and much of the CNCF — treat that trailer as the contributor's statement that they have the right to submit the code. A commit without it cannot be merged, so catching it locally saves a round trip through CI.
Example
Use instead:
git commit --signoff -m "fix: handle an empty config file"
# or fix the commit you already made
git commit --amend --signoff
Options
commit.require_signed_off_by— set totrueto enable this rule.
ai-attribution (CC013)¶
What it does
Rejects a commit whose message carries any AI attribution: a co-author or
sign-off line naming Claude Code, Copilot, Codex, Gemini, Cursor, Devin,
Aider, Windsurf, Tabby, Amp, OpenHands, Factory Droid or Jules, a disclosure
trailer such as Assisted-by: or Generated-by:, a vendor's own mark such
as 🤖 Generated with Claude Code, Amp's Amp-Thread-ID: trailer, the
Agent-Logs-Url: trailer the Copilot coding agent adds or Cursor's
Made-with: Cursor, and generic AI model names. Copilot is also recognised by its account's
N+Copilot@users.noreply.github.com address, so the coding agent's
copilot-swe-agent[bot] co-author line counts under any display name.
Why is this bad?
Some projects want their history to carry no AI attribution at all — the Kubernetes contributor guide takes that position, asking for disclosure in the pull request instead. This rule exists for projects that have made that decision and want it enforced mechanically rather than relitigated in every review.
Example
CC013 ai-attribution check failed ==> Claude Code
AI attribution is forbidden in this project — detected: Claude Code
Suggest: This project does not accept AI attribution in commit messages. Remove the AI trailer lines and re-commit.
Options
commit.ai_attribution—"forbid"enables this rule."ignore"(the default) disables it;"disclose"replaces it with CC014–CC016.
ai-disclosure (CC014)¶
What it does
Under ai_attribution = "disclose", requires that a commit showing signs of
AI assistance discloses it with one of the project's trailers — by default
Assisted-by: or Generated-by:. A vendor's co-author line, an AI sign-off,
a 🤖 Generated with mark, or a disclosure written with some other trailer
all count as signs; none of them counts as the disclosure.
A trailer that is there but says nothing — no value, or a value that does not
match ai_disclosure_pattern — fails too.
Why is this bad?
A project that allows AI assistance and asks for it to be disclosed has an
honour-system rule until something checks it.
Fedora and
FluxCD ask for Assisted-by:, as the
Linux kernel did
until its maintainers decided in October 2026 to drop the tag; the
Apache Software Foundation
recommends Generated-by:. The tools, meanwhile, stamp their own format on
the way out, so a contributor following the policy and one ignoring it produce
commits that look much the same.
Example
CC014 ai-disclosure check failed ==> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
AI assistance is not disclosed with Assisted-by or Generated-by — detected: Claude Code
Suggest: Disclose the tool with "Assisted-by: Claude Opus 5"
The suggestion keeps the name the tool gave itself, and --format json
carries the whole corrected message in fix.
Options
commit.ai_attribution— set to"disclose"to enable this rule.commit.ai_disclosure_trailers— the trailers that count as a disclosure. The first one is what a correction is written with.commit.ai_disclosure_pattern— a regex the disclosure's value must match, for a project that wants a particular format.
ai-co-author (CC015)¶
What it does
Under ai_attribution = "disclose", rejects a commit that credits an AI tool
as a person: a Co-authored-by: or Co-developed-by: trailer naming a
recognised tool.
Why is this bad?
A co-author line is a claim about who wrote the change, and it travels: GitHub
counts co-authors as contributors, and a squash merge carries the line into
the default branch. Projects that welcome AI assistance still commonly draw
the line here — pip,
attrs and Requests all say no AI in Co-authored-by: — because disclosure
and authorship are different claims.
Example
CC015 ai-co-author check failed ==> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An AI tool is credited as a co-author: Claude Code
Suggest: Use "Assisted-by: Claude Opus 5" in place of the co-author line
Options
commit.ai_attribution— set to"disclose"to enable this rule.commit.ai_disclosure_trailers— listingCo-authored-byhere says the project accepts the tool as a co-author, and this rule then passes.
ai-signoff (CC016)¶
What it does
Under ai_attribution = "disclose", rejects a commit whose Signed-off-by:
trailer names an AI tool.
Why is this bad?
A sign-off is a legal certification — the
Developer Certificate of Origin — and
only a person can make it. The Linux kernel's guidance says so outright: AI
agents must not add Signed-off-by tags, because they cannot certify the
DCO. No trailer list may name Signed-off-by, so this rule cannot be turned
off by accepting it as a disclosure.
Example
CC016 ai-signoff check failed ==> Signed-off-by: Claude <noreply@anthropic.com>
An AI tool signed off the commit: Claude Code
Suggest: Use "Assisted-by: Claude" in place of the AI sign-off, then sign off yourself (git commit --signoff)
Options
commit.ai_attribution— set to"disclose"to enable this rule.
Author rules¶
author-name (CC101)¶
What it does
Checks the committer's configured name against a pattern. The built-in pattern
accepts letters (including accented Latin characters), spaces, and
, . ' -, and always allows [bot] accounts.
Why is this bad?
When user.name is unset, Git falls back to the machine's account name.
Histories built in CI containers and on fresh VMs fill up with commits by
machine accounts — authorship that cannot be traced back to a person, which
matters for both code archaeology and compliance.
Example
Use instead:
Options
commit.author_name_pattern— a custom regex replacing the built-in pattern. For example,"^.+ .+$"to require a full name.
Note
The built-in pattern accepts a name that starts with a letter and goes on
with letters, digits, _, spaces and , . ' -, so account names such as
root, ubuntu or ec2-user still pass it; a name that starts with a
digit or holds other symbols (@, /, ...) is rejected. Set
author_name_pattern if you need something stricter.
author-email (CC102)¶
What it does
Checks the committer's configured email against a pattern. The built-in pattern
is ^.+@.+$, which only requires an @ with something on either side.
Why is this bad?
An address with no @ is not routable, so it breaks the link between a commit
and its author: forges cannot attribute the commit to an account, and
mailmap-based tooling cannot merge identities.
The built-in pattern is deliberately permissive — it is a sanity check, not a policy. Its real value comes from replacing it, which is how organisations require contributions to come from a corporate address.
Example
With author_email_pattern = "^.+@example\\.com$" configured:
Use instead:
Options
commit.author_email_pattern— the regex to match against. Defaults to^.+@.+$; set something like"^.+@example\\.com$"to require a company domain.
To reject an address instead of requiring one, use a negative lookahead. This
refuses GitHub's no-reply addresses (jane@users.noreply.github.com and
12345+jane@users.noreply.github.com), which cannot receive mail and can
lose their link to the author's account after a GitHub username change:
Note
Because the built-in pattern only looks for an @, local and placeholder
addresses such as root@localhost pass it. Set author_email_pattern
if you need to reject those.
Branch rules¶
branch (CC201)¶
What it does
Checks that the current branch name follows the
Conventional Branch specification:
<type>/<description>.
main, master, develop, HEAD, PR-*, and the gh-readonly-queue/*
branches GitHub's merge queue creates are always accepted.
By default only the type is checked, and any description after the / is
accepted. With require_description_grammar the description has to follow
the specification's grammar too: lowercase letters and digits, words joined
by single hyphens, dots only inside a word, as in release/v1.2.0.
Why is this bad?
A predictable prefix is something automation can act on: CI can skip expensive
jobs for docs/ branches, deployment workflows can key off release/, and
branch protection rules can be written per type. It also makes a list of a
hundred open branches scannable, which an unstructured list never is.
Example
Use instead:
Options
branch.conventional_branch— set tofalseto disable this rule.branch.allow_branch_types— the accepted<type>values. The default is a superset of the specification: the spec's types (feature,bugfix,hotfix,release,chore, their aliasesfeatandfix, and the AI agent prefixesai,claude,codex,copilotandcursor), plus the other Conventional Commit types,agents(the prefix VS Code's Agents window uses) and the bot prefixesdependabotandrenovate. Set it explicitly for strict spec-only validation.branch.require_description_grammar— set totrueto hold the description to the specification's grammar, sofeature/Add_Login,feature/new--loginandfeature/a/bare rejected. Off by default, because bot branches such asdependabot/npm_and_yarn/lodash-4.17.21do not follow it.branch.allow_branch_names— additional standalone names to accept, such as["staging", "production"].branch.ignore_authors— bypass the check for specific authors.
To check branches exactly as the specification does, set the spec's types and
the description grammar. Its trunk branches, main, master and develop,
are accepted already:
With that, commit-check --branch agrees with every case of the
specification's conformance suite
that git can create as a branch.
merge-base (CC202)¶
What it does
Checks that the current branch is rebased onto a target branch.
When the target matches no branch, neither locally nor under origin/, the
check is a skip, not a pass, and the JSON reason says
rebase target 'main' not found. A shallow CI clone without the target branch
is the usual cause; fetch the branch before the check.
Why is this bad?
A branch that has fallen behind is tested against code that no longer exists on the target. CI passing on it says little about whether it will pass after merging, and the failures it hides — a renamed function, a changed migration — surface on the target branch instead of the pull request.
Example
Use instead:
Options
branch.require_rebase_target— the target branch, for example"main". Unset by default, meaning no rebase requirement.
Push and file rules¶
no-force-push (CC301)¶
What it does
Blocks force pushes. Run it as a pre-push hook, where it reads the push
details from stdin, or with --no-force-push, where it compares the current
branch against its upstream.
Why is this bad?
A force push to a shared branch rewrites history that other people have already based work on. Their next pull produces conflicts against commits that no longer exist, and any commit pushed between their fetch and the force push is silently dropped. On a personal branch this is a routine part of rebasing; on a shared one it is a data-loss event.
Example
Use instead:
# on a shared branch, add a commit rather than rewriting
git revert <sha>
# on your own branch, at least refuse to clobber someone else's work
git push --force-with-lease
Options
push.allow_force_push— set tofalseto enable this rule.
file-size (CC302)¶
What it does
Rejects a commit that adds or updates a file larger than files.max_size. The
size read is the one recorded in the commit, not whatever the working tree
holds now, so the verdict is the same wherever the check runs.
Why is this bad?
Git stores every version of a file forever. A large binary committed once stays in the pack for the life of the repository, and every clone downloads it — including the clones of people who will never open it. Removing the file later does not shrink history; only a rewrite does, and by then everyone has already pulled it. Large assets belong in Git LFS or an artifact store.
Example
Options
files.max_size— largest a committed file may be, in bytes or with aKB/MB/GBsuffix. Empty (the default) disables this rule.
file-pattern (CC303)¶
What it does
Rejects a commit that touches a path matching any files.prohibited_patterns
entry. Patterns are fnmatch globs, matched against the full path and against
the file name alone, so a bare *.pem catches the file at any depth. Matching
is case-sensitive on every platform, like git pathspecs.
Why is this bad?
Some files are never meant to be in a repository: private keys, .env files,
credential dumps, build output. A path rule is the cheapest possible guard —
it costs one glob comparison and needs no knowledge of what is inside the file.
This organization once shipped a GitHub App private key as a committed .pem;
one line of configuration would have stopped that commit.
A path rule is not secret scanning. It cannot see a token pasted into a .txt
file, and it happily allows an empty secrets.pem. Pair it with a content
scanner such as gitleaks — each catches
what the other cannot.
Example
Options
files.prohibited_patterns— fnmatch patterns a committed path may not match. Empty (the default) disables this rule.
path-length (CC304)¶
What it does
Rejects a commit whose file paths exceed files.max_path_length characters.
Why is this bad?
Path length limits are a portability problem, not a style preference. Windows caps a path at 260 characters unless long paths are explicitly enabled, so a deeply nested file added on Linux can make the repository impossible to check out on a teammate's machine — and the failure surfaces at clone time, far from the commit that caused it.
Example
Options
files.max_path_length— longest a committed file path may be, in characters.0(the default) disables this rule.
Tag rules¶
tag (CC401)¶
What it does
Checks the names of the tags pointing at the commit under test against
tag.regex. The default is the
official SemVer pattern
with an optional leading v, so v1.2.3 and 1.2.3 both pass while v1.2
and release-1.2.3 do not.
A commit with no tag is a skip, not a failure: the rule governs how tags are named, and the absence of one is not a naming violation.
As a pre-push hook it checks the tags the push creates, under the name the
remote will give them: git push origin v1.0.0:refs/tags/release-1 is checked
as release-1. Deleting a tag is not checked.
Why is this bad?
Tags are the public interface of a release. Package managers, changelog
generators, and pre-commit's own rev: all resolve a version string to a
tag, and every one of them assumes a shape. A single release-2.0 among a
history of v1.x.y tags breaks version sorting silently — the tag still
exists, it just stops being found.
Example
Use instead:
Options
tag.regex— the pattern a tag name must match. An empty value disables the pattern match.
Configuration¶
Commit Check reads its settings from four places. When the same option is set in more than one, the first one listed wins:
- Command-line arguments —
--subject-imperative=true - Environment variables —
CCHK_SUBJECT_IMPERATIVE=true - A configuration file —
cchk.tomlorcommit-check.toml - Built-in defaults
The file carries the policy the project agreed on, the environment overrides it for a single CI job, and a flag overrides both for a single run.
Defaults are not "nothing"
Two different things decide whether a rule fires: whether you asked for
that check at all, and what the option defaults to. A check only runs when
its flag is passed — --message never evaluates branch rules — but once a
check is running, these apply with no configuration file present:
| Check | Enforced by default |
|---|---|
--message |
Conventional Commits (CC001), subject lengths of 5–80 characters (CC004, CC005), and an allow-list of ten commit types |
--branch |
Conventional Branch (CC201) and an allow-list of twenty-one branch types |
--author-name / --author-email |
The built-in name and email patterns (CC101, CC102) |
--tag |
SemVer tag names, with an optional leading v (CC401) |
Off until you turn them on: subject capitalization, imperative mood, required body and signoff, and rebase requirements.
The allow_* options are a mix, so read them individually rather than
assuming: allow_commit_types and allow_branch_types are allow-lists
that restrict from the start, while allow_merge_commits,
allow_revert_commits, allow_empty_commits, allow_fixup_commits,
allow_wip_commits and allow_force_push all default to permitting
everything. The Default column in the
rules reference is the full picture.
Where the config file lives¶
The file is TOML, and may be called cchk.toml or commit-check.toml. Commit
Check searches four locations and uses the first that exists:
cchk.tomlcommit-check.toml.github/cchk.toml.github/commit-check.toml
Run from a subdirectory of a repository, where none of the four is in the current directory, it looks in the same four places at the top of the repository's work tree. The current directory still comes first, so a package in a monorepo can keep a config of its own.
Pass --config to point at one directly and skip the search:
Why .github/
Putting the file in .github/ keeps the repository root uncluttered and
matches where Dependabot and Renovate already keep theirs. Commit Check
treats both locations identically.
Editor autocompletion
The TOML schema is published on SchemaStore,
so VS Code (via
Even Better TOML),
PyCharm and IntelliJ offer completion, validation and inline documentation
for cchk.toml with nothing to configure.
Settings nothing reads¶
A key that no check reads, usually a misspelling, leaves its rule at the default. Commit Check names each one on stderr, with the closest real name:
$ echo "feat: add streaming support" | commit-check -m
⚠ unknown setting [commit] subject_max_lenght is ignored; did you mean subject_max_length?
It is a warning, not an error: the exit code and the JSON on stdout do not
change. The top-level warn list and the GitHub App's
[jira] and
[pull_request] sections
are read elsewhere and are not reported. The Python API reads no file at all
(it takes a config dict), and the MCP server loads the file itself without
reporting unknown settings.
Inheriting a shared config¶
An organization can keep one base policy and have every repository build on it.
Point inherit_from at the shared file; the parent loads first, and anything
set locally overrides it. The key itself is never passed to the validation
engine.
The value can be a GitHub shorthand, a local path or an HTTPS URL:
| Form | Example |
|---|---|
| GitHub, default branch | github:owner/repo:path/to/cchk.toml |
| GitHub, pinned branch | github:owner/repo@main:path/to/cchk.toml |
| Local file | ../../shared/org-cchk.toml |
| HTTPS URL | https://example.com/shared/cchk.toml |
Inheritance fails open
If the target is unreachable or the format is not recognized, Commit Check
ignores the inheritance and uses the local configuration alone, printing a
one-line ⊘ inherit_from ... could not be loaded notice on stderr; the exit
code does not change. Plain HTTP URLs are rejected the same way. Only one
level is followed: an inherit_from inside the parent is ignored, with a
notice. A repository that stops inheriting still passes its own checks, so
pin the branch when the policy matters.
Pull requests: every commit, or the squash message¶
By default the GitHub App checks every commit of a pull request on its own. A team that squash-merges does not keep those commits: what lands on the base branch is one commit whose message GitHub builds from the pull request. Checking the drafts instead of the result is the reason teams turn commit rules off, so the App can check the result:
With check = "squash" a pull request gets one Commit Check result on its
head commit, and it is the message a squash merge would produce, built the way
GitHub builds it from the repository's merge settings (Settings → General →
Pull Requests):
| Repository setting | Title checked | Body checked |
|---|---|---|
| Default to pull request title | The pull request title | The pull request description, the commit messages, or nothing, per the setting |
| Default message, one commit | That commit's title | That commit's body |
| Default message, several commits | The pull request title | The commit messages, one per bullet |
The pull request number GitHub appends to the title (… (#42)) is part of what
is checked, so a subject-length rule sees the real subject. Editing the title
or description re-runs the check; pushes to the branch are not checked
commit by commit. Author checks run on the head commit, the branch check on
the head branch.
check = "commits" is the default and the behaviour of the CLI, the
pre-commit hook and the Action, which have no pull request to read. The
setting is read by the App only.
Report a rule without enforcing it¶
A rule is normally on or off. warn gives it a third setting: run, report the
finding in full, and never fail the run. It is a top-level key, so it goes
before the first section, and it names a check or its rule ID in any case:
A warned rule prints the same block as a failure with warning in place of
failed, no rejection banner, and one closing line saying the run is not
failed by it; with --compact it is one [WARN] line. A one-line notice on
stderr names the warned rules, so a hook that exits 0 after red-looking
output is not mistaken for a broken hook. The exit code counts only enforced
rules. In --format json the check's status is warn, the top-level
status stays pass unless an enforced rule failed, and warnings counts
them — see Reading the JSON.
A name that matches no rule is a configuration error that lists the known rules, so a typo cannot leave a rule silently enforced. Rules not listed behave as before.
This is how a team adopts a rule gradually: turn it on as a warning, watch
what it catches for a week, then drop it from warn once the history is
clean. It is also how a shared config carries both
kinds of rule at once — the ones every repository must satisfy, and the ones
it is asking teams to move towards. A repository's own warn list replaces
the shared one, as any inherited key does.
A worked example¶
Every line below that differs from the built-in default is marked, so it is clear what this file changes:
# changed: report the branch rule without enforcing it (nothing is warned by default)
warn = ["branch"]
[commit]
# https://www.conventionalcommits.org
conventional_commits = true
# message_pattern = "" # optional: a custom regex, replacing the above
subject_capitalized = false
subject_imperative = true # changed: off by default
subject_max_length = 80
subject_min_length = 5
# changed: a subset of the default list, which also has perf, build and ci
allow_commit_types = ["feat", "fix", "docs", "style", "refactor", "test", "chore"]
allow_merge_commits = true
allow_revert_commits = true
allow_empty_commits = false # changed: allowed by default
allow_fixup_commits = true
allow_wip_commits = false # changed: allowed by default
require_body = false
require_signed_off_by = false
ai_attribution = "disclose" # changed: "ignore" by default; see the AI policy guide
# ignore_authors = [] # optional: bypass all commit checks for these authors
[push]
allow_force_push = true # set false to block force pushes
[branch]
# https://conventionalbranch.org
conventional_branch = true
# changed: the spec's five purpose types only. The default is a superset —
# these plus their aliases feat and fix, AI agent prefixes, the other
# Conventional Commit types and bot prefixes — so setting this at all narrows
# it. Omit the line to accept all of them.
allow_branch_types = ["feature", "bugfix", "hotfix", "release", "chore"]
# true also holds the part after "/" to the spec's grammar
require_description_grammar = false
# allow_branch_names = [] # optional: extra standalone names, e.g. ["staging"]
# require_rebase_target = "main" # optional: no rebase requirement by default
# ignore_authors = [] # optional: as above, for branch checks
allow_* options describe what is permitted
They read backwards from most linters. allow_wip_commits = false is the
setting that rejects WIP commits; leaving it at its default of true
lets them through.
Command-line arguments¶
Every option can be set as a flag, which is what makes a TOML file optional
entirely — useful when the policy lives in .pre-commit-config.yaml instead.
| Type | Form |
|---|---|
| Boolean | --option-name=true / --option-name=false |
| Integer | --option-name=80 |
| List | --option-name=value1,value2,value3 |
| String | --option-name=value |
$ commit-check --message --subject-imperative=false
$ commit-check --message --subject-max-length=72
$ commit-check --message --allow-commit-types=feat,fix,docs
$ commit-check --branch --allow-branch-types=feature,bugfix,hotfix
Used from a hook definition, with no config file anywhere in the repository:
Environment variables¶
Any option can also be set through the environment, which is the practical way
to vary policy per CI job without editing the file. Uppercase the option name,
replace hyphens with underscores, and prefix CCHK_:
$ export CCHK_SUBJECT_MAX_LENGTH=72
$ export CCHK_ALLOW_COMMIT_TYPES=feat,fix,docs,chore
$ CCHK_SUBJECT_MAX_LENGTH=100 commit-check --message
The full mapping between the three forms:
| TOML Config | Environment Variable | CLI Argument |
|---|---|---|
conventional_commits = true |
CCHK_CONVENTIONAL_COMMITS=true |
--conventional-commits=true |
message_pattern = "^PROJ-\\d+: .+" |
CCHK_MESSAGE_PATTERN=^PROJ-\\d+: .+ |
N/A (config file only) |
subject_capitalized = false |
CCHK_SUBJECT_CAPITALIZED=false |
--subject-capitalized=false |
subject_imperative = true |
CCHK_SUBJECT_IMPERATIVE=true |
--subject-imperative=true |
subject_max_length = 80 |
CCHK_SUBJECT_MAX_LENGTH=80 |
--subject-max-length=80 |
subject_min_length = 5 |
CCHK_SUBJECT_MIN_LENGTH=5 |
--subject-min-length=5 |
allow_commit_types = ["feat", "fix"] |
CCHK_ALLOW_COMMIT_TYPES=feat,fix |
--allow-commit-types=feat,fix |
allow_merge_commits = true |
CCHK_ALLOW_MERGE_COMMITS=true |
--allow-merge-commits=true |
allow_revert_commits = true |
CCHK_ALLOW_REVERT_COMMITS=true |
--allow-revert-commits=true |
allow_empty_commits = false |
CCHK_ALLOW_EMPTY_COMMITS=false |
--allow-empty-commits=false |
allow_fixup_commits = true |
CCHK_ALLOW_FIXUP_COMMITS=true |
--allow-fixup-commits=true |
allow_wip_commits = false |
CCHK_ALLOW_WIP_COMMITS=false |
--allow-wip-commits=false |
require_body = false |
CCHK_REQUIRE_BODY=false |
--require-body=false |
require_signed_off_by = false |
CCHK_REQUIRE_SIGNED_OFF_BY=false |
--require-signed-off-by=false |
ignore_authors = ["bot"] |
CCHK_IGNORE_AUTHORS=bot,user |
--ignore-authors=bot,user |
author_email_pattern = '^.+@example\.com$' |
CCHK_AUTHOR_EMAIL_PATTERN=^.+@example\.com$ |
--author-email-pattern=^.+@example\.com$ |
author_name_pattern = '^.+ .+$' |
CCHK_AUTHOR_NAME_PATTERN=^.+ .+$ |
--author-name-pattern=^.+ .+$ |
conventional_branch = true |
CCHK_CONVENTIONAL_BRANCH=true |
--conventional-branch=true |
allow_branch_types = ["feature"] |
CCHK_ALLOW_BRANCH_TYPES=feature,bugfix |
--allow-branch-types=feature,bugfix |
allow_branch_names = ["staging", "production"] |
CCHK_ALLOW_BRANCH_NAMES=staging,production |
--allow-branch-names=staging,production |
require_description_grammar = true |
CCHK_REQUIRE_DESCRIPTION_GRAMMAR=true |
--require-description-grammar=true |
require_rebase_target = "main" |
CCHK_REQUIRE_REBASE_TARGET=main |
--require-rebase-target=main |
allow_force_push = true |
CCHK_ALLOW_FORCE_PUSH=true |
--no-force-push (sets allow_force_push to false) |
ai_attribution = "forbid" |
CCHK_AI_ATTRIBUTION=forbid |
--ai-attribution=forbid |
ai_disclosure_trailers = ["Assisted-by"] |
CCHK_AI_DISCLOSURE_TRAILERS=Assisted-by |
--ai-disclosure-trailers=Assisted-by |
ai_disclosure_pattern = '^\S+/\S+$' |
CCHK_AI_DISCLOSURE_PATTERN=^\S+/\S+$ |
--ai-disclosure-pattern='^\S+/\S+$' |
ignore_authors = ["bot"] (in branch section) |
CCHK_BRANCH_IGNORE_AUTHORS=bot,user |
--branch-ignore-authors=bot,user |
regex = "^v\\d+\\.\\d+\\.\\d+$" (in tag section) |
CCHK_TAG_REGEX=^v\\d+\\.\\d+\\.\\d+$ |
--tag-regex=^v\\d+\\.\\d+\\.\\d+$ |
max_size = "5MB" (in files section) |
CCHK_FILES_MAX_SIZE=5MB |
--files-max-size=5MB |
prohibited_patterns = ["*.pem"] (in files section) |
CCHK_FILES_PROHIBITED_PATTERNS=*.pem,.env |
--files-prohibited-patterns=*.pem,.env |
max_path_length = 250 (in files section) |
CCHK_FILES_MAX_PATH_LENGTH=250 |
--files-max-path-length=250 |
Which value wins¶
The four sources layer, so the same option can be set in several at once. Only the highest-priority one takes effect:
$ grep subject_max_length cchk.toml
subject_max_length = 100
$ export CCHK_SUBJECT_MAX_LENGTH=80
$ commit-check --message --subject-max-length=50
The limit applied is 50 — the flag beats the environment, which beats the file. Nothing warns about the values that lost, which is worth remembering when a setting in the file appears to have no effect.
Every option¶
Types are as TOML understands them. A pattern, a subject length, or a type or name list given a value of another type stops the run with exit 2 and names the setting:
$ echo "feat: add streaming support" | commit-check -m
Error: [commit] subject_max_length must be a whole number, got '50'
allow_commit_types, allow_branch_types and allow_branch_names also take
one comma-separated string, as --allow-commit-types does. A boolean has to be
a TOML boolean: "false" in quotes is a non-empty string, and it counts as
true.
A default shown as "" means the option is
unset, which is never the same as the check being off — but it does not mean
the same thing twice, so read the description rather than the cell:
message_patternunset leavesconventional_commitsto generate the pattern. CC001 still runs.author_name_patternunset falls back to the built-in name pattern. CC101 still runs.require_rebase_targetunset is the one case where the check really does not run — there is no branch to compare against.
| Section | Option | Type | Default | Description |
|---|---|---|---|---|
| commit | conventional_commits | bool | true | Enforce Conventional Commits specification. |
| commit | message_pattern | str | "" (no custom pattern) | Custom regex pattern for commit message validation. When set, this pattern replaces the auto-generated Conventional Commits regex entirely, making it possible to enforce custom formats such as JIRA smart commits (e.g., "^PROJ-\\d+: .+"). When message_pattern is set (non-empty) it takes precedence over conventional_commits. |
| commit | subject_capitalized | bool | false | Subject must start with a capital letter. |
| commit | subject_imperative | bool | false | Subject must be in imperative mood. Judged on the first word's form, so a verb no list contains is still accepted — see CC003. |
| commit | subject_max_length | int | 80 | Maximum length of the subject line. |
| commit | subject_min_length | int | 5 | Minimum length of the subject line. |
| commit | allow_commit_types | list[str] | ["feat", "fix", "docs", "style", "refactor", "test", "chore", "perf", "build", "ci"] | Allowed commit types when conventional_commits is true. |
| commit | allow_merge_commits | bool | true | Allow merge commits. |
| commit | allow_revert_commits | bool | true | Allow revert commits. |
| commit | allow_empty_commits | bool | true | Allow empty commits. |
| commit | allow_fixup_commits | bool | true | Allow fixup commits: fixup!, squash! and amend! subjects. |
| commit | allow_wip_commits | bool | true | Allow work-in-progress commits (e.g., "WIP: |
| commit | require_body | bool | false | Require a body in the commit message. |
| commit | ignore_authors | list[str] | [] (none ignored) | List of commit authors or co-authors (Co-authored-by: lines) to bypass all commit checks. Useful for bots (e.g., "dependabot[bot]", "coderabbitai[bot]"). |
| commit | author_email_pattern | str | ^.+@.+$ |
Custom regex for the author email check. When empty, the built-in default pattern is used. This option only takes effect when the author_email check is enabled (-e / --author-email). |
| commit | author_name_pattern | str | "" (built-in default) | Custom regex for the author name check. When empty, the built-in default pattern is used (it is not disabled). This option only takes effect when the author_name check is enabled (-n / --author-name). |
| commit | require_signed_off_by | bool | false | Require "Signed-off-by" line in the commit message footer. |
| commit | ai_attribution | str | "ignore" | AI attribution policy. "forbid" rejects any commit carrying AI attribution — a co-author or sign-off line naming a known tool, a disclosure trailer, a vendor mark (CC013). "disclose" accepts AI assistance that is disclosed with one of ai_disclosure_trailers (CC014) and rejects the tool as a co-author (CC015) or as a sign-off (CC016). "ignore" disables the check. Any other value is a configuration error. |
| commit | ai_disclosure_trailers | list[str] | ["Assisted-by", "Generated-by"] | The trailers that disclose AI assistance under "disclose". The first one is what a correction is written with. Listing Co-authored-by says the project accepts the tool as a co-author, and CC015 then passes. Signed-off-by is refused: only a person can certify the DCO. |
| commit | ai_disclosure_pattern | str | "" (any value) | A regex the disclosure trailer's value must match under "disclose", e.g. '^\S+/\S+$' for an agent/model format. Empty accepts any value, but a trailer with no value at all is reported whatever the pattern is. |
| branch | conventional_branch | bool | true | Enforce Conventional Branch specification. |
| branch | allow_branch_types | list[str] | ["feature", "bugfix", "hotfix", "release", "chore", "feat", "fix", "build", "ci", "docs", "perf", "refactor", "style", "test", "ai", "claude", "codex", "copilot", "cursor", "agents", "dependabot", "renovate"] | Allowed branch types when conventional_branch is true. The default is a superset of the Conventional Branch spec: the spec's types (feature, bugfix, hotfix, release, chore, their aliases feat and fix, and the AI agent prefixes ai, claude, codex, copilot, cursor) plus the other Conventional Commit types (build, ci, docs, perf, refactor, style, test), agents (the prefix VS Code's Agents window uses) and bot prefixes (dependabot, renovate). For strict spec-only validation, set this option explicitly to the spec's types; CC201 has the full config. |
| branch | require_description_grammar | bool | false | Also hold the description after <type>/ to the Conventional Branch grammar: lowercase letters and digits, words joined by single hyphens, dots only inside a word. Off by default, because bot branches such as dependabot/npm_and_yarn/lodash-4.17.21 do not follow it. See CC201. |
| branch | allow_branch_names | list[str] | [] (empty list) | Additional standalone branch names allowed when conventional_branch is true (e.g., ["staging", "production"]). By default, main, master, develop, HEAD, PR-* and gh-readonly-queue/* (GitHub merge queue branches) are always allowed. |
| branch | require_rebase_target | str | "" (no requirement) | Target branch for rebase requirement. If not set, no rebase validation is performed. |
| push | allow_force_push | bool | true | Allow force pushes. Set to false to block force pushes when used as a pre-push hook or with --no-force-push. |
| branch | ignore_authors | list[str] | [] (none ignored) | List of authors to ignore (i.e., always allow). |
| tag | regex | str | ^v?(?:0\|[1-9]\d*)\.(?:0\|[1-9]\d*)\.(?:0\|[1-9]\d*)(?:-(?:0\|[1-9]\d*\|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0\|[1-9]\d*\|\d*[a-zA-Z-][0-9a-zA-Z-]*))*)?(?:\+[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*)?$ |
Pattern a tag name must match. The default is the official SemVer pattern with an optional leading v, so both v1.2.3 and 1.2.3 pass. An empty value disables the pattern match. |
| files | max_size | str | "" (disabled) | Largest a committed file may be, in bytes or with a KB/MB/GB suffix (binary units, so 5MB is 5 × 1024²). Empty disables the rule. |
| files | prohibited_patterns | list[str] | [] (empty list) | fnmatch patterns a committed path may not match, e.g. ["*.pem", ".env", "id_rsa*"]. A bare pattern also matches the file name at any depth. Matching is case-sensitive on every platform, like git pathspecs. Empty disables the rule. |
| files | max_path_length | int | 0 (disabled) | Longest a committed file path may be, in characters. 0 disables the rule. |
| pull_request | check | str | "commits" | GitHub App only. "commits" checks every commit of a pull request on its own; "squash" checks the one message a squash merge would land, built from the repository's merge settings. See Pull requests. |
Changelog¶
All notable changes to this project will be documented in this file.
Full changelog available at GitHub releases.
Highlights¶
The releases worth knowing about, newest first. Each links to its full entry below and to the page that documents the feature properly.
| Version | What changed | Documented in |
|---|---|---|
| 2.19.5 | GitHub merge queue branches accepted by default; aider's default co-author trailer recognised; exit 2 when git cannot read the repository | CC201 · CC013 · Exit codes |
| 2.19.4 | agents/ branches accepted by default; Cursor's Made-with: Cursor trailer recognised |
CC201 · CC013 |
| 2.19.3 | A quoted "false" for a true/false setting refused instead of read as true |
Every option |
| 2.19.2 | Copilot coding agent commits recognised; config values of the wrong type refused; commit text printed inert | CC013 · Every option · Color and links |
| 2.19.1 | develop accepted by default; stricter merge-subject, fixup, WIP and sign-off checks |
CC201 · CC001 · CC009 |
| 2.19.0 | Branch descriptions held to the Conventional Branch grammar; Amp, OpenHands, Factory Droid and Jules recognised | CC201 · CC013 |
| 2.18.3 | --fix corrects a message in place; --branch reads GitLab, Bitbucket, Azure and Jenkins variables |
Fixing a message in place · Other CI |
| 2.18.0 | AI disclosure policy: three rules, and the correction written for you | Policy guides · CC014 |
| 2.17.0 | Concrete corrections for mechanical slips; a warn level per rule |
Reading the JSON · Report without enforcing |
| 2.16.0 | Tag name validation, and file size, path and pattern policies | CC401 · CC302–CC304 |
| 2.15.1 | Color and rule-ID links appear only where they render; NO_COLOR honoured |
Color and links |
| 2.15.0 | --rev names the commit under test; skipped checks are named on stderr |
Command-line recipes |
| 2.14.0 | CC003 judges imperative mood by a word's form, not by a list of verbs | CC003 |
| 2.13.1 | JSON output reports the checked value for passing checks | Output for scripts and CI |
| 2.13.0 | Stable rule IDs in terminal output and JSON | Rules reference |
| 2.12.0 | Author name and email patterns became configurable | CC101 · CC102 |
| 2.11.0 | AI attribution policy | Policy guides |
| 2.10.0 | dependabot/ and renovate/ branches accepted by default |
CC201 |
| 2.9.0 | AI agent branch prefixes accepted by default | CC201 |
| 2.8.0 | Custom message_pattern; Python 3.9 dropped |
CC001 |
| 2.7.0 | Force push blocking | CC301 |
| 2.6.0 | --format json, --compact, --no-banner |
Command-line recipes |
| 2.5.0 | Organization-wide config with inherit_from |
Across an organization |
| 2.0.0 | Configuration moved from YAML to TOML — breaking | Migrating from v1 |
v2.19.5 (2026-11-04)¶
Fixed¶
-
GitHub merge queue branches are accepted by default — the merge queue runs its
merge_groupchecks on a branch it creates,gh-readonly-queue/<base>/pr-<N>-<sha>, and CC201 rejected it. A repository with a merge queue and Commit Check as a required check failed every queue entry. The name is now accepted next tomain,master,develop,HEADandPR-*, whateverallow_branch_typesorallow_branch_namessay. See PR #653. -
CC013–CC016 recognise aider's default co-author trailer — since v0.85 aider adds
Co-authored-by: aider (<model>) <aider@aider.chat>to every commit, and the model in parentheses kept it from matching, soforbidanddiscloselet it through. It is now matched by its address, whatever model it names, and underdisclosethe correction isAssisted-by: aider (<model>). See PR #652. -
A directory git cannot read stops the run with exit 2 — outside a repository, or in one git refuses for "dubious ownership" (common in containers and on self-hosted agents), every check was skipped and the run exited 0, so CI went green without checking anything. Now git's own reason is printed, including its
safe.directoryhint, and the run exits 2. A piped or file message, and a repository with no commits yet, work as before. See PR #654 and Exit codes.
v2.19.4 (2026-10-29)¶
Changed¶
agents/branches are accepted by default — VS Code's Agents window names its branchesagents/<description>, and its users cannot change the prefix, so CC201 failed them unlessallow_branch_typeslistedagents. It is now in the default list. The Conventional Branch specification plans to accept it as an alias ofai(conventional-branch#196). This only widens what passes. See PR #645.
Fixed¶
- CC013–CC016 recognise Cursor's
Made-with: Cursortrailer — Cursor marks the commits it writes with it, and every policy let it through. It now counts as Cursor's own mark, like the Copilot coding agent'sAgent-Logs-Url::forbidrejects it, and underdisclosethe correction appendsAssisted-by: Cursor. OtherMade-with:values are left alone. See PR #643.
v2.19.3 (2026-10-21)¶
Fixed¶
- A true/false setting written as a string stops the run — a quoted
value such as
conventional_commits = "false"was read as true, so the rule stayed on and rejected the commit. Any value of a true/false setting that is nottrueorfalseis now a configuration error (exit 2) that names the setting, like the other wrong-type values in 2.19.2. CLI flags andCCHK_*variables were already read correctly. See PR #636.
v2.19.2 (2026-10-18)¶
Changed¶
-
A config value of the wrong type stops the run —
message_pattern = 5crashed,subject_max_length = truebecame a one-character limit,subject_max_length = "50"turned the rule off without a word, andallow_commit_types = "feat,fix"was read letter by letter, sof: xpassed. A pattern, a subject length, or a type or name list of the wrong type is now a configuration error (exit 2) that names the setting. A comma-separated string inallow_commit_types,allow_branch_typesorallow_branch_namesis split the way--allow-commit-typessplits it. A config that relied on the old reading now stops with that error, so check yours when you upgrade. See PR #624 and Every option. -
CC202 skips when the rebase target does not resolve — a
require_rebase_targetthat matched no branch, because of a typo or a shallow clone withoutorigin/main, reported a pass without comparing anything. It is now a skip, and the JSONreasonnames the missing target. The exit code stays 0. See PR #617. -
CC401 checks the tag name the remote receives — in a
pre-pushhook,git push origin v1.0.0:refs/tags/bad-namewas checked asv1.0.0, sobad-namereached the remote. The remote name is checked now. See PR #615.
Fixed¶
-
CC013–CC016 recognise the Copilot coding agent — its commits carry an
Agent-Logs-Url:trailer, and a squash merge of its pull request adds acopilot-swe-agent[bot]co-author line. Both passedforbidanddisclose. They are now read as GitHub Copilot, and underdisclosethe correction appendsAssisted-by: GitHub Copilot. See PR #632. -
Commit text is printed inert — a failing message, branch or tag name was printed as written, so escape sequences reached the terminal, and a body line starting with
::reached a GitHub Actions runner as a workflow command. Text output now shows control characters as\xNNand puts a backslash before such a::.--format jsonis unchanged. See PR #620 and Color and links. -
Three patterns run in linear time — the CC001 header correction and the AI trailer matching could take seconds on a line padded with spaces. Verdicts are unchanged, except that a
Co-authored-by:line with an empty name before(aider)no longer counts. See PR #626.
v2.19.1 (2026-10-16)¶
Changed¶
-
developis accepted by default — the Conventional Branch specification countsdevelopas a trunk branch besidemainandmaster, but CC201 failed it unlessallow_branch_nameslisted it. It now passes likemainandmaster, and the spec-strict config needs only the types and the description grammar. This only widens what passes. See PR #612. -
Only merge subjects a tool wrote are exempt — any subject starting with
Mergeskipped CC001 and the subject rules. Now only the forms git, GitHub, GitLab and Bitbucket write are exempt, such asMerge branch 'x' into 'y'andMerge pull request #12 from …. A typed subject such asMerge the parser rewriteis judged like any other, so it now fails CC001. See PR #619. -
A commit message file is checked as git will store it — the
commit-msghook runs before git strips comments, so git's comment block, and withgit commit -vthe whole diff, were read as part of the message. Comment lines and everything from the scissors line on are now dropped first. A subject-only commit written in the editor now fails CC011 whenrequire_bodyis on, and aSigned-off-by:line in the diff no longer counts.--fixkeeps the comment and diff blocks in the file. See PR #618.
Fixed¶
-
CC009 rejects
squash!andamend!too — withallow_fixup_commits = falseonlyfixup!commits failed. See PR #613. -
CC010 catches every WIP marker —
WIPfollowed by a newline, a tab or a dash passed. Any subject that begins with the wordWIPor[WIP], in any case, now fails;wipe the cachestill passes. See PR #616. -
CC012 reads only a real trailer —
Signed-off-by:text anywhere in the message passed, including the subject and a line with no name. The check now needs a trailer line with a name and an address, and it runs in linear time on any input. See PR #621. -
CC002 and CC003 skip only a real type prefix — any leading
word:was skipped, even withconventional_commits = false, soAdded: support for Xpassed the imperative check. The prefix is now skipped only when Conventional Commits is on and the type is allowed. See PR #622. -
CC013 sees two more AI co-author forms — a trailer with a blank before the colon,
Co-authored-by : Claude <…>, which git still reads as a trailer, and the line Cursor's agent writes,Co-authored-by: Cursor Agent <cursoragent@cursor.com>. See PR #623. -
The config is found from a subdirectory — run below the top of a repository, commit-check found no config and used the built-in defaults without saying so. It now falls back to the top of the work tree, and a relative
inherit_fromis read next to the config that names it. See PR #625 and Where the config file lives. -
A parent config is read up to 1 MiB —
inherit_fromnow refuses a larger parent, or a local path that is not a regular file, and continues with the local config as it does when a parent is unreachable. See PR #614 and Across an organization.
v2.19.0 (2026-10-14)¶
Added¶
-
require_description_grammarchecks what follows the branch type —conventional_branchchecks the<type>/prefix and accepted anything after it, sofeature/Add_Login,fix/header_bugandrelease/v1.-2.0all passed. With[branch] require_description_grammar = truethe description must follow the specification's grammar: lowercase letters and digits, words joined by single hyphens, dots only inside a word. It is off by default, because bot branches such asdependabot/npm_and_yarn/lodash-4.17.21do not follow that grammar. See PR #581 and CC201. -
Amp, OpenHands, Factory Droid and Jules are recognised — co-author and sign-off lines naming them (
Amp <amp@ampcode.com>,openhands,factory-droid[bot],google-labs-jules[bot]) and Amp'sAmp-Thread-ID:trailer now count as AI attribution, for CC013 and for thediscloserules alike. People named Jules or Amp are not flagged. See PR #606 and Forbidding AI-attributed commits. -
--format jsonsays why a check was skipped — a check skipped because the author or a co-author is on anignore_authorslist carries areason, such asauthor dependabot[bot] is in [commit].ignore_authors. Every check has the key; it is empty when there is no reason to give. See PR #605 and When a check is skipped.
Fixed¶
-
check-tagchecks the tags a push sends — under pre-commit the hook judged the tags onHEAD, so a bad tag sent bygit push --follow-tagsfrom another branch got through, and a tag push could be rejected over a local tag onHEADthat it did not carry. It now checks the tag a tag push names, and on a branch push the tags on every commit the push carries. See PR #603. -
A GitHub Actions tag build has no branch to judge — on a tag push,
GITHUB_REF_NAMEholds the tag, so--branchfailed CC201 onv1.2.0. A tag build now falls back toHEAD, which is always accepted, as tag builds on GitLab, Bitbucket, Azure and Jenkins already did. See PR #603.
v2.18.3 (2026-10-07)¶
Added¶
-
--fixcorrects a commit message in place — when every message check the commit failed has a mechanical fix, the ones--format jsonreports infix, the message file is rewritten and the check passes, soFix: add streaming supportis recorded asfix: add streaming support. Anything that takes a judgment, such as a subject with no type at all, leaves the file as it was and is rejected as before. It needs a message file, so stdin and--revare a usage error, and--dry-runrewrites nothing. With pre-commit it isargs: [--fix]oncheck-message. See PR #594 and Fixing a message in place. -
--branchreads the branch from GitLab, Bitbucket, Azure and Jenkins — on a detached checkout git names no branch, and only GitHub's variables were read, so elsewhere the name becameHEAD, which is always allowed, and every branch passed. Each CI's own variables are now read, a merge or pull request's source branch first, and Jenkins' only inside a Jenkins build that is not a tag build. A pipeline that runs--branchon a long-lived branch such asdevelopnow has that name judged;allow_branch_namescan allow it. See PR #600 and GitLab CI, Bitbucket and Azure Pipelines. -
A setting nothing reads is named — a misspelt key in
cchk.tomlwas ignored in silence, and its rule stayed at the default. Each one is now reported on stderr with the closest real name, as in⚠ unknown setting [commit] subject_max_lenght is ignored; did you mean subject_max_length?. The exit code and the JSON do not change. See PR #593 and Settings nothing reads.
v2.18.2 (2026-09-28)¶
Fixed¶
-
A piped message is only the message — next to
--message, piped stdin was read by every requested check, so on a validfeature/streaming-supportbranchprintf 'feat: add streaming support\n' | commit-check --message --branchfailed CC201 on the message text, and the author and tag checks did the same. The other checks now read git. A value piped into one check on its own, such asecho feature/x | commit-check --branch, is still what that check judges. See PR #579. -
CC301 reports a skip when it has nothing to compare — with no push refs, no upstream, or only malformed ref lines, the force-push check reported a pass. It now prints
⊘ skipped (not validated): no-force-pushon stderr and reports"skip"in--format jsonand fromvalidate_push. Exit codes do not change. See PR #591.
v2.18.1 (2026-09-24)¶
Changed¶
- Python 3.15 is supported. CI now builds and installs the wheel on 3.15 alongside 3.10–3.14, on Linux, macOS and Windows, and the package declares it. Contributed by @itzzdev09. See PR #583.
v2.18.0 (2026-09-13)¶
Added¶
-
AI attribution gained a third policy,
"disclose"— for the projects that welcome AI assistance and ask to be told about it. It turns on three rules, one per condition, so any of them can be demoted to a warning while the others keep enforcing: the assistance is disclosed with one ofai_disclosure_trailers(CC014, defaultAssisted-byandGenerated-by), the tool is not credited as a co-author (CC015), and it did not sign off the commit (CC016) — a sign-off certifies the DCO, which only a person can do. A failing check carries the correction: the vendor's co-author line rewritten as the project's disclosure trailer, keeping the name the tool gave itself.ai_disclosure_patternasks for a particular format, such asagent/model. The default stays"ignore", and anai_attributionvalue that is none of the three is now a configuration error rather than a silently disabled check. See PR #575 and Asking for disclosure instead. -
A failed check names the value it rejected, and by how much — a long subject reports
Subject is 96 characters; it must be at most 80 charactersand how many to cut, a custommessage_patternis echoed back, and the rejection banner says whether it was the commit, the branch, the tag or the push that was rejected. See PR #571. -
A specification named in an error links to it on terminals that render hyperlinks, so
Conventional CommitsandConventional Branchare clickable instead of trailing a URL. Piped output is unchanged. See PR #568.
Changed¶
ai_attribution = "forbid"now recognises the disclosure trailers too.Assisted-by:andGenerated-by:, in the formats the Linux kernel, Fedora, FluxCD and the ASF actually write them, are AI attribution like any other — a project that forbids attribution was letting them through. See PR #575 and CC013.
Fixed¶
-
CC201 anchors a branch name at both ends —
main-backup,master2anddevelop-xpassed as if they weremain,masteranddevelop. They fail now. See PR #569. -
CC001 exempts only the subjects git writes itself —
Merge,Revert ",fixup!,squash!andamend!. Author prose that merely started that way, such asMerged stufforfixup!! nonsense, was exempt too. See PR #569. -
A setting whose regex does not compile names the setting and exits
2, the code for a broken configuration:[commit] message_pattern is not a valid regex: '^(unclosed'. It used to surface as a bare Python error with exit code1, the code a rejected commit gets. See PR #574 and Exit codes. -
A configuration error exits
2and names the file it came from, and--dry-runruns the checks it was asked for instead of printing nothing and exiting0. See PR #570. -
An
inherit_fromthat cannot be loaded says so on stderr instead of falling back to the local config in silence. The run still continues. See PR #569.
v2.17.0 (2026-09-06)¶
Added¶
-
A failed check names its correction when there is exactly one — a type written
Fixwherefixis allowed, a letter out of place (feta), a missing colon after a recognised type, aWIP:marker, a missingSigned-off-bytrailer, a branch typedFeature/x, an AI attribution line underai_attribution = "forbid": the suggestion now says what to use (Use "fix: add x") instead of restating the rule, and--format jsonand the Python API carry the corrected value in a newfixfield.fixstays empty whenever the correction takes a judgment — a subject with no type, one over the length limit, a verb that is not imperative — so a tool can apply a non-emptyfixas it stands. The pre-commit hook, the Action and the App show the better suggestion with no change on their side; the MCP server passesfixthrough to agents. See PR #564 and Reading the JSON. -
A rule can be reported without being enforced — a top-level
warn = ["branch", "CC003"]runs the named rules, prints their findings in full, and never fails the run. A warned rule printswarningin place offailed, no rejection banner, and one closing line saying the run is not failed by it;--compactprints one[WARN]line. The exit code counts only enforced rules. In--format jsonthe check'sstatusiswarn, the top-levelstatusstayspass, and awarningscount is added. A name that matches no rule is a configuration error, so a typo cannot leave a rule silently enforced. This is how a team adopts a rule gradually: turn it on as a warning, watch what it catches, drop it fromwarnwhen the history is clean. See PR #565 and Report a rule without enforcing it.
Fixed¶
- CC002 reads a plain subject from its first word — the capitalisation
check treated any first word as a Conventional Commits type, so
Add featurefailed on thefoffeature,Updatealone failed, andadd Featurepassed. Only a prefix that ends in a colon is a type now: the first two pass, the third fails. See PR #564 and CC002.
v2.16.0 (2026-08-31)¶
Added¶
-
Tag names can be checked —
--tag(-t) validates the names of the tags pointing at the commit under test against a pattern, defaulting to SemVer with an optional leadingv. GitHub can require a tag name to match a regular expression only through its Enterprise-plan metadata restrictions — tag rulesets themselves are not gated that high; this brings the same policy to every plan and forge. A commit with no tag is a skip, not a failure: the rule governs how tags are named, and the absence of one is not a naming violation. Acheck-tagpre-push hook validates exactly what a push carries. See CC401. -
The files a commit touches can be policed —
--files(-f) checks file size (CC302), prohibited path patterns (CC303) and path length (CC304). GitHub sells the same three as push rules, on Team and Enterprise plans for private repositories.All three read only the paths and sizes recorded in a commit — never file contents, which keeps content scanning where it belongs, with tools like gitleaks. All three are off until their
[files]setting carries a usable value, and a value that cannot be used says so on stderr rather than disabling its rule in silence.A
check-filespre-push hook checks every commit a push adds, not just the tip; the Action and the App check every commit of a push or pull request.
v2.15.1 (2026-08-16)¶
Fixed¶
- ANSI color is emitted only where it renders — a terminal gets color;
piped and redirected output (a CI log, a file, another tool) gets plain text
instead of escape codes.
NO_COLOR(no-color.org) set to any non-empty value disables color everywhere, andFORCE_COLORoverrides the detection in both directions —0off, anything else on — outrankingNO_COLOR, the same way the wider tooling ecosystem resolves the pair. Contributed by @larsch. See PRs #551 and #552, and Color and links.
v2.15.0 (2026-08-13)¶
Added¶
--rev REVISIONnames the commit under test — anythinggit rev-parseunderstands: a SHA,HEAD~2,HEAD^2. Message checks read that commit's message, and the author checks read that commit's recorded author, never the local git config — an existing commit's identity is a fact about the commit, not about whoever runs the check. Before--rev, CI could not iterate a pull request's commits without checking each one out, and a malformed author on any commit passed as long as the operator's own config was valid. A revision that does not resolve is a one-line error before any check runs; combining--revwith a message file or stdin is rejected, since each would name a second subject for the same checks. See Checking a range of commits.
Fixed¶
- The CLI no longer hangs on an open, idle stdin — stdin was read
whenever it was not a terminal, for every check type. Under CI runners and
process managers that hand the process a pipe nothing ever writes to or
closes,
commit-check --author-nameblocked forever: a stuck step, not a failed one. The read is now gated on data actually being available, and genuinely piped input still works unchanged.
Changed¶
-
Skipped checks are named on stderr instead of passing in silence — a check that had nothing to judge (a merge subject under the subject rules, an absent message) reports a skip, and text mode prints one line naming every skipped check:
⊘ skipped (not validated): subject-max-length, subject-min-length. Exit codes are unchanged and stdout is untouched. The case that motivated it: on apull_requestcheckoutHEADis the synthetic merge commit, so a barecommit-check -mused to exit0having validated nothing it was asked about. See When a check is skipped. -
Only git's literal merge and fixup prefixes bypass the subject rules — the bypass matched any subject starting with the word "merge" in any case, so an author's own
merge the parser tablesescaped judgement. Now only the machine-written forms qualify:Merge(andfixup!for CC003).
v2.14.0 (2026-08-12)¶
Changed¶
- CC003 judges the word's form, not its membership in a list — the rule used to accept a subject only when its first word appeared in a list of known imperative verbs, so any verb the list had not heard of was reported as a mood error. Growing the list never fixed that; each release recognised a few more verbs and the next contributor found the next gap.
It now works the other way round. A first word is rejected only when it
carries non-imperative morphology — a past tense (fixed), a gerund
(adding) or a third person singular (fixes) — and accepted otherwise.
Measured across 59,140 subjects from git.git, a project that writes
strictly imperative subjects, the old list rejected 45.5% of them; the new
rule rejects 0.97%.
Nothing that passed before fails now. Two things that used to fail now pass:
* Subjects led by a correct verb no list contained — `reword the report
format`, `dedupe the helper`, `backfill an absent value`.
* Subjects led by a noun — `parser improvements`. CC003 checks mood, and a
noun phrase has none to get wrong.
The practical consequence is that IMPERATIVES no longer needs additions.
A verb missing from it can no longer cause a failure, so there is no reason
to send a patch adding the verb you just used.
See PR #540, which closes issue #526.
v2.13.1 (2026-08-05)¶
New Features¶
- Passed checks report what they checked —
--format jsonand thevalidate_all_detailed()API now populatevalueon passing checks with the raw input that was validated (commit subject, branch name, author name/email, push refs), so tooling such as commit-check-action can show exactly what passed instead of an empty string. Terminal output is unchanged. See PR #522.
v2.13.0 (2026-08-04)¶
New Features¶
- Stable rule IDs — every diagnostic now carries an ID such as
CC003that never changes once released. The ID appears in terminal output and in--format jsonasrule_id, alongside adocs_urlpointing at the rule's section of the rules reference, so a failure can be cited in review, looked up, or matched by tooling. See PR #512. - More branch types accepted by default —
docs/,ci/,test/,refactor/,build/,perf/andstyle/join the defaultallow_branch_types, which stays a superset of the Conventional Branch specification. See PR #505.
Bug Fixes¶
- Rule IDs link to their documentation — where the terminal renders OSC 8
hyperlinks, the ID itself is the link and the separate
Docs:line is dropped. Piped output and CI logs keep the printed URL. Failure output also names rules the way the reference titles them (subject-min-lengthrather thansubject_min_length), and the advice for the length rules now names the configured limit instead of referring to it. See PR #520.
Documentation¶
- The documentation, the landing page and the blog moved to
commit-check.com and are published from one
domain.
docs.commit-check.comURLs redirect. See PRs #514, #515, #518 and #519.
v2.12.2 (2026-08-01)¶
Bug Fixes¶
- Space-separated AI model names — Co-author trailers naming a model with spaces rather than hyphens are now recognised by the AI attribution check. See PR #506.
Chores¶
- Recognised more common imperative verbs, so ordinary subjects stop being
rejected by
subject_imperative. See PR #496.
v2.12.1 (2026-07-31)¶
Bug Fixes¶
- Running without the package installed —
PackageNotFoundErroris handled instead of crashing when the version cannot be resolved. See PR #483. - Hardened the
pip installstep flagged by SonarCloud code scanning. See PR #479.
v2.12.0 (2026-07-24)¶
New Features¶
- Configurable author patterns —
author_name_patternandauthor_email_patternaccept a custom regex, so organisations can require their own naming or email domain instead of the built-in patterns. See PR #459.
Bug Fixes¶
- Crash on Chinese Windows — commit messages are no longer decoded with the
system GBK codec, which raised
UnicodeDecodeError. See PR #475. - Author validation reads git config first —
git config user.nameis the identity the next commit will carry, so it is checked before falling back to the last commit's author. A misconfigured identity used to pass whenever the previous commit happened to be valid. See PR #461.
v2.11.1 (2026-07-09)¶
Bug Fixes¶
require_signed_off_byaccepts any name, and is skipped for authors listed inignore_authors. See PRs #462 and #464.
v2.11.0 (2026-07-06)¶
New Features¶
- AI attribution governance — Added support for forbidding known AI tool
signatures (e.g.,
Co-authored-by: Copilot) in commit messages. New[commit]config optionai_attribution(default"ignore") rejects commits carrying known AI tool signatures when set to"forbid". See PR #456.
Bug Fixes¶
- Fixed
MergeBaseValidatorbranch detection — replacedgit branch -aregex matching withgit rev-parse --verifyto avoid false positives (e.g., patternmainmatchingmain-staging). See PR #451.
Chores¶
- Added OpenSSF Scorecard workflow, badge, and pinned dependency SHAs for CI
- Migrated PyPI publishing to
pypa/gh-action-pypi-publish - Removed OpenSSF Scorecard badge after evaluation (moved to Scorecard dashboard)
v2.10.1 (2026-06-30)¶
Bug Fixes¶
- WIP detection case-insensitivity —
WIP([WIP],WIP:,wip:, etc.) is now recognized regardless of case across all common patterns. See PR #448. - Conventional commit special characters — Allowed special characters (parentheses, brackets, etc.) in the description part of conventional commit messages. See PR #447.
Refactors¶
- Extracted
_get_commit_messagetoBaseValidatorto remove code duplication across validators. See PR #445. - Removed legacy YAML config parsing code from
util.py. See PR #444.
v2.10.0 (2026-06-26)¶
New Features¶
- Dependabot / Renovate as default branch type —
dependabot/andrenovate/branch prefixes are now included inDEFAULT_BRANCH_TYPES, so dependency update branches are automatically recognized. See PR #442.
v2.9.0 (2026-06-22)¶
New Features¶
- AI agent branch prefixes (Conventional Branch v1.1.0) — Added
ai/,claude/,codex/,copilot/, andcursor/toDEFAULT_BRANCH_TYPESso branches created by AI coding agents are recognized as valid. See PR #438.
v2.8.1 (2026-06-22)¶
Chores¶
- Fixed 27 SonarQube code-quality issues across source and test files, including path traversal vulnerability fix, cognitive complexity reduction, and duplicate branch consolidation. See PR #436.
- Added SchemaStore IDE autocompletion support for
cchk.toml. See PR #433.
v2.8.0 (2026-06-13)¶
New Features¶
- Custom commit message pattern — New
message_patternoption in the[commit]config section allows replacing the built-in Conventional Commits regex with a user-defined regex pattern. Also supported via theCCHK_MESSAGE_PATTERNenvironment variable. See PR #427.
Breaking Changes¶
- Dropped Python 3.9 support — Minimum required Python version is now
3.10. Type annotations have been modernized (PEP 604/585) and the
py.typedmarker added for downstream type checkers. See PR #424.
v2.7.1 (2026-06-08)¶
Chores¶
- Added
autoto the list of imperative verbs. See PR #417. - Added commit-check vs GitHub Rulesets comparison table to the README. See PR #419.
v2.7.0 (2026-05-16)¶
New Features¶
-
Force push detection and blocking — Added
--no-force-pushCLI flag andcheck-no-force-pushpre-push hook that inspect pushed ref ancestry viagit merge-base --is-ancestorto detect and blockgit push --forceandgit push -f. A new[push]TOML config section withallow_force_push(defaulttrue) controls the behavior. Environment variableCCHK_ALLOW_FORCE_PUSHis also supported. -
validate_push()API — Newcommit_check.api.validate_push()function for programmatic push safety checks, matching the--no-force-pushCLI behavior without spawning a subprocess. -
Standalone mode — When
--no-force-pushis run outside a pre-push hook (no stdin), it checks whether pushingHEADto its configured upstream would require force, usinggit ls-remoteand optionalgit fetchto resolve the remote commit. -
Expanded imperative verbs — Added 156 new imperative verbs across 10 categories (auth/security, data ops, lifecycle, I/O, debugging, UI/UX, engineering, general), growing the total from 234 to 390. See PR #414.
v2.6.0 (2026-04-20)¶
New Features¶
- Lower-noise CLI failure output — Added
--no-bannerto suppress the ASCII art header while preserving detailed errors and suggestions. - Compact failure mode — Added
--compactto print one[FAIL]line per failing check for CI logs and automation-friendly terminal output. This mode also suppresses the banner.
Bug Fixes¶
- Fixed
print_error_headerstate handling so repeated validations stay consistent when--compactis used.
v2.5.0 (2026-04-03)¶
New Features¶
- Co-author bypass in
ignore_authors—_should_skip_commit_validation()now parsesCo-authored-by:trailers in the commit message body. If any co-author name matchesignore_authors, all commit checks are skipped. Useful for AI bots that co-author commits (e.g.,coderabbitai[bot]). - Organization-level config inheritance via
inherit_from— New top-level TOML key that loads a parent config from a GitHub shorthand (github:owner/repo:path), a local file path, or an HTTPS URL, then deep-merges it with local settings. HTTP (non-TLS) URLs are rejected to prevent MITM attacks. - Git config author validation —
AuthorValidatornow checksgit config user.name/user.emailfirst (the identity used for the next commit), falling back togit logif unset. Previously, a misconfigured identity would pass if the last commit had a valid author.
Bug Fixes¶
- Fixed incorrect mock target in
test_main_with_message_empty_string_no_stdin_with_git: was patchingcommit_check.util.get_commit_info(ineffective) instead ofcommit_check.engine.get_commit_info.
v2.0.0 (2025-10-01)¶
Warning
This major release introduces significant architectural changes and breaking updates to commit-check. Please review carefully before upgrading.
What's New¶
- TOML Configuration — Replaces the old
.commit-check.ymlwithcchk.tomlorcommit-check.tomlfor clearer syntax. - Simplified CLI & Hooks — Legacy pre-commit hooks and command-line options have been removed for a cleaner, more consistent interface.
- New Validation Engine — The validation system has been completely redesigned around a new ValidationEngine to improve maintainability and flexibility.
Breaking Changes¶
Configuration Format:
.commit-check.ymlhas been replaced withcchk.tomlorcommit-check.toml.- All YAML configurations must be migrated to TOML from this version onward.
- See Migrating from v1 for step-by-step instructions.
Removed Pre-commit Hooks and CLI Options:
- Several legacy hooks and command-line flags have been removed in favor of a simplified interface.
- Removed hooks:
check-commit-signoff,check-merge-base,check-imperative. - Removed CLI options:
--signoff,--merge-base,--imperative.
Module Removal:
- The following legacy modules have been removed:
author.py,branch.py,commit.py,error.py.
Architecture Redesign:
- The validation system has been completely restructured around the new
ValidationEngine, breaking compatibility with any code or integrations relying on the old module structure.
See PR #280
v0.10.2 (2025-08-26)¶
Last release before the big v2.0 changes.
v0.1.0 (2022-11-02)¶
Initial release of commit-check.
Privacy¶
This page describes what the Commit Check GitHub App reads, what it keeps, and what it never sees. The command-line tool, the pre-commit hooks and the GitHub Action run entirely inside your own environment and send nothing anywhere; this page is about the hosted App only.
What the App reads¶
When you install the App, GitHub grants it these permissions and nothing else:
| Permission | Level | Used for |
|---|---|---|
| Metadata | read | Knowing which repositories it is installed on |
| Contents | read | Fetching the commits under test and the repository's cchk.toml / commit-check.toml |
| Checks | write | Posting the Commit Check result on each commit |
| Pull requests | read | Listing the commits of a pull request |
On every push and pull request event the App fetches the commits under
test into a temporary directory. The fetch is shallow and filtered
(--filter=blob:none), so what comes across the wire is the commit
metadata — message, author, timestamps, the list of paths each commit
touches — plus the contents of the repository's configuration file, if it
has one. Configuration files are the only file contents the App ever
reads, and the only ones that ever reach the temporary directory, its
object store included. The rules are evaluated, the result is posted as a
check run, and the temporary directory is deleted before the event is
considered handled.
Two more configuration files can be read, and nothing else. For a repository
that has no configuration file of its own, the App reads its owner's shared
one from the owner's .github repository (an organization's or a user's),
at the same paths, with the same permission. When a configuration file
inherits from another repository with a github: address, the App fetches
that one file too.
For a private repository owned by an organization, the App also asks GitHub which Marketplace plan that organization is on, to decide whether the Team plan applies. That request carries the organization's account id and nothing about the repository.
What the App keeps¶
Nothing. The App has no database and stores no repository content, commit messages, author details or configuration. Each event is processed from the webhook payload and discarded.
The hosting platform retains operational logs for a short period. A log line carries the webhook delivery id, the commit SHA being checked, the id of the check run created and, when a check could not run, the error text the tool produced — never an author name or email, and never file content.
What the App never sees¶
- Your source code beyond the configuration file named above
- Repositories the App is not installed on
- The contents of a delivery whose signature does not verify. GitHub signs the raw request body, so the App has to receive that body to check the signature; an unsigned or mis-signed delivery is rejected at that point, before anything in it is parsed or acted on
Where it runs¶
The App runs on Fly.io in the ams (Amsterdam) region.
Traffic between GitHub and the App is encrypted in transit; GitHub signs
every delivery and the App verifies the signature before doing anything
else.
Contact¶
Questions about this page or the App's data handling: open an issue.
Last updated: 2026-09-05.
Terms of Service¶
These terms cover the Commit Check GitHub App — the hosted service we run, which you install onto your GitHub account. The command-line tool, the pre-commit hooks, the GitHub Action and the MCP server are not covered here: they run inside your own environment and are governed by the MIT licence of the repository you got them from.
Installing the App means you accept these terms.
What the service does¶
The App reads the commits of a push or pull request and the repository's configuration file, evaluates them against the rules that file turns on, and posts the result as a GitHub check run. That is all it does. It never writes to your repository, never rejects a push, and never blocks a merge on its own — a failing check only blocks a merge if you make it a required status check yourself.
What it reads and what it stores is set out on the privacy page.
Your side¶
You are responsible for the repositories you install the App on, for the rules you turn on, and for having the right to install it on an organization's repositories. Do not attempt to disrupt the service — for instance by sending traffic designed to exhaust it, or by probing it for vulnerabilities without telling us first. If you find a security problem, please report it rather than exploiting it.
Plans, billing and trials¶
Nothing is charged today. The Marketplace listing is not published yet, so there is no plan to buy and every repository is checked at no charge. This section describes what will apply once it is; the pricing section and the App guide say the same, and will say so before anything changes.
Public repositories are free on any account, and so is everything on a personal account. An organization's private repositories will be covered by the Team plan, at the price shown in the pricing section.
Billing, trials, renewals, cancellations and refunds will be handled by GitHub under the GitHub Marketplace Terms of Service, not by us — we never see your payment details. The Team plan comes with a 14-day free trial; cancelling is done from your GitHub account's billing settings and takes effect at the end of the current period.
If the price changes, the change applies to your account from the next renewal, and GitHub notifies you.
Availability¶
The App is offered as is. We aim to keep it running and to fix what breaks, but we do not promise a level of uptime, and there is no support commitment beyond the issue tracker. We may change how the service works, or stop running it, with notice on this site and on the Marketplace listing.
If the hosted App stops being available to you, the
GitHub Action reads the same configuration file and
reports the same rule IDs. Nothing you write in cchk.toml is locked to the
hosted service.
Liability¶
To the extent the law allows, the App is provided without warranty of any kind, and we are not liable for indirect or consequential loss arising from using it — including anything that follows from a check run that passed when you think it should have failed, or failed when you think it should have passed. Commit Check validates commit metadata; it is not a review, a test suite or a security control, and it is not a substitute for any of them.
Where liability cannot be excluded, it is limited to what you paid for the service in the twelve months before the claim.
Ending it¶
You can end these terms at any time by uninstalling the App from your account. We may suspend or end an installation that breaches these terms, or that is being used to disrupt the service.
Changes to these terms¶
Changes are published on this page, and the date below changes with them. Continuing to use the App after a change means you accept it. If a change matters materially to paying accounts, we will also note it on the Marketplace listing.
Contact¶
Open an issue for anything about these terms or the service.
Last updated: 2026-09-07.
Compared with GitHub's built-in rules¶
GitHub can enforce some of the same policies natively — but the useful ones sit behind its paid tiers, and the commit-metadata rules specifically behind the most expensive one:
| Policy | Native GitHub | Commit Check |
|---|---|---|
| Commit message patterns | Enterprise plan only (from $21/user/month) | Every plan, free |
| Author / committer email patterns | Enterprise plan only | Every plan, free |
| Branch naming patterns | Enterprise plan only | Every plan, free |
| Branch/tag rulesets on a private repository | Pro or Team plan and up; push rulesets Team and up | Every plan, free |
| Organization-wide rules | Team plan and up, enforced centrally | Shared config via inherit_from — each repo opts in, local settings override |
For a 20-person team on the GitHub Team plan, turning on native commit message rules means upgrading every seat from $4 to $21 at current list pricing — about $340 a month for a regex. The failure experience differs too: native rulesets speak RE2 (no lookaheads) and report a bare mismatch, while Commit Check failures carry a rule ID, a concrete suggestion, and a link to the rule's documentation.
One honest difference in the other direction: GitHub's rules run server-side
and refuse the ref update itself, so nothing lands on the protected branch no
matter how it is pushed. Commit Check's pre-commit hook is fast local feedback
a developer can bypass (git commit --no-verify); its enforcement boundary is
CI and a required check, which keeps a violating change from merging. For a
pull-request workflow the outcome is the same — the protected branch never
receives it.
Compared with other tools¶
What else checks commit messages, branch names and pull request titles on GitHub, what each one does, and when it is the better choice. Prices and features are as the projects list them in September and October 2026; follow the links for the current state. GitHub's own rulesets are on their own page.
The field¶
| Tool | What it is | What it checks | Where it runs | Price |
|---|---|---|---|---|
| commitlint with commitlint-github-action | The reference Conventional Commits linter in the JavaScript world: shared configs, plugins, a commitizen prompt | Commit messages | Node hook (husky), CI Action | Free, open source |
| gitlint | A Python commit message linter with a commit-msg hook, configured in a .gitlint file; one of the two commit linters the pre-commit hooks page features. Its latest release, 0.19.1, is from March 2023, and its PyPI page says the maintainer is looking for co-maintainers |
Commit messages: title and body length, whitespace, required words, regexes; Conventional Commits and sign-off as opt-in rules | Git hook, pre-commit, CLI | Free, open source |
| action-semantic-pull-request | The most-used pull request title check; Electron, Vite and Vercel run it | The pull request title (optionally the single commit, for squash merges) | GitHub Action | Free, open source |
| Semantic PRs | A GitHub App with the same idea: type and scope on the title or the commits | Pull request title or commits | GitHub App | Free, open source |
| commitcheck | A hosted App that applies one regular expression, set in its web admin | Commit message, pull request title or description, by regex | GitHub App | Free for public repositories; $25 a month for private ones |
| DCO app and DCO-2 | The sign-off check the Linux kernel and the CNCF use, with a remediation flow for missing sign-offs | Signed-off-by trailers |
GitHub App | Free, open source |
| PRLint, PR Title Checker | Regular expressions over pull request fields | Title, body, labels, branch | App / Action | Free, open source |
| Commit Check | One rule engine, documented rule by rule, run as a hook, a CLI, an Action, a hosted App or an MCP tool | Commit message, branch name, author name and email, sign-off, AI attribution, force pushes, file size and paths, tag names; the pull request title (Action) and the squash message of a pull request (App) | All of the above | Free, open source; the App's private organization repositories move to a paid plan later |
What is different about Commit Check¶
- One config, every place it runs. The same
cchk.tomldrives the pre-commit hook, the Action, the App and the MCP server. The others cover one place each: commitlint is a Node hook and an Action; the title checkers are Actions or Apps; commitcheck is an App. - More than the message. Branch names, author identity, sign-off, AI attribution trailers, force pushes and file rules are rules, not separate tools to find and wire up.
- A failure explains itself. Every rule has a stable ID, a one-line error, a suggestion and a documentation page, and the Action and App show all of it where the failure is. A regex tool reports a mismatch.
- Squash-aware. The App can check the message a squash merge would land, built from the repository's merge settings, rather than every commit on the branch or only the title.
- No JavaScript toolchain. It is a Python package with no runtime dependencies; a Go, Rust or Java repository does not need Node to lint its commits.
Where the others are stronger¶
- commitlint's ecosystem. Years of shared configs (
@commitlint/config-*), plugins and editor integrations, plus an interactive prompt for writing the message. Commit Check has no prompt, and its rules are the ones it ships. - A Node monorepo already on husky. If commitlint is installed and the policy is "message only", there is little to gain from switching.
- Sign-off remediation. The DCO app can accept a later commit that certifies earlier ones, and lets a maintainer override. Commit Check checks the trailer and stops there.
- Rejecting the push itself. Only GitHub's rulesets (and a server-side hook on GitHub Enterprise Server) refuse a ref update; see the rulesets page. Every tool above, Commit Check included, marks the commit or blocks the merge.
Coming from gitlint¶
Both are Python, both run as a commit-msg hook through pre-commit, so the
switch is a change to .pre-commit-config.yaml and a translation of
.gitlint into cchk.toml.
Replace the hook:
Install it the same way: pre-commit install --hook-type commit-msg.
Two defaults differ. Commit Check requires
Conventional Commits unless you turn it off, where
gitlint makes it an opt-in rule (contrib=CT1). And gitlint requires a body
out of the box (B6), where Commit Check does not. This keeps gitlint's
defaults:
Rule by rule:
| gitlint | Commit Check |
|---|---|
T1 title-max-length |
subject_max_length (CC004) |
T5 title-must-not-contain-word (default WIP) |
allow_wip_commits = false (CC010), which rejects a subject that starts with WIP; gitlint rejects the word anywhere in the title |
T7 title-match-regex, B8 body-match-regex |
message_pattern, one regex over the whole message (CC001) |
T8 title-min-length |
subject_min_length (CC005) |
B5 body-min-length, B6 body-is-missing |
require_body (CC011); there is no minimum length |
M1 author-valid-email |
the check-author-email hook (CC102), with author_email_pattern |
CT1 contrib-title-conventional-commits |
conventional_commits, on by default (CC001) |
CC1 contrib-body-requires-signed-off-by |
require_signed_off_by (CC012) |
CC2 contrib-disallow-cleanup-commits |
allow_fixup_commits = false (CC009) |
I4 ignore-by-author-name |
ignore_authors |
| merge, revert, fixup commits ignored | the format rules already skip the subjects git writes; CC006, CC007 and CC009 decide whether they are allowed |
| T2–T4, T6 whitespace and tab rules, T3 trailing punctuation, B1–B4 body line rules, B7 changed-file mention, user-defined Python rules | no equivalent |
The last row is the honest gap: if your policy depends on body line length or a custom rule written in Python, gitlint still does that and Commit Check does not. What you gain is the rest of this page: branch names, authors, the Action, the App, and a release that is current.
Which to choose¶
- You want the pull request title checked and nothing else:
action-semantic-pull-request. - You are in a JavaScript repository with husky and want plugins: commitlint.
- You need sign-off with a remediation flow: the DCO app, or DCO-2.
- You rely on gitlint's body line rules or your own Python rules: gitlint.
- You want one policy for messages, branches and authors, the same everywhere a commit is made and checked, with a hosted option that needs no workflow file: Commit Check.
Blog¶
Release announcements, and the reasoning behind decisions the reference pages only state. Rules tells you what a rule does and how to configure it; these posts cover why it exists and what changed along the way.
github-actionsbot commentededited
Commit Check
2 of 5 checks failed
bad msgFix: handle empty password▸ Show all 5 checks
All 5 checks passed
▸ Show all 5 checks
Rules reference