test(git): add regression tests with a minimal headless harness

This commit is contained in:
2026-05-06 17:32:36 +02:00
parent e00a282194
commit d63c2ae578
4 changed files with 537 additions and 0 deletions
+210
View File
@@ -0,0 +1,210 @@
local h = require("helpers")
require("git").init()
---Run the cursor-restore autocmd that was responsible for the original
---cursor-jump bug. Replicating it lets the regression test exercise the
---same interaction the user had in their config.
local function install_cursor_restore_autocmd()
vim.api.nvim_create_autocmd("BufReadPost", {
pattern = "*",
command = 'silent! normal! g`"zv',
})
end
---@param sidebar_buf integer
---@param needle string
---@return integer?
local function find_line(sidebar_buf, needle)
for i, l in
ipairs(vim.api.nvim_buf_get_lines(sidebar_buf, 0, -1, false))
do
if l:match(needle) then
return i
end
end
end
---@return integer? sidebar_buf, integer? sidebar_win
local function find_sidebar()
local buf, win
for _, b in ipairs(vim.api.nvim_list_bufs()) do
if vim.bo[b].filetype == "gitstatus" then
buf = b
end
end
for _, w in ipairs(vim.api.nvim_list_wins()) do
if vim.api.nvim_win_get_buf(w) == buf then
win = w
end
end
return buf, win
end
---@param role string
---@return integer?
local function find_diff_win(role)
for _, w in ipairs(vim.api.nvim_list_wins()) do
if vim.w[w].git_diff_role == role then
return w
end
end
end
---Set up a repo with one tracked-and-modified file, open the sidebar, and
---return the sidebar window plus the line of the file's entry.
---@param file_path string
---@param committed_content string
---@param worktree_content string
---@return integer sidebar_win
---@return integer entry_line
local function setup_sidebar_with_unstaged_file(
file_path,
committed_content,
worktree_content
)
local repo = h.make_repo({ [file_path] = committed_content })
h.write(repo, file_path, worktree_content)
vim.cmd("cd " .. repo)
require("git.status_view").open({ placement = "sidebar" })
local sidebar_buf, sidebar_win = find_sidebar()
assert(sidebar_buf, "sidebar buffer should exist")
assert(sidebar_win, "sidebar window should exist")
local r = assert(
require("git.repo").find(vim.fn.getcwd()),
"repo should resolve for the test worktree"
)
r:refresh()
vim.wait(1000, function()
return r.status and #r.status:by_kind("unstaged") > 0
end)
local entry_line = assert(
find_line(sidebar_buf, vim.pesc(file_path) .. "$"),
file_path .. " should appear in sidebar"
)
return sidebar_win, entry_line
end
local function press(keys)
local rhs = vim.api.nvim_replace_termcodes(keys, true, false, true)
vim.api.nvim_feedkeys(rhs, "x", false)
end
---@param cond fun(): boolean
---@param msg string
local function wait_for(cond, msg)
h.truthy(vim.wait(1000, cond), "timed out waiting for: " .. msg)
end
h.test(
"stage with diff open: sidebar cursor stays put",
function()
install_cursor_restore_autocmd()
local sidebar_win, line = setup_sidebar_with_unstaged_file(
"zsh/rc",
"ZSH=true\n",
"ZSH=true\nmodified\n"
)
vim.api.nvim_set_current_win(sidebar_win)
vim.api.nvim_win_set_cursor(sidebar_win, { line, 0 })
press("<Tab>")
wait_for(function()
return find_diff_win("left") ~= nil
end, "diff windows to appear")
local r = assert(require("git.repo").find(vim.fn.getcwd()))
vim.api.nvim_set_current_win(sidebar_win)
press("s")
wait_for(function()
return #r.status:by_kind("staged") > 0
end, "stage to propagate to repo state")
h.eq(
vim.api.nvim_win_get_cursor(sidebar_win),
{ line, 0 },
"sidebar cursor should remain at the entry's original line"
)
end
)
h.test(
"stage with diff open: diff foldmethod is preserved on refresh",
function()
local sidebar_win, line = setup_sidebar_with_unstaged_file(
"zsh/rc",
"# vim: set ft=zsh nowrap:\nZSH=true\n",
"# vim: set ft=zsh nowrap:\nZSH=true\nmodified\n"
)
vim.api.nvim_set_current_win(sidebar_win)
vim.api.nvim_win_set_cursor(sidebar_win, { line, 0 })
press("<Tab>")
wait_for(function()
return find_diff_win("left") ~= nil
end, "diff windows to appear")
local left_win = assert(find_diff_win("left"))
h.eq(
vim.wo[left_win].foldmethod,
"diff",
"left diff foldmethod should be 'diff' after Tab"
)
local r = assert(require("git.repo").find(vim.fn.getcwd()))
vim.api.nvim_set_current_win(sidebar_win)
press("s")
wait_for(function()
return #r.status:by_kind("staged") > 0
end, "stage to propagate to repo state")
h.eq(
vim.wo[left_win].foldmethod,
"diff",
"left diff foldmethod should still be 'diff' after stage refresh"
)
end
)
h.test("refresh on stage updates the index URI buffer's content", function()
local sidebar_win, line = setup_sidebar_with_unstaged_file(
"foo.txt",
"v1\n",
"v2\n"
)
vim.api.nvim_set_current_win(sidebar_win)
vim.api.nvim_win_set_cursor(sidebar_win, { line, 0 })
press("<Tab>")
wait_for(function()
return find_diff_win("left") ~= nil
end, "diff windows to appear")
local left_win = assert(find_diff_win("left"))
local index_buf = vim.api.nvim_win_get_buf(left_win)
h.eq(
vim.api.nvim_buf_get_lines(index_buf, 0, -1, false),
{ "v1" },
"index pane should initially show committed content"
)
vim.api.nvim_set_current_win(sidebar_win)
press("s")
wait_for(function()
local first =
vim.api.nvim_buf_get_lines(index_buf, 0, -1, false)[1]
return first == "v2"
end, "index pane to refresh to staged content")
h.eq(
vim.api.nvim_buf_get_lines(index_buf, 0, -1, false),
{ "v2" },
"index pane should reflect staged content after refresh"
)
end)
h.report()
+47
View File
@@ -0,0 +1,47 @@
local h = require("helpers")
local util = require("git.util")
h.test("set_buf_lines preserves modifiable=false", function()
local buf = vim.api.nvim_create_buf(false, true)
vim.bo[buf].modifiable = false
util.set_buf_lines(buf, 0, -1, { "a", "b", "c" })
h.eq(
vim.api.nvim_buf_get_lines(buf, 0, -1, false),
{ "a", "b", "c" },
"lines should be replaced"
)
h.falsy(vim.bo[buf].modifiable, "modifiable should stay false")
h.falsy(vim.bo[buf].modified, "modified should be cleared")
vim.api.nvim_buf_delete(buf, { force = true })
end)
h.test("set_buf_lines preserves modifiable=true", function()
local buf = vim.api.nvim_create_buf(false, true)
vim.bo[buf].modifiable = true
util.set_buf_lines(buf, 0, -1, { "a", "b" })
h.truthy(vim.bo[buf].modifiable, "modifiable should stay true")
h.falsy(vim.bo[buf].modified, "modified should be cleared")
vim.api.nvim_buf_delete(buf, { force = true })
end)
h.test("set_buf_lines partial range update", function()
local buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(buf, 0, -1, false, { "a", "b", "c", "d" })
util.set_buf_lines(buf, 1, 3, { "X", "Y", "Z" })
h.eq(
vim.api.nvim_buf_get_lines(buf, 0, -1, false),
{ "a", "X", "Y", "Z", "d" },
"lines [1, 3) should be replaced"
)
vim.api.nvim_buf_delete(buf, { force = true })
end)
h.test("set_buf_lines errors on out-of-bounds (strict_indexing)", function()
local buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(buf, 0, -1, false, { "a", "b" })
local ok = pcall(util.set_buf_lines, buf, 100, 200, { "x" })
h.falsy(ok, "out-of-bounds index should error")
vim.api.nvim_buf_delete(buf, { force = true })
end)
h.report()
+191
View File
@@ -0,0 +1,191 @@
local M = {}
local stats = { passed = 0, failed = 0, errors = {} }
local pending_cleanup = {}
local started = false
local color_on = vim.env.TEST_COLOR == "1"
local function color(code, str)
if color_on then
return string.format("\27[%sm%s\27[0m", code, str)
end
return str
end
local function red(s)
return color("31", s)
end
local function green(s)
return color("32", s)
end
local function ensure_started()
if started then
return
end
started = true
io.stdout:write(
string.format("-> %s ", vim.env.TEST_FILE_LABEL or "?")
)
io.stdout:flush()
end
---Tear down repos created during the current test. Stops fs watchers first,
---drains any scheduled callbacks, wipes buffers tied to the repo, then nukes
---the directory.
---@param dir string
function M.cleanup(dir)
pcall(vim.cmd.cd, "/tmp")
pcall(function()
require("git.repo").stop_all()
end)
vim.wait(60)
for _, b in ipairs(vim.api.nvim_list_bufs()) do
local name = vim.api.nvim_buf_get_name(b)
if name:find(dir, 1, true) or name:match("^git[a-z]*://") then
pcall(vim.api.nvim_buf_delete, b, { force = true })
end
end
vim.fn.delete(dir, "rf")
end
---@param name string
---@param fn fun()
function M.test(name, fn)
ensure_started()
local ok, err = pcall(fn)
while #pending_cleanup > 0 do
local dir = table.remove(pending_cleanup)
pcall(M.cleanup, dir)
end
if ok then
stats.passed = stats.passed + 1
io.stdout:write(".")
else
stats.failed = stats.failed + 1
table.insert(stats.errors, { name = name, err = err })
io.stdout:write(red("F"))
end
io.stdout:flush()
end
function M.report()
ensure_started()
if stats.failed > 0 then
io.stdout:write(" " .. red("FAIL") .. "\n")
for _, e in ipairs(stats.errors) do
io.stdout:write(
string.format(
" %s %s\n %s\n",
red("FAIL"),
e.name,
e.err
)
)
end
else
io.stdout:write(" " .. green("OK") .. "\n")
end
io.stdout:write(
string.format("RESULTS %d %d\n", stats.passed, stats.failed)
)
if stats.failed > 0 then
vim.cmd("cquit 1")
end
end
local function fmt_value(v)
if type(v) == "string" then
return string.format("%q", v)
end
return vim.inspect(v)
end
---@param actual any
---@param expected any
---@param msg string?
function M.eq(actual, expected, msg)
if not vim.deep_equal(actual, expected) then
error(
string.format(
"%s\n expected: %s\n actual: %s",
msg or "values differ",
fmt_value(expected),
fmt_value(actual)
),
2
)
end
end
---@param val any
---@param msg string?
function M.truthy(val, msg)
if not val then
error(msg or ("expected truthy, got " .. tostring(val)), 2)
end
end
---@param val any
---@param msg string?
function M.falsy(val, msg)
if val then
error(msg or ("expected falsy, got " .. tostring(val)), 2)
end
end
---@param dir string
---@param ... string
local function git(dir, ...)
local r = vim.system({ "git", ... }, { cwd = dir, text = true }):wait()
if r.code ~= 0 then
error(
string.format(
"git %s failed: %s",
table.concat({ ... }, " "),
vim.trim(r.stderr or "")
),
2
)
end
return r
end
---@param dir string
---@param path string
---@param content string
function M.write(dir, path, content)
local full = vim.fs.joinpath(dir, path)
vim.fn.mkdir(vim.fs.dirname(full), "p")
local f = assert(io.open(full, "w"))
f:write(content)
f:close()
end
---Create a temporary git repo and seed it with files committed on `main`.
---The directory is auto-cleaned at the end of the current `M.test(...)`.
---@param files table<string, string>? path -> content
---@return string dir
---@return fun(...): vim.SystemCompleted runner
function M.make_repo(files)
local dir = vim.fn.tempname()
vim.fn.mkdir(dir, "p")
git(dir, "init", "-q", "-b", "main")
git(dir, "config", "user.email", "t@t.com")
git(dir, "config", "user.name", "t")
if files and next(files) then
for path, content in pairs(files) do
M.write(dir, path, content)
end
git(dir, "add", ".")
git(dir, "commit", "-q", "-m", "init")
end
table.insert(pending_cleanup, dir)
return dir, function(...)
return git(dir, ...)
end
end
return M
Executable
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env bash
set -u
usage() {
cat <<EOF
Usage: ${0##*/} [--help] [TARGET ...]
Run Neovim integration tests. Each test file is invoked via
'nvim --headless --clean' with the project's runtimepath added.
With no targets, runs every test/**/*_test.lua. Each TARGET may be a
test file (test/git/util_test.lua) or a directory (test/git); a
directory expands to all _test.lua files beneath it.
Exit status is 0 if all tests passed, non-zero otherwise.
EOF
}
if [[ ${1:-} == --help || ${1:-} == -h ]]; then
usage
exit 0
fi
cd "$(dirname "$0")/.." || exit 1
ROOT=$PWD
if [[ -t 1 ]]; then
red=$'\033[31m'
green=$'\033[32m'
reset=$'\033[0m'
export TEST_COLOR=1
else
red=''
green=''
reset=''
fi
if [[ $# -gt 0 ]]; then
targets=("$@")
else
mapfile -d '' -t targets < <(find test -type f -name '*_test.lua' -print0 | sort -z)
fi
exit_code=0
total_passed=0
total_failed=0
for arg in "${targets[@]}"; do
if [[ -d "$arg" ]]; then
mapfile -d '' -t files < <(find "$arg" -type f -name '*_test.lua' -print0 | sort -z)
elif [[ -f "$arg" ]]; then
files=("$arg")
else
printf 'no such test target: %s\n' "$arg" >&2
exit_code=2
continue
fi
for f in "${files[@]}"; do
out=$(TEST_FILE_LABEL="$f" nvim --headless --clean \
-c "set rtp+=$ROOT" \
-c "lua package.path = package.path .. ';$ROOT/test/?.lua'" \
-c "luafile $f" \
-c "qa!" \
2>&1)
rc=$?
# Split the per-file results from any output.
results_line=$(printf '%s\n' "$out" | grep '^RESULTS ' | tail -1)
printf '%s\n' "$out" | grep -v '^RESULTS '
if [[ -n "$results_line" ]]; then
read -r _ p f_count <<<"$results_line"
total_passed=$((total_passed + p))
total_failed=$((total_failed + f_count))
fi
if [[ $rc -ne 0 ]]; then
exit_code=$rc
fi
done
done
printf '\n'
if [[ $total_failed -gt 0 ]]; then
printf '%s%d passed%s, %s%d failed%s\n' \
"$green" "$total_passed" "$reset" \
"$red" "$total_failed" "$reset"
else
printf '%s%d passed%s\n' "$green" "$total_passed" "$reset"
fi
exit "$exit_code"