git-commit-guard 0.12.0__tar.gz → 0.13.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@cad366366cd6d2691f7c36ff5e7a9999279906dd # v0.12.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.13.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
 
@@ -209,7 +231,7 @@ steps:
209
231
  - uses: actions/checkout@v4
210
232
  with:
211
233
  fetch-depth: 0
212
- - uses: benner/commit-guard@vX.Y.Z
234
+ - uses: benner/commit-guard@v0.13.0
213
235
  ```
214
236
 
215
237
  Check all commits in a pull request:
@@ -225,7 +247,7 @@ jobs:
225
247
  - uses: actions/checkout@v4
226
248
  with:
227
249
  fetch-depth: 0
228
- - uses: benner/commit-guard@vX.Y.Z
250
+ - uses: benner/commit-guard@v0.13.0
229
251
  with:
230
252
  range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}
231
253
  ```
@@ -243,12 +265,13 @@ jobs:
243
265
  - uses: actions/checkout@v4
244
266
  with:
245
267
  fetch-depth: 0
246
- - uses: benner/commit-guard@vX.Y.Z
268
+ - uses: benner/commit-guard@v0.13.0
247
269
  with:
248
270
  range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}
249
271
  disable: signed-off,signature
250
272
  scopes: auth,api,db
251
273
  require-scope: 'true'
274
+ require-trailer: 'Closes,Reviewed-by'
252
275
  max-subject-length: '100'
253
276
  min-description-length: '10'
254
277
  ```
@@ -261,7 +284,7 @@ Add to your `.pre-commit-config.yaml`:
261
284
  ---
262
285
  repos:
263
286
  - repo: https://github.com/benner/commit-guard
264
- rev: v0.1.0
287
+ rev: v0.13.0
265
288
  hooks:
266
289
  - id: commit-guard
267
290
  - 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
 
@@ -188,7 +210,7 @@ steps:
188
210
  - uses: actions/checkout@v4
189
211
  with:
190
212
  fetch-depth: 0
191
- - uses: benner/commit-guard@vX.Y.Z
213
+ - uses: benner/commit-guard@v0.13.0
192
214
  ```
193
215
 
194
216
  Check all commits in a pull request:
@@ -204,7 +226,7 @@ jobs:
204
226
  - uses: actions/checkout@v4
205
227
  with:
206
228
  fetch-depth: 0
207
- - uses: benner/commit-guard@vX.Y.Z
229
+ - uses: benner/commit-guard@v0.13.0
208
230
  with:
209
231
  range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}
210
232
  ```
@@ -222,12 +244,13 @@ jobs:
222
244
  - uses: actions/checkout@v4
223
245
  with:
224
246
  fetch-depth: 0
225
- - uses: benner/commit-guard@vX.Y.Z
247
+ - uses: benner/commit-guard@v0.13.0
226
248
  with:
227
249
  range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}
228
250
  disable: signed-off,signature
229
251
  scopes: auth,api,db
230
252
  require-scope: 'true'
253
+ require-trailer: 'Closes,Reviewed-by'
231
254
  max-subject-length: '100'
232
255
  min-description-length: '10'
233
256
  ```
@@ -240,7 +263,7 @@ Add to your `.pre-commit-config.yaml`:
240
263
  ---
241
264
  repos:
242
265
  - repo: https://github.com/benner/commit-guard
243
- rev: v0.1.0
266
+ rev: v0.13.0
244
267
  hooks:
245
268
  - id: commit-guard
246
269
  - id: commit-guard-signature
