mirror of
https://github.com/lightninglabs/loop.git
synced 2026-08-13 12:33:03 +02:00
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
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:
commit
dbf1205b57
4 changed files with 1058 additions and 0 deletions
31
.github/workflows/main.yml
vendored
31
.github/workflows/main.yml
vendored
|
|
@ -24,6 +24,37 @@ env:
|
|||
LITD_ITEST_BRANCH: master
|
||||
|
||||
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
|
||||
########################
|
||||
|
|
|
|||
37
Makefile
37
Makefile
|
|
@ -174,6 +174,43 @@ endif
|
|||
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
|
||||
|
||||
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:
|
||||
@$(call print, "Generating sql models and queries in Go")
|
||||
./scripts/gen_sqlc_docker.sh
|
||||
|
|
|
|||
30
docs/commit-tooling.md
Normal file
30
docs/commit-tooling.md
Normal 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
960
scripts/commit_message.py
Executable 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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue