From c248af308a89f227fe245cc667bb11443e267019 Mon Sep 17 00:00:00 2001 From: Oscar Wallberg Date: Sat, 9 May 2026 00:00:57 +0200 Subject: [PATCH] feat(git): ambient progress for long-running :G subcommands --- lua/git/cmd.lua | 273 +++++++++++++++++++++++++++++++++++------- lua/git/history.lua | 58 +++++++++ lua/git/init.lua | 7 +- test/git/cmd_test.lua | 229 +++++++++++++++++++++++++++++++++++ 4 files changed, 526 insertions(+), 41 deletions(-) create mode 100644 lua/git/history.lua diff --git a/lua/git/cmd.lua b/lua/git/cmd.lua index e5bf137..9dee8ac 100644 --- a/lua/git/cmd.lua +++ b/lua/git/cmd.lua @@ -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 -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,34 +211,230 @@ 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) - local out = vim.trim(result.stdout or "") - local err = vim.trim(result.stderr or "") - local chunks = {} - if out ~= "" then - table.insert(chunks, { out }) - end - if err ~= "" then - if #chunks > 0 then - table.insert(chunks, { "\n" }) - end - table.insert(chunks, { err, "ErrorMsg" }) - end - if #chunks == 0 and result.code ~= 0 then - table.insert( - chunks, - { "git exited " .. tostring(result.code), "ErrorMsg" } - ) - end - if #chunks > 0 then - vim.api.nvim_echo(chunks, true, {}) - end + 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 }) + end + if err ~= "" then + if #chunks > 0 then + table.insert(chunks, { "\n" }) + end + table.insert(chunks, { err, failed and "ErrorMsg" or nil }) + end + if #chunks == 0 and failed then + table.insert( + chunks, + { "git exited " .. tostring(result.code), "ErrorMsg" } + ) + end + 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", "pclose", { + buffer = buf, + nowait = true, + desc = "Close preview", + }) + vim.keymap.set("n", "", 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 +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 diff --git a/lua/git/history.lua b/lua/git/history.lua new file mode 100644 index 0000000..4eb9968 --- /dev/null +++ b/lua/git/history.lua @@ -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 diff --git a/lua/git/init.lua b/lua/git/init.lua index 509705e..e0ff458 100644 --- a/lua/git/init.lua +++ b/lua/git/init.lua @@ -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", diff --git a/test/git/cmd_test.lua b/test/git/cmd_test.lua index 95f134d..10ca765 100644 --- a/test/git/cmd_test.lua +++ b/test/git/cmd_test.lua @@ -419,3 +419,232 @@ t.test(":G diff 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 +)