git-commit-guard 0.17.0__tar.gz → 0.19.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. {git_commit_guard-0.17.0 → git_commit_guard-0.19.0}/.github/workflows/lint-commits.yml +1 -1
  2. git_commit_guard-0.19.0/.github/workflows/lint-workflows.yml +70 -0
  3. {git_commit_guard-0.17.0 → git_commit_guard-0.19.0}/.github/workflows/release.yml +4 -2
  4. {git_commit_guard-0.17.0 → git_commit_guard-0.19.0}/PKG-INFO +32 -12
  5. {git_commit_guard-0.17.0 → git_commit_guard-0.19.0}/README.md +31 -11
  6. {git_commit_guard-0.17.0 → git_commit_guard-0.19.0}/action.yml +7 -1
  7. git_commit_guard-0.19.0/cliff.toml +56 -0
  8. {git_commit_guard-0.17.0 → git_commit_guard-0.19.0}/docs/index.html +22 -6
  9. {git_commit_guard-0.17.0 → git_commit_guard-0.19.0}/src/git_commit_guard/__init__.py +37 -3
  10. {git_commit_guard-0.17.0 → git_commit_guard-0.19.0}/tests/test_git_commit_guard.py +183 -0
  11. {git_commit_guard-0.17.0 → git_commit_guard-0.19.0}/.editorconfig +0 -0
  12. {git_commit_guard-0.17.0 → git_commit_guard-0.19.0}/.github/workflows/coverage-baseline.yml +0 -0
  13. {git_commit_guard-0.17.0 → git_commit_guard-0.19.0}/.github/workflows/coverage-comment.yml +0 -0
  14. {git_commit_guard-0.17.0 → git_commit_guard-0.19.0}/.github/workflows/lint-md.yml +0 -0
  15. {git_commit_guard-0.17.0 → git_commit_guard-0.19.0}/.github/workflows/lint-python.yml +0 -0
  16. {git_commit_guard-0.17.0 → git_commit_guard-0.19.0}/.github/workflows/test.yml +0 -0
  17. {git_commit_guard-0.17.0 → git_commit_guard-0.19.0}/.gitignore +0 -0
  18. {git_commit_guard-0.17.0 → git_commit_guard-0.19.0}/.markdownlint.json +0 -0
  19. {git_commit_guard-0.17.0 → git_commit_guard-0.19.0}/.pre-commit-hooks.yaml +0 -0
  20. {git_commit_guard-0.17.0 → git_commit_guard-0.19.0}/.python-version +0 -0
  21. {git_commit_guard-0.17.0 → git_commit_guard-0.19.0}/LICENSE +0 -0
  22. {git_commit_guard-0.17.0 → git_commit_guard-0.19.0}/docs/commit-guard-icon.svg +0 -0
  23. {git_commit_guard-0.17.0 → git_commit_guard-0.19.0}/pyproject.toml +0 -0
  24. {git_commit_guard-0.17.0 → git_commit_guard-0.19.0}/ruff.toml +0 -0
  25. {git_commit_guard-0.17.0 → git_commit_guard-0.19.0}/tests/__init__.py +0 -0
  26. {git_commit_guard-0.17.0 → git_commit_guard-0.19.0}/uv.lock +0 -0
@@ -22,7 +22,7 @@ jobs:
22
22
  key: nltk-averaged-perceptron-tagger-punkt
23
23
  - name: Lint commits
24
24
  # yamllint disable-line rule:line-length
25
- uses: benner/commit-guard@064132b8b0cf5ec26083de7193f4e78d37a615d4 # v0.15.1
25
+ uses: benner/commit-guard@8a007fe3ebc1346ec111f7782b26af7e4ca32025 # v0.18.0
26
26
  with:
27
27
  range: origin/${{ github.base_ref }}..HEAD
28
28
  disable: signature
@@ -0,0 +1,70 @@
1
+ ---
2
+ name: Lint GitHub Actions workflows
3
+ on: # yamllint disable-line rule:truthy
4
+ pull_request:
5
+ permissions:
6
+ contents: read
7
+ jobs:
8
+ actionlint:
9
+ runs-on: ubuntu-latest
10
+ permissions:
11
+ pull-requests: write
12
+ steps:
13
+ - name: Checkout code
14
+ # yamllint disable-line rule:line-length
15
+ uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # ratchet:actions/checkout@v4
16
+ with:
17
+ persist-credentials: false
18
+ - name: Run actionlint
19
+ # yamllint disable-line rule:line-length
20
+ uses: reviewdog/action-actionlint@6fb7acc99f4a1008869fa8a0f09cfca740837d9d # ratchet:reviewdog/action-actionlint@v1
21
+ with:
22
+ github_token: ${{ github.token }}
23
+ reporter: github-pr-review
24
+ yamlfix:
25
+ runs-on: ubuntu-latest
26
+ permissions:
27
+ pull-requests: write
28
+ steps:
29
+ - name: Checkout code
30
+ # yamllint disable-line rule:line-length
31
+ uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # ratchet:actions/checkout@v4
32
+ with:
33
+ persist-credentials: false
34
+ - name: Set up uv
35
+ # yamllint disable-line rule:line-length
36
+ uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # ratchet:astral-sh/setup-uv@v7
37
+ - name: Set up reviewdog
38
+ # yamllint disable-line rule:line-length
39
+ uses: reviewdog/action-setup@d8a7baabd7f3e8544ee4dbde3ee41d0011c3a93f # ratchet:reviewdog/action-setup@v1
40
+ - name: Run yamlfix
41
+ env:
42
+ REVIEWDOG_GITHUB_API_TOKEN: ${{ github.token }}
43
+ run: |-
44
+ uvx yamlfix .github/workflows/
45
+ git diff .github/workflows/ |
46
+ reviewdog -f=diff -name=yamlfix \
47
+ -reporter=github-pr-review -fail-on-error
48
+ zizmor:
49
+ runs-on: ubuntu-latest
50
+ permissions:
51
+ pull-requests: write
52
+ steps:
53
+ - name: Checkout code
54
+ # yamllint disable-line rule:line-length
55
+ uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # ratchet:actions/checkout@v4
56
+ with:
57
+ persist-credentials: false
58
+ - name: Set up uv
59
+ # yamllint disable-line rule:line-length
60
+ uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # ratchet:astral-sh/setup-uv@v7
61
+ - name: Set up reviewdog
62
+ # yamllint disable-line rule:line-length
63
+ uses: reviewdog/action-setup@d8a7baabd7f3e8544ee4dbde3ee41d0011c3a93f # ratchet:reviewdog/action-setup@v1
64
+ - name: Run zizmor
65
+ env:
66
+ REVIEWDOG_GITHUB_API_TOKEN: ${{ github.token }}
67
+ run: |-
68
+ uvx zizmor==1.24.1 --format=sarif . |
69
+ reviewdog -f=sarif -name=zizmor \
70
+ -reporter=github-pr-review -fail-on-error
@@ -2,11 +2,11 @@
2
2
  name: Release
3
3
  on: # yamllint disable-line rule:truthy
4
4
  push:
5
- tags:
6
- - 'v*'
5
+ tags: [v*]
7
6
  permissions:
8
7
  contents: write
9
8
  id-token: write
9
+ pull-requests: read
10
10
  jobs:
11
11
  release:
12
12
  runs-on: ubuntu-latest
@@ -34,6 +34,8 @@ jobs:
34
34
  id: git-cliff
35
35
  # yamllint disable-line rule:line-length
36
36
  uses: orhun/git-cliff-action@f50e11560dce63f7c33227798f90b924471a88b5 # ratchet:orhun/git-cliff-action@v4.8.0
37
+ env:
38
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
37
39
  with:
38
40
  args: --current
39
41
  - name: Create GitHub Release
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: git-commit-guard
3
- Version: 0.17.0
3
+ Version: 0.19.0
4
4
  Summary: Opinionated conventional commit message linter with imperative mood detection
5
5
  Project-URL: Homepage, https://github.com/benner/commit-guard
6
6
  Project-URL: Repository, https://github.com/benner/commit-guard
@@ -94,7 +94,7 @@ commit-guard --disable body,signed-off,signature
94
94
  Available checks:
95
95
 
96
96
  * `subject` - Format matches `type(scope): description`, valid type,
97
- lowercase start, no trailing period, max 72 chars
97
+ lowercase start, no trailing `.` `!` `?` or space, max 72 chars
98
98
  * `imperative` - First word is an imperative verb (for example `add` not `added`)
99
99
  * `body` - Blank line separates subject from body, and body is non-empty
100
100
  * `signed-off` - `Signed-off-by:` trailer exists
@@ -131,8 +131,8 @@ In `.commit-guard.toml`:
131
131
  require-lowercase = false
132
132
  ```
133
133
 
134
- By default trailing `.` is forbidden. To change the set of forbidden trailing
135
- characters (any character is valid, including space):
134
+ By default `.`, `!`, `?`, and space are forbidden as trailing characters.
135
+ To change the set (any character is valid):
136
136
 
137
137
  ```bash
138
138
  commit-guard --no-trailing-chars ".,"
@@ -181,6 +181,25 @@ commit-guard --require-scope
181
181
  commit-guard --scopes auth,api --require-scope
182
182
  ```
183
183
 
184
+ ### Required subject pattern
185
+
186
+ Require the commit subject to match a regular expression. Useful for
187
+ enforcing ticket references or any custom naming convention:
188
+
189
+ ```bash
190
+ commit-guard --require-subject-pattern "[A-Z]+-[0-9]+"
191
+ commit-guard --require-subject-pattern "#[0-9]+"
192
+ ```
193
+
194
+ In `.commit-guard.toml`:
195
+
196
+ ```toml
197
+ require-subject-pattern = "[A-Z]+-[0-9]+"
198
+ ```
199
+
200
+ An invalid regex causes an immediate error at startup (exit 2). This
201
+ check runs independently of `--enable`/`--disable`.
202
+
184
203
  ### Required custom trailers
185
204
 
186
205
  Require arbitrary trailers to be present in the commit message. Multiple
@@ -206,7 +225,7 @@ independently of `--enable`/`--disable`.
206
225
  Place `.commit-guard.toml` in your project root (or any parent directory) to
207
226
  set defaults for `enable`, `disable`, `scopes`, `require-scope`, `types`,
208
227
  `max-subject-length`, `min-description-length`, `require-lowercase`,
209
- `no-trailing-chars`, and `require-trailers`.
228
+ `no-trailing-chars`, `require-subject-pattern`, and `require-trailers`.
210
229
  commit-guard searches upward from the working directory and uses the first
211
230
  file found.
212
231
 
@@ -246,7 +265,7 @@ COMMIT_GUARD_GIT_TIMEOUT=30 commit-guard --range origin/main..HEAD
246
265
  In GitHub Actions, set it at the step or job level:
247
266
 
248
267
  ```yaml
