git-commit-guard 0.20.0__tar.gz → 0.21.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.20.0 → git_commit_guard-0.21.0}/.github/workflows/coverage-baseline.yml +1 -1
  2. {git_commit_guard-0.20.0 → git_commit_guard-0.21.0}/.github/workflows/lint-commits.yml +1 -2
  3. {git_commit_guard-0.20.0 → git_commit_guard-0.21.0}/.github/workflows/lint-md.yml +1 -1
  4. {git_commit_guard-0.20.0 → git_commit_guard-0.21.0}/.github/workflows/lint-workflows.yml +8 -5
  5. {git_commit_guard-0.20.0 → git_commit_guard-0.21.0}/.github/workflows/test.yml +3 -1
  6. {git_commit_guard-0.20.0 → git_commit_guard-0.21.0}/PKG-INFO +28 -14
  7. {git_commit_guard-0.20.0 → git_commit_guard-0.21.0}/README.md +27 -13
  8. {git_commit_guard-0.20.0 → git_commit_guard-0.21.0}/docs/index.html +36 -16
  9. {git_commit_guard-0.20.0 → git_commit_guard-0.21.0}/src/git_commit_guard/__init__.py +79 -16
  10. {git_commit_guard-0.20.0 → git_commit_guard-0.21.0}/tests/test_git_commit_guard.py +219 -0
  11. {git_commit_guard-0.20.0 → git_commit_guard-0.21.0}/.editorconfig +0 -0
  12. {git_commit_guard-0.20.0 → git_commit_guard-0.21.0}/.github/workflows/coverage-comment.yml +0 -0
  13. {git_commit_guard-0.20.0 → git_commit_guard-0.21.0}/.github/workflows/lint-python.yml +0 -0
  14. {git_commit_guard-0.20.0 → git_commit_guard-0.21.0}/.github/workflows/release.yml +0 -0
  15. {git_commit_guard-0.20.0 → git_commit_guard-0.21.0}/.gitignore +0 -0
  16. {git_commit_guard-0.20.0 → git_commit_guard-0.21.0}/.markdownlint.json +0 -0
  17. {git_commit_guard-0.20.0 → git_commit_guard-0.21.0}/.pre-commit-hooks.yaml +0 -0
  18. {git_commit_guard-0.20.0 → git_commit_guard-0.21.0}/.python-version +0 -0
  19. {git_commit_guard-0.20.0 → git_commit_guard-0.21.0}/LICENSE +0 -0
  20. {git_commit_guard-0.20.0 → git_commit_guard-0.21.0}/action.yml +0 -0
  21. {git_commit_guard-0.20.0 → git_commit_guard-0.21.0}/cliff.toml +0 -0
  22. {git_commit_guard-0.20.0 → git_commit_guard-0.21.0}/docs/commit-guard-icon.svg +0 -0
  23. {git_commit_guard-0.20.0 → git_commit_guard-0.21.0}/pyproject.toml +0 -0
  24. {git_commit_guard-0.20.0 → git_commit_guard-0.21.0}/ruff.toml +0 -0
  25. {git_commit_guard-0.20.0 → git_commit_guard-0.21.0}/tests/__init__.py +0 -0
  26. {git_commit_guard-0.20.0 → git_commit_guard-0.21.0}/uv.lock +0 -0
@@ -19,7 +19,7 @@ jobs:
19
19
  uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # ratchet:astral-sh/setup-uv@v7
20
20
  - name: Cache NLTK data
21
21
  # yamllint disable-line rule:line-length
22
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # ratchet:actions/cache@v5
22
+ uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
23
23
  with:
24
24
  path: ~/nltk_data
25
25
  key: nltk-averaged-perceptron-tagger-punkt
@@ -22,7 +22,6 @@ 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@7704c563540b24bb10394e373e508dc664a7f01f # v0.19.0
25
+ uses: benner/commit-guard@87889d2f3a1fb68bfa55dbbab28c96def415341e # v0.20.1
26
26
  with:
27
27
  range: origin/${{ github.base_ref }}..HEAD
28
- disable: signature
@@ -23,4 +23,4 @@ jobs:
23
23
  github_token: ${{ secrets.GITHUB_TOKEN }}
24
24
  reporter: github-pr-review
25
25
  level: info
26
- fail_on_error: true
26
+ fail_level: any
@@ -12,7 +12,7 @@ jobs:
12
12
  steps:
13
13
  - name: Checkout code
14
14
  # yamllint disable-line rule:line-length
15
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # ratchet:actions/checkout@v4
15
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
16
16
  with:
17
17
  persist-credentials: false
18
18
  - name: Run actionlint
@@ -21,6 +21,7 @@ jobs:
21
21
  with:
22
22
  github_token: ${{ github.token }}
23
23
  reporter: github-pr-review
24
+ fail_level: any
24
25
  yamlfix:
25
26
  runs-on: ubuntu-latest
26
27
  permissions:
@@ -28,7 +29,7 @@ jobs:
28
29
  steps:
29
30
  - name: Checkout code
30
31
  # yamllint disable-line rule:line-length
31
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # ratchet:actions/checkout@v4
32
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
32
33
  with:
33
34
  persist-credentials: false
34
35
  - name: Set up uv
@@ -41,10 +42,11 @@ jobs:
41
42
  env:
42
43
  REVIEWDOG_GITHUB_API_TOKEN: ${{ github.token }}
43
44
  run: |-
45
+ set -o pipefail
44
46
  uvx yamlfix .github/workflows/
