feat(git): route commit through GIT_EDITOR proxy
This commit is contained in:
+57
-74
@@ -1,3 +1,5 @@
|
|||||||
|
local editor = require("git.editor")
|
||||||
|
local git = require("git")
|
||||||
local log = require("log")
|
local log = require("log")
|
||||||
local repo = require("git.repo")
|
local repo = require("git.repo")
|
||||||
|
|
||||||
@@ -6,89 +8,70 @@ local M = {}
|
|||||||
---@param opts { amend: boolean? }?
|
---@param opts { amend: boolean? }?
|
||||||
function M.commit(opts)
|
function M.commit(opts)
|
||||||
local amend = opts and opts.amend or false
|
local amend = opts and opts.amend or false
|
||||||
local gitdir, worktree = repo.resolve_cwd()
|
local _, worktree = repo.resolve_cwd()
|
||||||
if not gitdir or not worktree then
|
if not worktree then
|
||||||
log.warning("not in a git repository")
|
log.warning("not in a git repository")
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local msg_path = vim.fs.joinpath(gitdir, "COMMIT_EDITMSG")
|
|
||||||
|
|
||||||
local initial = ""
|
local cmd = { "git", "commit" }
|
||||||
if amend then
|
if amend then
|
||||||
local result = vim.system(
|
table.insert(cmd, "--amend")
|
||||||
{ "git", "log", "-1", "--pretty=%B" },
|
end
|
||||||
{ cwd = worktree, text = true }
|
|
||||||
):wait()
|
local proxy_buf
|
||||||
if result.code == 0 then
|
editor.run(cmd, { cwd = worktree }, function(file_path, done)
|
||||||
initial = (result.stdout or ""):gsub("\n+$", "")
|
local lines = {}
|
||||||
else
|
local f = io.open(file_path, "r")
|
||||||
log.warning("git log -1 failed: %s", vim.trim(result.stderr or ""))
|
if f then
|
||||||
|
for line in f:lines() do
|
||||||
|
table.insert(lines, line)
|
||||||
|
end
|
||||||
|
f:close()
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
|
||||||
local f, err = io.open(msg_path, "w")
|
local buf = git.new_scratch({ name = file_path })
|
||||||
if not f then
|
proxy_buf = buf
|
||||||
log.error("failed to open %s: %s", msg_path, err or "")
|
vim.bo[buf].buftype = "acwrite"
|
||||||
return
|
vim.bo[buf].bufhidden = "wipe"
|
||||||
end
|
vim.bo[buf].modifiable = true
|
||||||
f:write(initial)
|
vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)
|
||||||
f:close()
|
vim.bo[buf].modified = false
|
||||||
|
vim.bo[buf].filetype = "gitcommit"
|
||||||
|
|
||||||
local ok, err = pcall(vim.cmd.edit, vim.fn.fnameescape(msg_path))
|
vim.api.nvim_create_autocmd("BufWriteCmd", {
|
||||||
if not ok then
|
buffer = buf,
|
||||||
log.error("failed to open %s: %s", msg_path, err or "")
|
callback = function()
|
||||||
return
|
local out = vim.api.nvim_buf_get_lines(buf, 0, -1, false)
|
||||||
end
|
local fw, werr = io.open(file_path, "w")
|
||||||
local buf = vim.api.nvim_get_current_buf()
|
if not fw then
|
||||||
if vim.api.nvim_buf_get_name(buf) ~= msg_path then
|
log.error("failed to write %s: %s", file_path, werr or "")
|
||||||
-- `:edit` returned without surfacing an error but didn't actually
|
return
|
||||||
-- switch (defensive against an unusual ftplugin/autocmd path). Bail
|
end
|
||||||
-- before attaching a BufWriteCmd that would overwrite the wrong
|
fw:write(table.concat(out, "\n"))
|
||||||
-- file on the next `:w`.
|
fw:close()
|
||||||
log.error("failed to switch to %s", msg_path)
|
vim.bo[buf].modified = false
|
||||||
return
|
end,
|
||||||
end
|
})
|
||||||
vim.bo[buf].filetype = "gitcommit"
|
|
||||||
|
|
||||||
vim.api.nvim_create_autocmd("BufWriteCmd", {
|
vim.api.nvim_create_autocmd("BufWipeout", {
|
||||||
buffer = buf,
|
buffer = buf,
|
||||||
callback = function()
|
once = true,
|
||||||
local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false)
|
callback = done,
|
||||||
local fw, werr = io.open(msg_path, "w")
|
})
|
||||||
if not fw then
|
end, function(result)
|
||||||
log.error("failed to write %s: %s", msg_path, werr or "")
|
if proxy_buf and vim.api.nvim_buf_is_valid(proxy_buf) then
|
||||||
return
|
vim.api.nvim_buf_delete(proxy_buf, { force = true })
|
||||||
end
|
end
|
||||||
fw:write(table.concat(lines, "\n"))
|
if result.code ~= 0 then
|
||||||
fw:close()
|
log.error("git commit failed: %s", vim.trim(result.stderr or ""))
|
||||||
vim.bo[buf].modified = false
|
return
|
||||||
|
end
|
||||||
local cmd = { "git", "commit", "-F", msg_path }
|
local out = vim.trim(result.stdout or "")
|
||||||
if amend then
|
if out ~= "" then
|
||||||
table.insert(cmd, "--amend")
|
log.info("%s", out)
|
||||||
end
|
end
|
||||||
vim.system(
|
end)
|
||||||
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,
|
|
||||||
})
|
|
||||||
end
|
end
|
||||||
|
|
||||||
return M
|
return M
|
||||||
|
|||||||
@@ -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<string,string> }
|
||||||
|
---@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
|
||||||
Reference in New Issue
Block a user