#!/usr/bin/env python3 """Validate commit messages for devx. Rules: - On feature branches: conventional commits ONLY, must NOT include -N prefix. - On master branch: must follow ': ' pattern, e.g. 'DEVX-24: fix: resolve timeout'. The task ID prefix is configurable via the ``DEVX_TASK_PREFIX`` environment variable (default: ``DEVX``). Projects consuming devx (e.g., GRM) set their own prefix (e.g., ``GRM``) so the validator enforces the correct task ID format for each project. """ import re import subprocess # nosec B404 import sys import click from devx.config import CONVENTIONAL_RE, TASK_PREFIX from devx.i18n import _ MASTER_TASK_ID_RE = re.compile(rf"^{TASK_PREFIX}-\d+:") def get_latest_commit_msg() -> str: """Get the latest commit message from git.""" result = subprocess.run( # nosec ["git", "log", "-1", "--format=%B"], capture_output=True, text=True, check=True, ) return result.stdout.strip() def first_line(text: str) -> str: return text.split("\n")[0] def get_branch() -> str: try: result = subprocess.run( # nosec ["git", "symbolic-ref", "--short", "HEAD"], capture_output=True, text=True, check=True, ) return result.stdout.strip() except subprocess.CalledProcessError: return "" @click.command() @click.argument("commit_msg_file", required=False) @click.option("--branch", default=None, help="Override branch detection (for CI use).") @click.option( "--git", "from_git", is_flag=True, default=False, help="Read commit message from git log instead of a file.", ) def main(commit_msg_file: str | None, branch: str | None, from_git: bool) -> None: if from_git: msg = get_latest_commit_msg() elif commit_msg_file: if commit_msg_file == "-": msg = sys.stdin.read().strip() else: with open(commit_msg_file) as f: msg = f.read().strip() else: raise click.ClickException(_("Provide a commit message file or use --git.")) if branch is None: branch = get_branch() subject = first_line(msg) if branch == "master": if not MASTER_TASK_ID_RE.match(subject): raise click.ClickException( _( "Oops! Master branch commits must start with a task ID.\n" " Expected: {prefix}-N: \n" " Got: {subject}", prefix=TASK_PREFIX, subject=subject, ) ) remainder = MASTER_TASK_ID_RE.sub("", subject).strip() if not CONVENTIONAL_RE.match(remainder): raise click.ClickException( _( "Oops! Master branch commit must follow conventional format after task ID.\n" " Expected: {prefix}-N: : \n" " Got: {subject}", prefix=TASK_PREFIX, subject=subject, ) ) return if MASTER_TASK_ID_RE.match(subject): raise click.ClickException( _( "Oops! Do not include task ID ({prefix}-N) in feature branch commits.\n" " The task ID will be added automatically on merge via CI.", prefix=TASK_PREFIX, ) ) if not CONVENTIONAL_RE.match(subject): raise click.ClickException( _( "Oops! Commit message must follow conventional commit format.\n" " Expected: : \n" " Got: {subject}\n" " Allowed types: feat, fix, chore, docs, style, refactor,\n" " perf, test, ci, build, revert, BREAKING CHANGE", subject=subject, ) ) if __name__ == "__main__": # pragma: no cover main()