git-commit-guard 0.4.0__tar.gz → 0.6.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.
@@ -0,0 +1,44 @@
1
+ ---
2
+ name: Lint commits
3
+ on: # yamllint disable-line rule:truthy
4
+ pull_request:
5
+ permissions:
6
+ contents: read
7
+ jobs:
8
+ lint-commits:
9
+ runs-on: ubuntu-latest
10
+ permissions:
11
+ contents: write
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: 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
+ - name: Cache NLTK data
30
+ uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
31
+ with:
32
+ path: ~/nltk_data
33
+ key: nltk-averaged-perceptron-tagger-punkt
34
+ - 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
@@ -0,0 +1,27 @@
1
+ ---
2
+ name: Test
3
+ on: # yamllint disable-line rule:truthy
4
+ pull_request:
5
+ permissions:
6
+ contents: read
7
+ jobs:
8
+ test:
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - name: Checkout code
12
+ # yamllint disable-line rule:line-length
13
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
14
+ with:
15
+ persist-credentials: false
16
+ - name: Install uv
17
+ # yamllint disable-line rule:line-length
18
+ uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
19
+ - name: Cache NLTK data
20
+ uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
21
+ with:
22
+ path: ~/nltk_data
23
+ key: nltk-averaged-perceptron-tagger-punkt
24
+ - name: Run tests with coverage
25
+ run: |-
26
+ uv run --dev pytest \
27
+ tests/ --cov=git_commit_guard --cov-report=term-missing
@@ -1 +1,2 @@
1
1
  __pycache__
2
+ .coverage
@@ -0,0 +1,12 @@
1
+ ---
2
+ - id: commit-guard
3
+ name: commit-guard
4
+ description: >-
5
+ Opinionated conventional commit message linter
6
+ with imperative mood detection
7
+ entry: commit-guard --message-file
8
+ language: python
9
+ stages: [commit-msg]
10
+ always_run: true
11
+ pass_filenames: true
12
+ minimum_pre_commit_version: 2.8.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: git-commit-guard
3
- Version: 0.4.0
3
+ Version: 0.6.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
@@ -27,11 +27,33 @@ Unlike regular expression only tools, commit-guard uses
27
27
  NLP (nltk POS tagging) to verify that commit descriptions start with an
28
28
  imperative verb.
29
29
 
30
+ ## Example
31
+
32
+ ```bash
33
+ $ commit-guard
34
+ ✗ subject does not match 'type(scope): description': Merge pull request #5 from fix/branch
35
+ ✗ missing 'Signed-off-by' trailer
36
+ ✗ commit is not signed (GPG/SSH)
37
+ ```
38
+
30
39
  ## Installation
31
40
 
41
+ From PyPI:
42
+
43
+ ```bash
44
+ uv tool install git-commit-guard
45
+ ```
46
+
47
+ or:
48
+
49
+ ```bash
50
+ pipx install git-commit-guard
51
+ ```
52
+
53
+ From a local clone:
54
+
32
55
  ```bash
33
56
  uv tool install -e .
34
- commit-guard
35
57
  ```
36
58
 
37
59
  During development:
@@ -92,6 +114,32 @@ git rev-list --no-merges origin/main..HEAD | while read -r rev; do
92
114
  done
93
115
  ```
94
116
 
117
+ ### pre-commit
118
+
119
+ Add to your `.pre-commit-config.yaml`:
120
+
121
+ ```yaml
122
+ ---
123
+ repos:
124
+ - repo: https://github.com/benner/commit-guard
125
+ rev: v0.1.0
126
+ hooks:
127
+ - id: commit-guard
128
+ ```
129
+
130
+ Install the hook:
131
+
132
+ ```bash
133
+ pre-commit install --hook-type commit-msg
134
+ ```
135
+
136
+ To selectively enable or disable checks, pass `args`:
137
+
138
+ ```yaml
139
+ - id: commit-guard
140
+ args: ["--enable", "subject,imperative"]
141
+ ```
142
+
95
143
  ## Imperative mood detection
96
144
 
97
145
  commit-guard combines two strategies to detect non-imperative descriptions:
@@ -6,11 +6,33 @@ Unlike regular expression only tools, commit-guard uses
6
6
  NLP (nltk POS tagging) to verify that commit descriptions start with an
7
7
  imperative verb.
8
8
 
9
+ ## Example
10
+
11
+ ```bash
12
+ $ commit-guard
13
+ ✗ subject does not match 'type(scope): description': Merge pull request #5 from fix/branch
14
+ ✗ missing 'Signed-off-by' trailer
15
+ ✗ commit is not signed (GPG/SSH)
16
+ ```
17
+
9
18
  ## Installation
10
19
 
20
+ From PyPI:
21
+
22
+ ```bash
23
+ uv tool install git-commit-guard
24
+ ```
25
+
26
+ or:
27
+
28
+ ```bash
29
+ pipx install git-commit-guard
30
+ ```
31
+
32
+ From a local clone:
33
+
11
34
  ```bash
