feat(git): ambient progress for long-running :G subcommands

This commit is contained in:
2026-05-09 00:00:57 +02:00
parent 6515458d96
commit c248af308a
4 changed files with 526 additions and 41 deletions
+214 -21
View File
@@ -1,18 +1,12 @@
local commit = require("git.commit") local commit = require("git.commit")
local history = require("git.history")
local object = require("git.object") local object = require("git.object")
local repo = require("git.repo") local repo = require("git.repo")
local util = require("git.util") local util = require("git.util")
local M = {} local M = {}
---@class ow.Git.Cmd.SplitHandler ---@alias ow.Git.Cmd.Run fun(r: ow.Git.Repo, args: string[], bang: boolean)
---@field ft string
---@type table<string, ow.Git.Cmd.SplitHandler>
local SPLIT_HANDLERS = {
log = { ft = "git" },
diff = { ft = "git" },
}
---@type string[]? ---@type string[]?
local cached_cmds local cached_cmds
@@ -182,8 +176,8 @@ end
---@param r ow.Git.Repo ---@param r ow.Git.Repo
---@param args string[] ---@param args string[]
---@param conf ow.Git.Cmd.SplitHandler ---@param ft string
local function run_in_split(r, args, conf) local function run_in_split(r, args, ft)
util.git(args, { util.git(args, {
cwd = r.worktree, cwd = r.worktree,
on_exit = function(result) on_exit = function(result)
@@ -201,7 +195,7 @@ local function run_in_split(r, args, conf)
object.attach_dispatch(buf) object.attach_dispatch(buf)
attach_history_keys(buf) attach_history_keys(buf)
local state = r:state(buf) --[[@as -nil]] 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. -- Force a new undo block so each rerun is its own undo step.
vim.bo[buf].undolevels = vim.bo[buf].undolevels vim.bo[buf].undolevels = vim.bo[buf].undolevels
local first_run = not state.initialized local first_run = not state.initialized
@@ -217,11 +211,17 @@ end
---@param r ow.Git.Repo ---@param r ow.Git.Repo
---@param args string[] ---@param args string[]
local function run_to_messages(r, args) 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, cwd = r.worktree,
on_exit = function(result) text = true,
env = util.DEFAULT_GIT_ENV,
}):wait()
local out = vim.trim(result.stdout or "") local out = vim.trim(result.stdout or "")
local err = vim.trim(result.stderr or "") local err = vim.trim(result.stderr or "")
local failed = result.code ~= 0
local chunks = {} local chunks = {}
if out ~= "" then if out ~= "" then
table.insert(chunks, { out }) table.insert(chunks, { out })
@@ -230,9 +230,9 @@ local function run_to_messages(r, args)
if #chunks > 0 then if #chunks > 0 then
table.insert(chunks, { "\n" }) table.insert(chunks, { "\n" })
end end
table.insert(chunks, { err, "ErrorMsg" }) table.insert(chunks, { err, failed and "ErrorMsg" or nil })
end end
if #chunks == 0 and result.code ~= 0 then if #chunks == 0 and failed then
table.insert( table.insert(
chunks, chunks,
{ "git exited " .. tostring(result.code), "ErrorMsg" } { "git exited " .. tostring(result.code), "ErrorMsg" }
@@ -241,10 +241,200 @@ local function run_to_messages(r, args)
if #chunks > 0 then if #chunks > 0 then
vim.api.nvim_echo(chunks, true, {}) vim.api.nvim_echo(chunks, true, {})
end 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, 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 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[] ---@param args string[]
---@return boolean ---@return boolean
local function has_message(args) local function has_message(args)
@@ -262,13 +452,16 @@ local function has_message(args)
end end
---@param args string[] ---@param args string[]
function M.run(args) ---@param opts { bang: boolean? }?
function M.run(args, opts)
local r = repo.resolve() local r = repo.resolve()
if not r then if not r then
util.error("not in a git repository") util.error("not in a git repository")
return return
end end
local bang = opts and opts.bang or false
local sub = args[1] local sub = args[1]
if sub == "commit" and not has_message(args) then if sub == "commit" and not has_message(args) then
commit.commit({ args = vim.list_slice(args, 2) }) commit.commit({ args = vim.list_slice(args, 2) })
@@ -280,7 +473,7 @@ function M.run(args)
object.open(r, args[2]) object.open(r, args[2])
return return
end end
run_in_split(r, args, { ft = "git" }) run_in_split(r, args, "git")
return return
end end
@@ -289,13 +482,13 @@ function M.run(args)
object.open(r, args[3]) object.open(r, args[3])
return return
end end
run_in_split(r, args, { ft = "git" }) run_in_split(r, args, "git")
return return
end end
local conf = sub and SPLIT_HANDLERS[sub] local handler = sub and HANDLERS[sub]
if conf then if handler then
run_in_split(r, args, conf) handler(r, args, bang)
else else
run_to_messages(r, args) run_to_messages(r, args)
end end
+58
View File
@@ -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
View File
@@ -138,9 +138,10 @@ function M.init()
vim.api.nvim_create_user_command("G", function(opts) vim.api.nvim_create_user_command("G", function(opts)
local cmd = require("git.cmd") local cmd = require("git.cmd")
cmd.run(cmd.parse_args(opts.args)) cmd.run(cmd.parse_args(opts.args), { bang = opts.bang })
end, { end, {
nargs = "*", nargs = "*",
bang = true,
complete = function(...) complete = function(...)
return require("git.cmd").complete(...) return require("git.cmd").complete(...)
end, end,
@@ -150,6 +151,10 @@ function M.init()
require("git.repo").refresh_all() require("git.repo").refresh_all()
end, { desc = "Refresh git status for all repos" }) 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) vim.api.nvim_create_user_command("Gstatus", function(opts)
require("git.status_view").open({ require("git.status_view").open({
placement = opts.fargs[1] --[[@as ow.Git.StatusView.Placement]] or "split", placement = opts.fargs[1] --[[@as ow.Git.StatusView.Placement]] or "split",
+229
View File
@@ -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.truthy(require("git.object").open_under_cursor())
t.eq(vim.api.nvim_buf_get_name(0), vim.fs.joinpath(dir, "a")) t.eq(vim.api.nvim_buf_get_name(0), vim.fs.joinpath(dir, "a"))
end) 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
)