@@ -157,6 +157,7 @@ def check_subject( # noqa: PLR0913 Too many arguments in function definition (7
157
157
 
158
158
 
159
159
  def check_imperative(desc, result):
160
+ _ensure_nltk_data()
160
161
  tokens = nltk.word_tokenize(desc.lower())
161
162
  if not tokens:
162
163
  return
@@ -195,6 +196,13 @@ def check_signed_off(message, result):
195
196
  result.error("missing 'Signed-off-by' trailer")
196
197
 
197
198
 
199
+ def check_required_trailers(message, required, result):
200
+ for trailer in required:
201
+ pattern = re.compile(rf"^{re.escape(trailer)}:\s+\S", re.MULTILINE)
202
+ if not pattern.search(message):
203
+ result.error(f"missing required trailer: {trailer}")
204
+
205
+
198
206
  def check_signature(rev, result):
199
207
  proc = subprocess.run( # noqa: S603
200
208
  ["git", "verify-commit", rev], # noqa: S607
@@ -257,6 +265,7 @@ class Args:
257
265
  rev_range: str | None
258
266
  allow_empty: bool
259
267
  include_merges: bool
268
+ required_trailers: list
260
269
 
261
270
 
262
271
  def _resolve_enabled(args, config, parser):
@@ -291,6 +300,14 @@ def _resolve_min_description_length(args, config):
291
300
  return 0
292
301
 
293
302
 
303
+ def _resolve_required_trailers(args, config):
304
+ if args.require_trailer:
305
+ return [t.strip() for t in args.require_trailer.split(",")]
306
+ if config.get("require-trailers"):
307
+ return list(config["require-trailers"])
308
+ return []
309
+
310
+
294
311
  def _resolve_types(args, config):
295
312
  if args.types:
296
313
  return frozenset(t.strip() for t in args.types.split(","))
@@ -381,6 +398,11 @@ def _parse_args():
381
398
  default=False,
382
399
  help="exit 0 when --range yields no commits (default: exit 1)",
383
400
  )
401
+ parser.add_argument(
402
+ "--require-trailer",
403
+ metavar="TRAILER[,TRAILER,...]",
404
+ help="require these trailers in the commit message",
405
+ )
384
406
  parser.add_argument(
385
407
  "--include-merges",
386
408
  action="store_true",
@@ -394,6 +416,7 @@ def _parse_args():
394
416
  allowed_types = _resolve_types(args, config)
395
417
  max_subject_length = _resolve_max_subject_length(args, config)
396
418
  min_description_length = _resolve_min_description_length(args, config)
419
+ required_trailers = _resolve_required_trailers(args, config)
397
420
 
398
421
  if args.allow_empty and not args.rev_range:
399
422
  parser.error("--allow-empty requires --range")
@@ -430,6 +453,7 @@ def _parse_args():
430
453
  rev_range=args.rev_range,
431
454
  allow_empty=args.allow_empty,
432
455
  include_merges=args.include_merges,
456
+ required_trailers=required_trailers,
433
457
  )
434
458
 
435
459
 
@@ -466,6 +490,8 @@ def _run_checks(args, rev, message, result):
466
490
  check_body(lines, result)
467
491
  if Check.SIGNED_OFF in args.enabled:
468
492
  check_signed_off(message, result)
493
+ if args.required_trailers:
494
+ check_required_trailers(message, args.required_trailers, result)
469
495
  if Check.SIGNATURE in args.enabled and rev:
470
496
  check_signature(rev, result)
471
497
 
@@ -473,9 +499,6 @@ def _run_checks(args, rev, message, result):
473
499
  def main():
474
500
  args = _parse_args()
475
501
 
476
- if Check.IMPERATIVE in args.enabled:
477
- _ensure_nltk_data()
478
-
479
502
  if args.rev_range:
480
503
  revs = _get_range_revs(args.rev_range, include_merges=args.include_merges)
481
504
  if not revs:
@@ -18,10 +18,12 @@ from git_commit_guard import (
18
18
  _report,
19
19
  _resolve_max_subject_length,
20
20
  _resolve_min_description_length,
21
+ _resolve_required_trailers,
21
22
  _resolve_types,
22
23
  _strip_comments,
23
24
  check_body,
24
25
  check_imperative,
26
+ check_required_trailers,
25
27
  check_signature,
26
28
  check_signed_off,
27
29
  check_subject,
@@ -273,6 +275,83 @@ class TestCheckSignedOff:
273
275
  assert not r.ok
274
276
 
275
277
 
278
+ class TestCheckRequiredTrailers:
279
+ def test_present_passes(self):
280
+ r = Result()
281
+ check_required_trailers("fix: add x\n\nbody\n\nCloses: #42", ["Closes"], r)
282
+ assert r.ok
283
+
284
+ def test_missing_fails(self):
285
+ r = Result()
286
+ check_required_trailers("fix: add x\n\nbody", ["Closes"], r)
287
+ assert not r.ok
288
+ assert "missing required trailer: Closes" in r.errors[0][1]
289
+
290
+ def test_multiple_all_present_passes(self):
291
+ r = Result()
292
+ check_required_trailers(
293
+ "fix: add x\n\nbody\n\nCloses: #42\nReviewed-by: Jane",
294
+ ["Closes", "Reviewed-by"],
295
+ r,
296
+ )
297
+ assert r.ok
298
+
299
+ def test_multiple_one_missing_fails(self):
300
+ r = Result()
301
+ check_required_trailers(
302
+ "fix: add x\n\nbody\n\nCloses: #42",
303
+ ["Closes", "Reviewed-by"],
304
+ r,
305
+ )
306
+ assert not r.ok
307
+ assert any("Reviewed-by" in msg for _, msg in r.errors)
308
+
309
+ def test_case_sensitive(self):
310
+ r = Result()
311
+ check_required_trailers("fix: add x\n\nbody\n\ncloses: #42", ["Closes"], r)
312
+ assert not r.ok
313
+
314
+ def test_empty_required_list_always_passes(self):
315
+ r = Result()
316
+ check_required_trailers("fix: add x", [], r)
317
+ assert r.ok
318
+
319
+
320
+ class TestResolveRequiredTrailers:
321
+ def test_defaults_to_empty(self):
322
+ assert _resolve_required_trailers(Namespace(require_trailer=None), {}) == []
323
+
324
+ def test_cli_flag_single(self):
325
+ result = _resolve_required_trailers(Namespace(require_trailer="Closes"), {})
326
+ assert result == ["Closes"]
327
+
328
+ def test_cli_flag_multiple(self):
329
+ result = _resolve_required_trailers(
330
+ Namespace(require_trailer="Closes,Reviewed-by"), {}
331
+ )
332
+ assert result == ["Closes", "Reviewed-by"]
333
+
334
+ def test_cli_flag_strips_spaces(self):
335
+ result = _resolve_required_trailers(
336
+ Namespace(require_trailer="Closes, Reviewed-by"), {}
337
+ )
338
+ assert result == ["Closes", "Reviewed-by"]
339
+
340
+ def test_config(self):
341
+ result = _resolve_required_trailers(
342
+ Namespace(require_trailer=None),
343
+ {"require-trailers": ["Closes", "Reviewed-by"]},
344
+ )
345
+ assert result == ["Closes", "Reviewed-by"]
346
+
347
+ def test_cli_overrides_config(self):
348
+ result = _resolve_required_trailers(
349
+ Namespace(require_trailer="Fixes"),
350
+ {"require-trailers": ["Closes"]},
351
+ )
352
+ assert result == ["Fixes"]
353
+
354
+
276
355
  class TestCheckImperative:
277
356
  def test_imperative_verb_passes(self):
278
357
  r = Result()
@@ -1103,3 +1182,89 @@ class TestGetRangeRevs:
1103
1182
  pytest.raises(SystemExit, match="git error"),
1104
1183
  ):
1105
1184
  _get_range_revs("bogus")
1185
+
1186
+
1187
+ class TestRequireTrailerIntegration:
1188
+ def test_require_trailer_flag_passes(self, tmp_path):
1189
+ f = tmp_path / "msg"
1190
+ f.write_text(
1191
+ "fix: add thing\n\nbody\n\nCloses: #42\nSigned-off-by: A <a@b.com>"
1192
+ )
1193
+ argv = [
1194
+ "cg",
1195
+ "--message-file",
1196
+ str(f),
1197
+ "--disable",
1198
+ "signature,imperative",
1199
+ "--require-trailer",
1200
+ "Closes",
1201
+ ]
1202
+ with patch("sys.argv", argv):
1203
+ assert main() == 0
1204
+
1205
+ def test_require_trailer_flag_fails(self, tmp_path):
1206
+ f = tmp_path / "msg"
1207
+ f.write_text("fix: add thing\n\nbody\n\nSigned-off-by: A <a@b.com>")
1208
+ argv = [
1209
+ "cg",
1210
+ "--message-file",
1211
+ str(f),
1212
+ "--disable",
1213
+ "signature,imperative",
1214
+ "--require-trailer",
1215
+ "Closes",
1216
+ ]
1217
+ with patch("sys.argv", argv):
1218
+ assert main() == 1
1219
+
1220
+ def test_require_trailer_multiple_passes(self, tmp_path):
1221
+ f = tmp_path / "msg"
1222
+ f.write_text(
1223
+ "fix: add thing\n\nbody\n\n"
1224
+ "Closes: #42\nReviewed-by: Jane\nSigned-off-by: A <a@b.com>"
1225
+ )
1226
+ argv = [
1227
+ "cg",
1228
+ "--message-file",
1229
+ str(f),
1230
+ "--disable",
1231
+ "signature,imperative",
1232
+ "--require-trailer",
1233
+ "Closes,Reviewed-by",
1234
+ ]
1235
+ with patch("sys.argv", argv):
1236
+ assert main() == 0
1237
+
1238
+ def test_require_trailer_from_config(self, tmp_path):
1239
+ f = tmp_path / "msg"
1240
+ f.write_text("fix: add thing\n\nbody\n\nSigned-off-by: A <a@b.com>")
1241
+ argv = ["cg", "--message-file", str(f), "--disable", "signature,imperative"]
1242
+ with (
1243
+ patch("sys.argv", argv),
1244
+ patch(
1245
+ "git_commit_guard._load_config",
1246
+ return_value={"require-trailers": ["Closes"]},
1247
+ ),
1248
+ ):
1249
+ assert main() == 1
1250
+
1251
+ def test_require_trailer_cli_overrides_config(self, tmp_path):
1252
+ f = tmp_path / "msg"
1253
+ f.write_text("fix: add thing\n\nbody\n\nFixes: #99\nSigned-off-by: A <a@b.com>")
1254
+ argv = [
1255
+ "cg",
1256
+ "--message-file",
1257
+ str(f),
1258
+ "--disable",
1259
+ "signature,imperative",
1260
+ "--require-trailer",
1261
+ "Fixes",
1262
+ ]
1263
+ with (
1264
+ patch("sys.argv", argv),
1265
+ patch(
1266
+ "git_commit_guard._load_config",
1267
+ return_value={"require-trailers": ["Closes"]},
1268
+ ),
1269
+ ):
1270
+ assert main() == 0