Skip to content

Commit 3641cd9

Browse files
committed
feat: Commit Message convention feature is interactive
1 parent 0d266ce commit 3641cd9

4 files changed

Lines changed: 202 additions & 38 deletions

File tree

.pre-commit-config.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ repos:
2121
entry: uv run ty check
2222
- id: conventional-commits
2323
name: Conventional Commits
24-
language: script
25-
entry: scripts/hooks/check-commit-msg.sh
24+
language: system
25+
entry: python3 scripts/hooks/check_commit_msg.py
2626
stages: [commit-msg]
2727
always_run: true
2828
- repo: https://github.com/asottile/pyupgrade

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ AWS orchestrator scripts run from the root workspace — there is **no** separat
116116
`.pre-commit-config.yaml` enforces on every commit:
117117
- **Syntax / style**: ruff (lint + import sort), ruff-format, pyupgrade, ty
118118
- **Merge safety**: check-merge-conflict, check-executables-have-shebangs, check-shebang-scripts-are-executable
119-
- **Commit format**: Conventional Commits via `scripts/hooks/check-commit-msg.sh` (`<type>[scope]: <description>`)
119+
- **Commit format**: Conventional Commits via `scripts/hooks/check_commit_msg.py` (`<type>[scope]: <description>`) — interactively prompts to fix a non-conforming message when run from a terminal; fails with guidance in CI / non-interactive contexts
120120

121121
`scripts/setup-hooks.sh` additionally installs `detect-secrets` and generates `.secrets.baseline` for **manual** scans. It is not wired into pre-commit — run `detect-secrets scan --baseline .secrets.baseline` manually before pushing changes that touch credentials, hostnames, or generated configs.
122122

scripts/hooks/check-commit-msg.sh

Lines changed: 0 additions & 35 deletions
This file was deleted.

