Skip to content

Commit b74f825

Browse files
chore[ci]: add commit message formatter (#4711)
targets commitmsg.txt by default. helpful for preparing commit messages locally
1 parent d1a9b74 commit b74f825

File tree

2 files changed

+175
-0
lines changed

2 files changed

+175
-0
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,6 @@ tmp/
5959
trees/
6060
worktrees/
6161
repros/
62+
63+
# used by fmt_commit_msg.py
64+
commitmsg.txt

fmt_commit_msg.py

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Format commit messages while preserving:
4+
- List items (-, *, +, numbered)
5+
- Code blocks
6+
- Blank lines between paragraphs
7+
- Intentional formatting
8+
"""
9+
10+
import sys
11+
import re
12+
from textwrap import fill
13+
14+
15+
def is_list_item(line):
16+
"""Check if a line starts with a list marker."""
17+
stripped = line.lstrip()
18+
# bullet lists: -, *, +
19+
if stripped.startswith(("- ", "* ", "+ ")):
20+
return True
21+
# numbered lists: 1. 2. etc
22+
if re.match(r"^\d+\.\s", stripped):
23+
return True
24+
return False
25+
26+
27+
def get_list_prefix(line):
28+
"""Extract the list prefix and calculate continuation indent."""
29+
indent = len(line) - len(line.lstrip())
30+
stripped = line.lstrip()
31+
32+
# bullet lists - continuation aligns with text after marker
33+
for marker in ["- ", "* ", "+ "]:
34+
if stripped.startswith(marker):
35+
prefix = " " * indent + marker
36+
content = stripped[len(marker) :].strip()
37+
# continuation lines indent by 2 spaces after the bullet
38+
cont_indent = " " * (indent + 2)
39+
return prefix, content, cont_indent
40+
41+
# numbered lists - continuation aligns with text after number
42+
match = re.match(r"^(\d+\.)\s+", stripped)
43+
if match:
44+
number = match.group(1)
45+
prefix = " " * indent + number + " "
46+
content = stripped[match.end() :].strip()
47+
# continuation lines align with the start of text, not the number
48+
cont_indent = " " * (indent + len(number) + 1)
49+
return prefix, content, cont_indent
50+
51+
return None, line.strip(), ""
52+
53+
54+
def format_commit_message(text, width=72):
55+
"""Format commit message text while preserving structure."""
56+
lines = text.split("\n")
57+
result = []
58+
i = 0
59+
60+
while i < len(lines):
61+
line = lines[i]
62+
63+
# preserve blank lines
64+
if not line.strip():
65+
result.append("")
66+
i += 1
67+
continue
68+
69+
# handle code blocks
70+
if line.strip().startswith("```"):
71+
# preserve the opening fence
72+
result.append(line)
73+
i += 1
74+
75+
# preserve all lines until closing fence
76+
while i < len(lines):
77+
result.append(lines[i])
78+
if lines[i].strip().startswith("```"):
79+
i += 1
80+
break
81+
i += 1
82+
continue
83+
84+
# handle list items
85+
if is_list_item(line):
86+
prefix, content, cont_indent = get_list_prefix(line)
87+
88+
# collect continuation lines for this list item
89+
j = i + 1
90+
while j < len(lines) and lines[j].strip() and not is_list_item(lines[j]):
91+
content += " " + lines[j].strip()
92+
j += 1
93+
94+
# wrap the list item content
95+
wrapped = fill(
96+
content, width=width - len(prefix), initial_indent="", subsequent_indent=""
97+
)
98+
99+
# add the prefix to the first line
100+
wrapped_lines = wrapped.split("\n")
101+
result.append(prefix + wrapped_lines[0])
102+
103+
# add continuation lines with proper indent
104+
for wline in wrapped_lines[1:]:
105+
result.append(cont_indent + wline)
106+
107+
i = j
108+
continue
109+
110+
# handle regular paragraphs
111+
paragraph = []
112+
while (
113+
i < len(lines)
114+
and lines[i].strip()
115+
and not is_list_item(lines[i])
116+
and not lines[i].strip().startswith("```")
117+
):
118+
paragraph.append(lines[i].strip())
119+
i += 1
120+
121+
if paragraph:
122+
# join and wrap the paragraph
123+
text = " ".join(paragraph)
124+
wrapped = fill(text, width=width)
125+
result.extend(wrapped.split("\n"))
126+
127+
return "\n".join(result)
128+
129+
130+
def main():
131+
import argparse
132+
133+
parser = argparse.ArgumentParser(description="Format commit messages")
134+
parser.add_argument(
135+
"file", nargs="?", default="commitmsg.txt", help="File to format (default: commitmsg.txt)"
136+
)
137+
parser.add_argument(
138+
"-n",
139+
"--dry-run",
140+
action="store_true",
141+
help="Print formatted output without modifying the file",
142+
)
143+
144+
args = parser.parse_args()
145+
146+
# read from file or stdin
147+
if args.file == "-":
148+
text = sys.stdin.read()
149+
else:
150+
try:
151+
with open(args.file, "r") as f:
152+
text = f.read()
153+
except FileNotFoundError:
154+
print(f"Error: File '{args.file}' not found", file=sys.stderr)
155+
sys.exit(1)
156+
157+
# format the message
158+
formatted = format_commit_message(text)
159+
160+
# output
161+
if args.dry_run or args.file == "-":
162+
# dry run or stdin: just print
163+
print(formatted)
164+
else:
165+
# write back to file and print
166+
with open(args.file, "w") as f:
167+
f.write(formatted)
168+
print(formatted)
169+
170+
171+
if __name__ == "__main__":
172+
main()

0 commit comments

Comments
 (0)