-- lua/plugins/extras/git-ai-commit.lua -- AI-powered commit message generator using ThePrimeagen/99 + lazygit -- -- Flow: -- 9g → gather diff (staged → unstaged fallback) -- → send diff to 99 with a Conventional Commits prompt -- → write cleaned response to .git/COMMIT_EDITMSG -- → show message preview with confirm/edit/cancel options return { -- Virtual plugin entry — no remote source, just wires deps + config dir = vim.fn.stdpath 'config', name = 'git-ai-commit', dependencies = { 'ThePrimeagen/99', 'kdheepak/lazygit.nvim', }, event = 'VeryLazy', config = function() -- ── helpers ──────────────────────────────────────────────────────────── --- Return the git root for cwd, or nil if not in a repo. local function git_root() local result = vim.system({ 'git', 'rev-parse', '--show-toplevel' }, { text = true }):wait() if result.code ~= 0 then return nil end return vim.trim(result.stdout) end --- Collect the diff to describe. --- Strategy: staged first, fall back to HEAD, abort if clean. --- @return string|nil diff text, or nil when there is nothing to commit local function get_diff() -- 1. staged changes local staged = vim.system({ 'git', 'diff', '--cached' }, { text = true }):wait() if staged.code == 0 and vim.trim(staged.stdout) ~= '' then return staged.stdout end -- 2. unstaged changes against HEAD local unstaged = vim.system({ 'git', 'diff', 'HEAD' }, { text = true }):wait() if unstaged.code == 0 and vim.trim(unstaged.stdout) ~= '' then return unstaged.stdout end return nil end --- Strip markdown fences, surrounding quotes, and whitespace from the --- AI response so we're left with a clean commit message. --- Returns both the subject line and any body content. --- @param raw string --- @return string subject, string body local function clean_message(raw) local msg = vim.trim(raw) -- remove ```...``` fences msg = msg:gsub('^```[^\n]*\n', ''):gsub('\n```$', '') -- remove surrounding single/double quotes msg = msg:match '^["\'](.+)["\']$' or msg -- split into subject (first line) and body (remaining lines) local subject = msg:match '^([^\n]+)' or msg local body = msg:match '^[^\n]+\n(.+)$' or '' return vim.trim(subject), vim.trim(body) end -- ── core function ─────────────────────────────────────────────────────── local function generate_and_commit() -- 1. sanity: must be inside a git repo local root = git_root() if not root then vim.notify('git-ai-commit: not inside a git repository', vim.log.levels.WARN) return end -- 2. collect diff local diff = get_diff() if not diff then vim.notify('git-ai-commit: nothing to commit (clean working tree)', vim.log.levels.WARN) return end -- 3. tell the user we're working vim.notify('AI: generating commit message…', vim.log.levels.INFO) -- 4. build prompt local prompt = string.format( [[ You are an expert at writing git commit messages. Given the following git diff, write a commit message following the Conventional Commits specification. Format: type(scope): subject body (optional, keep it brief) Rules: - Subject: One line only, max 72 characters - Subject: Use imperative mood ("add", "fix", "refactor" — not "added" / "fixes") - Body: Optional but recommended for complex changes; explain WHAT and WHY, not HOW - Body: Wrap at 72 characters, blank line between subject and body - Output ONLY the raw commit message — no backticks, no quotes, no preamble %s ]], diff ) -- 5. fire the 99 request via its internal Prompt API local ok, err = pcall(function() local _99 = require '99' local Prompt = require '99.prompt' local CleanUp = require '99.ops.clean-up' local state = _99.__get_state() local context = Prompt.search(state) context:add_prompt_content(prompt) context:finalize() local clean_up = CleanUp.make_clean_up(function() context:stop() end) context:add_clean_up(clean_up) context:start_request(CleanUp.make_observer(clean_up, { on_complete = function(status, response) vim.schedule(function() if status ~= 'success' then vim.notify('git-ai-commit: AI request ' .. status, vim.log.levels.ERROR) return end -- 6. clean and persist the message local subject, body = clean_message(response) if subject == '' then vim.notify('git-ai-commit: AI returned an empty message', vim.log.levels.ERROR) return end local editmsg_path = root .. '/.git/COMMIT_EDITMSG' local f = io.open(editmsg_path, 'w') if not f then vim.notify('git-ai-commit: could not write to ' .. editmsg_path, vim.log.levels.ERROR) return end -- Write subject and optional body f:write(subject .. '\n') if body ~= '' then f:write('\n' .. body .. '\n') end f:close() -- 7. show editable commit message with accept/cancel buttons local full_msg = subject if body ~= '' then full_msg = full_msg .. '\n\n' .. body end -- Create floating window with editable buffer local lines = vim.split(full_msg, '\n') local max_width = 0 for _, line in ipairs(lines) do max_width = math.max(max_width, vim.fn.strdisplaywidth(line)) end max_width = math.min(math.max(max_width, 50), 80) -- Add button row local button_padding = math.floor((max_width - 28) / 2) local accept_btn = string.rep(' ', button_padding) .. '[ Accept (Enter) ]' local cancel_btn = '[ Cancel (Esc) ]' local button_row = accept_btn .. ' ' .. cancel_btn local buf = vim.api.nvim_create_buf(false, true) vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) -- Set buffer options for editing vim.api.nvim_set_option_value('modifiable', true, { buf = buf }) vim.api.nvim_set_option_value('buftype', 'acwrite', { buf = buf }) vim.api.nvim_set_option_value('swapfile', false, { buf = buf }) local width = max_width local height = math.min(#lines + 4, vim.o.lines - 8) local opts = { relative = 'editor', width = width, height = height, col = (vim.o.columns - width) / 2, row = (vim.o.lines - height - 4) / 2, style = 'minimal', border = 'rounded', title = ' Edit Commit Message ', title_pos = 'center', } local win = vim.api.nvim_open_win(buf, true, opts) -- Create button window below local btn_buf = vim.api.nvim_create_buf(false, true) vim.api.nvim_buf_set_lines(btn_buf, 0, -1, false, { '', button_row, '' }) vim.api.nvim_set_option_value('modifiable', false, { buf = btn_buf }) vim.api.nvim_set_option_value('buftype', 'nofile', { buf = btn_buf }) -- Highlight the buttons local ns = vim.api.nvim_create_namespace 'git-ai-commit-buttons' vim.api.nvim_buf_add_highlight(btn_buf, ns, 'Keyword', 1, button_padding, button_padding + 19) vim.api.nvim_buf_add_highlight(btn_buf, ns, 'WarningMsg', 1, button_padding + 23, button_padding + 41) local btn_opts = { relative = 'editor', width = width, height = 3, col = opts.col, row = opts.row + opts.height + 1, style = 'minimal', border = 'none', focusable = false, } local btn_win = vim.api.nvim_open_win(btn_buf, false, btn_opts) -- Set up keymaps for the floating window local function close_windows() if vim.api.nvim_win_is_valid(win) then vim.api.nvim_win_close(win, true) end if vim.api.nvim_win_is_valid(btn_win) then vim.api.nvim_win_close(btn_win, true) end end local function do_accept() -- Get edited content local edited_lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) local edited_msg = table.concat(edited_lines, '\n') close_windows() -- Commit with the edited message local commit_result = vim.system({ 'git', 'commit', '-m', edited_msg }, { text = true }):wait() if commit_result.code == 0 then -- Remove temp file on success vim.fn.delete(editmsg_path) vim.notify('Committed successfully!', vim.log.levels.INFO) else vim.notify('git-ai-commit: commit failed - ' .. vim.trim(commit_result.stderr or ''), vim.log.levels.ERROR) end end local function do_cancel() close_windows() vim.notify('Commit cancelled (message saved to .git/COMMIT_EDITMSG)', vim.log.levels.INFO) end -- Keymaps vim.keymap.set('n', '', do_accept, { buffer = buf, nowait = true, desc = 'Accept commit' }) vim.keymap.set('n', '', do_cancel, { buffer = buf, nowait = true, desc = 'Cancel commit' }) vim.keymap.set('i', '', do_cancel, { buffer = buf, desc = 'Cancel commit' }) -- Start in insert mode at the beginning vim.cmd 'normal! gg0' end) end, })) end) if not ok then vim.notify('git-ai-commit: ' .. tostring(err), vim.log.levels.ERROR) end end -- ── keymap ───────────────────────────────────────────────────────────── vim.keymap.set('n', '9g', generate_and_commit, { desc = 'AI: generate commit message + confirm', }) end, }