From bf79e561116e5393514103f9a734e11045058d11 Mon Sep 17 00:00:00 2001 From: 7000pctAUTO Date: Sat, 31 Jan 2026 03:00:21 +0000 Subject: [PATCH] Initial upload: Git Commit AI - privacy-first CLI for generating commit messages with local LLM --- git_commit_ai/core/conventional.py | 176 +++++++++++++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 git_commit_ai/core/conventional.py diff --git a/git_commit_ai/core/conventional.py b/git_commit_ai/core/conventional.py new file mode 100644 index 0000000..1fa1bbd --- /dev/null +++ b/git_commit_ai/core/conventional.py @@ -0,0 +1,176 @@ +"""Conventional commit validation and utilities.""" + +import re +from dataclasses import dataclass +from typing import Optional + + +VALID_TYPES = ["feat", "fix", "docs", "style", "refactor", "perf", "test", "build", "ci", "chore", "revert"] + +CONVENTIONAL_PATTERN = re.compile( + r"^(?Pfeat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)" + r"(?:\((?P[^)]+)\))?: (?P.+)$" +) + + +@dataclass +class ParsedCommit: + """Parsed conventional commit message.""" + type: str + scope: Optional[str] + description: str + raw: str + + @property + def formatted(self) -> str: + if self.scope: + return f"{self.type}({self.scope}): {self.description}" + return f"{self.type}: {self.description}" + + +class ConventionalCommitParser: + """Parser for conventional commit messages.""" + + @staticmethod + def parse(message: str) -> Optional[ParsedCommit]: + message = message.strip() + match = CONVENTIONAL_PATTERN.match(message) + if match: + return ParsedCommit( + type=match.group("type"), scope=match.group("scope"), + description=match.group("description"), raw=message) + return None + + @staticmethod + def is_valid(message: str) -> bool: + return ConventionalCommitParser.parse(message) is not None + + @staticmethod + def validate(message: str) -> list[str]: + errors = [] + message = message.strip() + + if not message: + errors.append("Commit message cannot be empty") + return errors + + if not CONVENTIONAL_PATTERN.match(message): + errors.append("Message does not follow conventional commit format. Expected: type(scope): description") + return errors + + parsed = ConventionalCommitParser.parse(message) + if parsed: + if parsed.type not in VALID_TYPES: + errors.append(f"Invalid type '{parsed.type}'. Valid types: {', '.join(VALID_TYPES)}") + + if parsed.scope and len(parsed.scope) > 20: + errors.append("Scope is too long (max 20 characters)") + + return errors + + +class ConventionalCommitFixer: + """Auto-fixer for conventional commit format issues.""" + + @staticmethod + def fix(message: str, diff: str) -> str: + message = message.strip() + + type_hint = ConventionalCommitFixer._detect_type(diff) + if not type_hint: + type_hint = "chore" + + description = ConventionalCommitFixer._extract_description(message, diff) + + if description: + return f"{type_hint}: {description}" + + return message + + @staticmethod + def _detect_type(diff: str) -> Optional[str]: + diff_lower = diff.lower() + + if any(kw in diff_lower for kw in ["bug", "fix", "error", "issue", "problem"]): + return "fix" + if any(kw in diff_lower for kw in ["feature", "add", "implement", "new"]): + return "feat" + if any(kw in diff_lower for kw in ["doc", "readme", "comment"]): + return "docs" + if any(kw in diff_lower for kw in ["test", "spec"]): + return "test" + if any(kw in diff_lower for kw in ["refactor", "restructure", "reorganize"]): + return "refactor" + if any(kw in diff_lower for kw in ["style", "format", "lint"]): + return "style" + if any(kw in diff_lower for kw in ["perf", "optimize", "speed", "performance"]): + return "perf" + if any(kw in diff_lower for kw in ["build", "ci", "docker", "pipeline"]): + return "build" + + return None + + @staticmethod + def _extract_description(message: str, diff: str) -> str: + if message and len(message) > 3: + cleaned = message.strip() + if ":" in cleaned: + cleaned = cleaned.split(":", 1)[1].strip() + if len(cleaned) > 3: + return cleaned[:72].rsplit(" ", 1)[0] if " " in cleaned else cleaned + + files = ConventionalCommitFixer._get_changed_files(diff) + if files: + action = ConventionalCommitFixer._get_action(diff) + return f"{action} {files[0]}" + + return "" + + @staticmethod + def _get_changed_files(diff: str) -> list[str]: + files = [] + for line in diff.split("\n"): + if line.startswith("+++ b/") or line.startswith("--- a/"): + path = line[6:] + if path and path != "/dev/null": + filename = path.split("/")[-1] + if filename not in files: + files.append(filename) + return files[:3] + + @staticmethod + def _get_action(diff: str) -> str: + if "new file:" in diff: + return "add" + if "delete file:" in diff: + return "remove" + if "rename" in diff: + return "rename" + return "update" + + +def validate_commit_message(message: str) -> tuple[bool, list[str]]: + errors = ConventionalCommitParser.validate(message) + return len(errors) == 0, errors + + +def format_conventional(message: str, commit_type: Optional[str] = None, scope: Optional[str] = None) -> str: + message = message.strip() + if not commit_type: + return message + type_str = commit_type + if scope: + type_str += f"({scope})" + if message and not message.startswith(f"{type_str}:"): + return f"{type_str}: {message}" + return message + + +def extract_conventional_parts(message: str) -> dict: + result = {"type": None, "scope": None, "description": message} + parsed = ConventionalCommitParser.parse(message) + if parsed: + result["type"] = parsed.type + result["scope"] = parsed.scope + result["description"] = parsed.description + return result