#!/usr/bin/env python3 from __future__ import annotations import argparse import json import os import re import subprocess import sys from dataclasses import dataclass from pathlib import Path from typing import Pattern TYPE_RE = re.compile(r"^[a-z][a-z0-9-]*$") EMOJI_RE = re.compile(r"^:[a-z0-9_+-]+:$") GIT_OBJECT_ID_RE = re.compile(r"^[0-9a-fA-F]{40}(?:[0-9a-fA-F]{24})?$") HEADER_RE = re.compile( r"^(?:(?P:[a-z0-9_+-]+:)\s+)?" r"(?P[a-z][a-z0-9-]*)" r"(?:\((?P[^()]*)\))?" r":\s+(?P.+)$" ) class PolicyError(ValueError): pass class InputError(ValueError): pass @dataclass(frozen=True) class CommitPolicy: source: Path types: dict[str, str] scope_optional: bool scope_pattern: Pattern[str] subject_max_length: int subject_forbidden_suffixes: tuple[str, ...] emoji_required_by_default: bool emoji_requirement_env: str emoji_false_values: frozenset[str] def _eprint(*args: object) -> None: print(*args, file=sys.stderr) def resolve_policy_path(cli_path: str | None) -> Path: if cli_path is not None: return Path(cli_path).expanduser() environment_path = os.getenv("COMMIT_POLICY_PATH") if environment_path: return Path(environment_path).expanduser() return Path(__file__).resolve().parent.parent / "references" / "commit_policy.json" def _load_json(path: Path) -> object: try: return json.loads(path.read_text(encoding="utf-8")) except (OSError, UnicodeError, json.JSONDecodeError) as exc: raise PolicyError(f"cannot read policy {path}: {exc}") from exc def load_policy(path: Path) -> CommitPolicy: source = path.resolve() raw = _load_json(source) if not isinstance(raw, dict): raise PolicyError(f"policy root must be an object: {source}") schema_version = raw.get("schema_version") if type(schema_version) is not int: raise PolicyError(f"policy schema_version must be the integer 1: {source}") if schema_version != 1: raise PolicyError( f"unsupported policy schema_version {schema_version}: {source}" ) types = raw.get("types") if not isinstance(types, dict) or not types: raise PolicyError("policy.types must be a non-empty object") if not all( isinstance(name, str) and TYPE_RE.fullmatch(name) and isinstance(emoji, str) and EMOJI_RE.fullmatch(emoji) for name, emoji in types.items() ): raise PolicyError( "policy.types must map lowercase type names to colon-delimited emoji codes" ) scope = raw.get("scope") if not isinstance(scope, dict): raise PolicyError("policy.scope must be an object") scope_optional = scope.get("optional") scope_expression = scope.get("pattern") if not isinstance(scope_optional, bool): raise PolicyError("policy.scope.optional must be boolean") if not isinstance(scope_expression, str) or not scope_expression: raise PolicyError("policy.scope.pattern must be a non-empty string") try: scope_pattern = re.compile(scope_expression) except re.error as exc: raise PolicyError(f"invalid policy.scope.pattern: {exc}") from exc subject = raw.get("subject") if not isinstance(subject, dict): raise PolicyError("policy.subject must be an object") max_length = subject.get("max_length") forbidden_suffixes = subject.get("forbidden_suffixes") if type(max_length) is not int or max_length <= 0: raise PolicyError("policy.subject.max_length must be a positive integer") if not isinstance(forbidden_suffixes, list) or not all( isinstance(suffix, str) and suffix for suffix in forbidden_suffixes ): raise PolicyError( "policy.subject.forbidden_suffixes must contain non-empty strings" ) emoji = raw.get("emoji") if not isinstance(emoji, dict): raise PolicyError("policy.emoji must be an object") required_by_default = emoji.get("required_by_default") requirement_env = emoji.get("requirement_env") false_values = emoji.get("false_values") if not isinstance(required_by_default, bool): raise PolicyError("policy.emoji.required_by_default must be boolean") if not isinstance(requirement_env, str) or not requirement_env.strip(): raise PolicyError("policy.emoji.requirement_env must be a non-empty string") if not isinstance(false_values, list) or not false_values or not all( isinstance(value, str) and value.strip() for value in false_values ): raise PolicyError("policy.emoji.false_values must contain non-empty strings") return CommitPolicy( source=source, types=dict(types), scope_optional=scope_optional, scope_pattern=scope_pattern, subject_max_length=max_length, subject_forbidden_suffixes=tuple(forbidden_suffixes), emoji_required_by_default=required_by_default, emoji_requirement_env=requirement_env.strip(), emoji_false_values=frozenset( value.strip().casefold() for value in false_values ), ) def emoji_is_required(policy: CommitPolicy) -> bool: override = os.getenv(policy.emoji_requirement_env) if override is None: return policy.emoji_required_by_default return override.strip().casefold() not in policy.emoji_false_values def validate_subject( line: str, policy: CommitPolicy, *, require_emoji: bool, ) -> str | None: if "\n" in line or "\r" in line: return "subject must be a single line" if not line.strip(): return "empty subject" if line != line.strip(): return "subject must not have leading or trailing whitespace" subject = line match = HEADER_RE.fullmatch(subject) if not match: return "does not match ':emoji: type(scope): subject' or 'type(scope): subject'" type_name = match.group("type") scope = match.group("scope") text = match.group("text").rstrip() if type_name not in policy.types: return f"unknown type: {type_name}" if scope is None and not policy.scope_optional: return "missing required scope" if scope is not None and policy.scope_pattern.fullmatch(scope) is None: return f"invalid scope: {scope}" supplied_emoji = match.group("emoji") if supplied_emoji is None and require_emoji: false_values = ", ".join( repr(value) for value in sorted(policy.emoji_false_values) ) return ( "missing emoji " f"(set {policy.emoji_requirement_env} to one of {false_values} to allow)" ) expected_emoji = policy.types[type_name] if supplied_emoji is not None and supplied_emoji != expected_emoji: return ( "emoji/type mismatch: " f"got {supplied_emoji} {type_name}, expected {expected_emoji}" ) if not text: return "empty subject" if len(text) > policy.subject_max_length: return f"subject exceeds {policy.subject_max_length} characters" forbidden_suffix = next( ( suffix for suffix in policy.subject_forbidden_suffixes if text.endswith(suffix) ), None, ) if forbidden_suffix is not None: if forbidden_suffix in {".", "。"}: return ( "subject should not end with a period " f"(forbidden suffix {forbidden_suffix!r})" ) return f"subject must not end with forbidden suffix {forbidden_suffix!r}" return None def _read_message_file(path: Path) -> str: try: content = path.read_text(encoding="utf-8") except (OSError, UnicodeError) as exc: raise InputError(f"cannot read message file {path}: {exc}") from exc lines = content.splitlines() return lines[0] if lines else "" def _parse_args(argv: list[str] | None) -> argparse.Namespace: parser = argparse.ArgumentParser(description="Validate commit-message subjects") parser.add_argument("--policy", help="path to a versioned commit policy JSON") candidates = parser.add_mutually_exclusive_group() candidates.add_argument("--subject", help="single commit subject to validate") candidates.add_argument( "--message-file", type=Path, help="UTF-8 commit message file whose first line is validated", ) return parser.parse_args(argv) def _requires_event_payload(event_name: str) -> bool: return event_name == "push" or event_name.startswith("pull_request") def _load_event_payload() -> tuple[str, dict[str, object] | None]: event_name = os.getenv("GITHUB_EVENT_NAME") or os.getenv("GITEA_EVENT_NAME") or "" event_path = os.getenv("GITHUB_EVENT_PATH") or os.getenv("GITEA_EVENT_PATH") or "" if not event_path: if _requires_event_payload(event_name): raise InputError(f"{event_name} event requires an event payload path") return event_name, None path = Path(event_path) try: payload = json.loads(path.read_text(encoding="utf-8")) except (OSError, UnicodeError, json.JSONDecodeError) as exc: if _requires_event_payload(event_name): raise InputError(f"cannot parse {event_name} event payload {path}: {exc}") from exc _eprint(f"WARN: failed to parse event payload: {path} ({exc})") return event_name, None if not isinstance(payload, dict): if _requires_event_payload(event_name): raise InputError(f"{event_name} event payload must be an object: {path}") _eprint(f"WARN: event payload is not an object: {path}") return event_name, None return event_name, payload def _message_subject(message: str, label: str) -> str: if not message: raise InputError(f"{label}.message must be a non-empty string") lines = message.splitlines() if not lines: raise InputError(f"{label}.message must contain a subject line") return lines[0] def _git_log_subjects( revisions: list[str], description: str, *, allow_empty: bool = False, label_prefix: str = "push.commit", ) -> list[tuple[str, str]]: try: result = subprocess.run( ["git", "log", "--reverse", "--format=%H%x00%s", *revisions], capture_output=True, text=True, check=False, ) except OSError as exc: raise InputError(f"cannot read {description}: {exc}") from exc if result.returncode != 0: detail = result.stderr.strip() suffix = f": {detail}" if detail else "" raise InputError(f"cannot read {description}{suffix}") subjects: list[tuple[str, str]] = [] for line in result.stdout.splitlines(): sha, separator, subject = line.partition("\0") if not separator or not sha or not subject: raise InputError(f"invalid git log output for {description}") subjects.append((f"{label_prefix} {sha[:7]}", subject)) if not subjects and not allow_empty: raise InputError(f"{description} is empty") return subjects def _require_complete_git_history(context: str) -> None: try: result = subprocess.run( ["git", "rev-parse", "--is-shallow-repository"], capture_output=True, text=True, check=False, ) except OSError as exc: raise InputError(f"cannot inspect repository depth for {context}: {exc}") from exc if result.returncode != 0: detail = result.stderr.strip() suffix = f": {detail}" if detail else "" raise InputError(f"cannot inspect repository depth for {context}{suffix}") shallow_state = result.stdout.strip() if shallow_state == "true": raise InputError(f"{context} requires complete, non-shallow Git history") if shallow_state != "false": raise InputError(f"cannot determine repository depth for {context}") def _zero_before_subjects( payload: dict[str, object], after: str ) -> list[tuple[str, str]]: target_ref = payload.get("ref") if not isinstance(target_ref, str) or not target_ref.startswith("refs/heads/"): raise InputError("zero-before push event requires a refs/heads/* ref") try: check_ref = subprocess.run( ["git", "check-ref-format", target_ref], capture_output=True, text=True, check=False, ) refs_result = subprocess.run( [ "git", "for-each-ref", "--format=%(refname)%00%(symref)", "refs/heads", "refs/remotes", ], capture_output=True, text=True, check=False, ) except OSError as exc: raise InputError(f"cannot inspect refs for zero-before push: {exc}") from exc if check_ref.returncode != 0: raise InputError(f"invalid zero-before push ref: {target_ref}") if refs_result.returncode != 0: detail = refs_result.stderr.strip() suffix = f": {detail}" if detail else "" raise InputError(f"cannot inspect refs for zero-before push{suffix}") branch_name = target_ref.removeprefix("refs/heads/") exclusions: list[str] = [] for line in refs_result.stdout.splitlines(): ref_name, separator, symbolic_target = line.partition("\0") if not separator or not ref_name: raise InputError("invalid git ref output for zero-before push") if symbolic_target: continue if ref_name == target_ref: continue if ref_name.startswith("refs/remotes/"): remote_parts = ref_name.split("/", 3) if len(remote_parts) == 4 and remote_parts[3] == branch_name: continue exclusions.append(ref_name) revisions = [after] if exclusions: revisions.extend(["--not", *exclusions]) return _git_log_subjects( revisions, f"zero-before push history for {target_ref}", allow_empty=True, ) def _push_range_subjects(payload: dict[str, object]) -> list[tuple[str, str]]: before = payload.get("before") after = payload.get("after") if ( not isinstance(before, str) or not isinstance(after, str) or GIT_OBJECT_ID_RE.fullmatch(before) is None or GIT_OBJECT_ID_RE.fullmatch(after) is None ): raise InputError("push event before/after must be full Git object IDs") if set(after) == {"0"}: raise InputError("push event after cannot be the zero object ID") _require_complete_git_history("push event") if set(before) == {"0"}: return _zero_before_subjects(payload, after) revision_range = f"{before}..{after}" return _git_log_subjects( [revision_range], f"push commit range {revision_range}" ) def _gather_ci_subjects() -> tuple[str, list[tuple[str, str]]]: event_name, payload = _load_event_payload() subjects: list[tuple[str, str]] = [] if event_name.startswith("pull_request"): if not isinstance(payload, dict): raise InputError(f"{event_name} event payload is required") pull_request = payload.get("pull_request") if not isinstance(pull_request, dict): raise InputError("pull_request event payload.pull_request must be an object") title = pull_request.get("title") if not isinstance(title, str) or not title: raise InputError("pull_request.title must be a non-empty string") subjects.append(("pull_request.title", _message_subject(title, "pull_request.title"))) base = pull_request.get("base") head = pull_request.get("head") if not isinstance(base, dict) or not isinstance(head, dict): raise InputError("pull_request.base/head must be objects") base_sha = base.get("sha") head_sha = head.get("sha") if ( not isinstance(base_sha, str) or not isinstance(head_sha, str) or GIT_OBJECT_ID_RE.fullmatch(base_sha) is None or GIT_OBJECT_ID_RE.fullmatch(head_sha) is None ): raise InputError("pull_request.base/head.sha must be full Git object IDs") _require_complete_git_history("pull request event") revision_range = f"{base_sha}..{head_sha}" subjects.extend( _git_log_subjects( [revision_range], f"pull request commit range {revision_range}", allow_empty=True, label_prefix="pull_request.commit", ) ) return event_name, subjects if event_name == "push": if not isinstance(payload, dict): raise InputError("push event payload is required") commits = payload.get("commits") if not isinstance(commits, list): raise InputError("push event payload.commits must be a list") for index, commit in enumerate(commits): item_label = f"push.commits[{index}]" if not isinstance(commit, dict): raise InputError(f"{item_label} must be an object") message = commit.get("message") if not isinstance(message, str): raise InputError(f"{item_label}.message must be a non-empty string") subject = _message_subject(message, item_label) sha = commit.get("id") or commit.get("sha") or "" sha_text = str(sha) label = f"push.commit {sha_text[:7]}" if sha_text else "push.commit" subjects.append((label, subject)) range_subjects = _push_range_subjects(payload) return event_name, range_subjects try: result = subprocess.run( ["git", "log", "-1", "--format=%s", "HEAD"], capture_output=True, text=True, check=False, ) except OSError as exc: raise InputError(f"cannot read HEAD subject: {exc}") from exc lines = result.stdout.splitlines() if result.returncode == 0 and lines: return event_name, [("HEAD", lines[0])] detail = result.stderr.strip() suffix = f": {detail}" if detail else "" raise InputError(f"no commit subject found in event payload or HEAD{suffix}") def main(argv: list[str] | None = None) -> int: args = _parse_args(argv) try: policy = load_policy(resolve_policy_path(args.policy)) if args.subject is not None: event_name = "" subjects = [("--subject", args.subject)] elif args.message_file is not None: event_name = "" subjects = [(str(args.message_file), _read_message_file(args.message_file))] else: event_name, subjects = _gather_ci_subjects() except (InputError, PolicyError) as exc: _eprint(f"ERROR: {exc}") return 2 require_emoji = emoji_is_required(policy) print(f"commit policy: {policy.source}") if event_name: print(f"event: {event_name}") print(f"require emoji: {require_emoji}") print(f"checks: {len(subjects)} subject(s)") for label, _subject in subjects: print(f"input: {label}") errors: list[str] = [] for label, subject in subjects: error = validate_subject(subject, policy, require_emoji=require_emoji) if error: errors.append(f"- {label}: {error}\n subject: {subject}") if errors: _eprint("ERROR: commit message lint failed:") for error in errors: _eprint(error) return 1 print("OK") return 0 if __name__ == "__main__": raise SystemExit(main())