diff --git a/test/git/status_view_test.lua b/test/git/status_view_test.lua new file mode 100644 index 0000000..913506d --- /dev/null +++ b/test/git/status_view_test.lua @@ -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("") + 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("") + 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("") + 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() diff --git a/test/git/util_test.lua b/test/git/util_test.lua new file mode 100644 index 0000000..a16b9c6 --- /dev/null +++ b/test/git/util_test.lua @@ -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() diff --git a/test/helpers.lua b/test/helpers.lua new file mode 100644 index 0000000..fa15c01 --- /dev/null +++ b/test/helpers.lua @@ -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? 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 diff --git a/test/run.sh b/test/run.sh new file mode 100755 index 0000000..849c270 --- /dev/null +++ b/test/run.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +set -u + +usage() { + cat <&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"