Merge pull request #1183 from hieblmi/gate-commit-messages
Some checks are pending
CI / Commit Message (push) Waiting to run
CI / RPC compilation check (push) Waiting to run
CI / SQL compilation check (push) Waiting to run
CI / go mod check (push) Waiting to run
CI / build and lint code (push) Waiting to run
CI / verify that auto-generated documentation is up-to-date (push) Waiting to run
CI / run unit-test sqlite3 race (push) Waiting to run
CI / run unit-test postgres race (push) Waiting to run
CI / run LiT itests (push) Waiting to run
CI / run LiT unit tests (push) Waiting to run

build: add commit message tooling
This commit is contained in:
Slyghtning 2026-07-22 21:16:13 +02:00 committed by GitHub
commit dbf1205b57
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 1058 additions and 0 deletions

View file

@ -24,6 +24,37 @@ env:
LITD_ITEST_BRANCH: master LITD_ITEST_BRANCH: master
jobs: jobs:
########################
# commit message lint
########################
commit-message:
name: Commit Message
runs-on: ubuntu-latest
steps:
- name: git checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Lint commit messages
env:
EVENT_NAME: ${{ github.event_name }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
BEFORE_SHA: ${{ github.event.before }}
GITHUB_SHA: ${{ github.sha }}
run: |
if [ "$EVENT_NAME" = "pull_request" ]; then
range="$BASE_SHA..$HEAD_SHA"
elif [ "$EVENT_NAME" = "push" ] &&
[ "$BEFORE_SHA" != "0000000000000000000000000000000000000000" ]; then
range="$BEFORE_SHA..$GITHUB_SHA"
else
range="$(git rev-parse HEAD^ 2>/dev/null)..HEAD"
fi
make commitmsg-lint range="$range"
######################## ########################
# RPC compile and check # RPC compile and check
######################## ########################

View file

@ -174,6 +174,43 @@ endif
cd tools/ && GOPROXY=direct $(GOMOD) tidy cd tools/ && GOPROXY=direct $(GOMOD) tidy
if test -n "$$(git status --porcelain)"; then echo "Running go mod tidy changes go.mod/go.sum"; git status; git diff; exit 1; fi if test -n "$$(git status --porcelain)"; then echo "Running go mod tidy changes go.mod/go.sum"; git status; git diff; exit 1; fi
commitmsg-lint:
@$(call print, "Linting commit message(s).")
@if [ -n "$(range)" ]; then \
./scripts/commit_message.py lint --range "$(range)"; \
elif [ -n "$(commit)" ]; then \
./scripts/commit_message.py lint --commit "$(commit)"; \
elif [ -n "$(file)" ]; then \
./scripts/commit_message.py lint --file "$(file)"; \
else \
./scripts/commit_message.py lint --commit HEAD; \
fi
commitmsg-fmt:
@$(call print, "Formatting commit message.")
@if [ -n "$(file)" ]; then \
if [ "$(inplace)" = "1" ]; then \
./scripts/commit_message.py fmt --file "$(file)" --in-place \
$(if $(filter 1,$(decode)),--decode-escaped-newlines,); \
else \
./scripts/commit_message.py fmt --file "$(file)" \
$(if $(filter 1,$(decode)),--decode-escaped-newlines,); \
fi; \
elif [ -n "$(commit)" ]; then \
./scripts/commit_message.py fmt --commit "$(commit)" \
$(if $(filter 1,$(decode)),--decode-escaped-newlines,); \
else \
echo "Error: provide file=<path> or commit=<rev>"; \
exit 1; \
fi
commitmsg-reword:
@$(call print, "Rewording commit with formatted message.")
@./scripts/commit_message.py reword \
--commit "$(if $(commit),$(commit),HEAD)" \
$(if $(filter 1,$(decode)),--decode-escaped-newlines,) \
$(if $(filter 1,$(dryrun)),--dry-run,)
sqlc: sqlc:
@$(call print, "Generating sql models and queries in Go") @$(call print, "Generating sql models and queries in Go")
./scripts/gen_sqlc_docker.sh ./scripts/gen_sqlc_docker.sh

30
docs/commit-tooling.md Normal file
View file

@ -0,0 +1,30 @@
# Commit Message Tooling
Use `scripts/commit_message.py` to lint, format, and safely reword commit
messages. The script enforces subject/body wrapping (`69`/`72`), keeps real
newlines, and preserves markdown-like body structure (lists, quotes, fenced
blocks, trailers).
## Common Workflows
```bash
# Lint the current commit message
make commitmsg-lint commit=HEAD
# Lint a commit range
make commitmsg-lint range=upstream/master..HEAD
# Format a message file in place
make commitmsg-fmt file=/tmp/msg inplace=1
# Reword a commit from formatted output
make commitmsg-reword commit=<sha>
# Preview a reword without rewriting history
make commitmsg-reword commit=<sha> dryrun=1
```
## Literal Newlines
If a message was created with literal `\n` sequences, use `decode=1` with
`commitmsg-fmt` or `commitmsg-reword` to convert them to real line breaks.

960
scripts/commit_message.py Executable file
View file

@ -0,0 +1,960 @@
#!/usr/bin/env python3
"""
commit_message lints, formats, and rewrites Git commit messages.
Goals:
- Enforce a commit subject in "<package>: <summary>" form.
- Wrap subjects and bodies using repository defaults (69/72 columns).
- Preserve markdown-like body structure:
- Paragraph breaks.
- Bullet/numbered list item indentation.
- Fenced and indented code blocks.
- Git trailers.
- Support linting existing commit ranges and rewording commits in-place.
"""
from __future__ import annotations
import argparse
import os
import re
import subprocess
import sys
import tempfile
import textwrap
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable
DEFAULT_SUBJECT_WIDTH = 69
DEFAULT_BODY_WIDTH = 72
SUBJECT_PATTERN = re.compile(
r"^(?P<pkg>[A-Za-z0-9_][A-Za-z0-9+_.\-/]*): (?P<summary>.+)$"
)
LIST_ITEM_PATTERN = re.compile(
r"^(?P<indent>\s*)(?P<marker>[-+*]|\d+[.)])(?P<space>\s+)(?P<text>.*)$"
)
TRAILER_PATTERN = re.compile(r"^[A-Za-z0-9-]+:\s+\S.*$")
FENCE_PATTERN = re.compile(r"^\s*(```|~~~)")
QUOTE_PATTERN = re.compile(r"^(?P<indent>\s*)>\s?(?P<text>.*)$")
NEWLINE_ESCAPE_PATTERN = re.compile(r"\\n")
@dataclass
class LintIssue:
"""LintIssue captures one formatting violation."""
line: int
message: str
@dataclass
class LintResult:
"""LintResult stores issues and whether a message passed lint."""
issues: list[LintIssue]
def ok(self) -> bool:
return len(self.issues) == 0
def run_git(args: list[str]) -> str:
"""run_git executes git and returns trimmed stdout."""
return subprocess.check_output(
["git", *args],
stderr=subprocess.STDOUT,
text=True,
).rstrip("\n")
def get_commit_message(rev: str) -> str:
"""get_commit_message returns the full commit message for a revision."""
return run_git(["show", "-s", "--format=%B", rev])
def commit_subject(rev: str) -> str:
"""commit_subject returns only the subject line for a revision."""
return run_git(["show", "-s", "--format=%s", rev])
def commit_is_merge(rev: str) -> bool:
"""commit_is_merge reports whether rev has more than one parent."""
parents = run_git(["rev-list", "--parents", "-n", "1", rev]).split()
return len(parents) > 2
def split_subject_body(message: str) -> tuple[str, list[str]]:
"""
split_subject_body returns (subject, body_lines) from a commit message.
The first line is always treated as subject. Any following lines are body.
"""
lines = message.splitlines()
if not lines:
return "", []
return lines[0], lines[1:]
def _count_columns(text: str) -> int:
"""_count_columns counts characters for width checks."""
return len(text)
def _is_list_item(line: str) -> bool:
"""_is_list_item checks whether a line starts a markdown-like list item."""
return LIST_ITEM_PATTERN.match(line) is not None
def _is_trailer(line: str) -> bool:
"""_is_trailer checks whether a line is a git trailer."""
return TRAILER_PATTERN.match(line) is not None
def _is_indented_code(line: str) -> bool:
"""_is_indented_code checks whether a line looks like indented code."""
if "\t" in line[:1]:
return True
spaces = len(line) - len(line.lstrip(" "))
return spaces >= 4 and line.strip() != ""
def _is_quote_line(line: str) -> bool:
"""_is_quote_line checks whether a line is markdown quote text."""
return QUOTE_PATTERN.match(line) is not None
def _normalize_body_leading_blank(body_lines: list[str]) -> list[str]:
"""
_normalize_body_leading_blank removes body-leading blank lines.
The formatter emits exactly one blank separator between subject and body.
"""
idx = 0
while idx < len(body_lines) and body_lines[idx].strip() == "":
idx += 1
return body_lines[idx:]
def _normalize_body_trailing_blank(body_lines: list[str]) -> list[str]:
"""_normalize_body_trailing_blank trims trailing blank lines."""
idx = len(body_lines)
while idx > 0 and body_lines[idx - 1].strip() == "":
idx -= 1
return body_lines[:idx]
def _collapse_redundant_blank_lines(lines: list[str]) -> list[str]:
"""_collapse_redundant_blank_lines keeps at most one consecutive blank."""
out: list[str] = []
blank = False
for line in lines:
is_blank = line.strip() == ""
if is_blank and blank:
continue
out.append(line)
blank = is_blank
return out
def _decode_escaped_newlines(lines: list[str]) -> list[str]:
"""
_decode_escaped_newlines expands literal "\\n" sequences into real lines.
This targets the common shell-escaping mistake from `git commit -m`.
"""
out: list[str] = []
split_re = re.compile(r"(?<!\\)\\n")
for line in lines:
if "\\n" not in line:
out.append(line)
continue
out.extend(split_re.split(line))
return out
def wrap_text(
text: str,
width: int,
initial_indent: str = "",
subsequent_indent: str = "",
) -> list[str]:
"""wrap_text wraps a paragraph without splitting long tokens."""
wrapper = textwrap.TextWrapper(
width=width,
initial_indent=initial_indent,
subsequent_indent=subsequent_indent,
break_long_words=False,
break_on_hyphens=False,
replace_whitespace=True,
drop_whitespace=True,
)
wrapped = wrapper.wrap(text)
return wrapped if wrapped else [initial_indent.rstrip()]
def _consume_paragraph(body: list[str], start: int) -> tuple[int, list[str]]:
"""
_consume_paragraph consumes plain paragraph lines from body[start:].
It stops before blank lines and before other structured blocks.
"""
parts: list[str] = []
i = start
while i < len(body):
line = body[i]
if line.strip() == "":
break
if FENCE_PATTERN.match(line):
break
if _is_list_item(line):
break
if _is_indented_code(line):
break
if _is_trailer(line):
break
if _is_quote_line(line):
break
parts.append(line.strip())
i += 1
return i, parts
def _format_paragraph(parts: list[str], width: int) -> list[str]:
"""_format_paragraph normalizes spacing and wraps plain text paragraphs."""
text = " ".join(part for part in parts if part)
if not text:
return []
return wrap_text(text, width)
def _consume_quote_block(
body: list[str],
start: int,
width: int,
) -> tuple[int, list[str]]:
"""_consume_quote_block consumes consecutive markdown quote lines."""
i = start
quoted: list[str] = []
indent = ""
while i < len(body):
line = body[i]
if line.strip() == "":
break
match = QUOTE_PATTERN.match(line)
if not match:
break
indent = match.group("indent")
quoted.append(match.group("text").strip())
i += 1
text = " ".join(fragment for fragment in quoted if fragment)
if not text:
return i, [f"{indent}>"]
prefix = f"{indent}> "
return i, wrap_text(
text,
width,
initial_indent=prefix,
subsequent_indent=prefix,
)
def _consume_list_item(
body: list[str],
start: int,
width: int,
) -> tuple[int, list[str]]:
"""
_consume_list_item consumes a single list item with continuation lines.
Continuation lines are treated as part of the item until a new peer list
item starts or a blank line is reached.
"""
first = body[start]
match = LIST_ITEM_PATTERN.match(first)
if match is None:
return start + 1, [first.rstrip()]
indent = match.group("indent")
marker = match.group("marker")
content = [match.group("text").strip()]
i = start + 1
while i < len(body):
line = body[i]
if line.strip() == "":
break
next_match = LIST_ITEM_PATTERN.match(line)
if next_match is not None and len(next_match.group("indent")) <= len(indent):
break
if FENCE_PATTERN.match(line):
break
if _is_trailer(line):
break
content.append(line.strip())
i += 1
text = " ".join(part for part in content if part)
prefix = f"{indent}{marker} "
continuation = " " * len(prefix)
if not text:
return i, [prefix.rstrip()]
return i, wrap_text(
text,
width,
initial_indent=prefix,
subsequent_indent=continuation,
)
def _consume_fence_block(body: list[str], start: int) -> tuple[int, list[str]]:
"""_consume_fence_block preserves fenced code blocks verbatim."""
lines: list[str] = []
i = start
open_line = body[i].rstrip()
lines.append(open_line)
i += 1
while i < len(body):
line = body[i].rstrip()
lines.append(line)
if FENCE_PATTERN.match(line):
i += 1
break
i += 1
return i, lines
def _consume_indented_block(body: list[str], start: int) -> tuple[int, list[str]]:
"""_consume_indented_block preserves indented code blocks verbatim."""
lines: list[str] = []
i = start
while i < len(body):
line = body[i]
if line.strip() == "":
lines.append("")
i += 1
continue
if _is_indented_code(line):
lines.append(line.rstrip())
i += 1
continue
break
while lines and lines[-1] == "":
lines.pop()
return i, lines
def format_body(body_lines: list[str], body_width: int) -> list[str]:
"""format_body returns a wrapped body while preserving markdown structure."""
body = _normalize_body_leading_blank(body_lines)
body = _normalize_body_trailing_blank(body)
if not body:
return []
out: list[str] = []
i = 0
while i < len(body):
line = body[i]
if line.strip() == "":
out.append("")
i += 1
continue
if FENCE_PATTERN.match(line):
i, block = _consume_fence_block(body, i)
out.extend(block)
continue
if _is_indented_code(line):
i, block = _consume_indented_block(body, i)
out.extend(block)
continue
if _is_list_item(line):
while i < len(body):
current = body[i]
if current.strip() == "":
break
if not _is_list_item(current):
break
i, item = _consume_list_item(body, i, body_width)
out.extend(item)
continue
if _is_quote_line(line):
i, quoted = _consume_quote_block(body, i, body_width)
out.extend(quoted)
continue
if _is_trailer(line):
out.append(line.rstrip())
i += 1
continue
i, para = _consume_paragraph(body, i)
out.extend(_format_paragraph(para, body_width))
return _collapse_redundant_blank_lines(out)
def format_message(
message: str,
subject_width: int = DEFAULT_SUBJECT_WIDTH,
body_width: int = DEFAULT_BODY_WIDTH,
decode_escaped_newlines: bool = False,
) -> str:
"""format_message normalizes spacing and wraps subject/body."""
subject, body_lines = split_subject_body(message)
subject = subject.strip()
if decode_escaped_newlines:
body_lines = _decode_escaped_newlines(body_lines)
match = SUBJECT_PATTERN.match(subject)
if match is None:
wrapped_subject = wrap_text(subject, subject_width)
subject_out = " ".join(wrapped_subject).strip()
else:
pkg = match.group("pkg")
summary = match.group("summary").strip()
prefix = f"{pkg}: "
available = max(10, subject_width - len(prefix))
summary_wrapped = wrap_text(summary, available)
subject_out = prefix + " ".join(
line.strip() for line in summary_wrapped if line.strip()
)
body_lines = format_body(body_lines, body_width)
if not body_lines:
return f"{subject_out}\n"
return f"{subject_out}\n\n" + "\n".join(body_lines) + "\n"
def lint_message(
message: str,
subject_width: int = DEFAULT_SUBJECT_WIDTH,
body_width: int = DEFAULT_BODY_WIDTH,
) -> LintResult:
"""lint_message validates message structure and line widths."""
issues: list[LintIssue] = []
subject, body_lines = split_subject_body(message)
if not subject:
issues.append(LintIssue(1, "empty commit message subject"))
return LintResult(issues)
if _count_columns(subject) > subject_width:
issues.append(
LintIssue(
1,
f"subject length {_count_columns(subject)} exceeds "
f"{subject_width} chars",
)
)
match = SUBJECT_PATTERN.match(subject)
if match is None:
issues.append(
LintIssue(
1,
'subject must match "<package>: <summary>"',
)
)
else:
summary = match.group("summary")
if summary.endswith("."):
issues.append(
LintIssue(1, "subject summary should not end with period")
)
if body_lines:
if body_lines[0].strip() != "":
issues.append(
LintIssue(
2,
"body must be separated from subject by one blank line",
)
)
else:
leading_blanks = 0
while (
leading_blanks < len(body_lines)
and body_lines[leading_blanks].strip() == ""
):
leading_blanks += 1
if leading_blanks > 1 and leading_blanks < len(body_lines):
issues.append(
LintIssue(
2,
"use exactly one blank line between subject and body",
)
)
in_fence = False
for idx, line in enumerate(body_lines, start=2):
if NEWLINE_ESCAPE_PATTERN.search(line):
issues.append(
LintIssue(
idx,
r'found literal "\n"; use real newlines in commit body',
)
)
if FENCE_PATTERN.match(line):
in_fence = not in_fence
continue
if in_fence:
continue
if line.strip() == "":
continue
if _is_indented_code(line):
continue
if _is_trailer(line):
continue
width = _count_columns(line)
if width > body_width:
issues.append(
LintIssue(
idx,
f"body line length {width} exceeds {body_width} chars",
)
)
return LintResult(issues)
def read_input_message(args: argparse.Namespace) -> str:
"""read_input_message reads commit message input from file/stdin/commit."""
if args.file:
return Path(args.file).read_text(encoding="utf-8")
if args.commit:
return get_commit_message(args.commit)
if not sys.stdin.isatty():
return sys.stdin.read()
raise ValueError("no input provided: use --file, --commit, or stdin")
def write_output_message(args: argparse.Namespace, message: str) -> None:
"""write_output_message writes formatted output to stdout or file."""
if args.in_place and args.file:
Path(args.file).write_text(message, encoding="utf-8")
return
sys.stdout.write(message)
def _lint_one(
label: str,
message: str,
subject_width: int,
body_width: int,
) -> bool:
"""_lint_one lints one message and prints issues. Returns success."""
result = lint_message(
message,
subject_width=subject_width,
body_width=body_width,
)
if result.ok():
print(f"{label}: OK")
return True
print(f"{label}: FAIL")
for issue in result.issues:
print(f" L{issue.line}: {issue.message}")
return False
def _collect_revs(range_expr: str, include_merges: bool) -> list[str]:
"""_collect_revs returns commits in a revision range."""
args = ["rev-list", "--reverse", range_expr]
if not include_merges:
args.insert(1, "--no-merges")
out = run_git(args)
return [line for line in out.splitlines() if line.strip()]
def lint_command(args: argparse.Namespace) -> int:
"""lint_command runs linting for a message source or commit range."""
all_ok = True
if args.range:
revs = _collect_revs(args.range, include_merges=args.include_merges)
if not revs:
print(f"no commits found in range: {args.range}")
return 0
for rev in revs:
if not args.include_merges and commit_is_merge(rev):
continue
subject = commit_subject(rev)
label = f"{rev[:7]} {subject}"
all_ok &= _lint_one(
label,
get_commit_message(rev),
args.subject_width,
args.body_width,
)
return 0 if all_ok else 1
message = read_input_message(args)
label = args.file or args.commit or "<stdin>"
ok = _lint_one(label, message, args.subject_width, args.body_width)
return 0 if ok else 1
def fmt_command(args: argparse.Namespace) -> int:
"""fmt_command formats a message from file/stdin/commit."""
message = read_input_message(args)
formatted = format_message(
message,
subject_width=args.subject_width,
body_width=args.body_width,
decode_escaped_newlines=args.decode_escaped_newlines,
)
if args.check:
if message == formatted:
return 0
print("message is not properly formatted")
return 1
write_output_message(args, formatted)
return 0
def _assert_clean_worktree() -> None:
"""_assert_clean_worktree ensures there are no staged or unstaged changes."""
status = run_git(["status", "--porcelain"])
if status.strip():
raise ValueError(
"worktree is not clean; commit rewriting requires a clean state"
)
def _reword_head(new_message: str, no_verify: bool) -> None:
"""_reword_head amends HEAD with new_message."""
with tempfile.NamedTemporaryFile("w", delete=False) as tmp:
tmp.write(new_message)
tmp_path = tmp.name
try:
cmd = ["git", "commit", "--amend", "-F", tmp_path]
if no_verify:
cmd.append("--no-verify")
subprocess.check_call(cmd)
finally:
Path(tmp_path).unlink(missing_ok=True)
def _reword_non_head(target_rev: str, new_message: str, no_verify: bool) -> None:
"""_reword_non_head rewrites a non-HEAD commit via non-interactive rebase."""
full = run_git(["rev-parse", target_rev])
head = run_git(["rev-parse", "HEAD"])
if full == head:
_reword_head(new_message, no_verify=no_verify)
return
merge_count = run_git(["rev-list", "--count", "--merges", f"{full}..HEAD"])
if int(merge_count) > 0:
raise ValueError(
"range from target commit to HEAD contains merge commits; "
"automatic reword is disabled for this case"
)
is_ancestor = subprocess.call(
["git", "merge-base", "--is-ancestor", full, "HEAD"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
if is_ancestor != 0:
raise ValueError(f"target commit {target_rev} is not an ancestor of HEAD")
parent = run_git(["rev-parse", f"{full}^"])
with tempfile.TemporaryDirectory() as tmpdir:
tmp = Path(tmpdir)
msg_file = tmp / "new-message.txt"
seq_editor = tmp / "sequence-editor.py"
msg_editor = tmp / "message-editor.py"
msg_file.write_text(new_message, encoding="utf-8")
seq_editor.write_text(
textwrap.dedent(
"""\
#!/usr/bin/env python3
import os
import pathlib
import re
import sys
target = os.environ["COMMIT_REWORD_TARGET"]
todo_path = pathlib.Path(sys.argv[1])
lines = todo_path.read_text(encoding="utf-8").splitlines()
out = []
replaced = False
for line in lines:
match = re.match(r"^(pick|p)\\s+([0-9a-f]+)(\\s+.*)$", line)
if match and not replaced:
commit = match.group(2)
if target.startswith(commit) or commit.startswith(target):
out.append(f"reword {commit}{match.group(3)}")
replaced = True
continue
out.append(line)
if not replaced:
raise SystemExit(
f"could not find commit {target} in rebase todo"
)
todo_path.write_text(
"\\n".join(out) + "\\n",
encoding="utf-8",
)
"""
),
encoding="utf-8",
)
msg_editor.write_text(
textwrap.dedent(
"""\
#!/usr/bin/env python3
import pathlib
import shutil
import sys
src = pathlib.Path(__import__("os").environ["COMMIT_REWORD_FILE"])
dst = pathlib.Path(sys.argv[1])
shutil.copyfile(src, dst)
"""
),
encoding="utf-8",
)
seq_editor.chmod(0o755)
msg_editor.chmod(0o755)
env = os.environ.copy()
env["COMMIT_REWORD_TARGET"] = full
env["COMMIT_REWORD_FILE"] = str(msg_file)
env["GIT_SEQUENCE_EDITOR"] = str(seq_editor)
env["GIT_EDITOR"] = str(msg_editor)
cmd = ["git", "rebase", "-i", parent]
if no_verify:
cmd.append("--no-verify")
subprocess.check_call(cmd, env=env)
def reword_command(args: argparse.Namespace) -> int:
"""reword_command rewrites a commit message using formatted content."""
original = get_commit_message(args.commit)
formatted = format_message(
original,
subject_width=args.subject_width,
body_width=args.body_width,
decode_escaped_newlines=args.decode_escaped_newlines,
)
if original == formatted and not args.force:
print("commit message already formatted; nothing to do")
return 0
if args.dry_run:
print(formatted, end="")
return 0
_assert_clean_worktree()
try:
_reword_non_head(
args.commit,
formatted,
no_verify=args.no_verify,
)
except subprocess.CalledProcessError as err:
print(f"git command failed with exit status {err.returncode}")
return err.returncode or 1
except ValueError as err:
print(f"error: {err}")
return 1
print(f"reworded commit {args.commit} with formatted message")
return 0
def build_parser() -> argparse.ArgumentParser:
"""build_parser creates the CLI parser."""
parser = argparse.ArgumentParser(
description=(
"Lint, format, and reword commit messages with markdown-aware "
"body wrapping."
)
)
parser.add_argument(
"--subject-width",
type=int,
default=DEFAULT_SUBJECT_WIDTH,
help=f"max subject width (default: {DEFAULT_SUBJECT_WIDTH})",
)
parser.add_argument(
"--body-width",
type=int,
default=DEFAULT_BODY_WIDTH,
help=f"max body width (default: {DEFAULT_BODY_WIDTH})",
)
sub = parser.add_subparsers(dest="cmd", required=True)
lint = sub.add_parser("lint", help="lint a message or commit range")
lint.add_argument("--file", help="path to commit message file")
lint.add_argument("--commit", help="commit revision to lint")
lint.add_argument(
"--range",
help="lint all commits in revision range (example: origin/main..HEAD)",
)
lint.add_argument(
"--include-merges",
action="store_true",
help="include merge commits when linting --range",
)
lint.set_defaults(func=lint_command)
fmt = sub.add_parser("fmt", help="format a message from file/stdin/commit")
fmt.add_argument("--file", help="path to commit message file")
fmt.add_argument("--commit", help="commit revision to read as input")
fmt.add_argument(
"--in-place",
action="store_true",
help="write formatted output back to --file",
)
fmt.add_argument(
"--check",
action="store_true",
help="exit non-zero if formatting changes would be applied",
)
fmt.add_argument(
"--decode-escaped-newlines",
action="store_true",
help='decode literal "\\n" body sequences before formatting',
)
fmt.set_defaults(func=fmt_command)
reword = sub.add_parser(
"reword",
help="reword an existing commit with its formatted message",
)
reword.add_argument(
"--commit",
default="HEAD",
help="commit revision to reword (default: HEAD)",
)
reword.add_argument(
"--force",
action="store_true",
help="rewrite even if message is already formatted",
)
reword.add_argument(
"--no-verify",
action="store_true",
help="pass --no-verify to git amend/rebase reword operations",
)
reword.add_argument(
"--decode-escaped-newlines",
action="store_true",
help='decode literal "\\n" body sequences before rewording',
)
reword.add_argument(
"--dry-run",
action="store_true",
help="print the formatted message instead of rewriting commits",
)
reword.set_defaults(func=reword_command)
return parser
def main(argv: Iterable[str] | None = None) -> int:
"""main parses arguments and dispatches subcommands."""
parser = build_parser()
args = parser.parse_args(argv)
try:
return int(args.func(args))
except ValueError as err:
print(f"error: {err}")
return 1
if __name__ == "__main__":
sys.exit(main())