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 +16 -1
- commit_check/ai_signatures.py +14 -6
- commit_check/ai_signatures_data.py +22 -4
- commit_check/config.py +115 -13
- commit_check/engine.py +81 -32
- commit_check/fixes.py +4 -1
- commit_check/main.py +15 -4
- commit_check/rule_builder.py +93 -14
- commit_check/rules_catalog.py +6 -2
- commit_check/util.py +110 -4
- {commit_check-2.19.0.dist-info → commit_check-2.19.2.dist-info}/METADATA +4 -4
- commit_check-2.19.2.dist-info/RECORD +21 -0
- {commit_check-2.19.0.dist-info → commit_check-2.19.2.dist-info}/licenses/LICENSE +1 -1
- commit_check-2.19.0.dist-info/RECORD +0 -21
- {commit_check-2.19.0.dist-info → commit_check-2.19.2.dist-info}/WHEEL +0 -0
- {commit_check-2.19.0.dist-info → commit_check-2.19.2.dist-info}/entry_points.txt +0 -0
- {commit_check-2.19.0.dist-info → commit_check-2.19.2.dist-info}/top_level.txt +0 -0
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.,
|
|
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,
|
commit_check/ai_signatures.py
CHANGED
|
@@ -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})
|
|
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
|
-
#
|
|
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})
|
|
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
|
-
|
|
94
|
-
|
|
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})
|
|
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,
|
|
149
|
-
not valid TOML
|
|
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
|
|
182
|
+
data = _read_capped(response)
|
|
157
183
|
return toml_load(io.BytesIO(data))
|
|
158
184
|
|
|
159
185
|
|
|
160
|
-
def _load_parent_config(
|
|
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
|
-
|
|
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
|
-
|
|
175
|
-
|
|
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(
|
|
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
|
|
241
|
-
|
|
242
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
486
|
-
|
|
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
|
-
#
|
|
506
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
552
|
-
# author prose.
|
|
553
|
-
if subject.startswith(
|
|
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
|
|
557
|
-
#
|
|
558
|
-
match = re.match(r"
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
|
commit_check/rule_builder.py
CHANGED
|
@@ -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 =
|
|
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
|
|
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
|
-
|
|
551
|
-
|
|
552
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 ..."
|
|
730
|
-
# format.
|
|
731
|
-
git_prefixes =
|
|
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
|
-
|
|
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 = (
|
commit_check/rules_catalog.py
CHANGED
|
@@ -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="
|
|
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
|
-
|
|
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.
|
|
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
|
[](https://pypi.org/project/commit-check/)
|
|
49
49
|
[](https://pepy.tech/projects/commit-check)
|
|
50
|
-
[](https://github.com/commit-check/commit-check/actions/workflows/ci.yml)
|
|
51
51
|
[](https://codecov.io/gh/commit-check/commit-check)
|
|
52
52
|
[](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
|
|
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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|