git-commit-guard 0.8.0__tar.gz → 0.10.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.
Files changed (27) hide show
  1. git_commit_guard-0.10.0/.github/workflows/lint-python.yml +24 -0
  2. git_commit_guard-0.10.0/.github/workflows/release.yml +33 -0
  3. {git_commit_guard-0.8.0 → git_commit_guard-0.10.0}/PKG-INFO +57 -16
  4. {git_commit_guard-0.8.0 → git_commit_guard-0.10.0}/README.md +56 -15
  5. git_commit_guard-0.10.0/ruff.toml +13 -0
  6. {git_commit_guard-0.8.0 → git_commit_guard-0.10.0}/src/git_commit_guard/__init__.py +134 -19
  7. {git_commit_guard-0.8.0 → git_commit_guard-0.10.0}/tests/test_git_commit_guard.py +358 -3
  8. git_commit_guard-0.8.0/htmlcov/.gitignore +0 -2
  9. git_commit_guard-0.8.0/htmlcov/class_index.html +0 -161
  10. git_commit_guard-0.8.0/htmlcov/coverage_html_cb_dd2e7eb5.js +0 -735
  11. git_commit_guard-0.8.0/htmlcov/favicon_32_cb_c827f16f.png +0 -0
  12. git_commit_guard-0.8.0/htmlcov/function_index.html +0 -331
  13. git_commit_guard-0.8.0/htmlcov/index.html +0 -117
  14. git_commit_guard-0.8.0/htmlcov/keybd_closed_cb_900cfef5.png +0 -0
  15. git_commit_guard-0.8.0/htmlcov/status.json +0 -1
  16. git_commit_guard-0.8.0/htmlcov/style_cb_9ff733b0.css +0 -389
  17. git_commit_guard-0.8.0/htmlcov/z_262b75d81d1cf686___init___py.html +0 -454
  18. {git_commit_guard-0.8.0 → git_commit_guard-0.10.0}/.github/workflows/lint-commits.yml +0 -0
  19. {git_commit_guard-0.8.0 → git_commit_guard-0.10.0}/.github/workflows/lint-md.yml +0 -0
  20. {git_commit_guard-0.8.0 → git_commit_guard-0.10.0}/.github/workflows/test.yml +0 -0
  21. {git_commit_guard-0.8.0 → git_commit_guard-0.10.0}/.gitignore +0 -0
  22. {git_commit_guard-0.8.0 → git_commit_guard-0.10.0}/.pre-commit-hooks.yaml +0 -0
  23. {git_commit_guard-0.8.0 → git_commit_guard-0.10.0}/.python-version +0 -0
  24. {git_commit_guard-0.8.0 → git_commit_guard-0.10.0}/LICENSE +0 -0
  25. {git_commit_guard-0.8.0 → git_commit_guard-0.10.0}/pyproject.toml +0 -0
  26. {git_commit_guard-0.8.0 → git_commit_guard-0.10.0}/tests/__init__.py +0 -0
  27. {git_commit_guard-0.8.0 → git_commit_guard-0.10.0}/uv.lock +0 -0
