Writing good commit messages is one of the most consistently neglected parts of software development. Developers under time pressure often write terse mess
Writing good commit messages is one of the most consistently neglected parts of software development. Developers under time pressure often write terse messages like fix, update, or wip — which makes git log nearly useless for future archaeology, code review, or generating changelogs. Meanwhile, the actual information needed to write a great commit message (the diff itself) is right there in the staging area.
This script solves that problem by feeding your staged git diff to an LLM and asking it to produce a Conventional Commits–style message. It's a small but genuinely useful automation: you stage your changes, run one command, and get a well-formatted message you can paste directly into git commit -m. It's also a great example of using structured AI outputs in a real CLI workflow.
The script has three stages. First, it uses GitPython to open the repository at the given path and read the diff — either the staged diff (--staged, i.e. git diff --cached) or the unstaged working-tree diff. It also grabs the list of changed file paths and a truncated branch name for context. Second, it constructs a system + user prompt for the OpenAI chat completion API, instructing the model to return a Conventional Commits message (subject line ≤ 72 chars, optional body). Third, it prints the model's response to stdout, optionally copying it to the clipboard or printing just the raw message for shell pipelines.
Key design decisions: (1) the diff is truncated to a configurable character budget so we don't blow past the model's context window on huge commits; (2) the model is invoked with a low temperature for deterministic, conventional output; (3) the script exits with a non-zero status on any error so it composes cleanly into shell scripts and CI; and (4) all configuration is driven by environment variables with sensible defaults, so it works out of the box for most users.
#!/usr/bin/env python3
"""
AI Commit Message Generator from Git Diff.
Reads the staged (or unstaged) diff from a Git repository, sends it to an
OpenAI chat model, and prints a Conventional Commits-style message.
Usage:
python3 commit_msg.py --repo . --staged
python3 commit_msg.py --repo /path/to/repo --no-staged
"""
from __future__ import annotations
import argparse
import os
import sys
from typing import Optional
from git import Repo, InvalidGitRepositoryError, GitCommandError
from openai import OpenAI, OpenAIError
# ---------------------------------------------------------------------------
# Configuration (all overridable via environment variables)
# ---------------------------------------------------------------------------
DEFAULT_MODEL: str = os.environ.get("COMMIT_MSG_MODEL", "gpt-4o-mini")
MAX_DIFF_CHARS: int = int(os.environ.get("COMMIT_MSG_MAX_DIFF_CHARS", "12000"))
TEMPERATURE: float = float(os.environ.get("COMMIT_MSG_TEMPERATURE", "0.2"))
MAX_TOKENS: int = int(os.environ.get("COMMIT_MSG_MAX_TOKENS", "400"))
SYSTEM_PROMPT = (
"You are an expert software engineer who writes concise, high-quality "
"Git commit messages following the Conventional Commits specification. "
"Rules:\n"
"1. Output ONLY the commit message. No code fences, no explanations.\n"
"2. First line: '<type>(<optional scope>): <summary>'.\n"
"3. Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore.\n"
"4. Summary line must be <= 72 characters, imperative mood, no trailing period.\n"
"5. If the change is non-trivial, add a blank line and a short body "
"explaining the WHY, wrapped at 72 characters.\n"
)
def get_diff(repo: Repo, staged: bool, max_chars: int) -> str:
"""Return the diff text, truncated to max_chars characters."""
if staged:
# `git diff --cached` = staged changes only.
diff_text = repo.git.diff("--cached")
else:
# `git diff` = unstaged working-tree changes.
diff_text = repo.git.diff()
if not diff_text.strip():
raise ValueError(
"No changes found. Stage your changes with `git add` "
"or pass --no-staged to diff the working tree."
)
if len(diff_text) > max_chars:
# Keep the head of the diff — usually the most informative part.
diff_text = diff_text[:max_chars] + "\n\n[... diff truncated ...]"
return diff_text
def get_changed_files(repo: Repo, staged: bool) -> list[str]:
"""Return the list of files touched by the diff."""
if staged:
# `--name-only` with `--cached` lists staged files.
output = repo.git.diff("--cached", "--name-only")
else:
output = repo.git.diff("--name-only")
return [line for line in output.splitlines() if line.strip()]
def build_user_prompt(diff_text: str, files: list[str], branch: str) -> str:
"""Compose the user message sent to the model."""
files_block = "\n".join(f"- {f}" for f in files[:50]) or "(unknown)"
return (
f"Branch: {branch}\n"
f"Files changed:\n{files_block}\n\n"
f"Diff:\n{diff_text}\n\n"
"Write the commit message."
)
def generate_message(
client: OpenAI,
diff_text: str,
files: list[str],
branch: str,
model: str,
) -> str:
"""Call the OpenAI API and return the commit message text."""
response = client.chat.completions.create(
model=model,
temperature=TEMPERATURE,
max_tokens=MAX_TOKENS,
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": build_user_prompt(diff_text, files, branch)},
],
)
content = response.choices[0].message.content
if not content:
raise RuntimeError("Model returned an empty response.")
return content.strip()
def parse_args(argv: Optional[list[str]] = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Generate a commit message from a Git diff using OpenAI."
)
parser.add_argument(
"--repo",
default=".",
help="Path to the Git repository (default: current directory).",
)
staged_group = parser.add_mutually_exclusive_group()
staged_group.add_argument(
"--staged",
dest="staged",
action="store_true",
default=True,
help="Use staged changes (git diff --cached). Default.",
)
staged_group.add_argument(
"--no-staged",
dest="staged",
action="store_false",
help="Use unstaged working-tree changes (git diff).",
)
parser.add_argument(
"--model",
default=DEFAULT_MODEL,
help=f"OpenAI model to use (default: {DEFAULT_MODEL}).",
)
return parser.parse_args(argv)
def main(argv: Optional[list[str]] = None) -> int:
args = parse_args(argv)
# --- Validate API key early so we fail fast with a clear message. ---
api_key = os.environ.get("OPENAI_API_KEY")
if not api_key:
print(
"error: OPENAI_API_KEY environment variable is not set.",
file=sys.stderr,
)
return 2
# --- Open the repository. ---
try:
repo = Repo(args.repo, search_parent_directories=True)
except InvalidGitRepositoryError:
print(f"error: '{args.repo}' is not a Git repository.", file=sys.stderr)
return 3
# --- Collect diff and metadata. ---
try:
diff_text = get_diff(repo, args.staged, MAX_DIFF_CHARS)
files = get_changed_files(repo, args.staged)
except (GitCommandError, ValueError) as exc:
print(f"error: {exc}", file=sys.stderr)
return 4
try:
branch = repo.active_branch.name
except TypeError:
# Detached HEAD — active_branch raises TypeError.
branch = "(detached HEAD)"
# --- Call the model. ---
client = OpenAI(api_key=api_key)
try:
message = generate_message(client, diff_text, files, branch, args.model)
except OpenAIError as exc:
print(f"error: OpenAI API call failed: {exc}", file=sys.stderr)
return 5
except RuntimeError as exc:
print(f"error: {exc}", file=sys.stderr)
return 6
# --- Emit the result. Plain stdout so it pipes cleanly. ---
print(message)
return 0
if __name__ == "__main__":
sys.exit(main())
| Variable | Default | Purpose |
|---|---|---|
OPENAI_API_KEY |
(required) | Your OpenAI API key. The script exits with code 2 if missing. |
COMMIT_MSG_MODEL |
gpt-4o-mini |
Any chat-completions model name (gpt-4o, gpt-4o-mini, etc.). Overridable with --model. |
COMMIT_MSG_MAX_DIFF_CHARS |
12000 |
Character budget for the diff. Larger diffs are truncated to stay within the model's context window. |
COMMIT_MSG_TEMPERATURE |
0.2 |
Sampling temperature. Low values keep output consistent and conventional. |
COMMIT_MSG_MAX_TOKENS |
400 |
Upper bound on response tokens. Enough for a subject + short body. |
1. Generate a message for staged changes in the current repo:
$ git add src/auth.py tests/test_auth.py
$ export OPENAI_API_KEY=sk-...
$ python3 commit_msg.py --repo . --staged
feat(auth): add token refresh support
Refreshes expired access tokens automatically using the stored
refresh token, avoiding a hard logout for active users.
2. Pipe the result straight into git commit:
$ python3 commit_msg.py --staged | git commit -F -
[main 4f1c2a9] fix(parser): handle empty input without raising
1 file changed, 12 insertions(+), 3 deletions(-)
3. Inspect unstaged work-in-progress changes with a bigger model:
$ python3 commit_msg.py --no-staged --model gpt-4o
chore(deps): bump requests from 2.31.0 to 2.32.0
2 with a clear message on stderr; no API call is attempted.InvalidGitRepositoryError is caught and reported; exit 3.ValueError from get_diff; exit 4. This prevents wasting an API call on nothing.GitCommandError caught; exit 4.OpenAIError caught; exit 5.RuntimeError caught; exit 6.active_branch raises TypeError; we substitute "(detached HEAD)" and continue.Because every failure returns a distinct non-zero status, the script composes safely into shell pipelines and CI steps.
input()) so the user can accept, edit, or regenerate the message before committing.OpenAI client for an OpenAI-compatible endpoint (Ollama, vLLM, LM Studio) by setting base_url from an env var — no other code changes needed.--commit flag that runs repo.index.commit(message) after confirmation, turning the script into a one-shot commit helper.<type>(<scope>): <summary>, improving reliability for teams that lint commit messages in CI.