commit-check 2.18.2__py3-none-any.whl → 2.19.0__py3-none-any.whl

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.
commit_check/__init__.py CHANGED
@@ -8,7 +8,7 @@ Exports:
8
8
 
9
9
  import os
10
10
  import sys
11
- from importlib.metadata import version, PackageNotFoundError
11
+ from importlib.metadata import PackageNotFoundError, version
12
12
 
13
13
  # Exit codes used across the package
14
14
  PASS = 0
@@ -40,9 +40,7 @@ def supports_color() -> bool:
40
40
  return False
41
41
  if not sys.stdout.isatty():
42
42
  return False
43
- if os.environ.get("TERM") in ("", "dumb"):
44
- return False
45
- return True
43
+ return os.environ.get("TERM") not in ("", "dumb")
46
44
 
47
45
 
48
46
  # ANSI color codes used for CLI output, empty when stdout cannot render color.
@@ -52,10 +52,10 @@ class _Group:
52
52
  """One compiled scan, and what each of its alternatives means.
53
53
 
54
54
  Every pattern that reads the same trailer key is compiled into a single
55
- alternation whose branches are numbered capturing groups, in catalog
56
- order, so a trailer line is read once however many tools the catalog
57
- knows and the most specific pattern still wins. A body marker has no
58
- key, and its group is the pattern by itself.
55
+ alternation whose branches each end in a numbered capturing group, in
56
+ catalog order, so a trailer line is read once however many tools the
57
+ catalog knows and the most specific pattern still wins. A body marker
58
+ has no key, and its group is the pattern by itself.
59
59
  """
60
60
 
61
61
  keys: frozenset[str]
@@ -91,7 +91,10 @@ def _build_groups() -> list[_Group]:
91
91
  groups = []
92
92
  for keys, (values, members) in keyed.items():
93
93
  alternation = "|".join(re.escape(key) for key in sorted(keys))
94
- branches = "|".join(f"(?P<s{i}>{value})" for i, value in enumerate(values))
94
+ # The group is empty and closes the branch. Wrapped around the value
95
+ # instead, it is entered on every line the branch rejects, at a cost
96
+ # that grows with each tool added to the catalog.
97
+ branches = "|".join(f"(?:{value})(?P<s{i}>)" for i, value in enumerate(values))
95
98
  regex = re.compile(
96
99
  rf"^(?:{alternation}):[ \t]*(?:{branches})[ \t]*$",
97
100
  re.IGNORECASE | re.MULTILINE,
@@ -265,6 +265,59 @@ TABBY = KnownAiTool(
265
265
  ],
266
266
  )
267
267
 
268
+ # --- Amp (Sourcegraph) ---
269
+ # Both trailers are on by default and switched off in Amp's settings:
270
+ # https://ampcode.com/docs/cli/settings
271
+ AMP = KnownAiTool(
272
+ name="Amp",
273
+ name_pattern=_names(r"amp|ampcode"),
274
+ patterns=[
275
+ # "Amp" is a short word, so when an address is present it must be
276
+ # Amp's own.
277
+ *_identity(r"Amp(?:[ \t]*<amp@ampcode\.com>)?", "Amp <amp@ampcode.com>"),
278
+ _stamp_trailer("Amp-Thread-ID", "``Amp-Thread-ID:`` trailer"),
279
+ ],
280
+ )
281
+
282
+ # --- OpenHands (All Hands AI) ---
283
+ # Commits as openhands <openhands@all-hands.dev> by default, and stays on as
284
+ # a co-author when the user sets their own identity:
285
+ # https://docs.openhands.dev/openhands/usage/settings/application-settings
286
+ OPENHANDS = KnownAiTool(
287
+ name="OpenHands",
288
+ name_pattern=_names(r"openhands|all-hands"),
289
+ patterns=[
290
+ # By name only: the people who work at All Hands share the agent's
291
+ # all-hands.dev domain.
292
+ *_identity(r"OpenHands[ \t]*(?:<[^>\n]*>)?", "OpenHands"),
293
+ ],
294
+ )
295
+
296
+ # --- Factory Droid ---
297
+ # Adds itself as a co-author by default (includeCoAuthoredByDroid):
298
+ # https://docs.factory.com/droid-cli/settings.md
299
+ FACTORY_DROID = KnownAiTool(
300
+ name="Factory Droid",
301
+ name_pattern=_names(r"factory-droid|droid"),
302
+ patterns=[
303
+ *_identity(r"factory-droid\[bot\][ \t]*(?:<[^>\n]*>)?", "factory-droid[bot]"),
304
+ ],
305
+ )
306
+
307
+ # --- Jules (Google) ---
308
+ # Commits as the google-labs-jules[bot] app, or co-authors with the user
309
+ # when Commit Authoring says so: https://jules.google/docs/changelog/2026-02-19
310
+ # "Jules" alone is a person's name, so only the app's name counts.
311
+ JULES = KnownAiTool(
312
+ name="Jules",
313
+ name_pattern=_names(r"google-labs-jules|jules"),
314
+ patterns=[
315
+ *_identity(
316
+ r"google-labs-jules\[bot\][ \t]*(?:<[^>\n]*>)?", "google-labs-jules[bot]"
317
+ ),
318
+ ],
319
+ )
320
+
268
321
  # --- Generic / catch-all AI patterns ---
269
322
  GENERIC_AI = KnownAiTool(
270
323
  name="Generic AI",
@@ -321,5 +374,9 @@ ALL_KNOWN_TOOLS: list[KnownAiTool] = [
321
374
  AIDER,
322
375
  WINDSURF,
323
376
  TABBY,
377
+ AMP,
378
+ OPENHANDS,
379
+ FACTORY_DROID,
380
+ JULES,
324
381
  GENERIC_AI,
325
382
  ]
commit_check/api.py CHANGED
@@ -29,6 +29,7 @@ Return-value schema (all functions)::
29
29
  "error": "<error description>",
30
30
  "suggest": "<how to fix>",
31
31
  "fix": "<the corrected value when the fix is unambiguous; else empty>",
32
+ "reason": "<why a skipped check did not run, when known; else empty>",
32
33
  },
33
34
  ...
34
35
  ]
