git-commit-guard 0.16.0__tar.gz → 0.18.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 (25) hide show
  1. git_commit_guard-0.18.0/.github/workflows/coverage-baseline.yml +33 -0
  2. git_commit_guard-0.18.0/.github/workflows/coverage-comment.yml +97 -0
  3. {git_commit_guard-0.16.0 → git_commit_guard-0.18.0}/.github/workflows/lint-commits.yml +1 -1
  4. git_commit_guard-0.18.0/.github/workflows/lint-workflows.yml +47 -0
  5. {git_commit_guard-0.16.0 → git_commit_guard-0.18.0}/.github/workflows/release.yml +1 -1
  6. {git_commit_guard-0.16.0 → git_commit_guard-0.18.0}/.github/workflows/test.yml +9 -5
  7. {git_commit_guard-0.16.0 → git_commit_guard-0.18.0}/PKG-INFO +52 -11
  8. {git_commit_guard-0.16.0 → git_commit_guard-0.18.0}/README.md +51 -10
  9. {git_commit_guard-0.16.0 → git_commit_guard-0.18.0}/action.yml +11 -0
  10. {git_commit_guard-0.16.0 → git_commit_guard-0.18.0}/docs/index.html +11 -7
  11. {git_commit_guard-0.16.0 → git_commit_guard-0.18.0}/src/git_commit_guard/__init__.py +43 -4
  12. {git_commit_guard-0.16.0 → git_commit_guard-0.18.0}/tests/test_git_commit_guard.py +98 -0
  13. {git_commit_guard-0.16.0 → git_commit_guard-0.18.0}/.editorconfig +0 -0
  14. {git_commit_guard-0.16.0 → git_commit_guard-0.18.0}/.github/workflows/lint-md.yml +0 -0
  15. {git_commit_guard-0.16.0 → git_commit_guard-0.18.0}/.github/workflows/lint-python.yml +0 -0
  16. {git_commit_guard-0.16.0 → git_commit_guard-0.18.0}/.gitignore +0 -0
  17. {git_commit_guard-0.16.0 → git_commit_guard-0.18.0}/.markdownlint.json +0 -0
  18. {git_commit_guard-0.16.0 → git_commit_guard-0.18.0}/.pre-commit-hooks.yaml +0 -0
  19. {git_commit_guard-0.16.0 → git_commit_guard-0.18.0}/.python-version +0 -0
  20. {git_commit_guard-0.16.0 → git_commit_guard-0.18.0}/LICENSE +0 -0
  21. {git_commit_guard-0.16.0 → git_commit_guard-0.18.0}/docs/commit-guard-icon.svg +0 -0
  22. {git_commit_guard-0.16.0 → git_commit_guard-0.18.0}/pyproject.toml +0 -0
  23. {git_commit_guard-0.16.0 → git_commit_guard-0.18.0}/ruff.toml +0 -0
  24. {git_commit_guard-0.16.0 → git_commit_guard-0.18.0}/tests/__init__.py +0 -0
  25. {git_commit_guard-0.16.0 → git_commit_guard-0.18.0}/uv.lock +0 -0
