Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ below and to the page that documents the feature properly.

| Version | What changed | Documented in |
|---|---|---|
| [2.16.0](#v2160) | Tag name validation, and file size, path and pattern policies | [CC401](rules.md#cc401) · [CC302–CC304](rules.md#push-rules) |
| [2.15.1](#v2151) | Color and rule-ID links appear only where they render; `NO_COLOR` honoured | [Color and links](example.md#color-and-links) |
| [2.15.0](#v2150) | `--rev` names the commit under test; skipped checks are named on stderr | [Command-line recipes](example.md#checking-a-range-of-commits) |
| [2.14.0](#v2140) | CC003 judges imperative mood by a word's form, not by a list of verbs | [CC003](rules.md#cc003) |
Expand All @@ -26,6 +27,36 @@ below and to the page that documents the feature properly.
| [2.5.0](#v250) | Organization-wide config with `inherit_from` | [Integrations](guides/integrations.md#across-an-organization) |
| [2.0.0](#v200) | Configuration moved from YAML to TOML — breaking | [Migrating from v1](migration.md) |

## v2.16.0 (2026-08-31) { #v2160 }

### 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 leading `v`. GitHub can require a tag name to match a
regular expression only through its Enterprise-plan
[metadata restrictions](https://docs.github.com/en/enterprise-cloud@latest/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/available-rules-for-rulesets#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. A `check-tag` pre-push hook validates exactly
what a push carries. See [CC401](rules.md#cc401).

* **The files a commit touches can be policed** — `--files` (`-f`) checks file
size ([CC302](rules.md#cc302)), prohibited path patterns
([CC303](rules.md#cc303)) and path length ([CC304](rules.md#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-files` pre-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) { #v2151 }

### Fixed
Expand Down
10 changes: 9 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ Used from a hook definition, with no config file anywhere in the repository:
```yaml title=".pre-commit-config.yaml"
repos:
- repo: https://github.com/commit-check/commit-check
rev: v2.15.1
rev: v2.16.0
hooks:
- id: check-message
args:
Expand Down Expand Up @@ -216,6 +216,10 @@ The full mapping between the three forms:
| `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` |
| `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

Expand Down Expand Up @@ -274,3 +278,7 @@ the same thing twice, so read the description rather than the cell:
| 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](https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string) 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. |
2 changes: 1 addition & 1 deletion docs/example.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ pushed:
```yaml title=".pre-commit-config.yaml"
repos:
- repo: https://github.com/commit-check/commit-check
rev: v2.15.1
rev: v2.16.0
hooks:
- id: check-no-force-push
stages: [pre-push]
Expand Down
4 changes: 2 additions & 2 deletions docs/guides/integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ Add Commit Check to `.pre-commit-config.yaml`:
```yaml title=".pre-commit-config.yaml"
repos:
- repo: https://github.com/commit-check/commit-check
rev: v2.15.1
rev: v2.16.0
hooks:
- id: check-message
- id: check-branch
Expand Down Expand Up @@ -70,7 +70,7 @@ Options can be passed as hook arguments, which keeps everything in one file:
```yaml title=".pre-commit-config.yaml"
repos:
- repo: https://github.com/commit-check/commit-check
rev: v2.15.1
rev: v2.16.0
hooks:
- id: check-message
args:
Expand Down
2 changes: 1 addition & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ whatever your AI agent is committing on your behalf.
```yaml title=".pre-commit-config.yaml"
repos:
- repo: https://github.com/commit-check/commit-check
rev: v2.15.1
rev: v2.16.0
hooks:
- id: check-message
- id: check-branch
Expand Down
156 changes: 153 additions & 3 deletions docs/rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ Rule IDs are grouped by what they inspect:
| `CC0xx` | [Commit message](#commit-message-rules) | The subject, body, and trailers of a commit message |
| `CC1xx` | [Author](#author-rules) | The committer's configured name and email |
| `CC2xx` | [Branch](#branch-rules) | The current branch's name and its position relative to a target branch |
| `CC3xx` | [Push](#push-rules) | The push operation itself |
| `CC3xx` | [Push and files](#push-rules) | The push operation, and the files a commit touches |
| `CC4xx` | [Tag](#tag-rules) | The names of the tags pointing at the commit |

Two things determine whether a rule runs:

Expand Down Expand Up @@ -106,13 +107,32 @@ Run with `-b` / `--branch`.

</div>

### Push rules (`CC3xx`) { #push-rules }
### Push and file rules (`CC3xx`) { #push-rules }

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](#cc303).

<div class="rules-index" markdown>

| Code | Name | Message | Check | Default |
|---|---|---|---|---|
| [CC301](#cc301) | `no-force-push` | Force push is not allowed | `--no-force-push` | ⚪ Off |
| [CC302](#cc302) | `file-size` | File exceeds the maximum size of `{max_size}` | `-f` | ⚪ Off |
| [CC303](#cc303) | `file-pattern` | File path matches a prohibited pattern | `-f` | ⚪ Off |
| [CC304](#cc304) | `path-length` | File path exceeds `{max_len}` characters | `-f` | ⚪ Off |

</div>

### Tag rules (`CC4xx`) { #tag-rules }

Run with `-t` / `--tag`.

<div class="rules-index" markdown>

| Code | Name | Message | Check | Default |
|---|---|---|---|---|
| [CC401](#cc401) | `tag` | The tag name does not match the required pattern | `-t` | ✅ On |

</div>

Expand Down Expand Up @@ -683,7 +703,7 @@ git push --force-with-lease
* `branch.require_rebase_target` — the target branch, for example `"main"`.
Unset by default, meaning no rebase requirement.

## Push rules
## Push and file rules

### no-force-push (CC301) { #cc301 }

Expand Down Expand Up @@ -720,3 +740,133 @@ git push --force-with-lease
**Options**

* `push.allow_force_push` — set to `false` to enable this rule.

### file-size (CC302) { #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**

```toml title="cchk.toml"
[files]
max_size = "5MB"
```

**Options**

* `files.max_size` — largest a committed file may be, in bytes or with a
`KB`/`MB`/`GB` suffix. Empty (the default) disables this rule.

### file-pattern (CC303) { #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`;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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](https://github.com/gitleaks/gitleaks) — each catches
what the other cannot.

**Example**

```toml title="cchk.toml"
[files]
prohibited_patterns = ["*.pem", "*.key", ".env", "id_rsa*"]
```

**Options**

* `files.prohibited_patterns` — fnmatch patterns a committed path may not
match. Empty (the default) disables this rule.

### path-length (CC304) { #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**

```toml title="cchk.toml"
[files]
max_path_length = 250
```

**Options**

* `files.max_path_length` — longest a committed file path may be, in
characters. `0` (the default) disables this rule.

## Tag rules

### tag (CC401) { #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](https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string)
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.

**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**

```bash
git tag v1.2 # not SemVer: no patch version
git tag release-1.2.3 # not SemVer: prefixed
```

Use instead:

```bash
git tag v1.2.3
git tag v1.2.3-rc.1
```

**Options**

* `tag.regex` — the pattern a tag name must match. An empty value disables the
pattern match.
11 changes: 8 additions & 3 deletions tests/docs_sync_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,10 +168,13 @@ def test_pinned_revisions_match_the_released_version(self):


_OPTIONS_ROW = re.compile(
r"^\|\s*(commit|branch|push)\s*" # section
r"^\|\s*(commit|branch|push|files|tag)\s*" # section
r"\|\s*(\w+)\s*" # option name
r"\|\s*(bool|int|str|list\[str\])\s*" # type
r"\|\s*(.+?)\s*\|", # documented default
# A default may itself contain a pipe (a regex alternation, say), which a
# table cell can only carry escaped -- so the cell runs to the first
# *unescaped* pipe.
r"\|\s*((?:[^|\\]|\\.)+?)\s*\|", # documented default
re.M,
)

Expand All @@ -193,7 +196,9 @@ def _documented_default(type_: str, cell: str) -> Any:
Cells carry a human annotation after the value itself (``"" (disabled)``),
so the value is read from the front of the cell and the rest ignored.
"""
cell = cell.strip()
# Undo the table escaping so the value compares against the runtime as
# the runtime holds it.
cell = cell.strip().replace("\\|", "|")
if type_ == "bool":
return cell.startswith("true")
if type_ == "int":
Expand Down