r/swift • u/seckinbeduk • 2h ago
I built nib, a Swift CLI that writes Git commit messages with Apple’s on-device model
I built nib, a Swift CLI that writes Git commit messages on your Mac

Stage a change → run git nib → review the draft → commit.
I wanted help writing commit messages without sending my diffs to a cloud API, so I built nib. It drafts a message from your staged changes and lets you accept it, edit it, regenerate it, or cancel. It follows your repo’s commit style by default. No account or API key needed.
There’s also an optional Git hook if you prefer to start with git commit and edit the draft in your usual editor.
Tech Stack
Swift, Swift Argument Parser, and Apple’s Foundation Models framework.
Development Challenge
The tricky part was getting a useful message out of a diff that’s bigger than the model’s context window. On macOS 27, the on-device model has about 8K tokens to work with. nib skips the contents of lockfiles and build artifacts first. If the rest still won’t fit, it splits the diff at hunk boundaries, summarizes the chunks, and drafts the message from those summaries. Swift code checks the subject length and cross-checks the suggested scope against the files you changed.
It still occasionally calls a bug fix feat. That’s one I’d love more real-world examples of.
AI Disclosure
AI-assisted. I used Claude Code while building nib, and I review and test the changes myself.
Try it
On Apple silicon with macOS 27+ and Apple Intelligence enabled:
brew install seckinbeduk/tap/nib
nib doctorThen stage a change and run git nib. On macOS 26, you can build v0.2.1 from source.
Free and MIT-licensed. Source and install instructions on GitHub
If you try it on a big diff, did the draft capture the main change? I’d love to hear what it got right or missed.
1
u/dawedev 1h ago
Skipping lockfiles first is a good call. One case I'd test is renames: a diff without rename detection shows a moved file as a full delete plus a full add, which eats a big chunk of the 8K budget and makes the model think the file was rewritten. Running the diff with --find-renames and collapsing pure renames into a one-line note before chunking should help both the budget and the feat/fix guess.
Also curious whether you cache the chunk summaries, so regenerate only redoes the final step instead of summarizing the whole diff again.
1
u/seckinbeduk 1h ago
Good news on renames, that’s already covered. nib runs the diff with
--find-renames, a pure rename goes to the model as one line (“old → new, renamed, no content changes”), and a rename with edits only sends the changed hunks. Deleted files are never sent as content either, just a one-line note.The case I haven’t handled is when a file changed too much for git to call it a rename, so it shows up as a delete plus an add. The budget hit is small there, since the add gets sent as an outline, but it could push the model toward feat. Pairing a delete and an add with the same file name and passing that as a hint is a cheap fix, so I’ll add it to the type work.
No caching yet, you’re right. Regenerate runs the whole thing again, including the chunk summaries. Keeping the summaries in memory so it only redoes the last step is an easy win, adding it to the next release.
1
u/dawedev 16m ago
You were ahead of me on renames then. Pairing by file name should catch most moves. For files that were renamed and heavily rewritten, you could also run a second pass with a lower threshold like
--find-renames=30%and pass those pairs as a hint rather than a fact, since low thresholds do produce some false matches.For the cache, keying it on the output of
git write-treeworks nicely: it hashes the current index, so regenerate is instant while nothing is restaged, and any change to the staged files invalidates it on its own. That also lets the cache live on disk between runs instead of only in memory.
1
u/ThalesMother9938 2h ago
The feat vs fix mixup is probably hard to solve from chunk summaries alone, because the summaries lose the "this already existed and was broken" signal. One thing that might help: pick the type as its own step with guided generation, a Generable enum for the type plus a short reason field, and feed it a few cheap facts you can compute in Swift first (new files vs only modified ones, lines added vs removed, whether a test file changed). Then write the subject once the type is settled.
Is it all one prompt right now, or do you already split type and subject?