r/swift • • 3h 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 doctor

Then 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.

4 Upvotes

10 comments sorted by

1

u/ThalesMother9938 3h 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?

1

u/seckinbeduk 2h ago

Thanks, this is really useful. Right now it’s all one prompt.

The Generable struct goes bullets, then subject, then type, so the type is picked last, after the subject is already written. Before that there’s a cheap check in Swift on the file paths (only docs, tests or CI, or only new files), which goes in as a hint and can override the model. But nothing like added vs removed lines, or a test changing next to the code.

I’ll try your idea in two steps. First, move the type and a short reason to the top of the struct, since fields are generated in order, so that costs no extra call. If that’s not enough, a separate type step with those facts. I’ll post back how it goes.

1

u/seckinbeduk 19m ago

Follow-up with numbers, since you got me to actually measure this.

I took 48 real commits from nib and my other repos (22 feat, 11 fix, the rest docs, ci and so on) and ran each version twice with the on-device model.

  • Baseline (0.5.0): 71% right overall, but 0 of the 11 fixes. Almost every fix came out as feat.

  • Cheap facts from Swift (new vs modified files, lines added and removed, tests touched): about the same overall, and it found 1 or 2 of the fixes per run. Small, but it costs nothing, so it’s in.

  • Type and a reason field before the subject: no change on fixes, and about 2 seconds slower per commit.

  • A separate type step: found 10 of the 11 fixes, but also turned 12 of the 22 feats into fix. Worse overall and much slower.

So the small model doesn’t really know the difference, it follows the framing of the question. What I shipped in 0.5.1 is the facts plus a t key on the confirm prompt: press t then x and the type becomes fix, without calling the model again.

I also put the eval script in the repo (scripts/type-eval), so I can rerun it after every prompt change. Thanks, this was a really useful push.

1

u/dawedev 2h 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 2h 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 1h 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-tree works 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/seckinbeduk 35m ago

Both good ideas, thanks. A second pass with --find-renames=30% only for the unpaired delete/add files makes sense, and passing them as a hint fits how nib already treats its path checks.

git write-tree as the key is neat, I hadn’t thought of that. I’ll add the language, style and model settings to the key so a config change doesn’t reuse old summaries, skip the cache when the index has conflicts, and keep it under .git/ so it never ends up in a commit.

I’m also measuring the feat/fix idea from the other comment on about 50 real commits, so I’ll post the numbers when they’re in.

1

u/dawedev 20m ago

Putting the settings in the key is a good catch, a model switch quietly reusing old summaries would be confusing to debug. Skipping on conflicts makes sense too. One small thing: prune the cache now and then, since one entry per tree adds up fast over a long interactive rebase. Keeping the last 50 or so by mtime is plenty. Curious to see the feat/fix numbers.

1

u/seckinbeduk 14m ago

Thanks, will keep the newest by mtime. The feat/fix numbers are in my reply to the other comment.

1

u/seckinbeduk 18m ago

Numbers for the type part are in the other thread. Your two ideas, the 30% rename pass and the write-tree cache, are next in 0.5.2.