45
47
  git diff .github/workflows/ |
46
48
  reviewdog -f=diff -name=yamlfix \
47
- -reporter=github-pr-review -fail-on-error
49
+ -reporter=github-pr-review -fail-level=any
48
50
  zizmor:
49
51
  runs-on: ubuntu-latest
50
52
  permissions:
@@ -52,7 +54,7 @@ jobs:
52
54
  steps:
53
55
  - name: Checkout code
54
56
  # yamllint disable-line rule:line-length
55
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # ratchet:actions/checkout@v4
57
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
56
58
  with:
57
59
  persist-credentials: false
58
60
  - name: Set up uv
@@ -65,6 +67,7 @@ jobs:
65
67
  env:
66
68
  REVIEWDOG_GITHUB_API_TOKEN: ${{ github.token }}
67
69
  run: |-
70
+ set -o pipefail
68
71
  uvx zizmor==1.24.1 --format=sarif . |
69
72
  reviewdog -f=sarif -name=zizmor \
70
- -reporter=github-pr-review -fail-on-error
73
+ -reporter=github-pr-review -fail-level=any
@@ -27,9 +27,11 @@ jobs:
27
27
  tests/ --cov=git_commit_guard --cov-report=term-missing \
28
28
  --cov-report=xml
29
29
  - name: Save coverage artifact
30
+ env:
31
+ PR_NUMBER: ${{ github.event.number }}
30
32
  run: |
31
33
  mkdir -p ./pr
32
- echo ${{ github.event.number }} > ./pr/NR
34
+ echo "$PR_NUMBER" > ./pr/NR
33
35
  cp coverage.xml ./pr/
34
36
  - name: Upload PR artifact
35
37
  # yamllint disable-line rule:line-length
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: git-commit-guard
3
- Version: 0.20.0
3
+ Version: 0.21.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
@@ -21,18 +21,32 @@ Description-Content-Type: text/markdown
21
21
 
22
22
  # commit-guard
23
23
 
