git-commit-guard 0.9.0__tar.gz → 0.11.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.11.0/.github/workflows/release.yml +52 -0
  2. {git_commit_guard-0.9.0 → git_commit_guard-0.11.0}/PKG-INFO +38 -15
  3. {git_commit_guard-0.9.0 → git_commit_guard-0.11.0}/README.md +37 -14
  4. {git_commit_guard-0.9.0 → git_commit_guard-0.11.0}/src/git_commit_guard/__init__.py +104 -14
  5. {git_commit_guard-0.9.0 → git_commit_guard-0.11.0}/tests/test_git_commit_guard.py +272 -0
  6. git_commit_guard-0.9.0/htmlcov/.gitignore +0 -2
  7. git_commit_guard-0.9.0/htmlcov/class_index.html +0 -161
  8. git_commit_guard-0.9.0/htmlcov/coverage_html_cb_dd2e7eb5.js +0 -735
  9. git_commit_guard-0.9.0/htmlcov/favicon_32_cb_c827f16f.png +0 -0
  10. git_commit_guard-0.9.0/htmlcov/function_index.html +0 -331
  11. git_commit_guard-0.9.0/htmlcov/index.html +0 -117
  12. git_commit_guard-0.9.0/htmlcov/keybd_closed_cb_900cfef5.png +0 -0
  13. git_commit_guard-0.9.0/htmlcov/status.json +0 -1
  14. git_commit_guard-0.9.0/htmlcov/style_cb_9ff733b0.css +0 -389
  15. git_commit_guard-0.9.0/htmlcov/z_262b75d81d1cf686___init___py.html +0 -454
  16. {git_commit_guard-0.9.0 → git_commit_guard-0.11.0}/.github/workflows/lint-commits.yml +0 -0
  17. {git_commit_guard-0.9.0 → git_commit_guard-0.11.0}/.github/workflows/lint-md.yml +0 -0
  18. {git_commit_guard-0.9.0 → git_commit_guard-0.11.0}/.github/workflows/lint-python.yml +0 -0
  19. {git_commit_guard-0.9.0 → git_commit_guard-0.11.0}/.github/workflows/test.yml +0 -0
  20. {git_commit_guard-0.9.0 → git_commit_guard-0.11.0}/.gitignore +0 -0
  21. {git_commit_guard-0.9.0 → git_commit_guard-0.11.0}/.pre-commit-hooks.yaml +0 -0
  22. {git_commit_guard-0.9.0 → git_commit_guard-0.11.0}/.python-version +0 -0
  23. {git_commit_guard-0.9.0 → git_commit_guard-0.11.0}/LICENSE +0 -0
  24. {git_commit_guard-0.9.0 → git_commit_guard-0.11.0}/pyproject.toml +0 -0
  25. {git_commit_guard-0.9.0 → git_commit_guard-0.11.0}/ruff.toml +0 -0
  26. {git_commit_guard-0.9.0 → git_commit_guard-0.11.0}/tests/__init__.py +0 -0
  27. {git_commit_guard-0.9.0 → git_commit_guard-0.11.0}/uv.lock +0 -0
