commit-check 2.19.0__py3-none-any.whl → 2.19.2__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
@@ -90,7 +90,7 @@ DEFAULT_BRANCH_TYPES = [
90
90
  "dependabot",
91
91
  "renovate",
92
92
  ]
93
- # Additional allowed branch names (e.g., develop, staging)
93
+ # Additional allowed branch names (e.g., staging, production)
94
94
  DEFAULT_BRANCH_NAMES: list[str] = []
95
95
 
96
96
  # Tag naming default: SemVer, with an optional leading "v" (v1.2.3 or 1.2.3),
@@ -104,6 +104,21 @@ DEFAULT_TAG_REGEX = (
104
104
  r"(?:\+[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*)?$"
105
105
  )
106
106
 
107
+ # The subjects merge tools write themselves, which no subject rule judges and
108
+ # CC006 alone decides on. git's fmt-merge-msg writes "Merge branch 'x'" (and
109
+ # "branches", "remote-tracking branch", "tag", "commit", each possibly
110
+ # plural), or "Merge <url>" when pulling a remote's HEAD; GitLab writes
111
+ # "Merge branch 'x' into 'y'", GitHub and Bitbucket "Merge pull request #N
112
+ # ...", and the merge refs CI checks out read "Merge <sha> into <sha>".
113
+ # Anything else starting "Merge " is author prose and is held to the rules
114
+ # like any other subject. Unanchored, so it can be embedded in a larger
115
+ # pattern; match it with re.match.
116
+ GIT_MERGE_SUBJECT_PATTERN = (
117
+ r"Merge (?:(?:remote-tracking )?branch(?:es)? '|tags? '|commits? '"
118
+ r"|pull request #|[0-9a-f]{7,64} into [0-9a-f]{7,64}\b"
119
+ r"|[a-z][a-z0-9+.-]*://|[\w.-]+@[\w.-]+:)"
120
+ )
121
+
107
122
  # Push-related defaults