scripts/hooks/check_commit_msg.py

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
#!/usr/bin/env python3
2+
"""Conventional Commits validator with interactive correction.
3+
4+
Wired in as the ``commit-msg`` hook (see ``.pre-commit-config.yaml``).
5+
6+
- If the first line already follows ``<type>[scope][!]: <description>`` it passes
7+
untouched.
8+
- Otherwise, when a controlling terminal is available, it prompts for the type,
9+
optional scope, and breaking-change flag, rewrites the message, and lets the
10+
commit proceed.
11+
- With no terminal (CI, GUI/IDE commit dialogs) it prints guidance and fails,
12+
exactly like a plain non-interactive check.
13+
14+
Standard library only, so it runs under any Python 3.10+ on PATH.
15+
"""
16+
17+
from __future__ import annotations
18+
19+
import io
20+
import os
21+
import re
22+
import sys
23+
import tempfile
24+
25+
# (type, help text) — order drives the interactive menu numbering.
26+
TYPES: list[tuple[str, str]] = [
27+
("feat", "A new feature"),
28+
("fix", "A bug fix"),
29+
("docs", "Documentation only changes"),
30+
("style", "Formatting / whitespace — no code behavior change"),
31+
("refactor", "Code change that neither fixes a bug nor adds a feature"),
32+
("perf", "Performance improvement"),
33+
("test", "Add or fix tests"),
34+
("build", "Build system or dependencies"),
35+
("ci", "CI configuration"),
36+
("chore", "Maintenance / tooling"),
37+
("revert", "Revert a previous commit"),
38+
]
39+
40+
_TYPE_NAMES = "|".join(name for name, _ in TYPES)
41+
PATTERN = re.compile(rf"^({_TYPE_NAMES})(\([a-zA-Z0-9_./-]+\))?(!)?: .+")
42+
SKIP_PATTERN = re.compile(r"^(Merge |Revert |fixup!|squash!)")
43+
SCOPE_PATTERN = re.compile(r"^[a-zA-Z0-9_./-]+$")
44+
# Leading "<word>(scope)?!?:" prefix we strip so a re-typed message isn't doubled.
45+
BOGUS_PREFIX = re.compile(r"^[A-Za-z]+(\([^)]*\))?!?:\s*")
46+
47+
48+
def guidance(message: str) -> None:
49+
"""Print the non-interactive failure message (CI / no TTY)."""
50+
types = " ".join(name for name, _ in TYPES)
51+
lines = [
52+
"ERROR: Commit message does not follow Conventional Commits format.",
53+
"",
54+
" Expected: <type>[optional scope]: <description>",
55+
"",
56+
f" Types: {types}",
57+
"",
58+
" Examples:",
59+
" feat: add login page",
60+
" fix(auth): resolve token expiry issue",
61+
" docs: update README",
62+
" feat!: breaking change to API",
63+
"",
64+
f" Your message: {message}",
65+
]
66+
print("\n".join(lines), file=sys.stderr)
67+
68+
69+
def open_tty() -> tuple[io.TextIOBase, io.TextIOBase] | None:
70+
"""Return ``(reader, writer)`` bound to the controlling terminal, or None.
71+
72+
git and pre-commit pipe stdin, so ``input()`` would hit EOF; talking to the
73+
terminal device directly reaches the user even when stdin is redirected.
74+
"""
75+
# Separate read/write handles: a TTY is not seekable, so a single "r+"
76+
# buffered stream raises UnsupportedOperation on open.
77+
read_dev, write_dev = (
78+
("CONIN$", "CONOUT$") if os.name == "nt" else ("/dev/tty", "/dev/tty")
79+
)
80+
try:
81+
reader = open(read_dev, encoding="utf-8")
82+
writer = open(write_dev, "w", encoding="utf-8")
83+
except OSError:
84+
return None
85+
return reader, writer
86+
87+
88+
def _ask(reader: io.TextIOBase, writer: io.TextIOBase, prompt: str) -> str | None:
89+
"""Write a prompt to the terminal and read one line; None on EOF."""
90+
writer.write(prompt)
91+
writer.flush()
92+
line = reader.readline()
93+
if line == "": # Ctrl-D / closed terminal
94+
return None
95+
return line.strip()
96+
97+
98+
def correct(message: str, reader: io.TextIOBase, writer: io.TextIOBase) -> str | None:
99+
"""Interactively build a valid first line; None if the user aborts."""
100+
writer.write("\nCommit message is not in Conventional Commits format:\n")
101+
writer.write(f" {message}\n\n")
102+
writer.write("Pick a type:\n")
103+
for i, (name, desc) in enumerate(TYPES, start=1):
104+
writer.write(f" {i:2d}) {name:<9} {desc}\n")
105+
writer.write(" q) abort commit\n\n")
106+
writer.flush()
107+
108+
chosen = ""
109+
while not chosen:
110+
answer = _ask(reader, writer, f"Type number [1-{len(TYPES)}/q]: ")
111+
if answer is None or answer.lower() == "q":
112+
writer.write("Aborted.\n")
113+
writer.flush()
114+
return None
115+
if not answer.isdigit():
116+
writer.write(" Please enter a number from the list.\n")
117+
writer.flush()
118+
continue
119+
idx = int(answer)
120+
if 1 <= idx <= len(TYPES):
121+
chosen = TYPES[idx - 1][0]
122+
else:
123+
writer.write(" Out of range.\n")
124+
writer.flush()
125+
126+
scope = ""
127+
while True:
128+
answer = _ask(reader, writer, "Optional scope (Enter to skip): ")
129+
scope = answer or ""
130+
if scope == "" or SCOPE_PATTERN.match(scope):
131+
break
132+
writer.write(" Scope may only contain letters, digits, and _ . / -\n")
133+
writer.flush()
134+
135+
answer = _ask(reader, writer, "Breaking change? [y/N]: ")
136+
bang = "!" if (answer or "").lower() in {"y", "yes"} else ""
137+
138+
description = BOGUS_PREFIX.sub("", message).strip()
139+
while not description:
140+
answer = _ask(reader, writer, "Description: ")
141+
if answer is None:
142+
writer.write("\nAborted.\n")
143+
writer.flush()
144+
return None
145+
description = answer
146+
147+
scope_part = f"({scope})" if scope else ""
148+
return f"{chosen}{scope_part}{bang}: {description}"
149+
150+
151+
def main(argv: list[str] | None = None) -> int:
152+
args = sys.argv[1:] if argv is None else argv
153+
if not args:
154+
print("usage: check_commit_msg.py <commit-msg-file>", file=sys.stderr)
155+
return 2
156+
157+
path = args[0]
158+
with open(path, encoding="utf-8") as f:
159+
lines = f.read().splitlines()
160+
first = lines[0] if lines else ""
161+
162+
if SKIP_PATTERN.match(first) or PATTERN.match(first):
163+
return 0
164+
165+
tty = open_tty()
166+
if tty is None:
167+
guidance(first)
168+
return 1
169+
170+
reader, writer = tty
171+
try:
172+
new_first = correct(first, reader, writer)
173+
if new_first is None:
174+
return 1
175+
if not PATTERN.match(new_first):
176+
writer.write("\n Could not build a valid message from that input.\n")
177+
writer.flush()
178+
guidance(first)
179+
return 1
180+
181+
body = lines[1:]
182+
fd, tmp = tempfile.mkstemp()
183+
with os.fdopen(fd, "w", encoding="utf-8") as out:
184+
out.write(new_first + "\n")
185+
if body:
186+
out.write("\n".join(body) + "\n")
187+
os.replace(tmp, path)
188+
189+
writer.write(f"\nRewrote commit message to:\n {new_first}\n\n")
190+
writer.flush()
191+
return 0
192+
finally:
193+
reader.close()
194+
if writer is not reader:
195+
writer.close()
196+
197+
198+
if __name__ == "__main__":
199+
raise SystemExit(main())

0 commit comments

Comments
 (0)