Build a Git Commit-Message Generator
“wip”, “fix stuff”, “asdf”, every developer has typed a lazy commit message at 6pm on a Friday. This project builds a CLI tool that removes the excuse: it captures your real staged git diff with Python’s subprocess module, hands it to a free-tier language model with a system prompt designed specifically for writing Conventional-Commits-style messages, and shows you a draft you can accept, edit, or throw away, before anything is ever committed. The tool never commits on its own; a human always confirms the final message first.
This assumes Python 101 and enough comfort with git to know what git add and git commit do, nothing from Data Analysis is required. It’s optional and ungraded; see Real-World Projects for the full, growing list.
🎯 What you’ll do
- Install
uv, get a free-tier LLM API key, and set up a small project, all in one place, before any building starts. - Use Python’s
subprocessmodule to rungit diff --stagedfor real and capture its output as text. - Design a system prompt that turns a general-purpose LLM into a focused Conventional-Commits-style message drafter.
- Build an interactive CLI loop: show the draft, let the user accept, edit, or regenerate it.
- Wire the loop up to actually run
git commit -m "...", but only after the user explicitly confirms.
Where to run this
Locally with uv is the primary, recommended path here, more so than for most other projects in this series, this tool’s entire premise is reading git diff --staged from a real local git repository and, if you say so, committing to it. That means it needs an actual .git folder with staged changes on disk to work against (your own project, or a clone of this course’s repo).
GitHub Codespaces works well too: open the whole course repo in a free Codespace (Node, Python, uv, and git are already installed), it’s a real clone with a real place to stage changes, so every step below works exactly as it does locally.
Google Colab and Kaggle Notebooks are a reasonable way to try the drafting logic, but not to run the tool for real. Neither gives you a real local git repository with staged changes by default, and the whole premise of this tool is drafting a message for your own in-progress work, a notebook’s ephemeral filesystem has none of that, and there’s nothing sensible to actually commit to. The notebook below works around this honestly, rather than pretending the gap doesn’t exist: it !git clones this course’s own repository into the notebook and drafts a message for one real, small, historical commit from it with git show, so the diff capture, the system prompt, and the LLM call all run against real, real-looking output, it’s just drafting for a fixed example commit, and it stops there; it does not demo the interactive accept/edit/commit loop, since committing only makes sense against a repo you’re really working in. Use it to see the drafting logic work end to end with zero setup; switch to local uv or a Codespace once you want the full interactive tool pointed at your own actual changes.
Setup
Everything you need before you write a line of the drafter itself: a real Python, a free API key, and a small project to hold both.
Install uv
uv is a single tool that replaces the usual “install Python, then install pip, then install a virtual environment tool, then install packages” chain, it can install and manage Python versions itself, alongside your project’s dependencies.
macOS / Linux (terminal):
curl -LsSf https://astral.sh/uv/install.sh | sh
Windows (PowerShell):
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
Close and reopen your terminal, then confirm it installed:
uv --version
Set up the project
uv init commit-message-agent
cd commit-message-agent
uv add openai python-dotenv
openai’s client library works here for every provider in the table below, not just OpenAI itself, GitHub Models, Gemini, Groq, Mistral, Cerebras, and OpenRouter all expose an OpenAI-compatible chat endpoint, so one client, pointed at a different base_url, is all this project needs. python-dotenv lets you keep your API key in a local .env file instead of export-ing it every session.
Get a free LLM API key
Pick whichever provider you like, none of them require a credit card at the time of writing, and this course doesn’t favor one over another. The fuller example in the course repo (examples/commit-message-agent/) supports all six out of the box, selected with one setting.
| Provider | Where to get a key | Why you might pick it |
|---|---|---|
| GitHub Models (suggested default) | github.com/settings/tokens, a personal access token with the models: read scope | No separate signup, you already have a GitHub account. More generous free-tier limits than Gemini’s. |
| Gemini | Google AI Studio | The most commonly referenced option; also exposes an OpenAI-compatible endpoint, used below. |
| Groq | console.groq.com/keys | Fast inference, generous free tier, no card. |
| Mistral | console.mistral.ai/api-keys | One of the more generous permanent free quotas. |
| Cerebras | cloud.cerebras.ai | High daily token volume, no card. |
| OpenRouter | openrouter.ai/keys | One API, many free models, good for comparing providers. |
Whichever you pick, the process is the same:
- Sign in and generate an API key on that provider’s site.
- Never paste this key directly into code or commit it to a repository. Create a
.envfile in your project folder instead (never commit this):
# .env
LLM_PROVIDER=github
GITHUB_TOKEN=your-key-here
An API key is a secret, exactly like a password, anyone with it can use your account’s quota. Treating it as an environment variable rather than a hardcoded string is the standard practice for exactly this reason.
A .env file is often more convenient than export
Instead of export-ing a key in every new terminal session, python-dotenv reads a .env file in your project folder into os.environ automatically, the first time your script runs, see load_dotenv() in Step 1 below.
✅ Checklist
- ✅
uv --versionprints a version number. - ✅
commit-message-agent/exists with apyproject.toml, andopenaiandpython-dotenvare installed. - ✅ You have a real API key from one provider, saved in a
.envfile in your project folder, not pasted into any script.
Step 1: Capture a staged git diff with subprocess
Python’s subprocess module runs another program and captures its output as text, here, that program is git diff --staged, not the plain git diff you might reach for first. That’s a deliberate choice: a commit message should describe what’s actually about to be committed, which is whatever you’ve staged with git add, not every unstaged change sitting in your working tree.
Create commit_helper.py:
1.1 Write get_diff_staged and the subprocess wrapper
👟 Starter hint: subprocess.run(["git", "diff", "--staged"], capture_output=True, text=True) is the whole call, pass the command as a list (not a shell string), then check result.returncode yourself instead of relying on check=True’s generic error:
# commit_helper.py
import subprocess
from dotenv import load_dotenv
load_dotenv() # reads .env into the environment, if present
def get_diff_staged() -> str:
"""The diff between the index (staged changes) and the last commit."""
return _run_git(["diff", "--staged"])
def _run_git(args: list[str]) -> str:
"""Runs `git <args>` in the current directory and returns its stdout."""
result = subprocess.run(
["git", *args],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
raise RuntimeError(f"git {' '.join(args)} failed:\n{result.stderr}")
return result.stdout
if __name__ == "__main__":
diff = get_diff_staged()
print(diff if diff.strip() else "No staged changes. Stage something first with `git add`.")
subprocess.run([...], capture_output=True, text=True) is the key line: passing the command as a list of arguments (["git", "diff", "--staged"]) rather than one shell string avoids a whole class of shell-quoting and injection bugs, capture_output=True grabs stdout/stderr instead of letting them print directly to your terminal, and text=True decodes that output as a string instead of raw bytes. check=False plus a manual if result.returncode != 0 is deliberate here rather than check=True: it lets this function raise its own clear error message (including git’s real stderr) instead of a generic CalledProcessError.
Try it against this project itself, edit a file, git add it, then run:
uv run python commit_helper.py
This is the same subprocess pattern as any other CLI wrapper
subprocess.run doesn’t care that the program being run is git, it works identically for any command-line tool: ls, a shell script, another Python program. Once this pattern clicks, “let Python drive an existing CLI tool and use its output” becomes available for a lot more than just git.
1.2 Verify with staged, unstaged, and no changes
🎯 Expected output: With a staged change, get_diff_staged() prints real unified-diff text (+/- lines); with nothing staged, it prints the “No staged changes” message instead of an empty blob.
🩹 If it’s off: If it prints your changes even though nothing is git add-ed, you’re calling plain git diff somewhere instead of git diff --staged, double check the _run_git call. Running this outside any git repo should raise a clear RuntimeError naming git’s real stderr, not a raw FileNotFoundError, that means the if result.returncode != 0 check is missing.
✅ Checklist
- ✅
get_diff_staged()returns real diff text aftergit add-ing a change, and an empty string when nothing is staged. - ✅ Running
commit_helper.pyinside a folder that isn’t a git repo at all raises a clearRuntimeError, not a confusing traceback from deep insidesubprocess. - ✅ You can explain, in your own words, why this tool reads
git diff --stagedinstead of plaingit diff(unstaged changes).
🤔 Socratic Question(s)
- If you
git added one file and left another modified-but-unstaged, what wouldget_diff_staged()show, and what would plaingit diff(no--staged) show instead? Why does a commit-message tool specifically want the former? - What would
_run_git(["diff", "--staged"])return in a repo with uncommitted changes that are all unstaged? Why does handling an empty diff, rather than assuming there’s always something staged, matter for a tool meant to run as part of a normal commit workflow?
Step 2: Design the commit-message system prompt
A language model with no instructions might write a message that’s too vague (“update code”), too verbose (a full paragraph for a one-line typo fix), or in no consistent format at all. The system prompt is what turns a general-purpose chat model into a drafter that behaves like a disciplined project maintainer: what format to use, what mood to write in, and when to bother with more than one line.
2.1 Write the system prompt
👟 Starter hint: Spell out the exact type(scope): summary shape you want, list the valid Conventional Commits types explicitly (don’t let the model invent its own), and forbid inventing ticket numbers the diff doesn’t mention:
SYSTEM_PROMPT = """\
You are an experienced software engineer writing a git commit message for a
staged diff. You will be given a unified git diff. Base the message ONLY on
what the diff actually changes -- do not invent context you can't see, and
do not guess at a ticket number or issue reference that isn't in the diff.
Write the message in the Conventional Commits style:
<type>(<optional scope>): <short summary, imperative mood, no period>
<optional body: a few lines explaining WHY the change was made, not
just restating what the diff shows -- wrap around 72 characters>
Valid types: feat, fix, docs, style, refactor, perf, test, build, ci, chore.
Pick the type that best matches the *dominant* change -- if a diff touches
both a fix and its test, "fix" usually still wins over "test".
Rules:
- The summary line must stay under 72 characters and use the imperative
mood ("add", not "added" or "adds").
- Only include a body if it adds real information beyond the summary --
for a small, self-explanatory diff, the summary line alone is enough.
- Never wrap the whole message in a fenced code block or add commentary
before/after it -- output ONLY the commit message text itself, nothing
else, so it can be used directly as a commit message.
"""
Three deliberate design choices worth noticing:
- A fixed structure (
type(scope): summary, optional body) is what makes the output usable as an actual commit message, not a chat reply that happens to describe the diff, Conventional Commits is a widely-used convention specifically because tools (changelogs, semantic-release, CI) can parse it reliably. - “Only include a body if it adds real information” stops the model from padding out a one-line typo fix with three sentences of restated diff content, the same instinct a human reviewer has when they see a bloated commit message for a trivial change.
- “Base the message ONLY on what the diff actually changes… do not guess at a ticket number” exists because models are happy to hallucinate a plausible-looking
JIRA-1234or issue reference if you don’t explicitly forbid it, a fabricated reference in a commit message is worse than no reference at all.
Iterate on the prompt like you would on code
Treat this system prompt as a first draft, not a finished spec. Run it against a diff you already know deserves a specific type (a pure test addition, a docs-only change, a real bug fix), if the model picks the wrong type or the summary runs long, tighten the wording and try again.
2.2 Verify it holds up on a diff with a known answer
🎯 Expected output: This step has no runnable code on its own (Step 3 wires the prompt to a real call), the check here is reading the prompt back and confirming it actually forbids everything the design notes above claim it does.
🩹 If it’s off: If, once you test it in Step 3, the model still invents a ticket number or writes a multi-paragraph body for a one-line fix, that’s a sign the corresponding rule needs to be stated more forcefully or more specifically, not that prompting doesn’t work, see the tip on iterating like code.
✅ Checklist
- ✅ You can explain, in your own words, why the prompt forbids inventing a ticket number or issue reference that isn’t in the diff.
- ✅ The prompt specifies a concrete output format (
type(scope): summary, optional body), not just “write a commit message.”
🤔 Socratic Question(s)
- If you removed the “only include a body if it adds real information” instruction, what kind of commit messages would you expect for very small, self-explanatory diffs?
- The prompt lists ten valid Conventional Commits types. What would go wrong for a real project’s changelog tooling if the model were free to invent its own types instead of picking from a fixed list?
Step 3: Call the LLM and build the interactive loop
Wire the diff-capturing code from Step 1 and the system prompt from Step 2 together, then add the part that makes this a real tool instead of a one-shot script: a loop that shows the draft and lets a human accept, edit, or regenerate it.
3.1 Write draft_commit_message
👟 Starter hint: Guard against an empty diff before ever touching the network, truncate an oversized one, then send it as the user message alongside SYSTEM_PROMPT and return response.choices[0].message.content.strip(), this function’s whole job is “diff in, message string out,” nothing about terminals or committing:
# commit_helper.py (continued -- add these imports and functions)
import os
from openai import OpenAI
MAX_DIFF_CHARS = 12_000 # see the "huge diffs" pitfall below
def truncate_diff(diff: str, max_chars: int = MAX_DIFF_CHARS) -> str:
"""Cuts an oversized diff down to a size that fits a free-tier context window."""
if len(diff) <= max_chars:
return diff
return diff[:max_chars] + f"\n\n... [diff truncated -- {len(diff) - max_chars} more characters not shown] ..."
def draft_commit_message(diff: str) -> str:
"""Sends a diff to the configured free-tier LLM and returns a drafted commit message.
Returns a plain string. That's the whole job of this function -- it has
no idea a terminal or a `git commit` call exists anywhere. See Step 4
for the only place this tool actually commits.
"""
if not diff.strip():
return ""
client = OpenAI(
api_key=os.environ["GITHUB_TOKEN"],
base_url="https://models.github.ai/inference",
)
diff = truncate_diff(diff)
response = client.chat.completions.create(
model="gpt-4o-mini", # confirm this still has a free tier before running
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": f"Write a commit message for this staged diff:\n\n```diff\n{diff}\n```"},
],
)
return response.choices[0].message.content.strip()
🎯 Expected output: draft_commit_message(diff) on a real staged diff returns a plain string shaped like feat(scope): short summary, no surrounding quotes, no code fence, no commentary before or after.
🩹 If it’s off: If the returned string still has a ```diff fence around it despite the prompt saying not to, that’s a free-tier model occasionally not following instructions exactly, strip fences defensively the same way the study-buddy and voice-to-task projects do, rather than assuming the prompt alone is airtight. A KeyError on GITHUB_TOKEN means .env isn’t being loaded, confirm load_dotenv() ran before this function is called.
3.2 Build the interactive accept/edit/regenerate loop
👟 Starter hint: A while True: loop that prints the current draft, reads one character of input (y/e/r/n), and either returns, replaces message with typed text, or calls draft_commit_message again, nothing here calls git commit yet, that’s deliberately deferred to Step 4:
def run_interactive_loop(diff: str) -> None:
"""Drafts a message and lets the user accept, edit, or regenerate it -- see Step 4
for where (and only where) an accepted message actually gets committed."""
if not diff.strip():
print("No staged changes. Stage something first with `git add`.")
return
print(f"Drafting a commit message from {len(diff)} characters of staged diff...\n")
message = draft_commit_message(diff)
while True:
print("-" * 60)
print(message)
print("-" * 60)
choice = input("\nUse this message? [y]es / [e]dit / [r]egenerate / [n]o, cancel: ").strip().lower()
if choice in ("n", "no"):
print("Cancelled -- nothing was committed.")
return
if choice in ("r", "regenerate"):
message = draft_commit_message(diff)
continue
if choice in ("e", "edit"):
message = input("Type your edited message: ").strip() or message
continue
if choice in ("y", "yes"):
print(f"\n(Would commit here with message:\n{message}\n)")
return
print("Please answer y, e, r, or n.")
if __name__ == "__main__":
diff = get_diff_staged()
run_interactive_loop(diff)
truncate_diff matters more here than it might first appear, see the pitfalls section below for why a large diff isn’t just slow, it can silently fail or produce a shallow, generic message. The loop deliberately does not call git commit yet, Step 4 adds that as its own small, explicit function, so it’s obvious exactly where and how committing happens.
Run it:
uv run python commit_helper.py
Using a different provider?
Swap the OpenAI(...) block for a different base_url and key, e.g. base_url="https://api.groq.com/openai/v1" with api_key=os.environ["GROQ_API_KEY"] for Groq, or base_url="https://generativelanguage.googleapis.com/v1beta/openai/" with api_key=os.environ["GOOGLE_API_KEY"] for Gemini’s OpenAI-compatible endpoint. Everything else in this file stays the same. See examples/commit-message-agent/commit_helper.py in the course repo for all six wired up side by side, selectable with one environment variable.
🎯 Expected output: A draft prints, followed by the [y]es / [e]dit / [r]egenerate / [n]o prompt; each option behaves distinctly, r calls the model again, e waits for typed replacement text, n prints “Cancelled” and returns, y prints the placeholder commit message (Step 4 makes it real).
🩹 If it’s off: If typing e then pressing Enter with nothing typed wipes out the draft instead of keeping it, check the or message fallback in the input(...).strip() or message line, an empty typed string should keep the previous draft, not replace it with an empty one. An input loop that never re-prints the prompt after an invalid answer means the trailing print("Please answer y, e, r, or n.") branch isn’t being reached, check every if above it uses continue or return, not a silent fallthrough.
✅ Checklist
- ✅
uv run python commit_helper.pyprints a Conventional-Commits-style draft for a real staged diff. - ✅ Typing
rat the prompt asks the model again and prints a (possibly different) draft, without doing anything else. - ✅ Typing
ncancels cleanly, and typingelets you type a replacement message before continuing.
🤔 Socratic Question(s)
draft_commit_messagereturns early with an empty string when the diff is empty, before ever building anOpenAIclient. Why is checking first, calling the API second, worth doing deliberately, rather than just letting an empty prompt go to the model?- If two different runs of
draft_commit_messageon the exact same staged diff produced two visibly different messages, would that surprise you? What does that suggest about why ther(regenerate) option exists at all, rather than trusting the first draft blindly?
Step 4: Wire it up to actually commit, only on confirmation
The last piece: replace the “(Would commit here…)” placeholder from Step 3 with a function that actually runs git commit -m, called from exactly one place, right after the user types y.
4.1 Write _commit and wire it into the y branch
👟 Starter hint: Same subprocess.run([...], capture_output=True, text=True, check=False) pattern as Step 1’s _run_git, just with ["git", "commit", "-m", message], then replace Step 3’s placeholder print in the y branch with a real call to it:
# commit_helper.py (continued)
def _commit(message: str) -> None:
"""Runs the actual `git commit -m <message>`.
This is the ONLY function in this file that commits anything. It's only
ever called from run_interactive_loop, only ever after an explicit 'y'
from a human. There is no other code path that reaches it.
"""
result = subprocess.run(
["git", "commit", "-m", message],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
raise RuntimeError(f"git commit failed:\n{result.stderr}")
print(result.stdout)
print("Committed.")
def run_interactive_loop(diff: str) -> None:
if not diff.strip():
print("No staged changes. Stage something first with `git add`.")
return
print(f"Drafting a commit message from {len(diff)} characters of staged diff...\n")
message = draft_commit_message(diff)
while True:
print("-" * 60)
print(message)
print("-" * 60)
choice = input("\nUse this message? [y]es / [e]dit / [r]egenerate / [n]o, cancel: ").strip().lower()
if choice in ("n", "no"):
print("Cancelled -- nothing was committed.")
return
if choice in ("r", "regenerate"):
message = draft_commit_message(diff)
continue
if choice in ("e", "edit"):
message = input("Type your edited message: ").strip() or message
continue
if choice in ("y", "yes"):
_commit(message)
return
print("Please answer y, e, r, or n.")
Try the full loop against a real change:
# make a small, real change
git add <the file you changed>
uv run python commit_helper.py
# read the draft, then type e to tweak it, r to try again, or y to commit for real
Check that it actually happened:
git log -1
4.2 Verify a real commit lands, and that “no” leaves things untouched
🎯 Expected output: After typing y, git commit output prints followed by Committed., and git log -1 shows exactly the message you accepted. After typing n on a separate run, git log -1 is unchanged and git status still shows your change staged.
🩹 If it’s off: If git log -1 doesn’t reflect the accepted message, check _commit is actually being called from the y branch, not still printing the Step 3 placeholder. If n somehow still commits, that’s a serious bug for this specific tool, re-read the if choice in ("n", "no"): ... return branch and confirm it returns before reaching any code path that calls _commit.
✅ Checklist
- ✅ Typing
yat the prompt actually creates a real commit,git log -1shows the message you accepted. - ✅ Typing
nat the prompt leaves your staged changes staged and uncommitted, nothing happened. - ✅ You can point to the single line of code where
git commitis actually invoked, and explain why it’s reachable from exactly one place.
🤔 Socratic Question(s)
_commitis a tiny, separate function instead of being inlined into theybranch of the loop. What does keeping it separate make easier if you later wanted to log every real commit this tool makes, or add a--dry-runflag that skips it entirely?- Imagine a version of this tool that skips the confirmation prompt and commits automatically whenever the model’s draft looks “confident.” What’s a realistic way that could go wrong on a diff you didn’t fully review yourself before staging it?
Never let a tool commit without a human confirming the message first
This is the single most important lesson in this project, more important than any specific line of code: a tool that drafts a commit message is useful; a tool that commits one autonomously is a very different, much riskier thing, one bad draft, one truncated diff that hid the real change, or one model having a bad day, and history now has a commit message that doesn’t describe what actually happened, with your name on it. _commit is the only function here that touches git commit, and it’s only reachable after an explicit y. That’s not a missing “auto-commit” feature, it’s the design. Keep that boundary if you extend this project yourself.
⚠️ Common pitfalls
- Huge diffs blowing past the context window or free-tier token quota. A multi-thousand-line diff (a big refactor, a vendored dependency bump) can exceed what the model can actually attend to, or simply exceed your free tier’s per-request token limit and fail outright.
truncate_diffin Step 3 caps this, but truncation means the model is drafting from a partial view, for genuinely large changes, stage and commit in smaller, more logical chunks rather than trusting a truncated diff to produce an accurate message. - Staging unrelated changes together. If
git addpicks up two unrelated fixes at once, no system prompt can produce one honest, focused commit message for both, the model will either pick one to describe and ignore the other, or write a vague message that covers neither well.git add -pto stage hunks selectively is worth learning alongside this tool. - Treating the draft as always correct. The model doesn’t know why you made a change, only what the diff shows, it can misread intent (calling a deliberate refactor a “fix”, for example) in ways a human glancing at the same diff wouldn’t. Reading the draft before typing
y, not just skimming it, is the entire point of the confirmation step. - Committing generated or vendored files by accident. A diff that touches
uv.lock, a minified bundle, or an auto-generated file wastes tokens and usually produces a low-quality, generic message, review what’s staged (git status,git diff --staged --stat) before running the drafter, not after.
What you just built
A real, working commit-message CLI: it captures your actual staged git diff via subprocess, drafts a Conventional-Commits-style message with a free-tier LLM guided by a prompt engineered specifically for this task, and only ever runs git commit after you’ve read the draft and explicitly said yes. Nothing here is a toy simulation, point it at your own staged work, or a real historical commit from this course’s own repository, and it works against the actual text either way.
Run a fuller version without any local setup
examples/commit-message-agent/ in the course repo is a fuller version of the code above, with all six providers from the table wired up side by side (selected with one LLM_PROVIDER setting) and a --dry-run/--commit/--stdin set of CLI options already included. Clone it, or open the whole repo in a GitHub Codespace, and run it from there.
Where to go from here
- Wire this up as a real git alias (e.g.
git draft-commit = !uv run --project ~/commit-message-agent python commit_helper.py) so it’s one short command away in any repo, instead of alwayscd-ing into this project’s folder. - Add it as a prompt inside a pre-commit hook, rather than replacing
git commitoutright, have the hook print the drafted message as a suggestion alongside whatever message the developer already typed, so it stays a second opinion rather than a gate. - Try comparing drafts across two different providers on the same staged diff, do they pick the same Conventional Commits
type? Where do they disagree, and what does that tell you about how much to trust any single model’s read of “why” a change was made, versus just “what” changed?
Share your project with the class
Built something you’re proud of? examples/student-projects/ is a gallery of projects other students have submitted, and its README has a full, beginner-friendly walkthrough for adding yours via a pull request, even if you’ve never used git before: forking the repo, making a branch, committing your files, and opening the PR, one step at a time. No prior git experience assumed.
Welcome to writing Python outside the browser. 🎓
Finish every step, then mark this project complete to claim its XP.