git-commit-guard 0.13.0__tar.gz → 0.14.1__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.
@@ -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@cad366366cd6d2691f7c36ff5e7a9999279906dd # v0.12.0
25
+ uses: benner/commit-guard@ccb4e549f589787e294199688f20b5cb5fcac1e1 # v0.14.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.13.0
3
+ Version: 0.14.1
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
@@ -31,10 +31,9 @@ imperative verb.
31
31
 
32
32
  ```bash
33
33
  $ commit-guard
34
- ✗ subject does not match 'type(scope): description':
35
- Merge pull request #5 from fix/branch
36
- missing 'Signed-off-by' trailer
37
- ✗ commit is not signed (GPG/SSH)
34
+ [subject] subject does not match 'type(scope): description': WIP
35
+ [signed-off] missing 'Signed-off-by' trailer
36
+ [signature] commit is not signed (GPG/SSH)
38
37
  ```
39
38
 
40
39
  ## Installation
@@ -224,6 +223,33 @@ misconfigured range specs in CI. Use `--allow-empty` to exit 0 instead:
224
223
  commit-guard --range origin/main..HEAD --allow-empty
225
224
  ```
226
225
 
226
+ ### Machine-readable output
227
+
228
+ Use `--output jsonl` to emit one JSON line per commit to stdout instead of the
229
+ default human-readable text on stderr:
230
+
231
+ ```bash
232
+ commit-guard --range origin/main..HEAD --output jsonl
233
+ ```
234
+
235
+ Each line is a JSON object:
236
+
237
+ ```json
238
+ {
239
+ "sha": "abc1234...",
240
+ "subject": "feat: add thing",
241
+ "ok": false,
242
+ "results": [{"check": "body", "level": "error", "message": "missing body"}]
243
+ }
244
+ ```
245
+
246
+ `sha` is `null` when reading from a file or stdin. `results` is empty when all
247
+ checks pass. Pipe to `jq` for filtering:
248
+
249
+ ```bash
250
+ commit-guard --range origin/main..HEAD --output jsonl | jq 'select(.ok == false)'
251
+ ```
252
+
227
253
  ### GitHub Actions
228
254
 
229
255
  ```yaml
@@ -231,7 +257,7 @@ steps:
231
257
  - uses: actions/checkout@v4
232
258
  with:
233
259
  fetch-depth: 0
234
- - uses: benner/commit-guard@v0.13.0
260
+ - uses: benner/commit-guard@v0.14.1
235
261
  ```
236
262
 
237
263
  Check all commits in a pull request:
@@ -247,7 +273,7 @@ jobs:
247
273
  - uses: actions/checkout@v4
248
274
  with:
249
275
  fetch-depth: 0
250
- - uses: benner/commit-guard@v0.13.0
276
+ - uses: benner/commit-guard@v0.14.1
251
277
  with:
252
278
  range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}
253
279
  ```
@@ -265,7 +291,7 @@ jobs:
265
291
  - uses: actions/checkout@v4
266
292
  with:
267
293
  fetch-depth: 0
268
- - uses: benner/commit-guard@v0.13.0
294
+ - uses: benner/commit-guard@v0.14.1
269
295
  with:
270
296
  range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}
271
297
  disable: signed-off,signature
@@ -284,7 +310,7 @@ Add to your `.pre-commit-config.yaml`:
284
310
  ---
285
311
  repos:
286
312
  - repo: https://github.com/benner/commit-guard
287
- rev: v0.13.0
313
+ rev: v0.14.1
288
314
  hooks:
289
315
  - id: commit-guard
290
316
  - id: commit-guard-signature
@@ -10,10 +10,9 @@ imperative verb.
10
10
 
11
11
  ```bash
12
12
  $ commit-guard
13
- ✗ subject does not match 'type(scope): description':
14
- Merge pull request #5 from fix/branch
15
- missing 'Signed-off-by' trailer
16
- ✗ commit is not signed (GPG/SSH)
13
+ [subject] subject does not match 'type(scope): description': WIP
14
+ [signed-off] missing 'Signed-off-by' trailer
15
+ [signature] commit is not signed (GPG/SSH)
17
16
  ```
18
17
 
19
18
  ## Installation
@@ -203,6 +202,33 @@ misconfigured range specs in CI. Use `--allow-empty` to exit 0 instead:
203
202
  commit-guard --range origin/main..HEAD --allow-empty