@@ -0,0 +1,33 @@
1
+ ---
2
+ name: Coverage Baseline
3
+ on: # yamllint disable-line rule:truthy
4
+ push:
5
+ branches: [main]
6
+ permissions:
7
+ contents: read
8
+ jobs:
9
+ baseline:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - name: Checkout code
13
+ # yamllint disable-line rule:line-length
14
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # ratchet:actions/checkout@v6
15
+ with:
16
+ persist-credentials: false
17
+ - name: Install uv
18
+ # yamllint disable-line rule:line-length
19
+ uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # ratchet:astral-sh/setup-uv@v7
20
+ - name: Cache NLTK data
21
+ # yamllint disable-line rule:line-length
22
+ uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # ratchet:actions/cache@v5
23
+ with:
24
+ path: ~/nltk_data
25
+ key: nltk-averaged-perceptron-tagger-punkt
26
+ - name: Run tests with coverage
27
+ run: uv run --dev pytest tests/ --cov=git_commit_guard --cov-report=xml
28
+ - name: Upload coverage baseline
29
+ # yamllint disable-line rule:line-length
30
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # ratchet:actions/upload-artifact@v4
31
+ with:
32
+ name: main-coverage
33
+ path: coverage.xml
@@ -0,0 +1,97 @@
1
+ ---
2
+ name: Coverage Comment
3
+ on: # yamllint disable-line rule:truthy # zizmor: ignore[dangerous-triggers]
4
+ workflow_run:
5
+ workflows: [Test]
6
+ types: [completed]
7
+ permissions:
8
+ actions: read
9
+ pull-requests: write
10
+ jobs:
11
+ comment:
12
+ runs-on: ubuntu-latest
13
+ if: >-
14
+ github.event.workflow_run.event == 'pull_request' &&
15
+ github.event.workflow_run.conclusion == 'success'
16
+ steps:
17
+ - name: Download artifact
18
+ # yamllint disable-line rule:line-length
19
+ uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # ratchet:actions/download-artifact@v4
20
+ with:
21
+ run-id: ${{ github.event.workflow_run.id }}
22
+ name: pr
23
+ github-token: ${{ secrets.GITHUB_TOKEN }}
24
+ - name: Read PR number
25
+ id: pr
26
+ run: |
27
+ pr_number=$(cat NR)
28
+ if ! [[ "$pr_number" =~ ^[0-9]+$ ]]; then
29
+ echo "Invalid PR number: $pr_number" >&2
30
+ exit 1
31
+ fi
32
+ echo "number=$pr_number" >> "$GITHUB_OUTPUT"
33
+ - name: Find baseline run
34
+ id: baseline
35
+ env:
36
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
37
+ run: |
38
+ python3 - <<'PYEOF'
39
+ import json, os, urllib.request
40
+ token = os.environ['GITHUB_TOKEN']
41
+ repo = os.environ['GITHUB_REPOSITORY']
42
+ url = (
43
+ f'https://api.github.com/repos/{repo}/actions/workflows'
44
+ f'/coverage-baseline.yml/runs'
45
+ f'?branch=main&status=success&per_page=1'
46
+ )
47
+ req = urllib.request.Request(url, headers={
48
+ 'Authorization': f'Bearer {token}',
49
+ 'Accept': 'application/vnd.github+json',
50
+ })
51
+ with urllib.request.urlopen(req) as r:
52
+ runs = json.loads(r.read()).get('workflow_runs', [])
53
+ run_id = str(runs[0]['id']) if runs else ''
54
+ with open(os.environ['GITHUB_OUTPUT'], 'a') as f:
55
+ f.write(f'run-id={run_id}\n')
56
+ PYEOF
57
+ - name: Download baseline coverage
58
+ if: steps.baseline.outputs.run-id != ''
59
+ # yamllint disable-line rule:line-length
60
+ uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # ratchet:actions/download-artifact@v4
61
+ with:
62
+ run-id: ${{ steps.baseline.outputs.run-id }}
63
+ name: main-coverage
64
+ path: main/
65
+ github-token: ${{ secrets.GITHUB_TOKEN }}
66
+ - name: Compute coverage delta
67
+ id: delta
68
+ if: steps.baseline.outputs.run-id != ''
69
+ run: |
70
+ python3 - <<'PYEOF'
71
+ import xml.etree.ElementTree as ET
72
+ import os, sys
73
+ try:
74
+ pr_rate = ET.parse('coverage.xml').getroot().get('line-rate')
75
+ main_xml = ET.parse('main/coverage.xml').getroot()
76
+ main_rate = main_xml.get('line-rate')
77
+ pr = float(pr_rate) * 100
78
+ main = float(main_rate) * 100
79
+ delta = pr - main
80
+ if not (-100 <= delta <= 100):
81
+ raise ValueError(f"delta out of range: {delta}")
82
+ sign = '+' if delta >= 0 else ''
83
+ title = f"Coverage Report (Δ {sign}{delta:.1f}%)"
84
+ except Exception as e:
85
+ print(f"Warning: could not compute delta: {e}", file=sys.stderr)
86
+ title = "Coverage Report"
87
+ with open(os.environ['GITHUB_OUTPUT'], 'a') as f:
88
+ f.write(f"title={title}\n")
89
+ PYEOF
90
+ - name: Post coverage comment
91
+ # yamllint disable-line rule:line-length
92
+ uses: MishaKav/pytest-coverage-comment@dd5b80bde6d16941f336518e92929e89069d8451 # ratchet:MishaKav/pytest-coverage-comment@v1.7.2
93
+ with:
94
+ pytest-xml-coverage-path: coverage.xml
95
+ unique-id-for-comment: coverage
96
+ issue-number: ${{ steps.pr.outputs.number }}
97
+ title: ${{ steps.delta.outputs.title || 'Coverage Report' }}
@@ -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@d4c9fbe4a203ab32f63f66e1902d780528f781f2 # v0.17.0
26
26
  with:
27
27
  range: origin/${{ github.base_ref }}..HEAD
28
28
  disable: signature
@@ -0,0 +1,47 @@
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
+
25
+ zizmor:
26
+ runs-on: ubuntu-latest
27
+ permissions:
28
+ pull-requests: write
29
+ steps:
30
+ - name: Checkout code
31
+ # yamllint disable-line rule:line-length
32
+ uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # ratchet:actions/checkout@v4
33
+ with:
34
+ persist-credentials: false
35
+ - name: Set up uv
36
+ # yamllint disable-line rule:line-length
37
+ uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # ratchet:astral-sh/setup-uv@v7
38
+ - name: Set up reviewdog
39
+ # yamllint disable-line rule:line-length
40
+ uses: reviewdog/action-setup@d8a7baabd7f3e8544ee4dbde3ee41d0011c3a93f # ratchet:reviewdog/action-setup@v1
41
+ - name: Run zizmor
42
+ env:
43
+ REVIEWDOG_GITHUB_API_TOKEN: ${{ github.token }}
44
+ run: |
45
+ uvx zizmor==1.24.1 --format=sarif . |
46
+ reviewdog -f=sarif -name=zizmor \
47
+ -reporter=github-pr-review -fail-on-error
@@ -33,7 +33,7 @@ jobs:
33
33
  - name: Generate release notes
