git-commit-guard 0.15.1__tar.gz → 0.17.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 (24) hide show
  1. git_commit_guard-0.17.0/.github/workflows/coverage-baseline.yml +33 -0
  2. git_commit_guard-0.17.0/.github/workflows/coverage-comment.yml +97 -0
  3. {git_commit_guard-0.15.1 → git_commit_guard-0.17.0}/.github/workflows/lint-commits.yml +1 -1
  4. {git_commit_guard-0.15.1 → git_commit_guard-0.17.0}/.github/workflows/release.yml +1 -1
  5. {git_commit_guard-0.15.1 → git_commit_guard-0.17.0}/.github/workflows/test.yml +13 -1
  6. {git_commit_guard-0.15.1 → git_commit_guard-0.17.0}/PKG-INFO +65 -10
  7. {git_commit_guard-0.15.1 → git_commit_guard-0.17.0}/README.md +64 -9
  8. {git_commit_guard-0.15.1 → git_commit_guard-0.17.0}/action.yml +11 -0
  9. {git_commit_guard-0.15.1 → git_commit_guard-0.17.0}/docs/index.html +104 -13
  10. {git_commit_guard-0.15.1 → git_commit_guard-0.17.0}/src/git_commit_guard/__init__.py +48 -5
  11. {git_commit_guard-0.15.1 → git_commit_guard-0.17.0}/tests/test_git_commit_guard.py +36 -0
  12. {git_commit_guard-0.15.1 → git_commit_guard-0.17.0}/.editorconfig +0 -0
  13. {git_commit_guard-0.15.1 → git_commit_guard-0.17.0}/.github/workflows/lint-md.yml +0 -0
  14. {git_commit_guard-0.15.1 → git_commit_guard-0.17.0}/.github/workflows/lint-python.yml +0 -0
  15. {git_commit_guard-0.15.1 → git_commit_guard-0.17.0}/.gitignore +0 -0
  16. {git_commit_guard-0.15.1 → git_commit_guard-0.17.0}/.markdownlint.json +0 -0
  17. {git_commit_guard-0.15.1 → git_commit_guard-0.17.0}/.pre-commit-hooks.yaml +0 -0
  18. {git_commit_guard-0.15.1 → git_commit_guard-0.17.0}/.python-version +0 -0
  19. {git_commit_guard-0.15.1 → git_commit_guard-0.17.0}/LICENSE +0 -0
  20. {git_commit_guard-0.15.1 → git_commit_guard-0.17.0}/docs/commit-guard-icon.svg +0 -0
  21. {git_commit_guard-0.15.1 → git_commit_guard-0.17.0}/pyproject.toml +0 -0
  22. {git_commit_guard-0.15.1 → git_commit_guard-0.17.0}/ruff.toml +0 -0
  23. {git_commit_guard-0.15.1 → git_commit_guard-0.17.0}/tests/__init__.py +0 -0
  24. {git_commit_guard-0.15.1 → git_commit_guard-0.17.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@c3ca496e477d64051d06fdb3aac44d8ffec7e852 # v0.14.1
25
+ uses: benner/commit-guard@064132b8b0cf5ec26083de7193f4e78d37a615d4 # v0.15.1
26
26
  with:
27
27
  range: origin/${{ github.base_ref }}..HEAD
28
28
  disable: signature
@@ -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
@@ -24,4 +24,16 @@ jobs:
24
24
  - name: Run tests with coverage
25
25
  run: |-
26
26
  uv run --dev pytest \
27
- tests/ --cov=git_commit_guard --cov-report=term-missing
27
+ tests/ --cov=git_commit_guard --cov-report=term-missing \
28
+ --cov-report=xml
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
35
+ # yamllint disable-line rule:line-length
36
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # ratchet:actions/upload-artifact@v4
37
+ with:
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.15.1
3
+ Version: 0.17.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
@@ -96,7 +96,7 @@ Available checks:
96
96
  * `subject` - Format matches `type(scope): description`, valid type,
97
97
  lowercase start, no trailing period, max 72 chars
98
98
  * `imperative` - First word is an imperative verb (for example `add` not `added`)
99
- * `body` - Body is present after a blank line
99
+ * `body` - Blank line separates subject from body, and body is non-empty
100
100
  * `signed-off` - `Signed-off-by:` trailer exists
101
101
  * `signature` - Verify GPG or SSH signature
102
102
 
@@ -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 trailing `.` is forbidden. To change the set of forbidden trailing
135
+ characters (any character is valid, including space):
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.15.0
249
+ - uses: benner/commit-guard@v0.17.0
211
250
  env:
212
251
  COMMIT_GUARD_GIT_TIMEOUT: 30
213
252
  with:
@@ -280,6 +319,10 @@ commit-guard --range origin/main..HEAD --output-file results.jsonl
280
319
  `--output-file` is independent of `--output`: combining both writes JSONL to
281
320
  both stdout and the file.
282
321
 
322
+ In GitHub Actions, `output-file` is the recommended way to get machine-readable
323
+ results — text stays in the CI log and the file is accessible to subsequent steps
324
+ via `steps.<id>.outputs.output-file`.
325
+
283
326
  ### GitHub Actions
284
327
 
285
328
  ```yaml
@@ -287,7 +330,7 @@ steps:
287
330
  - uses: actions/checkout@v4
288
331
  with:
289
332
  fetch-depth: 0
290
- - uses: benner/commit-guard@v0.15.0
333
+ - uses: benner/commit-guard@v0.17.0
291
334
  ```
292
335
 
293
336
  Check all commits in a pull request:
@@ -303,11 +346,19 @@ jobs:
303
346
  - uses: actions/checkout@v4
304
347
  with:
305
348
  fetch-depth: 0
306
- - uses: benner/commit-guard@v0.15.0
349
+ - uses: benner/commit-guard@v0.17.0
307
350
  with:
308
351
  range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}