249
- - uses: benner/commit-guard@v0.17.0
268
+ - uses: benner/commit-guard@v0.19.0
250
269
  env:
251
270
  COMMIT_GUARD_GIT_TIMEOUT: 30
252
271
  with:
@@ -330,7 +349,7 @@ steps:
330
349
  - uses: actions/checkout@v4
331
350
  with:
332
351
  fetch-depth: 0
333
- - uses: benner/commit-guard@v0.17.0
352
+ - uses: benner/commit-guard@v0.19.0
334
353
  ```
335
354
 
336
355
  Check all commits in a pull request:
@@ -346,7 +365,7 @@ jobs:
346
365
  - uses: actions/checkout@v4
347
366
  with:
348
367
  fetch-depth: 0
349
- - uses: benner/commit-guard@v0.17.0
368
+ - uses: benner/commit-guard@v0.19.0
350
369
  with:
351
370
  range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}
352
371
  ```
@@ -354,7 +373,7 @@ jobs:
354
373
  Check a specific commit SHA (mirrors the positional CLI argument):
355
374
 
356
375
  ```yaml
357
- - uses: benner/commit-guard@v0.17.0
376
+ - uses: benner/commit-guard@v0.19.0
358
377
  with:
359
378
  rev: ${{ github.sha }}
360
379
  ```
@@ -372,12 +391,13 @@ jobs:
372
391
  - uses: actions/checkout@v4
373
392
  with:
374
393
  fetch-depth: 0
375
- - uses: benner/commit-guard@v0.17.0
394
+ - uses: benner/commit-guard@v0.19.0
376
395
  with:
377
396
  range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}
378
397
  disable: signed-off,signature
379
398
  scopes: auth,api,db
380
399
  require-scope: 'true'
400
+ require-subject-pattern: '[A-Z]+-[0-9]+'
381
401
  require-trailer: 'Closes,Reviewed-by'
382
402
  max-subject-length: '100'
383
403
  min-description-length: '10'
@@ -391,7 +411,7 @@ jobs:
391
411
  When `output-file` is set the action exposes the path as an output:
392
412
 
393
413
  ```yaml
394
- - uses: benner/commit-guard@v0.17.0
414
+ - uses: benner/commit-guard@v0.19.0
395
415
  id: cg
396
416
  with:
397
417
  range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}
@@ -407,7 +427,7 @@ Add to your `.pre-commit-config.yaml`:
407
427
  ---
408
428
  repos:
409
429
  - repo: https://github.com/benner/commit-guard
410
- rev: v0.17.0
430
+ rev: v0.19.0
411
431
  hooks:
412
432
  - id: commit-guard
413
433
  - id: commit-guard-signature
@@ -73,7 +73,7 @@ commit-guard --disable body,signed-off,signature
73
73
  Available checks:
74
74
 
75
75
  * `subject` - Format matches `type(scope): description`, valid type,
76
- lowercase start, no trailing period, max 72 chars
76
+ lowercase start, no trailing `.` `!` `?` or space, max 72 chars
77
77
  * `imperative` - First word is an imperative verb (for example `add` not `added`)
78
78
  * `body` - Blank line separates subject from body, and body is non-empty
79
79
  * `signed-off` - `Signed-off-by:` trailer exists
@@ -110,8 +110,8 @@ In `.commit-guard.toml`:
110
110
  require-lowercase = false
111
111
  ```
112
112
 
113
- By default trailing `.` is forbidden. To change the set of forbidden trailing
114
- characters (any character is valid, including space):
113
+ By default `.`, `!`, `?`, and space are forbidden as trailing characters.
114
+ To change the set (any character is valid):
115
115
 
116
116
  ```bash
117
117
  commit-guard --no-trailing-chars ".,"
@@ -160,6 +160,25 @@ commit-guard --require-scope
160
160
  commit-guard --scopes auth,api --require-scope
161
161
  ```
162
162
 
163
+ ### Required subject pattern
164
+
165
+ Require the commit subject to match a regular expression. Useful for
166
+ enforcing ticket references or any custom naming convention:
167
+
168
+ ```bash
169
+ commit-guard --require-subject-pattern "[A-Z]+-[0-9]+"
170
+ commit-guard --require-subject-pattern "#[0-9]+"
171
+ ```
172
+
173
+ In `.commit-guard.toml`:
174
+
175
+ ```toml
176
+ require-subject-pattern = "[A-Z]+-[0-9]+"
177
+ ```
178
+
179
+ An invalid regex causes an immediate error at startup (exit 2). This
180
+ check runs independently of `--enable`/`--disable`.
181
+
163
182
  ### Required custom trailers
164
183
 
165
184
  Require arbitrary trailers to be present in the commit message. Multiple
@@ -185,7 +204,7 @@ independently of `--enable`/`--disable`.
185
204
  Place `.commit-guard.toml` in your project root (or any parent directory) to
186
205
  set defaults for `enable`, `disable`, `scopes`, `require-scope`, `types`,
187
206
  `max-subject-length`, `min-description-length`, `require-lowercase`,
188
- `no-trailing-chars`, and `require-trailers`.
207
+ `no-trailing-chars`, `require-subject-pattern`, and `require-trailers`.
189
208
  commit-guard searches upward from the working directory and uses the first