34
34
  id: git-cliff
35
35
  # yamllint disable-line rule:line-length
36
- uses: orhun/git-cliff-action@c93ef52f3d0ddcdcc9bd5447d98d458a11cd4f72 # v4.7.1
36
+ uses: orhun/git-cliff-action@f50e11560dce63f7c33227798f90b924471a88b5 # ratchet:orhun/git-cliff-action@v4.8.0
37
37
  with:
38
38
  args: --current
39
39
  - name: Create GitHub Release
@@ -4,7 +4,6 @@ on: # yamllint disable-line rule:truthy
4
4
  pull_request:
5
5
  permissions:
6
6
  contents: read
7
- pull-requests: write
8
7
  jobs:
9
8
  test:
10
9
  runs-on: ubuntu-latest
@@ -27,9 +26,14 @@ jobs:
27
26
  uv run --dev pytest \
28
27
  tests/ --cov=git_commit_guard --cov-report=term-missing \
29
28
  --cov-report=xml
30
- - name: Post coverage comment
29
+ - name: Save coverage artifact
30
+ run: |
31
+ mkdir -p ./pr
32
+ echo ${{ github.event.number }} > ./pr/NR
33
+ cp coverage.xml ./pr/
34
+ - name: Upload PR artifact
31
35
  # yamllint disable-line rule:line-length
32
- uses: MishaKav/pytest-coverage-comment@dd5b80bde6d16941f336518e92929e89069d8451 # v1.7.2
36
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # ratchet:actions/upload-artifact@v4
33
37
  with:
34
- pytest-xml-coverage-path: coverage.xml
35
- unique-id-for-comment: coverage
38
+ name: pr
39
+ path: pr/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: git-commit-guard
3
- Version: 0.16.0
3
+ Version: 0.18.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
@@ -116,6 +116,41 @@ By default there is no minimum description length. Enforce one with
116
116
  commit-guard --min-description-length 10
117
117
  ```
118
118
 
119
+ ### Subject format
120
+
121
+ By default the description must start with a lowercase letter. To allow
122
+ uppercase descriptions:
123
+
124
+ ```bash
125
+ commit-guard --no-require-lowercase
126
+ ```
127
+
128
+ In `.commit-guard.toml`:
129
+
130
+ ```toml
131
+ require-lowercase = false
132
+ ```
133
+
134
+ By default `.`, `!`, `?`, and space are forbidden as trailing characters.
135
+ To change the set (any character is valid):
136
+
137
+ ```bash
138
+ commit-guard --no-trailing-chars ".,"
139
+ commit-guard --no-trailing-chars ".,!"
140
+ ```
141
+
142
+ In `.commit-guard.toml`:
143
+
144
+ ```toml
145
+ no-trailing-chars = [".", "!"]
146
+ ```
147
+
148
+ Pass an empty list to disable the check entirely:
149
+
150
+ ```toml
151
+ no-trailing-chars = []
152
+ ```
153
+
119
154
  ### Type validation
120
155
 
121
156
  By default the standard conventional commit types are accepted. Use `--types`
@@ -170,7 +205,8 @@ independently of `--enable`/`--disable`.
170
205
 
171
206
  Place `.commit-guard.toml` in your project root (or any parent directory) to
172
207
  set defaults for `enable`, `disable`, `scopes`, `require-scope`, `types`,
173
- `max-subject-length`, `min-description-length`, and `require-trailers`.
208
+ `max-subject-length`, `min-description-length`, `require-lowercase`,
209
+ `no-trailing-chars`, and `require-trailers`.
174
210
  commit-guard searches upward from the working directory and uses the first
175
211
  file found.
176
212
 
@@ -182,6 +218,8 @@ require-scope = true
182
218
  types = ["feat", "fix", "chore", "wip"]
183
219
  max-subject-length = 100
184
220
  min-description-length = 10
221
+ require-lowercase = false
222
+ no-trailing-chars = [".", "!"]
185
223
  require-trailers = ["Closes", "Reviewed-by"]
186
224
  ```
187
225
 
@@ -191,7 +229,8 @@ enable = ["subject", "imperative"]
191
229
  ```
192
230
 
193
231
  CLI flags (`--enable`, `--disable`, `--scopes`, `--require-scope`, `--types`,
194
- `--max-subject-length`, `--min-description-length`, `--require-trailer`) take
232
+ `--max-subject-length`, `--min-description-length`, `--no-require-lowercase`,
233
+ `--no-trailing-chars`, `--require-trailer`) take
195
234
  full precedence and ignore config file values when provided.
196
235
 
197
236
  ### Environment variables
@@ -207,7 +246,7 @@ COMMIT_GUARD_GIT_TIMEOUT=30 commit-guard --range origin/main..HEAD
207
246
  In GitHub Actions, set it at the step or job level:
208
247
 
209
248
  ```yaml
