From a8b19f3761457079bcd9d4fb233b2ad0b783ca5b Mon Sep 17 00:00:00 2001 From: Hans Johnson Date: Fri, 3 Apr 2026 15:51:15 -0500 Subject: [PATCH] ENH: Add ITK commit message hooks (prepare-commit-msg and kw-commit-msg) Add pre-commit hooks in Utilities/Hooks/ matching ITK's directory structure and commit message enforcement: - prepare-commit-msg: Inserts ITK prefix instructions into the commit message editor so developers see valid prefixes while composing. - kw-commit-msg.py: Validates commit messages with the same rules as upstream ITK: prefix check (BUG/COMP/DOC/ENH/PERF/STYLE/WIP), subject line max 78 chars, no leading/trailing whitespace, empty second line, and Merge/Revert exemptions. Co-Authored-By: Claude Opus 4.6 (1M context) --- .pre-commit-config.yaml | 13 +++ Utilities/Hooks/kw-commit-msg.py | 153 +++++++++++++++++++++++++++++ Utilities/Hooks/prepare-commit-msg | 53 ++++++++++ 3 files changed, 219 insertions(+) create mode 100644 .pre-commit-config.yaml create mode 100755 Utilities/Hooks/kw-commit-msg.py create mode 100755 Utilities/Hooks/prepare-commit-msg diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..b15a4760 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,13 @@ +repos: + - repo: local + hooks: + - id: local-prepare-commit-msg + name: 'local prepare-commit-msg' + entry: 'Utilities/Hooks/prepare-commit-msg' + language: system + stages: [prepare-commit-msg] + - id: kw-commit-msg + name: 'kw commit-msg' + entry: 'python3 Utilities/Hooks/kw-commit-msg.py' + language: system + stages: [commit-msg] diff --git a/Utilities/Hooks/kw-commit-msg.py b/Utilities/Hooks/kw-commit-msg.py new file mode 100755 index 00000000..0d6b0af8 --- /dev/null +++ b/Utilities/Hooks/kw-commit-msg.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +# ========================================================================== +# +# Copyright NumFOCUS +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0.txt +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# ========================================================================== + +import os +import re +import subprocess +import sys + +from pathlib import Path + + +DEFAULT_LINE_LENGTH: int = 78 + + +def die(message, commit_msg_path): + print("commit-msg hook failure", file=sys.stderr) + print("-----------------------", file=sys.stderr) + print(message, file=sys.stderr) + print("-----------------------", file=sys.stderr) + print( + f""" +To continue editing, run the command + git commit -e -F "{commit_msg_path}" +(assuming your working directory is at the top).""", + file=sys.stderr, + ) + sys.exit(1) + + +def get_max_length(): + try: + result = subprocess.run( + ["git", "config", "--get", "hooks.commit-msg.ITKCommitSubjectMaxLength"], + capture_output=True, + text=True, + check=True, + ) + return int(result.stdout.strip()) + except (subprocess.CalledProcessError, ValueError): + return DEFAULT_LINE_LENGTH + + +def main(): + git_dir_path: Path = Path(os.environ.get("GIT_DIR", ".git")).resolve() + commit_msg_path: Path = git_dir_path / "COMMIT_MSG" + + if len(sys.argv) < 2: + die(f"Usage: {sys.argv[0]} ", commit_msg_path) + + input_file: Path = Path(sys.argv[1]) + if not input_file.exists(): + die( + f"Missing input_file {sys.argv[1]} for {sys.argv[0]} processing", + commit_msg_path, + ) + max_subjectline_length: int = get_max_length() + + original_input_file_lines: list[str] = [] + with open(input_file) as f_in: + original_input_file_lines = f_in.readlines() + + input_file_lines: list[str] = [] + for test_line in original_input_file_lines: + test_line = test_line.strip() + is_empty_line_before_subject: bool = ( + len(input_file_lines) == 0 and len(test_line) == 0 + ) + if test_line.startswith("#") or is_empty_line_before_subject: + continue + input_file_lines.append(f"{test_line}\n") + + with open(commit_msg_path, "w") as f_out: + f_out.writelines(input_file_lines) + + subject_line: str = input_file_lines[0] + + if len(subject_line) < 8: + die( + f"The first line must be at least 8 characters:\n--------\n{subject_line}\n--------", + commit_msg_path, + ) + if ( + len(subject_line) > max_subjectline_length + and not subject_line.startswith("Merge ") + and not subject_line.startswith("Revert ") + ): + die( + f"The first line may be at most {max_subjectline_length} characters:\n" + + "-" * max_subjectline_length + + f"\n{subject_line}\n" + + "-" * max_subjectline_length, + commit_msg_path, + ) + if re.match(r"^[ \t]|[ \t]$", subject_line): + die( + f"The first line may not have leading or trailing space:\n[{subject_line}]", + commit_msg_path, + ) + if not re.match( + r"^(Merge|Revert|BUG:|COMP:|DOC:|ENH:|PERF:|STYLE:|WIP:)\s", subject_line + ): + die( + f"""Start ITK commit messages with a standard prefix (and a space): + BUG: - fix for runtime crash or incorrect result + COMP: - compiler error or warning fix + DOC: - documentation change + ENH: - new functionality + PERF: - performance improvement + STYLE: - no logic impact (indentation, comments) + WIP: - Work In Progress not ready for merge +To reference GitHub issue XXXX, add "Issue #XXXX" to the commit message. +If the issue addresses an open issue, add "Closes #XXXX" to the message.""", + commit_msg_path, + ) + if re.match(r"^BUG: [0-9]+\.", subject_line): + die( + f'Do not put a "." after the bug number:\n\n {subject_line}', + commit_msg_path, + ) + del subject_line + + if len(input_file_lines) > 1: + second_line: str = input_file_lines[ + 1 + ].strip() # Remove whitespace at beginning and end + if len(second_line) == 0: + input_file_lines[1] = "\n" # Replace line with only newline + else: + die( + f'The second line of the commit message must be empty:\n"{second_line}" with length {len(second_line)}', + commit_msg_path, + ) + del second_line + + +if __name__ == "__main__": + main() diff --git a/Utilities/Hooks/prepare-commit-msg b/Utilities/Hooks/prepare-commit-msg new file mode 100755 index 00000000..276fd093 --- /dev/null +++ b/Utilities/Hooks/prepare-commit-msg @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +#========================================================================== +# +# Copyright NumFOCUS +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0.txt +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +#========================================================================== + +egrep-q() { + egrep "$@" >/dev/null 2>/dev/null +} + +# First argument is file containing commit message. +commit_msg="$1" + +# Check for our extra instructions. +egrep-q "^# Start ITK commit messages" -- "$commit_msg" && return 0 + +# Insert our extra instructions. +commit_msg_tmp="$commit_msg.$$" +instructions='#\ +# Start ITK commit messages with a standard prefix (and a space):\ +# BUG: - fix for runtime crash or incorrect result\ +# COMP: - compiler error or warning fix\ +# DOC: - documentation change\ +# ENH: - new functionality\ +# PERF: - performance improvement\ +# STYLE: - no logic impact (indentation, comments)\ +# WIP: - Work In Progress not ready for merge\ +#\ +# The first line of the commit message should preferably be 72 characters\ +# or less; the maximum allowed is 78 characters.\ +#\ +# Follow the first line commit summary with an empty line, then a detailed\ +# description in one or more paragraphs.\ +#' && +sed '/^# On branch.*$/ a\ +'"$instructions"' +/^# Not currently on any branch.*$/ a\ +'"$instructions"' +' "$commit_msg" > "$commit_msg_tmp" && +mv "$commit_msg_tmp" "$commit_msg"