309
352
  ```
310
353
 
354
+ Check a specific commit SHA (mirrors the positional CLI argument):
355
+
356
+ ```yaml
357
+ - uses: benner/commit-guard@v0.17.0
358
+ with:
359
+ rev: ${{ github.sha }}
360
+ ```
361
+
311
362
  All inputs are optional and mirror the CLI flags:
312
363
 
313
364
  ```yaml
@@ -321,7 +372,7 @@ jobs:
321
372
  - uses: actions/checkout@v4
322
373
  with:
323
374
  fetch-depth: 0
324
- - uses: benner/commit-guard@v0.15.0
375
+ - uses: benner/commit-guard@v0.17.0
325
376
  with:
326
377
  range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}
327
378
  disable: signed-off,signature
@@ -330,13 +381,17 @@ jobs:
330
381
  require-trailer: 'Closes,Reviewed-by'
331
382
  max-subject-length: '100'
332
383
  min-description-length: '10'
384
+ no-require-lowercase: 'true'
385
+ no-trailing-chars: '.,!'
386
+ allow-empty: 'true'
387
+ include-merges: 'true'
333
388
  output-file: results.jsonl
334
389
  ```
335
390
 
336
391
  When `output-file` is set the action exposes the path as an output:
337
392
 
338
393
  ```yaml
339
- - uses: benner/commit-guard@v0.15.0
394
+ - uses: benner/commit-guard@v0.17.0
340
395
  id: cg
341
396
  with:
342
397
  range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}
@@ -352,7 +407,7 @@ Add to your `.pre-commit-config.yaml`:
352
407
  ---
353
408
  repos:
354
409
  - repo: https://github.com/benner/commit-guard
355
- rev: v0.15.0
410
+ rev: v0.17.0
356
411
  hooks:
357
412
  - id: commit-guard
358
413
  - id: commit-guard-signature
@@ -75,7 +75,7 @@ Available checks:
75
75
  * `subject` - Format matches `type(scope): description`, valid type,
76
76
  lowercase start, no trailing period, max 72 chars
77
77
  * `imperative` - First word is an imperative verb (for example `add` not `added`)
78
- * `body` - Body is present after a blank line
78
+ * `body` - Blank line separates subject from body, and body is non-empty
79
79
  * `signed-off` - `Signed-off-by:` trailer exists
80
80
  * `signature` - Verify GPG or SSH signature
81
81
 
@@ -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 trailing `.` is forbidden. To change the set of forbidden trailing
114
+ characters (any character is valid, including space):
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.15.0
228
+ - uses: benner/commit-guard@v0.17.0
190
229
  env:
