Files
nvim/test/git/cmd_test.lua
T

636 lines
21 KiB
Lua

local cmd = require("git.cmd")
local h = require("test.git.helpers")
local t = require("test")
require("git").init()
---@param files table<string, string>?
---@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 <rev>:<path> 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 <CR> 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("<leader>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 <C-o> back to the log.
vim.api.nvim_win_set_cursor(log_win, { 1, 0 })
t.press("<CR>")
t.truthy(vim.api.nvim_buf_get_name(0):match("^git://"))
t.press("<C-o>")
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("<CR> 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 <sha>" 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)
-- <C-o> 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("<C-o>")
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(),
"<CR> must work after returning to the buffer"
)
end)
t.test(":G diff <CR> 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 highlights only fatal/error lines",
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 table?
local dump
for _, c in ipairs(calls) do
if c.history == true then
dump = c.chunks
break
end
end
t.truthy(dump, "expected history dump")
---@cast dump -nil
local fatal_chunks_red, plain_continuation = 0, false
for _, chunk in ipairs(dump) do
local text, hl = chunk[1], chunk[2]
if text:match("^fatal:") and hl == "ErrorMsg" then
fatal_chunks_red = fatal_chunks_red + 1
end
if text:match("Please make sure") and hl ~= "ErrorMsg" then
plain_continuation = true
end
end
t.truthy(
fatal_chunks_red >= 1,
"expected at least one fatal: line highlighted as ErrorMsg"
)
t.truthy(
plain_continuation,
"expected continuation line to be plain"
)
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 :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
)