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.
@@ -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
- env:
36
- BASE_REF: ${{ github.base_ref }}
37
- run: |-
38
- commits=$(git log --no-merges --format="%H" "origin/$BASE_REF"..HEAD)
39
- failed=0
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.12.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`, and `min-description-length`. commit-guard searches
155
- upward from the working directory and uses the first file found.
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 full precedence and
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@vX.Y.Z
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@vX.Y.Z
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@vX.Y.Z
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.1.0
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`, and `min-description-length`. commit-guard searches
134
- upward from the working directory and uses the first file found.
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 full precedence and
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@vX.Y.Z
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@vX.Y.Z
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@vX.Y.Z
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.1.0
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(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
 
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(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
+ )
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 _report(result):
437
- for level, msg in result.errors:
438
- 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")
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
- sys.stderr.write(f"{rev[:7]} {message.split('\n')[0]}\n")
552
+ subject = message.split("\n")[0]
488
553
  result = Result()
489
554
  _run_checks(args, rev, message, result)
490
- if _report(result) != 0:
491
- 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
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
- 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,13 +16,16 @@ 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,
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 = _report(r)
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 = _report(r)
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 = _report(r)
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