191
230
  COMMIT_GUARD_GIT_TIMEOUT: 30
192
231
  with:
@@ -259,6 +298,10 @@ commit-guard --range origin/main..HEAD --output-file results.jsonl
259
298
  `--output-file` is independent of `--output`: combining both writes JSONL to
260
299
  both stdout and the file.
261
300
 
301
+ In GitHub Actions, `output-file` is the recommended way to get machine-readable
302
+ results — text stays in the CI log and the file is accessible to subsequent steps
303
+ via `steps.<id>.outputs.output-file`.
304
+
262
305
  ### GitHub Actions
263
306
 
264
307
  ```yaml
@@ -266,7 +309,7 @@ steps:
266
309
  - uses: actions/checkout@v4
267
310
  with:
268
311
  fetch-depth: 0
269
- - uses: benner/commit-guard@v0.15.0
312
+ - uses: benner/commit-guard@v0.17.0
270
313
  ```
271
314
 
272
315
  Check all commits in a pull request:
@@ -282,11 +325,19 @@ jobs:
282
325
  - uses: actions/checkout@v4
283
326
  with:
284
327
  fetch-depth: 0
285
- - uses: benner/commit-guard@v0.15.0
328
+ - uses: benner/commit-guard@v0.17.0
286
329
  with:
287
330
  range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}
288
331
  ```
289
332
 
333
+ Check a specific commit SHA (mirrors the positional CLI argument):
334
+
335
+ ```yaml
336
+ - uses: benner/commit-guard@v0.17.0
337
+ with:
338
+ rev: ${{ github.sha }}
339
+ ```
340
+
290
341
  All inputs are optional and mirror the CLI flags:
291
342
 
292
343
  ```yaml
@@ -300,7 +351,7 @@ jobs:
300
351
  - uses: actions/checkout@v4
301
352
  with:
302
353
  fetch-depth: 0
303
- - uses: benner/commit-guard@v0.15.0
354
+ - uses: benner/commit-guard@v0.17.0
304
355
  with:
305
356
  range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}
306
357
  disable: signed-off,signature
@@ -309,13 +360,17 @@ jobs:
309
360
  require-trailer: 'Closes,Reviewed-by'
310
361
  max-subject-length: '100'
311
362
  min-description-length: '10'
363
+ no-require-lowercase: 'true'
364
+ no-trailing-chars: '.,!'
365
+ allow-empty: 'true'
366
+ include-merges: 'true'
312
367
  output-file: results.jsonl
313
368
  ```
314
369
 
315
370
  When `output-file` is set the action exposes the path as an output:
316
371
 
317
372
  ```yaml
318
- - uses: benner/commit-guard@v0.15.0
373
+ - uses: benner/commit-guard@v0.17.0
319
374
  id: cg
320
375
  with:
321
376
  range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}
@@ -331,7 +386,7 @@ Add to your `.pre-commit-config.yaml`:
331
386
  ---
332
387
  repos:
333
388
  - repo: https://github.com/benner/commit-guard
334
- rev: v0.15.0
389
+ rev: v0.17.0
335
390
  hooks:
336
391
  - id: commit-guard
337
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")
@@ -332,7 +332,10 @@ $ commit-guard abc1234
332
332
  $ commit-guard --range origin/main..HEAD
333
333
 
334
334
  # read from a file (for git hooks)
335
- $ commit-guard --message-file .git/COMMIT_EDITMSG</code></pre>
335
+ $ commit-guard --message-file .git/COMMIT_EDITMSG
336
+
337
+ # pipe message via stdin
338
+ $ echo "fix(auth): add token refresh" | commit-guard</code></pre>
336
339
 
337
340
  <h3>Checks</h3>
338
341
  <p>
@@ -351,8 +354,11 @@ $ commit-guard --message-file .git/COMMIT_EDITMSG</code></pre>
351
354
  <tr>
352
355
  <td><code>subject</code></td>
353
356
  <td>
354
- Format matches <code>type(scope): description</code>, valid
355
- type, lowercase start, no trailing period, max 72 chars
357
+ <a href="https://www.conventionalcommits.org/" target="_blank" rel="noopener">Conventional Commits</a>
358
+ format: valid type, lowercase start, no trailing period,
359
+ max length (default 72). All limits are configurable. Use
360
+ <code>!</code> before the colon for breaking changes:
361
+ <code>feat!: remove endpoint</code>
356
362
  </td>