@@ -60,7 +61,6 @@ from commit_check.engine import (
60
61
  from commit_check.rule_builder import RuleBuilder
61
62
  from commit_check.rules_catalog import BRANCH_CHECKS, MESSAGE_CHECKS
62
63
 
63
-
64
64
  # ---------------------------------------------------------------------------
65
65
  # Internal helpers
66
66
  # ---------------------------------------------------------------------------
commit_check/config.py CHANGED
@@ -1,13 +1,14 @@
1
1
  """TOML config loader and schema for commit-check."""
2
2
 
3
3
  from __future__ import annotations
4
- from typing import Any
5
- from pathlib import Path
4
+
6
5
  import copy
7
6
  import io
8
7
  import sys
9
- import urllib.request
10
8
  import urllib.error
9
+ import urllib.request
10
+ from pathlib import Path
11
+ from typing import Any
11
12
 
12
13
  try:
13
14
  import tomllib
@@ -125,7 +126,7 @@ class _LazyOpener:
125
126
 
126
127
  @property
127
128
  def handlers(self) -> list[urllib.request.BaseHandler]:
128
- return list(getattr(self._get(), "handlers"))
129
+ return list(self._get().handlers) # type: ignore[attr-defined]
129
130
 
130
131
  def _get(self) -> urllib.request.OpenerDirector:
131
132
  if self._opener is None:
@@ -151,7 +152,7 @@ def _load_from_url(url: str) -> dict[str, Any]:
151
152
  """
152
153
  if not url.startswith("https://"):
153
154
  raise ValueError("only https:// URLs are accepted")
154
- with _opener.open(url, timeout=10) as response: # noqa: S310
155
+ with _opener.open(url, timeout=10) as response:
155
156
  data = response.read()
156
157
  return toml_load(io.BytesIO(data))
157
158
 
@@ -1,24 +1,27 @@
1
1
  """Configuration merger that combines CLI args, env vars, TOML config, and defaults."""
2
2
 
3
3
  from __future__ import annotations
4
+
5
+ import argparse
6
+ import difflib
4
7
  import os
5
8
  import sys
6
- import argparse
7
9
  from collections.abc import Callable
8
- from typing import Any
10
+ from typing import Any, ClassVar
9
11
 
10
- from commit_check.config import deep_merge, load_config as load_toml_config
11
12
  from commit_check import (
12
- DEFAULT_COMMIT_TYPES,
13
- DEFAULT_BRANCH_TYPES,
14
- DEFAULT_BRANCH_NAMES,
15
- DEFAULT_BOOLEAN_RULES,
16
- DEFAULT_PUSH_RULES,
17
13
  DEFAULT_AI_ATTRIBUTION,
18
14
  DEFAULT_AI_DISCLOSURE_PATTERN,
19
15
  DEFAULT_AI_DISCLOSURE_TRAILERS,
16
+ DEFAULT_BOOLEAN_RULES,
17
+ DEFAULT_BRANCH_NAMES,
18
+ DEFAULT_BRANCH_TYPES,
19
+ DEFAULT_COMMIT_TYPES,
20
+ DEFAULT_PUSH_RULES,
20
21
  DEFAULT_TAG_REGEX,
21
22
  )
23
+ from commit_check.config import deep_merge
24
+ from commit_check.config import load_config as load_toml_config
22
25
 
23
26
 
24
27
  def parse_bool(value: Any) -> bool:
@@ -89,6 +92,7 @@ def get_default_config() -> dict[str, Any]:
89
92
  },
90
93
  "branch": {
91
94
  "conventional_branch": True,
95
+ "require_description_grammar": False,
92
96
  "allow_branch_types": DEFAULT_BRANCH_TYPES.copy(),
93
97
  "allow_branch_names": DEFAULT_BRANCH_NAMES.copy(),
94
98
  "require_rebase_target": "",
@@ -108,11 +112,42 @@ def get_default_config() -> dict[str, Any]:
108
112
  }
109
113
 
110
114
 
115
+ #: Read from the same file, but by other tools: ``warn`` by the rule builder,
116
+ #: ``[jira]`` and ``[pull_request]`` by the GitHub App.
117
+ _OTHER_KEYS = frozenset({"warn", "jira", "pull_request"})
118
+
119
+
120
+ def unknown_setting_warnings(config: dict[str, Any]) -> list[str]:
121
+ """One line per setting in a config file that nothing reads.
122
+
123
+ A misspelt key used to be ignored in silence, leaving its rule off.
124
+ """
125
+ defaults = get_default_config()
126
+ names = []
127
+ for section, value in config.items():
128
+ if section in _OTHER_KEYS:
129
+ continue
130
+ if section not in defaults:
131
+ names.append((f"[{section}]", section, [*defaults, *_OTHER_KEYS]))
132
+ elif isinstance(value, dict):
133
+ names.extend(
134
+ (f"[{section}] {key}", key, defaults[section])
135
+ for key in value
136
+ if key not in defaults[section]
137
+ )
138
+ warnings = []
139
+ for shown, name, known in names:
140
+ match = difflib.get_close_matches(name, list(known), n=1)
141
+ hint = f"; did you mean {match[0]}?" if match else ""
142
+ warnings.append(f"⚠ unknown setting {shown} is ignored{hint}")
143
+ return warnings
144
+
145
+
111
146
  class ConfigMerger:
112
147
  """Merges configurations from multiple sources with priority: CLI > Env > TOML > Defaults."""
113
148
 
114
149
  # Mapping of environment variable names to config keys
115
- ENV_VAR_MAPPING: dict[str, tuple[str, str, Callable[[Any], Any]]] = {
150
+ ENV_VAR_MAPPING: ClassVar[dict[str, tuple[str, str, Callable[[Any], Any]]]] = {
116
151
  # Commit section
117
152
  "CCHK_CONVENTIONAL_COMMITS": ("commit", "conventional_commits", parse_bool),
118
153
  "CCHK_MESSAGE_PATTERN": ("commit", "message_pattern", str),
@@ -140,6 +175,11 @@ class ConfigMerger:
140
175
  "CCHK_AUTHOR_NAME_PATTERN": ("commit", "author_name_pattern", str),
141
176
  # Branch section
142
177
  "CCHK_CONVENTIONAL_BRANCH": ("branch", "conventional_branch", parse_bool),
178
+ "CCHK_REQUIRE_DESCRIPTION_GRAMMAR": (
179
+ "branch",
180
+ "require_description_grammar",
181
+ parse_bool,
182
+ ),
143
183
  "CCHK_ALLOW_BRANCH_TYPES": ("branch", "allow_branch_types", parse_list),
144
184
  "CCHK_ALLOW_BRANCH_NAMES": ("branch", "allow_branch_names", parse_list),
145
185
  "CCHK_REQUIRE_REBASE_TARGET": ("branch", "require_rebase_target", str),
@@ -155,7 +195,7 @@ class ConfigMerger:
155
195
  }
156
196
 
157
197
  # Mapping of CLI argument names to config keys
158
- CLI_ARG_MAPPING: dict[str, tuple[str, str]] = {
198
+ CLI_ARG_MAPPING: ClassVar[dict[str, tuple[str, str]]] = {
159
199
  # Commit section
160
200
  "conventional_commits": ("commit", "conventional_commits"),
161
201
  "subject_capitalized": ("commit", "subject_capitalized"),
@@ -178,6 +218,7 @@ class ConfigMerger:
178
218
  "author_name_pattern": ("commit", "author_name_pattern"),
179
219
  # Branch section
180
220
  "conventional_branch": ("branch", "conventional_branch"),
221
+ "require_description_grammar": ("branch", "require_description_grammar"),
181
222
  "allow_branch_types": ("branch", "allow_branch_types"),
182
223
  "allow_branch_names": ("branch", "allow_branch_names"),
183
224
  "require_rebase_target": ("branch", "require_rebase_target"),
@@ -259,6 +300,8 @@ class ConfigMerger:
259
300
  try:
260
301
  toml_config = load_toml_config(config_path or "")
261
302
  if toml_config:
303
+ for warning in unknown_setting_warnings(toml_config):
304
+ print(warning, file=sys.stderr)
262
305
  deep_merge(config, toml_config)
263
306
  except FileNotFoundError:
264
307
  # If a specific path was provided and not found, this error is already raised
commit_check/engine.py CHANGED
@@ -1,54 +1,54 @@
1
1
  """Clean validation engine following SOLID principles."""
2
2
 
3
3
  from __future__ import annotations
4
+
4
5
  import re
5
6
  import shlex
6
7
  import subprocess
7
8
  import sys
8
9
  from abc import ABC, abstractmethod
9
10
  from collections.abc import Iterable
10
- from dataclasses import dataclass
11
+ from dataclasses import dataclass, field
11
12
  from enum import IntEnum
12
- from dataclasses import field
13
13
  from fnmatch import fnmatchcase
14
+ from typing import ClassVar
14
15
 
15
- from commit_check import DEFAULT_AI_DISCLOSURE_TRAILERS
16
- from commit_check import ai_policy
17
- from commit_check.rule_builder import ValidationRule
16
+ from commit_check import DEFAULT_AI_DISCLOSURE_TRAILERS, ai_policy
18
17
  from commit_check.ai_signatures import (
19
18
  detect_ai_signatures,
20
19
  )
20
+ from commit_check.fixes import (
21
+ fix_branch_type,
22
+ fix_conventional_header,
23
+ fix_subject_case,
24
+ fix_wip,
25
+ signoff_trailer,
26
+ strip_lines_containing,
27
+ )
28
+ from commit_check.imperatives import IMPERATIVES, NON_IMPERATIVE_LOOKALIKES
29
+ from commit_check.rule_builder import ValidationRule
21
30
  from commit_check.util import (
22
31
  _print_failure,
23
32
  fetch_remote_ref,
24
33
  fetch_upstream_ref,
34
+ format_size,
35
+ get_branch_name,
25
36
  get_commit_author_identity,
37
+ get_commit_files,
26
38
  get_commit_info,
27
- get_git_user_identity,
28
39
  get_git_config_value,
29
- get_branch_name,
30
- get_commit_files,
31
- get_push_commits,
32
40
  get_git_remotes,
41
+ get_git_user_identity,
42
+ get_push_commits,
33
43
  get_tags_at,
34
- format_size,
35
44
  get_upstream_branch,
36
45
  get_upstream_remote_sha,
37
- has_commits,
38
46
  git_merge_base,
39
47
  git_rev_parse_verify,
48
+ has_commits,
40
49
  print_error_header,
41
50
  rejection_headline,
42
51
  )
43
- from commit_check.imperatives import IMPERATIVES, NON_IMPERATIVE_LOOKALIKES
44
- from commit_check.fixes import (
45
- fix_branch_type,
46
- fix_conventional_header,
47
- fix_subject_case,
48
- fix_wip,
49
- signoff_trailer,
50
- strip_lines_containing,
51
- )
52
52
 
53
53
 
54
54
  class ValidationResult(IntEnum):
@@ -96,12 +96,27 @@ class ValidationContext:
96
96
  # Set when stdin_text is a piped commit message: checks with a value of
97
97
  # their own (branch, author, tags, push refs) then read git instead.
98
98
  stdin_is_message: bool = False
99
+ # Set when stdin_text is the pre-push line rebuilt from pre-commit's
100
+ # environment. pre-commit names one ref per push, the first that carries
101
+ # commits the remote lacks, so a ref pushed alongside it -- a tag that
102
+ # ``git push --follow-tags`` sends after a branch -- is not in the line.
103
+ push_from_pre_commit: bool = False
99
104
 
100
105
  @property
101
106
  def piped_value(self) -> str | None:
102
107
  """stdin_text as a check's own value, or None when it is the message."""
103
108
  return None if self.stdin_is_message else self.stdin_text
104
109
 
110
+ @property
111
+ def piped_name(self) -> str | None:
112
+ """piped_value as the one name a branch or author check reads.
113
+
114
+ None as well when stdin_text is the pre-push line rebuilt from
115
+ pre-commit's environment: that names refs for the push checks run
116
+ alongside, not a branch or an author, so these read git instead.
117
+ """
118
+ return None if self.push_from_pre_commit else self.piped_value
119
+
105
120
 
106
121
  @dataclass
107
122
  class CheckOutcome:
@@ -132,6 +147,9 @@ class CheckOutcome:
132
147
  fix: str = ""
133
148
  rule_id: str = ""
134
149
  docs_url: str = ""
150
+ # Why a skipped check did not run, when commit-check knows (the author
151
+ # is on an ``ignore_authors`` list); empty otherwise.
152
+ reason: str = ""
135
153
 
136
154
  def to_dict(self) -> dict[str, str]:
137
155
  """Serialise to a plain dict (suitable for JSON encoding)."""
@@ -144,6 +162,7 @@ class CheckOutcome:
144
162
  "suggest": self.suggest,
145
163
  "fix": self.fix,
146
164
  "docs_url": self.docs_url,
165
+ "reason": self.reason,
147
166
  }