@@ -0,0 +1,52 @@
1
+ ---
2
+ name: Release
3
+ on: # yamllint disable-line rule:truthy
4
+ push:
5
+ tags:
6
+ - 'v*'
7
+ permissions:
8
+ contents: write
9
+ id-token: write
10
+ jobs:
11
+ release:
12
+ runs-on: ubuntu-latest
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
+ fetch-depth: 0
20
+ - name: Set up Python
21
+ # yamllint disable-line rule:line-length
22
+ uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
23
+ - name: Set up uv
24
+ # yamllint disable-line rule:line-length
25
+ uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
26
+ with:
27
+ enable-cache: false
28
+ - name: Build package
29
+ run: uv build
30
+ - name: Publish to PyPI
31
+ # yamllint disable-line rule:line-length
32
+ uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
33
+ - name: Generate release notes
34
+ id: git-cliff
35
+ # yamllint disable-line rule:line-length
36
+ uses: orhun/git-cliff-action@c93ef52f3d0ddcdcc9bd5447d98d458a11cd4f72 # v4.7.1
37
+ with:
38
+ args: --current
39
+ - name: Create GitHub Release
40
+ env:
41
+ GH_TOKEN: ${{ github.token }}
42
+ TAG: ${{ github.ref_name }}
43
+ NOTES_FILE: ${{ steps.git-cliff.outputs.changelog }}
44
+ run: |-
45
+ gh release create "$TAG" \
46
+ --title "$TAG" \
47
+ --notes-file "$NOTES_FILE"
48
+ - name: Upload dist to GitHub Release
49
+ env:
50
+ GH_TOKEN: ${{ github.token }}
51
+ TAG: ${{ github.ref_name }}
52
+ run: gh release upload "$TAG" dist/*
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: git-commit-guard
3
- Version: 0.9.0
3
+ Version: 0.11.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
@@ -110,6 +110,13 @@ The default maximum subject line length is 72 characters. Override with
110
110
  commit-guard --max-subject-length 100
111
111
  ```
112
112
 
113
+ By default there is no minimum description length. Enforce one with
114
+ `--min-description-length`:
115
+
116
+ ```bash
117
+ commit-guard --min-description-length 10
118
+ ```
119
+
113
120
  ### Type validation
114
121
 
115
122
  By default the standard conventional commit types are accepted. Use `--types`
@@ -143,9 +150,9 @@ commit-guard --scopes auth,api --require-scope
143
150
  ### Configuration file
144
151
 
145
152
  Place `.commit-guard.toml` in your project root (or any parent directory) to
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.
153
+ 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.
149
156
 
150
157
  ```toml
151
158
  # .commit-guard.toml
@@ -154,6 +161,7 @@ scopes = ["auth", "api", "db"]
154
161
  require-scope = true
155
162
  types = ["feat", "fix", "chore", "wip"]
156
163
  max-subject-length = 100
164
+ min-description-length = 10
157
165
  ```
158
166
 
159
167
  ```toml
@@ -162,21 +170,36 @@ enable = ["subject", "imperative"]
162
170
  ```
163
171
 
164
172
  CLI flags (`--enable`, `--disable`, `--scopes`, `--require-scope`, `--types`,
165
- `--max-subject-length`) take full precedence and ignore config file values when
166
- provided.
173
+ `--max-subject-length`, `--min-description-length`) take full precedence and
174
+ ignore config file values when provided.
167
175
 
168
176
  ### Checking a range of commits
169
177
 
178
+ Use `--range` to check all commits in a revision range. All commits are
179
+ checked and a single non-zero exit code is returned if any fail:
180
+
181
+ ```bash
182
+ # check all commits in a PR
183
+ commit-guard --range origin/main..HEAD
184
+
185
+ # check between two tags
186
+ commit-guard --range v1.0..v2.0
187
+
188
+ # only subject checks on a range
189
+ commit-guard --range origin/main..HEAD --enable subject,imperative
190
+ ```
191
+
192
+ Merge commits are excluded by default. Use `--include-merges` to check them:
193
+
194
+ ```bash
195
+ commit-guard --range origin/main..HEAD --include-merges
196
+ ```
197
+
198
+ An empty range (no commits) exits non-zero by default — this catches
199
+ misconfigured range specs in CI. Use `--allow-empty` to exit 0 instead:
200
+
170
201
  ```bash
171
- # all non-merge commits between tags
172
- git rev-list --no-merges v1.0..v2.0 | while read -r rev; do
173
- commit-guard "$rev" || git log -1 --oneline "$rev"
174
- done
175
-
176
- # only subject checks on a PR range
177
- git rev-list --no-merges origin/main..HEAD | while read -r rev; do
178
- commit-guard "$rev" --enable subject,imperative
179
- done
202
+ commit-guard --range origin/main..HEAD --allow-empty
180
203
  ```
181
204
 
182
205
  ### pre-commit
@@ -89,6 +89,13 @@ The default maximum subject line length is 72 characters. Override with
89
89
  commit-guard --max-subject-length 100
90
90
  ```
91
91
 
92
+ By default there is no minimum description length. Enforce one with
93
+ `--min-description-length`:
94
+
95
+ ```bash
96
+ commit-guard --min-description-length 10
97
+ ```
98
+
92
99
  ### Type validation
93
100
 
94
101
  By default the standard conventional commit types are accepted. Use `--types`
@@ -122,9 +129,9 @@ commit-guard --scopes auth,api --require-scope
122
129
  ### Configuration file
123
130
 
124
131
  Place `.commit-guard.toml` in your project root (or any parent directory) to
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.
132
+ 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.
128
135
 
129
136
  ```toml
130
137
  # .commit-guard.toml
@@ -133,6 +140,7 @@ scopes = ["auth", "api", "db"]
133
140
  require-scope = true
134
141
  types = ["feat", "fix", "chore", "wip"]
135
142
  max-subject-length = 100
143
+ min-description-length = 10
136
144
  ```
137
145
 
138
146
  ```toml
@@ -141,21 +149,36 @@ enable = ["subject", "imperative"]
141
149
  ```
142
150
 
143
151
  CLI flags (`--enable`, `--disable`, `--scopes`, `--require-scope`, `--types`,
144
- `--max-subject-length`) take full precedence and ignore config file values when
145
- provided.
152
+ `--max-subject-length`, `--min-description-length`) take full precedence and
153
+ ignore config file values when provided.
146
154
 
147
155
  ### Checking a range of commits
148
156
 
157
+ Use `--range` to check all commits in a revision range. All commits are
158
+ checked and a single non-zero exit code is returned if any fail:
159
+
160
+ ```bash
161
+ # check all commits in a PR
162
+ commit-guard --range origin/main..HEAD
163
+
164
+ # check between two tags
165
+ commit-guard --range v1.0..v2.0
166
+
167
+ # only subject checks on a range
168
+ commit-guard --range origin/main..HEAD --enable subject,imperative
169
+ ```
170
+
171
+ Merge commits are excluded by default. Use `--include-merges` to check them:
172
+
173
+ ```bash
174
+ commit-guard --range origin/main..HEAD --include-merges
175
+ ```
176
+
177
+ An empty range (no commits) exits non-zero by default — this catches
178
+ misconfigured range specs in CI. Use `--allow-empty` to exit 0 instead:
179
+
149
180
  ```bash
150
- # all non-merge commits between tags
151
- git rev-list --no-merges v1.0..v2.0 | while read -r rev; do
152
- commit-guard "$rev" || git log -1 --oneline "$rev"
153
- done
154
-
155
- # only subject checks on a PR range
156
- git rev-list --no-merges origin/main..HEAD | while read -r rev; do
157
- commit-guard "$rev" --enable subject,imperative
158
- done
181
+ commit-guard --range origin/main..HEAD --allow-empty
159
182
  ```
160
183
 
161
184
  ### pre-commit
@@ -120,12 +120,13 @@ def _strip_comments(message):
120
120
  )
121
121
 
122
122
 
123
- def check_subject( # noqa: PLR0913 Too many arguments in function definition (6 > 5)
123
+ def check_subject( # noqa: PLR0913 Too many arguments in function definition (7 > 5)
124
124
  line,
125
125
  result,
126
126
  allowed_scopes=frozenset(),
127
127
  allowed_types=TYPES,
128
128
  max_subject_length=MAX_SUBJECT_LEN,
129
+ min_description_length=0,
129
130
  *,
130
131
  require_scope=False,
131
132
  ):
@@ -150,6 +151,8 @@ def check_subject( # noqa: PLR0913 Too many arguments in function definition (6
150
151
  result.error("description must not end with period")
151
152
  if len(line) > max_subject_length:
152
153
  result.error(f"subject too long: {len(line)} > {max_subject_length}")
154
+ if min_description_length > 0 and len(desc) < min_description_length:
155
+ result.error(f"description too short: {len(desc)} < {min_description_length}")
153
156
  return desc
154
157
 
155
158
 
@@ -224,6 +227,23 @@ def _get_message(rev):
224
227
  sys.exit(f"git error: {stderr}")
225
228
 
226
229
 
230
+ def _get_range_revs(rev_range, *, include_merges=False):
231
+ cmd = ["git", "log", "--format=%H"]
232
+ if not include_merges:
233
+ cmd.append("--no-merges")
234
+ cmd.append(rev_range)
235
+ try:
236
+ output = subprocess.check_output( # noqa: S603
237
+ cmd,
238
+ text=True,
239
+ stderr=subprocess.PIPE,
240
+ timeout=GIT_TIMEOUT,
241
+ ).strip()
242
+ except subprocess.CalledProcessError as e:
243
+ sys.exit(f"git error: {e.stderr.strip()}")
244
+ return output.split("\n") if output else []
245
+
246
+
227
247
  @dataclass
228
248
  class Args:
229
249
  rev: str | None
@@ -233,6 +253,10 @@ class Args:
233
253
  require_scope: bool
234
254
  allowed_types: frozenset
235
255
  max_subject_length: int
256
+ min_description_length: int
257
+ rev_range: str | None
258
+ allow_empty: bool
259
+ include_merges: bool
236
260
 
237
261
 
238
262
  def _resolve_enabled(args, config, parser):
@@ -259,6 +283,14 @@ def _resolve_max_subject_length(args, config):
259
283
  return MAX_SUBJECT_LEN
260
284
 
261
285
 
286
+ def _resolve_min_description_length(args, config):
287
+ if args.min_description_length is not None:
288
+ return args.min_description_length
289
+ if "min-description-length" in config:
290
+ return config["min-description-length"]
291
+ return 0
292
+
293
+
262
294
  def _resolve_types(args, config):
263
295
  if args.types:
264
296
  return frozenset(t.strip() for t in args.types.split(","))
@@ -330,14 +362,50 @@ def _parse_args():
330
362
  metavar="N",
331
363
  help=f"maximum subject line length (default: {MAX_SUBJECT_LEN})",
332
364
  )
365
+ parser.add_argument(
366
+ "--min-description-length",
367
+ type=int,
368
+ default=None,
369
+ metavar="N",
370
+ help="minimum description length in characters (default: 0, off)",
371
+ )
372
+ parser.add_argument(
373
+ "--range",
374
+ dest="rev_range",
375
+ metavar="REF..REF",
376
+ help="check all commits in the given revision range",
377
+ )
378
+ parser.add_argument(
379
+ "--allow-empty",
380
+ action="store_true",
381
+ default=False,
382
+ help="exit 0 when --range yields no commits (default: exit 1)",
383
+ )
384
+ parser.add_argument(
385
+ "--include-merges",
386
+ action="store_true",
387
+ default=False,
388
+ help="include merge commits when checking a range (default: excluded)",
389
+ )
333
390
  args = parser.parse_args()
334
391
  config = _load_config()
335
392
  enabled = _resolve_enabled(args, config, parser)
336
393
  allowed_scopes, require_scope = _resolve_scopes(args, config)
337
394
  allowed_types = _resolve_types(args, config)
338
395
  max_subject_length = _resolve_max_subject_length(args, config)
396
+ min_description_length = _resolve_min_description_length(args, config)
339
397
 
340
- if args.message_file:
398
+ if args.allow_empty and not args.rev_range:
399
+ parser.error("--allow-empty requires --range")
400
+ if args.include_merges and not args.rev_range:
401
+ parser.error("--include-merges requires --range")
402
+
403
+ if args.rev_range:
404
+ if args.rev is not None or args.message_file:
405
+ parser.error("--range cannot be combined with rev or --message-file")
406
+ rev = None
407
+ message = ""
408
+ elif args.message_file:
341
409
  rev = None
342
410
  message = _strip_comments(args.message_file.read_text().strip())
343
411
  elif args.rev:
@@ -358,6 +426,10 @@ def _parse_args():
358
426
  require_scope=require_scope,
359
427
  allowed_types=allowed_types,
360
428
  max_subject_length=max_subject_length,
429
+ min_description_length=min_description_length,
430
+ rev_range=args.rev_range,
431
+ allow_empty=args.allow_empty,
432
+ include_merges=args.include_merges,
361
433
  )
362
434
 
363
435
 
@@ -371,15 +443,8 @@ def _report(result):
371
443
  return 0 if result.ok else 1
372
444
 
373
445
 
374
- def main():
375
- args = _parse_args()
376
- lines = args.message.split("\n")
377
-
378
- if Check.IMPERATIVE in args.enabled:
379
- _ensure_nltk_data()
380
-
381
- result = Result()
382
-
446
+ def _run_checks(args, rev, message, result):
447
+ lines = message.split("\n")
383
448
  desc = None
384
449
  if Check.SUBJECT in args.enabled:
385
450
  desc = check_subject(
@@ -388,6 +453,7 @@ def main():
388
453
  args.allowed_scopes,
389
454
  args.allowed_types,
390
455
  args.max_subject_length,
456
+ args.min_description_length,
391
457
  require_scope=args.require_scope,
392
458
  )
393
459
  if Check.IMPERATIVE in args.enabled:
@@ -399,8 +465,32 @@ def main():
399
465
  if Check.BODY in args.enabled:
400
466
  check_body(lines, result)
401
467
  if Check.SIGNED_OFF in args.enabled:
402
- check_signed_off(args.message, result)
403
- if Check.SIGNATURE in args.enabled and args.rev:
404
- check_signature(args.rev, result)
468
+ check_signed_off(message, result)
469
+ if Check.SIGNATURE in args.enabled and rev:
470
+ check_signature(rev, result)
471
+
472
+
473
+ def main():
474
+ args = _parse_args()
405
475
 
476
+ if Check.IMPERATIVE in args.enabled:
477
+ _ensure_nltk_data()
478
+
479
+ if args.rev_range:
480
+ revs = _get_range_revs(args.rev_range, include_merges=args.include_merges)
481
+ if not revs:
482
+ sys.stderr.write("no commits in range\n")
483
+ return 0 if args.allow_empty else 1
484
+ failed = False
485
+ for rev in revs:
486
+ message = _strip_comments(_get_message(rev))
487
+ sys.stderr.write(f"{rev[:7]} {message.split('\n')[0]}\n")
488
+ result = Result()
489
+ _run_checks(args, rev, message, result)
490
+ if _report(result) != 0:
491
+ failed = True
492
+ return 1 if failed else 0
493
+
494
+ result = Result()
495
+ _run_checks(args, args.rev, args.message, result)
406
496
  return _report(result)