12
35
  uv tool install -e .
13
- commit-guard
14
36
  ```
15
37
 
16
38
  During development:
@@ -71,6 +93,32 @@ git rev-list --no-merges origin/main..HEAD | while read -r rev; do
71
93
  done
72
94
  ```
73
95
 
96
+ ### pre-commit
97
+
98
+ Add to your `.pre-commit-config.yaml`:
99
+
100
+ ```yaml
101
+ ---
102
+ repos:
103
+ - repo: https://github.com/benner/commit-guard
104
+ rev: v0.1.0
105
+ hooks:
106
+ - id: commit-guard
107
+ ```
108
+
109
+ Install the hook:
110
+
111
+ ```bash
112
+ pre-commit install --hook-type commit-msg
113
+ ```
114
+
115
+ To selectively enable or disable checks, pass `args`:
116
+
117
+ ```yaml
118
+ - id: commit-guard
119
+ args: ["--enable", "subject,imperative"]
120
+ ```
121
+
74
122
  ## Imperative mood detection
75
123
 
76
124
  commit-guard combines two strategies to detect non-imperative descriptions:
@@ -33,3 +33,9 @@ build-backend = "hatchling.build"
33
33
 
34
34
  [tool.hatch.version]
35
35
  source = "vcs"
36
+
37
+ [dependency-groups]
38
+ dev = [
39
+ "pytest>=9.0.2",
40
+ "pytest-cov>=7.1.0",
41
+ ]
@@ -7,6 +7,7 @@ from enum import StrEnum
7
7
  from pathlib import Path
8
8
 
9
9
  import nltk
10
+ from nltk.corpus import wordnet
10
11
 