@@ -0,0 +1,24 @@
1
+ ---
2
+ name: Lint Python
3
+ on: # yamllint disable-line rule:truthy
4
+ pull_request:
5
+ permissions:
6
+ contents: read
7
+ jobs:
8
+ lint-python:
9
+ runs-on: ubuntu-latest
10
+ permissions:
11
+ checks: write
12
+ pull-requests: write
13
+ steps:
14
+ - name: Checkout code
15
+ # yamllint disable-line rule:line-length
16
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
17
+ with:
18
+ persist-credentials: false
19
+ - name: Run ruff
20
+ # yamllint disable-line rule:line-length
21
+ uses: benner/action-ruff@b36eb590165f0ea36700bb92de15235c121f24f0 # v0.1.0
22
+ with:
23
+ fail_level: error
24
+ reporter: github-pr-review
@@ -0,0 +1,33 @@
1
+ ---
2
+ name: Release
3
+ on: # yamllint disable-line rule:truthy
4
+ push:
5
+ tags:
6
+ - 'v*'
7
+ permissions:
8
+ contents: write
9
+ jobs:
10
+ release:
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - name: Checkout code
14
+ # yamllint disable-line rule:line-length
15
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
16
+ with:
17
+ persist-credentials: false
18
+ fetch-depth: 0
19
+ - name: Generate release notes
20
+ id: git-cliff
21
+ # yamllint disable-line rule:line-length
22
+ uses: orhun/git-cliff-action@c93ef52f3d0ddcdcc9bd5447d98d458a11cd4f72 # v4.7.1
23
+ with:
24
+ args: --current
25
+ - name: Create GitHub Release
26
+ env:
27
+ GH_TOKEN: ${{ github.token }}
28
+ TAG: ${{ github.ref_name }}
29
+ NOTES_FILE: ${{ steps.git-cliff.outputs.changelog }}
30
+ run: |-
31
+ gh release create "$TAG" \
32
+ --title "$TAG" \
33
+ --notes-file "$NOTES_FILE"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: git-commit-guard
3
- Version: 0.8.0
3
+ Version: 0.10.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
@@ -101,6 +101,28 @@ Available checks:
101
101
  * `signed-off` - `Signed-off-by:` trailer exists
102
102
  * `signature` - Verify GPG or SSH signature
103
103
 
104
+ ### Subject length
105
+
106
+ The default maximum subject line length is 72 characters. Override with
107
+ `--max-subject-length`:
108
+
109
+ ```bash
110
+ commit-guard --max-subject-length 100
111
+ ```
112
+
113
+ ### Type validation
114
+
115
+ By default the standard conventional commit types are accepted. Use `--types`
116
+ to replace the allowed set entirely:
117
+
118
+ ```bash
119
+ # restrict to a subset
120
+ commit-guard --types feat,fix,chore
121
+
122
+ # add a project-specific type
123
+ commit-guard --types feat,fix,docs,style,refactor,perf,test,build,ci,chore,revert,wip
124
+ ```
125
+
104
126
  ### Scope validation
105
127
 
106
128
  By default any scope is accepted and scope is optional. Use `--scopes` to
@@ -121,15 +143,17 @@ commit-guard --scopes auth,api --require-scope
121
143
  ### Configuration file
122
144
 
123
145
  Place `.commit-guard.toml` in your project root (or any parent directory) to
124
- set defaults for `enable`, `disable`, `scopes`, and `require-scope`.
125
- commit-guard searches upward from the working directory and uses the first file
126
- found.
146
+ set defaults for `enable`, `disable`, `scopes`, `require-scope`, `types`, and
147
+ `max-subject-length`. commit-guard searches upward from the working directory
148
+ and uses the first file found.
127
149
 
128
150
  ```toml
129
151
  # .commit-guard.toml
130
152
  disable = ["signature", "body"]
131
153
  scopes = ["auth", "api", "db"]
132
154
  require-scope = true
155
+ types = ["feat", "fix", "chore", "wip"]
156
+ max-subject-length = 100
133
157
  ```
134
158
 
135
159
  ```toml
@@ -137,21 +161,37 @@ require-scope = true
137
161
  enable = ["subject", "imperative"]
138
162
  ```
139
163
 
140
- CLI flags (`--enable`, `--disable`, `--scopes`, `--require-scope`) take full
141
- precedence and ignore config file values when provided.
164
+ CLI flags (`--enable`, `--disable`, `--scopes`, `--require-scope`, `--types`,
165
+ `--max-subject-length`) take full precedence and ignore config file values when
166
+ provided.
142
167
 
143
168
  ### Checking a range of commits
144
169
 