190
209
  file found.
191
210
 
@@ -225,7 +244,7 @@ COMMIT_GUARD_GIT_TIMEOUT=30 commit-guard --range origin/main..HEAD
225
244
  In GitHub Actions, set it at the step or job level:
226
245
 
227
246
  ```yaml
228
- - uses: benner/commit-guard@v0.17.0
247
+ - uses: benner/commit-guard@v0.19.0
229
248
  env:
230
249
  COMMIT_GUARD_GIT_TIMEOUT: 30
231
250
  with:
@@ -309,7 +328,7 @@ steps:
309
328
  - uses: actions/checkout@v4
310
329
  with:
311
330
  fetch-depth: 0
312
- - uses: benner/commit-guard@v0.17.0
331
+ - uses: benner/commit-guard@v0.19.0
313
332
  ```
314
333
 
315
334
  Check all commits in a pull request:
@@ -325,7 +344,7 @@ jobs:
325
344
  - uses: actions/checkout@v4
326
345
  with:
327
346
  fetch-depth: 0
328
- - uses: benner/commit-guard@v0.17.0
347
+ - uses: benner/commit-guard@v0.19.0
329
348
  with:
330
349
  range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}
331
350
  ```
@@ -333,7 +352,7 @@ jobs:
333
352
  Check a specific commit SHA (mirrors the positional CLI argument):
334
353
 
335
354
  ```yaml
336
- - uses: benner/commit-guard@v0.17.0
355
+ - uses: benner/commit-guard@v0.19.0
337
356
  with:
338
357
  rev: ${{ github.sha }}
339
358
  ```
@@ -351,12 +370,13 @@ jobs:
351
370
  - uses: actions/checkout@v4
352
371
  with:
353
372
  fetch-depth: 0
354
- - uses: benner/commit-guard@v0.17.0
373
+ - uses: benner/commit-guard@v0.19.0
355
374
  with:
356
375
  range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}
357
376
  disable: signed-off,signature
358
377
  scopes: auth,api,db
359
378
  require-scope: 'true'
379
+ require-subject-pattern: '[A-Z]+-[0-9]+'
360
380
  require-trailer: 'Closes,Reviewed-by'
361
381
  max-subject-length: '100'
362
382
  min-description-length: '10'
@@ -370,7 +390,7 @@ jobs:
370
390
  When `output-file` is set the action exposes the path as an output:
371
391
 
372
392
  ```yaml
373
- - uses: benner/commit-guard@v0.17.0
393
+ - uses: benner/commit-guard@v0.19.0
374
394
  id: cg
375
395
  with:
376
396
  range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}
@@ -386,7 +406,7 @@ Add to your `.pre-commit-config.yaml`:
386
406
  ---
387
407
  repos:
388
408
  - repo: https://github.com/benner/commit-guard
389
- rev: v0.17.0
409
+ rev: v0.19.0
390
410
  hooks:
391
411
  - id: commit-guard
392
412
  - id: commit-guard-signature
@@ -39,7 +39,7 @@ inputs:
39
39
  required: false
40
40
  default: 'false'
41
41
  no-trailing-chars:
42
- description: Forbidden trailing characters, comma-separated (default '.')
42
+ description: Forbidden trailing characters, comma-separated (default '.', '!', '?', ' ')
43
43
  required: false
44
44
  allow-empty:
45
45
  description: Exit 0 when --range yields no commits
@@ -49,6 +49,9 @@ inputs:
49
49
  description: Include merge commits when checking a range
50
50
  required: false
51
51
  default: 'false'
52
+ require-subject-pattern:
53
+ description: Regex the subject line must match (e.g. '[A-Z]+-[0-9]+')
54
+ required: false
52
55
  require-trailer:
53
56
  description: Comma-separated list of required trailers (e.g. Closes,Reviewed-by)
54
57
  required: false
@@ -86,6 +89,7 @@ runs:
86
89
  CG_NO_TRAILING_CHARS: ${{ inputs.no-trailing-chars }}
87
90
  CG_ALLOW_EMPTY: ${{ inputs.allow-empty }}
88
91
  CG_INCLUDE_MERGES: ${{ inputs.include-merges }}
92
+ CG_REQUIRE_SUBJECT_PATTERN: ${{ inputs.require-subject-pattern }}
89
93
  CG_REQUIRE_TRAILER: ${{ inputs.require-trailer }}
90
94
  CG_OUTPUT_FILE: ${{ inputs.output-file }}
91
95
  run: |
@@ -105,6 +109,8 @@ runs:
105
109
  [[ -n "$CG_NO_TRAILING_CHARS" ]] && ARGS+=(--no-trailing-chars "$CG_NO_TRAILING_CHARS")
106
110
  [[ "$CG_ALLOW_EMPTY" == "true" ]] && ARGS+=(--allow-empty)
107
111
  [[ "$CG_INCLUDE_MERGES" == "true" ]] && ARGS+=(--include-merges)
112
+ [[ -n "$CG_REQUIRE_SUBJECT_PATTERN" ]] && \
113
+ ARGS+=(--require-subject-pattern "$CG_REQUIRE_SUBJECT_PATTERN")
108
114
  [[ -n "$CG_REQUIRE_TRAILER" ]] && ARGS+=(--require-trailer "$CG_REQUIRE_TRAILER")