148
167
 
149
168
 
@@ -206,11 +225,14 @@ class BaseValidator(ABC):
206
225
  # collection. Text-mode validation skips the extra lookups (e.g. a
207
226
  # git subprocess for the branch name) and keeps values empty.
208
227
  self._collect_value: bool = False
228
+ # Why the rule declined to run, when the reason is something the
229
+ # user configured (an ignored author). A bare "skipped" leaves the
230
+ # reader guessing whether the policy was bypassed on purpose.
231
+ self._skip_reason: str = ""
209
232
 
210
233
  @abstractmethod
211
234
  def validate(self, context: ValidationContext) -> ValidationResult:
212
235
  """Perform validation and return result."""
213
- pass
214
236
 
215
237
  @staticmethod
216
238
  def _resolve_current_author(context: ValidationContext) -> str:
@@ -286,6 +308,7 @@ class BaseValidator(ABC):
286
308
 
287
309
  current_author = self._resolve_current_author(context)
288
310
  if current_author and current_author in ignore_authors:
311
+ self._skip_reason = f"author {current_author} is in [commit].ignore_authors"
289
312
  return True
290
313
 
291
314
  # Check co-authors from the commit message body
@@ -298,7 +321,13 @@ class BaseValidator(ABC):
298
321
  message,
299
322
  re.MULTILINE,
300
323
  )
301
- return any(co_author.strip() in ignore_authors for co_author in co_authors)
324
+ for co_author in co_authors:
325
+ if co_author.strip() in ignore_authors:
326
+ self._skip_reason = (
327
+ f"co-author {co_author.strip()} is in [commit].ignore_authors"
328
+ )
329
+ return True
330
+ return False
302
331
 
303
332
  @staticmethod
304
333
  def _get_commit_body(context: ValidationContext) -> str:
@@ -314,7 +343,7 @@ class BaseValidator(ABC):
314
343
  try:
315
344
  with open(context.commit_file, "r", encoding="utf-8") as f:
316
345
  return f.read()
317
- except (OSError, IOError):
346
+ except OSError:
318
347
  pass
319
348
  if context.rev is not None:
320
349
  return get_commit_info("b", context.rev)
@@ -348,8 +377,11 @@ class BaseValidator(ABC):
348
377
  if ignore_authors:
349
378
  current_author = self._resolve_current_author(context)
350
379
  if current_author and current_author in ignore_authors:
380
+ self._skip_reason = (
381
+ f"author {current_author} is in [branch].ignore_authors"
382
+ )
351
383
  return True
352
- return context.piped_value is None and not has_commits()
384
+ return context.piped_name is None and not has_commits()
353
385
 