170
+ Use `--range` to check all commits in a revision range. All commits are
171
+ checked and a single non-zero exit code is returned if any fail:
172
+
173
+ ```bash
174
+ # check all commits in a PR
175
+ commit-guard --range origin/main..HEAD
176
+
177
+ # check between two tags
178
+ commit-guard --range v1.0..v2.0
179
+
180
+ # only subject checks on a range
181
+ commit-guard --range origin/main..HEAD --enable subject,imperative
182
+ ```
183
+
184
+ Merge commits are excluded by default. Use `--include-merges` to check them:
185
+
145
186
  ```bash
146
- # all non-merge commits between tags
147
- git rev-list --no-merges v1.0..v2.0 | while read -r rev; do
148
- commit-guard "$rev" || git log -1 --oneline "$rev"
149
- done
187
+ commit-guard --range origin/main..HEAD --include-merges
188
+ ```
150
189
 
151
- # only subject checks on a PR range
152
- git rev-list --no-merges origin/main..HEAD | while read -r rev; do
153
- commit-guard "$rev" --enable subject,imperative
154
- done
190
+ An empty range (no commits) exits non-zero by default — this catches
191
+ misconfigured range specs in CI. Use `--allow-empty` to exit 0 instead:
192
+
193
+ ```bash
194
+ commit-guard --range origin/main..HEAD --allow-empty
155
195
  ```
156
196
 
157
197
  ### pre-commit
@@ -206,8 +246,9 @@ body
206
246
  trailers
207
247
  ```
208
248
 
209
- Supported types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`,
210
- `build`, `ci`, `chore`, `revert`.
249
+ Default types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`,
250
+ `build`, `ci`, `chore`, `revert`. Override with `--types` or the `types` config
251
+ key.
211
252
 
212
253
  Scope is optional. Mark breaking changes with `!` before
213
254
  the colon.
@@ -80,6 +80,28 @@ Available checks:
80
80
  * `signed-off` - `Signed-off-by:` trailer exists
81
81
  * `signature` - Verify GPG or SSH signature
82
82
 
83
+ ### Subject length
84
+
85
+ The default maximum subject line length is 72 characters. Override with
86
+ `--max-subject-length`:
87
+
88
+ ```bash
89
+ commit-guard --max-subject-length 100
90
+ ```
91
+
92
+ ### Type validation
93
+
94
+ By default the standard conventional commit types are accepted. Use `--types`
95
+ to replace the allowed set entirely:
96
+
97
+ ```bash
98
+ # restrict to a subset
99
+ commit-guard --types feat,fix,chore
100
+
101
+ # add a project-specific type
102
+ commit-guard --types feat,fix,docs,style,refactor,perf,test,build,ci,chore,revert,wip
103
+ ```
104
+
83
105
  ### Scope validation
84
106
 
85
107
  By default any scope is accepted and scope is optional. Use `--scopes` to
@@ -100,15 +122,17 @@ commit-guard --scopes auth,api --require-scope
100
122
  ### Configuration file
101
123
 
102
124
  Place `.commit-guard.toml` in your project root (or any parent directory) to
103
- set defaults for `enable`, `disable`, `scopes`, and `require-scope`.
104
- commit-guard searches upward from the working directory and uses the first file
105
- found.
125
+ set defaults for `enable`, `disable`, `scopes`, `require-scope`, `types`, and
126
+ `max-subject-length`. commit-guard searches upward from the working directory
127
+ and uses the first file found.
106
128
 
107
129
  ```toml
108
130
  # .commit-guard.toml
109
131
  disable = ["signature", "body"]
110
132
  scopes = ["auth", "api", "db"]
111
133
  require-scope = true
134
+ types = ["feat", "fix", "chore", "wip"]
135
+ max-subject-length = 100
112
136
  ```
113
137
 
114
138
  ```toml
@@ -116,21 +140,37 @@ require-scope = true
116
140
  enable = ["subject", "imperative"]
