zendev-commit 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- zendev/commit/__init__.py +572 -0
- zendev/commit/py.typed +0 -0
- zendev/conventional/__init__.py +145 -0
- zendev/conventional/py.typed +0 -0
- zendev/data/LICENSE.gitmoji +21 -0
- zendev/data/emoji-conventions.toml +80 -0
- zendev/data/gitmojis.json +605 -0
- zendev/gitmoji/__init__.py +182 -0
- zendev/gitmoji/py.typed +0 -0
- zendev_commit-0.2.0.dist-info/METADATA +119 -0
- zendev_commit-0.2.0.dist-info/RECORD +13 -0
- zendev_commit-0.2.0.dist-info/WHEEL +4 -0
- zendev_commit-0.2.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
"""Commit-message profiles, validation, and zendev's interactive commit tool."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import tomllib
|
|
9
|
+
from collections import OrderedDict
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from enum import StrEnum
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Annotated, Literal, TextIO, TypedDict
|
|
14
|
+
|
|
15
|
+
import questionary
|
|
16
|
+
import typer
|
|
17
|
+
|
|
18
|
+
from zendev.conventional import ParseIssue, parse_conventional_commit
|
|
19
|
+
from zendev.gitmoji import load_emoji_conventions, load_gitmojis, match_gitmoji, parse_gitmoji_commit
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"COMMIT_CONVENTION_EXAMPLES",
|
|
23
|
+
"EMOJI_MAP",
|
|
24
|
+
"TYPE_DISPLAY_ORDER",
|
|
25
|
+
"TYPE_SHORT_DESCRIPTIONS",
|
|
26
|
+
"CommitProfile",
|
|
27
|
+
"CommitProfileSelection",
|
|
28
|
+
"ValidationResult",
|
|
29
|
+
"ZendevAnswers",
|
|
30
|
+
"ask",
|
|
31
|
+
"commit_app",
|
|
32
|
+
"format_commit_convention_help_body",
|
|
33
|
+
"hook_app",
|
|
34
|
+
"hook_main",
|
|
35
|
+
"is_valid_commit_message",
|
|
36
|
+
"main",
|
|
37
|
+
"message",
|
|
38
|
+
"report_invalid_commit_message",
|
|
39
|
+
"resolve_commit_profile",
|
|
40
|
+
"schema_pattern",
|
|
41
|
+
"suggest_commit_message",
|
|
42
|
+
"validate_commit_message",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class CommitProfile(StrEnum):
|
|
47
|
+
"""Supported commit-message contracts."""
|
|
48
|
+
|
|
49
|
+
ZENDEV = "zendev"
|
|
50
|
+
CONVENTIONAL = "conventional"
|
|
51
|
+
GITMOJI = "gitmoji"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class CommitProfileSelection(StrEnum):
|
|
55
|
+
"""CLI values include automatic repository configuration discovery."""
|
|
56
|
+
|
|
57
|
+
AUTO = "auto"
|
|
58
|
+
ZENDEV = "zendev"
|
|
59
|
+
CONVENTIONAL = "conventional"
|
|
60
|
+
GITMOJI = "gitmoji"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
commit_app = typer.Typer(
|
|
64
|
+
add_completion=False,
|
|
65
|
+
help="Create a commit using zendev's interactive message convention.",
|
|
66
|
+
pretty_exceptions_enable=False,
|
|
67
|
+
rich_markup_mode=None,
|
|
68
|
+
)
|
|
69
|
+
hook_app = typer.Typer(
|
|
70
|
+
add_completion=False,
|
|
71
|
+
help="Validate a commit message against a configured profile.",
|
|
72
|
+
pretty_exceptions_enable=False,
|
|
73
|
+
rich_markup_mode=None,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True, slots=True)
|
|
78
|
+
class ValidationResult:
|
|
79
|
+
valid: bool
|
|
80
|
+
profile: CommitProfile
|
|
81
|
+
issue: ParseIssue | None = None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
_CONVENTIONS = load_emoji_conventions()
|
|
85
|
+
_CONVENTION_BY_GITMOJI_NAME = {convention.gitmoji.name: convention for convention in _CONVENTIONS}
|
|
86
|
+
|
|
87
|
+
EMOJI_MAP: dict[str, str] = {convention.type: convention.gitmoji.emoji for convention in _CONVENTIONS}
|
|
88
|
+
_DESCRIPTIONS: dict[str, str] = {convention.type: convention.gitmoji.description for convention in _CONVENTIONS}
|
|
89
|
+
|
|
90
|
+
# Short labels for CLI / CI help tables come directly from the vendored catalog.
|
|
91
|
+
TYPE_SHORT_DESCRIPTIONS: dict[str, str] = dict(_DESCRIPTIONS)
|
|
92
|
+
|
|
93
|
+
_LEGACY_DISPLAY_ORDER: tuple[str, ...] = (
|
|
94
|
+
"init",
|
|
95
|
+
"feat",
|
|
96
|
+
"fix",
|
|
97
|
+
"refactor",
|
|
98
|
+
"perf",
|
|
99
|
+
"docs",
|
|
100
|
+
"test",
|
|
101
|
+
"build",
|
|
102
|
+
"ci",
|
|
103
|
+
"chore",
|
|
104
|
+
"style",
|
|
105
|
+
)
|
|
106
|
+
TYPE_DISPLAY_ORDER: tuple[str, ...] = _LEGACY_DISPLAY_ORDER + tuple(
|
|
107
|
+
convention.type for convention in _CONVENTIONS if convention.type not in _LEGACY_DISPLAY_ORDER
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
COMMIT_CONVENTION_EXAMPLES: tuple[str, ...] = (
|
|
111
|
+
"🎉 init: begin a project",
|
|
112
|
+
"✨ feat: add JSON logging mode",
|
|
113
|
+
"🐛 fix(parser): handle null token",
|
|
114
|
+
"🚀 deploy: publish the package",
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
BUMP_PATTERN = r"^((BREAKING[\-\ ]CHANGE|\w+)(\(.+\))?!?):"
|
|
118
|
+
BUMP_MAP: OrderedDict[str, str] = OrderedDict(
|
|
119
|
+
(
|
|
120
|
+
(r"^.+!$", "MAJOR"),
|
|
121
|
+
(r"^BREAKING[\-\ ]CHANGE", "MAJOR"),
|
|
122
|
+
(r"^feat", "MINOR"),
|
|
123
|
+
(r"^fix", "PATCH"),
|
|
124
|
+
(r"^refactor", "PATCH"),
|
|
125
|
+
(r"^perf", "PATCH"),
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
SPECIAL_COMMIT_PREFIXES = ("Merge ", "Revert ", "fixup! ", "squash! ", "amend! ", "reword! ")
|
|
130
|
+
|
|
131
|
+
assert set(TYPE_DISPLAY_ORDER) == set(EMOJI_MAP.keys())
|
|
132
|
+
assert set(TYPE_SHORT_DESCRIPTIONS.keys()) == set(EMOJI_MAP.keys())
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _configured_commit_profile(start: Path | None = None) -> CommitProfile | None:
|
|
136
|
+
current = (start or Path.cwd()).resolve()
|
|
137
|
+
if current.is_file():
|
|
138
|
+
current = current.parent
|
|
139
|
+
for directory in (current, *current.parents):
|
|
140
|
+
config_path = directory / "pyproject.toml"
|
|
141
|
+
if not config_path.is_file():
|
|
142
|
+
continue
|
|
143
|
+
try:
|
|
144
|
+
payload = tomllib.loads(config_path.read_text(encoding="utf-8"))
|
|
145
|
+
except (OSError, tomllib.TOMLDecodeError) as error:
|
|
146
|
+
raise ValueError(f"{config_path}: failed to load commit profile ({error}).") from error
|
|
147
|
+
|
|
148
|
+
tool = payload.get("tool", {})
|
|
149
|
+
if not isinstance(tool, dict):
|
|
150
|
+
raise ValueError(f"{config_path}: tool must be a TOML table.")
|
|
151
|
+
zendev = tool.get("zendev", {})
|
|
152
|
+
if not isinstance(zendev, dict):
|
|
153
|
+
raise ValueError(f"{config_path}: tool.zendev must be a TOML table.")
|
|
154
|
+
commit = zendev.get("commit", {})
|
|
155
|
+
if not isinstance(commit, dict):
|
|
156
|
+
raise ValueError(f"{config_path}: tool.zendev.commit must be a TOML table.")
|
|
157
|
+
value = commit.get("profile")
|
|
158
|
+
if value is None:
|
|
159
|
+
return None
|
|
160
|
+
if not isinstance(value, str):
|
|
161
|
+
raise ValueError(f"{config_path}: tool.zendev.commit.profile must be a string.")
|
|
162
|
+
try:
|
|
163
|
+
return CommitProfile(value)
|
|
164
|
+
except ValueError as error:
|
|
165
|
+
choices = ", ".join(profile.value for profile in CommitProfile)
|
|
166
|
+
raise ValueError(f"{config_path}: unknown commit profile {value!r}; expected one of {choices}.") from error
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def resolve_commit_profile(
|
|
171
|
+
profile: CommitProfile | str | None = None,
|
|
172
|
+
*,
|
|
173
|
+
start: Path | None = None,
|
|
174
|
+
) -> CommitProfile:
|
|
175
|
+
"""Resolve an explicit profile or the nearest pyproject setting."""
|
|
176
|
+
|
|
177
|
+
if profile is None or profile == "auto":
|
|
178
|
+
return _configured_commit_profile(start) or CommitProfile.ZENDEV
|
|
179
|
+
if isinstance(profile, CommitProfile):
|
|
180
|
+
return profile
|
|
181
|
+
return CommitProfile(profile)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _parse_scope(text: str) -> str:
|
|
185
|
+
return "-".join(text.strip().split())
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _parse_subject(text: str) -> str:
|
|
189
|
+
subject = text.strip(".").strip()
|
|
190
|
+
if not subject:
|
|
191
|
+
raise ValueError("Subject is required.")
|
|
192
|
+
return subject
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class ZendevAnswers(TypedDict):
|
|
196
|
+
prefix: str
|
|
197
|
+
scope: str
|
|
198
|
+
subject: str
|
|
199
|
+
body: str
|
|
200
|
+
footer: str
|
|
201
|
+
is_breaking_change: bool
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def message(answers: ZendevAnswers) -> str:
|
|
205
|
+
prefix = answers["prefix"]
|
|
206
|
+
scope = answers["scope"]
|
|
207
|
+
subject = answers["subject"]
|
|
208
|
+
body = answers["body"]
|
|
209
|
+
footer = answers["footer"]
|
|
210
|
+
is_breaking_change = answers["is_breaking_change"]
|
|
211
|
+
|
|
212
|
+
emoji = EMOJI_MAP.get(prefix, "")
|
|
213
|
+
formatted_scope = f"({scope})" if scope else ""
|
|
214
|
+
title = f"{emoji} {prefix}{formatted_scope}"
|
|
215
|
+
|
|
216
|
+
if is_breaking_change:
|
|
217
|
+
footer = f"BREAKING CHANGE: {footer}"
|
|
218
|
+
|
|
219
|
+
formatted_body = f"\n\n{body}" if body else ""
|
|
220
|
+
formatted_footer = f"\n\n{footer}" if footer else ""
|
|
221
|
+
|
|
222
|
+
return f"{title}: {subject}{formatted_body}{formatted_footer}"
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def schema_pattern(*, require_emoji: bool = True) -> str:
|
|
226
|
+
types = "|".join(re.escape(name) for name in EMOJI_MAP)
|
|
227
|
+
if require_emoji:
|
|
228
|
+
pairs: list[str] = []
|
|
229
|
+
for convention in _CONVENTIONS:
|
|
230
|
+
gitmoji = convention.gitmoji
|
|
231
|
+
tokens = {gitmoji.emoji, gitmoji.emoji.replace("\ufe0f", ""), gitmoji.code}
|
|
232
|
+
token_pattern = "|".join(re.escape(token) for token in sorted(tokens, key=len, reverse=True))
|
|
233
|
+
pairs.append(r"(?:" + token_pattern + r") " + re.escape(convention.type))
|
|
234
|
+
header = r"(?:" + "|".join(pairs) + r")"
|
|
235
|
+
else:
|
|
236
|
+
header = r"(?:\S+ )?(?:" + types + r")"
|
|
237
|
+
return (
|
|
238
|
+
r"(?s)"
|
|
239
|
+
+ header
|
|
240
|
+
+ r"(\(\S+\))?" # optional scope
|
|
241
|
+
+ r"!?"
|
|
242
|
+
+ r": "
|
|
243
|
+
+ r"([^\n\r]+)" # subject
|
|
244
|
+
+ r"((\n\n.*)|(\s*))?$"
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def normalize_commit_message(text: str, *, comment_char: str = "#") -> str:
|
|
249
|
+
lines: list[str] = []
|
|
250
|
+
scissors = f"{comment_char} ------------------------ >8 ------------------------"
|
|
251
|
+
for line in text.splitlines():
|
|
252
|
+
if line.startswith(scissors):
|
|
253
|
+
break
|
|
254
|
+
if comment_char and line.startswith(comment_char):
|
|
255
|
+
continue
|
|
256
|
+
lines.append(line.rstrip())
|
|
257
|
+
return "\n".join(lines).strip()
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _validate_zendev(normalized: str) -> ValidationResult:
|
|
261
|
+
match = match_gitmoji(normalized)
|
|
262
|
+
if match is None:
|
|
263
|
+
without_emoji, _ = parse_conventional_commit(normalized)
|
|
264
|
+
issue = (
|
|
265
|
+
ParseIssue("missing-emoji", "An emoji prefix is required.")
|
|
266
|
+
if without_emoji is not None
|
|
267
|
+
else ParseIssue(
|
|
268
|
+
"invalid-zendev-header",
|
|
269
|
+
"Expected <emoji> <type>(<scope>)!: <description>.",
|
|
270
|
+
)
|
|
271
|
+
)
|
|
272
|
+
return ValidationResult(False, CommitProfile.ZENDEV, issue)
|
|
273
|
+
|
|
274
|
+
separator_end = len(match.token) + 1
|
|
275
|
+
if normalized[len(match.token)] != " " or separator_end >= len(normalized) or normalized[separator_end].isspace():
|
|
276
|
+
return ValidationResult(
|
|
277
|
+
False,
|
|
278
|
+
CommitProfile.ZENDEV,
|
|
279
|
+
ParseIssue(
|
|
280
|
+
"invalid-zendev-separator",
|
|
281
|
+
"The emoji or shortcode must be followed by exactly one space.",
|
|
282
|
+
),
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
parsed, issue = parse_conventional_commit(match.remainder)
|
|
286
|
+
if parsed is None:
|
|
287
|
+
return ValidationResult(
|
|
288
|
+
False,
|
|
289
|
+
CommitProfile.ZENDEV,
|
|
290
|
+
issue
|
|
291
|
+
or ParseIssue(
|
|
292
|
+
"invalid-zendev-header",
|
|
293
|
+
"Expected <emoji-or-shortcode> <type>(<scope>)!: <description>.",
|
|
294
|
+
),
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
expected_type = _CONVENTION_BY_GITMOJI_NAME[match.gitmoji.name].type
|
|
298
|
+
if parsed.header.type != expected_type:
|
|
299
|
+
return ValidationResult(
|
|
300
|
+
False,
|
|
301
|
+
CommitProfile.ZENDEV,
|
|
302
|
+
ParseIssue(
|
|
303
|
+
"emoji-type-mismatch",
|
|
304
|
+
f"{match.token} must be paired with type {expected_type!r}, not {parsed.header.type!r}.",
|
|
305
|
+
),
|
|
306
|
+
)
|
|
307
|
+
return ValidationResult(True, CommitProfile.ZENDEV)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def validate_commit_message(
|
|
311
|
+
text: str,
|
|
312
|
+
*,
|
|
313
|
+
profile: CommitProfile | str | None = None,
|
|
314
|
+
comment_char: str = "#",
|
|
315
|
+
) -> ValidationResult:
|
|
316
|
+
"""Validate a complete message against the selected commit profile."""
|
|
317
|
+
|
|
318
|
+
selected = resolve_commit_profile(profile)
|
|
319
|
+
normalized = normalize_commit_message(text, comment_char=comment_char)
|
|
320
|
+
if not normalized:
|
|
321
|
+
return ValidationResult(False, selected, ParseIssue("empty-message", "The commit message is empty."))
|
|
322
|
+
if normalized.startswith(SPECIAL_COMMIT_PREFIXES):
|
|
323
|
+
return ValidationResult(True, selected)
|
|
324
|
+
|
|
325
|
+
if selected is CommitProfile.ZENDEV:
|
|
326
|
+
return _validate_zendev(normalized)
|
|
327
|
+
if selected is CommitProfile.CONVENTIONAL:
|
|
328
|
+
parsed, issue = parse_conventional_commit(normalized)
|
|
329
|
+
else:
|
|
330
|
+
parsed, issue = parse_gitmoji_commit(normalized)
|
|
331
|
+
return ValidationResult(parsed is not None, selected, issue)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def is_valid_commit_message(
|
|
335
|
+
text: str,
|
|
336
|
+
*,
|
|
337
|
+
profile: CommitProfile | str | None = None,
|
|
338
|
+
comment_char: str = "#",
|
|
339
|
+
) -> bool:
|
|
340
|
+
return validate_commit_message(text, profile=profile, comment_char=comment_char).valid
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def suggest_commit_message(text: str) -> str | None:
|
|
344
|
+
normalized = normalize_commit_message(text)
|
|
345
|
+
if not normalized or is_valid_commit_message(normalized):
|
|
346
|
+
return None
|
|
347
|
+
parsed, _ = parse_conventional_commit(normalized)
|
|
348
|
+
if parsed is None:
|
|
349
|
+
return None
|
|
350
|
+
emoji = EMOJI_MAP.get(parsed.header.type)
|
|
351
|
+
if emoji is None:
|
|
352
|
+
return None
|
|
353
|
+
return f"{emoji} {normalized}"
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _format_type_table_lines() -> list[str]:
|
|
357
|
+
lines: list[str] = []
|
|
358
|
+
type_width = max(map(len, TYPE_DISPLAY_ORDER))
|
|
359
|
+
for name in TYPE_DISPLAY_ORDER:
|
|
360
|
+
emoji = EMOJI_MAP[name]
|
|
361
|
+
desc = TYPE_SHORT_DESCRIPTIONS[name]
|
|
362
|
+
lines.append(f" {emoji} {name:{type_width}} {desc}")
|
|
363
|
+
return lines
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def format_commit_convention_help_body(
|
|
367
|
+
*,
|
|
368
|
+
include_special_prefix_note: bool = True,
|
|
369
|
+
profile: CommitProfile | str | None = None,
|
|
370
|
+
) -> str:
|
|
371
|
+
selected = resolve_commit_profile(profile)
|
|
372
|
+
if selected is CommitProfile.CONVENTIONAL:
|
|
373
|
+
parts = [
|
|
374
|
+
"",
|
|
375
|
+
" Expected: <type>(<scope>)!: <description>",
|
|
376
|
+
" [blank line + optional body]",
|
|
377
|
+
" [blank line + optional footer(s)]",
|
|
378
|
+
"",
|
|
379
|
+
" Examples:",
|
|
380
|
+
" feat: add JSON logging mode",
|
|
381
|
+
" fix(parser): handle null token",
|
|
382
|
+
" feat(api)!: replace the response envelope",
|
|
383
|
+
"",
|
|
384
|
+
]
|
|
385
|
+
elif selected is CommitProfile.GITMOJI:
|
|
386
|
+
parts = [
|
|
387
|
+
"",
|
|
388
|
+
" Expected: <gitmoji> (<scope>): <message>",
|
|
389
|
+
f" Catalog: {len(load_gitmojis())} official Unicode/shortcode intentions from gitmoji.dev",
|
|
390
|
+
"",
|
|
391
|
+
" Examples:",
|
|
392
|
+
" ✨ Introduce JSON logging mode",
|
|
393
|
+
" :bug: (parser): Handle null token",
|
|
394
|
+
" ♿️ (account): Improve modal accessibility",
|
|
395
|
+
"",
|
|
396
|
+
]
|
|
397
|
+
else:
|
|
398
|
+
parts = [
|
|
399
|
+
"",
|
|
400
|
+
" Expected: <emoji-or-shortcode> <type>(<scope>)!: <description>",
|
|
401
|
+
f" Catalog: {len(_CONVENTIONS)} strict emoji-to-type pairs covering every Gitmoji intention",
|
|
402
|
+
"",
|
|
403
|
+
" Type table:",
|
|
404
|
+
*_format_type_table_lines(),
|
|
405
|
+
"",
|
|
406
|
+
" Examples:",
|
|
407
|
+
*(f" {example}" for example in COMMIT_CONVENTION_EXAMPLES),
|
|
408
|
+
"",
|
|
409
|
+
]
|
|
410
|
+
if include_special_prefix_note:
|
|
411
|
+
parts.append(" Merge, Revert, fixup!, squash!, amend!, and reword! prefixes are allowed (git-generated).")
|
|
412
|
+
parts.append("")
|
|
413
|
+
return "\n".join(parts).rstrip()
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def report_invalid_commit_message(
|
|
417
|
+
normalized: str,
|
|
418
|
+
*,
|
|
419
|
+
context: Literal["hook", "ci"],
|
|
420
|
+
file: TextIO,
|
|
421
|
+
profile: CommitProfile | str | None = None,
|
|
422
|
+
result: ValidationResult | None = None,
|
|
423
|
+
) -> None:
|
|
424
|
+
"""Print a unified error for invalid messages (commit-msg hook or CI title check)."""
|
|
425
|
+
selected = resolve_commit_profile(profile)
|
|
426
|
+
validation = result or validate_commit_message(normalized, profile=selected)
|
|
427
|
+
suggestion = suggest_commit_message(normalized) if selected is CommitProfile.ZENDEV else None
|
|
428
|
+
if context == "hook":
|
|
429
|
+
print("Invalid commit message.", file=file)
|
|
430
|
+
elif selected is CommitProfile.ZENDEV:
|
|
431
|
+
print("::error::Title does not match zendev emoji commit conventions.", file=file)
|
|
432
|
+
else:
|
|
433
|
+
print(f"::error::Title does not match the {selected.value} commit profile.", file=file)
|
|
434
|
+
|
|
435
|
+
if validation.issue is not None:
|
|
436
|
+
print(f"{validation.issue.message} (line {validation.issue.line})", file=file)
|
|
437
|
+
|
|
438
|
+
print(format_commit_convention_help_body(profile=selected), file=file)
|
|
439
|
+
|
|
440
|
+
if suggestion:
|
|
441
|
+
print(f"Maybe you meant: `{suggestion.splitlines()[0]}`.", file=file)
|
|
442
|
+
elif context == "hook":
|
|
443
|
+
print("Example: `✨ feat: generalize upgrade`.", file=file)
|
|
444
|
+
|
|
445
|
+
received_line = normalized.splitlines()[0] if normalized else ""
|
|
446
|
+
print(f"Received: {received_line!r}", file=file)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
@hook_app.command()
|
|
450
|
+
def commit_message(
|
|
451
|
+
commit_msg_file: Annotated[
|
|
452
|
+
Path,
|
|
453
|
+
typer.Argument(
|
|
454
|
+
exists=True,
|
|
455
|
+
file_okay=True,
|
|
456
|
+
dir_okay=False,
|
|
457
|
+
readable=True,
|
|
458
|
+
help="Path to the commit message file provided by Git or the hook runner.",
|
|
459
|
+
),
|
|
460
|
+
],
|
|
461
|
+
profile: Annotated[
|
|
462
|
+
CommitProfileSelection,
|
|
463
|
+
typer.Option(
|
|
464
|
+
"--profile",
|
|
465
|
+
help="Validation profile; auto reads [tool.zendev.commit] and falls back to zendev.",
|
|
466
|
+
),
|
|
467
|
+
] = CommitProfileSelection.AUTO,
|
|
468
|
+
) -> None:
|
|
469
|
+
"""Validate the message file supplied by Git or a hook runner."""
|
|
470
|
+
|
|
471
|
+
try:
|
|
472
|
+
selected = resolve_commit_profile(profile.value, start=commit_msg_file.parent)
|
|
473
|
+
except ValueError as error:
|
|
474
|
+
raise typer.BadParameter(str(error), param_hint="--profile") from error
|
|
475
|
+
message_text = commit_msg_file.read_text(encoding="utf-8")
|
|
476
|
+
comment_char = _git_comment_char(commit_msg_file.parent)
|
|
477
|
+
normalized = normalize_commit_message(message_text, comment_char=comment_char)
|
|
478
|
+
result = validate_commit_message(normalized, profile=selected, comment_char=comment_char)
|
|
479
|
+
if result.valid:
|
|
480
|
+
return
|
|
481
|
+
|
|
482
|
+
report_invalid_commit_message(
|
|
483
|
+
normalized,
|
|
484
|
+
context="hook",
|
|
485
|
+
file=sys.stderr,
|
|
486
|
+
profile=selected,
|
|
487
|
+
result=result,
|
|
488
|
+
)
|
|
489
|
+
raise typer.Exit(code=1)
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _git_comment_char(cwd: Path) -> str:
|
|
493
|
+
result = subprocess.run(
|
|
494
|
+
["git", "config", "--get", "core.commentChar"],
|
|
495
|
+
cwd=cwd,
|
|
496
|
+
check=False,
|
|
497
|
+
capture_output=True,
|
|
498
|
+
text=True,
|
|
499
|
+
)
|
|
500
|
+
value = result.stdout.strip()
|
|
501
|
+
return value if len(value) == 1 else "#"
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def ask() -> ZendevAnswers:
|
|
505
|
+
"""Interactively prompt the user for commit details."""
|
|
506
|
+
choices = [
|
|
507
|
+
questionary.Choice(title=f"{emoji} {name}: {_DESCRIPTIONS[name]}", value=name)
|
|
508
|
+
for name in TYPE_DISPLAY_ORDER
|
|
509
|
+
for emoji in (EMOJI_MAP[name],)
|
|
510
|
+
]
|
|
511
|
+
|
|
512
|
+
prefix = questionary.select("Select the type of change you are committing", choices=choices).ask()
|
|
513
|
+
if prefix is None:
|
|
514
|
+
raise KeyboardInterrupt
|
|
515
|
+
|
|
516
|
+
scope_raw = questionary.text("Scope (press enter to skip):").ask()
|
|
517
|
+
if scope_raw is None:
|
|
518
|
+
raise KeyboardInterrupt
|
|
519
|
+
scope = _parse_scope(scope_raw)
|
|
520
|
+
|
|
521
|
+
subject_raw = questionary.text("Short imperative summary:").ask()
|
|
522
|
+
if subject_raw is None:
|
|
523
|
+
raise KeyboardInterrupt
|
|
524
|
+
subject = _parse_subject(subject_raw)
|
|
525
|
+
|
|
526
|
+
body = questionary.text("Body (press enter to skip):").ask()
|
|
527
|
+
if body is None:
|
|
528
|
+
raise KeyboardInterrupt
|
|
529
|
+
|
|
530
|
+
is_breaking_change = questionary.confirm("Is this a BREAKING CHANGE?", default=False).ask()
|
|
531
|
+
if is_breaking_change is None:
|
|
532
|
+
raise KeyboardInterrupt
|
|
533
|
+
|
|
534
|
+
footer = questionary.text("Footer (press enter to skip):").ask()
|
|
535
|
+
if footer is None:
|
|
536
|
+
raise KeyboardInterrupt
|
|
537
|
+
|
|
538
|
+
return ZendevAnswers(
|
|
539
|
+
prefix=prefix,
|
|
540
|
+
scope=scope,
|
|
541
|
+
subject=subject,
|
|
542
|
+
body=body,
|
|
543
|
+
footer=footer,
|
|
544
|
+
is_breaking_change=is_breaking_change,
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
@commit_app.command()
|
|
549
|
+
def create_commit() -> None:
|
|
550
|
+
"""Prompt for a commit message and invoke Git."""
|
|
551
|
+
|
|
552
|
+
try:
|
|
553
|
+
answers = ask()
|
|
554
|
+
except KeyboardInterrupt:
|
|
555
|
+
print("\nAborted.")
|
|
556
|
+
raise typer.Exit(code=1) from None
|
|
557
|
+
|
|
558
|
+
msg = message(answers)
|
|
559
|
+
result = subprocess.run(["git", "commit", "-m", msg], check=False)
|
|
560
|
+
raise typer.Exit(code=result.returncode)
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def main() -> None:
|
|
564
|
+
"""Run the interactive commit CLI."""
|
|
565
|
+
|
|
566
|
+
commit_app(prog_name="zendev-commit")
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def hook_main() -> None:
|
|
570
|
+
"""Run the reusable commit-msg hook CLI."""
|
|
571
|
+
|
|
572
|
+
hook_app(prog_name="zendev-commit-msg")
|
zendev/commit/py.typed
ADDED
|
File without changes
|