109
115
  [[ -n "$CG_OUTPUT_FILE" ]] && ARGS+=(--output-file "$CG_OUTPUT_FILE")
110
116
  commit-guard "${ARGS[@]}"
@@ -0,0 +1,56 @@
1
+ [changelog]
2
+ body = """
3
+ {% if version %}\
4
+ ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
5
+ {% else %}\
6
+ ## [unreleased]
7
+ {% endif %}\
8
+ {% for group, commits in commits | group_by(attribute="group") %}
9
+ ### {{ group | striptags | trim | upper_first }}
10
+ {% for commit in commits %}
11
+ - {% if commit.scope %}*({{ commit.scope }})* {% endif %}\
12
+ {% if commit.breaking %}[**breaking**] {% endif %}\
13
+ {{ commit.message | upper_first }} \
14
+ ([`{{ commit.id | truncate(length=7, end="") }}`]\
15
+ (https://github.com/benner/commit-guard/commit/{{ commit.id }}))\
16
+ {% endfor %}
17
+ {% endfor %}
18
+ {%- if github.contributors | filter(attribute="is_first_time", value=true) | length != 0 %}
19
+
20
+ ### New Contributors
21
+ {% for contributor in github.contributors | filter(attribute="is_first_time", value=true) %}
22
+ - @{{ contributor.username }} made their first contribution\
23
+ {% if contributor.pr_number %} in \
24
+ [#{{ contributor.pr_number }}]\
25
+ (https://github.com/benner/commit-guard/pull/{{ contributor.pr_number }})\
26
+ {% endif %}
27
+ {% endfor %}
28
+ {%- endif %}
29
+ """
30
+ trim = true
31
+
32
+ [remote.github]
33
+ owner = "benner"
34
+ repo = "commit-guard"
35
+
36
+ [git]
37
+ conventional_commits = true
38
+ filter_unconventional = true
39
+ split_commits = false
40
+ protect_breaking_commits = false
41
+ commit_parsers = [
42
+ { message = "^feat", group = "<!-- 0 -->🚀 Features" },
43
+ { message = "^fix", group = "<!-- 1 -->🐛 Bug Fixes" },
44
+ { message = "^perf", group = "<!-- 2 -->⚡ Performance" },
45
+ { message = "^refactor", group = "<!-- 3 -->🚜 Refactor" },
46
+ { message = "^style", group = "<!-- 4 -->🎨 Styling" },
47
+ { message = "^test", group = "<!-- 5 -->🧪 Testing" },
48
+ { message = "^docs", skip = true },
49
+ { message = "^ci", skip = true },
50
+ { message = "^chore", group = "<!-- 6 -->⚙️ Miscellaneous Tasks" },
51
+ { message = "^revert", group = "<!-- 7 -->◀️ Revert" },
52
+ { body = ".*security", group = "<!-- 8 -->🛡️ Security" },
53
+ ]
54
+ filter_commits = true
55
+ topo_order = false
56
+ sort_commits = "oldest"
@@ -355,7 +355,7 @@ $ echo "fix(auth): add token refresh" | commit-guard</code></pre>
355
355
  <td><code>subject</code></td>
356
356
  <td>
357
357
  <a href="https://www.conventionalcommits.org/" target="_blank" rel="noopener">Conventional Commits</a>
358
- format: valid type, lowercase start, no trailing period,
358
+ format: valid type, lowercase start, no trailing <code>.</code> <code>!</code> <code>?</code> or space,
359
359
  max length (default 72). All limits are configurable. Use
360
360
  <code>!</code> before the colon for breaking changes:
361
361
  <code>feat!: remove endpoint</code>
@@ -400,7 +400,23 @@ max-subject-length = 100
400
400
  min-description-length = 10
401
401
  require-lowercase = false
402
402
  no-trailing-chars = [".", "!"]
403
- require-trailers = ["Closes", "Reviewed-by"]</code></pre>
403
+ require-trailers = ["Closes", "Reviewed-by"]
404
+ require-subject-pattern = "[A-Z]+-[0-9]+"</code></pre>
405
+
406
+ <h3>Required subject pattern</h3>
407
+ <p>
408
+ Require the commit subject to match a regular expression. Useful for
409
+ enforcing ticket references or any custom naming convention:
410
+ </p>
411
+ <pre><code class="language-bash">commit-guard --require-subject-pattern "[A-Z]+-[0-9]+"</code></pre>
412
+ <p>
413
+ In <code>.commit-guard.toml</code>:
414
+ </p>
415
+ <pre><code class="language-toml">require-subject-pattern = "[A-Z]+-[0-9]+"</code></pre>
416
+ <p>
417
+ An invalid regex causes an immediate error at startup (exit 2). This
418
+ check runs independently of <code>--enable</code>/<code>--disable</code>.
419
+ </p>
404
420
 
405
421
  <h3>Required trailers</h3>
406
422
  <p>
@@ -479,13 +495,13 @@ require-trailers = ["Closes", "Reviewed-by"]</code></pre>
479
495
  - uses: actions/checkout@v4
480
496
  with:
481
497
  fetch-depth: 0
482
- - uses: benner/commit-guard@v0.17.0
498
+ - uses: benner/commit-guard@v0.19.0
483
499
  with:
484
500
  range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}
485
501
  disable: signed-off,signature</code></pre>
486
502
 
487
503
  <p>Check a specific commit SHA:</p>
