Last active
April 12, 2026 05:18
-
-
Save RoCry/ee6ec5a651137c2702b3f3cef6fdbd97 to your computer and use it in GitHub Desktop.
auto_commit_message.py — AI-powered git commit messages via uv run. Only needs GEMINI_API_KEY (free).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # /// script | |
| # requires-python = ">=3.12" | |
| # dependencies = [ | |
| # "gitpython", | |
| # "smolllm", | |
| # ] | |
| # /// | |
| """ | |
| auto_commit_message.py — Generate AI-powered conventional commit messages from staged git diffs. | |
| Minimal setup (requires only a free Gemini API key): | |
| 1. Get a key: https://aistudio.google.com/apikey | |
| 2. export GEMINI_API_KEY="your-key-here" | |
| 3. uv run https://gist.github.com/RoCry/ee6ec5a651137c2702b3f3cef6fdbd97 | |
| Quick git alias setup (run once, then use `git ac` / `git aac` everywhere): | |
| GIST=https://gist.github.com/RoCry/ee6ec5a651137c2702b3f3cef6fdbd97 | |
| uv run $GIST --setup $GIST | |
| Usage: | |
| uv run auto_commit_message.py # print commit message | |
| uv run auto_commit_message.py --commit # generate + commit | |
| uv run auto_commit_message.py --setup # add `git ac` / `git aac` aliases to ~/.gitconfig | |
| Environment variables: | |
| GEMINI_API_KEY — (required) Google Gemini API key (free at https://aistudio.google.com/apikey) | |
| GIT_COMMIT_MODELS — comma-separated fallback list (default: gemini/gemini-flash-latest,gemini/gemini-flash-lite-latest) | |
| format: "provider/model" e.g. "gemini/gemini-2.0-flash,deepseek/deepseek-chat" | |
| supported: any provider supported by smolllm (https://github.com/nicepkg/smolllm) | |
| each provider needs <PROVIDER>_API_KEY env var set | |
| GIT_COMMIT_STYLE_PATH — path to custom commit style markdown (default: built-in conventional commits) | |
| GIT_COMMIT_TIMEOUT — LLM request timeout in seconds (default: 15) | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import asyncio | |
| import os | |
| import subprocess | |
| import sys | |
| from pathlib import Path | |
| # --------------------------------------------------------------------------- | |
| # Config | |
| # --------------------------------------------------------------------------- | |
| DEFAULT_MODELS = "gemini/gemini-flash-latest,gemini/gemini-flash-lite-latest" | |
| COMMIT_STYLE_FALLBACK = """\ | |
| Start with a one-line summary under 70 characters: `<type>[(scope)]: <description>` | |
| Types: feat|fix|docs|style|refactor|test|chore|perf|ci|build | |
| - `!` after type/scope for breaking changes (e.g. `feat!:`) | |
| - Imperative mood ("add", "fix", "update") | |
| - Optional body after blank line for extra context | |
| - Footer `BREAKING CHANGE: <desc>` for breaking changes | |
| Examples: feat(parser): add array parsing / fix: prevent request racing""" | |
| SYSTEM_PROMPT = """\ | |
| You are a text generator creating a Conventional Commit. | |
| Follow the commit style rules below verbatim. Output only the commit | |
| message text - no code fences, no explanations. | |
| Keep the summary under 70 characters. | |
| {style}""" | |
| # --------------------------------------------------------------------------- | |
| # Git diff generation | |
| # --------------------------------------------------------------------------- | |
| MAX_LINES_PER_FILE = 30 | |
| def _process_diff_lines(patch_text: str) -> list[str]: | |
| lines: list[str] = [] | |
| for line in patch_text.splitlines(): | |
| if line.startswith(("diff --git", "index ", "--- ", "+++ ")): | |
| continue | |
| if line.startswith("@@ "): | |
| lines.append(f"--- Section: {line} ---") | |
| continue | |
| lines.append(line) | |
| if len(lines) > MAX_LINES_PER_FILE + 1: | |
| half = MAX_LINES_PER_FILE // 2 | |
| start, end = lines[:half], lines[-half:] | |
| lines = start + [f"... {len(lines) - len(start) - len(end)} lines truncated ..."] + end | |
| return lines | |
| def _generate_diff_text(cached: bool = True) -> str: | |
| from git import Repo | |
| repo = Repo(os.getcwd(), search_parent_directories=True) | |
| args = dict(create_patch=True, ignore_blank_lines=True, ignore_space_at_eol=True, unified=1) | |
| diffs = repo.head.commit.diff(None, cached=cached, **args) if cached else repo.head.commit.diff(None, **args) | |
| if not diffs: | |
| raise SystemExit("No staged changes found. Stage files first: git add <files>") | |
| parts: list[str] = [] | |
| for d in diffs: | |
| if d.change_type == "D" or d.deleted_file: | |
| parts.append(f"DELETED: {d.a_path or '<unknown>'}") | |
| elif d.change_type == "A" or d.new_file: | |
| parts.append(f"ADDED: {d.b_path or '<unknown>'}") | |
| elif d.change_type in ("C", "R") or d.renamed_file: | |
| parts.append(f"RENAMED: {d.rename_from} → {d.rename_to}") | |
| else: | |
| fp = d.b_path or d.a_path or "<unknown>" | |
| try: | |
| patch = d.diff.decode("utf-8", errors="replace") | |
| except Exception: | |
| parts.append(f"FILE: {fp}\n[Binary or unreadable file]") | |
| continue | |
| lines = _process_diff_lines(patch) | |
| if lines: | |
| parts.append(f"FILE: {fp}\n" + "\n".join(lines)) | |
| if not parts: | |
| raise SystemExit("No displayable changes found.") | |
| return "\n\n".join(parts) | |
| # --------------------------------------------------------------------------- | |
| # Message cleanup | |
| # --------------------------------------------------------------------------- | |
| def _cleanup_message(msg: str) -> str: | |
| cleaned = msg.strip() | |
| if not cleaned: | |
| raise ValueError("LLM returned empty commit message") | |
| if cleaned.startswith("```"): | |
| first_nl = cleaned.find("\n") | |
| last_fence = cleaned.rfind("```") | |
| if first_nl != -1 and last_fence > first_nl: | |
| cleaned = cleaned[first_nl + 1 : last_fence].strip() | |
| else: | |
| cleaned = cleaned.strip("`").strip() | |
| if cleaned.startswith("`") and cleaned.endswith("`") and "\n" not in cleaned: | |
| cleaned = cleaned[1:-1].strip() | |
| if not cleaned: | |
| raise ValueError("LLM returned empty commit message after cleanup") | |
| if "\n" in cleaned: | |
| summary, *rest = cleaned.splitlines() | |
| rest_text = "\n".join(rest).strip() | |
| return f"{summary.strip()}\n\n{rest_text}" if rest_text else summary.strip() | |
| return cleaned | |
| # --------------------------------------------------------------------------- | |
| # Core | |
| # --------------------------------------------------------------------------- | |
| def _load_commit_style() -> str: | |
| env_path = os.getenv("GIT_COMMIT_STYLE_PATH") | |
| style_path = Path(env_path).expanduser() if env_path else Path("~/.config/gitx/commit-style.md").expanduser() | |
| if style_path.exists(): | |
| content = style_path.read_text(encoding="utf-8").strip() | |
| if content: | |
| return content | |
| if env_path: | |
| raise SystemExit(f"Commit style file is empty: {style_path}") | |
| elif env_path: | |
| raise SystemExit(f"GIT_COMMIT_STYLE_PATH points to missing file: {style_path}") | |
| return COMMIT_STYLE_FALLBACK | |
| async def _generate_commit_message() -> str: | |
| from smolllm import ask_llm | |
| diff_text = _generate_diff_text(cached=True) | |
| style = _load_commit_style() | |
| system = SYSTEM_PROMPT.format(style=style) | |
| timeout = float(os.getenv("GIT_COMMIT_TIMEOUT", "15")) | |
| models = os.getenv("GIT_COMMIT_MODELS", DEFAULT_MODELS) | |
| resp = await ask_llm( | |
| prompt=diff_text, | |
| system_prompt=system, | |
| model=models, | |
| timeout=timeout, | |
| ) | |
| return _cleanup_message(resp.text) | |
| def generate_commit_message() -> str: | |
| return asyncio.run(_generate_commit_message()) | |
| # --------------------------------------------------------------------------- | |
| # Setup | |
| # --------------------------------------------------------------------------- | |
| def _setup_git_aliases(script_url: str | None = None) -> None: | |
| """Add ac/aac aliases to global ~/.gitconfig.""" | |
| if not script_url: | |
| script_url = os.path.abspath(__file__) | |
| ac_cmd = f'!f() {{ git commit -m "$(uv run {script_url})"; }}; f' | |
| aac_cmd = "!git add -A && git ac" | |
| subprocess.run(["git", "config", "--global", "alias.ac", ac_cmd], check=True) | |
| subprocess.run(["git", "config", "--global", "alias.aac", aac_cmd], check=True) | |
| print(f"✓ Added git aliases to ~/.gitconfig:") | |
| print(f' git ac = auto-commit staged changes (alias.ac = "{ac_cmd}")') | |
| print(f' git aac = stage all + auto-commit (alias.aac = "{aac_cmd}")') | |
| if "GEMINI_API_KEY" not in os.environ: | |
| print(f"\n⚠ GEMINI_API_KEY not set. Get one free at: https://aistudio.google.com/apikey") | |
| print(f' Add to your shell profile: export GEMINI_API_KEY="your-key"') | |
| # --------------------------------------------------------------------------- | |
| # Main | |
| # --------------------------------------------------------------------------- | |
| def main() -> None: | |
| parser = argparse.ArgumentParser(description="Generate AI commit messages from staged git diffs") | |
| parser.add_argument("--commit", action="store_true", help="Commit with generated message") | |
| parser.add_argument("--setup", nargs="?", const="", metavar="GIST_URL", | |
| help="Add git ac/aac aliases to ~/.gitconfig. Optionally pass gist URL to use remote script.") | |
| args = parser.parse_args() | |
| if args.setup is not None: | |
| _setup_git_aliases(args.setup or None) | |
| return | |
| message = generate_commit_message() | |
| if args.commit: | |
| subprocess.run(["git", "commit", "-m", message], check=True) | |
| else: | |
| print(message) | |
| if __name__ == "__main__": | |
| main() |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Quick Start
Requires: uv + a free Gemini API key
Optional: use other providers
Set
GIT_COMMIT_MODELSto customize the model(s) with fallback:Supported providers:
gemini,deepseek,openai,groq,openrouter,mistral,grok,ollama— any OpenAI-compatible endpoint works.