204
203
  ```
205
204
 
205
+ ### Machine-readable output
206
+
207
+ Use `--output jsonl` to emit one JSON line per commit to stdout instead of the
208
+ default human-readable text on stderr:
209
+
210
+ ```bash
211
+ commit-guard --range origin/main..HEAD --output jsonl
212
+ ```
213
+
214
+ Each line is a JSON object:
215
+
216
+ ```json
217
+ {
218
+ "sha": "abc1234...",
219
+ "subject": "feat: add thing",
220
+ "ok": false,
221
+ "results": [{"check": "body", "level": "error", "message": "missing body"}]
222
+ }
223
+ ```
224
+
225
+ `sha` is `null` when reading from a file or stdin. `results` is empty when all
226
+ checks pass. Pipe to `jq` for filtering:
227
+
228
+ ```bash
229
+ commit-guard --range origin/main..HEAD --output jsonl | jq 'select(.ok == false)'
230
+ ```
231
+
206
232
  ### GitHub Actions
207
233
 
208
234
  ```yaml
@@ -210,7 +236,7 @@ steps:
210
236
  - uses: actions/checkout@v4
211
237
  with:
212
238
  fetch-depth: 0
213
- - uses: benner/commit-guard@v0.13.0
239
+ - uses: benner/commit-guard@v0.14.1
214
240
  ```
215
241
 
216
242
  Check all commits in a pull request:
@@ -226,7 +252,7 @@ jobs:
226
252
  - uses: actions/checkout@v4
227
253
  with:
228
254
  fetch-depth: 0
229
- - uses: benner/commit-guard@v0.13.0
255
+ - uses: benner/commit-guard@v0.14.1
230
256
  with:
231
257
  range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}
232
258
  ```
@@ -244,7 +270,7 @@ jobs:
244
270
  - uses: actions/checkout@v4
245
271
  with:
246
272
  fetch-depth: 0
247
- - uses: benner/commit-guard@v0.13.0
273
+ - uses: benner/commit-guard@v0.14.1
248
274
  with:
249
275
  range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}
250
276
  disable: signed-off,signature
@@ -263,7 +289,7 @@ Add to your `.pre-commit-config.yaml`:
263
289
  ---
264
290
  repos:
265
291
  - repo: https://github.com/benner/commit-guard
266
- rev: v0.13.0
292
+ rev: v0.14.1
267
293
  hooks:
268
294
  - id: commit-guard
269
295
  - id: commit-guard-signature
@@ -42,6 +42,9 @@ inputs:
42
42
  description: Include merge commits when checking a range
43
43
  required: false
44
44
  default: 'false'
45
+ require-trailer:
46
+ description: Comma-separated list of required trailers (e.g. Closes,Reviewed-by)
47
+ required: false
45
48
  runs:
46
49
  using: composite
47
50
  steps:
@@ -66,6 +69,7 @@ runs:
66
69
  CG_MIN_DESCRIPTION_LENGTH: ${{ inputs.min-description-length }}
67
70
  CG_ALLOW_EMPTY: ${{ inputs.allow-empty }}
68
71
  CG_INCLUDE_MERGES: ${{ inputs.include-merges }}
72
+ CG_REQUIRE_TRAILER: ${{ inputs.require-trailer }}
69
73
  run: |
70
74
  ARGS=()
71
75
  [[ -n "$CG_REV" ]] && ARGS+=("$CG_REV")
@@ -81,5 +85,6 @@ runs:
81
85
  ARGS+=(--min-description-length "$CG_MIN_DESCRIPTION_LENGTH")
82
86
  [[ "$CG_ALLOW_EMPTY" == "true" ]] && ARGS+=(--allow-empty)
83
87
  [[ "$CG_INCLUDE_MERGES" == "true" ]] && ARGS+=(--include-merges)
88
+ [[ -n "$CG_REQUIRE_TRAILER" ]] && ARGS+=(--require-trailer "$CG_REQUIRE_TRAILER")
84
89
  commit-guard "${ARGS[@]}"
85
90
  shell: bash
@@ -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(f"subject does not match 'type(scope): description': {line}")
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(f"subject too long: {len(line)} > {max_subject_length}")
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(f"description too short: {len(desc)} < {min_description_length}")
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(f"expected imperative verb, got '{first}' (non-imperative suffix)")
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 _report(result):
461
- for level, msg in result.errors:
462
- sys.stderr.write(f" {PREFIXES[level]} {msg}\n")
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
- sys.stderr.write(f"{rev[:7]} {message.split('\n')[0]}\n")
552
+ subject = message.split("\n")[0]
511
553
  result = Result()
512
554
  _run_checks(args, rev, message, result)
513
- if _report(result) != 0:
514
- failed = True
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
- return _report(result)
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
- _report,
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][1]
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 = _report(r)
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 = _report(r)
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 = _report(r)
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