11
12
  TYPES = frozenset(
12
13
  {
@@ -24,112 +25,7 @@ TYPES = frozenset(
24
25
  }
25
26
  )
26
27
 
27
- IMPERATIVE_VERBS = frozenset(
28
- {
29
- "add",
30
- "allow",
31
- "apply",
32
- "avoid",
33
- "bump",
34
- "change",
35
- "check",
36
- "clean",
37
- "clear",
38
- "configure",
39
- "correct",
40
- "create",
41
- "catch",
42
- "define",
43
- "delete",
44
- "deprecate",
45
- "disable",
46
- "document",
47
- "drop",
48
- "enable",
49
- "enforce",
50
- "ensure",
51
- "exclude",
52
- "export",
53
- "extend",
54
- "extract",
55
- "fix",
56
- "format",
57
- "guard",
58
- "handle",
59
- "ignore",
60
- "implement",
61
- "import",
62
- "improve",
63
- "include",
64
- "increase",
65
- "initialize",
66
- "inline",
67
- "install",
68
- "introduce",
69
- "invalidate",
70
- "limit",
71
- "log",
72
- "make",
73
- "mark",
74
- "merge",
75
- "migrate",
76
- "move",
77
- "normalize",
78
- "open",
79
- "optimize",
80
- "override",
81
- "parse",
82
- "pass",
83
- "patch",
84
- "pin",
85
- "port",
86
- "prevent",
87
- "print",
88
- "provide",
89
- "publish",
90
- "reduce",
91
- "refactor",
92
- "release",
93
- "remove",
94
- "rename",
95
- "reorganize",
96
- "replace",
97
- "require",
98
- "reset",
99
- "resolve",
100
- "restore",
101
- "restrict",
102
- "return",
103
- "revert",
104
- "report",
105
- "run",
106
- "separate",
107
- "set",
108
- "show",
109
- "simplify",
110
- "skip",
111
- "sort",
112
- "split",
113
- "start",
114
- "store",
115
- "stop",
116
- "support",
117
- "suppress",
118
- "switch",
119
- "sync",
120
- "track",
121
- "trim",
122
- "unify",
123
- "update",
124
- "upgrade",
125
- "use",
126
- "wait",
127
- "validate",
128
- "vendor",
129
- "verify",
130
- "wrap",
131
- }
132
- )
28
+ _NON_IMPERATIVE_SUFFIX_RE = re.compile(r"(?:ing|ed)$")
133
29
 
134
30
  SUBJECT_RE = re.compile(
135
31
  r"^(?P<type>\w+)(?:\((?P<scope>[^)]+)\))?!?:\s+(?P<desc>.+)$",
@@ -189,6 +85,7 @@ class Result:
189
85
  def _ensure_nltk_data():
190
86
  _download_if_missing("taggers/averaged_perceptron_tagger_eng")
191
87
  _download_if_missing("tokenizers/punkt_tab")
88
+ _download_if_missing("corpora/wordnet")
192
89
 
193
90
 
194
91
  def _download_if_missing(resource):
@@ -228,12 +125,21 @@ def check_imperative(desc, result):
228
125
  if not tokens:
229
126
  return
230
127
  first = tokens[0]
231
- if first in IMPERATIVE_VERBS:
128
+ if _NON_IMPERATIVE_SUFFIX_RE.search(first):
129
+ result.error(f"expected imperative verb, got '{first}' (non-imperative suffix)")
130
+ return
131
+ base = wordnet.morphy(first, wordnet.VERB)
132
+ if base is not None and base != first:
133
+ result.error(
134
+ f"expected imperative verb, got '{first}' (inflected form of '{base}')"
135
+ )
232
136
  return
233
- tagged = nltk.pos_tag(tokens)
234
- if tagged[0][1] != "VB":
137
+ tagged = nltk.pos_tag(["to", *tokens])
138
+ if tagged[1][1] != "VB":
139
+ if wordnet.morphy(first, wordnet.VERB) == first:
140
+ return
235
141
  result.error(
236
- f"expected imperative verb, got '{tagged[0][0]}' (POS={tagged[0][1]})",
142
+ f"expected imperative verb, got '{tagged[1][0]}' (POS={tagged[1][1]})",
237
143
  )
238
144
 
239
145
 
File without changes
@@ -0,0 +1,464 @@
1
+ import subprocess
2
+ from argparse import ArgumentParser
3
+ from unittest.mock import MagicMock, patch
4
+
5
+ import pytest
6
+ from git_commit_guard import (
7
+ Result,
8
+ _download_if_missing,
9
+ _ensure_nltk_data,
10
+ _get_message,
11
+ _parse_checks,
12
+ _report,
13
+ _strip_comments,
14
+ check_body,
15
+ check_imperative,
16
+ check_signature,
17
+ check_signed_off,
18
+ check_subject,
19
+ main,
20
+ )
21
+
22
+ # ruff: noqa: S101 # Use of `assert` detected
23
+
24
+
25
+ @pytest.fixture(scope="session", autouse=True)
26
+ def nltk_data():
27
+ _ensure_nltk_data()
28
+
29
+
30
+ class TestResult:
31
+ def test_ok_when_empty(self):
32
+ assert Result().ok
33
+
34
+ def test_not_ok_with_error(self):
35
+ r = Result()
36
+ r.error("bad")
37
+ assert not r.ok
38
+
39
+ def test_ok_with_only_warn(self):
40
+ r = Result()
41
+ r.warn("hmm")
42
+ assert r.ok
43
+
44
+ def test_ok_with_only_info(self):
45
+ r = Result()
46
+ r.info("fyi")
47
+ assert r.ok
48
+
49
+
50
+ class TestStripComments:
51
+ def test_removes_comment_lines(self):
52
+ assert _strip_comments("# comment\nfoo") == "foo"
53
+
54
+ def test_keeps_non_comment_lines(self):
55
+ assert _strip_comments("foo\nbar") == "foo\nbar"
56
+
57
+ def test_removes_indented_comments(self):
58
+ assert _strip_comments(" # indented\nfoo") == "foo"
59
+
60
+ def test_empty_string(self):
61
+ assert _strip_comments("") == ""
62
+
63
+
64
+ class TestCheckSubject:
65
+ def test_valid_simple(self):
66
+ r = Result()
67
+ desc = check_subject("fix: add token refresh", r)
68
+ assert desc == "add token refresh"
69
+ assert r.ok
70
+
71
+ def test_valid_with_scope(self):
72
+ r = Result()
73
+ desc = check_subject("feat(auth): add login", r)
74
+ assert desc == "add login"
75
+ assert r.ok
76
+
77
+ def test_valid_breaking_change(self):
78
+ r = Result()
79
+ check_subject("feat!: drop v1 support", r)
80
+ assert r.ok
81
+
82
+ def test_valid_breaking_change_with_scope(self):
83
+ r = Result()
84
+ check_subject("feat(api)!: drop v1", r)
85
+ assert r.ok
86
+
87
+ def test_invalid_format(self):
88
+ r = Result()
89
+ desc = check_subject("not a commit", r)
90
+ assert desc is None
91
+ assert not r.ok
92
+
93
+ def test_unknown_type(self):
94
+ r = Result()
95
+ check_subject("unknown: add thing", r)
96
+ assert not r.ok
97
+
98
+ def test_uppercase_description(self):
99
+ r = Result()
100
+ check_subject("fix: Add token", r)
101
+ assert not r.ok
102
+
103
+ def test_trailing_period(self):
104
+ r = Result()
105
+ check_subject("fix: add token.", r)
106
+ assert not r.ok
107
+
108
+ def test_subject_too_long(self):
109
+ r = Result()
110
+ check_subject("fix: " + "a" * 68, r) # 73 chars total
111
+ assert not r.ok
112
+
113
+ def test_subject_at_max_length(self):
114
+ r = Result()
115
+ check_subject("fix: " + "a" * 67, r) # exactly 72 chars
116
+ assert r.ok
117
+
118
+ @pytest.mark.parametrize(
119
+ "type_",
120
+ [
121
+ "feat",
122
+ "fix",
123
+ "docs",
124
+ "style",
125
+ "refactor",
126
+ "perf",
127
+ "test",
128
+ "build",
129
+ "ci",
130
+ "chore",
131
+ "revert",
132
+ ],
133
+ )
134
+ def test_all_valid_types(self, type_):
135
+ r = Result()
136
+ check_subject(f"{type_}: add thing", r)
137
+ assert r.ok
138
+
139
+
140
+ class TestCheckBody:
141
+ def test_subject_only(self):
142
+ r = Result()
143
+ check_body(["fix: add thing"], r)
144
+ assert not r.ok
145
+
146
+ def test_subject_and_blank_only(self):
147
+ r = Result()
148
+ check_body(["fix: add thing", ""], r)
149
+ assert not r.ok
150
+
151
+ def test_valid_body(self):
152
+ r = Result()
153
+ check_body(["fix: add thing", "", "body text here"], r)
154
+ assert r.ok
155
+
156
+ def test_missing_blank_line(self):
157
+ r = Result()
158
+ check_body(["fix: add thing", "body text", "more"], r)
159
+ assert not r.ok
160
+
161
+ def test_blank_body_content(self):
162
+ r = Result()
163
+ check_body(["fix: add thing", "", " "], r)
164
+ assert not r.ok
165
+
166
+ def test_multiline_body(self):
167
+ r = Result()
168
+ check_body(["fix: add thing", "", "line one", "line two"], r)
169
+ assert r.ok
170
+
171
+
172
+ class TestCheckSignedOff:
173
+ def test_valid(self):
174
+ r = Result()
175
+ check_signed_off(
176
+ "fix: add thing\n\nbody\n\nSigned-off-by: John Doe <john@example.com>",
177
+ r,
178
+ )
179
+ assert r.ok
180
+
181
+ def test_missing(self):
182
+ r = Result()
183
+ check_signed_off("fix: add thing\n\nbody", r)
184
+ assert not r.ok
185
+
186
+ def test_malformed_no_email(self):
187
+ r = Result()
188
+ check_signed_off("fix: add thing\n\nSigned-off-by: John Doe", r)
189
+ assert not r.ok
190
+
191
+
192
+ class TestCheckImperative:
193
+ def test_imperative_verb_passes(self):
194
+ r = Result()
195
+ check_imperative("add token refresh", r)
196
+ assert r.ok
197
+
198
+ def test_ed_suffix_fails(self):
199
+ r = Result()
200
+ check_imperative("added token refresh", r)
201
+ assert not r.ok
202
+
203
+ def test_ing_suffix_fails(self):
204
+ r = Result()
205
+ check_imperative("adding token refresh", r)
206
+ assert not r.ok
207
+
208
+ def test_third_person_fails(self):
209
+ r = Result()
210
+ check_imperative("adds token refresh", r)
211
+ assert not r.ok
212
+
213
+ def test_third_person_es_suffix_fails(self):
214
+ r = Result()
215
+ check_imperative("fixes the bug", r)
216
+ assert not r.ok
217
+
218
+ def test_non_whitelist_imperative_passes(self):
219
+ r = Result()
220
+ check_imperative("refactor authentication module", r)
221
+ assert r.ok
222
+
223
+ def test_write_imperative_passes(self):
224
+ r = Result()
225
+ check_imperative("write unit tests", r)
226
+ assert r.ok
227
+
228
+ def test_tagger_misclassified_verb_passes(self):
229
+ # 'disable' is tagged non-VB by the tagger but wordnet confirms it as a verb
230
+ r = Result()
231
+ check_imperative("disable feature flag", r)
232
+ assert r.ok
233
+
234
+ def test_refactor_passes(self):
235
+ r = Result()
236
+ check_imperative("refactor authentication module", r)
237
+ assert r.ok
238
+
239
+ def test_vendor_passes(self):
240
+ r = Result()
241
+ check_imperative("vendor third-party libs", r)
242
+ assert r.ok
243
+
244
+ def test_configure_passes(self):
245
+ r = Result()
246
+ check_imperative("configure logging pipeline", r)
247
+ assert r.ok
248
+
249
+ def test_empty_desc_passes(self):
250
+ r = Result()
251
+ check_imperative("", r)
252
+ assert r.ok
253
+
254
+
255
+ class TestDownloadIfMissing:
256
+ def test_skips_download_when_present(self):
257
+ with (
258
+ patch("git_commit_guard.nltk.data.find"),
259
+ patch("git_commit_guard.nltk.download") as mock_dl,
260
+ ):
261
+ _download_if_missing("tokenizers/punkt_tab")
262
+ mock_dl.assert_not_called()
263
+
264
+ def test_downloads_when_missing(self):
265
+ with (
266
+ patch("git_commit_guard.nltk.data.find", side_effect=LookupError),
267
+ patch("git_commit_guard.nltk.download") as mock_dl,
268
+ ):
269
+ _download_if_missing("tokenizers/punkt_tab")
270
+ mock_dl.assert_called_once_with("punkt_tab", quiet=True)
271
+
272
+
273
+ class TestCheckSignature:
274
+ def test_unsigned_commit(self):
275
+ r = Result()
276
+ proc = MagicMock(returncode=1)
277
+ with patch("git_commit_guard.subprocess.run", return_value=proc):
278
+ check_signature("abc123", r)
279
+ assert not r.ok
280
+
281
+ def test_gpg_signed_commit(self):
282
+ r = Result()
283
+ proc = MagicMock(returncode=0, stderr="gpg signature verified")
284
+ with patch("git_commit_guard.subprocess.run", return_value=proc):
285
+ check_signature("abc123", r)
286
+ assert r.ok
287
+ assert any("GPG" in msg for _, msg in r.errors)
288
+
289
+ def test_ssh_signed_commit(self):
290
+ r = Result()
291
+ proc = MagicMock(returncode=0, stderr="Good ssh signature")
292
+ with patch("git_commit_guard.subprocess.run", return_value=proc):
293
+ check_signature("abc123", r)
294
+ assert r.ok
295
+ assert any("SSH" in msg for _, msg in r.errors)
296
+
297
+
298
+ class TestGetMessage:
299
+ def test_success(self):
300
+ with patch(
301
+ "git_commit_guard.subprocess.check_output",
302
+ return_value="fix: add thing\n\n",
303
+ ):
304
+ assert _get_message("abc123") == "fix: add thing"
305
+
306
+ def test_unknown_revision(self):
307
+ err = subprocess.CalledProcessError(128, "git")
308
+ err.stderr = "fatal: unknown revision 'abc'"
309
+ with (
310
+ patch("git_commit_guard.subprocess.check_output", side_effect=err),
311
+ pytest.raises(SystemExit, match="no commits yet"),
312
+ ):
313
+ _get_message("abc")
314
+
315
+ def test_ambiguous_argument(self):
316
+ err = subprocess.CalledProcessError(128, "git")
317
+ err.stderr = "fatal: ambiguous argument 'HEAD'"
318
+ with (
319
+ patch("git_commit_guard.subprocess.check_output", side_effect=err),
320
+ pytest.raises(SystemExit, match="no commits yet"),
321
+ ):
322
+ _get_message("HEAD")
323
+
324
+ def test_other_git_error(self):
325
+ err = subprocess.CalledProcessError(128, "git")
326
+ err.stderr = "fatal: not a git repository"
327
+ with (
328
+ patch("git_commit_guard.subprocess.check_output", side_effect=err),
329
+ pytest.raises(SystemExit, match="git error"),
330
+ ):
331
+ _get_message("abc")
332
+
333
+
334
+ class TestParseChecks:
335
+ def test_invalid_check_name(self):
336
+ parser = ArgumentParser()
337
+ with pytest.raises(SystemExit):
338
+ _parse_checks(parser, "invalid")
339
+
340
+
341
+ class TestReport:
342
+ def test_all_passed(self, capsys):
343
+ r = Result()
344
+ ret = _report(r)
345
+ assert ret == 0
346
+ assert "all checks passed" in capsys.readouterr().err
347
+
348
+ def test_with_error(self, capsys):
349
+ r = Result()
350
+ r.error("something broke")
351
+ ret = _report(r)
352
+ assert ret == 1
353
+ assert "something broke" in capsys.readouterr().err
354
+
355
+ def test_with_warning_returns_zero(self, capsys):
356
+ r = Result()
357
+ r.warn("heads up")
358
+ ret = _report(r)
359
+ assert ret == 0
360
+ captured = capsys.readouterr().err
361
+ assert "heads up" in captured
362
+ assert "all checks passed" in captured
363
+
364
+
365
+ _VALID_MSG = "fix: add thing\n\nbody text\n\nSigned-off-by: A User <a@b.com>"
366
+
367
+
368
+ class TestMain:
369
+ def test_from_message_file(self, tmp_path, capsys):
370
+ f = tmp_path / "msg"
371
+ f.write_text(_VALID_MSG)
372
+ with patch(
373
+ "sys.argv",
374
+ ["cg", "--message-file", str(f), "--disable", "signature"],
375
+ ):
376
+ assert main() == 0
377
+ assert "all checks passed" in capsys.readouterr().err
378
+
379
+ def test_from_stdin(self):
380
+ stdin = MagicMock()
381
+ stdin.isatty.return_value = False
382
+ stdin.read.return_value = _VALID_MSG
383
+ with (
384
+ patch("sys.argv", ["cg", "--disable", "signature"]),
385
+ patch("sys.stdin", stdin),
386
+ ):
387
+ assert main() == 0
388
+
389
+ def test_from_rev(self):
390
+ with (
391
+ patch("sys.argv", ["cg", "abc123", "--disable", "signature"]),
392
+ patch("git_commit_guard._get_message", return_value=_VALID_MSG),
393
+ ):
394
+ assert main() == 0
395
+
396
+ def test_from_head(self):
397
+ stdin = MagicMock()
398
+ stdin.isatty.return_value = True
399
+ with (
400
+ patch("sys.argv", ["cg", "--disable", "signature"]),
401
+ patch("sys.stdin", stdin),
402
+ patch("git_commit_guard._get_message", return_value=_VALID_MSG),
403
+ ):
404
+ assert main() == 0
405
+
406
+ def test_enable_flag(self, tmp_path):
407
+ f = tmp_path / "msg"
408
+ f.write_text(_VALID_MSG)
409
+ with patch("sys.argv", ["cg", "--message-file", str(f), "--enable", "subject"]):
410
+ assert main() == 0
411
+
412
+ def test_invalid_message_returns_one(self, tmp_path):
413
+ f = tmp_path / "msg"
414
+ f.write_text("not a valid commit")
415
+ with patch(
416
+ "sys.argv",
417
+ [
418
+ "cg",
419
+ "--message-file",
420
+ str(f),
421
+ "--disable",
422
+ "signature,body,signed-off,imperative",
423
+ ],
424
+ ):
425
+ assert main() == 1
426
+
427
+ def test_signature_skipped_without_rev(self, tmp_path, capsys):
428
+ f = tmp_path / "msg"
429
+ f.write_text(_VALID_MSG)
430
+ with patch(
431
+ "sys.argv",
432
+ ["cg", "--message-file", str(f), "--enable", "signature"],
433
+ ):
434
+ ret = main()
435
+ assert ret == 0
436
+ assert "skipped" in capsys.readouterr().err
437
+
438
+ def test_imperative_only_no_subject_check(self, tmp_path):
439
+ # imperative enabled, subject not — desc starts as None, parsed from line
440
+ f = tmp_path / "msg"
441
+ f.write_text(_VALID_MSG)
442
+ with patch(
443
+ "sys.argv",
444
+ ["cg", "--message-file", str(f), "--enable", "imperative"],
445
+ ):
446
+ assert main() == 0
447
+
448
+ def test_signature_with_rev(self):
449
+ proc = MagicMock(returncode=0, stderr="gpg signature verified")
450
+ with (
451
+ patch("sys.argv", ["cg", "abc123", "--enable", "signature"]),
452
+ patch("git_commit_guard._get_message", return_value=_VALID_MSG),
453
+ patch("git_commit_guard.subprocess.run", return_value=proc),
454
+ ):
455
+ assert main() == 0
456
+
457
+ def test_invalid_check_name_exits(self, tmp_path):
458
+ f = tmp_path / "msg"
459
+ f.write_text(_VALID_MSG)
460
+ with (
461
+ patch("sys.argv", ["cg", "--message-file", str(f), "--enable", "bogus"]),
462
+ pytest.raises(SystemExit),
463
+ ):
464
+ main()
@@ -23,6 +23,90 @@ wheels = [
23
23
  { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
24
24
  ]
25
25
 
26
+ [[package]]
27
+ name = "coverage"
28
+ version = "7.13.5"
29
+ source = { registry = "https://pypi.org/simple" }
30
+ sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" }
31
+ wheels = [
32
+ { url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" },
33
+ { url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" },
34
+ { url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" },
35
+ { url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" },
36
+ { url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" },
37
+ { url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" },
38
+ { url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" },
39
+ { url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" },
40
+ { url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" },
41
+ { url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" },
42
+ { url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" },
43
+ { url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" },
44
+ { url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" },
45
+ { url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" },
46
+ { url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" },
47
+ { url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" },
48
+ { url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" },
49
+ { url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" },
50
+ { url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" },
51
+ { url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" },
52
+ { url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" },
53
+ { url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" },
54
+ { url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" },
55
+ { url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" },
56
+ { url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" },
57
+ { url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" },
58
+ { url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" },
59
+ { url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" },
60
+ { url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" },
61
+ { url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" },
62
+ { url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" },
63
+ { url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" },
64
+ { url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" },
65
+ { url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" },
66
+ { url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" },
67
+ { url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" },
68
+ { url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" },
69
+ { url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" },
70
+ { url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" },
71
+ { url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" },
72
+ { url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" },
73
+ { url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" },
74
+ { url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" },
75
+ { url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" },
76
+ { url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" },
77
+ { url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" },
78
+ { url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" },
79
+ { url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" },
80
+ { url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" },
81
+ { url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" },
82
+ { url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" },
83
+ { url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" },
84
+ { url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" },
85
+ { url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" },
86
+ { url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" },
87
+ { url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" },
88
+ { url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" },
89
+ { url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" },
90
+ { url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" },
91
+ { url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" },
92
+ { url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" },
93
+ { url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" },
94
+ { url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" },
95
+ { url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" },
96
+ { url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" },
97
+ { url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" },
98
+ { url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" },
99
+ { url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" },
100
+ { url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" },
101
+ { url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" },
102
+ { url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" },
103
+ { url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" },
104
+ { url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" },
105
+ { url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" },
106
+ { url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" },
107
+ { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" },
108
+ ]
109
+
26
110
  [[package]]
27
111
  name = "git-commit-guard"
28
112
  source = { editable = "." }
@@ -30,9 +114,30 @@ dependencies = [
30
114
  { name = "nltk" },
31
115
  ]
32
116
 
117
+ [package.dev-dependencies]
118
+ dev = [
119
+ { name = "pytest" },
120
+ { name = "pytest-cov" },
121
+ ]
122
+
33
123
  [package.metadata]
34
124
  requires-dist = [{ name = "nltk", specifier = ">=3.9.3" }]
35
125
 
126
+ [package.metadata.requires-dev]
127
+ dev = [
128
+ { name = "pytest", specifier = ">=9.0.2" },
129
+ { name = "pytest-cov", specifier = ">=7.1.0" },
130
+ ]
131
+
132
+ [[package]]
133
+ name = "iniconfig"
134
+ version = "2.3.0"
135
+ source = { registry = "https://pypi.org/simple" }
136
+ sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
137
+ wheels = [
138
+ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
139
+ ]
140
+
36
141
  [[package]]
37
142
  name = "joblib"
38
143
  version = "1.5.3"
@@ -57,6 +162,63 @@ wheels = [
57
162
  { url = "https://files.pythonhosted.org/packages/c2/7e/9af5a710a1236e4772de8dfcc6af942a561327bb9f42b5b4a24d0cf100fd/nltk-3.9.3-py3-none-any.whl", hash = "sha256:60b3db6e9995b3dd976b1f0fa7dec22069b2677e759c28eb69b62ddd44870522", size = 1525385, upload-time = "2026-02-24T12:05:46.54Z" },
58
163
  ]
59
164
 
165
+ [[package]]
166
+ name = "packaging"
167
+ version = "26.0"
168
+ source = { registry = "https://pypi.org/simple" }
169
+ sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
170
+ wheels = [
171
+ { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
172
+ ]
173
+
174
+ [[package]]
175
+ name = "pluggy"
176
+ version = "1.6.0"
177
+ source = { registry = "https://pypi.org/simple" }
178
+ sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
179
+ wheels = [
180
+ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
181
+ ]
182
+
183
+ [[package]]
184
+ name = "pygments"
185
+ version = "2.19.2"
186
+ source = { registry = "https://pypi.org/simple" }
187
+ sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
188
+ wheels = [
189
+ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
190
+ ]
191
+
192
+ [[package]]
193
+ name = "pytest"
194
+ version = "9.0.2"
195
+ source = { registry = "https://pypi.org/simple" }
196
+ dependencies = [
197
+ { name = "colorama", marker = "sys_platform == 'win32'" },
198
+ { name = "iniconfig" },
199
+ { name = "packaging" },
200
+ { name = "pluggy" },
201
+ { name = "pygments" },
202
+ ]
203
+ sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
204
+ wheels = [
205
+ { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
206
+ ]
207
+
208
+ [[package]]
209
+ name = "pytest-cov"
210
+ version = "7.1.0"
211
+ source = { registry = "https://pypi.org/simple" }
212
+ dependencies = [
213
+ { name = "coverage" },
214
+ { name = "pluggy" },
215
+ { name = "pytest" },
216
+ ]
217
+ sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" }
218
+ wheels = [
219
+ { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
220
+ ]
221
+
60
222
  [[package]]
61
223
  name = "regex"
62
224
  version = "2026.2.28"