117
141
  ```
118
142
 
119
- CLI flags (`--enable`, `--disable`, `--scopes`, `--require-scope`) take full
120
- precedence and ignore config file values when provided.
143
+ CLI flags (`--enable`, `--disable`, `--scopes`, `--require-scope`, `--types`,
144
+ `--max-subject-length`) take full precedence and ignore config file values when
145
+ provided.
121
146
 
122
147
  ### Checking a range of commits
123
148
 
149
+ Use `--range` to check all commits in a revision range. All commits are
150
+ checked and a single non-zero exit code is returned if any fail:
151
+
152
+ ```bash
153
+ # check all commits in a PR
154
+ commit-guard --range origin/main..HEAD
155
+
156
+ # check between two tags
157
+ commit-guard --range v1.0..v2.0
158
+
159
+ # only subject checks on a range
160
+ commit-guard --range origin/main..HEAD --enable subject,imperative
161
+ ```
162
+
163
+ Merge commits are excluded by default. Use `--include-merges` to check them:
164
+
124
165
  ```bash
125
- # all non-merge commits between tags
126
- git rev-list --no-merges v1.0..v2.0 | while read -r rev; do
127
- commit-guard "$rev" || git log -1 --oneline "$rev"
128
- done
166
+ commit-guard --range origin/main..HEAD --include-merges
167
+ ```
129
168
 
130
- # only subject checks on a PR range
131
- git rev-list --no-merges origin/main..HEAD | while read -r rev; do
132
- commit-guard "$rev" --enable subject,imperative
133
- done
169
+ An empty range (no commits) exits non-zero by default — this catches
170
+ misconfigured range specs in CI. Use `--allow-empty` to exit 0 instead:
171
+
172
+ ```bash
173
+ commit-guard --range origin/main..HEAD --allow-empty
134
174
  ```
135
175
 
136
176
  ### pre-commit
@@ -185,8 +225,9 @@ body
185
225
  trailers
186
226
  ```
187
227
 
188
- Supported types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`,
189
- `build`, `ci`, `chore`, `revert`.
228
+ Default types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`,
229
+ `build`, `ci`, `chore`, `revert`. Override with `--types` or the `types` config
230
+ key.
190
231
 
191
232
  Scope is optional. Mark breaking changes with `!` before
192
233
  the colon.
@@ -0,0 +1,13 @@
1
+ target-version = "py312"
2
+
3
+ [lint]
4
+ select = ["ALL"]
5
+ ignore = [
6
+ "ANN", # flake8-annotations (ANN)
7
+ "COM812", # missing-trailing-comma
8
+ "D", # pydocstyle (D)
9
+ "RET504", # Unnecessary assignment to X before `return` statement
10
+ ]
11
+
12
+ [lint.per-file-ignores]
13
+ "tests/**" = ["S101"] # assert is expected in tests
@@ -1,13 +1,13 @@
1
1
  import re
2
2
  import subprocess
3
3
  import sys
4
+ import tomllib
4
5
  from argparse import ArgumentParser
5
6
  from dataclasses import dataclass, field
6
7
  from enum import StrEnum
7
8
  from pathlib import Path
8
9
 
9
10
  import nltk
10
- import tomllib
11
11
  from nltk.corpus import wordnet
12
12
 
13
13
  TYPES = frozenset(
@@ -120,13 +120,21 @@ def _strip_comments(message):
120
120
  )
121
121
 
122
122
 
123
- def check_subject(line, result, allowed_scopes=frozenset(), *, require_scope=False):
123
+ def check_subject( # noqa: PLR0913 Too many arguments in function definition (6 > 5)
124
+ line,
125
+ result,
126
+ allowed_scopes=frozenset(),
127
+ allowed_types=TYPES,
128
+ max_subject_length=MAX_SUBJECT_LEN,
129
+ *,
130
+ require_scope=False,
131
+ ):
124
132
  m = SUBJECT_RE.match(line)
125
133
  if not m:
126
134
  result.error(f"subject does not match 'type(scope): description': {line}")
127
135
  return None
128
136
 
129
- if m.group("type") not in TYPES:
137
+ if m.group("type") not in allowed_types:
130
138
  result.error(f"unknown type: {m.group('type')}")
