diff --git a/test/git/cmd_test.lua b/test/git/cmd_test.lua index 5c5cbd7..28efd6e 100644 --- a/test/git/cmd_test.lua +++ b/test/git/cmd_test.lua @@ -1,6 +1,25 @@ local t = require("test") +local helpers = require("test.git.helpers") local cmd = require("git.cmd") +require("git").init() + +local git = helpers.git + +---@param files table? +---@return string dir +local function make_repo(files) + return helpers.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"), @@ -129,3 +148,89 @@ t.test("positional_index ignores flags", function() 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" }) + git(dir, "branch", "feature") + git(dir, "tag", "v1") + t.write(dir, "a", "modified") + 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" }) + git(dir, "branch", "feature") + t.write(dir, "a", "y") + 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" }) + git(dir, "remote", "add", "origin", "/tmp/nope") + 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" }) + 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() + vim.wait(500, function() + return r.status and #vim.tbl_keys(r.status.entries) > 0 + end) + 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() + local dir = 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() + local dir = 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() + local dir = make_repo({ a = "x", b = "y" }) + local matches = cmd.complete("", "G nonexistent ", 14) + eq_sorted(matches, { "a", "b" }) +end) diff --git a/test/git/helpers.lua b/test/git/helpers.lua new file mode 100644 index 0000000..0730671 --- /dev/null +++ b/test/git/helpers.lua @@ -0,0 +1,69 @@ +local t = require("test") + +local M = {} + +---@param dir string +---@vararg string +---@return vim.SystemCompleted +function M.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 + +---Build a temporary git repo with the given committed contents and queue +---cleanup (stop fs watchers, drop test buffers, delete the dir). When +---`opts.cd` is true, also `cd` into the repo and restore the previous +---working directory on cleanup. +---@param files table? +---@param opts { cd: boolean? }? +---@return string dir +function M.make_repo(files, opts) + local dir = vim.fn.tempname() + vim.fn.mkdir(dir, "p") + M.git(dir, "init", "-q", "-b", "main") + M.git(dir, "config", "user.email", "t@t.com") + M.git(dir, "config", "user.name", "t") + if files and next(files) then + for path, content in pairs(files) do + t.write(dir, path, content) + end + M.git(dir, "add", ".") + M.git(dir, "commit", "-q", "-m", "init") + end + local prev_cwd + if opts and opts.cd then + prev_cwd = vim.fn.getcwd() + vim.cmd.cd(dir) + end + t.defer(function() + if prev_cwd then + pcall(vim.cmd.cd, prev_cwd) + else + pcall(vim.cmd.cd, "/tmp") + end + 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) + return dir +end + +return M diff --git a/test/git/repo_test.lua b/test/git/repo_test.lua new file mode 100644 index 0000000..0dfb35d --- /dev/null +++ b/test/git/repo_test.lua @@ -0,0 +1,131 @@ +local t = require("test") +local helpers = require("test.git.helpers") + +require("git").init() + +local git = helpers.git +local make_repo = helpers.make_repo + +---@param r ow.Git.Repo +---@param key string +---@param timeout integer? +local function wait_cleared(r, key, timeout) + vim.wait(timeout or 2000, function() + return r._cache[key] == nil + end) +end + +t.test("list_refs returns heads, tags, remotes (no HEAD)", function() + local dir = make_repo({ a = "x" }) + git(dir, "branch", "feature") + git(dir, "tag", "v1") + local r = assert(require("git.repo").resolve(dir)) + local refs = r:list_refs() + table.sort(refs) + t.eq(refs, { "feature", "main", "v1" }) +end) + +t.test("list_pseudo_refs always includes HEAD", function() + local dir = make_repo({ a = "x" }) + local r = assert(require("git.repo").resolve(dir)) + t.eq(r:list_pseudo_refs(), { "HEAD" }) +end) + +t.test("list_pseudo_refs picks up MERGE_HEAD when present", function() + local dir = make_repo({ a = "x" }) + local r = assert(require("git.repo").resolve(dir)) + t.write(dir .. "/.git", "MERGE_HEAD", "deadbeef\n") + -- Bypass cache (file appeared after first scan). + r._cache = {} + local refs = r:list_pseudo_refs() + table.sort(refs) + t.eq(refs, { "HEAD", "MERGE_HEAD" }) +end) + +t.test("list_stash_refs is empty when no stash", function() + local dir = make_repo({ a = "x" }) + local r = assert(require("git.repo").resolve(dir)) + t.eq(r:list_stash_refs(), {}) +end) + +t.test("list_stash_refs lists stash + entries when stash exists", function() + local dir = make_repo({ a = "x" }) + t.write(dir, "a", "modified") + git(dir, "stash") + local r = assert(require("git.repo").resolve(dir)) + local refs = r:list_stash_refs() + t.eq(#refs, 2) + t.eq(refs[1], "stash") + t.eq(refs[2], "stash@{0}") +end) + +t.test("get_cached memoizes by key", function() + local dir = make_repo({ a = "x" }) + local r = assert(require("git.repo").resolve(dir)) + local calls = 0 + local v1 = r:get_cached("k", function() + calls = calls + 1 + return { "first" } + end) + local v2 = r:get_cached("k", function() + calls = calls + 1 + return { "second" } + end) + t.eq(calls, 1) + t.truthy(v1 == v2, "second call should return cached table") +end) + +t.test("cache clears after top-level .git change (commit)", function() + local dir = make_repo({ a = "x" }) + local r = assert(require("git.repo").resolve(dir)) + local _ = r:list_refs() + t.truthy(r._cache.refs) + t.write(dir, "b", "y") + git(dir, "add", "b") + git(dir, "commit", "-q", "-m", "two") + wait_cleared(r, "refs") + t.falsy(r._cache.refs, "cache should be cleared after commit") +end) + +t.test("cache clears after slash-branch creation (polyfill)", function() + local dir = make_repo({ a = "x" }) + local r = assert(require("git.repo").resolve(dir)) + local _ = r:list_refs() + t.truthy(r._cache.refs) + git(dir, "branch", "feat/foo") + wait_cleared(r, "refs") + t.falsy(r._cache.refs, "cache should clear via polyfilled subdir watcher") + local refs = r:list_refs() + table.sort(refs) + t.eq(refs, { "feat/foo", "main" }) +end) + +t.test("cache clears after deeply nested slash branch", function() + local dir = make_repo({ a = "x" }) + local r = assert(require("git.repo").resolve(dir)) + local _ = r:list_refs() + git(dir, "branch", "deep/a/b/c") + wait_cleared(r, "refs") + local refs = r:list_refs() + table.sort(refs) + t.eq(refs, { "deep/a/b/c", "main" }) +end) + +t.test("watcher cleans up after a slash-branch dir is removed", function() + local dir = make_repo({ a = "x" }) + local r = assert(require("git.repo").resolve(dir)) + git(dir, "branch", "feat/foo") + -- Wait for the dynamic watcher on .git/refs/heads/feat to be added. + local feat_path = dir .. "/.git/refs/heads/feat" + vim.wait(2000, function() + return r._watchers[feat_path] ~= nil + end) + t.truthy(r._watchers[feat_path], "feat/ subdir should be watched") + -- Remove the branch; the feat/ directory becomes empty and is + -- pruned by git, triggering the deleted-self event. + git(dir, "branch", "-D", "feat/foo") + vim.wait(2000, function() + return r._watchers[feat_path] == nil + end) + t.falsy(r._watchers[feat_path], "watcher should self-close") +end) diff --git a/test/git/status_view_test.lua b/test/git/status_view_test.lua index d577240..bc1b933 100644 --- a/test/git/status_view_test.lua +++ b/test/git/status_view_test.lua @@ -1,57 +1,10 @@ local t = require("test") +local helpers = require("test.git.helpers") require("git").init() ----@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 - ----Build a temporary git repo with the given committed contents and ----queue cleanup (stop fs watchers, drop test buffers, delete the dir). ----@param files table? ----@return string dir -local function 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 - t.write(dir, path, content) - end - git(dir, "add", ".") - git(dir, "commit", "-q", "-m", "init") - end - t.defer(function() - 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) - return dir -end +local git = helpers.git +local make_repo = helpers.make_repo ---Replicate the user's global cursor-restore autocmd. Scoped to a ---named augroup + cleanup so it doesn't leak between tests.