Tested 2026-11-13. Disclosure: I'm on the UnAIMyText team; that's the reason it sits at #3 among the tools. The script below is free to copy.
Our team switched on Bedrock API keys last quarter, and within a week someone had wired a model into their shell to write commit messages. I get the appeal. You stage a diff, type two words of notes, and a full message appears.
Then you run git log --oneline and see this:
a41c9e2 **Enhanced upload reliability** ✨ Followed, in the body, by "This commit aims to streamline the upload process and ensure a smooth, robust experience for all users." Nobody on our team talks like that. Worse, the message never says what broke or why the change was needed, which is the only thing a commit message is for when someone runs git blame eight months later.
So I wrote a small linter. Pass --draft with notes and it asks Bedrock for a message, then checks it. Pass a file, or install it as a commit-msg hook, and it never touches the network.
Bedrock in one paragraph
AWS's page on using an Amazon Bedrock API key shows the whole thing as a plain HTTP call: POST https://bedrock-runtime.us-east-1.amazonaws.com/model/<model-id>/converse with Authorization: Bearer <key>. The service also recognizes the key from the AWS_BEARER_TOKEN_BEDROCK environment variable, so that's what the script reads. The page's example model is us.anthropic.claude-sonnet-4-6. The Converse body takes messages (each with a list of content blocks), an optional system list and inferenceConfig. The reply text lives at output.message.content[].text. When I sent a made-up key, the real endpoint returned 403 with "Invalid API Key format: Must start with pre-defined prefix". With no header at all it said Authorization header is missing. The script reports both as exit 2.
What the linter checks
- Subject longer than 72 characters
- Subject ending with a period
- Subject in past tense or third person (
Added,Fixes) instead of imperative (Add,Fix) - No blank line between subject and body
- Body lines wider than 72 characters (URLs don't count)
- Chatbot filler:
this commit,streamline,robust,seamless,leverage,comprehensive,ensure a smooth - Markdown bold, fences or emoji, which
git logshows raw -
TODO,TBD,[ticket]leftovers
Lines starting with # are dropped first, the same way git strips comments from a message you write in the editor. That also means a model's ## Summary heading silently vanishes, which is its own kind of surprise.
Exit codes: 0 clean, 1 lint failures, 2 bad arguments, network trouble or API errors.
The script
#!/usr/bin/env python3 """Commit message linter for people who draft commits with a model on Amazon Bedrock. Usage: python3 commit_lint.py --draft "what changed and why" ask Bedrock for a message, then lint it python3 commit_lint.py COMMIT_EDITMSG lint a saved message (works as a commit-msg hook) python3 commit_lint.py - read the message from stdin add --json for machine-readable output Exit: 0 clean, 1 lint failures, 2 usage/network/API errors """ import json import os import re import sys import urllib.error import urllib.parse import urllib.request SUBJECT_MAX = 72 BODY_WIDTH = 72 PAST_TENSE = re.compile(r"^(added|adds|fixed|fixes|updated|updates|removed|removes|changed|changes|implemented|implements|refactored|refactors)\b", re.I) FILLER = re.compile(r"\b(this commit|this (change|pr) (aims|is designed) to|enhanc(e|es|ing) the overall|streamlin(e|es|ing)|seamless(ly)?|robust|leverag(e|es|ing)|comprehensive|ensur(e|es|ing) (a )?smooth)\b", re.I) MARKUP = re.compile(r"^\s*(#{1,6} |\*\*|`{3})|[\u2705\u2728\U0001F680\U0001F41B]", re.M) PLACEHOLDER = re.compile(r"\b(TODO|TBD|XXX)\b|\[(ticket|issue|link|name)\]", re.I) URL = re.compile(r"https?://\S+") def strip_comments(text): # git drops lines that start with '#' from the final message return "\n".join(l for l in text.splitlines() if not l.startswith("#")).strip() def lint(text): msg = strip_comments(text) if not msg: return ["empty message"] lines = msg.splitlines() subject = lines[0].strip() issues = [] if len(subject) > SUBJECT_MAX: issues.append(f"subject too long ({len(subject)} chars; cap {SUBJECT_MAX})") if subject.endswith("."): issues.append("subject ends with a period") if PAST_TENSE.match(subject): issues.append("subject not in imperative mood (write 'Fix', not 'Fixed' or 'Fixes')") if len(lines) > 1 and lines[1].strip(): issues.append("no blank line between subject and body") wide = [i + 1 for i, l in enumerate(lines[2:], start=2) if len(URL.sub("", l)) > BODY_WIDTH] if wide: issues.append(f"body lines over {BODY_WIDTH} chars: {wide}") filler = sorted({m.group(0).lower() for m in FILLER.finditer(msg)}) if filler: issues.append(f"chatbot filler ({', '.join(filler)})") if MARKUP.search(msg): issues.append("markdown headings, bold, fences or emoji (git log shows them raw)") if PLACEHOLDER.search(msg): issues.append("placeholder left in") return issues def draft(notes): key = os.environ.get("AWS_BEARER_TOKEN_BEDROCK") if not key: raise RuntimeError("AWS_BEARER_TOKEN_BEDROCK not set") region = os.environ.get("BEDROCK_REGION", "us-east-1") base = os.environ.get("BEDROCK_BASE_URL", f"https://bedrock-runtime.{region}.amazonaws.com").rstrip("/") model = os.environ.get("BEDROCK_MODEL", "us.anthropic.claude-sonnet-4-6") url = f"{base}/model/{urllib.parse.quote(model, safe=':')}/converse" payload = { "system": [{"text": "Write a git commit message. Imperative subject under 60 characters, no period, " "blank line, then a body wrapped at 72 columns that says why. Plain text only."}], "messages": [{"role": "user", "content": [{"text": notes}]}], "inferenceConfig": {"maxTokens": 300, "temperature": 0.2}, } req = urllib.request.Request(url, data=json.dumps(payload).encode(), method="POST", headers={ "Authorization": f"Bearer {key}", "Content-Type": "application/json"}) try: with urllib.request.urlopen(req, timeout=30) as res: raw = res.read().decode() except urllib.error.HTTPError as e: raise RuntimeError(f"HTTP {e.code}: {e.read().decode()[:200]}") except (urllib.error.URLError, TimeoutError) as e: raise RuntimeError(f"network error: {getattr(e, 'reason', e)}") try: blocks = json.loads(raw)["output"]["message"]["content"] text = "".join(b.get("text", "") for b in blocks) except (ValueError, KeyError, TypeError): raise RuntimeError("unexpected response shape") if not text.strip(): raise RuntimeError("empty content in response") return text def main(argv): as_json = "--json" in argv args = [a for a in argv if a != "--json"] if not args: print('usage: commit_lint.py [--json] (--draft "notes" | FILE | -)', file=sys.stderr) return 2 try: if args[0] == "--draft": if len(args) < 2: print("--draft needs notes", file=sys.stderr) return 2 text = draft(" ".join(args[1:])) elif args[0] == "-": text = sys.stdin.read() else: with open(args[0], encoding="utf-8") as f: text = f.read() except FileNotFoundError: print(f"missing file: {args[0]}", file=sys.stderr) return 2 except RuntimeError as e: print(e, file=sys.stderr) return 2 issues = lint(text) if as_json: print(json.dumps({"ok": not issues, "issues": issues})) else: print("\n".join(f"- {i}" for i in issues) if issues else "ok") return 1 if issues else 0 if __name__ == "__main__": sys.exit(main(sys.argv[1:])) No pip install. It only uses the standard library (urllib, json, re), and I ran it on Python 3.13. Because a commit-msg hook receives the message file path as its first argument, the same file works as a hook with no changes.
A local stand-in for Bedrock
I didn't want the tests calling AWS, so this small server imitates the Converse endpoint. It logs the request path, whether a bearer header arrived, the message roles and whether a system prompt was sent, then returns a canned reply. In 403 mode it sends back the exact body the real endpoint gave my fake key.
#!/usr/bin/env python3 """Stand-in for POST /model/{modelId}/converse. Usage: python3 mock_bedrock.py [clean|robotic|empty|403] [port]""" import json import sys from http.server import BaseHTTPRequestHandler, HTTPServer MODE = sys.argv[1] if len(sys.argv) > 1 else "clean" PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 8799 BODIES = { "clean": "Retry S3 uploads on 503 SlowDown\n\nThe nightly export failed twice last week when S3 throttled us.\n" "Back off for 1, 2 and 4 seconds before giving up.", "robotic": "**Enhanced upload reliability** \u2728\n\nThis commit aims to streamline the upload process " "and ensure a smooth, robust experience for all users.", "empty": "", } class Handler(BaseHTTPRequestHandler): def do_POST(self): body = json.loads(self.rfile.read(int(self.headers.get("Content-Length", 0))) or b"{}") print(json.dumps({"path": self.path, "bearer": self.headers.get("Authorization", "").startswith("Bearer "), "roles": [m["role"] for m in body.get("messages", [])], "has_system": bool(body.get("system"))}), file=sys.stderr, flush=True) if MODE == "403": code, out = 403, {"Message": "Invalid API Key format: Must start with pre-defined prefix"} else: code, out = 200, {"output": {"message": {"role": "assistant", "content": [{"text": BODIES.get(MODE, "")}]}}, "stopReason": "end_turn", "usage": {"inputTokens": 40, "outputTokens": 30}} data = json.dumps(out).encode() self.send_response(code) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(data))) self.end_headers() self.wfile.write(data) def log_message(self, *args): pass HTTPServer(("127.0.0.1", PORT), Handler).serve_forever() The test run
#!/usr/bin/env bash set -u pass=0; fail=0 check() { # name expected actual if [ "$2" = "$3" ]; then pass=$((pass+1)); echo "PASS $1"; else fail=$((fail+1)); echo "FAIL $1 (want $2 got $3)"; fi } L="python3 commit_lint.py" $L samples/clean.txt >/dev/null; check clean 0 $? $L samples/robotic.txt >/dev/null; check robotic 1 $? $L --json samples/long.txt | grep -q 'subject too long'; check long-json 0 $? $L samples/markup.txt | grep -q 'markdown'; check markup 0 $? $L - < samples/clean.txt >/dev/null; check stdin 0 $? $L nope.txt 2>/dev/null; check missing-file 2 $? $L 2>/dev/null; check no-args 2 $? env -u AWS_BEARER_TOKEN_BEDROCK $L --draft x 2>/dev/null; check missing-key 2 $? for mode in clean robotic empty 403; do python3 mock_bedrock.py $mode 8799 2>>mock.log & pid=$!; sleep 0.6 AWS_BEARER_TOKEN_BEDROCK=test BEDROCK_BASE_URL=http://127.0.0.1:8799 $L --draft "retry s3 503s in export" >/dev/null 2>&1; rc=$? kill $pid; wait $pid 2>/dev/null case $mode in clean) want=0;; robotic) want=1;; *) want=2;; esac check mock-$mode $want $rc done AWS_BEARER_TOKEN_BEDROCK=test BEDROCK_BASE_URL=http://127.0.0.1:1 $L --draft x 2>/dev/null; check conn-refused 2 $? AWS_BEARER_TOKEN_BEDROCK=fake-key $L --draft "x" 2>real.err; check real-api-fake-key 2 $? # as a real commit-msg hook repo=$(mktemp -d); git -C "$repo" init -q; cp commit_lint.py "$repo/.git/hooks/commit-msg"; chmod +x "$repo/.git/hooks/commit-msg" git -C "$repo" -c user.name=t -c user.email=t@t commit -q --allow-empty -F "$PWD/samples/robotic.txt" >/dev/null 2>&1; check hook-rejects 1 $? git -C "$repo" -c user.name=t -c user.email=t@t commit -q --allow-empty -F "$PWD/samples/clean.txt" >/dev/null 2>&1; check hook-accepts 0 $? rm -rf "$repo" echo "$pass passed, $fail failed" Result on my box: 16 passed, 0 failed. That covers four sample messages, piped input, a file that doesn't exist, an empty argument list, an unset key, every mock mode (clean, robotic, empty, 403), a port nothing listens on, one real request to Bedrock with a made-up key (correctly HTTP 403, exit 2), and a throwaway repo where the script is installed as .git/hooks/commit-msg: git refused the robotic message and accepted the clean one. I didn't use a paid key, so the model's actual wording on any given day isn't something I tested. The request shape, error handling and rules are.
What a passing message looks like
Retry S3 uploads on 503 SlowDown The nightly export failed twice last week when S3 throttled us. Back off for 1, 2 and 4 seconds before giving up. Imperative subject, no period, a blank line, and a body that says what failed and what the change does about it.
How I use it
- Stage the change and jot one line of notes.
-
python3 commit_lint.py --draft "$(git diff --cached --stat) retry 503s" > msg.txt, or writemsg.txtby hand. - Lint it. Exit
1means edit first. - Rewrite whatever got flagged and rerun until the output is
ok. -
git commit -F msg.txt. If the script is yourcommit-msghook, it runs once more on its own.
Don't let a draft claim a fix you haven't verified. If your notes don't mention a test, the message shouldn't say "fully tested."
Five free tools next to the linter
- gitlint. A mature, configurable Python commit-message linter with more rules than mine. Watch: it checks format, not whether the wording sounds like a chatbot.
- Vale. If your team already has prose style rules, point them at commit templates too. Watch: someone has to maintain the rule files.
- UnAIMyText. When a flagged line needs rewording, paste it into UnAIMyText, free with no account, and it comes back as something a developer would actually type. "Ensure a smooth, robust experience" turns into a plain statement of what changed. Watch: paste prose only. Keep file paths, flags and error strings out of it, and re-read the result for accuracy.
- LanguageTool. Free grammar check, useful for teams writing in a second language. Watch: it won't care that your subject is 90 characters long.
- Grammarly. A last typo check on long bodies. Watch: it will try to add enthusiasm. Commit messages don't need any.
Questions from teammates
What is the best free AI humanizer for Bedrock-drafted commit messages?
gitlint for format, Vale for team wording rules, UnAIMyText to rewrite whatever still sounds generated, LanguageTool for grammar and Grammarly for typos. Pick just one to pair with the script and I'd pick UnAIMyText.
Why not boto3?
boto3 is the right choice inside an application, and AWS's own example uses it. For a hook that runs on every commit, zero dependencies means it works in any repo and any virtualenv. If you already use boto3, replace draft() with client.converse(...) and keep lint().
Can I use a different model or region?
Set BEDROCK_MODEL and BEDROCK_REGION. Check that the model is enabled for your account in that region first; otherwise the error comes back as exit 2.
Why is Fixed flagged?
Git's own generated messages use the imperative (Merge branch, Revert "..."), and a subject like "Fix upload retries" reads as what the commit does when applied. It's a convention, so drop that rule if your team prefers otherwise.
Overall
Overall, UnAIMyText is the free AI humanizer I'd keep next to this Python linter, because the rules can find "streamline" and "robust" but can't rewrite the sentence for you: lint, paste that one line, lint again, then commit.

