diff --git a/lua/git/commit.lua b/lua/git/commit.lua index fd74343..d075791 100644 --- a/lua/git/commit.lua +++ b/lua/git/commit.lua @@ -1,3 +1,5 @@ +local editor = require("git.editor") +local git = require("git") local log = require("log") local repo = require("git.repo") @@ -6,89 +8,70 @@ local M = {} ---@param opts { amend: boolean? }? function M.commit(opts) local amend = opts and opts.amend or false - local gitdir, worktree = repo.resolve_cwd() - if not gitdir or not worktree then + local _, worktree = repo.resolve_cwd() + if not worktree then log.warning("not in a git repository") return end - local msg_path = vim.fs.joinpath(gitdir, "COMMIT_EDITMSG") - local initial = "" + local cmd = { "git", "commit" } if amend then - local result = vim.system( - { "git", "log", "-1", "--pretty=%B" }, - { cwd = worktree, text = true } - ):wait() - if result.code == 0 then - initial = (result.stdout or ""):gsub("\n+$", "") - else - log.warning("git log -1 failed: %s", vim.trim(result.stderr or "")) + table.insert(cmd, "--amend") + end + + local proxy_buf + editor.run(cmd, { cwd = worktree }, function(file_path, done) + local lines = {} + local f = io.open(file_path, "r") + if f then + for line in f:lines() do + table.insert(lines, line) + end + f:close() end - end - local f, err = io.open(msg_path, "w") - if not f then - log.error("failed to open %s: %s", msg_path, err or "") - return - end - f:write(initial) - f:close() + local buf = git.new_scratch({ name = file_path }) + proxy_buf = buf + vim.bo[buf].buftype = "acwrite" + vim.bo[buf].bufhidden = "wipe" + vim.bo[buf].modifiable = true + vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) + vim.bo[buf].modified = false + vim.bo[buf].filetype = "gitcommit" - local ok, err = pcall(vim.cmd.edit, vim.fn.fnameescape(msg_path)) - if not ok then - log.error("failed to open %s: %s", msg_path, err or "") - return - end - local buf = vim.api.nvim_get_current_buf() - if vim.api.nvim_buf_get_name(buf) ~= msg_path then - -- `:edit` returned without surfacing an error but didn't actually - -- switch (defensive against an unusual ftplugin/autocmd path). Bail - -- before attaching a BufWriteCmd that would overwrite the wrong - -- file on the next `:w`. - log.error("failed to switch to %s", msg_path) - return - end - vim.bo[buf].filetype = "gitcommit" + vim.api.nvim_create_autocmd("BufWriteCmd", { + buffer = buf, + callback = function() + local out = vim.api.nvim_buf_get_lines(buf, 0, -1, false) + local fw, werr = io.open(file_path, "w") + if not fw then + log.error("failed to write %s: %s", file_path, werr or "") + return + end + fw:write(table.concat(out, "\n")) + fw:close() + vim.bo[buf].modified = false + end, + }) - vim.api.nvim_create_autocmd("BufWriteCmd", { - buffer = buf, - callback = function() - local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) - local fw, werr = io.open(msg_path, "w") - if not fw then - log.error("failed to write %s: %s", msg_path, werr or "") - return - end - fw:write(table.concat(lines, "\n")) - fw:close() - vim.bo[buf].modified = false - - local cmd = { "git", "commit", "-F", msg_path } - if amend then - table.insert(cmd, "--amend") - end - vim.system( - cmd, - { cwd = worktree, text = true }, - vim.schedule_wrap(function(result) - if result.code ~= 0 then - log.error( - "git commit failed: %s", - vim.trim(result.stderr or "") - ) - return - end - local out = vim.trim(result.stdout or "") - if out ~= "" then - log.info("%s", out) - end - if vim.api.nvim_buf_is_valid(buf) then - vim.api.nvim_buf_delete(buf, { force = true }) - end - end) - ) - end, - }) + vim.api.nvim_create_autocmd("BufWipeout", { + buffer = buf, + once = true, + callback = done, + }) + end, function(result) + if proxy_buf and vim.api.nvim_buf_is_valid(proxy_buf) then + vim.api.nvim_buf_delete(proxy_buf, { force = true }) + end + if result.code ~= 0 then + log.error("git commit failed: %s", vim.trim(result.stderr or "")) + return + end + local out = vim.trim(result.stdout or "") + if out ~= "" then + log.info("%s", out) + end + end) end return M diff --git a/lua/git/editor.lua b/lua/git/editor.lua new file mode 100644 index 0000000..4073372 --- /dev/null +++ b/lua/git/editor.lua @@ -0,0 +1,141 @@ +local log = require("log") + +local M = {} + +local SENTINEL = "__NVIM_GIT_EDIT__" + +-- Each git editor invocation runs this body afresh under `sh -c`. The +-- body picks a per-invocation flag file via `$$` (the wrapping shell's +-- pid), prints the sentinel + flag-path + abs-path on stderr so the +-- running Neovim can find both, and polls the flag until Neovim writes +-- it. Concurrent edits inside one git call (e.g. `rebase -i`'s todo +-- plus N reword commits) get distinct flags because each invocation is +-- a fresh shell with a fresh `$$`. +local SCRIPT = string.format( + [=[set -eu +flag="${TMPDIR:-/tmp}/nvim-git-editor-$$.done" +trap 'rm -f "$flag"' EXIT +abs=$(realpath "$1") +printf '%s\t%%s\t%%s\n' "$flag" "$abs" >&2 +while [ ! -e "$flag" ]; do + sleep 0.05 +done +]=], + SENTINEL +) + +---POSIX shell single-quote escape: foo'bar -> 'foo'\''bar'. +---@param s string +---@return string +local function shq(s) + return "'" .. s:gsub("'", "'\\''") .. "'" +end + +local GIT_EDITOR = "sh -c " .. shq(SCRIPT) .. " --" + +---Build a stderr callback that strips our sentinel lines, accumulates +---the rest, and dispatches `on_open(abs, done)` for each sentinel seen. +---The `finalize(result)` helper drains any trailing partial line into +---`result.stderr` so the caller can treat `result` like a plain +---`vim.system` result. +---@param on_open fun(file_path: string, done: fun()) +---@return fun(err: string?, data: string?), fun(result: vim.SystemCompleted) +local function build_stderr_handler(on_open) + local pending = "" + local stderr_buf = {} + + local function dispatch(flag_path, abs_path) + vim.schedule(function() + local fired = false + local function done() + if fired then + return + end + fired = true + local fw = io.open(flag_path, "w") + if fw then + fw:close() + end + end + local ok, err = pcall(on_open, abs_path, done) + if not ok then + log.error("git.editor on_open failed: %s", tostring(err)) + done() + end + end) + end + + local pattern = "^" .. SENTINEL .. "\t(.-)\t(.+)$" + + local function on_stderr(_, data) + if not data or data == "" then + return + end + pending = pending .. data + while true do + local nl = pending:find("\n", 1, true) + if not nl then + break + end + local line = pending:sub(1, nl - 1) + pending = pending:sub(nl + 1) + local flag, abs = line:match(pattern) + if flag then + dispatch(flag, abs) + else + table.insert(stderr_buf, line) + table.insert(stderr_buf, "\n") + end + end + end + + local function finalize(result) + if pending ~= "" then + table.insert(stderr_buf, pending) + end + result.stderr = table.concat(stderr_buf) + end + + return on_stderr, finalize +end + +---Run a git command with an editor proxy active. When git invokes the +---editor, `on_open` fires with the absolute file path git wants edited +---plus a `done` callback. The caller opens the file in a buffer and +---invokes `done()` once the user is finished (typically from a +---`BufWipeout` autocmd). For commands that fire the editor more than +---once in a single git invocation (`git rebase -i`, with one call for +---the todo and one per `reword`), `on_open` is invoked once per +---editor handoff. +--- +---`on_exit` is called on the main loop with a `vim.SystemCompleted`- +---shaped result. `result.stderr` has our protocol sentinels stripped. +---@param cmd string[] +---@param opts? { cwd?: string, env?: table } +---@param on_open fun(file_path: string, done: fun()) +---@param on_exit fun(result: vim.SystemCompleted) +function M.run(cmd, opts, on_open, on_exit) + opts = opts or {} + local on_stderr, finalize = build_stderr_handler(on_open) + + local env = vim.tbl_extend("force", opts.env or {}, { + GIT_EDITOR = GIT_EDITOR, + GIT_SEQUENCE_EDITOR = GIT_EDITOR, + }) + + vim.system( + cmd, + { + cwd = opts.cwd, + text = true, + env = env, + stderr = on_stderr, + }, + vim.schedule_wrap(function(result) + finalize(result) + on_exit(result) + end) + ) +end + +return M