357
363
  </tr>
358
364
  <tr>
@@ -363,7 +369,7 @@ $ commit-guard --message-file .git/COMMIT_EDITMSG</code></pre>
363
369
  </tr>
364
370
  <tr>
365
371
  <td><code>body</code></td>
366
- <td>Body is present after a blank line</td>
372
+ <td>Blank line separates subject from body, and body is non-empty</td>
367
373
  </tr>
368
374
  <tr>
369
375
  <td><code>signed-off</code></td>
@@ -381,8 +387,9 @@ $ commit-guard --message-file .git/COMMIT_EDITMSG</code></pre>
381
387
  <section id="configuration">
382
388
  <h2>Configuration <a href="#configuration" class="anchor">#</a></h2>
383
389
  <p>
384
- Place <code>.commit-guard.toml</code> in your project root. CLI flags
385
- always take precedence over the config file.
390
+ Place <code>.commit-guard.toml</code> in your project root or any
391
+ parent directory commit-guard searches upward and uses the first
392
+ file found. CLI flags always take precedence.
386
393
  </p>
387
394
  <pre><code class="language-toml"># .commit-guard.toml
388
395
  disable = ["signature", "body"]
@@ -391,7 +398,72 @@ require-scope = true
391
398
  types = ["feat", "fix", "chore", "wip"]
392
399
  max-subject-length = 100
393
400
  min-description-length = 10
401
+ require-lowercase = false
402
+ no-trailing-chars = [".", "!"]
394
403
  require-trailers = ["Closes", "Reviewed-by"]</code></pre>
404
+
405
+ <h3>Required trailers</h3>
406
+ <p>
407
+ Require arbitrary trailers in the commit message. Accepts a
408
+ comma-separated list; matching is case-sensitive and requires a
409
+ non-empty value after the colon (e.g. <code>Closes: #42</code>):
410
+ </p>
411
+ <pre><code class="language-bash">commit-guard --require-trailer "Closes,Reviewed-by"</code></pre>
412
+
413
+ <h3>Range options</h3>
414
+ <p>
415
+ When using <code>--range</code>, merge commits are excluded by
416
+ default. Use <code>--include-merges</code> to check them. An empty
417
+ range exits non-zero by default — use <code>--allow-empty</code> to
418
+ exit 0 instead:
419
+ </p>
420
+ <pre><code class="language-bash">commit-guard --range origin/main..HEAD --include-merges --allow-empty</code></pre>
421
+
422
+ <h3>Environment variables</h3>
423
+ <figure>
424
+ <table>
425
+ <thead>
426
+ <tr>
427
+ <th>Variable</th>
428
+ <th>Default</th>
429
+ <th>Description</th>
430
+ </tr>
431
+ </thead>
432
+ <tbody>
433
+ <tr>
434
+ <td><code>COMMIT_GUARD_GIT_TIMEOUT</code></td>
435
+ <td><code>10</code></td>
436
+ <td>Timeout in seconds for git subprocess calls</td>
437
+ </tr>
438
+ </tbody>
439
+ </table>
440
+ </figure>
441
+ </section>
442
+
443
+ <section id="output">
444
+ <h2>Output <a href="#output" class="anchor">#</a></h2>
445
+ <p>
446
+ Use <code>--output jsonl</code> to emit one JSON line per commit to
447
+ stdout instead of the default human-readable text:
448
+ </p>
449
+ <pre><code class="language-bash">commit-guard --range origin/main..HEAD --output jsonl | jq 'select(.ok == false)'</code></pre>
450
+ <p>Each line is a JSON object:</p>
451
+ <pre><code>{
452
+ "sha": "abc1234...",
453
+ "subject": "feat: add thing",
454
+ "ok": false,
455
+ "results": [{"check": "body", "level": "error", "message": "missing body"}]
456
+ }</code></pre>
457
+ <p>
458
+ <code>sha</code> is <code>null</code> when reading from a file or
459
+ stdin. <code>results</code> is empty when all checks pass.
460
+ </p>
461
+ <p>
462
+ Use <code>--output-file FILE</code> to write JSONL to a file while
463
+ keeping human-readable text on stdout — useful in CI where you want
464
+ readable logs and structured results for downstream steps:
465
+ </p>
466
+ <pre><code class="language-bash">commit-guard --range origin/main..HEAD --output-file results.jsonl</code></pre>
395
467
  </section>
396
468
 
397
469
  <section id="github-actions">
@@ -407,18 +479,37 @@ require-trailers = ["Closes", "Reviewed-by"]</code></pre>
407
479
  - uses: actions/checkout@v4
408
480
  with:
409
481
  fetch-depth: 0
410
- - uses: benner/commit-guard@v0.14.1
482
+ - uses: benner/commit-guard@v0.17.0
411
483
  with:
412
484
  range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}
