#!/usr/bin/env python3 """Lint commit messages against Conventional Commits. Input sources (priority order): 1) --input file (one commit subject per line) 2) stdin lines 3) git range via --from-ref/--to-ref Use --strict for non-zero exit on violations. """ import argparse import json import re import subprocess import sys from dataclasses import dataclass, asdict from pathlib import Path from typing import List, Optional CONVENTIONAL_RE = re.compile( r"^(feat|fix|perf|refactor|docs|test|build|ci|chore|security|deprecated|remove)" r"(\([a-z0-9._/-]+\))?(!)?:\s+.{1,120}$" ) class CLIError(Exception): """Raised for expected CLI errors.""" @dataclass class LintReport: total: int valid: int invalid: int violations: List[str] def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Validate conventional commit subjects.") parser.add_argument("--input", help="File with commit subjects (one per line).") parser.add_argument("--from-ref", help="Git ref start (exclusive).") parser.add_argument("--to-ref", help="Git ref end (inclusive).") parser.add_argument("--strict", action="store_true", help="Exit non-zero when violations exist.") parser.add_argument("--format", choices=["text", "json"], default="text", help="Output format.") return parser.parse_args() def lines_from_file(path: str) -> List[str]: try: return [line.strip() for line in Path(path).read_text(encoding="utf-8").splitlines() if line.strip()] except Exception as exc: raise CLIError(f"Failed reading --input file: {exc}") from exc def lines_from_stdin() -> List[str]: if sys.stdin.isatty(): return [] data = sys.stdin.read() return [line.strip() for line in data.splitlines() if line.strip()] def lines_from_git(args: argparse.Namespace) -> List[str]: if not args.to_ref: return [] range_spec = f"{args.from_ref}..{args.to_ref}" if args.from_ref else args.to_ref try: proc = subprocess.run( ["git", "log", range_spec, "--pretty=format:%s", "--no-merges"], text=True, capture_output=True, check=True, ) except subprocess.CalledProcessError as exc: raise CLIError(f"git log failed for range '{range_spec}': {exc.stderr.strip()}") from exc return [line.strip() for line in proc.stdout.splitlines() if line.strip()] def load_lines(args: argparse.Namespace) -> List[str]: if args.input: return lines_from_file(args.input) stdin_lines = lines_from_stdin() if stdin_lines: return stdin_lines git_lines = lines_from_git(args) if git_lines: return git_lines raise CLIError("No commit input found. Use --input, stdin, or --to-ref.") def lint(lines: List[str]) -> LintReport: violations: List[str] = [] valid = 0 for idx, line in enumerate(lines, start=1): if CONVENTIONAL_RE.match(line): valid += 1 continue violations.append(f"line {idx}: {line}") return LintReport(total=len(lines), valid=valid, invalid=len(violations), violations=violations) def format_text(report: LintReport) -> str: lines = [ "Conventional commit lint report", f"- total: {report.total}", f"- valid: {report.valid}", f"- invalid: {report.invalid}", ] if report.violations: lines.append("Violations:") lines.extend([f"- {v}" for v in report.violations]) return "\n".join(lines) def main() -> int: args = parse_args() lines = load_lines(args) report = lint(lines) if args.format == "json": print(json.dumps(asdict(report), indent=2)) else: print(format_text(report)) if args.strict and report.invalid > 0: return 1 return 0 if __name__ == "__main__": try: raise SystemExit(main()) except CLIError as exc: print(f"ERROR: {exc}", file=sys.stderr) raise SystemExit(2)