24
+ <!-- markdownlint-disable MD013 -->
25
+ [![PyPI version](https://img.shields.io/pypi/v/git-commit-guard.svg)](https://pypi.org/project/git-commit-guard/)
26
+ [![PyPI downloads](https://img.shields.io/pypi/dm/git-commit-guard.svg)](https://pypi.org/project/git-commit-guard/)
27
+ [![CI](https://github.com/benner/commit-guard/actions/workflows/test.yml/badge.svg)](https://github.com/benner/commit-guard/actions/workflows/test.yml)
28
+ [![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white)](https://pre-commit.com/)
29
+ <!-- markdownlint-restore -->
30
+
24
31
  Opinionated conventional commit message linter with imperative mood detection.
25
32
 
26
- Unlike regular expression only tools, commit-guard uses
27
- NLP (nltk POS tagging) to verify that commit descriptions start with an
28
- imperative verb.
33
+ ## Why commit-guard?
34
+
35
+ * **NLP imperative detection.** Descriptions must start with an imperative
36
+ verb, verified via nltk POS tagging — not a hand-coded regex of "bad"
37
+ words.
38
+ * **Signature verification without a local keyring.** Resolves the commit
39
+ author via the GitHub API and verifies GPG/SSH against their published
40
+ `.gpg`/`.keys` — no per-runner key management.
41
+ * **Strict by default.** Subject format, body, trailers, `Signed-off-by`,
42
+ and signature all enforced out of the box; opt out with `--disable`.
29
43
 
30
44
  ## Example
31
45
 
32
46
  ```bash
33
47
  $ commit-guard
34
48
  ✗ [subject] subject does not match 'type(scope): description': WIP
35
- ✗ [signed-off] missing 'Signed-off-by' trailer
49
+ ✗ [signed-off] missing 'Signed-off-by' trailer — use 'git commit -s'
36
50
  ✗ [signature] commit is not signed (GPG/SSH)
37
51
  ```
38
52
 
@@ -94,12 +108,12 @@ commit-guard --disable body,signed-off,signature
94
108
  Available checks:
95
109
 
96
110
  * `subject` - Format matches `type(scope): description`, valid type,
97
- lowercase start, no trailing `.` `!` `?` or space, max 72 chars
111
+ lowercase start, no trailing `.` `!` `?` or space, max 72 chars
98
112
  * `imperative` - First word is an imperative verb (for example `add` not `added`)
99
113
  * `body` - Blank line separates subject from body, and body is non-empty
100
114
  * `signed-off` - `Signed-off-by:` trailer exists
101
115
  * `signature` - Verify GPG or SSH signature via the GitHub Commits API or
102
- public key lookup
116
+ public key lookup
103
117
 
104
118
  ### Subject length
105
119
 
@@ -296,7 +310,7 @@ COMMIT_GUARD_GIT_TIMEOUT=30 commit-guard --range origin/main..HEAD
296
310
  In GitHub Actions, set it at the step or job level:
297
311
 
298
312
  ```yaml
299
- - uses: benner/commit-guard@v0.19.0
313
+ - uses: benner/commit-guard@v0.21.0
300
314
  env:
301
315
  COMMIT_GUARD_GIT_TIMEOUT: 30
302
316
  with:
@@ -380,7 +394,7 @@ steps:
380
394
  - uses: actions/checkout@v4
381
395
  with:
382
396
  fetch-depth: 0
383
- - uses: benner/commit-guard@v0.19.0
397
+ - uses: benner/commit-guard@v0.21.0
384
398
  ```
385
399
 
386
400
  Check all commits in a pull request:
@@ -396,7 +410,7 @@ jobs:
396
410
  - uses: actions/checkout@v4
397
411
  with:
398
412
  fetch-depth: 0
399
- - uses: benner/commit-guard@v0.19.0
413
+ - uses: benner/commit-guard@v0.21.0
400
414
  with:
401
415
  range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}
402
416
  ```
@@ -404,7 +418,7 @@ jobs:
404
418
  Check a specific commit SHA (mirrors the positional CLI argument):
405
419
 
406
420
  ```yaml
407
- - uses: benner/commit-guard@v0.19.0
421
+ - uses: benner/commit-guard@v0.21.0
408
422
  with:
409
423
  rev: ${{ github.sha }}
410
424
  ```
@@ -422,7 +436,7 @@ jobs:
422
436
  - uses: actions/checkout@v4
423
437
  with:
424
438
  fetch-depth: 0
425
- - uses: benner/commit-guard@v0.19.0
439
+ - uses: benner/commit-guard@v0.21.0
426
440
  with:
427
441
  range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}
428
442
  disable: signed-off,signature
@@ -442,7 +456,7 @@ jobs:
442
456
  When `output-file` is set the action exposes the path as an output:
443
457
 
444
458
  ```yaml
445
- - uses: benner/commit-guard@v0.19.0
459
+ - uses: benner/commit-guard@v0.21.0
446
460
  id: cg
447
461
  with:
448
462
  range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}
@@ -458,7 +472,7 @@ Add to your `.pre-commit-config.yaml`:
458
472
  ---
459
473
  repos:
460
474
  - repo: https://github.com/benner/commit-guard
461
- rev: v0.19.0
475
+ rev: v0.21.0
462
476
  hooks:
463
477
  - id: commit-guard
464
478
  - id: commit-guard-signature
@@ -1,17 +1,31 @@
1
1
  # commit-guard
2
2
 
3
+ <!-- markdownlint-disable MD013 -->
4
+ [![PyPI version](https://img.shields.io/pypi/v/git-commit-guard.svg)](https://pypi.org/project/git-commit-guard/)
5
+ [![PyPI downloads](https://img.shields.io/pypi/dm/git-commit-guard.svg)](https://pypi.org/project/git-commit-guard/)
6
+ [![CI](https://github.com/benner/commit-guard/actions/workflows/test.yml/badge.svg)](https://github.com/benner/commit-guard/actions/workflows/test.yml)
7
+ [![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white)](https://pre-commit.com/)
8
+ <!-- markdownlint-restore -->
9
+
3
10
  Opinionated conventional commit message linter with imperative mood detection.
4
11
 
5
- Unlike regular expression only tools, commit-guard uses
6
- NLP (nltk POS tagging) to verify that commit descriptions start with an
7
- imperative verb.
12
+ ## Why commit-guard?
13
+
14
+ * **NLP imperative detection.** Descriptions must start with an imperative
15
+ verb, verified via nltk POS tagging — not a hand-coded regex of "bad"
16
+ words.
17
+ * **Signature verification without a local keyring.** Resolves the commit
18
+ author via the GitHub API and verifies GPG/SSH against their published
19
+ `.gpg`/`.keys` — no per-runner key management.
20
+ * **Strict by default.** Subject format, body, trailers, `Signed-off-by`,
21
+ and signature all enforced out of the box; opt out with `--disable`.
8
22
 
9
23
  ## Example
10
24
 
11
25
  ```bash
12
26
  $ commit-guard
13
27
  ✗ [subject] subject does not match 'type(scope): description': WIP
14
- ✗ [signed-off] missing 'Signed-off-by' trailer
28
+ ✗ [signed-off] missing 'Signed-off-by' trailer — use 'git commit -s'
15
29
  ✗ [signature] commit is not signed (GPG/SSH)
16
30
  ```
17
31
 
@@ -73,12 +87,12 @@ commit-guard --disable body,signed-off,signature
73
87
  Available checks:
74
88
 
75
89
  * `subject` - Format matches `type(scope): description`, valid type,
76
- lowercase start, no trailing `.` `!` `?` or space, max 72 chars
90
+ lowercase start, no trailing `.` `!` `?` or space, max 72 chars
77
91
  * `imperative` - First word is an imperative verb (for example `add` not `added`)
78
92
  * `body` - Blank line separates subject from body, and body is non-empty
79
93
  * `signed-off` - `Signed-off-by:` trailer exists
80
94
  * `signature` - Verify GPG or SSH signature via the GitHub Commits API or
81
- public key lookup
95
+ public key lookup
82
96
 
83
97
  ### Subject length
84
98
 
@@ -275,7 +289,7 @@ COMMIT_GUARD_GIT_TIMEOUT=30 commit-guard --range origin/main..HEAD
275
289
  In GitHub Actions, set it at the step or job level:
276
290
 
277
291
  ```yaml
278
- - uses: benner/commit-guard@v0.19.0
292
+ - uses: benner/commit-guard@v0.21.0
279
293
  env:
280
294
  COMMIT_GUARD_GIT_TIMEOUT: 30
281
295
  with:
@@ -359,7 +373,7 @@ steps:
359
373
  - uses: actions/checkout@v4
360
374
  with:
361
375
  fetch-depth: 0
362
- - uses: benner/commit-guard@v0.19.0
376
+ - uses: benner/commit-guard@v0.21.0
363
377
  ```
364
378
 
365
379
  Check all commits in a pull request:
@@ -375,7 +389,7 @@ jobs:
375
389
  - uses: actions/checkout@v4
376
390
  with:
377
391
  fetch-depth: 0
378
- - uses: benner/commit-guard@v0.19.0
392
+ - uses: benner/commit-guard@v0.21.0
379
393
  with:
380
394
  range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}
381
395
  ```
@@ -383,7 +397,7 @@ jobs:
383
397
  Check a specific commit SHA (mirrors the positional CLI argument):
384
398
 
385
399
  ```yaml
386
- - uses: benner/commit-guard@v0.19.0
400
+ - uses: benner/commit-guard@v0.21.0
387
401
  with:
388
402
  rev: ${{ github.sha }}
389
403
  ```
@@ -401,7 +415,7 @@ jobs:
401
415
  - uses: actions/checkout@v4
402
416
  with:
403
417
  fetch-depth: 0
404
- - uses: benner/commit-guard@v0.19.0
418
+ - uses: benner/commit-guard@v0.21.0
405
419
  with:
406
420
  range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}
407
421
  disable: signed-off,signature
@@ -421,7 +435,7 @@ jobs:
421
435
  When `output-file` is set the action exposes the path as an output:
422
436
 
423
437
  ```yaml
424
- - uses: benner/commit-guard@v0.19.0
438
+ - uses: benner/commit-guard@v0.21.0
425
439
  id: cg
426
440
  with:
427
441
  range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}
@@ -437,7 +451,7 @@ Add to your `.pre-commit-config.yaml`:
437
451
  ---
438
452
  repos:
439
453
  - repo: https://github.com/benner/commit-guard
440
- rev: v0.19.0
454
+ rev: v0.21.0
441
455
  hooks:
442
456
  - id: commit-guard
443
457
  - id: commit-guard-signature
@@ -115,7 +115,7 @@
115
115
  .hero-terminal pre {
116
116
  margin: 0;
117
117
  font-size: 0.95rem;
118
- width: 64ch;
118
+ width: 80ch;
119
119
  height: 12em;
120
120
  overflow: hidden;
121
121
  }
@@ -291,12 +291,10 @@
291
291
  <h1>commit-guard</h1>
292
292
  <p>Conventional commit linting with imperative mood detection.</p>
293
293
  <div class="hero-terminal">
294
- <pre><code id="hero-code">$ commit-guard --range origin/main..HEAD
295
- <span class="c-dim">abc1234</span> feat: add user authentication
296
- <span class="c-green">✓</span> all checks passed
297
- <span class="c-dim">def5678</span> wip: still working
298
- <span class="c-red">✗</span> <span class="c-dim">[subject]</span> unknown type: wip
299
- <span class="c-red">✗</span> <span class="c-dim">[body]</span> missing body</code></pre>
294
+ <pre><code id="hero-code">$ commit-guard
295
+ <span class="c-red">✗</span> <span class="c-dim">[subject]</span> subject does not match 'type(scope): description': WIP
296
+ <span class="c-red">✗</span> <span class="c-dim">[signed-off]</span> missing 'Signed-off-by' trailer — use 'git commit -s'
297
+ <span class="c-red">✗</span> <span class="c-dim">[signature]</span> commit is not signed (GPG/SSH)</code></pre>
300
298
  </div>
301
299
  <div class="hero-dots">
302
300
  <button class="hero-dot" data-i="0"></button>
@@ -305,6 +303,28 @@
305
303
  </div>
306
304
  </section>
307
305
 
306
+ <section id="why">
307
+ <h2>Why commit-guard? <a href="#why" class="anchor">#</a></h2>
308
+ <ul>
309
+ <li>
310
+ <strong>NLP imperative detection.</strong> Descriptions must start
311
+ with an imperative verb, verified via nltk POS tagging — not a
312
+ hand-coded regex of "bad" words.
313
+ </li>
314
+ <li>
315
+ <strong>Signature verification without a local keyring.</strong>
316
+ Resolves the commit author via the GitHub API and verifies GPG/SSH
317
+ against their published <code>.gpg</code>/<code>.keys</code> — no
318
+ per-runner key management.
319
+ </li>
320
+ <li>
321
+ <strong>Strict by default.</strong> Subject format, body, trailers,
322
+ <code>Signed-off-by</code>, and signature all enforced out of the
323
+ box; opt out with <code>--disable</code>.
324
+ </li>
325
+ </ul>
326
+ </section>
327
+
308
328
  <section id="install">
309
329
  <h2>Install <a href="#install" class="anchor">#</a></h2>
310
330
  <div class="install-tabs">
@@ -538,13 +558,13 @@ require-subject-pattern = "[A-Z]+-[0-9]+"</code></pre>
538
558
  - uses: actions/checkout@v4
539
559
  with:
540
560
  fetch-depth: 0
541
- - uses: benner/commit-guard@v0.19.0
561
+ - uses: benner/commit-guard@v0.21.0
542
562
  with:
543
563
  range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}
544
564
  disable: signed-off,signature</code></pre>
545
565
 
546
566
  <p>Check a specific commit SHA:</p>
547
- <pre><code class="language-yaml"> - uses: benner/commit-guard@v0.19.0
567
+ <pre><code class="language-yaml"> - uses: benner/commit-guard@v0.21.0
548
568
  with:
549
569
  rev: ${{ github.sha }}</code></pre>
550
570
 
@@ -563,7 +583,7 @@ require-subject-pattern = "[A-Z]+-[0-9]+"</code></pre>
563
583
  When <code>output-file</code> is set the action exposes the path as
564
584
  a step output, making JSONL results available to subsequent steps:
565
585
  </p>
566
- <pre><code class="language-yaml"> - uses: benner/commit-guard@v0.19.0
586
+ <pre><code class="language-yaml"> - uses: benner/commit-guard@v0.21.0
567
587
  id: cg
568
588
  with:
569
589
  range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}
@@ -576,7 +596,7 @@ require-subject-pattern = "[A-Z]+-[0-9]+"</code></pre>
576
596
  <p>Add to <code>.pre-commit-config.yaml</code>:</p>
577
597
  <pre><code class="language-yaml">repos:
578
598
  - repo: https://github.com/benner/commit-guard
579
- rev: v0.19.0
599
+ rev: v0.21.0
580
600
  hooks:
581
601
  - id: commit-guard
582
602
  - id: commit-guard-signature</code></pre>
@@ -596,16 +616,16 @@ require-subject-pattern = "[A-Z]+-[0-9]+"</code></pre>
596
616
  const EXAMPLES = [
597
617
  [
598
618
  ["$ commit-guard\n ", null],
599
- ["✗", "c-red"], [" [subject]", "c-dim"], [" unknown type: wip\n ", null],
600
- ["✗", "c-red"], [" [body]", "c-dim"], [" missing body\n ", null],
601
- ["✗", "c-red"], [" [imperative]", "c-dim"], [" got 'added', want imperative verb", null],
619
+ ["✗", "c-red"], [" [subject]", "c-dim"], [" subject does not match 'type(scope): description': WIP\n ", null],
620
+ ["✗", "c-red"], [" [signed-off]", "c-dim"], [" missing 'Signed-off-by' trailer — use 'git commit -s'\n ", null],
621
+ ["✗", "c-red"], [" [signature]", "c-dim"], [" commit is not signed (GPG/SSH)", null],
602
622
  ],
603
623
  [
604
624
  ["$ commit-guard --range origin/main..HEAD\n", null],
605
625
  ["abc1234", "c-dim"], [" feat: add user authentication\n ", null],
606
626
  ["✓", "c-green"], [" all checks passed\n", null],
607
- ["def5678", "c-dim"], [" wip: still working\n ", null],
608
- ["✗", "c-red"], [" [subject]", "c-dim"], [" unknown type: wip\n ", null],
627
+ ["def5678", "c-dim"], [" chore: added logging\n ", null],
628
+ ["✗", "c-red"], [" [imperative]", "c-dim"], [" expected imperative verb, got 'added' (non-imperative suffix)\n ", null],
609
629
  ["✗", "c-red"], [" [body]", "c-dim"], [" missing body", null],
610
630
  ],
611
631
  [
@@ -11,6 +11,7 @@ import urllib.request
11
11
  from argparse import ArgumentParser
12
12
  from dataclasses import dataclass, field
13
13
  from enum import StrEnum
14
+ from http import HTTPStatus
14
15
  from pathlib import Path
15
16
 
16
17
  import nltk
@@ -78,7 +79,10 @@ def _load_config(start=None):
78
79
  config_path = directory / ".commit-guard.toml"
79
80
  if config_path.exists():
80
81
  with config_path.open("rb") as f:
81
- return tomllib.load(f)
82
+ try:
83
+ return tomllib.load(f)
84
+ except tomllib.TOMLDecodeError as e:
85
+ sys.exit(f"{config_path}: {e}")
82
86
  return {}
83
87
 
84
88
 
@@ -147,6 +151,12 @@ def _download_if_missing(resource):
147
151
  nltk.download(resource.rsplit("/", maxsplit=1)[-1], quiet=True)
148
152
 
149
153
 
154
+ def _format_allowed_hint(allowed, kind):
155
+ if len(allowed) <= len(TYPES):
156
+ return f"(allowed: {', '.join(sorted(allowed))})"
157
+ return f"(see configured {kind})"
158
+
159
+
150
160
  def _strip_comments(message):
151
161
  return "\n".join(
152
162
  line for line in message.split("\n") if not line.lstrip().startswith("#")
@@ -174,13 +184,16 @@ def check_subject( # noqa: PLR0913 Too many arguments in function definition (9
174
184
  return None
175
185
 
176
186
  if m.group("type") not in allowed_types:
177
- result.error(f"unknown type: {m.group('type')}", check=Check.SUBJECT)
187
+ bad_type = m.group("type")
188
+ hint = _format_allowed_hint(allowed_types, "types")
189
+ result.error(f"unknown type: {bad_type} {hint}", check=Check.SUBJECT)
178
190
 
179
191
  scope = m.group("scope")
180
192
  if require_scope and scope is None:
181
193
  result.error("scope is required", check=Check.SUBJECT)
182
194
  if allowed_scopes and scope is not None and scope not in allowed_scopes:
183
- result.error(f"unknown scope: {scope}", check=Check.SUBJECT)
195
+ hint = _format_allowed_hint(allowed_scopes, "scopes")
196
+ result.error(f"unknown scope: {scope} {hint}", check=Check.SUBJECT)
184
197
 
185
198
  desc = m.group("desc")
186
199
  if require_lowercase and desc[0].isupper():
@@ -245,7 +258,10 @@ def check_body(lines, result):
245
258
 
246
259
  def check_signed_off(message, result):
247
260
  if not SIGNED_OFF_RE.search(message):
248
- result.error("missing 'Signed-off-by' trailer", check=Check.SIGNED_OFF)
261
+ result.error(
262
+ "missing 'Signed-off-by' trailer — use 'git commit -s'",
263
+ check=Check.SIGNED_OFF,
264
+ )
249
265
 
250
266
 
251
267
  def check_subject_pattern(subject, pattern, result):
@@ -384,22 +400,46 @@ def _verify_ssh(rev, email, ssh_text):
384
400
  Path(signers_path).unlink(missing_ok=True)
385
401
 
386
402
 
403
+ def _resolve_github_username(rev, email):
404
+ username = None
405
+ commits_api_404 = False
406
+ remote = _get_github_remote_info()
407
+ if remote:
408
+ owner, repo = remote
409
+ try:
410
+ username = _fetch_github_commit_author(owner, repo, rev)
411
+ except urllib.error.HTTPError as e:
412
+ if e.code == HTTPStatus.NOT_FOUND:
413
+ commits_api_404 = True
414
+ elif e.code in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN):
415
+ raise
416
+ except (urllib.error.URLError, TimeoutError):
417
+ pass
418
+ if username is None:
419
+ username = _parse_noreply_username(email)
420
+ if username is None:
421
+ username = _fetch_github_username(email)
422
+ return username, commits_api_404
423
+
424
+
425
+ def _author_not_found_message(commits_api_404):
426
+ had_token = bool(os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN"))
427
+ if commits_api_404 and not had_token:
428
+ return (
429
+ "commit author not found on GitHub — if the repo is private, "
430
+ "set GITHUB_TOKEN in the workflow step "
431
+ "(env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }})"
432
+ )
433
+ return "commit author not found on GitHub — cannot verify signature"
434
+
435
+
387
436
  def check_signature(rev, result):
388
437
  try:
389
438
  email = _get_author_email(rev)
390
- username = None
391
- remote = _get_github_remote_info()
392
- if remote:
393
- owner, repo = remote
394
- with contextlib.suppress(urllib.error.URLError, TimeoutError):
395
- username = _fetch_github_commit_author(owner, repo, rev)
396
- if username is None:
397
- username = _parse_noreply_username(email)
398
- if username is None:
399
- username = _fetch_github_username(email)
439
+ username, commits_api_404 = _resolve_github_username(rev, email)
400
440
  if username is None:
401
441
  result.error(
402
- "commit author not found on GitHub — cannot verify signature",
442
+ _author_not_found_message(commits_api_404),
403
443
  check=Check.SIGNATURE,
404
444
  )
405
445
  return
@@ -411,6 +451,29 @@ def check_signature(rev, result):
411
451
  result.info("signature type: SSH", check=Check.SIGNATURE)
412
452
  return
413
453
  result.error("commit is not signed (GPG/SSH)", check=Check.SIGNATURE)
454
+ except subprocess.TimeoutExpired:
455
+ result.error(
456
+ "git operation timed out — cannot verify signature",
457
+ check=Check.SIGNATURE,
458
+ )
459
+ except urllib.error.HTTPError as e:
460
+ if e.code == HTTPStatus.UNAUTHORIZED:
461
+ result.error(
462
+ "GitHub API rejected token (HTTP 401) — "
463
+ "GITHUB_TOKEN may be invalid or expired",
464
+ check=Check.SIGNATURE,
465
+ )
466
+ elif e.code == HTTPStatus.FORBIDDEN:
467
+ result.error(
468
+ "GitHub API forbidden (HTTP 403) — GITHUB_TOKEN may lack "
469
+ "'repo' scope, or you are unauthenticated and rate-limited",
470
+ check=Check.SIGNATURE,
471
+ )
472
+ else:
473
+ result.error(
474
+ f"GitHub API error (HTTP {e.code}) — cannot verify signature",
475
+ check=Check.SIGNATURE,
476
+ )
414
477
  except (urllib.error.URLError, TimeoutError):
415
478
  result.error(
416
479
  "GitHub API unreachable — cannot verify signature",
@@ -698,7 +761,7 @@ def _parse_args(): # noqa: PLR0915 Too many statements (59 > 50)
698
761
  message = ""
699
762
  elif args.message_file:
700
763
  rev = None
701
- message = _strip_comments(args.message_file.read_text().strip())
764
+ message = _strip_comments(args.message_file.read_text(encoding="utf-8").strip())
702
765
  elif args.rev:
703
766
  rev = args.rev
704
767
  message = _strip_comments(_get_message(rev))
@@ -124,6 +124,25 @@ class TestCheckSubject:
124
124
  check_subject("unknown: add thing", r)
125
125
  assert not r.ok
126
126
 
127
+ def test_unknown_type_with_default_lists_all_allowed(self):
128
+ r = Result()
129
+ check_subject("unknown: add thing", r)
130
+ assert any(
131
+ "allowed: " in m and "feat" in m and "fix" in m for _, _, m in r.errors
132
+ )
133
+
134
+ def test_unknown_type_with_smaller_set_lists_them(self):
135
+ r = Result()
136
+ check_subject("foo: add thing", r, allowed_types=frozenset({"feat", "fix"}))
137
+ assert any("allowed: feat, fix" in m for _, _, m in r.errors)
138
+
139
+ def test_unknown_type_with_larger_than_default_points_at_config(self):
140
+ r = Result()
141
+ oversized = frozenset({f"t{i}" for i in range(20)})
142
+ check_subject("foo: add thing", r, allowed_types=oversized)
143
+ assert any("see configured types" in m for _, _, m in r.errors)
144
+ assert not any("allowed:" in m for _, _, m in r.errors)
145
+
127
146
  def test_uppercase_description(self):
128
147
  r = Result()
129
148
  check_subject("fix: Add token", r)
@@ -184,6 +203,22 @@ class TestCheckSubject:
184
203
  check_subject("fix(api): add token", r, allowed_scopes=frozenset(["auth"]))
185
204
  assert not r.ok
186
205
 
206
+ def test_unknown_scope_with_small_set_lists_them(self):
207
+ r = Result()
208
+ check_subject(
209
+ "fix(api): add token",
210
+ r,
211
+ allowed_scopes=frozenset({"auth", "db"}),
212
+ )
213
+ assert any("allowed: auth, db" in m for _, _, m in r.errors)
214
+
215
+ def test_unknown_scope_with_larger_than_default_points_at_config(self):
216
+ r = Result()
217
+ oversized = frozenset({f"s{i}" for i in range(20)})
218
+ check_subject("fix(foo): add token", r, allowed_scopes=oversized)
219
+ assert any("see configured scopes" in m for _, _, m in r.errors)
220
+ assert not any("allowed:" in m for _, _, m in r.errors)
221
+
187
222
  def test_no_scope_with_allowlist_passes(self):
188
223
  r = Result()
189
224
  check_subject("fix: add token", r, allowed_scopes=frozenset(["auth"]))
@@ -325,6 +360,11 @@ class TestCheckSignedOff:
325
360
  check_signed_off("fix: add thing\n\nbody", r)
326
361
  assert not r.ok
327
362
 
363
+ def test_missing_message_hints_at_git_commit_dash_s(self):
364
+ r = Result()
365
+ check_signed_off("fix: add thing\n\nbody", r)
366
+ assert any("git commit -s" in m for _, _, m in r.errors)
367
+
328
368
  def test_malformed_no_email(self):
329
369
  r = Result()
330
370
  check_signed_off("fix: add thing\n\nSigned-off-by: John Doe", r)
@@ -816,6 +856,157 @@ class TestCheckSignature:
816
856
  assert not r.ok
817
857
  assert any("not found on GitHub" in msg for _, _, msg in r.errors)
818
858
 
859
+ def test_commits_api_404_without_token_hints_at_token(self):
860
+ r = Result()
861
+ env = {
862
+ k: v for k, v in os.environ.items() if k not in ("GITHUB_TOKEN", "GH_TOKEN")
863
+ }
864
+ with (
865
+ patch(
866
+ "git_commit_guard._get_author_email", return_value="user@example.com"
867
+ ),
868
+ patch(
869
+ "git_commit_guard._get_github_remote_info",
870
+ return_value=("owner", "repo"),
871
+ ),
872
+ patch(
873
+ "git_commit_guard._fetch_github_commit_author",
874
+ side_effect=urllib.error.HTTPError(
875
+ url="", code=404, msg="Not Found", hdrs=None, fp=None
876
+ ),
877
+ ),
878
+ patch("git_commit_guard._fetch_github_username", return_value=None),
879
+ patch.dict("os.environ", env, clear=True),
880
+ ):
881
+ check_signature("abc123", r)
882
+ assert not r.ok
883
+ assert any("set GITHUB_TOKEN" in msg for _, _, msg in r.errors)
884
+
885
+ def test_commits_api_404_with_token_keeps_generic_message(self):
886
+ r = Result()
887
+ with (
888
+ patch(
889
+ "git_commit_guard._get_author_email", return_value="user@example.com"
890
+ ),
891
+ patch(
892
+ "git_commit_guard._get_github_remote_info",
893
+ return_value=("owner", "repo"),
894
+ ),
895
+ patch(
896
+ "git_commit_guard._fetch_github_commit_author",
897
+ side_effect=urllib.error.HTTPError(
898
+ url="", code=404, msg="Not Found", hdrs=None, fp=None
899
+ ),
900
+ ),
901
+ patch("git_commit_guard._fetch_github_username", return_value=None),
902
+ patch.dict("os.environ", {"GITHUB_TOKEN": "x"}, clear=False),
903
+ ):
904
+ check_signature("abc123", r)
905
+ assert not r.ok
906
+ assert not any("set GITHUB_TOKEN" in msg for _, _, msg in r.errors)
907
+ assert any("cannot verify signature" in msg for _, _, msg in r.errors)
908
+
909
+ def test_commits_api_non_404_http_error_falls_through(self):
910
+ r = Result()
911
+ with (
912
+ patch(
913
+ "git_commit_guard._get_author_email", return_value="user@example.com"
914
+ ),
915
+ patch(
916
+ "git_commit_guard._get_github_remote_info",
917
+ return_value=("owner", "repo"),
918
+ ),
919
+ patch(
920
+ "git_commit_guard._fetch_github_commit_author",
921
+ side_effect=urllib.error.HTTPError(
922
+ url="", code=500, msg="Server Error", hdrs=None, fp=None
923
+ ),
924
+ ),
925
+ patch("git_commit_guard._fetch_github_username", return_value="emailuser"),
926
+ patch("git_commit_guard._fetch_github_keys", return_value=("GPG KEY", "")),
927
+ patch("git_commit_guard._verify_gpg", return_value=True),
928
+ ):
929
+ check_signature("abc123", r)
930
+ assert r.ok
931
+
932
+ def test_commits_api_401_surfaces_token_message(self):
933
+ r = Result()
934
+ with (
935
+ patch(
936
+ "git_commit_guard._get_author_email", return_value="user@example.com"
937
+ ),
938
+ patch(
939
+ "git_commit_guard._get_github_remote_info",
940
+ return_value=("owner", "repo"),
941
+ ),
942
+ patch(
943
+ "git_commit_guard._fetch_github_commit_author",
944
+ side_effect=urllib.error.HTTPError(
945
+ url="", code=401, msg="Unauthorized", hdrs=None, fp=None
946
+ ),
947
+ ),
948
+ ):
949
+ check_signature("abc123", r)
950
+ assert not r.ok
951
+ assert any("rejected token (HTTP 401)" in msg for _, _, msg in r.errors)
952
+
953
+ def test_commits_api_403_surfaces_token_message(self):
954
+ r = Result()
955
+ with (
956
+ patch(
957
+ "git_commit_guard._get_author_email", return_value="user@example.com"
958
+ ),
959
+ patch(
960
+ "git_commit_guard._get_github_remote_info",
961
+ return_value=("owner", "repo"),
962
+ ),
963
+ patch(
964
+ "git_commit_guard._fetch_github_commit_author",
965
+ side_effect=urllib.error.HTTPError(
966
+ url="", code=403, msg="Forbidden", hdrs=None, fp=None
967
+ ),
968
+ ),
969
+ ):
970
+ check_signature("abc123", r)
971
+ assert not r.ok
972
+ assert any("forbidden (HTTP 403)" in msg for _, _, msg in r.errors)
973
+
974
+ def test_search_api_403_surfaces_rate_limit_message(self):
975
+ r = Result()
976
+ with (
977
+ patch(
978
+ "git_commit_guard._get_author_email", return_value="corp@example.com"
979
+ ),
980
+ patch("git_commit_guard._get_github_remote_info", return_value=None),
981
+ patch(
982
+ "git_commit_guard._fetch_github_username",
983
+ side_effect=urllib.error.HTTPError(
984
+ url="", code=403, msg="Forbidden", hdrs=None, fp=None
985
+ ),
986
+ ),
987
+ ):
988
+ check_signature("abc123", r)
989
+ assert not r.ok
990
+ assert any("forbidden (HTTP 403)" in msg for _, _, msg in r.errors)
991
+
992
+ def test_other_http_error_uses_generic_http_message(self):
993
+ r = Result()
994
+ with (
995
+ patch(
996
+ "git_commit_guard._get_author_email", return_value="corp@example.com"
997
+ ),
998
+ patch("git_commit_guard._get_github_remote_info", return_value=None),
999
+ patch(
1000
+ "git_commit_guard._fetch_github_username",
1001
+ side_effect=urllib.error.HTTPError(
1002
+ url="", code=500, msg="Server Error", hdrs=None, fp=None
1003
+ ),
1004
+ ),
1005
+ ):
1006
+ check_signature("abc123", r)
1007
+ assert not r.ok
1008
+ assert any("GitHub API error (HTTP 500)" in msg for _, _, msg in r.errors)
1009
+
819
1010
  def test_url_error_fails(self):
820
1011
  r = Result()
821
1012
  with patch(
@@ -833,6 +1024,16 @@ class TestCheckSignature:
833
1024
  assert not r.ok
834
1025
  assert any("API unreachable" in msg for _, _, msg in r.errors)
835
1026
 
1027
+ def test_subprocess_timeout_fails_gracefully(self):
1028
+ r = Result()
1029
+ with patch(
1030
+ "git_commit_guard._get_author_email",
1031
+ side_effect=subprocess.TimeoutExpired(cmd="git", timeout=10),
1032
+ ):
1033
+ check_signature("abc123", r)
1034
+ assert not r.ok
1035
+ assert any("git operation timed out" in msg for _, _, msg in r.errors)
1036
+
836
1037
  def test_commits_api_resolves_username(self):
837
1038
  r = Result()
838
1039
  with (
@@ -1007,6 +1208,12 @@ class TestLoadConfig:
1007
1208
  (subdir / ".commit-guard.toml").write_text('disable = ["signature"]\n')
1008
1209
  assert _load_config(subdir) == {"disable": ["signature"]}
1009
1210
 
1211
+ def test_malformed_toml_exits_with_path(self, tmp_path):
1212
+ config_path = tmp_path / ".commit-guard.toml"
1213
+ config_path.write_text("disable = [unclosed\n")
1214
+ with pytest.raises(SystemExit, match=re.escape(str(config_path))):
1215
+ _load_config(tmp_path)
1216
+
1010
1217
 
1011
1218
  class TestParseConfigChecks:
1012
1219
  def test_disable_list(self):
@@ -1244,6 +1451,18 @@ class TestMain:
1244
1451
  assert main() == 0
1245
1452
  assert "all checks passed" in capsys.readouterr().out
1246
1453
 
1454
+ def test_from_message_file_utf8_non_ascii(self, tmp_path):
1455
+ f = tmp_path / "msg"
1456
+ f.write_bytes(
1457
+ "fix: handle non-ascii\n\nbody\n\n"
1458
+ "Signed-off-by: Nerijus Bendžiūnas <a@b.com>".encode()
1459
+ )
1460
+ with patch(
1461
+ "sys.argv",
1462
+ ["cg", "--message-file", str(f), "--disable", "signature"],
1463
+ ):
1464
+ assert main() == 0
1465
+
1247
1466
  def test_from_stdin(self):
1248
1467
  stdin = MagicMock()
1249
1468
  stdin.isatty.return_value = False