131
139
 
132
140
  scope = m.group("scope")
@@ -140,8 +148,8 @@ def check_subject(line, result, allowed_scopes=frozenset(), *, require_scope=Fal
140
148
  result.error("description must not start with uppercase")
141
149
  if desc.endswith("."):
142
150
  result.error("description must not end with period")
143
- if len(line) > MAX_SUBJECT_LEN:
144
- result.error(f"subject too long: {len(line)} > {MAX_SUBJECT_LEN}")
151
+ if len(line) > max_subject_length:
152
+ result.error(f"subject too long: {len(line)} > {max_subject_length}")
145
153
  return desc
146
154
 
147
155
 
@@ -216,6 +224,23 @@ def _get_message(rev):
216
224
  sys.exit(f"git error: {stderr}")
217
225
 
218
226
 
227
+ def _get_range_revs(rev_range, *, include_merges=False):
228
+ cmd = ["git", "log", "--format=%H"]
229
+ if not include_merges:
230
+ cmd.append("--no-merges")
231
+ cmd.append(rev_range)
232
+ try:
233
+ output = subprocess.check_output( # noqa: S603
234
+ cmd,
235
+ text=True,
236
+ stderr=subprocess.PIPE,
237
+ timeout=GIT_TIMEOUT,
238
+ ).strip()
239
+ except subprocess.CalledProcessError as e:
240
+ sys.exit(f"git error: {e.stderr.strip()}")
241
+ return output.split("\n") if output else []
242
+
243
+
219
244
  @dataclass
220
245
  class Args:
221
246
  rev: str | None
@@ -223,6 +248,11 @@ class Args:
223
248
  enabled: frozenset
224
249
  allowed_scopes: frozenset
225
250
  require_scope: bool
251
+ allowed_types: frozenset
252
+ max_subject_length: int
253
+ rev_range: str | None
254
+ allow_empty: bool
255
+ include_merges: bool
226
256
 
227
257
 
228
258
  def _resolve_enabled(args, config, parser):
@@ -241,6 +271,22 @@ def _resolve_enabled(args, config, parser):
241
271
  return enabled
242
272
 
243
273
 
274
+ def _resolve_max_subject_length(args, config):
275
+ if args.max_subject_length is not None:
276
+ return args.max_subject_length
277
+ if "max-subject-length" in config:
278
+ return config["max-subject-length"]
279
+ return MAX_SUBJECT_LEN
280
+
281
+
282
+ def _resolve_types(args, config):
283
+ if args.types:
284
+ return frozenset(t.strip() for t in args.types.split(","))
285
+ if config.get("types"):
286
+ return frozenset(config["types"])
287
+ return TYPES
288
+
289
+
244
290
  def _resolve_scopes(args, config):
245
291
  if args.scopes:
246
292
  allowed_scopes = frozenset(s.strip() for s in args.scopes.split(","))
@@ -292,12 +338,54 @@ def _parse_args():
292
338
  default=False,
293
339
  help="require a scope in the subject line",
294
340
  )
341
+ parser.add_argument(
342
+ "--types",
343
+ metavar="TYPE[,TYPE,...]",
344
+ help="allowed commit types (replaces defaults when set)",
345
+ )
346
+ parser.add_argument(
347
+ "--max-subject-length",
348
+ type=int,
349
+ default=None,
350
+ metavar="N",
351
+ help=f"maximum subject line length (default: {MAX_SUBJECT_LEN})",
352
+ )
353
+ parser.add_argument(
354
+ "--range",
355
+ dest="rev_range",
356
+ metavar="REF..REF",
357
+ help="check all commits in the given revision range",
358
+ )
359
+ parser.add_argument(
360
+ "--allow-empty",
361
+ action="store_true",
362
+ default=False,
363
+ help="exit 0 when --range yields no commits (default: exit 1)",
364
+ )
365
+ parser.add_argument(
366
+ "--include-merges",
367
+ action="store_true",
368
+ default=False,
369
+ help="include merge commits when checking a range (default: excluded)",
370
+ )
295
371
  args = parser.parse_args()