210
- - uses: benner/commit-guard@v0.16.0
249
+ - uses: benner/commit-guard@v0.18.0
211
250
  env:
212
251
  COMMIT_GUARD_GIT_TIMEOUT: 30
213
252
  with:
@@ -291,7 +330,7 @@ steps:
291
330
  - uses: actions/checkout@v4
292
331
  with:
293
332
  fetch-depth: 0
294
- - uses: benner/commit-guard@v0.16.0
333
+ - uses: benner/commit-guard@v0.18.0
295
334
  ```
296
335
 
297
336
  Check all commits in a pull request:
@@ -307,7 +346,7 @@ jobs:
307
346
  - uses: actions/checkout@v4
308
347
  with:
309
348
  fetch-depth: 0
310
- - uses: benner/commit-guard@v0.16.0
349
+ - uses: benner/commit-guard@v0.18.0
311
350
  with:
312
351
  range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}
313
352
  ```
@@ -315,7 +354,7 @@ jobs:
315
354
  Check a specific commit SHA (mirrors the positional CLI argument):
316
355
 
317
356
  ```yaml
318
- - uses: benner/commit-guard@v0.16.0
357
+ - uses: benner/commit-guard@v0.18.0
319
358
  with:
320
359
  rev: ${{ github.sha }}
321
360
  ```
@@ -333,7 +372,7 @@ jobs:
333
372
  - uses: actions/checkout@v4
334
373
  with:
335
374
  fetch-depth: 0
336
- - uses: benner/commit-guard@v0.16.0
375
+ - uses: benner/commit-guard@v0.18.0
337
376
  with:
338
377
  range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}
339
378
  disable: signed-off,signature
@@ -342,6 +381,8 @@ jobs:
342
381
  require-trailer: 'Closes,Reviewed-by'
343
382
  max-subject-length: '100'
344
383
  min-description-length: '10'
384
+ no-require-lowercase: 'true'
385
+ no-trailing-chars: '.,!'
345
386
  allow-empty: 'true'
346
387
  include-merges: 'true'
347
388
  output-file: results.jsonl
@@ -350,7 +391,7 @@ jobs:
350
391
  When `output-file` is set the action exposes the path as an output:
351
392
 
352
393
  ```yaml
353
- - uses: benner/commit-guard@v0.16.0
394
+ - uses: benner/commit-guard@v0.18.0
354
395
  id: cg
355
396
  with:
356
397
  range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}
@@ -366,7 +407,7 @@ Add to your `.pre-commit-config.yaml`:
366
407
  ---
367
408
  repos:
368
409
  - repo: https://github.com/benner/commit-guard
369
- rev: v0.16.0
410
+ rev: v0.18.0
370
411
  hooks:
371
412
  - id: commit-guard
372
413
  - 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
@@ -95,6 +95,41 @@ By default there is no minimum description length. Enforce one with
95
95
  commit-guard --min-description-length 10
96
96
  ```
97
97
 
98
+ ### Subject format
99
+
100
+ By default the description must start with a lowercase letter. To allow
101
+ uppercase descriptions:
102
+
103
+ ```bash
104
+ commit-guard --no-require-lowercase
105
+ ```
106
+
107
+ In `.commit-guard.toml`:
108
+
109
+ ```toml
110
+ require-lowercase = false
111
+ ```
112
+
113
+ By default `.`, `!`, `?`, and space are forbidden as trailing characters.
114
+ To change the set (any character is valid):
115
+
116
+ ```bash
117
+ commit-guard --no-trailing-chars ".,"
118
+ commit-guard --no-trailing-chars ".,!"
119
+ ```
120
+
121
+ In `.commit-guard.toml`:
122
+
123
+ ```toml
124
+ no-trailing-chars = [".", "!"]
125
+ ```
126
+
127
+ Pass an empty list to disable the check entirely:
128
+
129
+ ```toml
130
+ no-trailing-chars = []
131
+ ```
132
+
98
133
  ### Type validation
99
134
 
100
135
  By default the standard conventional commit types are accepted. Use `--types`
@@ -149,7 +184,8 @@ independently of `--enable`/`--disable`.
149
184
 
150
185
  Place `.commit-guard.toml` in your project root (or any parent directory) to
151
186
  set defaults for `enable`, `disable`, `scopes`, `require-scope`, `types`,
152
- `max-subject-length`, `min-description-length`, and `require-trailers`.
187
+ `max-subject-length`, `min-description-length`, `require-lowercase`,
188
+ `no-trailing-chars`, and `require-trailers`.
153
189
  commit-guard searches upward from the working directory and uses the first
154
190
  file found.
155
191
 
@@ -161,6 +197,8 @@ require-scope = true
161
197
  types = ["feat", "fix", "chore", "wip"]