413
485
  disable: signed-off,signature</code></pre>
414
486
 
487
+ <p>Check a specific commit SHA:</p>
488
+ <pre><code class="language-yaml"> - uses: benner/commit-guard@v0.17.0
489
+ with:
490
+ rev: ${{ github.sha }}</code></pre>
491
+
415
492
  <p>
416
- All inputs mirror the CLI flags: <code>enable</code>,
417
- <code>disable</code>, <code>scopes</code>, <code>require-scope</code>,
418
- <code>types</code>, <code>max-subject-length</code>,
419
- <code>min-description-length</code>, <code>require-trailer</code>,
493
+ All inputs mirror the CLI flags: <code>rev</code>,
494
+ <code>range</code>, <code>enable</code>, <code>disable</code>,
495
+ <code>scopes</code>, <code>require-scope</code>, <code>types</code>,
496
+ <code>max-subject-length</code>, <code>min-description-length</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>,
420
500
  <code>output-file</code>.
421
501
  </p>
502
+
503
+ <p>
504
+ When <code>output-file</code> is set the action exposes the path as
505
+ a step output, making JSONL results available to subsequent steps:
506
+ </p>
507
+ <pre><code class="language-yaml"> - uses: benner/commit-guard@v0.17.0
508
+ id: cg
509
+ with:
510
+ range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}
511
+ output-file: results.jsonl
512
+ - run: jq 'select(.ok == false)' "${{ steps.cg.outputs.output-file }}"</code></pre>
422
513
  </section>
423
514
 
424
515
  <section id="pre-commit">
@@ -426,7 +517,7 @@ require-trailers = ["Closes", "Reviewed-by"]</code></pre>
426
517
  <p>Add to <code>.pre-commit-config.yaml</code>:</p>
427
518
  <pre><code class="language-yaml">repos:
428
519
  - repo: https://github.com/benner/commit-guard
429
- rev: v0.14.1
520
+ rev: v0.17.0
430
521
  hooks:
431
522
  - id: commit-guard
432
523
  - id: commit-guard-signature</code></pre>
@@ -596,7 +687,7 @@ require-trailers = ["Closes", "Reviewed-by"]</code></pre>
596
687
  });
597
688
 
598
689
  document.querySelectorAll(
599
- "#configuration pre code, #github-actions pre code, #pre-commit pre code"
690
+ "#configuration pre code, #output pre code, #github-actions pre code, #pre-commit pre code"
600
691
  ).forEach((code) => {
601
692
  const text = code.innerText.trim();
602
693
  const isConfig = code.className.includes("language-");
@@ -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
@@ -220,11 +222,15 @@ def check_imperative(desc, result):
220
222
 
221
223
 
222
224
  def check_body(lines, result):
223
- if len(lines) < 3: # noqa: PLR2004
225
+ if len(lines) == 1:
224
226
  result.error("missing body", check=Check.BODY)
225
227
  return
226
228
  if lines[1].strip():
227
229
  result.error("missing blank line between subject and body", check=Check.BODY)
230
+ return
231
+ if len(lines) == 2: # noqa: PLR2004 Magic value used in comparison, consider replacing 2 with a constant variable
232
+ result.error("missing body", check=Check.BODY)
233
+ return
228
234
  body_lines = [ln for ln in lines[2:] if not _TRAILER_RE.match(ln)]
229
235
  if not any(ln.strip() for ln in body_lines):
230
236
  result.error("missing body", check=Check.BODY)
@@ -301,6 +307,8 @@ class Args:
301
307
  allowed_types: frozenset
302
308
  max_subject_length: int
303
309
  min_description_length: int
310
+ require_lowercase: bool
311
+ no_trailing_chars: frozenset
304
312
  rev_range: str | None
305
313
  allow_empty: bool
306
314
  include_merges: bool
@@ -341,6 +349,22 @@ def _resolve_min_description_length(args, config):
341
349
  return 0
342
350
 
343
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
+
344
368
  def _resolve_required_trailers(args, config):
345
369
  if args.require_trailer:
346
370
  return [t.strip() for t in args.require_trailer.split(",")]
@@ -427,6 +451,19 @@ def _parse_args():
427
451
  metavar="N",
428
452
  help="minimum description length in characters (default: 0, off)",
429
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
+ )
430
467
  parser.add_argument(
431
468
  "--range",
432
469
  dest="rev_range",
@@ -469,6 +506,8 @@ def _parse_args():
469
506
  allowed_types = _resolve_types(args, config)
470
507
  max_subject_length = _resolve_max_subject_length(args, config)
471
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)
472
511
  required_trailers = _resolve_required_trailers(args, config)
473
512
 
474
513
  if args.allow_empty and not args.rev_range:
@@ -503,6 +542,8 @@ def _parse_args():
503
542
  allowed_types=allowed_types,
504
543
  max_subject_length=max_subject_length,
505
544
  min_description_length=min_description_length,
545
+ require_lowercase=require_lowercase,
546
+ no_trailing_chars=no_trailing_chars,
506
547
  rev_range=args.rev_range,
507
548
  allow_empty=args.allow_empty,
508
549
  include_merges=args.include_merges,
@@ -556,7 +597,9 @@ def _run_checks(args, rev, message, result):
556
597
  args.allowed_types,
557
598
  args.max_subject_length,
558
599
  args.min_description_length,
600
+ args.no_trailing_chars,
559
601
  require_scope=args.require_scope,
602
+ require_lowercase=args.require_lowercase,
560
603
  )
561
604
  if Check.IMPERATIVE in args.enabled:
562
605
  if desc is None:
@@ -113,11 +113,41 @@ class TestCheckSubject:
113
113
  check_subject("fix: Add token", r)
114
114
  assert not r.ok
115
115
 
116
+ def test_uppercase_description_allowed(self):
117
+ r = Result()
118
+ check_subject("fix: Add token", r, require_lowercase=False)
119
+ assert r.ok
120
+
121
+ def test_lowercase_required_by_default(self):
122
+ r = Result()
123
+ check_subject("fix: add token", r)
124
+ assert r.ok
125
+
116
126
  def test_trailing_period(self):
117
127
  r = Result()
118
128
  check_subject("fix: add token.", r)
119
129
  assert not r.ok
120
130
 
131
+ def test_trailing_char_custom(self):
132
+ r = Result()
133
+ check_subject("fix: add token!", r, no_trailing_chars=frozenset("!"))
134
+ assert not r.ok
135
+
136
+ def test_trailing_char_space(self):
137
+ r = Result()
138
+ check_subject("fix: add token ", r, no_trailing_chars=frozenset(". "))
139
+ assert not r.ok
140
+
141
+ def test_trailing_chars_empty_disables_check(self):
142
+ r = Result()
143
+ check_subject("fix: add token.", r, no_trailing_chars=frozenset())
144
+ assert r.ok
145
+
146
+ def test_trailing_chars_multiple(self):
147
+ r = Result()
148
+ check_subject("fix: add token!", r, no_trailing_chars=frozenset(".!"))
149
+ assert not r.ok
150
+
121
151
  def test_subject_too_long(self):
122
152
  r = Result()
123
153
  check_subject("fix: " + "a" * 68, r) # 73 chars total
@@ -237,6 +267,12 @@ class TestCheckBody:
237
267
  check_body(["fix: add thing", "body text", "more"], r)
238
268
  assert not r.ok
239
269
 
270
+ def test_missing_blank_line_two_lines(self):
271
+ r = Result()
272
+ check_body(["fix: add thing", "body text"], r)
273
+ assert not r.ok
274
+ assert any("blank line" in msg for _, _, msg in r.errors)
275
+
240
276
  def test_blank_body_content(self):
241
277
  r = Result()
242
278
  check_body(["fix: add thing", "", " "], r)