488
- <pre><code class="language-yaml"> - uses: benner/commit-guard@v0.17.0
504
+ <pre><code class="language-yaml"> - uses: benner/commit-guard@v0.19.0
489
505
  with:
490
506
  rev: ${{ github.sha }}</code></pre>
491
507
 
@@ -504,7 +520,7 @@ require-trailers = ["Closes", "Reviewed-by"]</code></pre>
504
520
  When <code>output-file</code> is set the action exposes the path as
505
521
  a step output, making JSONL results available to subsequent steps:
506
522
  </p>
507
- <pre><code class="language-yaml"> - uses: benner/commit-guard@v0.17.0
523
+ <pre><code class="language-yaml"> - uses: benner/commit-guard@v0.19.0
508
524
  id: cg
509
525
  with:
510
526
  range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}
@@ -517,7 +533,7 @@ require-trailers = ["Closes", "Reviewed-by"]</code></pre>
517
533
  <p>Add to <code>.pre-commit-config.yaml</code>:</p>
518
534
  <pre><code class="language-yaml">repos:
519
535
  - repo: https://github.com/benner/commit-guard
520
- rev: v0.17.0
536
+ rev: v0.19.0
521
537
  hooks:
522
538
  - id: commit-guard
523
539
  - id: commit-guard-signature</code></pre>
@@ -153,7 +153,7 @@ def check_subject( # noqa: PLR0913 Too many arguments in function definition (9
153
153
  allowed_types=TYPES,
154
154
  max_subject_length=MAX_SUBJECT_LEN,
155
155
  min_description_length=0,
156
- no_trailing_chars=frozenset("."),
156
+ no_trailing_chars=frozenset({".", "!", "?", " "}),
157
157
  *,
158
158
  require_scope=False,
159
159
  require_lowercase=True,
@@ -241,6 +241,14 @@ def check_signed_off(message, result):
241
241
  result.error("missing 'Signed-off-by' trailer", check=Check.SIGNED_OFF)
242
242
 
243
243
 
244
+ def check_subject_pattern(subject, pattern, result):
245
+ if not pattern.search(subject):
246
+ result.error(
247
+ f"subject must match pattern '{pattern.pattern}'",
248
+ check=Check.SUBJECT,
249
+ )
250
+
251
+
244
252
  def check_required_trailers(message, required, result):
245
253
  for trailer in required:
246
254
  pattern = re.compile(rf"^{re.escape(trailer)}:\s+\S", re.MULTILINE)
@@ -313,6 +321,7 @@ class Args:
313
321
  allow_empty: bool
314
322
  include_merges: bool
315
323
  required_trailers: list
324
+ subject_pattern: re.Pattern | None
316
325
  output: OutputFormat
317
326
  output_file: Path | None
318
327
 
@@ -362,7 +371,7 @@ def _resolve_no_trailing_chars(args, config):
362
371
  return frozenset(c for c in args.no_trailing_chars.split(",") if c)
363
372
  if "no-trailing-chars" in config:
364
373
  return frozenset(config["no-trailing-chars"])
365
- return frozenset(".")
374
+ return frozenset({".", "!", "?", " "})
366
375
 
367
376
 
368
377
  def _resolve_required_trailers(args, config):
@@ -373,6 +382,12 @@ def _resolve_required_trailers(args, config):
373
382
  return []
374
383
 
375
384
 
385
+ def _resolve_subject_pattern(args, config):
386
+ if args.require_subject_pattern is not None:
387
+ return args.require_subject_pattern
388
+ return config.get("require-subject-pattern")
389
+
390
+
376
391
  def _resolve_types(args, config):
377
392
  if args.types:
378
393
  return frozenset(t.strip() for t in args.types.split(","))
@@ -406,7 +421,7 @@ def _parse_checks(parser, value):
406
421
  parser.error(str(e))
407
422
 
408
423
 
409
- def _parse_args():
424
+ def _parse_args(): # noqa: PLR0915 Too many statements (59 > 50)
410
425
  checks_list = ",".join(sorted(Check))
411
426
  parser = ArgumentParser(description="conventional commit checker")
412
427
  parser.add_argument("rev", nargs="?", default=None)
@@ -476,6 +491,12 @@ def _parse_args():
476
491
  default=False,
477
492
  help="exit 0 when --range yields no commits (default: exit 1)",
478
493
  )
494
+ parser.add_argument(
495
+ "--require-subject-pattern",
496
+ default=None,
497
+ metavar="REGEX",
498
+ help="require subject line to match this regular expression",
499
+ )
479
500
  parser.add_argument(
480
501
  "--require-trailer",
481
502
  metavar="TRAILER[,TRAILER,...]",
@@ -509,6 +530,16 @@ def _parse_args():
509
530
  require_lowercase = _resolve_require_lowercase(args, config)
510
531
  no_trailing_chars = _resolve_no_trailing_chars(args, config)
511
532
  required_trailers = _resolve_required_trailers(args, config)
533
+ subject_pattern_str = _resolve_subject_pattern(args, config)
534
+ if subject_pattern_str is not None:
535
+ try:
536
+ subject_pattern = re.compile(subject_pattern_str)
537
+ except re.error as e:
538
+ parser.error(
539
+ f"invalid regex for --require-subject-pattern {subject_pattern_str!r}: {e}" # noqa: E501 Line too long
540
+ )
541
+ else:
542
+ subject_pattern = None
512
543
 
513
544
  if args.allow_empty and not args.rev_range:
514
545
  parser.error("--allow-empty requires --range")
@@ -548,6 +579,7 @@ def _parse_args():
548
579
  allow_empty=args.allow_empty,
549
580
  include_merges=args.include_merges,
550
581
  required_trailers=required_trailers,
582
+ subject_pattern=subject_pattern,
551
583
  output=OutputFormat(args.output),
552
584
  output_file=args.output_file,
553
585
  )
