A local-first CLI that generates git commit messages from your staged diff using a local LLM via Ollama — nothing ever leaves your machine. A companion commit-msg git hook catches low-effort messages ("wip", "fix", "update") and nudges you to write something real. No cloud API, no account, no telemetry.

Most AI commit-message tools work by shipping your diff off to a hosted model — OpenAI, Anthropic, or whoever's behind the API key you paste in. For a lot of real work that's a non-starter. If you're touching a private or proprietary codebase, quietly uploading every staged diff to a third party is exactly the kind of thing that gets a tool banned by your security team, and it should be. The convenience isn't worth the exposure.
At the same time, commit history quietly rots. Under deadline pressure people type "wip", "fix", "update", or just "changes" and move on, and six months later the log is useless for anyone trying to understand why a change happened. The two problems are related: a good tool should both help you write an honest message and gently stop you from shipping a lazy one.
Verbatim Commit is for developers who want AI assistance with commit messages but can't — or don't want to — send their code anywhere. It runs entirely against a local Ollama model on your own hardware, so the diffs, the messages, and the codebase never leave the machine. It's aimed squarely at people working on private repos, on air-gapped or offline setups, or anyone who simply prefers their tooling not to phone home.
The whole thing is built around a single non-negotiable: local-first and private by default. Generation and verification both talk only to localhost:11434 — your local Ollama instance. There are no accounts, no API keys, and nothing to sign up for; once the model is pulled it works fully offline. The only way data leaves your machine is if you deliberately point the config at a remote Ollama host. I treated that as the core design decision, not a feature bullet.
The tool has two modes that share the same underlying checks. Generate (verbatim gen) reads the staged diff, builds a carefully-constrained prompt, gets a candidate from the model, and drops you into a confirm loop — [y]es to commit, [e]dit to open it in your editor, [r]egenerate for a different take, [q]uit to abort. It never commits without an explicit confirmation. Verify is a commit-msg git hook that runs on every commit — typed or generated — and flags weak messages, then asks whether to commit anyway. Critically, it's a soft nudge, not a hard gate: if there's no terminal (CI, a GUI git client), it always lets the commit through so it can never block automation.
A big part of the product design was earning trust through honesty rather than assuming it. The generation prompt is aggressively engineered against the failure mode that kills these tools: fabrication. It's told to describe only what the diff actually shows, to never claim a change was made "for readability" or "to improve performance" unless the diff proves it, and to use each file's real change type (a rename is a rename, never an "update"). The default model, qwen2.5-coder:7b, wasn't picked at random — I started with a smaller gemma3:4b, watched it hallucinate and mislabel renames on real commits, and moved up to the model that stayed accurate while staying cross-platform and reasonably light.
Configuration is designed to fit how people actually work across different repos. Config is merged from three layers — built-in defaults, a global ~/.verbatim/config.json, and a per-repo .verbatimrc — with per-repo winning. So a relaxed side project and a strict conventional-commits work repo can each get exactly the rules they want, without either one leaking into the other.
Architecture. It's a small, deliberately dependency-light TypeScript CLI compiled to ESM. commander handles argument parsing and dispatches to four commands (gen, verify, install-hook, uninstall-hook); everything else is pure functions over Node built-ins. Git is driven through execFile with argument arrays rather than shell strings, so quoting behaves identically across zsh, bash, and cmd.exe. Ollama is reached over its plain HTTP /api/generate endpoint using native fetch — no SDK. The interesting design choice here is how much I kept out: the core logic (diff budgeting, heuristics, config merging, message cleaning) is I/O-free and pure, which is why the whole suite runs on Node's built-in test runner with no framework.
Diff budgeting. The trickiest algorithm is fitting an arbitrarily large diff into a finite context window without ever silently dropping information. First, noise is stripped — lockfiles, vendored/generated directories, minified files, and binary patches. Then the diff is sized against a token budget computed as contextWindow × diffBudgetFraction − reserve (default 50% of the window, minus 1,500 reserved tokens). If everything fits, the full diff goes through. If it doesn't, small files are sent whole and large files are trimmed by keeping lines from both the front and the back — alternating head/tail until the budget runs out — with an explicit [N lines omitted] marker where the middle was cut. The token estimate is a deliberately cheap ~4-chars-per-token heuristic; precision isn't worth a tokenizer dependency here. And regardless of what content gets budgeted out, a complete --name-status inventory of every changed file is always included in the prompt — so even a filtered-out binary change is never invisible to the model.
The two-tier verification. Verify mode runs a fast, rule-based pass on every commit: it extracts the subject (skipping blank and # comment lines), normalizes it (lowercase, unquote, de-punctuate), and checks it against three signals — an exact match to a blocklist phrase, a "every word is vague/filler" check for things like "update stuff", a minimum word count (default 3, chosen so legitimate imperative subjects like "add login route" pass), and an identical-to-previous-commit check. On top of that sits an optional LLM second pass, off by default because it adds latency to every commit. When enabled, it asks the model a narrow yes/no question — "does this message actually describe this diff?" — and expects a single GOOD/WEAK token back. That second pass is written to return null and never throw, so a hook can never block your commit just because Ollama was down.
The git hook, and surviving the real world. Installing the hook writes an LF-ended POSIX shim (LF specifically because Git for Windows runs hooks through its bundled bash). The subtle problems were all about where node lives. The shim prefers node on PATH at runtime so it survives version upgrades, but falls back to a baked absolute path for GUI git clients that launch hooks with a minimal PATH. That fallback deliberately prefers a stable package-manager symlink (/opt/homebrew/bin/node) over process.execPath, because on Homebrew the latter resolves to a versioned Cellar path that breaks on the very next brew upgrade. The hook also carries a marker comment so uninstall only ever removes a hook the tool actually wrote, never a hand-written one.
Interactive prompts from inside a hook. There's a genuinely tricky cross-platform detail here: during a commit-msg hook, stdin belongs to git, not the user, so you can't just read from it. The tool opens the controlling terminal directly — /dev/tty on POSIX, CONIN$/CONOUT$ on Windows — and reads a line byte-by-byte. If no controlling terminal can be opened (CI, scripts, GUI clients), the prompt returns null and the caller falls through to "let the commit proceed." That single behavior is what makes the "soft nudge, never blocks automation" promise actually true. All three platforms were verified on real hardware.
Models fabricate, and that's fatal for a commit tool. Early testing with gemma3:4b showed it inventing changes and calling renames "updates." I solved it two ways: heavily constraining the prompt (describe only what the diff shows; use exact change types; no invented rationale) and moving the default to qwen2.5-coder:7b, which stopped fabricating in testing. Lesson: for a trust-critical tool, the honest-but-slightly-less-fluent default beats the impressive-but-unreliable one. Next: parse repeated regenerations to detect when a model is consistently under-describing and surface that.
Big diffs don't fit, but truncating them silently is dishonest. The budgeting algorithm keeps head-and-tail slices with explicit [N lines omitted] markers and always ships a full file-change inventory, so the model (and the user) can see that something was cut rather than being quietly misled. Next: smarter per-file summarization instead of positional trimming.
Git hooks steal stdin. Getting an interactive "commit anyway?" prompt to work inside a hook meant bypassing stdin entirely and reading the controlling terminal (/dev/tty / CONIN$). Lesson: the no-terminal fallback is the actual feature — it's what guarantees the tool never blocks CI. Next: a config option to make the hook hard-blocking for teams that want a real gate.
The hook broke on brew upgrade and nvm switches. Baking process.execPath into the shim pointed at a versioned node path that upgrades deleted. Fixed by preferring node on PATH at runtime, then falling back to a stable symlink. Lesson: "resolve the absolute path once" is a trap for anything that outlives the process that wrote it. Next: detect a broken baked path and auto-suggest a re-install.
Small diffs are so high-confidence the model won't vary. On a one-line change, [r]egenerate kept returning identical phrasing. Fixed by raising the temperature, using a fresh random seed, and feeding rejected candidates back into the prompt with a "write a clearly different one" instruction. Lesson: "give me another option" needs explicit anti-repetition pressure, not just a re-roll. Next: show two candidates side by side instead of one-at-a-time.
Explore the live application or dive into the source code to see how it works.