162
198
  max-subject-length = 100
163
199
  min-description-length = 10
200
+ require-lowercase = false
201
+ no-trailing-chars = [".", "!"]
164
202
  require-trailers = ["Closes", "Reviewed-by"]
165
203
  ```
166
204
 
@@ -170,7 +208,8 @@ enable = ["subject", "imperative"]
170
208
  ```
171
209
 
172
210
  CLI flags (`--enable`, `--disable`, `--scopes`, `--require-scope`, `--types`,
173
- `--max-subject-length`, `--min-description-length`, `--require-trailer`) take
211
+ `--max-subject-length`, `--min-description-length`, `--no-require-lowercase`,
212
+ `--no-trailing-chars`, `--require-trailer`) take
174
213
  full precedence and ignore config file values when provided.
175
214
 
176
215
  ### Environment variables
@@ -186,7 +225,7 @@ COMMIT_GUARD_GIT_TIMEOUT=30 commit-guard --range origin/main..HEAD
186
225
  In GitHub Actions, set it at the step or job level:
187
226
 
188
227
  ```yaml
189
- - uses: benner/commit-guard@v0.16.0
228
+ - uses: benner/commit-guard@v0.18.0
190
229
  env:
191
230
  COMMIT_GUARD_GIT_TIMEOUT: 30
192
231
  with:
@@ -270,7 +309,7 @@ steps:
270
309
  - uses: actions/checkout@v4
271
310
  with:
272
311
  fetch-depth: 0
273
- - uses: benner/commit-guard@v0.16.0
312
+ - uses: benner/commit-guard@v0.18.0
274
313
  ```
275
314
 
276
315
  Check all commits in a pull request:
@@ -286,7 +325,7 @@ jobs:
286
325
  - uses: actions/checkout@v4
287
326
  with:
288
327
  fetch-depth: 0
289
- - uses: benner/commit-guard@v0.16.0
328
+ - uses: benner/commit-guard@v0.18.0
290
329
  with:
291
330
  range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}
292
331
  ```
@@ -294,7 +333,7 @@ jobs:
294
333
  Check a specific commit SHA (mirrors the positional CLI argument):
295
334
 
296
335
  ```yaml
297
- - uses: benner/commit-guard@v0.16.0
336
+ - uses: benner/commit-guard@v0.18.0
298
337
  with:
299
338
  rev: ${{ github.sha }}
300
339
  ```
@@ -312,7 +351,7 @@ jobs:
312
351
  - uses: actions/checkout@v4
313
352
  with:
314
353
  fetch-depth: 0
315
- - uses: benner/commit-guard@v0.16.0
354
+ - uses: benner/commit-guard@v0.18.0
316
355
  with:
317
356
  range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}
318
357
  disable: signed-off,signature
@@ -321,6 +360,8 @@ jobs:
321
360
  require-trailer: 'Closes,Reviewed-by'
322
361
  max-subject-length: '100'
323
362
  min-description-length: '10'
363
+ no-require-lowercase: 'true'
364
+ no-trailing-chars: '.,!'
324
365
  allow-empty: 'true'
325
366
  include-merges: 'true'
326
367
  output-file: results.jsonl
@@ -329,7 +370,7 @@ jobs:
329
370
  When `output-file` is set the action exposes the path as an output:
330
371
 
331
372
  ```yaml
332
- - uses: benner/commit-guard@v0.16.0
373
+ - uses: benner/commit-guard@v0.18.0
333
374
  id: cg
334
375
  with:
335
376
  range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}
@@ -345,7 +386,7 @@ Add to your `.pre-commit-config.yaml`:
345
386
  ---
346
387
  repos:
347
388
  - repo: https://github.com/benner/commit-guard
348
- rev: v0.16.0
389
+ rev: v0.18.0
349
390
  hooks:
350
391
  - id: commit-guard
351
392
  - id: commit-guard-signature
@@ -34,6 +34,13 @@ inputs:
34
34
  min-description-length:
35
35
  description: Minimum description length in characters (default 0, off)
36
36
  required: false
37
+ no-require-lowercase:
38
+ description: Allow description to start with uppercase (default false)
39
+ required: false
40
+ default: 'false'
41
+ no-trailing-chars:
42
+ description: Forbidden trailing characters, comma-separated (default '.', '!', '?', ' ')
43
+ required: false
37
44
  allow-empty:
38
45
  description: Exit 0 when --range yields no commits
39
46
  required: false
@@ -75,6 +82,8 @@ runs:
75
82
  CG_TYPES: ${{ inputs.types }}
76
83
  CG_MAX_SUBJECT_LENGTH: ${{ inputs.max-subject-length }}
77
84
  CG_MIN_DESCRIPTION_LENGTH: ${{ inputs.min-description-length }}
85
+ CG_NO_REQUIRE_LOWERCASE: ${{ inputs.no-require-lowercase }}
86
+ CG_NO_TRAILING_CHARS: ${{ inputs.no-trailing-chars }}
78
87
  CG_ALLOW_EMPTY: ${{ inputs.allow-empty }}
