We've all been there: you just made a small but meaningful change, and instead of writing a proper commit message, you type fix and move on. Later, reading through git log feels like deciphering ancient hieroglyphs.
Building a commit message generator with the Gemini API turns out to be a surprisingly satisfying solution to this problem. The core idea is simple — pass git diff --staged output to Gemini and ask for a Conventional Commits-style message. In this guide, I'll walk through the implementation from a minimal working script to a practical daily-use tool.
What the Finished Tool Looks Like
Here's the user experience once everything is set up:
# Stage your changes
git add src/auth/login.py
# Run the generator
python commit_gen.py
# Output:
# Suggested commit message:
# feat(auth): add rate limiting to login endpoint to prevent brute force attacksGemini reads the diff, understands the intent of the change, and proposes a descriptive message in the Conventional Commits format. The accuracy is good enough that I accept the suggestion as-is roughly 80% of the time.
Prerequisites
- Python 3.9 or higher
- A Gemini API key (free from Google AI Studio)
- The
google-genaipackage
pip install google-genaiSet your API key as an environment variable:
export GEMINI_API_KEY="YOUR_GEMINI_API_KEY"The Minimal Implementation
Start with something that works before adding polish:
import subprocess
import os
from google import genai
def get_staged_diff() -> str:
"""Retrieve the current staged diff from Git."""
result = subprocess.run(
["git", "diff", "--staged"],
capture_output=True,
text=True,
encoding="utf-8"
)
if result.returncode \!= 0:
raise RuntimeError(f"git diff failed: {result.stderr}")
return result.stdout
def generate_commit_message(diff: str) -> str:
"""Send the diff to Gemini API and return a commit message."""
if not diff.strip():
return "No staged changes found."
client = genai.Client(api_key=os.environ["GEMINI_API_KEY"])
prompt = f"""Read the following git diff and suggest a single commit message in Conventional Commits format.
Format: <type>(<scope>): <description>
Choose type from: feat / fix / refactor / docs / test / chore
Keep the description in English, under 50 characters.
Output the commit message only — no additional explanation.
---
{diff[:3000]}
---"""
response = client.models.generate_content(
model="gemini-2.5-flash",
contents=prompt
)
return response.text.strip()
if __name__ == "__main__":
diff = get_staged_diff()
message = generate_commit_message(diff)
print(f"Suggested commit message:\n{message}")Two design decisions worth explaining here.
First, the diff[:3000] truncation. Feeding an enormous diff doesn't improve message quality and drives up token costs. For the purpose of summarizing what changed, the first few thousand characters capture the essential information in almost every case.
Second, the choice of gemini-2.5-flash. This task values speed and cost-efficiency over deep reasoning. Flash handles commit message generation perfectly well, and since you might run this dozens of times a day, keeping costs low matters.
Practical Improvement 1: Copy to Clipboard
It saves a few seconds to have the suggested message land directly in your clipboard:
import sys
def copy_to_clipboard(text: str) -> bool:
"""Copy text to the system clipboard, handling macOS/Linux/Windows."""
try:
if sys.platform == "darwin":
subprocess.run(["pbcopy"], input=text.encode(), check=True)
elif sys.platform == "linux":
subprocess.run(["xclip", "-selection", "clipboard"],
input=text.encode(), check=True)
elif sys.platform == "win32":
subprocess.run(["clip"], input=text.encode(), check=True)
return True
except (subprocess.CalledProcessError, FileNotFoundError):
return False
if __name__ == "__main__":
diff = get_staged_diff()
message = generate_commit_message(diff)
print(f"Suggested commit message:\n{message}")
if copy_to_clipboard(message):
print("\n✓ Copied to clipboard")On Linux you may need to install xclip separately. On macOS, pbcopy is available out of the box.
Practical Improvement 2: Confirm Before Committing
Going one step further, you can confirm the message and run git commit directly:
def confirm_and_commit(message: str) -> None:
"""Prompt the user to accept, edit, or reject the suggested message."""
print(f"\nProposed message: {message}")
answer = input("Use this message? [Y/n/e(dit)]: ").strip().lower()
if answer in ("", "y"):
result = subprocess.run(
["git", "commit", "-m", message],
capture_output=True,
text=True
)
if result.returncode == 0:
print("✓ Committed successfully")
else:
print(f"✗ Commit failed: {result.stderr}")
elif answer == "e":
edited = input(f"Edit message [{message}]: ").strip()
if edited:
confirm_and_commit(edited)
else:
print("Cancelled.")The three-option flow (Y to accept, e to edit, n to cancel) keeps things quick without removing human oversight.
Error Handling Essentials
For a tool you'll rely on daily, handle the failure cases upfront:
from google.genai import errors as genai_errors
def generate_commit_message(diff: str) -> str:
if not diff.strip():
return ""
api_key = os.environ.get("GEMINI_API_KEY")
if not api_key:
raise ValueError("GEMINI_API_KEY environment variable is not set")
client = genai.Client(api_key=api_key)
try:
response = client.models.generate_content(
model="gemini-2.5-flash",
contents=build_prompt(diff)
)
return response.text.strip()
except genai_errors.APIError as e:
raise RuntimeError(f"Gemini API error: {e}")The explicit API key check avoids cryptic error messages when the environment variable isn't set — a small thing that saves real debugging time.
Setting Up as a Git Hook
For maximum convenience, wire this into Git's prepare-commit-msg hook so it triggers automatically on every git commit:
#\!/bin/bash
# .git/hooks/prepare-commit-msg
COMMIT_MSG_FILE=$1
COMMIT_SOURCE=$2
# Only fire for empty commits (not -m, -c, merge, etc.)
if [ -z "$COMMIT_SOURCE" ]; then
GENERATED=$(python /absolute/path/to/commit_gen.py --output-only 2>/dev/null)
if [ -n "$GENERATED" ]; then
echo "$GENERATED" > "$COMMIT_MSG_FILE"
fi
fiMake it executable:
chmod +x .git/hooks/prepare-commit-msgOne common pitfall: the hook runs in a different working context than your shell, so use an absolute path to the Python script. Relative paths are a silent failure waiting to happen.
Add an --output-only flag to your script to support this integration:
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("--output-only", action="store_true")
args = parser.parse_args()
diff = get_staged_diff()
message = generate_commit_message(diff)
if args.output_only:
print(message)
else:
confirm_and_commit(message)Where to Go From Here
The tool as described handles the vast majority of everyday commits. If you want to extend it, two natural next steps come to mind.
For large refactors where the diff runs into thousands of lines, chunking the diff and summarizing each chunk before the final message generation keeps quality consistent.
For team use, you might want to encode project-specific conventions into the prompt — custom scopes, issue tracker prefixes like JIRA-1234, or stricter length limits for automated changelog generation.
If you find yourself wanting more structure — like generating type, scope, and description as separate fields — the Gemini API's structured output capability makes that straightforward. The Gemini Structured Output Production Guide is a good next read once you have this baseline tool working.