local cmd = require("git.cmd") local h = require("test.git.helpers") local t = require("test") require("git").init() ---@param files table? ---@return string dir local function make_repo(files) return h.make_repo(files, { cd = true }) end ---@param actual string[] ---@param expected string[] local function eq_sorted(actual, expected, msg) table.sort(actual) table.sort(expected) t.eq(actual, expected, msg) end t.test("parse_args splits on whitespace", function() t.eq( cmd.parse_args("config user.name value"), { "config", "user.name", "value" } ) end) t.test("parse_args preserves spaces inside double quotes", function() t.eq( cmd.parse_args([[config user.name "Oscar Wallberg"]]), { "config", "user.name", "Oscar Wallberg" } ) end) t.test("parse_args preserves spaces inside single quotes", function() t.eq( cmd.parse_args([[log --grep='bug fix' --author=Oscar]]), { "log", "--grep=bug fix", "--author=Oscar" } ) end) t.test("parse_args handles backslash-escaped space", function() t.eq(cmd.parse_args([[a\ b c]]), { "a b", "c" }) end) t.test("parse_args handles escaped quote inside double quotes", function() t.eq(cmd.parse_args([["a\"b" c]]), { 'a"b', "c" }) end) t.test("parse_args treats backslash literally inside single quotes", function() t.eq(cmd.parse_args([['a\b' c]]), { "a\\b", "c" }) end) t.test("parse_args concatenates adjacent quoted segments", function() t.eq(cmd.parse_args([[foo"bar"baz]]), { "foobarbaz" }) end) t.test("parse_args handles tabs as separators", function() t.eq(cmd.parse_args("a\tb\tc"), { "a", "b", "c" }) end) t.test("parse_args returns empty list for empty or whitespace input", function() t.eq(cmd.parse_args(""), {}) t.eq(cmd.parse_args(" \t "), {}) end) t.test("parse_args preserves empty quoted token", function() t.eq(cmd.parse_args([[a "" b]]), { "a", "", "b" }) end) t.test("parse_args expands %% on unquoted token", function() local buf = vim.api.nvim_create_buf(false, false) t.defer(function() pcall(vim.api.nvim_buf_delete, buf, { force = true }) end) vim.api.nvim_buf_set_name(buf, vim.fn.getcwd() .. "/some-file.lua") vim.api.nvim_set_current_buf(buf) t.eq( cmd.parse_args("add %"), { "add", vim.fn.getcwd() .. "/some-file.lua" } ) end) t.test("parse_args does not expand %% inside double quotes", function() local buf = vim.api.nvim_create_buf(false, false) t.defer(function() pcall(vim.api.nvim_buf_delete, buf, { force = true }) end) vim.api.nvim_buf_set_name(buf, vim.fn.getcwd() .. "/some-file.lua") vim.api.nvim_set_current_buf(buf) t.eq(cmd.parse_args([[log -- "%"]]), { "log", "--", "%" }) end) t.test("parse_args does not expand %% inside single quotes", function() local buf = vim.api.nvim_create_buf(false, false) t.defer(function() pcall(vim.api.nvim_buf_delete, buf, { force = true }) end) vim.api.nvim_buf_set_name(buf, vim.fn.getcwd() .. "/some-file.lua") vim.api.nvim_set_current_buf(buf) t.eq(cmd.parse_args([[log -- '%']]), { "log", "--", "%" }) end) t.test("parse_args does not treat mid-token tilde as expansion", function() t.eq(cmd.parse_args("checkout HEAD~3"), { "checkout", "HEAD~3" }) end) t.test("parse_args expands leading ~/ to home", function() t.eq(cmd.parse_args("add ~/foo"), { "add", vim.fn.expand("~/foo") }) end) t.test("parse_complete_state with trailing space", function() local s = cmd._parse_complete_state("G push origin ") t.eq(s.prior, { "push", "origin" }) t.falsy(s.after_separator) end) t.test("parse_complete_state mid-token", function() local s = cmd._parse_complete_state("G push or") t.eq(s.prior, { "push" }) t.falsy(s.after_separator) end) t.test("parse_complete_state empty after command", function() local s = cmd._parse_complete_state("G ") t.eq(s.prior, {}) t.falsy(s.after_separator) end) t.test("parse_complete_state detects -- separator", function() local s = cmd._parse_complete_state("G log -- foo") t.eq(s.prior, { "log", "--" }) t.truthy(s.after_separator) end) t.test("positional_index ignores flags", function() t.eq(cmd._positional_index({ "push" }), 1) t.eq(cmd._positional_index({ "push", "origin" }), 2) t.eq(cmd._positional_index({ "push", "--force" }), 1) t.eq(cmd._positional_index({ "push", "--force", "origin" }), 2) t.eq(cmd._positional_index({ "checkout", "-b", "feature" }), 2) end) t.test("complete returns subcommands at first position", function() local matches = cmd.complete("ch", "G ch", 4) t.truthy(vim.tbl_contains(matches, "checkout")) t.truthy(vim.tbl_contains(matches, "cherry-pick")) end) t.test("complete returns flags when arg starts with -", function() local matches = cmd.complete("--am", "G commit --am", 13) t.eq(matches, { "--amend" }) end) t.test("complete branch returns plain refs (no pseudo, no stash)", function() local dir = make_repo({ a = "x" }) h.git(dir, "branch", "feature") h.git(dir, "tag", "v1") t.write(dir, "a", "modified") h.git(dir, "stash") local matches = cmd.complete("", "G branch ", 9) eq_sorted(matches, { "feature", "main", "v1" }) end) t.test("complete merge returns refs + pseudo + stash", function() local dir = make_repo({ a = "x" }) h.git(dir, "branch", "feature") t.write(dir, "a", "y") h.git(dir, "stash") local matches = cmd.complete("", "G merge ", 8) eq_sorted( matches, { "HEAD", "ORIG_HEAD", "feature", "main", "stash", "stash@{0}" } ) end) t.test("complete push first positional returns remotes", function() local dir = make_repo({ a = "x" }) h.git(dir, "remote", "add", "origin", "/tmp/nope") h.git(dir, "remote", "add", "upstream", "/tmp/nope") local matches = cmd.complete("", "G push ", 7) eq_sorted(matches, { "origin", "upstream" }) end) t.test("complete push second positional returns refs", function() local dir = make_repo({ a = "x" }) h.git(dir, "branch", "feature") local matches = cmd.complete("", "G push origin ", 14) eq_sorted(matches, { "HEAD", "feature", "main" }) end) t.test("complete add returns only unstaged/untracked paths", function() local dir = make_repo({ tracked = "x" }) t.write(dir, "tracked", "modified") t.write(dir, "newfile", "new") local r = assert(require("git.repo").resolve(dir)) r:refresh() t.wait_for(function() return r.status and #vim.tbl_keys(r.status.entries) > 0 end, "git status to report entries", 500) local matches = cmd.complete("", "G add ", 6) eq_sorted(matches, { "newfile", "tracked" }) end) t.test("complete after `--` returns tracked paths only", function() local dir = make_repo({ a = "x", b = "y" }) t.write(dir, "untracked", "z") local matches = cmd.complete("", "G log -- ", 9) eq_sorted(matches, { "a", "b" }) end) t.test("complete stash returns subsubcommands", function() make_repo({ a = "x" }) local matches = cmd.complete("p", "G stash p", 9) eq_sorted(matches, { "pop", "push" }) end) t.test("complete show with : returns tree paths", function() make_repo({ a = "x", ["sub/b"] = "y" }) local matches = cmd.complete("HEAD:", "G show HEAD:", 12) eq_sorted(matches, { "HEAD:a", "HEAD:sub/" }) end) t.test("complete unknown subcommand falls back to tracked paths", function() make_repo({ a = "x", b = "y" }) local matches = cmd.complete("", "G nonexistent ", 14) eq_sorted(matches, { "a", "b" }) end) ---@param name_pattern string ---@return integer count local function count_bufs_named(name_pattern) local n = 0 for _, b in ipairs(vim.api.nvim_list_bufs()) do if vim.api.nvim_buf_get_name(b):match(name_pattern) then n = n + 1 end end return n end ---@param buf_name_pattern string ---@param timeout integer? local function wait_buf_populated(buf_name_pattern, timeout) t.wait_for(function() for _, b in ipairs(vim.api.nvim_list_bufs()) do if vim.api.nvim_buf_get_name(b):match(buf_name_pattern) then return #vim.api.nvim_buf_get_lines(b, 0, -1, false) > 1 end end return false end, "buffer matching " .. buf_name_pattern .. " to populate", timeout) end ---Wait for a buffer matching `buf_name_pattern` to contain a line whose ---content equals `line`. Useful for asserting that re-running a :G ---command repopulated the buffer with new output. ---@param buf_name_pattern string ---@param line string ---@param timeout integer? local function wait_buf_has_line(buf_name_pattern, line, timeout) t.wait_for(function() for _, b in ipairs(vim.api.nvim_list_bufs()) do if vim.api.nvim_buf_get_name(b):match(buf_name_pattern) then for _, l in ipairs(vim.api.nvim_buf_get_lines(b, 0, -1, false)) do if l == line then return true end end end end return false end, "buffer " .. buf_name_pattern .. " to contain " .. line, timeout) end t.test("run :G diff reuses the same buffer across invocations", function() local dir = make_repo({ a = "v1\n" }) t.write(dir, "a", "v2\n") cmd.run({ "diff" }) wait_buf_has_line("%[Git diff%]", "+v2") t.eq(count_bufs_named("%[Git diff%]"), 1) t.write(dir, "a", "v3\n") cmd.run({ "diff" }) wait_buf_has_line("%[Git diff%]", "+v3") t.eq(count_bufs_named("%[Git diff%]"), 1, "second :G diff should reuse") t.write(dir, "a", "v4\n") cmd.run({ "diff" }) wait_buf_has_line("%[Git diff%]", "+v4") t.eq(count_bufs_named("%[Git diff%]"), 1, "third :G diff should reuse") end) ---@param buf integer ---@param prefix string ---@return integer? lnum local function find_line(buf, prefix) for i, l in ipairs(vim.api.nvim_buf_get_lines(buf, 0, -1, false)) do if l:sub(1, #prefix) == prefix then return i end end end t.test(":G show on + line opens the blob URI", function() local dir = make_repo({ a = "first\n" }) t.write(dir, "a", "second\n") h.git(dir, "add", "a") h.git(dir, "commit", "-q", "-m", "second") assert(require("git.repo").resolve(dir)) local blob = h.git(dir, "rev-parse", "HEAD:a").stdout cmd.run({ "show", "HEAD" }) wait_buf_populated("%[Git show HEAD%]") ---@type integer? local diff_buf for _, b in ipairs(vim.api.nvim_list_bufs()) do if vim.api.nvim_buf_get_name(b):match("%[Git show HEAD%]") then diff_buf = b end end assert(diff_buf, "expected [Git show HEAD] buffer") local win = vim.fn.bufwinid(diff_buf) vim.api.nvim_set_current_win(win) local lnum = assert(find_line(diff_buf, "+second")) vim.api.nvim_win_set_cursor(win, { lnum, 0 }) t.truthy(require("git.object").open_under_cursor()) t.eq(vim.api.nvim_buf_get_name(0), "git://" .. blob) end) t.test("gl log buffer refills after jumping back", function() local dir = make_repo({ a = "v1\n" }) t.write(dir, "a", "v2\n") h.git(dir, "add", "a") h.git(dir, "commit", "-q", "-m", "second") require("git.log_view").open({ max_count = 1000 }) wait_buf_populated("^gitlog://") local log_buf = vim.api.nvim_get_current_buf() local log_win = vim.api.nvim_get_current_win() t.truthy(vim.api.nvim_buf_get_name(log_buf):match("^gitlog://")) local initial_lines = #vim.api.nvim_buf_get_lines(log_buf, 0, -1, false) t.truthy(initial_lines >= 2) -- Step into a commit, then back to the log. vim.api.nvim_win_set_cursor(log_win, { 1, 0 }) t.press("") t.truthy(vim.api.nvim_buf_get_name(0):match("^git://")) t.press("") t.eq(vim.api.nvim_get_current_buf(), log_buf) t.eq( #vim.api.nvim_buf_get_lines(log_buf, 0, -1, false), initial_lines, "log buffer must repopulate on jump-back" ) end) t.test(" still dispatches after navigating away and back", function() local dir = make_repo({ a = "v1\n" }) t.write(dir, "a", "v2\n") h.git(dir, "add", "a") h.git(dir, "commit", "-q", "-m", "second") -- Open the HEAD commit object buffer. Its cat-file output includes a -- "parent " line we can navigate from. local r = assert(require("git.repo").resolve(dir)) require("git.object").open(r, "HEAD", { split = false }) local first_obj_buf = vim.api.nvim_get_current_buf() local first_obj_win = vim.api.nvim_get_current_win() t.truthy(vim.api.nvim_buf_get_name(first_obj_buf):match("^git://")) -- Step into the parent commit. This hides first_obj_buf which has -- bufhidden=delete, so it gets unloaded. local parent_lnum = assert(find_line(first_obj_buf, "parent ")) vim.api.nvim_win_set_cursor(first_obj_win, { parent_lnum, 0 }) t.truthy(require("git.object").open_under_cursor()) local parent_buf = vim.api.nvim_get_current_buf() t.truthy(parent_buf ~= first_obj_buf) -- back to first_obj_buf. With bufhidden=delete, vim re-reads the -- URI, which previously raced with BufDelete-driven unbind and left -- state cleared, so open_under_cursor returned false. t.press("") t.eq(vim.api.nvim_get_current_buf(), first_obj_buf) local tree_lnum = assert(find_line(first_obj_buf, "tree ")) vim.api.nvim_win_set_cursor(first_obj_win, { tree_lnum, 0 }) t.truthy( require("git.object").open_under_cursor(), " must work after returning to the buffer" ) end) t.test(":G diff on + line falls back to worktree file", function() local dir = make_repo({ a = "v1\n" }) t.write(dir, "a", "v2\n") assert(require("git.repo").resolve(dir)) cmd.run({ "diff" }) wait_buf_populated("%[Git diff%]") ---@type integer? local diff_buf for _, b in ipairs(vim.api.nvim_list_bufs()) do if vim.api.nvim_buf_get_name(b):match("%[Git diff%]") then diff_buf = b end end assert(diff_buf, "expected [Git diff] buffer") local win = vim.fn.bufwinid(diff_buf) vim.api.nvim_set_current_win(win) local lnum = assert(find_line(diff_buf, "+v2")) vim.api.nvim_win_set_cursor(win, { lnum, 0 }) 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 )