296
372
  config = _load_config()
297
373
  enabled = _resolve_enabled(args, config, parser)
298
374
  allowed_scopes, require_scope = _resolve_scopes(args, config)
375
+ allowed_types = _resolve_types(args, config)
376
+ max_subject_length = _resolve_max_subject_length(args, config)
377
+
378
+ if args.allow_empty and not args.rev_range:
379
+ parser.error("--allow-empty requires --range")
380
+ if args.include_merges and not args.rev_range:
381
+ parser.error("--include-merges requires --range")
299
382
 
300
- if args.message_file:
383
+ if args.rev_range:
384
+ if args.rev is not None or args.message_file:
385
+ parser.error("--range cannot be combined with rev or --message-file")
386
+ rev = None
387
+ message = ""
388
+ elif args.message_file:
301
389
  rev = None
302
390
  message = _strip_comments(args.message_file.read_text().strip())
303
391
  elif args.rev:
@@ -316,6 +404,11 @@ def _parse_args():
316
404
  enabled=enabled,
317
405
  allowed_scopes=allowed_scopes,
318
406
  require_scope=require_scope,
407
+ allowed_types=allowed_types,
408
+ max_subject_length=max_subject_length,
409
+ rev_range=args.rev_range,
410
+ allow_empty=args.allow_empty,
411
+ include_merges=args.include_merges,
319
412
  )
320
413
 
321
414
 
@@ -329,19 +422,17 @@ def _report(result):
329
422
  return 0 if result.ok else 1
330
423
 
331
424
 
332
- def main():
333
- args = _parse_args()
334
- lines = args.message.split("\n")
335
-
336
- if Check.IMPERATIVE in args.enabled:
337
- _ensure_nltk_data()
338
-
339
- result = Result()
340
-
425
+ def _run_checks(args, rev, message, result):
426
+ lines = message.split("\n")
341
427
  desc = None
342
428
  if Check.SUBJECT in args.enabled:
343
429
  desc = check_subject(
344
- lines[0], result, args.allowed_scopes, require_scope=args.require_scope
430
+ lines[0],
431
+ result,
432
+ args.allowed_scopes,
433
+ args.allowed_types,
434
+ args.max_subject_length,
435
+ require_scope=args.require_scope,
345
436
  )
346
437
  if Check.IMPERATIVE in args.enabled:
347
438
  if desc is None:
@@ -352,8 +443,32 @@ def main():
352
443
  if Check.BODY in args.enabled:
353
444
  check_body(lines, result)
354
445
  if Check.SIGNED_OFF in args.enabled:
355
- check_signed_off(args.message, result)
356
- if Check.SIGNATURE in args.enabled and args.rev:
357
- check_signature(args.rev, result)
446
+ check_signed_off(message, result)
447
+ if Check.SIGNATURE in args.enabled and rev:
448
+ check_signature(rev, result)
449
+
450
+
451
+ def main():
452
+ args = _parse_args()
358
453
 
454
+ if Check.IMPERATIVE in args.enabled:
455
+ _ensure_nltk_data()
456
+
457
+ if args.rev_range:
458
+ revs = _get_range_revs(args.rev_range, include_merges=args.include_merges)
459
+ if not revs:
460
+ sys.stderr.write("no commits in range\n")
461
+ return 0 if args.allow_empty else 1
462
+ failed = False
463
+ for rev in revs:
464
+ message = _strip_comments(_get_message(rev))
465
+ sys.stderr.write(f"{rev[:7]} {message.split('\n')[0]}\n")
466
+ result = Result()
467
+ _run_checks(args, rev, message, result)
468
+ if _report(result) != 0:
469
+ failed = True
470
+ return 1 if failed else 0
471
+
472
+ result = Result()
473
+ _run_checks(args, args.rev, args.message, result)
359
474
  return _report(result)