git-commit-guard 0.13.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.13.0 → git_commit_guard-0.14.0}/.github/workflows/lint-commits.yml +1 -1
- {git_commit_guard-0.13.0 → git_commit_guard-0.14.0}/PKG-INFO +32 -5
- {git_commit_guard-0.13.0 → git_commit_guard-0.14.0}/README.md +31 -4
- {git_commit_guard-0.13.0 → git_commit_guard-0.14.0}/src/git_commit_guard/__init__.py +80 -30
- {git_commit_guard-0.13.0 → git_commit_guard-0.14.0}/tests/test_git_commit_guard.py +130 -9
- {git_commit_guard-0.13.0 → git_commit_guard-0.14.0}/.github/workflows/lint-md.yml +0 -0
- {git_commit_guard-0.13.0 → git_commit_guard-0.14.0}/.github/workflows/lint-python.yml +0 -0
- {git_commit_guard-0.13.0 → git_commit_guard-0.14.0}/.github/workflows/release.yml +0 -0
- {git_commit_guard-0.13.0 → git_commit_guard-0.14.0}/.github/workflows/test.yml +0 -0
- {git_commit_guard-0.13.0 → git_commit_guard-0.14.0}/.gitignore +0 -0
- {git_commit_guard-0.13.0 → git_commit_guard-0.14.0}/.pre-commit-hooks.yaml +0 -0
- {git_commit_guard-0.13.0 → git_commit_guard-0.14.0}/.python-version +0 -0
- {git_commit_guard-0.13.0 → git_commit_guard-0.14.0}/LICENSE +0 -0
- {git_commit_guard-0.13.0 → git_commit_guard-0.14.0}/action.yml +0 -0
- {git_commit_guard-0.13.0 → git_commit_guard-0.14.0}/pyproject.toml +0 -0
- {git_commit_guard-0.13.0 → git_commit_guard-0.14.0}/ruff.toml +0 -0
- {git_commit_guard-0.13.0 → git_commit_guard-0.14.0}/tests/__init__.py +0 -0
- {git_commit_guard-0.13.0 → git_commit_guard-0.14.0}/uv.lock +0 -0
|
@@ -22,7 +22,7 @@ jobs:
|
|
|
22
22
|
key: nltk-averaged-perceptron-tagger-punkt
|
|
23
23
|
- name: Lint commits
|
|
24
24
|
# yamllint disable-line rule:line-length
|
|
25
|
-
uses: benner/commit-guard@
|
|
25
|
+
uses: benner/commit-guard@0f2660f0b4d0ea25b8524acfb459a35e544252cb # v0.13.0
|
|
26
26
|
with:
|
|
27
27
|
range: origin/${{ github.base_ref }}..HEAD
|
|
28
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
|
|
@@ -224,6 +224,33 @@ misconfigured range specs in CI. Use `--allow-empty` to exit 0 instead:
|
|
|
224
224
|
commit-guard --range origin/main..HEAD --allow-empty
|
|
225
225
|
```
|
|
226
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
|
+
|
|
227
254
|
### GitHub Actions
|
|
228
255
|
|
|
229
256
|
```yaml
|
|
@@ -231,7 +258,7 @@ steps:
|
|
|
231
258
|
- uses: actions/checkout@v4
|
|
232
259
|
with:
|
|
233
260
|
fetch-depth: 0
|
|
234
|
-
- uses: benner/commit-guard@v0.
|
|
261
|
+
- uses: benner/commit-guard@v0.14.0
|
|
235
262
|
```
|
|
236
263
|
|
|
237
264
|
Check all commits in a pull request:
|
|
@@ -247,7 +274,7 @@ jobs:
|
|
|
247
274
|
- uses: actions/checkout@v4
|
|
248
275
|
with:
|
|
249
276
|
fetch-depth: 0
|
|
250
|
-
- uses: benner/commit-guard@v0.
|
|
277
|
+
- uses: benner/commit-guard@v0.14.0
|
|
251
278
|
with:
|
|
252
279
|
range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}
|
|
253
280
|
```
|
|
@@ -265,7 +292,7 @@ jobs:
|
|
|
265
292
|
- uses: actions/checkout@v4
|
|
266
293
|
with:
|
|
267
294
|
fetch-depth: 0
|
|
268
|
-
- uses: benner/commit-guard@v0.
|
|
295
|
+
- uses: benner/commit-guard@v0.14.0
|
|
269
296
|
with:
|
|
270
297
|
range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}
|
|
271
298
|
disable: signed-off,signature
|
|
@@ -284,7 +311,7 @@ Add to your `.pre-commit-config.yaml`:
|
|
|
284
311
|
---
|
|
285
312
|
repos:
|
|
286
313
|
- repo: https://github.com/benner/commit-guard
|
|
287
|
-
rev: v0.
|
|
314
|
+
rev: v0.14.0
|
|
288
315
|
hooks:
|
|
289
316
|
- id: commit-guard
|
|
290
317
|
- id: commit-guard-signature
|
|
@@ -203,6 +203,33 @@ misconfigured range specs in CI. Use `--allow-empty` to exit 0 instead:
|
|
|
203
203
|
commit-guard --range origin/main..HEAD --allow-empty
|
|
204
204
|
```
|
|
205
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
|
+
|
|
206
233
|
### GitHub Actions
|
|
207
234
|
|
|
208
235
|
```yaml
|
|
@@ -210,7 +237,7 @@ steps:
|
|
|
210
237
|
- uses: actions/checkout@v4
|
|
211
238
|
with:
|
|
212
239
|
fetch-depth: 0
|
|
213
|
-
- uses: benner/commit-guard@v0.
|
|
240
|
+
- uses: benner/commit-guard@v0.14.0
|
|
214
241
|
```
|
|
215
242
|
|
|
216
243
|
Check all commits in a pull request:
|
|
@@ -226,7 +253,7 @@ jobs:
|
|
|
226
253
|
- uses: actions/checkout@v4
|
|
227
254
|
with:
|
|
228
255
|
fetch-depth: 0
|
|
229
|
-
- uses: benner/commit-guard@v0.
|
|
256
|
+
- uses: benner/commit-guard@v0.14.0
|
|
230
257
|
with:
|
|
231
258
|
range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}
|
|
232
259
|
```
|
|
@@ -244,7 +271,7 @@ jobs:
|
|
|
244
271
|
- uses: actions/checkout@v4
|
|
245
272
|
with:
|
|
246
273
|
fetch-depth: 0
|
|
247
|
-
- uses: benner/commit-guard@v0.
|
|
274
|
+
- uses: benner/commit-guard@v0.14.0
|
|
248
275
|
with:
|
|
249
276
|
range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}
|
|
250
277
|
disable: signed-off,signature
|
|
@@ -263,7 +290,7 @@ Add to your `.pre-commit-config.yaml`:
|
|
|
263
290
|
---
|
|
264
291
|
repos:
|
|
265
292
|
- repo: https://github.com/benner/commit-guard
|
|
266
|
-
rev: v0.
|
|
293
|
+
rev: v0.14.0
|
|
267
294
|
hooks:
|
|
268
295
|
- id: commit-guard
|
|
269
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,27 +138,35 @@ 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
|
|
|
@@ -163,12 +177,16 @@ def check_imperative(desc, result):
|
|
|
163
177
|
return
|
|
164
178
|
first = tokens[0]
|
|
165
179
|
if _NON_IMPERATIVE_SUFFIX_RE.search(first):
|
|
166
|
-
result.error(
|
|
180
|
+
result.error(
|
|
181
|
+
f"expected imperative verb, got '{first}' (non-imperative suffix)",
|
|
182
|
+
check=Check.IMPERATIVE,
|
|
183
|
+
)
|
|
167
184
|
return
|
|
168
185
|
base = wordnet.morphy(first, wordnet.VERB)
|
|
169
186
|
if base is not None and base != first:
|
|
170
187
|
result.error(
|
|
171
|
-
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,
|
|
172
190
|
)
|
|
173
191
|
return
|
|
174
192
|
tagged = nltk.pos_tag(["to", *tokens])
|
|
@@ -177,23 +195,24 @@ def check_imperative(desc, result):
|
|
|
177
195
|
return
|
|
178
196
|
result.error(
|
|
179
197
|
f"expected imperative verb, got '{tagged[1][0]}' (POS={tagged[1][1]})",
|
|
198
|
+
check=Check.IMPERATIVE,
|
|
180
199
|
)
|
|
181
200
|
|
|
182
201
|
|
|
183
202
|
def check_body(lines, result):
|
|
184
203
|
if len(lines) < 3: # noqa: PLR2004
|
|
185
|
-
result.error("missing body")
|
|
204
|
+
result.error("missing body", check=Check.BODY)
|
|
186
205
|
return
|
|
187
206
|
if lines[1].strip():
|
|
188
|
-
result.error("missing blank line between subject and body")
|
|
207
|
+
result.error("missing blank line between subject and body", check=Check.BODY)
|
|
189
208
|
body_lines = [ln for ln in lines[2:] if not _TRAILER_RE.match(ln)]
|
|
190
209
|
if not any(ln.strip() for ln in body_lines):
|
|
191
|
-
result.error("missing body")
|
|
210
|
+
result.error("missing body", check=Check.BODY)
|
|
192
211
|
|
|
193
212
|
|
|
194
213
|
def check_signed_off(message, result):
|
|
195
214
|
if not SIGNED_OFF_RE.search(message):
|
|
196
|
-
result.error("missing 'Signed-off-by' trailer")
|
|
215
|
+
result.error("missing 'Signed-off-by' trailer", check=Check.SIGNED_OFF)
|
|
197
216
|
|
|
198
217
|
|
|
199
218
|
def check_required_trailers(message, required, result):
|
|
@@ -212,12 +231,12 @@ def check_signature(rev, result):
|
|
|
212
231
|
timeout=GIT_TIMEOUT,
|
|
213
232
|
)
|
|
214
233
|
if proc.returncode != 0:
|
|
215
|
-
result.error("commit is not signed (GPG/SSH)")
|
|
234
|
+
result.error("commit is not signed (GPG/SSH)", check=Check.SIGNATURE)
|
|
216
235
|
return
|
|
217
236
|
|
|
218
237
|
output = proc.stderr.lower()
|
|
219
238
|
sig_type = "SSH" if "ssh" in output else "GPG"
|
|
220
|
-
result.info(f"signature type: {sig_type}")
|
|
239
|
+
result.info(f"signature type: {sig_type}", check=Check.SIGNATURE)
|
|
221
240
|
|
|
222
241
|
|
|
223
242
|
def _get_message(rev):
|
|
@@ -266,6 +285,7 @@ class Args:
|
|
|
266
285
|
allow_empty: bool
|
|
267
286
|
include_merges: bool
|
|
268
287
|
required_trailers: list
|
|
288
|
+
output: OutputFormat
|
|
269
289
|
|
|
270
290
|
|
|
271
291
|
def _resolve_enabled(args, config, parser):
|
|
@@ -409,6 +429,12 @@ def _parse_args():
|
|
|
409
429
|
default=False,
|
|
410
430
|
help="include merge commits when checking a range (default: excluded)",
|
|
411
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
|
+
)
|
|
412
438
|
args = parser.parse_args()
|
|
413
439
|
config = _load_config()
|
|
414
440
|
enabled = _resolve_enabled(args, config, parser)
|
|
@@ -454,12 +480,28 @@ def _parse_args():
|
|
|
454
480
|
allow_empty=args.allow_empty,
|
|
455
481
|
include_merges=args.include_merges,
|
|
456
482
|
required_trailers=required_trailers,
|
|
483
|
+
output=OutputFormat(args.output),
|
|
457
484
|
)
|
|
458
485
|
|
|
459
486
|
|
|
460
|
-
def
|
|
461
|
-
|
|
462
|
-
|
|
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")
|
|
463
505
|
|
|
464
506
|
if result.ok:
|
|
465
507
|
sys.stderr.write(" \033[32m✓\033[0m all checks passed\n")
|
|
@@ -507,13 +549,21 @@ def main():
|
|
|
507
549
|
failed = False
|
|
508
550
|
for rev in revs:
|
|
509
551
|
message = _strip_comments(_get_message(rev))
|
|
510
|
-
|
|
552
|
+
subject = message.split("\n")[0]
|
|
511
553
|
result = Result()
|
|
512
554
|
_run_checks(args, rev, message, result)
|
|
513
|
-
if
|
|
514
|
-
|
|
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
|
|
515
562
|
return 1 if failed else 0
|
|
516
563
|
|
|
564
|
+
subject = args.message.split("\n")[0]
|
|
517
565
|
result = Result()
|
|
518
566
|
_run_checks(args, args.rev, args.message, result)
|
|
519
|
-
|
|
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,7 +16,8 @@ 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,
|
|
21
23
|
_resolve_required_trailers,
|
|
@@ -173,7 +175,7 @@ class TestCheckSubject:
|
|
|
173
175
|
r = Result()
|
|
174
176
|
check_subject("fix: add x", r, min_description_length=6)
|
|
175
177
|
assert not r.ok
|
|
176
|
-
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)
|
|
177
179
|
|
|
178
180
|
def test_min_description_length_exact_passes(self):
|
|
179
181
|
r = Result()
|
|
@@ -285,7 +287,7 @@ class TestCheckRequiredTrailers:
|
|
|
285
287
|
r = Result()
|
|
286
288
|
check_required_trailers("fix: add x\n\nbody", ["Closes"], r)
|
|
287
289
|
assert not r.ok
|
|
288
|
-
assert "missing required trailer: Closes" in r.errors[0][
|
|
290
|
+
assert "missing required trailer: Closes" in r.errors[0][2]
|
|
289
291
|
|
|
290
292
|
def test_multiple_all_present_passes(self):
|
|
291
293
|
r = Result()
|
|
@@ -304,7 +306,7 @@ class TestCheckRequiredTrailers:
|
|
|
304
306
|
r,
|
|
305
307
|
)
|
|
306
308
|
assert not r.ok
|
|
307
|
-
assert any("Reviewed-by" in msg for _, msg in r.errors)
|
|
309
|
+
assert any("Reviewed-by" in msg for _, _, msg in r.errors)
|
|
308
310
|
|
|
309
311
|
def test_case_sensitive(self):
|
|
310
312
|
r = Result()
|
|
@@ -447,7 +449,7 @@ class TestCheckSignature:
|
|
|
447
449
|
with patch("git_commit_guard.subprocess.run", return_value=proc):
|
|
448
450
|
check_signature("abc123", r)
|
|
449
451
|
assert r.ok
|
|
450
|
-
assert any("GPG" in msg for _, msg in r.errors)
|
|
452
|
+
assert any("GPG" in msg for _, _, msg in r.errors)
|
|
451
453
|
|
|
452
454
|
def test_ssh_signed_commit(self):
|
|
453
455
|
r = Result()
|
|
@@ -455,7 +457,7 @@ class TestCheckSignature:
|
|
|
455
457
|
with patch("git_commit_guard.subprocess.run", return_value=proc):
|
|
456
458
|
check_signature("abc123", r)
|
|
457
459
|
assert r.ok
|
|
458
|
-
assert any("SSH" in msg for _, msg in r.errors)
|
|
460
|
+
assert any("SSH" in msg for _, _, msg in r.errors)
|
|
459
461
|
|
|
460
462
|
|
|
461
463
|
class TestGetMessage:
|
|
@@ -604,27 +606,78 @@ class TestParseChecks:
|
|
|
604
606
|
class TestReport:
|
|
605
607
|
def test_all_passed(self, capsys):
|
|
606
608
|
r = Result()
|
|
607
|
-
ret =
|
|
609
|
+
ret = _report_text(r)
|
|
608
610
|
assert ret == 0
|
|
609
611
|
assert "all checks passed" in capsys.readouterr().err
|
|
610
612
|
|
|
611
613
|
def test_with_error(self, capsys):
|
|
612
614
|
r = Result()
|
|
613
615
|
r.error("something broke")
|
|
614
|
-
ret =
|
|
616
|
+
ret = _report_text(r)
|
|
615
617
|
assert ret == 1
|
|
616
618
|
assert "something broke" in capsys.readouterr().err
|
|
617
619
|
|
|
618
620
|
def test_with_warning_returns_zero(self, capsys):
|
|
619
621
|
r = Result()
|
|
620
622
|
r.warn("heads up")
|
|
621
|
-
ret =
|
|
623
|
+
ret = _report_text(r)
|
|
622
624
|
assert ret == 0
|
|
623
625
|
captured = capsys.readouterr().err
|
|
624
626
|
assert "heads up" in captured
|
|
625
627
|
assert "all checks passed" in captured
|
|
626
628
|
|
|
627
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
|
+
|
|
628
681
|
_VALID_MSG = "fix: add thing\n\nbody text\n\nSigned-off-by: A User <a@b.com>"
|
|
629
682
|
|
|
630
683
|
|
|
@@ -1268,3 +1321,71 @@ class TestRequireTrailerIntegration:
|
|
|
1268
1321
|
),
|
|
1269
1322
|
):
|
|
1270
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
|