79
88
  CG_INCLUDE_MERGES: ${{ inputs.include-merges }}
80
89
  CG_REQUIRE_TRAILER: ${{ inputs.require-trailer }}
@@ -92,6 +101,8 @@ runs:
92
101
  ARGS+=(--max-subject-length "$CG_MAX_SUBJECT_LENGTH")
93
102
  [[ -n "$CG_MIN_DESCRIPTION_LENGTH" ]] && \
94
103
  ARGS+=(--min-description-length "$CG_MIN_DESCRIPTION_LENGTH")
104
+ [[ "$CG_NO_REQUIRE_LOWERCASE" == "true" ]] && ARGS+=(--no-require-lowercase)
105
+ [[ -n "$CG_NO_TRAILING_CHARS" ]] && ARGS+=(--no-trailing-chars "$CG_NO_TRAILING_CHARS")
95
106
  [[ "$CG_ALLOW_EMPTY" == "true" ]] && ARGS+=(--allow-empty)
96
107
  [[ "$CG_INCLUDE_MERGES" == "true" ]] && ARGS+=(--include-merges)
97
108
  [[ -n "$CG_REQUIRE_TRAILER" ]] && ARGS+=(--require-trailer "$CG_REQUIRE_TRAILER")
@@ -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>
@@ -398,6 +398,8 @@ require-scope = true
398
398
  types = ["feat", "fix", "chore", "wip"]
399
399
  max-subject-length = 100
400
400
  min-description-length = 10
401
+ require-lowercase = false
402
+ no-trailing-chars = [".", "!"]
401
403
  require-trailers = ["Closes", "Reviewed-by"]</code></pre>
402
404
 
403
405
  <h3>Required trailers</h3>
@@ -477,13 +479,13 @@ require-trailers = ["Closes", "Reviewed-by"]</code></pre>
477
479
  - uses: actions/checkout@v4
478
480
  with:
479
481
  fetch-depth: 0
480
- - uses: benner/commit-guard@v0.16.0
482
+ - uses: benner/commit-guard@v0.18.0
481
483
  with:
482
484
  range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}
483
485
  disable: signed-off,signature</code></pre>
484
486
 
485
487
  <p>Check a specific commit SHA:</p>
486
- <pre><code class="language-yaml"> - uses: benner/commit-guard@v0.16.0
488
+ <pre><code class="language-yaml"> - uses: benner/commit-guard@v0.18.0
487
489
  with:
488
490
  rev: ${{ github.sha }}</code></pre>
489
491
 
@@ -492,15 +494,17 @@ require-trailers = ["Closes", "Reviewed-by"]</code></pre>
492
494
  <code>range</code>, <code>enable</code>, <code>disable</code>,
493
495
  <code>scopes</code>, <code>require-scope</code>, <code>types</code>,
494
496
  <code>max-subject-length</code>, <code>min-description-length</code>,
495
- <code>require-trailer</code>, <code>allow-empty</code>,
496
- <code>include-merges</code>, <code>output-file</code>.
497
+ <code>no-require-lowercase</code>, <code>no-trailing-chars</code>,
498
+ <code>require-trailer</code>,
499
+ <code>allow-empty</code>, <code>include-merges</code>,
500
+ <code>output-file</code>.
497
501
  </p>
498
502
 
499
503
  <p>
500
504
  When <code>output-file</code> is set the action exposes the path as
501
505
  a step output, making JSONL results available to subsequent steps:
502
506
  </p>
503
- <pre><code class="language-yaml"> - uses: benner/commit-guard@v0.16.0
507
+ <pre><code class="language-yaml"> - uses: benner/commit-guard@v0.18.0
504
508
  id: cg
505
509
  with:
506
510
  range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}
@@ -513,7 +517,7 @@ require-trailers = ["Closes", "Reviewed-by"]</code></pre>
513
517
  <p>Add to <code>.pre-commit-config.yaml</code>:</p>
514
518
  <pre><code class="language-yaml">repos:
515
519
  - repo: https://github.com/benner/commit-guard
516
- rev: v0.16.0
520
+ rev: v0.18.0
517
521
  hooks:
518
522
  - id: commit-guard
519
523
  - id: commit-guard-signature</code></pre>
@@ -146,15 +146,17 @@ def _strip_comments(message):
146
146
  )
147
147
 
148
148
 