108
123
  DEFAULT_PUSH_RULES = {
109
124
  "allow_force_push": True,
@@ -95,8 +95,13 @@ def _build_groups() -> list[_Group]:
95
95
  # instead, it is entered on every line the branch rejects, at a cost
96
96
  # that grows with each tool added to the catalog.
97
97
  branches = "|".join(f"(?:{value})(?P<s{i}>)" for i, value in enumerate(values))
98
+ # The value starts and ends on a non-blank character, so the spaces
99
+ # around it belong to the ``[ \t]*`` beside it and nowhere else.
100
+ # Values that open with ``[^<\n]*`` or close with ``[ \t]*`` could
101
+ # otherwise share them, and a long run of spaces was tried every way
102
+ # it splits: seconds for a 30 KB line.
98
103
  regex = re.compile(
99
- rf"^(?:{alternation}):[ \t]*(?:{branches})[ \t]*$",
104
+ rf"^(?:{alternation})[ \t]*:[ \t]*(?=\S)(?:{branches})(?<![ \t])[ \t]*$",
100
105
  re.IGNORECASE | re.MULTILINE,
101
106
  )
102
107
  groups.append(_Group(keys, regex, tuple(members)))
@@ -122,7 +127,7 @@ def _scans() -> tuple[re.Pattern[str], ...]:
122
127
 
123
128
  #: The key of a trailer-shaped line, e.g. ``Co-authored-by`` in
124
129
  #: ``Co-authored-by: Claude``.
125
- _TRAILER_KEY = re.compile(r"([A-Za-z][A-Za-z0-9-]*):")
130
+ _TRAILER_KEY = re.compile(r"([A-Za-z][A-Za-z0-9-]*)[ \t]*:")
126
131
 
127
132
 
128
133
  def _trailer_lines(message: str) -> list[tuple[str, str]]:
@@ -248,7 +253,8 @@ def find_trailers(message: str, keys: list[str]) -> list[tuple[str, str, str]]:
248
253
  """Every trailer line in *message* whose key is one of *keys*.
249
254
 
250
255
  Keys match case-insensitively, at the start of any line — the reading
251
- the signature scanner and the sign-off rule take too.
256
+ the signature scanner and the sign-off rule take too. Blanks may precede
257
+ the colon, as git allows.
252
258
 
253
259
  :returns: ``(key as written, value, line)`` per match, in message order;
254
260
  the value is empty for a trailer that names nothing.
@@ -256,11 +262,13 @@ def find_trailers(message: str, keys: list[str]) -> list[tuple[str, str, str]]:
256
262
  if not keys:
257
263
  return []
258
264
  alternation = "|".join(re.escape(key) for key in keys)
259
- # A carriage return before the newline is line ending, not value.
265
+ # The value runs to the end of the line and is trimmed afterwards: a lazy
266
+ # value before ``[ \t\r]*$`` re-scans trailing spaces at every step. A
267
+ # carriage return before the newline is line ending, not value.
260
268
  pattern = re.compile(
261
- rf"^({alternation}):[ \t]*([^\n]*?)[ \t\r]*$", re.IGNORECASE | re.MULTILINE
269
+ rf"^({alternation})[ \t]*:[ \t]*([^\n]*)$", re.IGNORECASE | re.MULTILINE
262
270
  )
263
271
  return [
264
- (match.group(1), match.group(2), match.group(0))
272
+ (match.group(1), match.group(2).rstrip(" \t\r"), match.group(0))
265
273
  for match in pattern.finditer(message)
266
274
  ]
@@ -90,11 +90,15 @@ def _trailer(
90
90
  """Build a trailer pattern for a structured ``Key: value`` line.
91
91
 
92
92
  The match is case-insensitive and anchors the key at the start of a line.
93
- Horizontal whitespace only around the value: ``\\s`` would let a match run
94
- on to the next trailer line and report two lines as one.
93
+ Blanks may precede the colon, because git reads ``Key : value`` as the
94
+ trailer ``Key: value``. Horizontal whitespace only around the value:
95
+ ``\\s`` would let a match run on to the next trailer line and report two
96
+ lines as one. The value starts and ends on a non-blank character, so a
97
+ long run of spaces around it can be read only one way; see
98
+ ``ai_signatures._build_groups``.
95
99
  """
96
100
  alternation = "|".join(re.escape(key) for key in keys)
97
- raw = rf"^(?:{alternation}):[ \t]*{value_pattern}[ \t]*$"
101
+ raw = rf"^(?:{alternation})[ \t]*:[ \t]*(?=\S)(?:{value_pattern})(?<![ \t])[ \t]*$"
98
102
  return AiSignaturePattern(
99
103
  regex=re.compile(raw, re.IGNORECASE | re.MULTILINE),
100
104
  kind="trailer",
@@ -189,6 +193,10 @@ CLAUDE_CODE = KnownAiTool(
189
193
  )
190
194
 
191
195
  # --- GitHub Copilot ---
196
+ # The coding agent links each of its commits to the session logs with an
197
+ # ``Agent-Logs-Url`` trailer, and a squash merge of its pull request credits
198
+ # it as ``copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>``:
199
+ # https://github.blog/changelog/2026-03-20-trace-any-copilot-coding-agent-commit-to-its-session-logs
192
200
  COPILOT = KnownAiTool(
193
201
  name="GitHub Copilot",
194
202
  name_pattern=_names(r"copilot"),
@@ -197,6 +205,13 @@ COPILOT = KnownAiTool(
197
205
  r"Copilot(?:[ \t]*<\d+\+Copilot@users\.noreply\.github\.com>)?",
198
206
  "Copilot",
199
207
  ),
208
+ # Only the Copilot account has this address, so it catches the agent
209
+ # under any display name.
210
+ *_identity(
211
+ r"[^<\n]*<\d+\+Copilot@users\.noreply\.github\.com>",
212
+ "... <N+Copilot@users.noreply.github.com>",
213
+ ),
214
+ _stamp_trailer("Agent-Logs-Url", "``Agent-Logs-Url:`` trailer"),
200
215
  ],
201
216
  )
202
217
 
@@ -223,7 +238,10 @@ CURSOR = KnownAiTool(
223
238
  name="Cursor",
224
239
  name_pattern=_names(r"cursor"),
225
240
  patterns=[
226
- *_identity(r"Cursor[ \t]*(?:<[^>\n]*>)?", "Cursor"),
241
+ *_identity(r"Cursor(?: Agent)?[ \t]*(?:<[^>\n]*>)?", "Cursor"),
242
+ # Cursor's agent signs as ``Cursor Agent <cursoragent@cursor.com>``;
243
+ # the address catches it under any display name.
244
+ *_identity(r"[^<\n]*<cursoragent@cursor\.com>", "... <cursoragent@cursor.com>"),
227
245
  ],
228
246
  )
229
247
 
commit_check/config.py CHANGED
@@ -4,6 +4,8 @@ from __future__ import annotations
4
4
 
5
5
  import copy
6
6
  import io
7
+ import os
8
+ import stat
7
9
  import sys
8
10
  import urllib.error
9
11
  import urllib.request
@@ -140,28 +142,63 @@ class _LazyOpener:
140
142
  _opener = _LazyOpener()
141
143
 
142
144
 
145
+ # The largest parent config ``inherit_from`` will read, from a URL or a file.
146
+ # A real policy file is a few kilobytes; the cap is there so that a path such
147
+ # as ``/dev/zero``, or a URL that never stops sending, fails like any other
148
+ # parent that cannot be loaded instead of filling memory.
149
+ MAX_PARENT_CONFIG_BYTES = 1024 * 1024
150
+
151
+
152
+ def _read_capped(f: Any) -> bytes:
153
+ """Read at most :data:`MAX_PARENT_CONFIG_BYTES` from *f*.
154
+
155
+ One byte more than the cap is requested, so a body that is exactly at
156
+ the cap is accepted and anything longer is told apart from it.
157
+
158
+ :raises ValueError: If *f* holds more than the cap.
159
+ """
160
+ data = f.read(MAX_PARENT_CONFIG_BYTES + 1)
161
+ if len(data) > MAX_PARENT_CONFIG_BYTES:
162
+ raise ValueError(
163
+ f"larger than the {MAX_PARENT_CONFIG_BYTES // (1024 * 1024)} MiB limit"
164
+ )
165
+ return data
166
+
167
+
143
168
  def _load_from_url(url: str) -> dict[str, Any]:
144
169
  """Load TOML config from an HTTPS URL.
145
170
 
146
171
  :param url: HTTPS URL pointing to a TOML config file.
147
172
  :returns: Parsed config dict.
148
- :raises ValueError: If the URL does not use HTTPS, or the fetched body is
149
- not valid TOML (``TOMLDecodeError``).
173
+ :raises ValueError: If the URL does not use HTTPS, the body is larger than
174
+ :data:`MAX_PARENT_CONFIG_BYTES`, or it is not valid TOML
175
+ (``TOMLDecodeError``).
150
176
  :raises OSError: If the URL cannot be fetched, or redirects off HTTPS
151
177
  (``urllib.error.URLError`` is an ``OSError``).
152
178
  """
153
179
  if not url.startswith("https://"):
154
180
  raise ValueError("only https:// URLs are accepted")
155
181
  with _opener.open(url, timeout=10) as response:
156
- data = response.read()
182
+ data = _read_capped(response)
157
183
  return toml_load(io.BytesIO(data))
158
184
 
159
185
 
160
- def _load_parent_config(inherit_from: str) -> dict[str, Any]:
186
+ def _load_parent_config(
187
+ inherit_from: str, base_dir: Path | None = None
188
+ ) -> dict[str, Any]:
161
189
  """Load the parent config named by an ``inherit_from`` value.
162
190
 
191
+ A relative local path is looked for next to the config file that names
192
+ it (*base_dir*) first, so the reference holds wherever the CLI is run
193
+ from, then in the current directory, where it used to be resolved alone,
194
+ and last at the top of the git work tree, which is where the current
195
+ directory was for a config written against that before a run from a
196
+ subdirectory could find it.
197
+
163
198
  :raises ValueError: For a malformed ``github:`` shorthand, a non-HTTPS URL,
164
- or a parent that is not valid TOML (``TOMLDecodeError``).
199
+ a local path that is not a regular file, a parent larger than
200
+ :data:`MAX_PARENT_CONFIG_BYTES`, or one that is not valid TOML
201
+ (``TOMLDecodeError``).
165
202
  :raises OSError: For an unreachable URL or an unreadable local file.
166
203
  """
167
204
  if inherit_from.startswith("github:"):
@@ -171,11 +208,26 @@ def _load_parent_config(inherit_from: str) -> dict[str, Any]:
171
208
  return _load_from_url(url)
172
209
  if inherit_from.startswith(("https://", "http://")):
173
210
  return _load_from_url(inherit_from)
174
- with open(Path(inherit_from), "rb") as f:
175
- return toml_load(f)
211
+ path = Path(inherit_from)
212
+ if not path.is_absolute():
213
+ if base_dir is not None and (base_dir / path).exists():
214
+ path = base_dir / path
215
+ elif not path.exists():
216
+ root = _config_search_root()
217
+ if root is not None and (root / path).exists():
218
+ path = root / path
219
+ # Checked before opening: a device such as /dev/zero never ends, and
220
+ # opening a FIFO blocks until something writes to it.
221
+ if not stat.S_ISREG(os.stat(path).st_mode):
222
+ raise ValueError("not a regular file")
223
+ with open(path, "rb") as f:
224
+ data = _read_capped(f)
225
+ return toml_load(io.BytesIO(data))
176
226
 
177
227
 
178
- def _resolve_inherit_from(config: dict[str, Any]) -> dict[str, Any]:
228
+ def _resolve_inherit_from(
229
+ config: dict[str, Any], base_dir: Path | None = None
230
+ ) -> dict[str, Any]:
179
231
  """Resolve ``inherit_from`` directive, merging parent config with local.
180
232
 
181
233
  The ``inherit_from`` key at the top level of a config file may be:
@@ -187,7 +239,11 @@ def _resolve_inherit_from(config: dict[str, Any]) -> dict[str, Any]:
187
239
  HTTP (non-TLS) URLs are rejected to prevent MITM attacks. The parent
188
240
  config is loaded first; local settings override the parent.
189
241
 
242
+ Only one level is followed: an ``inherit_from`` in the parent itself is
243
+ dropped with a notice rather than fetched.
244
+
190
245
  :param config: Already-parsed local TOML dict (may contain ``inherit_from``).
246
+ :param base_dir: Directory of the config file, for a relative local path.
191
247
  :returns: Merged config with parent settings applied as base.
192
248
  """
193
249
  inherit_from = config.pop("inherit_from", None)
@@ -200,7 +256,7 @@ def _resolve_inherit_from(config: dict[str, Any]) -> dict[str, Any]:
200
256
  # TOMLDecodeError (tomllib and tomli alike) is a ValueError; URLError is
201
257
  # an OSError.
202
258
  try:
203
- parent = _load_parent_config(inherit_from)
259
+ parent = _load_parent_config(inherit_from, base_dir)
204
260
  except (OSError, ValueError) as e:
205
261
  print(
206
262
  f'⊘ inherit_from "{inherit_from}" could not be loaded: {e}; '
@@ -209,6 +265,16 @@ def _resolve_inherit_from(config: dict[str, Any]) -> dict[str, Any]:
209
265
  )
210
266
  parent = {}
211
267
 
268
+ # Left in place it would reach the merged config and be reported as an
269
+ # unknown setting, which reads as a typo rather than a limit.
270
+ if "inherit_from" in parent:
271
+ nested = parent.pop("inherit_from")
272
+ print(
273
+ f'⊘ inherit_from "{nested}" in the parent config "{inherit_from}" is '
274
+ "ignored: only one level of inherit_from is followed",
275
+ file=sys.stderr,
276
+ )
277
+
212
278
  if parent:
213
279
  deep_merge(parent, config)
214
280
  return parent
@@ -233,13 +299,40 @@ def _load_toml_file(path: Path, shown_as: str | None = None) -> dict[str, Any]:
233
299
  raise ConfigError(f"{shown_as or path}: {e}") from e
234
300
 
235
301
 
302
+ def _config_search_root() -> Path | None:
303
+ """The top of the git work tree, or ``None`` outside one.
304
+
305
+ The default config paths are repository locations: run from a
306
+ subdirectory, a search relative to the current directory found nothing
307
+ and the run went ahead on the built-in defaults without a word.
308
+
309
+ The top is the nearest directory up from the current one that holds a
310
+ ``.git`` directory, or a ``.git`` file as in a linked worktree or a
311
+ submodule. Looking for it costs a few ``stat`` calls where ``git
312
+ rev-parse --show-toplevel`` cost a process on every run without a config.
313
+ """
314
+ # os.path rather than pathlib: this runs on every run without a config,
315
+ # and building a Path per parent costs several times the stat calls.
316
+ directory = os.getcwd()
317
+ while True:
318
+ if os.path.exists(os.path.join(directory, ".git")):
319
+ return Path(directory)
320
+ parent = os.path.dirname(directory)
321
+ if parent == directory:
322
+ return None
323
+ directory = parent
324
+
325
+
236
326
  def find_config_path(path_hint: str = "") -> Path | None:
237
327
  """The config file a run reads, or ``None`` when there is none.
238
328
 
239
329
  With ``path_hint`` that is the hint, canonicalised, if it exists. Without
240
- one it is the first of :data:`DEFAULT_CONFIG_PATHS` that exists. Kept
241
- separate from :func:`load_config` so an error found later, in a setting
242
- the merged dict no longer attributes to a file, can still name the file.
330
+ one it is the first of :data:`DEFAULT_CONFIG_PATHS` that exists in the
331
+ current directory, as before, or failing that at the top of the git work
332
+ tree, given in full. A package in a monorepo with a config of its own
333
+ keeps using it. Kept separate from :func:`load_config` so an error found
334
+ later, in a setting the merged dict no longer attributes to a file, can
335
+ still name the file.
243
336
  """
244
337
  if path_hint:
245
338
  p = Path(path_hint).resolve()
@@ -247,6 +340,13 @@ def find_config_path(path_hint: str = "") -> Path | None:
247
340
  for candidate in DEFAULT_CONFIG_PATHS:
248
341
  if candidate.exists():
249
342
  return candidate
343
+ root = _config_search_root()
344
+ if root is None or root == Path.cwd():
345
+ # The current directory was searched above.
346
+ return None
347
+ for candidate in DEFAULT_CONFIG_PATHS:
348
+ if (root / candidate).exists():
349
+ return root / candidate
250
350
  return None
251
351
 
252
352
 
@@ -266,4 +366,6 @@ def load_config(path_hint: str = "") -> dict[str, Any]:
266
366
  raise FileNotFoundError(f"Specified config file not found: {path_hint}")
267
367
  # No default config file: run on the built-in defaults.
268
368
  return {}
269
- return _resolve_inherit_from(_load_toml_file(path, shown_as=path_hint or None))
369
+ return _resolve_inherit_from(
370
+ _load_toml_file(path, shown_as=path_hint or None), path.parent
371
+ )
commit_check/engine.py CHANGED
@@ -13,7 +13,11 @@ from enum import IntEnum
13
13
  from fnmatch import fnmatchcase
14
14
  from typing import ClassVar
15
15
 
16
- from commit_check import DEFAULT_AI_DISCLOSURE_TRAILERS, ai_policy
16
+ from commit_check import (
17
+ DEFAULT_AI_DISCLOSURE_TRAILERS,
18
+ GIT_MERGE_SUBJECT_PATTERN,
19
+ ai_policy,
20
+ )
17
21
  from commit_check.ai_signatures import (
18
22
  detect_ai_signatures,
19
23
  )
@@ -47,9 +51,28 @@ from commit_check.util import (
47
51
  git_rev_parse_verify,
48
52
  has_commits,
49
53
  print_error_header,
54
+ read_commit_message_file,
50
55
  rejection_headline,
51
56
  )
52
57
 
58
+ _GIT_MERGE_SUBJECT = re.compile(GIT_MERGE_SUBJECT_PATTERN)
59
+
60
+
61
+ def is_merge_subject(subject: str) -> bool:
62
+ """Whether *subject* is one a merge tool wrote, not the author.
63
+
64
+ The subject rules decline to judge these. A subject that merely starts
65
+ with the word ("Merge lowercase parser tables") is the author's, and is
66
+ judged like any other.
67
+ """
68
+ return _GIT_MERGE_SUBJECT.match(subject) is not None
69
+
70
+
71
+ # A work-in-progress marker at the start of the subject (CC010). "^" without
72
+ # re.MULTILINE and "[ \t]*" rather than "\s*" keep the match on the first
73
+ # line, so a body that merely mentions WIP is not a WIP commit.
74
+ _WIP_MARKER = re.compile(r"^[ \t]*(?:\[wip\]|wip\b)", re.IGNORECASE)
75
+
53
76
 
54
77
  class ValidationResult(IntEnum):
55
78
  """Validation result codes.
@@ -286,8 +309,7 @@ class BaseValidator(ABC):
286
309
 
287
310
  if context.commit_file:
288
311
  try:
289
- with open(context.commit_file, "r", encoding="utf-8") as f:
290
- return f.read().strip()
312
+ return read_commit_message_file(context.commit_file).strip()
291
313
  except FileNotFoundError:
292
314
  pass
293
315
 
@@ -341,8 +363,7 @@ class BaseValidator(ABC):
341
363
  return context.stdin_text
342
364
  if context.commit_file:
343
365
  try:
344
- with open(context.commit_file, "r", encoding="utf-8") as f:
345
- return f.read()
366
+ return read_commit_message_file(context.commit_file)
346
367
  except OSError:
347
368
  pass
348
369
  if context.rev is not None:
@@ -482,9 +503,8 @@ class SubjectValidator(BaseValidator):
482
503
 
483
504
  if context.commit_file:
484
505
  try:
485
- with open(context.commit_file, "r", encoding="utf-8") as f:
486
- message = f.read().strip()
487
- return message.split("\n")[0]
506
+ message = read_commit_message_file(context.commit_file).strip()
507
+ return message.split("\n")[0]
488
508
  except FileNotFoundError:
489
509
  pass
490
510
 
@@ -496,14 +516,32 @@ class SubjectValidator(BaseValidator):
496
516
  """Override in subclasses for specific validation logic."""
497
517
  return ValidationResult.PASS
498
518
 
519
+ def _description(self, subject: str) -> str:
520
+ """*subject* past its Conventional Commits ``type(scope)!:`` prefix.
521
+
522
+ The rule's ``allowed`` list says which words are types: the builder
523
+ sets it to ``allow_commit_types`` under Conventional Commits and to
524
+ an empty list without them, where "Added: support" or "oops: x" is
525
+ not a type but the subject itself. ``None``, from a rule built by
526
+ hand, keeps the old reading of any ``word:`` as a type.
527
+ """
528
+ match = re.match(r"^(\w+)(?:\([^)]*\))?!?:\s*(.*)", subject)
529
+ if not match:
530
+ return subject
531
+ allowed = self.rule.allowed
532
+ if allowed is not None and match.group(1) not in allowed:
533
+ return subject
534
+ return match.group(2)
535
+
499
536
 
500
537
  class SubjectCapitalizationValidator(SubjectValidator):
501
538
  """Validates that subject starts with capital letter."""
502
539
 
503
540
  def _validate_subject(self, subject: str) -> ValidationResult:
504
541
  # A merge subject is machine-written; the rule declines to judge it.
505
- # Git writes "Merge " exactly, so anything else is author prose.
506
- if subject.startswith("Merge "):
542
+ # Only the subjects merge tools write count, so anything else is
543
+ # author prose.
544
+ if is_merge_subject(subject):
507
545
  return ValidationResult.SKIP
508
546
 
509
547
  if self._is_capitalized(subject):
@@ -516,8 +554,7 @@ class SubjectCapitalizationValidator(SubjectValidator):
516
554
  self._print_failure(subject, fix=fix)
517
555
  return ValidationResult.FAIL
518
556
 
519
- @staticmethod
520
- def _is_capitalized(subject: str) -> bool:
557
+ def _is_capitalized(self, subject: str) -> bool:
521
558
  """Whether the description starts upper-case, after any Conventional Commits prefix.
522
559
 
523
560
  Only a prefix that ends in a colon is a type. Without one, the first
@@ -525,8 +562,7 @@ class SubjectCapitalizationValidator(SubjectValidator):
525
562
  "A" and not on the "f" that follows, and "Update" alone is judged at
526
563
  all.
527
564
  """
528
- match = re.match(r"^\w+(?:\([^)]*\))?!?:\s*(.*)", subject)
529
- description = match.group(1).strip() if match else subject
565
+ description = self._description(subject).strip()
530
566
  return bool(description) and description[0].isupper()
531
567
 
532
568
 
@@ -548,14 +584,14 @@ class SubjectImperativeValidator(SubjectValidator):
548
584
 
549
585
  def _validate_subject(self, subject: str) -> ValidationResult:
550
586
  # Merge and fixup subjects are machine-written; decline to judge them.
551
- # Git writes "Merge " and "fixup! " exactly, so anything else is
552
- # author prose.
553
- if subject.startswith(("Merge ", "fixup! ")):
587
+ # Only the subjects merge tools write, and git's exact "fixup! ",
588
+ # count, so anything else is author prose.
589
+ if is_merge_subject(subject) or subject.startswith("fixup! "):
554
590
  return ValidationResult.SKIP
555
591
 
556
- # Extract first word (ignore conventional commit prefixes)
557
- # support breaking changes (feat!:)
558
- match = re.match(r"^(?:\w+(?:\([^)]*\))?!?:\s*)?(\w+)", subject)
592
+ # Extract first word, past a Conventional Commits prefix (including
593
+ # a breaking "feat!:") when there is one.
594
+ match = re.match(r"(\w+)", self._description(subject))
559
595
  if not match:
560
596
  return ValidationResult.PASS
561
597
 
@@ -606,7 +642,7 @@ class SubjectLengthValidator(SubjectValidator):
606
642
 
607
643
  def _validate_subject(self, subject: str) -> ValidationResult:
608
644
  # A merge subject's length is git's doing, not the author's.
609
- if subject.startswith("Merge "):
645
+ if is_merge_subject(subject):
610
646
  return ValidationResult.SKIP
611
647
 
612
648
  length = len(subject)
@@ -752,7 +788,10 @@ class TagValidator(BaseValidator):
752
788
  pre-push hook receives. For push lines the tags under push are the
753
789
  ``refs/tags/*`` refs — a push carrying no tag ref yields nothing to
754
790
  validate, and a deletion (all-zero local sha) removes a tag rather
755
- than naming a new one, so neither can fail the check.
791
+ than naming a new one, so neither can fail the check. The remote ref
792
+ is the name checked, because it is the one the remote receives:
793
+ ``git push origin v1.0.0:refs/tags/anything`` creates ``anything``.
794
+ The local ref stands in only when the remote side is not a tag.
756
795
  """
757
796
  lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
758
797
  push_lines = [ln for ln in lines if len(ln.split()) == 4 and "refs/" in ln]
@@ -762,7 +801,7 @@ class TagValidator(BaseValidator):
762
801
  local_ref, local_sha, remote_ref, _ = ln.split()
763
802
  if set(local_sha) == {"0"}:
764
803
  continue
765
- for ref in (local_ref, remote_ref):
804
+ for ref in (remote_ref, local_ref):
766
805
  if ref.startswith("refs/tags/"):
767
806
  tags.append(ref.removeprefix("refs/tags/"))
768
807
  break
@@ -991,7 +1030,12 @@ class MergeBaseValidator(BaseValidator):
991
1030
  # Find target branch matching the pattern
992
1031
  target_branch = self._find_target_branch(target_pattern)
993
1032
  if not target_branch:
994
- return ValidationResult.PASS
1033
+ # A target that resolves to no ref (a typo, or a shallow CI clone
1034
+ # without origin/<target>) leaves nothing to compare. That is a
1035
+ # skip, not a pass: the branch was never judged against it.
1036
+ name = target_pattern.lstrip("^").rstrip("$").strip()
1037
+ self._skip_reason = f"rebase target '{name}' not found"
1038
+ return ValidationResult.SKIP
995
1039
 
996
1040
  result = git_merge_base(target_branch, current_branch)
997
1041
  if result == 128:
@@ -1374,18 +1418,23 @@ class CommitTypeValidator(BaseValidator):
1374
1418
  return not is_empty or self.rule.value
1375
1419
 
1376
1420
  def _is_fixup_commit_allowed(self, message: str) -> bool:
1377
- """Check if fixup commits are allowed."""
1378
- is_fixup = message.startswith("fixup!")
1421
+ """Check if fixup commits are allowed.
1422
+
1423
+ ``git commit --fixup`` writes ``fixup!``, ``--squash`` writes
1424
+ ``squash!`` and ``--fixup=amend:``/``--fixup=reword:`` write
1425
+ ``amend!``. ``git rebase --autosquash`` folds all three away, so all
1426
+ three are the commits this rule is about.
1427
+ """
1428
+ is_fixup = message.startswith(("fixup!", "squash!", "amend!"))
1379
1429
  return not is_fixup or self.rule.value
1380
1430
 
1381
1431
  def _is_wip_commit_allowed(self, message: str) -> bool:
1382
1432
  """Check if WIP commits are allowed."""
1383
- upper_msg = message.upper()
1384
- is_wip = (
1385
- # wip: / [wip] / "wip " at the start, in any case
1386
- upper_msg.startswith(("WIP:", "[WIP]", "WIP "))
1387
- or upper_msg == "WIP" # exact WIP
1388
- )
1433
+ # [wip] or the word "wip" opening the subject, in any case and
1434
+ # whatever follows it: "WIP:", "WIP add", "WIP-", "WIP\t", or "WIP"
1435
+ # alone on the subject line. The word boundary keeps "wipe ..." out;
1436
+ # this is the marker fix_wip knows how to drop.
1437
+ is_wip = _WIP_MARKER.match(message) is not None
1389
1438
  return not is_wip or self.rule.value
1390
1439
 
1391
1440
 
commit_check/fixes.py CHANGED
@@ -17,7 +17,10 @@ from collections.abc import Mapping
17
17
 
18
18
  # type, optional scope, optional bang, optional colon, the rest. Lenient on
19
19
  # purpose: this is the shape of a subject that *tried* to be conventional.
20
- _HEADER = re.compile(r"^\s*([A-Za-z]+)(\([^)]*\))?(!)?(\s*:\s*|\s+)(\S.*?)\s*$")
20
+ # The description ends on its last non-space character, found greedily: a
21
+ # lazy ``.*?`` before ``\s*$`` re-scans the trailing spaces at every step,
22
+ # which took seconds on a subject holding a long run of them.
23
+ _HEADER = re.compile(r"^\s*([A-Za-z]+)(\([^)]*\))?(!)?(\s*:\s*|\s+)(\S(?:.*\S)?)\s*$")
21
24
  _CONVENTIONAL_PREFIX = re.compile(r"^(\w+(?:\([^)]*\))?!?:\s*)(.*)$")
22
25
  _WIP = re.compile(r"^\s*(?:\[wip\]|wip:|wip\b)\s*[-:]?\s*", re.IGNORECASE)
23
26
 
commit_check/main.py CHANGED
@@ -24,6 +24,7 @@ from commit_check.util import (
24
24
  get_remote_branch_sha,
25
25
  git_rev_parse_verify,
26
26
  print_error_header,
27
+ split_message_comments,
27
28
  )
28
29
 
29
30
  from . import AI_ATTRIBUTION_POLICIES, __version__
@@ -355,7 +356,7 @@ def _get_parser() -> argparse.ArgumentParser:
355
356
  type=parse_bool,
356
357
  default=None,
357
358
  metavar="BOOL",
358
- help="allow fixup commits (true/false)",
359
+ help="allow fixup!, squash! and amend! commits (true/false)",
359
360
  )
360
361
 
361
362
  commit_group.add_argument(
@@ -548,7 +549,8 @@ def _fix_message_file(path: str, rules: list, config: dict) -> None:
548
549
  """
549
550
  with open(path, encoding="utf-8") as f:
550
551
  original = f.read()
551
- message = original.strip()
552
+ message, comments = split_message_comments(original)
553
+ message = message.strip()
552
554
  engine = ValidationEngine([r for r in rules if r.check in MESSAGE_CHECKS])
553
555
  fixed = []
554
556
  for _ in range(len(engine.rules) + 1):
@@ -569,8 +571,15 @@ def _fix_message_file(path: str, rules: list, config: dict) -> None:
569
571
  fixed.append(f"{first.rule_id} {first.check.replace('_', '-')}")
570
572
  if failed or not fixed:
571
573
  return
574
+ # The comment block and a scissors line with the diff below it stay in
575
+ # the file, after the message where git's own template puts them, so
576
+ # git's cleanup after the hook still finds and removes them.
577
+ if comments:
578
+ rewritten = f"{message}\n\n{comments}"
579
+ else:
580
+ rewritten = original.replace(original.strip(), message, 1)
572
581
  with open(path, "w", encoding="utf-8") as f:
573
- f.write(original.replace(original.strip(), message, 1))
582
+ f.write(rewritten)
574
583
  print(f"✎ fixed the commit message: {', '.join(fixed)}", file=sys.stderr)
575
584
 
576
585
 
@@ -824,7 +833,9 @@ def main() -> int:
824
833
  print(f"Error: {e}", file=sys.stderr)
825
834
  return EXIT_CONFIG_ERROR
826
835
  except Exception as e: # noqa: BLE001 - last resort: report it, no traceback
827
- print(f"Error: {e}", file=sys.stderr)
836
+ # Some exceptions carry no text (MemoryError, a bare KeyError()), and
837
+ # "Error: " on its own says nothing; name the type instead.
838
+ print(f"Error: {str(e) or type(e).__name__}", file=sys.stderr)
828
839
  return EXIT_FAIL
829
840
 
830
841
 
@@ -18,6 +18,7 @@ from commit_check import (
18
18
  DEFAULT_COMMIT_TYPES,
19
19
  DEFAULT_PUSH_RULES,
20
20
  DEFAULT_TAG_REGEX,
21
+ GIT_MERGE_SUBJECT_PATTERN,
21
22
  )
22
23
  from commit_check.config import ConfigError
23
24
  from commit_check.rules_catalog import (
@@ -153,6 +154,46 @@ def _checked_regex(pattern: str, setting: str) -> str:
153
154
  return pattern
154
155
 
155
156
 
157
+ def _string_setting(value: Any, setting: str) -> str:
158
+ """*value*, once it is known to be a string, stripped.
159
+
160
+ A number or a boolean used to reach ``.strip()`` and come back from
161
+ main's catch-all as "'int' object has no attribute 'strip'" under exit
162
+ code 1, the code a rejected commit gets.
163
+ """
164
+ if not isinstance(value, str):
165
+ raise ConfigError(f"{setting} must be a string, got {value!r}", setting=setting)
166
+ return value.strip()
167
+
168
+
169
+ def _string_list(
170
+ section: dict[str, Any], key: str, default: list[str], setting: str
171
+ ) -> list[str]:
172
+ """``section[key]`` as a list of strings, a comma-separated string split.
173
+
174
+ ``--allow-commit-types`` and ``CCHK_ALLOW_COMMIT_TYPES`` already split
175
+ on commas; the same string in the TOML file was iterated letter by
176
+ letter, so ``"feat,fix"`` allowed a type called ``f``. Anything else
177
+ that is not a list of strings is refused rather than guessed at.
178
+ """
179
+ value = section.get(key, default)
180
+ # The built-in default needs no checking, and this runs on every rule
181
+ # set built, so it is returned as is.
182
+ if value is default:
183
+ return value
184
+ if isinstance(value, list):
185
+ for item in value:
186
+ if not isinstance(item, str):
187
+ break
188
+ else:
189
+ return value
190
+ elif isinstance(value, str):
191
+ return [item.strip() for item in value.split(",") if item.strip()]
192
+ raise ConfigError(
193
+ f"{setting} must be a list of strings, got {value!r}", setting=setting
194
+ )
195
+
196
+
156
197
  class RuleBuilder:
157
198
  """Builds validation rules from config and catalog entries."""
158
199
 
@@ -445,7 +486,9 @@ class RuleBuilder:
445
486
  enforce custom formats such as JIRA smart commits
446
487
  (``PROJ-123: description``).
447
488
  """
448
- custom_pattern = self.commit_config.get("message_pattern", "").strip()
489
+ custom_pattern = _string_setting(
490
+ self.commit_config.get("message_pattern", ""), "[commit] message_pattern"
491
+ )
449
492
  if custom_pattern:
450
493
  custom_pattern = _checked_regex(custom_pattern, "[commit] message_pattern")
451
494
  # A team that opted out of Conventional Commits must not be told
@@ -506,8 +549,14 @@ class RuleBuilder:
506
549
  ) -> ValidationRule | None:
507
550
  """Build subject length validation rule."""
508
551
  length = self.commit_config.get(config_key)
509
- if not isinstance(length, int):
552
+ if length is None:
510
553
  return None
554
+ # bool is an int: ``true`` used to become a limit of 1 character.
555
+ if isinstance(length, bool) or not isinstance(length, int):
556
+ setting = f"[commit] {config_key}"
557
+ raise ConfigError(
558
+ f"{setting} must be a whole number, got {length!r}", setting=setting
559
+ )
511
560
 
512
561
  # The suggestion is templated on the same values as the error: naming
513
562
  # the configured length is the whole point of the advice, and "the
@@ -547,10 +596,10 @@ class RuleBuilder:
547
596
  ) -> ValidationRule | None:
548
597
  """Build author name or email validation rule."""
549
598
  regex = catalog_entry.regex
550
- if self.commit_config.get(config_key, ""):
551
- regex = _checked_regex(
552
- self.commit_config.get(config_key, "").strip(), f"[commit] {config_key}"
553
- )
599
+ setting = f"[commit] {config_key}"
600
+ configured = _string_setting(self.commit_config.get(config_key, ""), setting)
601
+ if configured:
602
+ regex = _checked_regex(configured, setting)
554
603
 
555
604
  return ValidationRule(
556
605
  check=catalog_entry.check,
@@ -697,27 +746,54 @@ class RuleBuilder:
697
746
  ):
698
747
  return None
699
748
 
749
+ # The case and mood rules judge the description, so they look past a
750
+ # "type(scope)!:" prefix -- but only a prefix that is one: a type the
751
+ # team allows, under Conventional Commits. With those off, "Added:
752
+ # support" or "oops: lowercase" is the subject itself and is judged
753
+ # from its first word. An empty list tells the validator to strip
754
+ # nothing.
755
+ allowed = None
756
+ if check in ("subject_capitalized", "subject_imperative"):
757
+ conventional = self.commit_config.get("conventional_commits", True)
758
+ allowed = self._get_allowed_commit_types() if conventional else []
759
+
700
760
  return ValidationRule(
701
761
  check=catalog_entry.check,
702
762
  regex=catalog_entry.regex,
703
763
  error=catalog_entry.error,
704
764
  suggest=catalog_entry.suggest,
705
765
  value=config_value,
766
+ allowed=allowed,
706
767
  )
707
768
 
708
769
  def _get_allowed_commit_types(self) -> list[str]:
709
770
  """Get deduplicated list of allowed commit types."""
710
- types = self.commit_config.get("allow_commit_types", DEFAULT_COMMIT_TYPES)
771
+ types = _string_list(
772
+ self.commit_config,
773
+ "allow_commit_types",
774
+ DEFAULT_COMMIT_TYPES,
775
+ "[commit] allow_commit_types",
776
+ )
711
777
  return list(dict.fromkeys(types)) # Preserve order, remove duplicates
712
778
 
713
779
  def _get_allowed_branch_types(self) -> list[str]:
714
780
  """Get deduplicated list of allowed branch types."""
715
- types = self.branch_config.get("allow_branch_types", DEFAULT_BRANCH_TYPES)
781
+ types = _string_list(
782
+ self.branch_config,
783
+ "allow_branch_types",
784
+ DEFAULT_BRANCH_TYPES,
785
+ "[branch] allow_branch_types",
786
+ )
716
787
  return list(dict.fromkeys(types)) # Preserve order, remove duplicates
717
788
 
718
789
  def _get_allowed_branch_names(self) -> list[str]:
719
790
  """Get deduplicated list of allowed branch names."""
720
- names = self.branch_config.get("allow_branch_names", DEFAULT_BRANCH_NAMES)
791
+ names = _string_list(
792
+ self.branch_config,
793
+ "allow_branch_names",
794
+ DEFAULT_BRANCH_NAMES,
795
+ "[branch] allow_branch_names",
796
+ )
721
797
  return list(dict.fromkeys(names)) # Preserve order, remove duplicates
722
798
 
723
799
  def _build_conventional_commit_regex(self, allowed_types: list[str]) -> str:
@@ -726,9 +802,11 @@ class RuleBuilder:
726
802
  # Subjects git writes itself are exempt from the format rule; whether
727
803
  # they are allowed at all is CC006/CC007/CC009's call. Git writes each
728
804
  # prefix exactly as below, trailing space or quote included, so author
729
- # prose such as "Merged ..." or "fixup!! ..." is still held to the
730
- # format.
731
- git_prefixes = r'^(?:Merge |Revert "|fixup! |squash! |amend! )'
805
+ # prose such as "Merged ...", "Merge the parser tables" or
806
+ # "fixup!! ..." is still held to the format.
807
+ git_prefixes = (
808
+ rf'^(?:{GIT_MERGE_SUBJECT_PATTERN}|Revert "|fixup! |squash! |amend! )'
809
+ )
732
810
  return (
733
811
  rf"^({types_pattern})(\([\w\-\.]+\))?(!)?: [^\n]+([\s\S]*)|{git_prefixes}"
734
812
  )
@@ -742,8 +820,9 @@ class RuleBuilder:
742
820
  # always been: the built-in "PR-.+" is one, and the Action's own
743
821
  # config allows "create-pull-request/.+" that way. Every alternative
744
822
  # is anchored at both ends so a name has to match the whole branch
745
- # name, not merely its start ("main-backup" is not "main").
746
- names_pattern = "master|main|HEAD|PR-.+"
823
+ # name, not merely its start ("main-backup" is not "main"). develop
824
+ # is the specification's third trunk branch, beside main and master.
825
+ names_pattern = "master|main|develop|HEAD|PR-.+"
747
826
  if allowed_names:
748
827
  names_pattern += "|" + "|".join(allowed_names)
749
828
  description_pattern = (
@@ -134,7 +134,7 @@ COMMIT_RULES = [
134
134
  check="allow_fixup_commits",
135
135
  regex=None,
136
136
  error="Fixup commits are not allowed",
137
- suggest="Use interactive rebase to clean up fixup commits",
137
+ suggest="Fold fixup!, squash! and amend! commits with git rebase -i --autosquash",
138
138
  ),
139
139
  RuleCatalogEntry(
140
140
  rule_id="CC010",
@@ -179,7 +179,11 @@ COMMIT_RULES = [
179
179
  RuleCatalogEntry(
180
180
  rule_id="CC012",
181
181
  check="require_signed_off_by",
182
- regex=r"Signed-off-by: .+ <.+@.+>",
182
+ # A whole line, so the words in a subject or a sentence of the body
183
+ # are not a sign-off, and with the key's case ignored, as git does.
184
+ # No two parts can match the same characters, which keeps matching
185
+ # linear: overlapping ``.+`` runs took seconds on a 6 KB line.
186
+ regex=r"(?mi)^Signed-off-by:[ \t]*[^<\s][^<\n]*<[^>@\n]+@[^>\n]+>[ \t\r]*$",
183
187
  error="Signed-off-by trailer not found in the latest commit",
184
188
  suggest="git commit --amend --signoff or use --signoff on commit",
185
189
  ),
commit_check/util.py CHANGED
@@ -8,6 +8,7 @@ A module containing utility functions.
8
8
  from __future__ import annotations
9
9
 
10
10
  import os
11
+ import re
11
12
  import subprocess
12
13
  import sys
13
14
  from collections.abc import Iterable
@@ -16,6 +17,34 @@ from subprocess import CalledProcessError
16
17
  from commit_check import GREEN, RED, RESET_COLOR, YELLOW
17
18
  from commit_check.rules_catalog import display_name
18
19
 
20
+ # C0 and C1 control characters, and DEL, except the newline and tab that a
21
+ # multi-line commit message legitimately carries.
22
+ _CONTROL_CHARS = re.compile(r"[\x00-\x08\x0b-\x1f\x7f-\x9f]")
23
+
24
+
25
+ def printable(text: str) -> str:
26
+ """Make commit-controlled text safe to print as terminal or CI-log text.
27
+
28
+ The values printed on failure come from whoever wrote the commit, branch
29
+ or tag. Printed raw, an escape sequence in them drives the reader's
30
+ terminal (OSC 52 can write the clipboard), and a line beginning with
31
+ ``::`` is a GitHub Actions workflow command, able to forge annotations
32
+ or mask log text. So control characters are shown as ``\\xNN`` escapes,
33
+ and such a line gets a backslash before its ``::`` — the runner ignores
34
+ leading whitespace, so indenting it would not be enough. The runner also
35
+ reads the older ``##[command]`` form anywhere in a line, so ``##[`` is
36
+ broken up the same way. JSON output is escaped by the encoder and does
37
+ not pass through here.
38
+ """
39
+ if text.isprintable() and "::" not in text and "##[" not in text:
40
+ return text # nothing to escape: the common case
41
+ text = _CONTROL_CHARS.sub(lambda m: f"\\x{ord(m.group()):02x}", text)
42
+ text = text.replace("##[", "#\\#[")
43
+ return "\n".join(
44
+ line.replace("::", "\\::", 1) if line.lstrip().startswith("::") else line
45
+ for line in text.split("\n")
46
+ )
47
+
19
48
 
20
49
  def _print_failure(
21
50
  check: dict,
@@ -32,7 +61,7 @@ def _print_failure(
32
61
  rule_id = check.get("rule_id", "")
33
62
  warn = check.get("severity") == "warn"
34
63
  if compact:
35
- compact_value = actual.splitlines()[0] if actual else actual
64
+ compact_value = printable(actual.splitlines()[0]) if actual else actual
36
65
  name = display_name(check["check"])
37
66
  label = f"{rule_id} {name}" if rule_id else name
38
67
  print(f"[{'WARN' if warn else 'FAIL'}] {label}: {compact_value}")
@@ -41,10 +70,12 @@ def _print_failure(
41
70
  if not warn and not no_banner and not print_error_header.has_been_called:
42
71
  print_error_header(rejection_headline([check["check"]]))
43
72
  docs_url = check.get("docs_url", "") or ""
73
+ # The value, and an error or suggestion built from it, are the commit's
74
+ # text; see printable() for why they are not printed raw.
44
75
  print_error_message(
45
76
  check["check"],
46
- check.get("error", ""),
47
- actual,
77
+ printable(check.get("error", "")),
78
+ printable(actual),
48
79
  rule_id=rule_id,
49
80
  docs_url=docs_url,
50
81
  warn=warn,
@@ -52,7 +83,7 @@ def _print_failure(
52
83
  spec_url=check.get("spec_url", "") or "",
53
84
  )
54
85
  if check.get("suggest"):
55
- print_suggestion(check["suggest"])
86
+ print_suggestion(printable(check["suggest"]))
56
87
  # When the ID above is already a link, repeating the URL only adds a line
57
88
  # to read. Without hyperlink support — a pipe, a CI log — it is the only
58
89
  # way the reader gets the address at all, so it stays.
@@ -566,6 +597,81 @@ def get_git_config_value(key: str) -> str:
566
597
  return ""
567
598
 
568
599
 
600
+ # git's scissors line, after the comment character and a space. ``git commit
601
+ # -v`` writes it above the diff, and git drops it and everything below it.
602
+ SCISSORS_LINE = "------------------------ >8 ------------------------"
603
+
604
+ # The characters ``core.commentChar = auto`` chooses from, in git's order.
605
+ _AUTO_COMMENT_CHARS = "#;@!$%^&|:"
606
+
607
+
608
+ def _comment_char(lines: list[str]) -> str:
609
+ """The comment character git used when it wrote these message lines.
610
+
611
+ ``core.commentChar`` names it, ``#`` when unset. ``auto`` lets git pick
612
+ a character no line of the message starts with, and the file does not
613
+ say which: its scissors line names it when there is one, and otherwise
614
+ the comment block git appends ends the file, so the last non-blank line
615
+ names it if it starts with one of the characters git picks from.
616
+ """
617
+ configured = get_git_config_value("core.commentChar")
618
+ if configured != "auto":
619
+ return configured or "#"
620
+ for line in lines:
621
+ if line[:1] in _AUTO_COMMENT_CHARS and line[1:] == f" {SCISSORS_LINE}":
622
+ return line[0]
623
+ last = next((line for line in reversed(lines) if line.strip()), "")
624
+ return last[0] if last[:1] in _AUTO_COMMENT_CHARS else "#"
625
+
626
+
627
+ def split_message_comments(text: str) -> tuple[str, str]:
628
+ """Split a commit message file into the message git will store and the rest.
629
+
630
+ git runs the commit-msg hook before it cleans the message, so the file
631
+ still holds the comment block written for the editor and, under ``git
632
+ commit -v``, a scissors line followed by the whole diff. Checking that as
633
+ the message lets a comment satisfy ``require_body`` and a sign-off in the
634
+ diff satisfy ``require_signed_off_by``. The message is what git's default
635
+ cleanup leaves: everything from the scissors line on goes, every line
636
+ that starts with the comment character goes, and so do the blank lines
637
+ left at the end. The rest is those comment lines and the scissors block,
638
+ in order, for a caller that writes the file back.
639
+ """
640
+ lines = text.split("\n")
641
+ if lines[-1] == "":
642
+ lines.pop()
643
+ tail: list[str] = []
644
+ # A message with no line that starts with punctuation holds nothing git
645
+ # strips, whatever the comment character (git's are punctuation), so the
646
+ # git call is skipped. That covers a file written by ``git commit -m``.
647
+ if not any(
648
+ line[:1] and not (line[0].isalnum() or line[0].isspace()) for line in lines
649
+ ):
650
+ while lines and not lines[-1].strip():
651
+ lines.pop()
652
+ return "\n".join(lines), ""
653
+ comment = _comment_char(lines)
654
+ scissors = f"{comment} {SCISSORS_LINE}"
655
+ if scissors in lines:
656
+ cut = lines.index(scissors)
657
+ lines, tail = lines[:cut], lines[cut:]
658
+ kept = [line for line in lines if not line.startswith(comment)]
659
+ while kept and not kept[-1].strip():
660
+ kept.pop()
661
+ rest = [line for line in lines if line.startswith(comment)] + tail
662
+ return "\n".join(kept), "".join(f"{line}\n" for line in rest)
663
+
664
+
665
+ def read_commit_message_file(path: str) -> str:
666
+ """Read a commit message file and return the message git will store.
667
+
668
+ See :func:`split_message_comments`. Errors from opening the file are
669
+ left to the caller.
670
+ """
671
+ with open(path, encoding="utf-8") as f:
672
+ return split_message_comments(f.read())[0]
673
+
674
+
569
675
  def get_git_user_identity() -> tuple[str, str]:
570
676
  """The configured ``user.name`` and ``user.email``, in one git call.
571
677
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: commit-check
3
- Version: 2.19.0
3
+ Version: 2.19.2
4
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
@@ -47,7 +47,7 @@ Dynamic: license-file
47
47
 
48
48
  [![PyPI](https://img.shields.io/pypi/v/commit-check?labelColor=0b1620&logo=pypi&logoColor=white&color=2c9ccd)](https://pypi.org/project/commit-check/)
49
49
  [![Downloads](https://img.shields.io/pepy/dt/commit-check?labelColor=0b1620&color=2c9ccd)](https://pepy.tech/projects/commit-check)
50
- [![CI](https://img.shields.io/github/actions/workflow/status/commit-check/commit-check/main.yml?branch=main&labelColor=0b1620&label=CI)](https://github.com/commit-check/commit-check/actions/workflows/main.yml)
50
+ [![CI](https://img.shields.io/github/actions/workflow/status/commit-check/commit-check/ci.yml?branch=main&labelColor=0b1620&label=CI)](https://github.com/commit-check/commit-check/actions/workflows/ci.yml)
51
51
  [![Coverage](https://img.shields.io/codecov/c/github/commit-check/commit-check?labelColor=0b1620&color=2c9ccd&label=coverage)](https://codecov.io/gh/commit-check/commit-check)
52
52
  [![OpenSSF Scorecard](https://img.shields.io/ossf-scorecard/github.com/commit-check/commit-check?labelColor=0b1620&color=2c9ccd&label=OpenSSF%20Scorecard)](https://scorecard.dev/viewer/?uri=github.com/commit-check/commit-check)
53
53
 
@@ -159,7 +159,7 @@ Priority: CLI > env > TOML > default. Booleans accept `true/false`, `yes/no`,
159
159
  | `commit.allow_merge_commits` | `true` | `--allow-merge-commits` | `CCHK_ALLOW_MERGE_COMMITS` | Allow merge commits (CC006) |
160
160
  | `commit.allow_revert_commits` | `true` | `--allow-revert-commits` | `CCHK_ALLOW_REVERT_COMMITS` | Allow revert commits (CC007) |
161
161
  | `commit.allow_empty_commits` | `true` | `--allow-empty-commits` | `CCHK_ALLOW_EMPTY_COMMITS` | Allow empty commit messages (CC008) |
162
- | `commit.allow_fixup_commits` | `true` | `--allow-fixup-commits` | `CCHK_ALLOW_FIXUP_COMMITS` | Allow `fixup!` commits (CC009) |
162
+ | `commit.allow_fixup_commits` | `true` | `--allow-fixup-commits` | `CCHK_ALLOW_FIXUP_COMMITS` | Allow `fixup!`, `squash!` and `amend!` commits (CC009) |
163
163
  | `commit.allow_wip_commits` | `true` | `--allow-wip-commits` | `CCHK_ALLOW_WIP_COMMITS` | Allow WIP commits (CC010) |
164
164
  | `commit.require_body` | `false` | `--require-body` | `CCHK_REQUIRE_BODY` | Require a commit body (CC011) |
165
165
  | `commit.require_signed_off_by` | `false` | `--require-signed-off-by` | `CCHK_REQUIRE_SIGNED_OFF_BY` | Require a `Signed-off-by:` trailer (CC012) |
@@ -172,7 +172,7 @@ Priority: CLI > env > TOML > default. Booleans accept `true/false`, `yes/no`,
172
172
  | `branch.conventional_branch` | `true` | `--conventional-branch` | `CCHK_CONVENTIONAL_BRANCH` | Enforce `<type>/<description>` branch names (CC201) |
173
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 |
174
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 |
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 |
175
+ | `branch.allow_branch_names` | `[]` | `--allow-branch-names` | `CCHK_ALLOW_BRANCH_NAMES` | Extra branch names allowed besides `main`, `master`, `develop`, `HEAD`, `PR-.+`; each entry is a regex matched against the whole branch name, so `create-pull-request/.+` allows a family of them |
176
176
  | `branch.require_rebase_target` | `""` | `--require-rebase-target` | `CCHK_REQUIRE_REBASE_TARGET` | Branch the current branch must be rebased onto (CC202); empty disables |
177
177
  | `branch.ignore_authors` | `[]` | `--branch-ignore-authors` | `CCHK_BRANCH_IGNORE_AUTHORS` | Authors whose branches skip the branch checks |
178
178
  | `push.allow_force_push` | `true` | — (`--no-force-push` runs the check) | `CCHK_ALLOW_FORCE_PUSH` | Reserved; has no effect today. CC301 runs only with `--no-force-push`, which always rejects a non-fast-forward push |
@@ -0,0 +1,21 @@
1
+ commit_check/__init__.py,sha256=wHIreV-DEKM5AiobnjfvxIuvIQ2DShRxc5SwSx50oX4,5232
2
+ commit_check/ai_policy.py,sha256=gYC3ROLgGL1TDyhPkFXkV-NiYbvMt80Qt0MFrJcbJY4,8131
3
+ commit_check/ai_signatures.py,sha256=0HTZy4othuwKVqfGZ2J7PaIMUPmQArhEQa0k7zGnCzs,10251
4
+ commit_check/ai_signatures_data.py,sha256=2xWH8M3HoFlUh7n766GK4ahkKJi8_FvcTTOuYv_yWls,14314
5
+ commit_check/api.py,sha256=07bvBtV8PrhZIOwCDHiHfu_7CXsX_4U2_K2xMAucd6c,12905
6
+ commit_check/config.py,sha256=SB8Zvcll-kFOZgQ-olzYLv9CLJqaYIaCb37bcrjVNOE,14068
7
+ commit_check/config_merger.py,sha256=umsjoTcWG7WXufxn7dIMlHrek0a8VWmqjynJR-A4LYQ,13406
8
+ commit_check/engine.py,sha256=rot2jQ9rc8h3-pERCRR7GHsZt62BHtbDVThjHLUwZ7k,74064
9
+ commit_check/fixes.py,sha256=BpbdoHsKDyw2WfsqymwYpnrW73aHdCDGr8tMF6Ho2RU,7305
10
+ commit_check/imperatives.py,sha256=dEd_ssu4G0j6Cann6jqPdB97lw5Adua43K0_Odg_FyA,9518
11
+ commit_check/main.py,sha256=LR3uyJXXHHLsVS90pshHbB7FfyTiUr-OmecI8wwvgEo,28157
12
+ commit_check/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
+ commit_check/rule_builder.py,sha256=YDa3re0-gNTKEBBOvnF_K8kP8MpFo9UC_l9eC_punsw,33435
14
+ commit_check/rules_catalog.py,sha256=-C3Jo721vXRrY7-LzJs9iNF3ffXKEFldDfmBoLtujt4,11920
15
+ commit_check/util.py,sha256=Q9nI_0yYu6WEJLD55vMnFozWhZzfP2Z4xrElecmqZBQ,32755
16
+ commit_check-2.19.2.dist-info/licenses/LICENSE,sha256=9mdTva71kQhrpWh_nZulxrdCnUrl5c6xJYh5hdo4p_s,1070
17
+ commit_check-2.19.2.dist-info/METADATA,sha256=ArwljHXzZqWZE2oKS5aSfoosL9b5RrPG49bS-MIsRyg,15971
18
+ commit_check-2.19.2.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
19
+ commit_check-2.19.2.dist-info/entry_points.txt,sha256=YMrkFGeub-3-IanV1d9qnopXVaRQM7MA9foXKW9iZ88,86
20
+ commit_check-2.19.2.dist-info/top_level.txt,sha256=Wf46u-ooHBMJNHbhfrBNQw3wC5_m8wt-o_Lfbc4QpRg,13
21
+ commit_check-2.19.2.dist-info/RECORD,,
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2022 shenxianpeng (xianpeng.shen@gmail.com)
3
+ Copyright (c) 2022 Xianpeng Shen
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
@@ -1,21 +0,0 @@
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,,