feat(git): ambient progress for long-running :G subcommands
This commit is contained in:
+214
-21
@@ -1,18 +1,12 @@
|
||||
local commit = require("git.commit")
|
||||
local history = require("git.history")
|
||||
local object = require("git.object")
|
||||
local repo = require("git.repo")
|
||||
local util = require("git.util")
|
||||
|
||||
local M = {}
|
||||
|
||||
---@class ow.Git.Cmd.SplitHandler
|
||||
---@field ft string
|
||||
|
||||
---@type table<string, ow.Git.Cmd.SplitHandler>
|
||||
local SPLIT_HANDLERS = {
|
||||
log = { ft = "git" },
|
||||
diff = { ft = "git" },
|
||||
}
|
||||
---@alias ow.Git.Cmd.Run fun(r: ow.Git.Repo, args: string[], bang: boolean)
|
||||
|
||||
---@type string[]?
|
||||
local cached_cmds
|
||||
@@ -182,8 +176,8 @@ end
|
||||
|
||||
---@param r ow.Git.Repo
|
||||
---@param args string[]
|
||||
---@param conf ow.Git.Cmd.SplitHandler
|
||||
local function run_in_split(r, args, conf)
|
||||
---@param ft string
|
||||
local function run_in_split(r, args, ft)
|
||||
util.git(args, {
|
||||
cwd = r.worktree,
|
||||
on_exit = function(result)
|
||||
@@ -201,7 +195,7 @@ local function run_in_split(r, args, conf)
|
||||
object.attach_dispatch(buf)
|
||||
attach_history_keys(buf)
|
||||
local state = r:state(buf) --[[@as -nil]]
|
||||
vim.bo[buf].filetype = conf.ft
|
||||
vim.bo[buf].filetype = ft
|
||||
-- Force a new undo block so each rerun is its own undo step.
|
||||
vim.bo[buf].undolevels = vim.bo[buf].undolevels
|
||||
local first_run = not state.initialized
|
||||
@@ -217,11 +211,17 @@ end
|
||||
---@param r ow.Git.Repo
|
||||
---@param args string[]
|
||||
local function run_to_messages(r, args)
|
||||
util.git(args, {
|
||||
local cmd = { "git" }
|
||||
vim.list_extend(cmd, args)
|
||||
local result = vim.system(cmd, {
|
||||
cwd = r.worktree,
|
||||
on_exit = function(result)
|
||||
text = true,
|
||||
env = util.DEFAULT_GIT_ENV,
|
||||
}):wait()
|
||||
local out = vim.trim(result.stdout or "")
|
||||
local err = vim.trim(result.stderr or "")
|
||||
local failed = result.code ~= 0
|
||||
|
||||
local chunks = {}
|
||||
if out ~= "" then
|
||||
table.insert(chunks, { out })
|
||||
@@ -230,9 +230,9 @@ local function run_to_messages(r, args)
|
||||
if #chunks > 0 then
|
||||
table.insert(chunks, { "\n" })
|
||||
end
|
||||
table.insert(chunks, { err, "ErrorMsg" })
|
||||
table.insert(chunks, { err, failed and "ErrorMsg" or nil })
|
||||
end
|
||||
if #chunks == 0 and result.code ~= 0 then
|
||||
if #chunks == 0 and failed then
|
||||
table.insert(
|
||||
chunks,
|
||||
{ "git exited " .. tostring(result.code), "ErrorMsg" }
|
||||
@@ -241,10 +241,200 @@ local function run_to_messages(r, args)
|
||||
if #chunks > 0 then
|
||||
vim.api.nvim_echo(chunks, true, {})
|
||||
end
|
||||
end
|
||||
|
||||
---@return integer
|
||||
local function find_or_create_preview_win()
|
||||
for _, w in ipairs(vim.api.nvim_tabpage_list_wins(0)) do
|
||||
if vim.wo[w].previewwindow then
|
||||
return w
|
||||
end
|
||||
end
|
||||
vim.cmd(("botright %dnew"):format(vim.o.previewheight))
|
||||
local w = vim.api.nvim_get_current_win()
|
||||
vim.wo[w].previewwindow = true
|
||||
return w
|
||||
end
|
||||
|
||||
---@param r ow.Git.Repo
|
||||
---@param args string[]
|
||||
local function run_in_preview(r, args)
|
||||
local prev_win = vim.api.nvim_get_current_win()
|
||||
local pwin = find_or_create_preview_win()
|
||||
|
||||
local buf = vim.api.nvim_create_buf(false, true)
|
||||
vim.bo[buf].bufhidden = "wipe"
|
||||
vim.api.nvim_win_set_buf(pwin, buf)
|
||||
|
||||
vim.api.nvim_set_current_win(pwin)
|
||||
local cmd = { "git" }
|
||||
vim.list_extend(cmd, args)
|
||||
local job = vim.fn.jobstart(cmd, {
|
||||
cwd = r.worktree,
|
||||
term = true,
|
||||
})
|
||||
vim.api.nvim_set_current_win(prev_win)
|
||||
|
||||
if job <= 0 then
|
||||
util.error("failed to start git job")
|
||||
return
|
||||
end
|
||||
|
||||
vim.api.nvim_create_autocmd("BufWipeout", {
|
||||
buffer = buf,
|
||||
once = true,
|
||||
callback = function()
|
||||
pcall(vim.fn.jobstop, job)
|
||||
end,
|
||||
})
|
||||
|
||||
vim.keymap.set("n", "q", "<cmd>pclose<cr>", {
|
||||
buffer = buf,
|
||||
nowait = true,
|
||||
desc = "Close preview",
|
||||
})
|
||||
vim.keymap.set("n", "<C-c>", function()
|
||||
pcall(vim.fn.jobstop, job)
|
||||
end, { buffer = buf, nowait = true, desc = "Cancel git job" })
|
||||
end
|
||||
|
||||
---@param ft string
|
||||
---@return ow.Git.Cmd.Run
|
||||
local function in_split(ft)
|
||||
return function(r, args, _bang)
|
||||
run_in_split(r, args, ft)
|
||||
end
|
||||
end
|
||||
|
||||
---@param line string
|
||||
---@return string
|
||||
local function clean_progress_line(line)
|
||||
if not (line:find("\27", 1, true) or line:find("\r", 1, true)) then
|
||||
return line
|
||||
end
|
||||
line = line:gsub("\27%[[%d;?]*[%a]", "")
|
||||
local parts = vim.split(line, "\r", { plain = true })
|
||||
for i = #parts, 1, -1 do
|
||||
if parts[i] ~= "" then
|
||||
return parts[i]
|
||||
end
|
||||
end
|
||||
return ""
|
||||
end
|
||||
|
||||
---@param r ow.Git.Repo
|
||||
---@param args string[]
|
||||
local function run_streaming(r, args)
|
||||
local title = "git " .. (args[1] or "")
|
||||
local id = "git." .. tostring(vim.uv.hrtime())
|
||||
---@type string[]
|
||||
local accum = {}
|
||||
local partial = ""
|
||||
local last_progress = ""
|
||||
|
||||
---@param text string
|
||||
---@param status "running"|"success"|"failed"
|
||||
local function emit_progress(text, status)
|
||||
vim.api.nvim_echo({ { text } }, false, {
|
||||
id = id,
|
||||
kind = "progress",
|
||||
status = status,
|
||||
title = title,
|
||||
source = "git",
|
||||
})
|
||||
end
|
||||
|
||||
local function on_data(_, data, _)
|
||||
if not data or #data == 0 then
|
||||
return
|
||||
end
|
||||
if #data == 1 and data[1] == "" then
|
||||
return
|
||||
end
|
||||
partial = partial .. data[1]
|
||||
local prev = last_progress
|
||||
for i = 2, #data do
|
||||
local cleaned = clean_progress_line(partial)
|
||||
if cleaned ~= "" then
|
||||
table.insert(accum, cleaned)
|
||||
last_progress = cleaned
|
||||
end
|
||||
partial = data[i]
|
||||
end
|
||||
if partial ~= "" then
|
||||
local cleaned = clean_progress_line(partial)
|
||||
if cleaned ~= "" then
|
||||
last_progress = cleaned
|
||||
end
|
||||
end
|
||||
if last_progress ~= prev then
|
||||
emit_progress(last_progress, "running")
|
||||
end
|
||||
end
|
||||
|
||||
local function on_exit(_, code)
|
||||
if partial ~= "" then
|
||||
local cleaned = clean_progress_line(partial)
|
||||
if cleaned ~= "" then
|
||||
table.insert(accum, cleaned)
|
||||
last_progress = cleaned
|
||||
end
|
||||
partial = ""
|
||||
end
|
||||
if code == 0 then
|
||||
emit_progress(
|
||||
last_progress ~= "" and last_progress or "done",
|
||||
"success"
|
||||
)
|
||||
history.append(args, accum)
|
||||
else
|
||||
emit_progress(("exit %d"):format(code), "failed")
|
||||
local body = #accum > 0 and table.concat(accum, "\n")
|
||||
or ("%s failed: exit %d"):format(title, code)
|
||||
vim.api.nvim_echo({ { body, "ErrorMsg" } }, true, {})
|
||||
history.append(args, accum)
|
||||
end
|
||||
end
|
||||
|
||||
local cmd = { "git" }
|
||||
vim.list_extend(cmd, args)
|
||||
local job = vim.fn.jobstart(cmd, {
|
||||
cwd = r.worktree,
|
||||
pty = true,
|
||||
env = util.DEFAULT_GIT_ENV,
|
||||
on_stdout = on_data,
|
||||
on_stderr = on_data,
|
||||
on_exit = on_exit,
|
||||
})
|
||||
if job <= 0 then
|
||||
util.error("failed to start git job")
|
||||
end
|
||||
end
|
||||
|
||||
---@param r ow.Git.Repo
|
||||
---@param args string[]
|
||||
---@param bang boolean
|
||||
local function streaming_dispatch(r, args, bang)
|
||||
if bang then
|
||||
run_in_preview(r, args)
|
||||
else
|
||||
run_streaming(r, args)
|
||||
end
|
||||
end
|
||||
|
||||
---@type table<string, ow.Git.Cmd.Run>
|
||||
local HANDLERS = {
|
||||
log = in_split("git"),
|
||||
diff = in_split("git"),
|
||||
push = streaming_dispatch,
|
||||
fetch = streaming_dispatch,
|
||||
pull = streaming_dispatch,
|
||||
clone = streaming_dispatch,
|
||||
am = streaming_dispatch,
|
||||
["cherry-pick"] = streaming_dispatch,
|
||||
revert = streaming_dispatch,
|
||||
}
|
||||
|
||||
---@param args string[]
|
||||
---@return boolean
|
||||
local function has_message(args)
|
||||
@@ -262,13 +452,16 @@ local function has_message(args)
|
||||
end
|
||||
|
||||
---@param args string[]
|
||||
function M.run(args)
|
||||
---@param opts { bang: boolean? }?
|
||||
function M.run(args, opts)
|
||||
local r = repo.resolve()
|
||||
if not r then
|
||||
util.error("not in a git repository")
|
||||
return
|
||||
end
|
||||
|
||||
local bang = opts and opts.bang or false
|
||||
|
||||
local sub = args[1]
|
||||
if sub == "commit" and not has_message(args) then
|
||||
commit.commit({ args = vim.list_slice(args, 2) })
|
||||
@@ -280,7 +473,7 @@ function M.run(args)
|
||||
object.open(r, args[2])
|
||||
return
|
||||
end
|
||||
run_in_split(r, args, { ft = "git" })
|
||||
run_in_split(r, args, "git")
|
||||
return
|
||||
end
|
||||
|
||||
@@ -289,13 +482,13 @@ function M.run(args)
|
||||
object.open(r, args[3])
|
||||
return
|
||||
end
|
||||
run_in_split(r, args, { ft = "git" })
|
||||
run_in_split(r, args, "git")
|
||||
return
|
||||
end
|
||||
|
||||
local conf = sub and SPLIT_HANDLERS[sub]
|
||||
if conf then
|
||||
run_in_split(r, args, conf)
|
||||
local handler = sub and HANDLERS[sub]
|
||||
if handler then
|
||||
handler(r, args, bang)
|
||||
else
|
||||
run_to_messages(r, args)
|
||||
end
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
local util = require("git.util")
|
||||
|
||||
local M = {}
|
||||
|
||||
local BUF_NAME = "githistory://log"
|
||||
|
||||
---@type integer?
|
||||
local cached_buf
|
||||
|
||||
---@return integer
|
||||
local function get_buf()
|
||||
if cached_buf and vim.api.nvim_buf_is_valid(cached_buf) then
|
||||
return cached_buf
|
||||
end
|
||||
local buf = vim.api.nvim_create_buf(false, true)
|
||||
pcall(vim.api.nvim_buf_set_name, buf, BUF_NAME)
|
||||
util.setup_scratch(buf, { bufhidden = "hide" })
|
||||
cached_buf = buf
|
||||
return buf
|
||||
end
|
||||
|
||||
---@param args string[]
|
||||
---@param lines string[]
|
||||
function M.append(args, lines)
|
||||
if #lines == 0 then
|
||||
return
|
||||
end
|
||||
local buf = get_buf()
|
||||
local count = vim.api.nvim_buf_line_count(buf)
|
||||
local first = vim.api.nvim_buf_get_lines(buf, 0, 1, false)[1] or ""
|
||||
local empty = count == 1 and first == ""
|
||||
local payload = { "$ git " .. table.concat(args, " ") }
|
||||
vim.list_extend(payload, lines)
|
||||
vim.bo[buf].modifiable = true
|
||||
if empty then
|
||||
vim.api.nvim_buf_set_lines(buf, 0, -1, false, payload)
|
||||
else
|
||||
table.insert(payload, 1, "")
|
||||
vim.api.nvim_buf_set_lines(buf, count, count, false, payload)
|
||||
end
|
||||
vim.bo[buf].modifiable = false
|
||||
vim.bo[buf].modified = false
|
||||
end
|
||||
|
||||
function M.open()
|
||||
local buf = get_buf()
|
||||
local win = vim.fn.bufwinid(buf)
|
||||
if win ~= -1 then
|
||||
vim.api.nvim_set_current_win(win)
|
||||
else
|
||||
util.place_buf(buf, nil)
|
||||
win = vim.api.nvim_get_current_win()
|
||||
end
|
||||
local last = vim.api.nvim_buf_line_count(buf)
|
||||
vim.api.nvim_win_set_cursor(win, { last, 0 })
|
||||
end
|
||||
|
||||
return M
|
||||
+6
-1
@@ -138,9 +138,10 @@ function M.init()
|
||||
|
||||
vim.api.nvim_create_user_command("G", function(opts)
|
||||
local cmd = require("git.cmd")
|
||||
cmd.run(cmd.parse_args(opts.args))
|
||||
cmd.run(cmd.parse_args(opts.args), { bang = opts.bang })
|
||||
end, {
|
||||
nargs = "*",
|
||||
bang = true,
|
||||
complete = function(...)
|
||||
return require("git.cmd").complete(...)
|
||||
end,
|
||||
@@ -150,6 +151,10 @@ function M.init()
|
||||
require("git.repo").refresh_all()
|
||||
end, { desc = "Refresh git status for all repos" })
|
||||
|
||||
vim.api.nvim_create_user_command("Ghistory", function()
|
||||
require("git.history").open()
|
||||
end, { desc = "Open the git streaming output history" })
|
||||
|
||||
vim.api.nvim_create_user_command("Gstatus", function(opts)
|
||||
require("git.status_view").open({
|
||||
placement = opts.fargs[1] --[[@as ow.Git.StatusView.Placement]] or "split",
|
||||
|
||||
@@ -419,3 +419,232 @@ t.test(":G diff <CR> on + line falls back to worktree file", function()
|
||||
t.truthy(require("git.object").open_under_cursor())
|
||||
t.eq(vim.api.nvim_buf_get_name(0), vim.fs.joinpath(dir, "a"))
|
||||
end)
|
||||
|
||||
---Run cmd.run via :lua so vim.fn.execute captures any nvim_echo output
|
||||
---and suppresses it from headless stdout.
|
||||
---@param args string[]
|
||||
---@return string
|
||||
local function run_capturing(args)
|
||||
return vim.trim(
|
||||
vim.fn.execute(
|
||||
string.format([[lua require("git.cmd").run(%s)]], vim.inspect(args))
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
---@return integer? pwin
|
||||
local function find_preview_win()
|
||||
for _, w in ipairs(vim.api.nvim_tabpage_list_wins(0)) do
|
||||
if vim.wo[w].previewwindow then
|
||||
return w
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function close_preview()
|
||||
pcall(vim.cmd.pclose)
|
||||
end
|
||||
|
||||
t.test("quiet :G echoes single-line stdout", function()
|
||||
make_repo({ a = "x" })
|
||||
local out = run_capturing({ "config", "user.email" })
|
||||
t.truthy(
|
||||
out:match("t@t%.com"),
|
||||
"expected output to contain t@t.com, got: " .. out
|
||||
)
|
||||
end)
|
||||
|
||||
t.test("quiet :G is silent on empty-output success", function()
|
||||
make_repo({ a = "x" })
|
||||
t.eq(run_capturing({ "config", "user.email", "new@t.com" }), "")
|
||||
end)
|
||||
|
||||
t.test("quiet :G echoes 'git exited N' on silent failure", function()
|
||||
make_repo({ a = "x" })
|
||||
local out = run_capturing({ "config", "--get", "nonexistent.foo.bar" })
|
||||
t.truthy(
|
||||
out:match("git exited 1"),
|
||||
"expected output to contain 'git exited 1', got: " .. out
|
||||
)
|
||||
end)
|
||||
|
||||
t.test("quiet :G echoes stderr on failure with output", function()
|
||||
make_repo({ a = "x" })
|
||||
local out = run_capturing({ "branch", "-d", "nonexistent-branch" })
|
||||
t.truthy(
|
||||
out:match("nonexistent%-branch"),
|
||||
"expected stderr mentioning the branch, got: " .. out
|
||||
)
|
||||
end)
|
||||
|
||||
---@param fn fun(calls: { chunks: table, history: boolean, opts: table }[])
|
||||
local function with_echo_stub(fn)
|
||||
---@type { chunks: table, history: boolean, opts: table }[]
|
||||
local calls = {}
|
||||
local original = vim.api.nvim_echo
|
||||
vim.api.nvim_echo = function(chunks, history, opts)
|
||||
table.insert(calls, {
|
||||
chunks = chunks,
|
||||
history = history,
|
||||
opts = opts or {},
|
||||
})
|
||||
return original(chunks, history, opts)
|
||||
end
|
||||
local ok, err = pcall(fn, calls)
|
||||
vim.api.nvim_echo = original
|
||||
if not ok then
|
||||
error(err, 0)
|
||||
end
|
||||
end
|
||||
|
||||
---@param calls { opts: table }[]
|
||||
---@param status string
|
||||
---@return boolean
|
||||
local function has_status(calls, status)
|
||||
for _, c in ipairs(calls) do
|
||||
if c.opts.status == status then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
t.test("streaming :G fetch (no bang) does not open a window", function()
|
||||
make_repo({ a = "x" })
|
||||
with_echo_stub(function(calls)
|
||||
local before = #vim.api.nvim_tabpage_list_wins(0)
|
||||
cmd.run({ "fetch" })
|
||||
t.wait_for(function()
|
||||
return has_status(calls, "failed")
|
||||
or has_status(calls, "success")
|
||||
end, "streaming job to terminate", 5000)
|
||||
t.eq(#vim.api.nvim_tabpage_list_wins(0), before, "no new window")
|
||||
t.falsy(find_preview_win(), "no preview window")
|
||||
end)
|
||||
end)
|
||||
|
||||
t.test(
|
||||
"streaming :G fetch (no bang) emits failed progress on bad remote",
|
||||
function()
|
||||
make_repo({ a = "x" })
|
||||
with_echo_stub(function(calls)
|
||||
cmd.run({ "fetch", "nonexistent" })
|
||||
t.wait_for(function()
|
||||
return has_status(calls, "failed")
|
||||
end, "failed progress notification", 5000)
|
||||
---@type { chunks: table, history: boolean, opts: table }?
|
||||
local final
|
||||
for _, c in ipairs(calls) do
|
||||
if c.opts.status == "failed" then
|
||||
final = c
|
||||
break
|
||||
end
|
||||
end
|
||||
t.truthy(final)
|
||||
---@cast final -nil
|
||||
t.eq(final.opts.kind, "progress")
|
||||
t.falsy(final.history, "transient progress, not history")
|
||||
t.truthy(has_status(calls, "running"), "running progress emitted")
|
||||
end)
|
||||
end
|
||||
)
|
||||
|
||||
t.test(
|
||||
"streaming :G fetch (no bang) on failure dumps output to :messages with ErrorMsg",
|
||||
function()
|
||||
make_repo({ a = "x" })
|
||||
with_echo_stub(function(calls)
|
||||
cmd.run({ "fetch", "nonexistent" })
|
||||
t.wait_for(function()
|
||||
return has_status(calls, "failed")
|
||||
end, "failed progress notification", 5000)
|
||||
local body = ""
|
||||
for _, c in ipairs(calls) do
|
||||
if
|
||||
c.history == true
|
||||
and c.chunks[1]
|
||||
and c.chunks[1][2] == "ErrorMsg"
|
||||
then
|
||||
body = c.chunks[1][1]
|
||||
break
|
||||
end
|
||||
end
|
||||
t.truthy(body ~= "", "expected ErrorMsg history dump")
|
||||
local lower = string.lower(body)
|
||||
t.truthy(
|
||||
lower:match("repository")
|
||||
or lower:match("remote")
|
||||
or lower:match("fatal"),
|
||||
"expected error mention in dump, got: " .. body
|
||||
)
|
||||
end)
|
||||
end
|
||||
)
|
||||
|
||||
t.test(
|
||||
"streaming :G fetch (no bang) on success does not dump to :messages",
|
||||
function()
|
||||
local remote = vim.fn.tempname()
|
||||
vim.fn.mkdir(remote, "p")
|
||||
h.git(remote, "init", "-q", "--bare")
|
||||
t.defer(function()
|
||||
vim.fn.delete(remote, "rf")
|
||||
end)
|
||||
|
||||
local dir = make_repo({ a = "x" })
|
||||
h.git(dir, "remote", "add", "origin", remote)
|
||||
h.git(dir, "push", "-q", "origin", "main")
|
||||
|
||||
with_echo_stub(function(calls)
|
||||
cmd.run({ "fetch", "origin" })
|
||||
t.wait_for(function()
|
||||
return has_status(calls, "success")
|
||||
end, "success progress notification", 5000)
|
||||
for _, c in ipairs(calls) do
|
||||
t.falsy(
|
||||
c.history,
|
||||
"success path must not echo to message history"
|
||||
)
|
||||
end
|
||||
end)
|
||||
end
|
||||
)
|
||||
|
||||
t.test("streaming output buffer collects runs (failure path)", function()
|
||||
make_repo({ a = "x" })
|
||||
with_echo_stub(function(calls)
|
||||
cmd.run({ "fetch", "nonexistent" })
|
||||
t.wait_for(function()
|
||||
return has_status(calls, "failed")
|
||||
end, "failed progress notification", 5000)
|
||||
end)
|
||||
require("git.history").open()
|
||||
local buf = vim.api.nvim_get_current_buf()
|
||||
local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false)
|
||||
local found_header, found_fatal = false, false
|
||||
for _, l in ipairs(lines) do
|
||||
if l:match("^%$ git fetch nonexistent$") then
|
||||
found_header = true
|
||||
end
|
||||
if l:match("fatal:") then
|
||||
found_fatal = true
|
||||
end
|
||||
end
|
||||
t.truthy(found_header, "expected '$ git fetch nonexistent' header")
|
||||
t.truthy(found_fatal, "expected fatal: line in output buffer")
|
||||
end)
|
||||
|
||||
t.test(
|
||||
"streaming :G! fetch (bang) opens preview window with terminal buffer",
|
||||
function()
|
||||
make_repo({ a = "x" })
|
||||
t.defer(close_preview)
|
||||
|
||||
cmd.run({ "fetch" }, { bang = true })
|
||||
local pwin = find_preview_win()
|
||||
t.truthy(pwin, "expected preview window to exist")
|
||||
---@cast pwin integer
|
||||
local buf = vim.api.nvim_win_get_buf(pwin)
|
||||
t.eq(vim.bo[buf].buftype, "terminal")
|
||||
end
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user