149
- def check_subject( # noqa: PLR0913 Too many arguments in function definition (7 > 5)
149
+ def check_subject( # noqa: PLR0913 Too many arguments in function definition (9 > 5)
150
150
  line,
151
151
  result,
152
152
  allowed_scopes=frozenset(),
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
157
  *,
157
158
  require_scope=False,
159
+ require_lowercase=True,
158
160
  ):
159
161
  m = SUBJECT_RE.match(line)
160
162
  if not m:
@@ -174,10 +176,10 @@ def check_subject( # noqa: PLR0913 Too many arguments in function definition (7
174
176
  result.error(f"unknown scope: {scope}", check=Check.SUBJECT)
175
177
 
176
178
  desc = m.group("desc")
177
- if desc[0].isupper():
179
+ if require_lowercase and desc[0].isupper():
178
180
  result.error("description must not start with uppercase", check=Check.SUBJECT)
179
- if desc.endswith("."):
180
- result.error("description must not end with period", check=Check.SUBJECT)
181
+ if no_trailing_chars and desc[-1] in no_trailing_chars:
182
+ result.error(f"description must not end with {desc[-1]!r}", check=Check.SUBJECT)
181
183
  if len(line) > max_subject_length:
182
184
  result.error(
183
185
  f"subject too long: {len(line)} > {max_subject_length}", check=Check.SUBJECT
@@ -305,6 +307,8 @@ class Args:
305
307
  allowed_types: frozenset
306
308
  max_subject_length: int
307
309
  min_description_length: int
310
+ require_lowercase: bool
311
+ no_trailing_chars: frozenset
308
312
  rev_range: str | None
309
313
  allow_empty: bool
310
314
  include_merges: bool
@@ -345,6 +349,22 @@ def _resolve_min_description_length(args, config):
345
349
  return 0
346
350
 
347
351
 
352
+ def _resolve_require_lowercase(args, config):
353
+ if args.require_lowercase is not None:
354
+ return args.require_lowercase
355
+ if "require-lowercase" in config:
356
+ return config["require-lowercase"]
357
+ return True
358
+
359
+
360
+ def _resolve_no_trailing_chars(args, config):
361
+ if args.no_trailing_chars is not None:
362
+ return frozenset(c for c in args.no_trailing_chars.split(",") if c)
363
+ if "no-trailing-chars" in config:
364
+ return frozenset(config["no-trailing-chars"])
365
+ return frozenset({".", "!", "?", " "})
366
+
367
+
348
368
  def _resolve_required_trailers(args, config):
349
369
  if args.require_trailer:
350
370
  return [t.strip() for t in args.require_trailer.split(",")]
@@ -431,6 +451,19 @@ def _parse_args():
431
451
  metavar="N",
432
452
  help="minimum description length in characters (default: 0, off)",
433
453
  )
454
+ parser.add_argument(
455
+ "--no-require-lowercase",
456
+ dest="require_lowercase",
457
+ action="store_false",
458
+ default=None,
459
+ help="allow description to start with uppercase (default: disallowed)",
460
+ )
461
+ parser.add_argument(
462
+ "--no-trailing-chars",
463
+ default=None,
464
+ metavar="CHAR[,CHAR,...]",
465
+ help="forbidden trailing characters in description (default: '.')",
466
+ )
434
467
  parser.add_argument(
435
468
  "--range",
436
469
  dest="rev_range",
@@ -473,6 +506,8 @@ def _parse_args():
473
506
  allowed_types = _resolve_types(args, config)
474
507
  max_subject_length = _resolve_max_subject_length(args, config)
475
508
  min_description_length = _resolve_min_description_length(args, config)
509
+ require_lowercase = _resolve_require_lowercase(args, config)
510
+ no_trailing_chars = _resolve_no_trailing_chars(args, config)
476
511
  required_trailers = _resolve_required_trailers(args, config)
477
512
 
478
513
  if args.allow_empty and not args.rev_range:
@@ -507,6 +542,8 @@ def _parse_args():
507
542
  allowed_types=allowed_types,
508
543
  max_subject_length=max_subject_length,
509
544
  min_description_length=min_description_length,
545
+ require_lowercase=require_lowercase,
546
+ no_trailing_chars=no_trailing_chars,
510
547
  rev_range=args.rev_range,
511
548
  allow_empty=args.allow_empty,
512
549
  include_merges=args.include_merges,
@@ -560,7 +597,9 @@ def _run_checks(args, rev, message, result):
560
597
  args.allowed_types,
561
598
  args.max_subject_length,
562
599
  args.min_description_length,
600
+ args.no_trailing_chars,
563
601
  require_scope=args.require_scope,
602
+ require_lowercase=args.require_lowercase,
564
603
  )
565
604
  if Check.IMPERATIVE in args.enabled:
566
605
  if desc is None:
@@ -22,6 +22,8 @@ from git_commit_guard import (
22
22
  _report_text,
23
23
  _resolve_max_subject_length,
24
24
  _resolve_min_description_length,
25
+ _resolve_no_trailing_chars,
26
+ _resolve_require_lowercase,
25
27
  _resolve_required_trailers,
26
28
  _resolve_types,
27
29
  _strip_comments,
@@ -113,11 +115,41 @@ class TestCheckSubject:
113
115
  check_subject("fix: Add token", r)
114
116
  assert not r.ok
115
117
 
118
+ def test_uppercase_description_allowed(self):
119
+ r = Result()
120
+ check_subject("fix: Add token", r, require_lowercase=False)
121
+ assert r.ok
122
+
123
+ def test_lowercase_required_by_default(self):
124
+ r = Result()
125
+ check_subject("fix: add token", r)
126
+ assert r.ok
127
+
116
128
  def test_trailing_period(self):
117
129
  r = Result()
118
130
  check_subject("fix: add token.", r)
119
131
  assert not r.ok
120
132
 
133
+ def test_trailing_char_custom(self):
134
+ r = Result()
135
+ check_subject("fix: add token!", r, no_trailing_chars=frozenset("!"))
136
+ assert not r.ok
137
+
138
+ def test_trailing_char_space(self):
139
+ r = Result()
140
+ check_subject("fix: add token ", r, no_trailing_chars=frozenset(". "))
141
+ assert not r.ok
142
+
143
+ def test_trailing_chars_empty_disables_check(self):
144
+ r = Result()
145
+ check_subject("fix: add token.", r, no_trailing_chars=frozenset())
146
+ assert r.ok
147
+
148
+ def test_trailing_chars_multiple(self):
149
+ r = Result()
150
+ check_subject("fix: add token!", r, no_trailing_chars=frozenset(".!"))
151
+ assert not r.ok
152
+
121
153
  def test_subject_too_long(self):
122
154
  r = Result()
123
155
  check_subject("fix: " + "a" * 68, r) # 73 chars total
@@ -424,6 +456,19 @@ class TestCheckImperative:
424
456
  check_imperative("", r)
425
457
  assert r.ok
426
458
 
459
+ def test_pos_fallback_unknown_word_fails(self):
460
+ r = Result()
461
+ with (
462
+ patch("git_commit_guard.wordnet.morphy", return_value=None),
463
+ patch(
464
+ "git_commit_guard.nltk.pos_tag",
465
+ return_value=[("to", "TO"), ("xyzzy", "NN")],
466
+ ),
467
+ ):
468
+ check_imperative("xyzzy something", r)
469
+ assert not r.ok
470
+ assert "POS=NN" in r.errors[0][2]
471
+
427
472
 
428
473
  class TestDownloadIfMissing:
429
474
  def test_skips_download_when_present(self):
@@ -587,6 +632,38 @@ class TestResolveMinDescriptionLength:
587
632
  assert result == 10
588
633
 
589
634
 
635
+ class TestResolveRequireLowercase:
636
+ def test_cli_flag_overrides_default(self):
637
+ assert (
638
+ _resolve_require_lowercase(Namespace(require_lowercase=False), {}) is False
639
+ )
640
+
641
+ def test_config_overrides_default(self):
642
+ result = _resolve_require_lowercase(
643
+ Namespace(require_lowercase=None), {"require-lowercase": False}
644
+ )
645
+ assert result is False
646
+
647
+ def test_default_is_true(self):
648
+ assert _resolve_require_lowercase(Namespace(require_lowercase=None), {}) is True
649
+
650
+
651
+ class TestResolveNoTrailingChars:
652
+ def test_cli_flag_overrides_default(self):
653
+ result = _resolve_no_trailing_chars(Namespace(no_trailing_chars=".,!"), {})
654
+ assert result == frozenset({".", "!"})
655
+
656
+ def test_config_overrides_default(self):
657
+ result = _resolve_no_trailing_chars(
658
+ Namespace(no_trailing_chars=None), {"no-trailing-chars": [".", "!"]}
659
+ )
660
+ assert result == frozenset({".", "!"})
661
+
662
+ def test_default_includes_common_punctuation_and_space(self):
663
+ result = _resolve_no_trailing_chars(Namespace(no_trailing_chars=None), {})
664
+ assert result == frozenset({".", "!", "?", " "})
665
+
666
+
590
667
  class TestGitTimeout:
591
668
  def test_default(self, monkeypatch):
592
669
  monkeypatch.delenv("COMMIT_GUARD_GIT_TIMEOUT", raising=False)
@@ -1428,6 +1505,27 @@ class TestOutputJsonl:
1428
1505
  assert data["sha"] == rev
1429
1506
  assert data["ok"] is True
1430
1507
 
1508
+ def test_range_failing_commit_returns_nonzero(self, capsys):
1509
+ with (
1510
+ patch(
1511
+ "sys.argv",
1512
+ [
1513
+ "cg",
1514
+ "--range",
1515
+ "HEAD~1..HEAD",
1516
+ "--disable",
1517
+ "signature,imperative",
1518
+ "--output",
1519
+ "jsonl",
1520
+ ],
1521
+ ),
1522
+ patch("git_commit_guard._get_range_revs", return_value=["aaa"]),
1523
+ patch("git_commit_guard._get_message", return_value="bad message"),
1524
+ ):
1525
+ assert main() == 1
1526
+ data = json.loads(capsys.readouterr().out)
1527
+ assert data["ok"] is False
1528
+
1431
1529
 
1432
1530
  class TestOutputFile:
1433
1531
  def test_single_commit_writes_jsonl_to_file(self, tmp_path, capsys):