354
386
  def _print_failure(
355
387
  self,
@@ -622,7 +654,7 @@ class AuthorValidator(BaseValidator):
622
654
  Checks git config first (for pre-commit validation of the configured identity),
623
655
  then falls back to the last commit's author info.
624
656
  """
625
- supplied = context.piped_value
657
+ supplied = context.piped_name
626
658
  if supplied is not None:
627
659
  return supplied.strip()
628
660
 
@@ -678,7 +710,7 @@ class BranchValidator(BaseValidator):
678
710
  def validate(self, context: ValidationContext) -> ValidationResult:
679
711
  if self._should_skip_branch_validation(context):
680
712
  return ValidationResult.SKIP
681
- supplied = context.piped_value
713
+ supplied = context.piped_name
682
714
  branch_name = supplied.strip() if supplied is not None else get_branch_name()
683
715
  self._checked_value = branch_name
684
716
 
@@ -706,7 +738,9 @@ class TagValidator(BaseValidator):
706
738
  ``HEAD``). A commit with no tag is a skip, not a failure: the rule
707
739
  validates how tags are named, and the absence of one is not a naming
708
740
  violation. Piped input (or an API-supplied value) names the tags to check
709
- directly, one per line, without consulting git.
741
+ directly, one per line, without consulting git. In a pre-push hook the
742
+ tags under push are checked instead; under pre-commit, which names only
743
+ one ref of a push, a branch push checks the tags on the commits it pushes.
710
744
  """
711
745
 
712
746
  @staticmethod
@@ -735,10 +769,32 @@ class TagValidator(BaseValidator):
735
769
  return list(dict.fromkeys(tags))
736
770
  return lines
737
771
 
772
+ @staticmethod
773
+ def _tags_on_pushed_commits(text: str) -> list[str]:
774
+ """The tags pointing at any commit pre-push lines carry.
775
+
776
+ Every commit of the push counts, not only the tip: a release commit
777
+ is often followed by another before the push, and
778
+ ``git push --follow-tags`` still sends the tag on the earlier one.
779
+ """
780
+ tags = []
781
+ for ln in text.splitlines():
782
+ fields = ln.split()
783
+ if len(fields) == 4 and set(fields[1]) != {"0"}:
784
+ for rev in get_push_commits(fields[1], fields[3]):
785
+ tags.extend(get_tags_at(rev))
786
+ return list(dict.fromkeys(tags))
787
+
738
788
  def validate(self, context: ValidationContext) -> ValidationResult:
739
789
  supplied = context.piped_value
740
790
  if supplied is not None:
741
791
  tags = self._tags_from_stdin(supplied)
792
+ if not tags and context.push_from_pre_commit:
793
+ # A branch push, as pre-commit tells it: any tag pushed with
794
+ # the branch is missing from the line, so the tags on the
795
+ # pushed commits stand in for it. Reading HEAD instead would
796
+ # judge whatever is checked out, which need not be pushed.
797
+ tags = self._tags_on_pushed_commits(supplied)
742
798
  else:
743
799
  tags = get_tags_at(context.rev or "HEAD")
744
800
 
@@ -1097,7 +1153,9 @@ class BodyValidator(BaseValidator):
1097
1153
  return ValidationResult.PASS
1098
1154
 
1099
1155
  # Check if there's content after the first line (even if separated by empty lines)
1100
- if len(lines) > 1:
1156
+ # Unreachable: the message is stripped, so two or more lines always
1157
+ # hold two non-empty ones and have passed above.
1158
+ if len(lines) > 1: # pragma: no cover
1101
1159
  body_content = "\n".join(lines[1:]).strip()
1102
1160
  if body_content:
1103
1161
  return ValidationResult.PASS
@@ -1324,9 +1382,8 @@ class CommitTypeValidator(BaseValidator):
1324
1382
  """Check if WIP commits are allowed."""
1325
1383
  upper_msg = message.upper()
1326
1384
  is_wip = (
1327
- upper_msg.startswith("WIP:") # wip: / WIP:
1328
- or upper_msg.startswith("[WIP]") # [wip] / [WIP]
1329
- or upper_msg.startswith("WIP ") # WIP at start with space
1385
+ # wip: / [wip] / "wip " at the start, in any case
1386
+ upper_msg.startswith(("WIP:", "[WIP]", "WIP "))
1330
1387
  or upper_msg == "WIP" # exact WIP
1331
1388
  )
1332
1389
  return not is_wip or self.rule.value
@@ -1510,7 +1567,7 @@ class AiAttributionValidator(BaseValidator):
1510
1567
  class ValidationEngine:
1511
1568
  """Main validation engine that orchestrates all validations."""
1512
1569
 
1513
- VALIDATOR_MAP: dict[str, type[BaseValidator]] = {
1570
+ VALIDATOR_MAP: ClassVar[dict[str, type[BaseValidator]]] = {
1514
1571
  "message": CommitMessageValidator,
1515
1572
  "subject_capitalized": SubjectCapitalizationValidator,
1516
1573
  "subject_imperative": SubjectImperativeValidator,
@@ -1688,6 +1745,7 @@ class ValidationEngine:
1688
1745
  value="" if skipped else (validator._checked_value or ""),
1689
1746
  rule_id=rule.rule_id or "",
1690
1747
  docs_url=rule.docs_url or "",
1748
+ reason=validator._skip_reason if skipped else "",
1691
1749
  )
1692
1750
  )
1693
1751
 
commit_check/main.py CHANGED
@@ -1,14 +1,23 @@
1
1
  """Modern commit-check CLI with clean architecture and TOML support."""
2
2
 
3
3
  from __future__ import annotations
4
+
5
+ import argparse
4
6
  import json
5
7
  import os
6
8
  import select
7
9
  import sys
8
- import argparse
9
10
 
10
11
  from commit_check.config import ConfigError, find_config_path
11
- from commit_check.config_merger import ConfigMerger, parse_bool, parse_list, parse_int
12
+ from commit_check.config_merger import ConfigMerger, parse_bool, parse_int, parse_list
13
+ from commit_check.engine import (
14
+ CheckOutcome,
15
+ ValidationContext,
16
+ ValidationEngine,
17
+ ValidationResult,
18
+ count_warnings,
19
+ overall_status,
20
+ )
12
21
  from commit_check.rule_builder import RuleBuilder
13
22
  from commit_check.rules_catalog import BRANCH_CHECKS, FILES_CHECKS, MESSAGE_CHECKS
14
23
  from commit_check.util import (
@@ -16,14 +25,7 @@ from commit_check.util import (
16
25
  git_rev_parse_verify,
17
26
  print_error_header,
18
27
  )
19
- from commit_check.engine import (
20
- ValidationEngine,
21
- ValidationContext,
22
- ValidationResult,
23
- CheckOutcome,
24
- count_warnings,
25
- overall_status,
26
- )
28
+
27
29
  from . import AI_ATTRIBUTION_POLICIES, __version__
28
30
 
29
31
  # Exit codes. ``1`` is a verdict on the commit; ``2`` means the run could not
@@ -71,7 +73,7 @@ class StdinReader:
71
73
  if not sys.stdin.isatty() and cls._has_pending_data(timeout=0.1):
72
74
  data = sys.stdin.read()
73
75
  return data.strip() if data else None
74
- except (OSError, IOError):
76
+ except OSError:
75
77
  return None
76
78
  return None
77
79
 
@@ -232,6 +234,14 @@ def _get_parser() -> argparse.ArgumentParser:
232
234
  required=False,
233
235
  )
234
236
 
237
+ parser.add_argument(
238
+ "--fix",
239
+ help="rewrite the commit message file when every failed message check "
240
+ "has a mechanical fix, such as a type's case or a missing colon",
241
+ action="store_true",
242
+ required=False,
243
+ )
244
+
235
245
  check_group.add_argument(
236
246
  "--no-force-push",
237
247
  help="check that no force push is being performed (uses pre-push hook stdin when available, otherwise checks the current branch against its upstream)",
@@ -439,6 +449,15 @@ def _get_parser() -> argparse.ArgumentParser:
439
449
  help="enforce conventional branch naming (true/false)",
440
450
  )
441
451
 
452
+ branch_group.add_argument(
453
+ "--require-description-grammar",
454
+ type=parse_bool,
455
+ default=None,
456
+ metavar="BOOL",
457
+ help="hold the description after '/' to the Conventional Branch grammar "
458
+ "(true/false)",
459
+ )
460
+
442
461
  branch_group.add_argument(
443
462
  "--allow-branch-types",
444
463
  type=parse_list,
@@ -520,6 +539,41 @@ def _get_parser() -> argparse.ArgumentParser:
520
539
  return parser
521
540
 
522
541
 
542
+ def _fix_message_file(path: str, rules: list, config: dict) -> None:
543
+ """Rewrite a commit message file when every failure in it has a fix.
544
+
545
+ A subject rule's fix is the corrected subject; every other rule's fix is
546
+ the corrected message. Fixes go in one at a time, re-checking after
547
+ each, and a failure no fix can reach leaves the file as it was.
548
+ """
549
+ with open(path, encoding="utf-8") as f:
550
+ original = f.read()
551
+ message = original.strip()
552
+ engine = ValidationEngine([r for r in rules if r.check in MESSAGE_CHECKS])
553
+ fixed = []
554
+ for _ in range(len(engine.rules) + 1):
555
+ context = ValidationContext(
556
+ stdin_text=message, stdin_is_message=True, config=config
557
+ )
558
+ outcomes = engine.validate_all_detailed(context)
559
+ failed = [o for o in outcomes if o.status == "fail"]
560
+ fixable = [o for o in failed if o.fix]
561
+ if not fixable:
562
+ break
563
+ first = fixable[0]
564
+ subject = message.split("\n", 1)[0]
565
+ if first.value == subject:
566
+ message = first.fix + message[len(subject) :]
567
+ else:
568
+ message = first.fix
569
+ fixed.append(f"{first.rule_id} {first.check.replace('_', '-')}")
570
+ if failed or not fixed:
571
+ return
572
+ with open(path, "w", encoding="utf-8") as f:
573
+ f.write(original.replace(original.strip(), message, 1))
574
+ print(f"✎ fixed the commit message: {', '.join(fixed)}", file=sys.stderr)
575
+
576
+
523
577
  def _resolve_commit_message_source(
524
578
  args: argparse.Namespace,
525
579
  stdin_reader: StdinReader,
@@ -540,8 +594,12 @@ def _resolve_commit_message_source(
540
594
 
541
595
  def _resolve_stdin_for_non_message(
542
596
  args: argparse.Namespace, stdin_reader: StdinReader
543
- ) -> str | None:
544
- """Resolve stdin content for non-message validation types."""
597
+ ) -> tuple[str | None, bool]:
598
+ """Resolve stdin content for non-message validation types.
599
+
600
+ Returns the content, and whether it is the pre-push line rebuilt from
601
+ pre-commit's environment rather than anything piped.
602
+ """
545
603
  has_non_message_check = any(
546
604
  [
547
605
  args.branch,
@@ -553,15 +611,16 @@ def _resolve_stdin_for_non_message(
553
611
  ]
554
612
  )
555
613
  if not has_non_message_check:
556
- return None
614
+ return None, False
557
615
 
558
616
  stdin_content = stdin_reader.read_piped_input()
559
- # Both push-shaped checks need the pre-push ref lines. Under the
617
+ # The push-shaped checks need the pre-push ref lines. Under the
560
618
  # pre-commit framework git's native stdin is consumed, so the same data
561
619
  # is rebuilt from the PRE_COMMIT_* environment.
562
- if (args.no_force_push or args.files) and stdin_content is None:
563
- return _build_pre_commit_push_input()
564
- return stdin_content
620
+ if (args.no_force_push or args.files or args.tag) and stdin_content is None:
621
+ push_input = _build_pre_commit_push_input()
622
+ return push_input, push_input is not None
623
+ return stdin_content, False
565
624
 
566
625
 
567
626
  def _get_requested_checks(args: argparse.Namespace) -> set[str]:
@@ -600,8 +659,10 @@ _UNCONFIGURED_HINTS = (
600
659
  (
601
660
  "branch",
602
661
  ("branch", "merge_base"),
603
- "--branch requested but no branch rules are configured "
604
- "(conventional_branch = false and no require_rebase_target)",
662
+ (
663
+ "--branch requested but no branch rules are configured "
664
+ "(conventional_branch = false and no require_rebase_target)"
665
+ ),
605
666
  ),
606
667
  )
607
668
 
@@ -702,6 +763,7 @@ def main() -> int:
702
763
  # Resolve validation context inputs. With --rev the commit itself is
703
764
  # the thing under test, so stdin is never consulted: piping and a
704
765
  # revision would name two different subjects for the same checks.
766
+ push_from_pre_commit = False
705
767
  if args.rev is not None:
706
768
  stdin_content, commit_file_path = None, None
707
769
  else:
@@ -709,11 +771,18 @@ def main() -> int:
709
771
  args, stdin_reader
710
772
  )
711
773
  if not args.message:
712
- stdin_content = _resolve_stdin_for_non_message(args, stdin_reader)
774
+ stdin_content, push_from_pre_commit = _resolve_stdin_for_non_message(
775
+ args, stdin_reader
776
+ )
713
777
 
714
778
  # Next to --message, piped stdin is the message; other checks read git.
715
779
  stdin_is_message = args.message and stdin_content is not None
716
780
 
781
+ if args.fix and not commit_file_path:
782
+ parser.error("--fix rewrites a commit message file; pass one")
783
+ if args.fix and commit_file_path and not args.dry_run:
784
+ _fix_message_file(commit_file_path, filtered_rules, config_data)
785
+
717
786
  # Reset banner state for this run
718
787
  print_error_header.has_been_called = False
719
788
 
@@ -727,6 +796,7 @@ def main() -> int:
727
796
  push_upstream_fallback=args.no_force_push
728
797
  and (stdin_content is None or stdin_is_message),
729
798
  stdin_is_message=stdin_is_message,
799
+ push_from_pre_commit=push_from_pre_commit,
730
800
  )
731
801
 
732
802
  # Run validation – choose output mode based on --format
@@ -753,7 +823,7 @@ def main() -> int:
753
823
  # Nothing was validated, so this is not a verdict on the commit.
754
824
  print(f"Error: {e}", file=sys.stderr)
755
825
  return EXIT_CONFIG_ERROR
756
- except Exception as e:
826
+ except Exception as e: # noqa: BLE001 - last resort: report it, no traceback
757
827
  print(f"Error: {e}", file=sys.stderr)
758
828
  return EXIT_FAIL
759
829
 
@@ -1,34 +1,35 @@
1
1
  """Rule builder that creates validation rules from config and catalog."""
2
2
 
3
3
  from __future__ import annotations
4
+
4
5
  import re
5
6
  import sys
6
- from typing import Any
7
7
  from dataclasses import dataclass, replace
8
- from commit_check.config import ConfigError
9
- from commit_check.util import format_size, parse_size
10
- from commit_check.rules_catalog import (
11
- COMMIT_RULES,
12
- BRANCH_RULES,
13
- PUSH_RULES,
14
- FILES_RULES,
15
- TAG_RULES,
16
- RULES_BY_CHECK,
17
- RuleCatalogEntry,
18
- )
8
+ from typing import Any
9
+
19
10
  from commit_check import (
20
11
  AI_ATTRIBUTION_POLICIES,
21
- DEFAULT_COMMIT_TYPES,
22
- DEFAULT_BRANCH_TYPES,
23
- DEFAULT_BRANCH_NAMES,
24
- DEFAULT_BOOLEAN_RULES,
25
- DEFAULT_PUSH_RULES,
26
12
  DEFAULT_AI_ATTRIBUTION,
27
13
  DEFAULT_AI_DISCLOSURE_PATTERN,
28
14
  DEFAULT_AI_DISCLOSURE_TRAILERS,
15
+ DEFAULT_BOOLEAN_RULES,
16
+ DEFAULT_BRANCH_NAMES,
17
+ DEFAULT_BRANCH_TYPES,
18
+ DEFAULT_COMMIT_TYPES,
19
+ DEFAULT_PUSH_RULES,
29
20
  DEFAULT_TAG_REGEX,
30
21
  )
31
-
22
+ from commit_check.config import ConfigError
23
+ from commit_check.rules_catalog import (
24
+ BRANCH_RULES,
25
+ COMMIT_RULES,
26
+ FILES_RULES,
27
+ PUSH_RULES,
28
+ RULES_BY_CHECK,
29
+ TAG_RULES,
30
+ RuleCatalogEntry,
31
+ )
32
+ from commit_check.util import format_size, parse_size
32
33
 
33
34
  #: The checks each ``ai_attribution`` policy builds. ``forbid`` rejects every
34
35
  #: AI signature under one rule; ``disclose`` asks for three things, one rule
@@ -43,6 +44,15 @@ _AI_CHECKS: frozenset[str] = frozenset().union(*_AI_CHECKS_BY_POLICY.values())
43
44
  #: A git trailer token: letters, digits and hyphens, and no colon.
44
45
  _TRAILER_KEY = re.compile(r"^[A-Za-z][A-Za-z0-9-]*$")
45
46
 
47
+ #: The description grammar from conventionalbranch.org's spec.json, used when
48
+ #: [branch] require_description_grammar is on; otherwise the description is ".+".
49
+ _STRICT_BRANCH_DESCRIPTION = r"[a-z0-9]+(?:\.[a-z0-9]+)*(?:-[a-z0-9]+(?:\.[a-z0-9]+)*)*"
50
+ _STRICT_BRANCH_SUGGEST = (
51
+ "Use <type>/<description> with an allowed type and a lowercase, "
52
+ "hyphen-separated description (e.g. feature/add-login), "
53
+ "or add the branch to allow_branch_names in config"
54
+ )
55
+
46
56
 
47
57
  # Lookup tables for the top-level ``warn`` list, built once at import: a
48
58
  # check name or rule ID in any case maps to the catalog's check name.
@@ -481,12 +491,13 @@ class RuleBuilder:
481
491
  allowed_types = self._get_allowed_branch_types()
482
492
  allowed_names = self._get_allowed_branch_names()
483
493
  regex = self._build_conventional_branch_regex(allowed_types, allowed_names)
494
+ strict = self.branch_config.get("require_description_grammar", False)
484
495
 
485
496
  return ValidationRule(
486
497
  check=catalog_entry.check,
487
498
  regex=regex,
488
499
  error=catalog_entry.error,
489
- suggest=catalog_entry.suggest,
500
+ suggest=_STRICT_BRANCH_SUGGEST if strict else catalog_entry.suggest,
490
501
  allowed=allowed_types,
491
502
  )
492
503
 
@@ -732,7 +743,12 @@ class RuleBuilder:
732
743
  # config allows "create-pull-request/.+" that way. Every alternative
733
744
  # is anchored at both ends so a name has to match the whole branch
734
745
  # name, not merely its start ("main-backup" is not "main").
735
- names_pattern = "|".join(["master", "main", "HEAD", r"PR-.+"])
746
+ names_pattern = "master|main|HEAD|PR-.+"
736
747
  if allowed_names:
737
748
  names_pattern += "|" + "|".join(allowed_names)
738
- return rf"^(?:{types_pattern})/.+$|^(?:{names_pattern})$"
749
+ description_pattern = (
750
+ _STRICT_BRANCH_DESCRIPTION
751
+ if self.branch_config.get("require_description_grammar", False)
752
+ else ".+"
753
+ )
754
+ return rf"^(?:{types_pattern})/{description_pattern}$|^(?:{names_pattern})$"
@@ -19,6 +19,7 @@ Internal bookkeeping entries that never produce a diagnostic (such as
19
19
  """
20
20
 
21
21
  from __future__ import annotations
22
+
22
23
  from dataclasses import dataclass
23
24
 
24
25
  #: Base URL of the rules reference documentation.
commit_check/util.py CHANGED
@@ -6,12 +6,14 @@ A module containing utility functions.
6
6
  """
7
7
 
8
8
  from __future__ import annotations
9
+
9
10
  import os
10
11
  import subprocess
11
12
  import sys
12
- from subprocess import CalledProcessError
13
- from commit_check import RED, GREEN, YELLOW, RESET_COLOR
14
13
  from collections.abc import Iterable
14
+ from subprocess import CalledProcessError
15
+
16
+ from commit_check import GREEN, RED, RESET_COLOR, YELLOW
15
17
  from commit_check.rules_catalog import display_name
16
18
 
17
19
 
@@ -63,6 +65,54 @@ def _print_failure(
63
65
  print()
64
66
 
65
67
 
68
+ # Where CI systems name the branch under test. Their checkouts are usually a
69
+ # detached HEAD, where git has no branch to report. Tried in order: within
70
+ # one system, a pull or merge request's source branch comes first, because
71
+ # the plain branch variable of such a build names something else.
72
+ _CI_BRANCH_VARIABLES = (
73
+ "GITHUB_HEAD_REF", # GitHub Actions, pull request
74
+ "GITHUB_REF_NAME", # GitHub Actions, push; the tag, on a tag push
75
+ "CI_MERGE_REQUEST_SOURCE_BRANCH_NAME", # GitLab CI, merge request pipeline
76
+ "CI_COMMIT_BRANCH", # GitLab CI, branch pipeline
77
+ "BITBUCKET_BRANCH", # Bitbucket Pipelines
78
+ "SYSTEM_PULLREQUEST_SOURCEBRANCH", # Azure Pipelines, pull request
79
+ "BUILD_SOURCEBRANCH", # Azure Pipelines, branch build
80
+ )
81
+
82
+ # Jenkins multibranch names are generic enough to be set by hand elsewhere,
83
+ # so they are read only inside a Jenkins build, and not in a tag build, where
84
+ # BRANCH_NAME is the tag (TAG_NAME is set too).
85
+ _JENKINS_BRANCH_VARIABLES = (
86
+ "CHANGE_BRANCH", # pull request source branch
87
+ "BRANCH_NAME", # branch build; "PR-24" on a pull request
88
+ )
89
+
90
+
91
+ def _ci_branch_name() -> str:
92
+ """The branch a CI system says it is building, or ``""``.
93
+
94
+ Azure Pipelines gives full refs (``refs/heads/feature/tools``); the branch
95
+ is what follows ``refs/heads/``. Any other ref — a tag, a pull request's
96
+ merge ref — names no branch and is passed over. So is ``GITHUB_REF_NAME``
97
+ in a GitHub Actions tag build (``GITHUB_REF_TYPE=tag``), where it holds
98
+ the tag.
99
+ """
100
+ names: tuple[str, ...] = _CI_BRANCH_VARIABLES
101
+ if os.getenv("GITHUB_REF_TYPE") == "tag":
102
+ names = tuple(name for name in names if name != "GITHUB_REF_NAME")
103
+ if os.getenv("JENKINS_URL") and not os.getenv("TAG_NAME"):
104
+ names += _JENKINS_BRANCH_VARIABLES
105
+ for name in names:
106
+ value = (os.getenv(name) or "").strip()
107
+ if value.startswith("refs/heads/"):
108
+ value = value.removeprefix("refs/heads/")
109
+ elif value.startswith("refs/"):
110
+ continue
111
+ if value:
112
+ return value
113
+ return ""
114
+
115
+
66
116
  def get_branch_name() -> str:
67
117
  """Identify current branch name.
68
118
  .. note::
@@ -70,6 +120,11 @@ def get_branch_name() -> str:
70
120
  Please open an issue at https://github.com/commit-check/commit-check/issues
71
121
  if you encounter any issue.
72
122
 
123
+ On a detached HEAD — the usual CI checkout — git reports no branch, so
124
+ the name comes from the CI system's environment (GitHub Actions, GitLab
125
+ CI, Bitbucket Pipelines, Azure Pipelines, Jenkins), and is ``HEAD``
126
+ when none names one.
127
+
73
128
  :returns: A `str` describing the current branch name.
74
129
  """
75
130
  try:
@@ -80,10 +135,7 @@ def get_branch_name() -> str:
80
135
  branch_name = ""
81
136
 
82
137
  if not branch_name:
83
- # Fallback to environment variables (GitHub Actions)
84
- branch_name = (
85
- os.getenv("GITHUB_HEAD_REF") or os.getenv("GITHUB_REF_NAME") or "HEAD"
86
- )
138
+ branch_name = _ci_branch_name() or "HEAD"
87
139
  return branch_name.strip()
88
140
 
89
141
 
@@ -102,8 +154,8 @@ def get_tags_at(rev: str = "HEAD") -> list[str]:
102
154
  # and a git diagnostic must not be parsed as a tag name.
103
155
  result = subprocess.run(
104
156
  ["git", "tag", "--points-at", rev],
105
- stdout=subprocess.PIPE,
106
- stderr=subprocess.PIPE,
157
+ capture_output=True,
158
+ check=False,
107
159
  encoding="utf-8",
108
160
  )
109
161
  output = result.stdout if result.returncode == 0 else ""
@@ -250,8 +302,8 @@ def get_commit_files(rev: str = "HEAD") -> list[tuple[str, int]]:
250
302
  # one, in a single call that also rejects an unresolvable revision.
251
303
  lineage = subprocess.run(
252
304
  ["git", "rev-list", "--parents", "-n", "1", rev],
253
- stdout=subprocess.PIPE,
254
- stderr=subprocess.PIPE,
305
+ capture_output=True,
306
+ check=False,
255
307
  encoding="utf-8",
256
308
  )
257
309
  if lineage.returncode != 0:
@@ -272,8 +324,8 @@ def get_commit_files(rev: str = "HEAD") -> list[tuple[str, int]]:
272
324
  "-z",
273
325
  *scope,
274
326
  ],
275
- stdout=subprocess.PIPE,
276
- stderr=subprocess.PIPE,
327
+ capture_output=True,
328
+ check=False,
277
329
  encoding="utf-8",
278
330
  )
279
331
  if diff.returncode != 0:
@@ -287,8 +339,8 @@ def get_commit_files(rev: str = "HEAD") -> list[tuple[str, int]]:
287
339
  try:
288
340
  tree = subprocess.run(
289
341
  ["git", "ls-tree", "-r", "-l", "-z", "--full-tree", rev, "--", *batch],
290
- stdout=subprocess.PIPE,
291
- stderr=subprocess.PIPE,
342
+ capture_output=True,
343
+ check=False,
292
344
  encoding="utf-8",
293
345
  )
294
346
  except OSError:
@@ -324,8 +376,8 @@ def get_push_commits(local_sha: str, remote_sha: str) -> list[str]:
324
376
  args = [f"{remote_sha}..{local_sha}"]
325
377
  result = subprocess.run(
326
378
  ["git", "rev-list", *args],
327
- stdout=subprocess.PIPE,
328
- stderr=subprocess.PIPE,
379
+ capture_output=True,
380
+ check=False,
329
381
  encoding="utf-8",
330
382
  )
331
383
  if result.returncode != 0:
@@ -343,8 +395,8 @@ def get_upstream_branch() -> str:
343
395
  """
344
396
  result = subprocess.run(
345
397
  ["git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"],
346
- stdout=subprocess.PIPE,
347
- stderr=subprocess.PIPE,
398
+ capture_output=True,
399
+ check=False,
348
400
  encoding="utf-8",
349
401
  )
350
402
  if result.returncode == 0 and result.stdout:
@@ -378,8 +430,8 @@ def get_remote_branch_sha(remote_name: str, branch_name: str) -> str:
378
430
 
379
431
  result = subprocess.run(
380
432
  ["git", "ls-remote", "--exit-code", remote_name, f"refs/heads/{branch_name}"],
381
- stdout=subprocess.PIPE,
382
- stderr=subprocess.PIPE,
433
+ capture_output=True,
434
+ check=False,
383
435
  encoding="utf-8",
384
436
  )
385
437
  if result.returncode != 0 or not result.stdout:
@@ -401,8 +453,8 @@ def fetch_upstream_ref(upstream_ref: str) -> bool:
401
453
  remote_name, branch_name = parts
402
454
  result = subprocess.run(
403
455
  ["git", "fetch", "--quiet", "--no-tags", remote_name, branch_name],
404
- stdout=subprocess.PIPE,
405
- stderr=subprocess.PIPE,
456
+ capture_output=True,
457
+ check=False,
406
458
  encoding="utf-8",
407
459
  )
408
460
  return result.returncode == 0
@@ -412,8 +464,8 @@ def get_git_remotes() -> list[str]:
412
464
  """Return configured git remote names."""
413
465
  result = subprocess.run(
414
466
  ["git", "remote"],
415
- stdout=subprocess.PIPE,
416
- stderr=subprocess.PIPE,
467
+ capture_output=True,
468
+ check=False,
417
469
  encoding="utf-8",
418
470
  )
419
471
  if result.returncode != 0 or not result.stdout:
@@ -433,8 +485,8 @@ def fetch_remote_ref(remote_name: str, remote_ref: str) -> bool:
433
485
 
434
486
  result = subprocess.run(
435
487
  ["git", "fetch", "--quiet", "--no-tags", remote_name, remote_ref],
436
- stdout=subprocess.PIPE,
437
- stderr=subprocess.PIPE,
488
+ capture_output=True,
489
+ check=False,
438
490
  encoding="utf-8",
439
491
  )
440
492
  return result.returncode == 0
@@ -551,7 +603,7 @@ def git_merge_base(target_branch: str, current_branch: str) -> int:
551
603
  f"{current_branch}",
552
604
  ]
553
605
  result = subprocess.run(
554
- commands, stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding="utf-8"
606
+ commands, capture_output=True, check=False, encoding="utf-8"
555
607
  )
556
608
  return result.returncode
557
609
  except CalledProcessError:
@@ -565,7 +617,7 @@ def cmd_output(commands: list) -> str:
565
617
  :returns: Get `str` output.
566
618
  """
567
619
  result = subprocess.run(
568
- commands, stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding="utf-8"
620
+ commands, capture_output=True, check=False, encoding="utf-8"
569
621
  )
570
622
  if result.returncode == 0 and result.stdout is not None:
571
623
  return result.stdout
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: commit-check
3
- Version: 2.18.2
4
- Summary: Check commit message formatting, branch naming, commit author, email, and more.
3
+ Version: 2.19.0
4
+ Summary: Commit policy as code for teams and AI agents: check commit messages, branch names, authors, tags, files and AI attribution.
5
5
  Author-email: Xianpeng Shen <xianpeng.shen@gmail.com>
6
6
  License-Expression: MIT
7
7
  Project-URL: documentation, https://commit-check.com/
8
8
  Project-URL: source, https://github.com/commit-check/commit-check
9
9
  Project-URL: tracker, https://github.com/commit-check/commit-check/issues
10
- Keywords: commit conventions,conventional commits,conventional branch,branch naming,commit-check,message,lint message,devops
10
+ Keywords: commit conventions,conventional commits,conventional branch,branch naming,commit-check,message,lint message,devops,policy as code,ai agents
11
11
  Classifier: Development Status :: 5 - Production/Stable
12
12
  Classifier: Intended Audience :: Developers
13
13
  Classifier: Natural Language :: English
@@ -59,10 +59,11 @@ Dynamic: license-file
59
59
 
60
60
  </div>
61
61
 
62
- **Commit Check** validates commit messages, branch names, authors, sign-offs,
63
- tags, committed files and AI attribution against one `cchk.toml` — in your
64
- commit hook, in CI, on every pull request and inside your AI agent. When the
65
- fix is obvious, it hands you the line.
62
+ **Commit Check** is commit policy as code for teams and AI agents. It validates
63
+ commit messages, branch names, authors, sign-offs, tags, committed files and AI
64
+ attribution against one `cchk.toml` — in your commit hook, in CI, on every
65
+ pull request and inside your AI agent. When the fix is obvious, it hands you
66
+ the line.
66
67
 
67
68
  ![commit-check demo](https://github.com/commit-check/commit-check/raw/main/assets/demo.gif)
68
69
 
@@ -83,12 +84,14 @@ Stop bad commits before they exist. In `.pre-commit-config.yaml`:
83
84
  ```yaml
84
85
  repos:
85
86
  - repo: https://github.com/commit-check/commit-check
86
- rev: v2.18.1
87
+ rev: v2.19.0
87
88
  hooks:
88
89
  - id: check-message
89
90
  - id: check-branch
90
91
  ```
91
92
 
93
+ Add `args: [--fix]` to `check-message` and the hook corrects mechanical slips
94
+ itself — a type's case, a missing colon — instead of rejecting the commit.
92
95
  The hooks for the other checks (`check-author-name`, `check-author-email`,
93
96
  `check-no-force-push`, `check-tag`, `check-files`) are listed in the
94
97
  [pre-commit guide](https://commit-check.com/guides/pre-commit/).
@@ -167,6 +170,7 @@ Priority: CLI > env > TOML > default. Booleans accept `true/false`, `yes/no`,
167
170
  | `commit.author_email_pattern` | `"^.+@.+$"` | `--author-email-pattern` | `CCHK_AUTHOR_EMAIL_PATTERN` | Regex the author email must match (CC102, with `--author-email`) |
168
171
  | `commit.author_name_pattern` | `""` | `--author-name-pattern` | `CCHK_AUTHOR_NAME_PATTERN` | Regex the author name must match (CC101, with `--author-name`) |
169
172
  | `branch.conventional_branch` | `true` | `--conventional-branch` | `CCHK_CONVENTIONAL_BRANCH` | Enforce `<type>/<description>` branch names (CC201) |
173
+ | `branch.require_description_grammar` | `false` | `--require-description-grammar` | `CCHK_REQUIRE_DESCRIPTION_GRAMMAR` | Also hold the description after `<type>/` to the Conventional Branch grammar: lowercase alphanumeric words joined by single hyphens, dots allowed inside a word (CC201). Off by default, since bot branches such as `dependabot/npm_and_yarn/lodash-4.17.21` do not follow it |
170
174
  | `branch.allow_branch_types` | `feature, bugfix, hotfix, release, chore, feat, fix, build, ci, docs, perf, refactor, test, style, ai, claude, codex, copilot, cursor, dependabot, renovate` | `--allow-branch-types` | `CCHK_ALLOW_BRANCH_TYPES` | Allowed branch `<type>` prefixes; each entry is a regex matched against the whole type |
171
175
  | `branch.allow_branch_names` | `[]` | `--allow-branch-names` | `CCHK_ALLOW_BRANCH_NAMES` | Extra branch names allowed besides `main`, `master`, `HEAD`, `PR-.+`; each entry is a regex matched against the whole branch name, so `create-pull-request/.+` allows a family of them |
172
176
  | `branch.require_rebase_target` | `""` | `--require-rebase-target` | `CCHK_REQUIRE_REBASE_TARGET` | Branch the current branch must be rebased onto (CC202); empty disables |
@@ -204,10 +208,14 @@ The failing entry of its `checks` array:
204
208
  "error": "The commit message should follow Conventional Commits. See https://www.conventionalcommits.org",
205
209
  "suggest": "Use \"fix: add streaming support\"",
206
210
  "fix": "fix: add streaming support",
207
- "docs_url": "https://commit-check.com/rules/#cc001"
211
+ "docs_url": "https://commit-check.com/rules/#cc001",
212
+ "reason": ""
208
213
  }
209
214
  ```
210
215
 
216
+ A check that did not run has status `skip`, and `reason` says why when the
217
+ author is on an `ignore_authors` list: `"author dependabot[bot] is in [commit].ignore_authors"`.
218
+
211
219
  The same result, without a subprocess:
212
220
 
213
221
  ```python
@@ -0,0 +1,21 @@
1
+ commit_check/__init__.py,sha256=QTpfP0b6G9TNS4McgunE6Oozwt3CkUaN4xkqEaR4570,4390
2
+ commit_check/ai_policy.py,sha256=gYC3ROLgGL1TDyhPkFXkV-NiYbvMt80Qt0MFrJcbJY4,8131
3
+ commit_check/ai_signatures.py,sha256=sPef5yb9KZ1FxN19XjLTAM2rzb4YdOSktF8HYUnzh5I,9662
4
+ commit_check/ai_signatures_data.py,sha256=3WZIV1fibvr9f1s7MGSA-mNt_na2t11GIgVeS7Uw8Wc,13125
5
+ commit_check/api.py,sha256=07bvBtV8PrhZIOwCDHiHfu_7CXsX_4U2_K2xMAucd6c,12905
6
+ commit_check/config.py,sha256=Cy7DMWKrWbmVmYER1lUlpvFf9eHn5SfVUVWZ4zgit2w,9820
7
+ commit_check/config_merger.py,sha256=umsjoTcWG7WXufxn7dIMlHrek0a8VWmqjynJR-A4LYQ,13406
8
+ commit_check/engine.py,sha256=BEjQ-70SV5j3a0WvLyjEWwpUXUsVIFYynBMWK_dwWs4,71662
9
+ commit_check/fixes.py,sha256=AsE9ChMXoOCV61pgxbZZNkAzmQS9_ARTWvvox4HkgEg,7088
10
+ commit_check/imperatives.py,sha256=dEd_ssu4G0j6Cann6jqPdB97lw5Adua43K0_Odg_FyA,9518
11
+ commit_check/main.py,sha256=14DkzTVO3dXgzMH0ZyEUob1fuPN8lBJiKJfnvlJx9Dg,27564
12
+ commit_check/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
+ commit_check/rule_builder.py,sha256=LU619d0Ndd76oiiszQ07ZHuZ1V-wKQRnUJBP35SiPHE,30351
14
+ commit_check/rules_catalog.py,sha256=JspIhkGXUviLkMG4HhH_IK2x5YTfX3t1p9gI7PbFD7Q,11558
15
+ commit_check/util.py,sha256=XRt9xc6yLjIYY0xwpcZZOAWLc1dL_MPiJNu-64Fi7jk,27810
16
+ commit_check-2.19.0.dist-info/licenses/LICENSE,sha256=VAJ9TE1ov8aUKmeoBRYqciMs0CXag1TeDCoLhwbeQmA,1095
17
+ commit_check-2.19.0.dist-info/METADATA,sha256=0vshw4iiOm7lPfdNUmgwrhMqLv2AIO7_i27AFW9sJIA,15940
18
+ commit_check-2.19.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
19
+ commit_check-2.19.0.dist-info/entry_points.txt,sha256=YMrkFGeub-3-IanV1d9qnopXVaRQM7MA9foXKW9iZ88,86
20
+ commit_check-2.19.0.dist-info/top_level.txt,sha256=Wf46u-ooHBMJNHbhfrBNQw3wC5_m8wt-o_Lfbc4QpRg,13
21
+ commit_check-2.19.0.dist-info/RECORD,,
@@ -1,21 +0,0 @@
1
- commit_check/__init__.py,sha256=--TSQBRscNxJtSNl74Mm6OGxjX0SDh_Gqicuu2AoEYU,4420
2
- commit_check/ai_policy.py,sha256=gYC3ROLgGL1TDyhPkFXkV-NiYbvMt80Qt0MFrJcbJY4,8131
3
- commit_check/ai_signatures.py,sha256=XTfHh8zD2EbHjuCBBbukQllTJWqO7EoehVc7bADMwQY,9437
4
- commit_check/ai_signatures_data.py,sha256=33uiiAieL1wB6isgU-dgkz10IrQub7lVCGh8hf7XiN8,11213
5
- commit_check/api.py,sha256=RlLGWfpEu9iW-7zIWJ7xDHTYHdH7w4a-0AE7yJH6jU0,12818
6
- commit_check/config.py,sha256=3KAoCU3sREyT0llePdKE6YrM79tmGka3a2j8LqF27WA,9815
7
- commit_check/config_merger.py,sha256=k3heMtKEMCSca-LtAntfyvNdPaB18iusfPjt_BOe6j8,11758
8
- commit_check/engine.py,sha256=vjMcrKpjQR9e_HZoHYkmCUJqcmOw8iOckxpCBqUUvXk,68640
9
- commit_check/fixes.py,sha256=AsE9ChMXoOCV61pgxbZZNkAzmQS9_ARTWvvox4HkgEg,7088
10
- commit_check/imperatives.py,sha256=dEd_ssu4G0j6Cann6jqPdB97lw5Adua43K0_Odg_FyA,9518
11
- commit_check/main.py,sha256=dyvEHptwOdGZA01E3nxbNwNmUvxfgT-vIgfXzvLtqBM,24881
12
- commit_check/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
- commit_check/rule_builder.py,sha256=f42vN3X_hYU47YgJ0xCniwKW9CwlDwt-4Q_WC7_Y-6E,29591
14
- commit_check/rules_catalog.py,sha256=Sa4jsyxWJ8g6fX2qJTdIMnd6513in5GmSxHYL5nTwws,11557
15
- commit_check/util.py,sha256=o0PuiiRhSgIVBmBF_20VytiHj74VlKb3nCMJ8KxB2Ds,25749
16
- commit_check-2.18.2.dist-info/licenses/LICENSE,sha256=VAJ9TE1ov8aUKmeoBRYqciMs0CXag1TeDCoLhwbeQmA,1095
17
- commit_check-2.18.2.dist-info/METADATA,sha256=9LtV66iFvvvt72VtMZo07FUtJCB78s5hBUfqRt-YsNU,15077
18
- commit_check-2.18.2.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
19
- commit_check-2.18.2.dist-info/entry_points.txt,sha256=YMrkFGeub-3-IanV1d9qnopXVaRQM7MA9foXKW9iZ88,86
20
- commit_check-2.18.2.dist-info/top_level.txt,sha256=Wf46u-ooHBMJNHbhfrBNQw3wC5_m8wt-o_Lfbc4QpRg,13
21
- commit_check-2.18.2.dist-info/RECORD,,