The model wrote better commit messages than I do. That was the problem.
It was also confident. Confident about a file that wasn't in the diff. Confident about a refactor that never happened. A free model will happily turn a two-line typo fix into "perf: optimize cache invalidation strategy across storage layer." Sounds great. It's fiction.
So I built a gate. One weekend project, one free server, one free model, and a validator that refused to be impressed. This is the case study, end to end.
Background
My commit history reads like a confession: "fix stuff", "update", "wip". I wanted better messages without becoming the person who writes essays about every whitespace change. Free model access makes that tempting. Free server capacity makes it automatable.
But here's the trap. A model that summarizes a diff is doing pattern matching, not code review. It sees "cache" in a variable name and writes "optimize cache". It sees a deleted test and writes "remove redundant test". Sometimes true. Sometimes a hallucination with a colon after it. Why would I trust a draft machine with my history? I wouldn't. That's why the gate exists.
Goal
The goal was boring and specific: generate a commit message from the staged diff, validate it against reality, and refuse to commit when the message doesn't match the diff. If validation fails, I write the message myself. The model is a draft machine, not a decision machine.
Implementation
The whole thing is one Python file. It runs as a cron job on a free server for my scheduled repos, and locally as a git hook for everything else.
# commit_gate.py — draft a commit message, then verify it against the diff.
import re
import subprocess
import sys
def staged_files():
out = subprocess.run(
['git', 'diff', '--cached', '--name-only'],
capture_output=True, text=True,
).stdout
return set(out.splitlines())
def draft_message(diff):
# Pseudocode: call any OpenAI-compatible chat endpoint here.
# I used MonkeyCode's free model access; the gate works with any provider.
return 'refactor: clean up cache invalidation in storage layer'
def validate(message, files):
problems = []
prefix = message.split(':')[0]
allowed = ('feat', 'fix', 'refactor', 'docs', 'test', 'chore')
if not any(prefix == p or prefix.startswith(p + '(') for p in allowed):
problems.append('missing conventional commit prefix')
mentioned = set(re.findall(r'[A-Za-z0-9_/.-]+[.](py|js|ts|go|rs|md)', message))
if mentioned and not mentioned.issubset(files):
problems.append(f'mentions files outside the diff: {mentioned - files}')
if len(message) > 120:
problems.append('message is longer than the diff it describes')
return problems
def main():
files = staged_files()
if not files:
sys.exit('nothing staged')
if len(files) == 1 and next(iter(files)).endswith('lock'):
print('chore: skip the model for lockfile-only changes')
return
diff = subprocess.run(
['git', 'diff', '--cached', '--stat'],
capture_output=True, text=True,
).stdout
message = draft_message(diff)
problems = validate(message, files)
if problems:
print('REJECTED by gate:')
for p in problems:
print(' -', p)
sys.exit(1)
print('ACCEPTED:', message)
if __name__ == '__main__':
main()
The draft_message function is the only placeholder. Wire it to any chat endpoint and the gate works. I used MonkeyCode's free model access because the whole experiment was supposed to cost nothing — it's an open source project with a free tier that covers this kind of small job. Disclosure: This article was prepared as part of MonkeyCode's product outreach.
The gate, not the model
Everyone is writing about reasoning ledgers for agents these days. I needed something smaller: a refusal ledger for commit messages.
The gate runs three checks. First, the prefix. No conventional commit prefix, no commit. That kills the "update stuff" class of messages instantly.
Second, file names. The model tends to name files that fit the story. The regex extracts every file-like token from the message and checks it against the staged files. A message that mentions storage.py when storage.py isn't staged is a lie, even if the prose sounds reasonable.
Third, length. A 140-character message about a three-line diff is suspicious. Short diffs deserve short messages.
Then there's the skip rule. Lockfile-only changes never touch the model. Dependency bumps don't need a poet, they need a version number.
What to measure
Here's the reproducible part. Run this for a week and measure three numbers: rejection rate, time saved on accepted messages, and how many accepted messages you later regret.
The failure modes are consistent across providers: invented file names, invented verbs, and messages that describe the repo's history instead of the staged diff. The rejection rate will feel high at first. That's the gate working. A rejected draft costs a second. A confident hallucination costs a revert. What does a good commit message actually do? It lets future-me answer one question: why did this change happen? A hallucinated message makes that answer worse than no message at all.
Why the free server matters
Because the workflow only earns its keep if it runs without me. A cron job on a free server can sweep my repos every morning, draft messages for anything staged, and leave the accepted ones in a file for review.
The model call is small. The storage is small. The whole thing fits comfortably in a free tier. I used MonkeyCode's free server option for exactly this: a scheduled job that costs nothing and disappears if I stop caring about it. A free tier is a constraint, and constraints are the point. If you want to run the same experiment, the free tier and the free server option are enough.
Limitations
This is not a review tool. It does not understand the diff. It checks grammar, file names, and length. A message can pass all three checks and still be wrong.
Do not use this for security-sensitive commits. Do not use it as a code review substitute. And if your repo has no conventional commit history, the prefix check will annoy you before it helps you.
Model quality varies by provider and by the day. Free tiers change, and quotas change. The 10M token allowance on the free tier is enough for this kind of small job, but check the current terms before you build a habit on it.
Who should not use this
People who commit once a week don't need it. People who write perfect messages already don't need it. People who will trust the model's output without the gate definitely should not use it. The gate is the product. The model is just the first draft.
Try it on a throwaway repo. Stage a bad change, run the script, watch it reject the confident nonsense. That moment is the whole article.
Top comments (0)