@@ -613,6 +645,8 @@ def _run_checks(args, rev, message, result):
613
645
  check_signed_off(message, result)
614
646
  if args.required_trailers:
615
647
  check_required_trailers(message, args.required_trailers, result)
648
+ if args.subject_pattern:
649
+ check_subject_pattern(lines[0], args.subject_pattern, result)
616
650
  if Check.SIGNATURE in args.enabled and rev:
617
651
  check_signature(rev, result)
618
652
 
@@ -1,4 +1,5 @@
1
1
  import json
2
+ import re
2
3
  import subprocess
3
4
  from argparse import ArgumentParser, Namespace
4
5
  from unittest.mock import MagicMock, patch
@@ -22,7 +23,10 @@ from git_commit_guard import (
22
23
  _report_text,
23
24
  _resolve_max_subject_length,
24
25
  _resolve_min_description_length,
26
+ _resolve_no_trailing_chars,
27
+ _resolve_require_lowercase,
25
28
  _resolve_required_trailers,
29
+ _resolve_subject_pattern,
26
30
  _resolve_types,
27
31
  _strip_comments,
28
32
  check_body,
@@ -31,6 +35,7 @@ from git_commit_guard import (
31
35
  check_signature,
32
36
  check_signed_off,
33
37
  check_subject,
38
+ check_subject_pattern,
34
39
  main,
35
40
  )
36
41
 
@@ -392,6 +397,55 @@ class TestResolveRequiredTrailers:
392
397
  assert result == ["Fixes"]
393
398
 
394
399
 
400
+ class TestCheckSubjectPattern:
401
+ def test_matching_subject_passes(self):
402
+ r = Result()
403
+ check_subject_pattern("feat: add PROJ-123 login", re.compile(r"[A-Z]+-\d+"), r)
404
+ assert r.ok
405
+
406
+ def test_non_matching_subject_fails(self):
407
+ r = Result()
408
+ check_subject_pattern(
409
+ "feat: implement OAuth login flow", re.compile(r"[A-Z]+-\d+"), r
410
+ )
411
+ assert not r.ok
412
+ assert "must match pattern" in r.errors[0][2]
413
+ assert "[A-Z]+-\\d+" in r.errors[0][2]
414
+
415
+ def test_error_includes_pattern(self):
416
+ r = Result()
417
+ check_subject_pattern("fix: oops", re.compile(r"#\d+"), r)
418
+ assert "#\\d+" in r.errors[0][2]
419
+
420
+
421
+ class TestResolveSubjectPattern:
422
+ def test_defaults_to_none(self):
423
+ assert (
424
+ _resolve_subject_pattern(Namespace(require_subject_pattern=None), {})
425
+ is None
426
+ )
427
+
428
+ def test_cli_flag(self):
429
+ result = _resolve_subject_pattern(
430
+ Namespace(require_subject_pattern="[A-Z]+-\\d+"), {}
431
+ )
432
+ assert result == "[A-Z]+-\\d+"
433
+
434
+ def test_config(self):
435
+ result = _resolve_subject_pattern(
436
+ Namespace(require_subject_pattern=None),
437
+ {"require-subject-pattern": "#\\d+"},
438
+ )
439
+ assert result == "#\\d+"
440
+
441
+ def test_cli_overrides_config(self):
442
+ result = _resolve_subject_pattern(
443
+ Namespace(require_subject_pattern="[A-Z]+-\\d+"),
444
+ {"require-subject-pattern": "#\\d+"},
445
+ )
446
+ assert result == "[A-Z]+-\\d+"
447
+
448
+
395
449
  class TestCheckImperative:
396
450
  def test_imperative_verb_passes(self):
397
451
  r = Result()
@@ -454,6 +508,19 @@ class TestCheckImperative:
454
508
  check_imperative("", r)
455
509
  assert r.ok
456
510
 
511
+ def test_pos_fallback_unknown_word_fails(self):
512
+ r = Result()
513
+ with (
514
+ patch("git_commit_guard.wordnet.morphy", return_value=None),
515
+ patch(
516
+ "git_commit_guard.nltk.pos_tag",
517
+ return_value=[("to", "TO"), ("xyzzy", "NN")],
518
+ ),
519
+ ):
520
+ check_imperative("xyzzy something", r)
521
+ assert not r.ok
522
+ assert "POS=NN" in r.errors[0][2]
523
+
457
524
 
458
525
  class TestDownloadIfMissing:
459
526
  def test_skips_download_when_present(self):
@@ -617,6 +684,38 @@ class TestResolveMinDescriptionLength:
617
684
  assert result == 10
618
685
 
619
686
 
687
+ class TestResolveRequireLowercase:
688
+ def test_cli_flag_overrides_default(self):
689
+ assert (
690
+ _resolve_require_lowercase(Namespace(require_lowercase=False), {}) is False
691
+ )
692
+
693
+ def test_config_overrides_default(self):
694
+ result = _resolve_require_lowercase(
695
+ Namespace(require_lowercase=None), {"require-lowercase": False}
696
+ )
697
+ assert result is False
698
+
699
+ def test_default_is_true(self):
700
+ assert _resolve_require_lowercase(Namespace(require_lowercase=None), {}) is True
701
+
702
+
703
+ class TestResolveNoTrailingChars:
704
+ def test_cli_flag_overrides_default(self):
705
+ result = _resolve_no_trailing_chars(Namespace(no_trailing_chars=".,!"), {})
706
+ assert result == frozenset({".", "!"})
707
+
708
+ def test_config_overrides_default(self):
709
+ result = _resolve_no_trailing_chars(
710
+ Namespace(no_trailing_chars=None), {"no-trailing-chars": [".", "!"]}
711
+ )
712
+ assert result == frozenset({".", "!"})
713
+
714
+ def test_default_includes_common_punctuation_and_space(self):
715
+ result = _resolve_no_trailing_chars(Namespace(no_trailing_chars=None), {})
716
+ assert result == frozenset({".", "!", "?", " "})
717
+
718
+
620
719
  class TestGitTimeout:
621
720
  def test_default(self, monkeypatch):
622
721
  monkeypatch.delenv("COMMIT_GUARD_GIT_TIMEOUT", raising=False)
@@ -1391,6 +1490,69 @@ class TestRequireTrailerIntegration:
1391
1490
  assert main() == 0
1392
1491
 
1393
1492
 
1493
+ class TestRequireSubjectPatternIntegration:
1494
+ def test_matching_pattern_passes(self, tmp_path):
1495
+ f = tmp_path / "msg"
1496
+ f.write_text(
1497
+ "fix: resolve PROJ-42 auth timeout\n\nbody\n\nSigned-off-by: A <a@b.com>"
1498
+ )
1499
+ argv = [
1500
+ "cg",
1501
+ "--message-file",
1502
+ str(f),
1503
+ "--disable",
1504
+ "signature,imperative",
1505
+ "--require-subject-pattern",
1506
+ "[A-Z]+-[0-9]+",
1507
+ ]
1508
+ with patch("sys.argv", argv):
1509
+ assert main() == 0
1510
+
1511
+ def test_non_matching_pattern_fails(self, tmp_path):
1512
+ f = tmp_path / "msg"
1513
+ f.write_text("fix: resolve auth timeout\n\nbody\n\nSigned-off-by: A <a@b.com>")
1514
+ argv = [
1515
+ "cg",
1516
+ "--message-file",
1517
+ str(f),
1518
+ "--disable",
1519
+ "signature,imperative",
1520
+ "--require-subject-pattern",
1521
+ "[A-Z]+-[0-9]+",
1522
+ ]
1523
+ with patch("sys.argv", argv):
1524
+ assert main() == 1
1525
+
1526
+ def test_invalid_regex_exits(self, tmp_path):
1527
+ f = tmp_path / "msg"
1528
+ f.write_text("fix: add thing\n\nbody\n\nSigned-off-by: A <a@b.com>")
1529
+ argv = [
1530
+ "cg",
1531
+ "--message-file",
1532
+ str(f),
1533
+ "--disable",
1534
+ "signature,imperative",
1535
+ "--require-subject-pattern",
1536
+ "[unclosed",
1537
+ ]
1538
+ with patch("sys.argv", argv), pytest.raises(SystemExit) as exc:
1539
+ main()
1540
+ assert exc.value.code == 2
1541
+
1542
+ def test_pattern_from_config(self, tmp_path):
1543
+ f = tmp_path / "msg"
1544
+ f.write_text("fix: resolve auth timeout\n\nbody\n\nSigned-off-by: A <a@b.com>")
1545
+ argv = ["cg", "--message-file", str(f), "--disable", "signature,imperative"]
1546
+ with (
1547
+ patch("sys.argv", argv),
1548
+ patch(
1549
+ "git_commit_guard._load_config",
1550
+ return_value={"require-subject-pattern": "[A-Z]+-[0-9]+"},
1551
+ ),
1552
+ ):
1553
+ assert main() == 1
1554
+
1555
+
1394
1556
  class TestOutputJsonl:
1395
1557
  def test_single_commit_ok(self, tmp_path, capsys):
1396
1558
 
@@ -1458,6 +1620,27 @@ class TestOutputJsonl:
1458
1620
  assert data["sha"] == rev
1459
1621
  assert data["ok"] is True
1460
1622
 
1623
+ def test_range_failing_commit_returns_nonzero(self, capsys):
1624
+ with (
1625
+ patch(
1626
+ "sys.argv",
1627
+ [
1628
+ "cg",
1629
+ "--range",
1630
+ "HEAD~1..HEAD",
1631
+ "--disable",
1632
+ "signature,imperative",
1633
+ "--output",
1634
+ "jsonl",
1635
+ ],
1636
+ ),
1637
+ patch("git_commit_guard._get_range_revs", return_value=["aaa"]),
1638
+ patch("git_commit_guard._get_message", return_value="bad message"),
1639
+ ):
1640
+ assert main() == 1
1641
+ data = json.loads(capsys.readouterr().out)
1642
+ assert data["ok"] is False
1643
+
1461
1644
 
1462
1645
  class TestOutputFile:
1463
1646
  def test_single_commit_writes_jsonl_to_file(self, tmp_path, capsys):