DevLab / ai / ai-commit-message-generator-from-git-diff
AI PYTHON BEGINNER

AI Commit Message Generator from Git Diff

Writing good commit messages is one of the most consistently neglected parts of software development. Developers under time pressure often write terse mess

Python3.10+
OSLinux / macOS / Windows
Lines~161
Packages2 deps
Install Dependencies
$ pip install openai gitpython
Usage
$ python3 commit_msg.py --repo . --staged
#ai#python#beginner#openai#gitpython

AI Commit Message Generator from Git Diff

Problem Statement

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.

How It Works

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.

Full Script

#!/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())

Configuration

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.

Usage Examples

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

Error Handling

  • Missing API key → exit 2 with a clear message on stderr; no API call is attempted.
  • Not a Git repository → InvalidGitRepositoryError is caught and reported; exit 3.
  • Empty diff → ValueError from get_diff; exit 4. This prevents wasting an API call on nothing.
  • Git command failure (e.g. corrupted index) → GitCommandError caught; exit 4.
  • OpenAI API errors (auth, rate limit, network) → OpenAIError caught; exit 5.
  • Empty model response → RuntimeError caught; exit 6.
  • Detached HEAD → 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.

Extending Ideas

  • Interactive confirmation: wrap the output in a prompt (input()) so the user can accept, edit, or regenerate the message before committing.
  • Local model support: swap the 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 directly: add a --commit flag that runs repo.index.commit(message) after confirmation, turning the script into a one-shot commit helper.
  • Conventional-Commits validation: parse the model's first line with a regex and retry once if it doesn't match <type>(<scope>): <summary>, improving reliability for teams that lint commit messages in CI.
// RELATED_SCRIPTS
← Back to DevLab valtersit.com