git-commit-guard 0.12.0__tar.gz → 0.14.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.
- {git_commit_guard-0.12.0 → git_commit_guard-0.14.0}/.github/workflows/lint-commits.yml +6 -22
- {git_commit_guard-0.12.0 → git_commit_guard-0.14.0}/PKG-INFO +59 -9
- {git_commit_guard-0.12.0 → git_commit_guard-0.14.0}/README.md +58 -8
- {git_commit_guard-0.12.0 → git_commit_guard-0.14.0}/src/git_commit_guard/__init__.py +106 -33
- {git_commit_guard-0.12.0 → git_commit_guard-0.14.0}/tests/test_git_commit_guard.py +293 -7
- {git_commit_guard-0.12.0 → git_commit_guard-0.14.0}/.github/workflows/lint-md.yml +0 -0
- {git_commit_guard-0.12.0 → git_commit_guard-0.14.0}/.github/workflows/lint-python.yml +0 -0
- {git_commit_guard-0.12.0 → git_commit_guard-0.14.0}/.github/workflows/release.yml +0 -0
- {git_commit_guard-0.12.0 → git_commit_guard-0.14.0}/.github/workflows/test.yml +0 -0
- {git_commit_guard-0.12.0 → git_commit_guard-0.14.0}/.gitignore +0 -0
- {git_commit_guard-0.12.0 → git_commit_guard-0.14.0}/.pre-commit-hooks.yaml +0 -0
- {git_commit_guard-0.12.0 → git_commit_guard-0.14.0}/.python-version +0 -0
- {git_commit_guard-0.12.0 → git_commit_guard-0.14.0}/LICENSE +0 -0
- {git_commit_guard-0.12.0 → git_commit_guard-0.14.0}/action.yml +0 -0
- {git_commit_guard-0.12.0 → git_commit_guard-0.14.0}/pyproject.toml +0 -0
- {git_commit_guard-0.12.0 → git_commit_guard-0.14.0}/ruff.toml +0 -0
- {git_commit_guard-0.12.0 → git_commit_guard-0.14.0}/tests/__init__.py +0 -0
- {git_commit_guard-0.12.0 → git_commit_guard-0.14.0}/uv.lock +0 -0
|
@@ -7,8 +7,6 @@ permissions:
|
|
|
7
7
|
jobs:
|
|
8
8
|
lint-commits:
|
|
9
9
|
runs-on: ubuntu-latest
|
|
10
|
-
permissions:
|
|
11
|
-
contents: write
|
|
12
10
|
steps:
|
|
13
11
|
- name: Checkout code
|
|
14
12
|
# yamllint disable-line rule:line-length
|
|
@@ -16,29 +14,15 @@ jobs:
|
|
|
16
14
|
with:
|
|
17
15
|
persist-credentials: false
|
|
18
16
|
fetch-depth: 0
|
|
19
|
-
- name: Install Python
|
|
20
|
-
# yamllint disable-line rule:line-length
|
|
21
|
-
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
|
22
|
-
with:
|
|
23
|
-
python-version: '3.12'
|
|
24
|
-
- name: Install uv
|
|
25
|
-
# yamllint disable-line rule:line-length
|
|
26
|
-
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
|
27
|
-
- name: Install commit-guard
|
|
28
|
-
run: uv pip install --system .
|
|
29
17
|
- name: Cache NLTK data
|
|
18
|
+
# yamllint disable-line rule:line-length
|
|
30
19
|
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
|
31
20
|
with:
|
|
32
21
|
path: ~/nltk_data
|
|
33
22
|
key: nltk-averaged-perceptron-tagger-punkt
|
|
34
23
|
- name: Lint commits
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
for sha in $commits; do
|
|
41
|
-
echo "--- checking $sha ---"
|
|
42
|
-
commit-guard --disable signature "$sha" || failed=1
|
|
43
|
-
done
|
|
44
|
-
exit $failed
|
|
24
|
+
# yamllint disable-line rule:line-length
|
|
25
|
+
uses: benner/commit-guard@0f2660f0b4d0ea25b8524acfb459a35e544252cb # v0.13.0
|
|
26
|
+
with:
|
|
27
|
+
range: origin/${{ github.base_ref }}..HEAD
|
|
28
|
+
disable: signature
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: git-commit-guard
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.14.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
|
|
@@ -147,12 +147,33 @@ commit-guard --require-scope
|
|
|
147
147
|
commit-guard --scopes auth,api --require-scope
|
|
148
148
|
```
|
|
149
149
|
|
|
150
|
+
### Required custom trailers
|
|
151
|
+
|
|
152
|
+
Require arbitrary trailers to be present in the commit message. Multiple
|
|
153
|
+
trailers can be specified as a comma-separated list:
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
commit-guard --require-trailer Closes
|
|
157
|
+
commit-guard --require-trailer "Closes,Reviewed-by"
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
In `.commit-guard.toml`:
|
|
161
|
+
|
|
162
|
+
```toml
|
|
163
|
+
require-trailers = ["Closes", "Reviewed-by"]
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Trailer matching is case-sensitive and requires at least one non-space
|
|
167
|
+
character after the colon (e.g. `Closes: #42`). This check runs
|
|
168
|
+
independently of `--enable`/`--disable`.
|
|
169
|
+
|
|
150
170
|
### Configuration file
|
|
151
171
|
|
|
152
172
|
Place `.commit-guard.toml` in your project root (or any parent directory) to
|
|
153
173
|
set defaults for `enable`, `disable`, `scopes`, `require-scope`, `types`,
|
|
154
|
-
`max-subject-length`,
|
|
155
|
-
upward from the working directory and uses the first
|
|
174
|
+
`max-subject-length`, `min-description-length`, and `require-trailers`.
|
|
175
|
+
commit-guard searches upward from the working directory and uses the first
|
|
176
|
+
file found.
|
|
156
177
|
|
|
157
178
|
```toml
|
|
158
179
|
# .commit-guard.toml
|
|
@@ -162,6 +183,7 @@ require-scope = true
|
|
|
162
183
|
types = ["feat", "fix", "chore", "wip"]
|
|
163
184
|
max-subject-length = 100
|
|
164
185
|
min-description-length = 10
|
|
186
|
+
require-trailers = ["Closes", "Reviewed-by"]
|
|
165
187
|
```
|
|
166
188
|
|
|
167
189
|
```toml
|
|
@@ -170,8 +192,8 @@ enable = ["subject", "imperative"]
|
|
|
170
192
|
```
|
|
171
193
|
|
|
172
194
|
CLI flags (`--enable`, `--disable`, `--scopes`, `--require-scope`, `--types`,
|
|
173
|
-
`--max-subject-length`, `--min-description-length`) take
|
|
174
|
-
ignore config file values when provided.
|
|
195
|
+
`--max-subject-length`, `--min-description-length`, `--require-trailer`) take
|
|
196
|
+
full precedence and ignore config file values when provided.
|
|
175
197
|
|
|
176
198
|
### Checking a range of commits
|
|
177
199
|
|
|
@@ -202,6 +224,33 @@ misconfigured range specs in CI. Use `--allow-empty` to exit 0 instead:
|
|
|
202
224
|
commit-guard --range origin/main..HEAD --allow-empty
|
|
203
225
|
```
|
|
204
226
|
|
|
227
|
+
### Machine-readable output
|
|
228
|
+
|
|
229
|
+
Use `--output jsonl` to emit one JSON line per commit to stdout instead of the
|
|
230
|
+
default human-readable text on stderr:
|
|
231
|
+
|
|
232
|
+
```bash
|
|
233
|
+
commit-guard --range origin/main..HEAD --output jsonl
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
Each line is a JSON object:
|
|
237
|
+
|
|
238
|
+
```json
|
|
239
|
+
{
|
|
240
|
+
"sha": "abc1234...",
|
|
241
|
+
"subject": "feat: add thing",
|
|
242
|
+
"ok": false,
|
|
243
|
+
"results": [{"check": "body", "level": "error", "message": "missing body"}]
|
|
244
|
+
}
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
`sha` is `null` when reading from a file or stdin. `results` is empty when all
|
|
248
|
+
checks pass. Pipe to `jq` for filtering:
|
|
249
|
+
|
|
250
|
+
```bash
|
|
251
|
+
commit-guard --range origin/main..HEAD --output jsonl | jq 'select(.ok == false)'
|
|
252
|
+
```
|
|
253
|
+
|
|
205
254
|
### GitHub Actions
|
|
206
255
|
|
|
207
256
|
```yaml
|
|
@@ -209,7 +258,7 @@ steps:
|
|
|
209
258
|
- uses: actions/checkout@v4
|
|
210
259
|
with:
|
|
211
260
|
fetch-depth: 0
|
|
212
|
-
- uses: benner/commit-guard@
|
|
261
|
+
- uses: benner/commit-guard@v0.14.0
|
|
213
262
|
```
|
|
214
263
|
|
|
215
264
|
Check all commits in a pull request:
|
|
@@ -225,7 +274,7 @@ jobs:
|
|
|
225
274
|
- uses: actions/checkout@v4
|
|
226
275
|
with:
|
|
227
276
|
fetch-depth: 0
|
|
228
|
-
- uses: benner/commit-guard@
|
|
277
|
+
- uses: benner/commit-guard@v0.14.0
|
|
229
278
|
with:
|
|
230
279
|
range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}
|
|
231
280
|
```
|
|
@@ -243,12 +292,13 @@ jobs:
|
|
|
243
292
|
- uses: actions/checkout@v4
|
|
244
293
|
with:
|
|
245
294
|
fetch-depth: 0
|
|
246
|
-
- uses: benner/commit-guard@
|
|
295
|
+
- uses: benner/commit-guard@v0.14.0
|
|
247
296
|
with:
|
|
248
297
|
range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}
|
|
249
298
|
disable: signed-off,signature
|
|
250
299
|
scopes: auth,api,db
|
|
251
300
|
require-scope: 'true'
|
|
301
|
+
require-trailer: 'Closes,Reviewed-by'
|
|
252
302
|
max-subject-length: '100'
|
|
253
303
|
min-description-length: '10'
|
|
254
304
|
```
|
|
@@ -261,7 +311,7 @@ Add to your `.pre-commit-config.yaml`:
|
|
|
261
311
|
---
|
|
262
312
|
repos:
|
|
263
313
|
- repo: https://github.com/benner/commit-guard
|
|
264
|
-
rev: v0.
|
|
314
|
+
rev: v0.14.0
|
|
265
315
|
hooks:
|
|
266
316
|
- id: commit-guard
|
|
267
317
|
- id: commit-guard-signature
|
|
@@ -126,12 +126,33 @@ commit-guard --require-scope
|
|
|
126
126
|
commit-guard --scopes auth,api --require-scope
|
|
127
127
|
```
|
|
128
128
|
|
|
129
|
+
### Required custom trailers
|
|
130
|
+
|
|
131
|
+
Require arbitrary trailers to be present in the commit message. Multiple
|
|
132
|
+
trailers can be specified as a comma-separated list:
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
commit-guard --require-trailer Closes
|
|
136
|
+
commit-guard --require-trailer "Closes,Reviewed-by"
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
In `.commit-guard.toml`:
|
|
140
|
+
|
|
141
|
+
```toml
|
|
142
|
+
require-trailers = ["Closes", "Reviewed-by"]
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Trailer matching is case-sensitive and requires at least one non-space
|
|
146
|
+
character after the colon (e.g. `Closes: #42`). This check runs
|
|
147
|
+
independently of `--enable`/`--disable`.
|
|
148
|
+
|
|
129
149
|
### Configuration file
|
|
130
150
|
|
|
131
151
|
Place `.commit-guard.toml` in your project root (or any parent directory) to
|
|
132
152
|
set defaults for `enable`, `disable`, `scopes`, `require-scope`, `types`,
|
|
133
|
-
`max-subject-length`,
|
|
134
|
-
upward from the working directory and uses the first
|
|
153
|
+
`max-subject-length`, `min-description-length`, and `require-trailers`.
|
|
154
|
+
commit-guard searches upward from the working directory and uses the first
|
|
155
|
+
file found.
|
|
135
156
|
|
|
136
157
|
```toml
|
|
137
158
|
# .commit-guard.toml
|
|
@@ -141,6 +162,7 @@ require-scope = true
|
|
|
141
162
|
types = ["feat", "fix", "chore", "wip"]
|
|
142
163
|
max-subject-length = 100
|
|
143
164
|
min-description-length = 10
|
|
165
|
+
require-trailers = ["Closes", "Reviewed-by"]
|
|
144
166
|
```
|
|
145
167
|
|
|
146
168
|
```toml
|
|
@@ -149,8 +171,8 @@ enable = ["subject", "imperative"]
|
|
|
149
171
|
```
|
|
150
172
|
|
|
151
173
|
CLI flags (`--enable`, `--disable`, `--scopes`, `--require-scope`, `--types`,
|
|
152
|
-
`--max-subject-length`, `--min-description-length`) take
|
|
153
|
-
ignore config file values when provided.
|
|
174
|
+
`--max-subject-length`, `--min-description-length`, `--require-trailer`) take
|
|
175
|
+
full precedence and ignore config file values when provided.
|
|
154
176
|
|
|
155
177
|
### Checking a range of commits
|
|
156
178
|
|
|
@@ -181,6 +203,33 @@ misconfigured range specs in CI. Use `--allow-empty` to exit 0 instead:
|
|
|
181
203
|
commit-guard --range origin/main..HEAD --allow-empty
|
|
182
204
|
```
|
|
183
205
|
|
|
206
|
+
### Machine-readable output
|
|
207
|
+
|
|
208
|
+
Use `--output jsonl` to emit one JSON line per commit to stdout instead of the
|
|
209
|
+
default human-readable text on stderr:
|
|
210
|
+
|
|
211
|
+
```bash
|
|
212
|
+
commit-guard --range origin/main..HEAD --output jsonl
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
Each line is a JSON object:
|
|
216
|
+
|
|
217
|
+
```json
|
|
218
|
+
{
|
|
219
|
+
"sha": "abc1234...",
|
|
220
|
+
"subject": "feat: add thing",
|
|
221
|
+
"ok": false,
|
|
222
|
+
"results": [{"check": "body", "level": "error", "message": "missing body"}]
|
|
223
|
+
}
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
`sha` is `null` when reading from a file or stdin. `results` is empty when all
|
|
227
|
+
checks pass. Pipe to `jq` for filtering:
|
|
228
|
+
|
|
229
|
+
```bash
|
|
230
|
+
commit-guard --range origin/main..HEAD --output jsonl | jq 'select(.ok == false)'
|
|
231
|
+
```
|
|
232
|
+
|
|
184
233
|
### GitHub Actions
|
|
185
234
|
|
|
186
235
|
```yaml
|
|
@@ -188,7 +237,7 @@ steps:
|
|
|
188
237
|
- uses: actions/checkout@v4
|
|
189
238
|
with:
|
|
190
239
|
fetch-depth: 0
|
|
191
|
-
- uses: benner/commit-guard@
|
|
240
|
+
- uses: benner/commit-guard@v0.14.0
|
|
192
241
|
```
|
|
193
242
|
|
|
194
243
|
Check all commits in a pull request:
|
|
@@ -204,7 +253,7 @@ jobs:
|
|
|
204
253
|
- uses: actions/checkout@v4
|
|
205
254
|
with:
|
|
206
255
|
fetch-depth: 0
|
|
207
|
-
- uses: benner/commit-guard@
|
|
256
|
+
- uses: benner/commit-guard@v0.14.0
|
|
208
257
|
with:
|
|
209
258
|
range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}
|
|
210
259
|
```
|
|
@@ -222,12 +271,13 @@ jobs:
|
|
|
222
271
|
- uses: actions/checkout@v4
|
|
223
272
|
with:
|
|
224
273
|
fetch-depth: 0
|
|
225
|
-
- uses: benner/commit-guard@
|
|
274
|
+
- uses: benner/commit-guard@v0.14.0
|
|
226
275
|
with:
|
|
227
276
|
range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}
|
|
228
277
|
disable: signed-off,signature
|
|
229
278
|
scopes: auth,api,db
|
|
230
279
|
require-scope: 'true'
|
|
280
|
+
require-trailer: 'Closes,Reviewed-by'
|
|
231
281
|
max-subject-length: '100'
|
|
232
282
|
min-description-length: '10'
|
|
233
283
|
```
|
|
@@ -240,7 +290,7 @@ Add to your `.pre-commit-config.yaml`:
|
|
|
240
290
|
---
|
|
241
291
|
repos:
|
|
242
292
|
- repo: https://github.com/benner/commit-guard
|
|
243
|
-
rev: v0.
|
|
293
|
+
rev: v0.14.0
|
|
244
294
|
hooks:
|
|
245
295
|
- id: commit-guard
|
|
246
296
|
- id: commit-guard-signature
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import json
|
|
1
2
|
import re
|
|
2
3
|
import subprocess
|
|
3
4
|
import sys
|
|
@@ -53,6 +54,11 @@ class Check(StrEnum):
|
|
|
53
54
|
ALL_CHECKS = frozenset(Check.__members__.values())
|
|
54
55
|
|
|
55
56
|
|
|
57
|
+
class OutputFormat(StrEnum):
|
|
58
|
+
TEXT = "text"
|
|
59
|
+
JSONL = "jsonl"
|
|
60
|
+
|
|
61
|
+
|
|
56
62
|
def _load_config(start=None):
|
|
57
63
|
start = start or Path.cwd()
|
|
58
64
|
for directory in [start, *start.parents]:
|
|
@@ -87,18 +93,18 @@ PREFIXES = {
|
|
|
87
93
|
class Result:
|
|
88
94
|
errors: list = field(default_factory=list)
|
|
89
95
|
|
|
90
|
-
def error(self, msg):
|
|
91
|
-
self.errors.append((Level.ERROR, msg))
|
|
96
|
+
def error(self, msg, check=None):
|
|
97
|
+
self.errors.append((check, Level.ERROR, msg))
|
|
92
98
|
|
|
93
|
-
def warn(self, msg):
|
|
94
|
-
self.errors.append((Level.WARN, msg))
|
|
99
|
+
def warn(self, msg, check=None):
|
|
100
|
+
self.errors.append((check, Level.WARN, msg))
|
|
95
101
|
|
|
96
|
-
def info(self, msg):
|
|
97
|
-
self.errors.append((Level.INFO, msg))
|
|
102
|
+
def info(self, msg, check=None):
|
|
103
|
+
self.errors.append((check, Level.INFO, msg))
|
|
98
104
|
|
|
99
105
|
@property
|
|
100
106
|
def ok(self):
|
|
101
|
-
return not any(lvl == Level.ERROR for lvl, _ in self.errors)
|
|
107
|
+
return not any(lvl == Level.ERROR for _, lvl, _ in self.errors)
|
|
102
108
|
|
|
103
109
|
|
|
104
110
|
def _ensure_nltk_data():
|
|
@@ -132,42 +138,55 @@ def check_subject( # noqa: PLR0913 Too many arguments in function definition (7
|
|
|
132
138
|
):
|
|
133
139
|
m = SUBJECT_RE.match(line)
|
|
134
140
|
if not m:
|
|
135
|
-
result.error(
|
|
141
|
+
result.error(
|
|
142
|
+
f"subject does not match 'type(scope): description': {line}",
|
|
143
|
+
check=Check.SUBJECT,
|
|
144
|
+
)
|
|
136
145
|
return None
|
|
137
146
|
|
|
138
147
|
if m.group("type") not in allowed_types:
|
|
139
|
-
result.error(f"unknown type: {m.group('type')}")
|
|
148
|
+
result.error(f"unknown type: {m.group('type')}", check=Check.SUBJECT)
|
|
140
149
|
|
|
141
150
|
scope = m.group("scope")
|
|
142
151
|
if require_scope and scope is None:
|
|
143
|
-
result.error("scope is required")
|
|
152
|
+
result.error("scope is required", check=Check.SUBJECT)
|
|
144
153
|
if allowed_scopes and scope is not None and scope not in allowed_scopes:
|
|
145
|
-
result.error(f"unknown scope: {scope}")
|
|
154
|
+
result.error(f"unknown scope: {scope}", check=Check.SUBJECT)
|
|
146
155
|
|
|
147
156
|
desc = m.group("desc")
|
|
148
157
|
if desc[0].isupper():
|
|
149
|
-
result.error("description must not start with uppercase")
|
|
158
|
+
result.error("description must not start with uppercase", check=Check.SUBJECT)
|
|
150
159
|
if desc.endswith("."):
|
|
151
|
-
result.error("description must not end with period")
|
|
160
|
+
result.error("description must not end with period", check=Check.SUBJECT)
|
|
152
161
|
if len(line) > max_subject_length:
|
|
153
|
-
result.error(
|
|
162
|
+
result.error(
|
|
163
|
+
f"subject too long: {len(line)} > {max_subject_length}", check=Check.SUBJECT
|
|
164
|
+
)
|
|
154
165
|
if min_description_length > 0 and len(desc) < min_description_length:
|
|
155
|
-
result.error(
|
|
166
|
+
result.error(
|
|
167
|
+
f"description too short: {len(desc)} < {min_description_length}",
|
|
168
|
+
check=Check.SUBJECT,
|
|
169
|
+
)
|
|
156
170
|
return desc
|
|
157
171
|
|
|
158
172
|
|
|
159
173
|
def check_imperative(desc, result):
|
|
174
|
+
_ensure_nltk_data()
|
|
160
175
|
tokens = nltk.word_tokenize(desc.lower())
|
|
161
176
|
if not tokens:
|
|
162
177
|
return
|
|
163
178
|
first = tokens[0]
|
|
164
179
|
if _NON_IMPERATIVE_SUFFIX_RE.search(first):
|
|
165
|
-
result.error(
|
|
180
|
+
result.error(
|
|
181
|
+
f"expected imperative verb, got '{first}' (non-imperative suffix)",
|
|
182
|
+
check=Check.IMPERATIVE,
|
|
183
|
+
)
|
|
166
184
|
return
|
|
167
185
|
base = wordnet.morphy(first, wordnet.VERB)
|
|
168
186
|
if base is not None and base != first:
|
|
169
187
|
result.error(
|
|
170
|
-
f"expected imperative verb, got '{first}' (inflected form of '{base}')"
|
|
188
|
+
f"expected imperative verb, got '{first}' (inflected form of '{base}')",
|
|
189
|
+
check=Check.IMPERATIVE,
|
|
171
190
|
)
|
|
172
191
|
return
|
|
173
192
|
tagged = nltk.pos_tag(["to", *tokens])
|
|
@@ -176,23 +195,31 @@ def check_imperative(desc, result):
|
|
|
176
195
|
return
|
|
177
196
|
result.error(
|
|
178
197
|
f"expected imperative verb, got '{tagged[1][0]}' (POS={tagged[1][1]})",
|
|
198
|
+
check=Check.IMPERATIVE,
|
|
179
199
|
)
|
|
180
200
|
|
|
181
201
|
|
|
182
202
|
def check_body(lines, result):
|
|
183
203
|
if len(lines) < 3: # noqa: PLR2004
|
|
184
|
-
result.error("missing body")
|
|
204
|
+
result.error("missing body", check=Check.BODY)
|
|
185
205
|
return
|
|
186
206
|
if lines[1].strip():
|
|
187
|
-
result.error("missing blank line between subject and body")
|
|
207
|
+
result.error("missing blank line between subject and body", check=Check.BODY)
|
|
188
208
|
body_lines = [ln for ln in lines[2:] if not _TRAILER_RE.match(ln)]
|
|
189
209
|
if not any(ln.strip() for ln in body_lines):
|
|
190
|
-
result.error("missing body")
|
|
210
|
+
result.error("missing body", check=Check.BODY)
|
|
191
211
|
|
|
192
212
|
|
|
193
213
|
def check_signed_off(message, result):
|
|
194
214
|
if not SIGNED_OFF_RE.search(message):
|
|
195
|
-
result.error("missing 'Signed-off-by' trailer")
|
|
215
|
+
result.error("missing 'Signed-off-by' trailer", check=Check.SIGNED_OFF)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def check_required_trailers(message, required, result):
|
|
219
|
+
for trailer in required:
|
|
220
|
+
pattern = re.compile(rf"^{re.escape(trailer)}:\s+\S", re.MULTILINE)
|
|
221
|
+
if not pattern.search(message):
|
|
222
|
+
result.error(f"missing required trailer: {trailer}")
|
|
196
223
|
|
|
197
224
|
|
|
198
225
|
def check_signature(rev, result):
|
|
@@ -204,12 +231,12 @@ def check_signature(rev, result):
|
|
|
204
231
|
timeout=GIT_TIMEOUT,
|
|
205
232
|
)
|
|
206
233
|
if proc.returncode != 0:
|
|
207
|
-
result.error("commit is not signed (GPG/SSH)")
|
|
234
|
+
result.error("commit is not signed (GPG/SSH)", check=Check.SIGNATURE)
|
|
208
235
|
return
|
|
209
236
|
|
|
210
237
|
output = proc.stderr.lower()
|
|
211
238
|
sig_type = "SSH" if "ssh" in output else "GPG"
|
|
212
|
-
result.info(f"signature type: {sig_type}")
|
|
239
|
+
result.info(f"signature type: {sig_type}", check=Check.SIGNATURE)
|
|
213
240
|
|
|
214
241
|
|
|
215
242
|
def _get_message(rev):
|
|
@@ -257,6 +284,8 @@ class Args:
|
|
|
257
284
|
rev_range: str | None
|
|
258
285
|
allow_empty: bool
|
|
259
286
|
include_merges: bool
|
|
287
|
+
required_trailers: list
|
|
288
|
+
output: OutputFormat
|
|
260
289
|
|
|
261
290
|
|
|
262
291
|
def _resolve_enabled(args, config, parser):
|
|
@@ -291,6 +320,14 @@ def _resolve_min_description_length(args, config):
|
|
|
291
320
|
return 0
|
|
292
321
|
|
|
293
322
|
|
|
323
|
+
def _resolve_required_trailers(args, config):
|
|
324
|
+
if args.require_trailer:
|
|
325
|
+
return [t.strip() for t in args.require_trailer.split(",")]
|
|
326
|
+
if config.get("require-trailers"):
|
|
327
|
+
return list(config["require-trailers"])
|
|
328
|
+
return []
|
|
329
|
+
|
|
330
|
+
|
|
294
331
|
def _resolve_types(args, config):
|
|
295
332
|
if args.types:
|
|
296
333
|
return frozenset(t.strip() for t in args.types.split(","))
|
|
@@ -381,12 +418,23 @@ def _parse_args():
|
|
|
381
418
|
default=False,
|
|
382
419
|
help="exit 0 when --range yields no commits (default: exit 1)",
|
|
383
420
|
)
|
|
421
|
+
parser.add_argument(
|
|
422
|
+
"--require-trailer",
|
|
423
|
+
metavar="TRAILER[,TRAILER,...]",
|
|
424
|
+
help="require these trailers in the commit message",
|
|
425
|
+
)
|
|
384
426
|
parser.add_argument(
|
|
385
427
|
"--include-merges",
|
|
386
428
|
action="store_true",
|
|
387
429
|
default=False,
|
|
388
430
|
help="include merge commits when checking a range (default: excluded)",
|
|
389
431
|
)
|
|
432
|
+
parser.add_argument(
|
|
433
|
+
"--output",
|
|
434
|
+
choices=[f.value for f in OutputFormat],
|
|
435
|
+
default=OutputFormat.TEXT,
|
|
436
|
+
help="output format: text (default) or jsonl",
|
|
437
|
+
)
|
|
390
438
|
args = parser.parse_args()
|
|
391
439
|
config = _load_config()
|
|
392
440
|
enabled = _resolve_enabled(args, config, parser)
|
|
@@ -394,6 +442,7 @@ def _parse_args():
|
|
|
394
442
|
allowed_types = _resolve_types(args, config)
|
|
395
443
|
max_subject_length = _resolve_max_subject_length(args, config)
|
|
396
444
|
min_description_length = _resolve_min_description_length(args, config)
|
|
445
|
+
required_trailers = _resolve_required_trailers(args, config)
|
|
397
446
|
|
|
398
447
|
if args.allow_empty and not args.rev_range:
|
|
399
448
|
parser.error("--allow-empty requires --range")
|
|
@@ -430,12 +479,29 @@ def _parse_args():
|
|
|
430
479
|
rev_range=args.rev_range,
|
|
431
480
|
allow_empty=args.allow_empty,
|
|
432
481
|
include_merges=args.include_merges,
|
|
482
|
+
required_trailers=required_trailers,
|
|
483
|
+
output=OutputFormat(args.output),
|
|
433
484
|
)
|
|
434
485
|
|
|
435
486
|
|
|
436
|
-
def
|
|
437
|
-
|
|
438
|
-
|
|
487
|
+
def _report_jsonl(result, sha, subject):
|
|
488
|
+
record = {
|
|
489
|
+
"sha": sha,
|
|
490
|
+
"subject": subject,
|
|
491
|
+
"ok": result.ok,
|
|
492
|
+
"results": [
|
|
493
|
+
{"check": check, "level": str(level), "message": msg}
|
|
494
|
+
for check, level, msg in result.errors
|
|
495
|
+
],
|
|
496
|
+
}
|
|
497
|
+
sys.stdout.write(json.dumps(record) + "\n")
|
|
498
|
+
return 0 if result.ok else 1
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _report_text(result):
|
|
502
|
+
for check, level, msg in result.errors:
|
|
503
|
+
prefix = f"[{check}] " if check else ""
|
|
504
|
+
sys.stderr.write(f" {PREFIXES[level]} {prefix}{msg}\n")
|
|
439
505
|
|
|
440
506
|
if result.ok:
|
|
441
507
|
sys.stderr.write(" \033[32m✓\033[0m all checks passed\n")
|
|
@@ -466,6 +532,8 @@ def _run_checks(args, rev, message, result):
|
|
|
466
532
|
check_body(lines, result)
|
|
467
533
|
if Check.SIGNED_OFF in args.enabled:
|
|
468
534
|
check_signed_off(message, result)
|
|
535
|
+
if args.required_trailers:
|
|
536
|
+
check_required_trailers(message, args.required_trailers, result)
|
|
469
537
|
if Check.SIGNATURE in args.enabled and rev:
|
|
470
538
|
check_signature(rev, result)
|
|
471
539
|
|
|
@@ -473,9 +541,6 @@ def _run_checks(args, rev, message, result):
|
|
|
473
541
|
def main():
|
|
474
542
|
args = _parse_args()
|
|
475
543
|
|
|
476
|
-
if Check.IMPERATIVE in args.enabled:
|
|
477
|
-
_ensure_nltk_data()
|
|
478
|
-
|
|
479
544
|
if args.rev_range:
|
|
480
545
|
revs = _get_range_revs(args.rev_range, include_merges=args.include_merges)
|
|
481
546
|
if not revs:
|
|
@@ -484,13 +549,21 @@ def main():
|
|
|
484
549
|
failed = False
|
|
485
550
|
for rev in revs:
|
|
486
551
|
message = _strip_comments(_get_message(rev))
|
|
487
|
-
|
|
552
|
+
subject = message.split("\n")[0]
|
|
488
553
|
result = Result()
|
|
489
554
|
_run_checks(args, rev, message, result)
|
|
490
|
-
if
|
|
491
|
-
|
|
555
|
+
if args.output == OutputFormat.JSONL:
|
|
556
|
+
if _report_jsonl(result, rev, subject) != 0:
|
|
557
|
+
failed = True
|
|
558
|
+
else:
|
|
559
|
+
sys.stderr.write(f"{rev[:7]} {subject}\n")
|
|
560
|
+
if _report_text(result) != 0:
|
|
561
|
+
failed = True
|
|
492
562
|
return 1 if failed else 0
|
|
493
563
|
|
|
564
|
+
subject = args.message.split("\n")[0]
|
|
494
565
|
result = Result()
|
|
495
566
|
_run_checks(args, args.rev, args.message, result)
|
|
496
|
-
|
|
567
|
+
if args.output == OutputFormat.JSONL:
|
|
568
|
+
return _report_jsonl(result, args.rev, subject)
|
|
569
|
+
return _report_text(result)
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import json
|
|
1
2
|
import subprocess
|
|
2
3
|
from argparse import ArgumentParser, Namespace
|
|
3
4
|
from unittest.mock import MagicMock, patch
|
|
@@ -15,13 +16,16 @@ from git_commit_guard import (
|
|
|
15
16
|
_load_config,
|
|
16
17
|
_parse_checks,
|
|
17
18
|
_parse_config_checks,
|
|
18
|
-
|
|
19
|
+
_report_jsonl,
|
|
20
|
+
_report_text,
|
|
19
21
|
_resolve_max_subject_length,
|
|
20
22
|
_resolve_min_description_length,
|
|
23
|
+
_resolve_required_trailers,
|
|
21
24
|
_resolve_types,
|
|
22
25
|
_strip_comments,
|
|
23
26
|
check_body,
|
|
24
27
|
check_imperative,
|
|
28
|
+
check_required_trailers,
|
|
25
29
|
check_signature,
|
|
26
30
|
check_signed_off,
|
|
27
31
|
check_subject,
|
|
@@ -171,7 +175,7 @@ class TestCheckSubject:
|
|
|
171
175
|
r = Result()
|
|
172
176
|
check_subject("fix: add x", r, min_description_length=6)
|
|
173
177
|
assert not r.ok
|
|
174
|
-
assert any("description too short" in m for _, m in r.errors)
|
|
178
|
+
assert any("description too short" in m for _, _, m in r.errors)
|
|
175
179
|
|
|
176
180
|
def test_min_description_length_exact_passes(self):
|
|
177
181
|
r = Result()
|
|
@@ -273,6 +277,83 @@ class TestCheckSignedOff:
|
|
|
273
277
|
assert not r.ok
|
|
274
278
|
|
|
275
279
|
|
|
280
|
+
class TestCheckRequiredTrailers:
|
|
281
|
+
def test_present_passes(self):
|
|
282
|
+
r = Result()
|
|
283
|
+
check_required_trailers("fix: add x\n\nbody\n\nCloses: #42", ["Closes"], r)
|
|
284
|
+
assert r.ok
|
|
285
|
+
|
|
286
|
+
def test_missing_fails(self):
|
|
287
|
+
r = Result()
|
|
288
|
+
check_required_trailers("fix: add x\n\nbody", ["Closes"], r)
|
|
289
|
+
assert not r.ok
|
|
290
|
+
assert "missing required trailer: Closes" in r.errors[0][2]
|
|
291
|
+
|
|
292
|
+
def test_multiple_all_present_passes(self):
|
|
293
|
+
r = Result()
|
|
294
|
+
check_required_trailers(
|
|
295
|
+
"fix: add x\n\nbody\n\nCloses: #42\nReviewed-by: Jane",
|
|
296
|
+
["Closes", "Reviewed-by"],
|
|
297
|
+
r,
|
|
298
|
+
)
|
|
299
|
+
assert r.ok
|
|
300
|
+
|
|
301
|
+
def test_multiple_one_missing_fails(self):
|
|
302
|
+
r = Result()
|
|
303
|
+
check_required_trailers(
|
|
304
|
+
"fix: add x\n\nbody\n\nCloses: #42",
|
|
305
|
+
["Closes", "Reviewed-by"],
|
|
306
|
+
r,
|
|
307
|
+
)
|
|
308
|
+
assert not r.ok
|
|
309
|
+
assert any("Reviewed-by" in msg for _, _, msg in r.errors)
|
|
310
|
+
|
|
311
|
+
def test_case_sensitive(self):
|
|
312
|
+
r = Result()
|
|
313
|
+
check_required_trailers("fix: add x\n\nbody\n\ncloses: #42", ["Closes"], r)
|
|
314
|
+
assert not r.ok
|
|
315
|
+
|
|
316
|
+
def test_empty_required_list_always_passes(self):
|
|
317
|
+
r = Result()
|
|
318
|
+
check_required_trailers("fix: add x", [], r)
|
|
319
|
+
assert r.ok
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
class TestResolveRequiredTrailers:
|
|
323
|
+
def test_defaults_to_empty(self):
|
|
324
|
+
assert _resolve_required_trailers(Namespace(require_trailer=None), {}) == []
|
|
325
|
+
|
|
326
|
+
def test_cli_flag_single(self):
|
|
327
|
+
result = _resolve_required_trailers(Namespace(require_trailer="Closes"), {})
|
|
328
|
+
assert result == ["Closes"]
|
|
329
|
+
|
|
330
|
+
def test_cli_flag_multiple(self):
|
|
331
|
+
result = _resolve_required_trailers(
|
|
332
|
+
Namespace(require_trailer="Closes,Reviewed-by"), {}
|
|
333
|
+
)
|
|
334
|
+
assert result == ["Closes", "Reviewed-by"]
|
|
335
|
+
|
|
336
|
+
def test_cli_flag_strips_spaces(self):
|
|
337
|
+
result = _resolve_required_trailers(
|
|
338
|
+
Namespace(require_trailer="Closes, Reviewed-by"), {}
|
|
339
|
+
)
|
|
340
|
+
assert result == ["Closes", "Reviewed-by"]
|
|
341
|
+
|
|
342
|
+
def test_config(self):
|
|
343
|
+
result = _resolve_required_trailers(
|
|
344
|
+
Namespace(require_trailer=None),
|
|
345
|
+
{"require-trailers": ["Closes", "Reviewed-by"]},
|
|
346
|
+
)
|
|
347
|
+
assert result == ["Closes", "Reviewed-by"]
|
|
348
|
+
|
|
349
|
+
def test_cli_overrides_config(self):
|
|
350
|
+
result = _resolve_required_trailers(
|
|
351
|
+
Namespace(require_trailer="Fixes"),
|
|
352
|
+
{"require-trailers": ["Closes"]},
|
|
353
|
+
)
|
|
354
|
+
assert result == ["Fixes"]
|
|
355
|
+
|
|
356
|
+
|
|
276
357
|
class TestCheckImperative:
|
|
277
358
|
def test_imperative_verb_passes(self):
|
|
278
359
|
r = Result()
|
|
@@ -368,7 +449,7 @@ class TestCheckSignature:
|
|
|
368
449
|
with patch("git_commit_guard.subprocess.run", return_value=proc):
|
|
369
450
|
check_signature("abc123", r)
|
|
370
451
|
assert r.ok
|
|
371
|
-
assert any("GPG" in msg for _, msg in r.errors)
|
|
452
|
+
assert any("GPG" in msg for _, _, msg in r.errors)
|
|
372
453
|
|
|
373
454
|
def test_ssh_signed_commit(self):
|
|
374
455
|
r = Result()
|
|
@@ -376,7 +457,7 @@ class TestCheckSignature:
|
|
|
376
457
|
with patch("git_commit_guard.subprocess.run", return_value=proc):
|
|
377
458
|
check_signature("abc123", r)
|
|
378
459
|
assert r.ok
|
|
379
|
-
assert any("SSH" in msg for _, msg in r.errors)
|
|
460
|
+
assert any("SSH" in msg for _, _, msg in r.errors)
|
|
380
461
|
|
|
381
462
|
|
|
382
463
|
class TestGetMessage:
|
|
@@ -525,27 +606,78 @@ class TestParseChecks:
|
|
|
525
606
|
class TestReport:
|
|
526
607
|
def test_all_passed(self, capsys):
|
|
527
608
|
r = Result()
|
|
528
|
-
ret =
|
|
609
|
+
ret = _report_text(r)
|
|
529
610
|
assert ret == 0
|
|
530
611
|
assert "all checks passed" in capsys.readouterr().err
|
|
531
612
|
|
|
532
613
|
def test_with_error(self, capsys):
|
|
533
614
|
r = Result()
|
|
534
615
|
r.error("something broke")
|
|
535
|
-
ret =
|
|
616
|
+
ret = _report_text(r)
|
|
536
617
|
assert ret == 1
|
|
537
618
|
assert "something broke" in capsys.readouterr().err
|
|
538
619
|
|
|
539
620
|
def test_with_warning_returns_zero(self, capsys):
|
|
540
621
|
r = Result()
|
|
541
622
|
r.warn("heads up")
|
|
542
|
-
ret =
|
|
623
|
+
ret = _report_text(r)
|
|
543
624
|
assert ret == 0
|
|
544
625
|
captured = capsys.readouterr().err
|
|
545
626
|
assert "heads up" in captured
|
|
546
627
|
assert "all checks passed" in captured
|
|
547
628
|
|
|
548
629
|
|
|
630
|
+
class TestReportJsonl:
|
|
631
|
+
def test_ok_commit(self, capsys):
|
|
632
|
+
r = Result()
|
|
633
|
+
ret = _report_jsonl(r, "abc1234567890", "fix: add thing")
|
|
634
|
+
assert ret == 0
|
|
635
|
+
out = capsys.readouterr().out
|
|
636
|
+
|
|
637
|
+
data = json.loads(out)
|
|
638
|
+
assert data["sha"] == "abc1234567890"
|
|
639
|
+
assert data["subject"] == "fix: add thing"
|
|
640
|
+
assert data["ok"] is True
|
|
641
|
+
assert data["results"] == []
|
|
642
|
+
|
|
643
|
+
def test_failed_commit(self, capsys):
|
|
644
|
+
r = Result()
|
|
645
|
+
r.error("missing body", check="body")
|
|
646
|
+
ret = _report_jsonl(r, "abc1234567890", "fix: add thing")
|
|
647
|
+
assert ret == 1
|
|
648
|
+
|
|
649
|
+
data = json.loads(capsys.readouterr().out)
|
|
650
|
+
assert data["ok"] is False
|
|
651
|
+
assert len(data["results"]) == 1
|
|
652
|
+
assert data["results"][0] == {
|
|
653
|
+
"check": "body",
|
|
654
|
+
"level": "error",
|
|
655
|
+
"message": "missing body",
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
def test_null_sha(self, capsys):
|
|
659
|
+
r = Result()
|
|
660
|
+
ret = _report_jsonl(r, None, "fix: add thing")
|
|
661
|
+
assert ret == 0
|
|
662
|
+
|
|
663
|
+
data = json.loads(capsys.readouterr().out)
|
|
664
|
+
assert data["sha"] is None
|
|
665
|
+
|
|
666
|
+
def test_check_none_in_results(self, capsys):
|
|
667
|
+
r = Result()
|
|
668
|
+
r.error("missing required trailer: Closes")
|
|
669
|
+
_report_jsonl(r, "abc", "fix: add thing")
|
|
670
|
+
|
|
671
|
+
data = json.loads(capsys.readouterr().out)
|
|
672
|
+
assert data["results"][0]["check"] is None
|
|
673
|
+
|
|
674
|
+
def test_output_is_single_line(self, capsys):
|
|
675
|
+
r = Result()
|
|
676
|
+
_report_jsonl(r, "abc", "fix: add thing")
|
|
677
|
+
out = capsys.readouterr().out
|
|
678
|
+
assert out.count("\n") == 1
|
|
679
|
+
|
|
680
|
+
|
|
549
681
|
_VALID_MSG = "fix: add thing\n\nbody text\n\nSigned-off-by: A User <a@b.com>"
|
|
550
682
|
|
|
551
683
|
|
|
@@ -1103,3 +1235,157 @@ class TestGetRangeRevs:
|
|
|
1103
1235
|
pytest.raises(SystemExit, match="git error"),
|
|
1104
1236
|
):
|
|
1105
1237
|
_get_range_revs("bogus")
|
|
1238
|
+
|
|
1239
|
+
|
|
1240
|
+
class TestRequireTrailerIntegration:
|
|
1241
|
+
def test_require_trailer_flag_passes(self, tmp_path):
|
|
1242
|
+
f = tmp_path / "msg"
|
|
1243
|
+
f.write_text(
|
|
1244
|
+
"fix: add thing\n\nbody\n\nCloses: #42\nSigned-off-by: A <a@b.com>"
|
|
1245
|
+
)
|
|
1246
|
+
argv = [
|
|
1247
|
+
"cg",
|
|
1248
|
+
"--message-file",
|
|
1249
|
+
str(f),
|
|
1250
|
+
"--disable",
|
|
1251
|
+
"signature,imperative",
|
|
1252
|
+
"--require-trailer",
|
|
1253
|
+
"Closes",
|
|
1254
|
+
]
|
|
1255
|
+
with patch("sys.argv", argv):
|
|
1256
|
+
assert main() == 0
|
|
1257
|
+
|
|
1258
|
+
def test_require_trailer_flag_fails(self, tmp_path):
|
|
1259
|
+
f = tmp_path / "msg"
|
|
1260
|
+
f.write_text("fix: add thing\n\nbody\n\nSigned-off-by: A <a@b.com>")
|
|
1261
|
+
argv = [
|
|
1262
|
+
"cg",
|
|
1263
|
+
"--message-file",
|
|
1264
|
+
str(f),
|
|
1265
|
+
"--disable",
|
|
1266
|
+
"signature,imperative",
|
|
1267
|
+
"--require-trailer",
|
|
1268
|
+
"Closes",
|
|
1269
|
+
]
|
|
1270
|
+
with patch("sys.argv", argv):
|
|
1271
|
+
assert main() == 1
|
|
1272
|
+
|
|
1273
|
+
def test_require_trailer_multiple_passes(self, tmp_path):
|
|
1274
|
+
f = tmp_path / "msg"
|
|
1275
|
+
f.write_text(
|
|
1276
|
+
"fix: add thing\n\nbody\n\n"
|
|
1277
|
+
"Closes: #42\nReviewed-by: Jane\nSigned-off-by: A <a@b.com>"
|
|
1278
|
+
)
|
|
1279
|
+
argv = [
|
|
1280
|
+
"cg",
|
|
1281
|
+
"--message-file",
|
|
1282
|
+
str(f),
|
|
1283
|
+
"--disable",
|
|
1284
|
+
"signature,imperative",
|
|
1285
|
+
"--require-trailer",
|
|
1286
|
+
"Closes,Reviewed-by",
|
|
1287
|
+
]
|
|
1288
|
+
with patch("sys.argv", argv):
|
|
1289
|
+
assert main() == 0
|
|
1290
|
+
|
|
1291
|
+
def test_require_trailer_from_config(self, tmp_path):
|
|
1292
|
+
f = tmp_path / "msg"
|
|
1293
|
+
f.write_text("fix: add thing\n\nbody\n\nSigned-off-by: A <a@b.com>")
|
|
1294
|
+
argv = ["cg", "--message-file", str(f), "--disable", "signature,imperative"]
|
|
1295
|
+
with (
|
|
1296
|
+
patch("sys.argv", argv),
|
|
1297
|
+
patch(
|
|
1298
|
+
"git_commit_guard._load_config",
|
|
1299
|
+
return_value={"require-trailers": ["Closes"]},
|
|
1300
|
+
),
|
|
1301
|
+
):
|
|
1302
|
+
assert main() == 1
|
|
1303
|
+
|
|
1304
|
+
def test_require_trailer_cli_overrides_config(self, tmp_path):
|
|
1305
|
+
f = tmp_path / "msg"
|
|
1306
|
+
f.write_text("fix: add thing\n\nbody\n\nFixes: #99\nSigned-off-by: A <a@b.com>")
|
|
1307
|
+
argv = [
|
|
1308
|
+
"cg",
|
|
1309
|
+
"--message-file",
|
|
1310
|
+
str(f),
|
|
1311
|
+
"--disable",
|
|
1312
|
+
"signature,imperative",
|
|
1313
|
+
"--require-trailer",
|
|
1314
|
+
"Fixes",
|
|
1315
|
+
]
|
|
1316
|
+
with (
|
|
1317
|
+
patch("sys.argv", argv),
|
|
1318
|
+
patch(
|
|
1319
|
+
"git_commit_guard._load_config",
|
|
1320
|
+
return_value={"require-trailers": ["Closes"]},
|
|
1321
|
+
),
|
|
1322
|
+
):
|
|
1323
|
+
assert main() == 0
|
|
1324
|
+
|
|
1325
|
+
|
|
1326
|
+
class TestOutputJsonl:
|
|
1327
|
+
def test_single_commit_ok(self, tmp_path, capsys):
|
|
1328
|
+
|
|
1329
|
+
f = tmp_path / "msg"
|
|
1330
|
+
f.write_text(_VALID_MSG)
|
|
1331
|
+
argv = [
|
|
1332
|
+
"cg",
|
|
1333
|
+
"--message-file",
|
|
1334
|
+
str(f),
|
|
1335
|
+
"--disable",
|
|
1336
|
+
"signature,imperative",
|
|
1337
|
+
"--output",
|
|
1338
|
+
"jsonl",
|
|
1339
|
+
]
|
|
1340
|
+
with patch("sys.argv", argv):
|
|
1341
|
+
assert main() == 0
|
|
1342
|
+
data = json.loads(capsys.readouterr().out)
|
|
1343
|
+
assert data["ok"] is True
|
|
1344
|
+
assert data["subject"] == "fix: add thing"
|
|
1345
|
+
assert data["sha"] is None
|
|
1346
|
+
|
|
1347
|
+
def test_single_commit_fail(self, tmp_path, capsys):
|
|
1348
|
+
|
|
1349
|
+
f = tmp_path / "msg"
|
|
1350
|
+
f.write_text("fix: add thing")
|
|
1351
|
+
argv = [
|
|
1352
|
+
"cg",
|
|
1353
|
+
"--message-file",
|
|
1354
|
+
str(f),
|
|
1355
|
+
"--disable",
|
|
1356
|
+
"signature,imperative",
|
|
1357
|
+
"--output",
|
|
1358
|
+
"jsonl",
|
|
1359
|
+
]
|
|
1360
|
+
with patch("sys.argv", argv):
|
|
1361
|
+
assert main() == 1
|
|
1362
|
+
data = json.loads(capsys.readouterr().out)
|
|
1363
|
+
assert data["ok"] is False
|
|
1364
|
+
assert any(r["check"] == "body" for r in data["results"])
|
|
1365
|
+
|
|
1366
|
+
def test_range_emits_one_line_per_commit(self, capsys):
|
|
1367
|
+
revs = ["aaa", "bbb"]
|
|
1368
|
+
messages = ["fix: add thing\n\nbody\n\nSigned-off-by: A <a@b.com>"] * len(revs)
|
|
1369
|
+
with (
|
|
1370
|
+
patch(
|
|
1371
|
+
"sys.argv",
|
|
1372
|
+
[
|
|
1373
|
+
"cg",
|
|
1374
|
+
"--range",
|
|
1375
|
+
"HEAD~2..HEAD",
|
|
1376
|
+
"--disable",
|
|
1377
|
+
"signature,imperative",
|
|
1378
|
+
"--output",
|
|
1379
|
+
"jsonl",
|
|
1380
|
+
],
|
|
1381
|
+
),
|
|
1382
|
+
patch("git_commit_guard._get_range_revs", return_value=revs),
|
|
1383
|
+
patch("git_commit_guard._get_message", side_effect=messages),
|
|
1384
|
+
):
|
|
1385
|
+
assert main() == 0
|
|
1386
|
+
lines = capsys.readouterr().out.strip().splitlines()
|
|
1387
|
+
assert len(lines) == len(revs)
|
|
1388
|
+
for line, rev in zip(lines, revs, strict=True):
|
|
1389
|
+
data = json.loads(line)
|
|
1390
|
+
assert data["sha"] == rev
|
|
1391
|
+
assert data["ok"] is True
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|