Skip to content

Instantly share code, notes, and snippets.

@RoCry
Last active April 12, 2026 05:18
Show Gist options
  • Select an option

  • Save RoCry/ee6ec5a651137c2702b3f3cef6fdbd97 to your computer and use it in GitHub Desktop.

Select an option

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).
# /// 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()
@RoCry
Copy link
Copy Markdown
Author

RoCry commented Apr 12, 2026

Quick Start

Requires: uv + a free Gemini API key

# 1. Set your API key
export GEMINI_API_KEY="your-key-here"

# 2. One-time setup: add git aliases
GIST=https://gist.github.com/RoCry/ee6ec5a651137c2702b3f3cef6fdbd97
uv run $GIST --setup $GIST

# 3. Use it!
git ac    # commit staged changes with AI-generated message
git aac   # stage all + commit with AI-generated message

Optional: use other providers

Set GIT_COMMIT_MODELS to customize the model(s) with fallback:

export GIT_COMMIT_MODELS="deepseek/deepseek-chat,gemini/gemini-2.0-flash-lite"
export DEEPSEEK_API_KEY="your-deepseek-key"

Supported providers: gemini, deepseek, openai, groq, openrouter, mistral, grok, ollama — any OpenAI-compatible endpoint works.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment