chore: initial commit

This commit is contained in:
2026-05-26 17:01:44 +02:00
commit 914aa493ff
33 changed files with 10008 additions and 0 deletions
+312
View File
@@ -0,0 +1,312 @@
local blame = require("git.blame")
local h = require("test.git.helpers")
local t = require("test")
---@param sha string
---@return boolean
local function is_zero(sha)
return sha:match("^0+$") ~= nil
end
---@param committed string
---@param worktree string?
---@param file string?
---@return string dir
---@return integer buf
local function setup(committed, worktree, file)
file = file or "a.txt"
local dir = h.make_repo({ [file] = committed })
if worktree then
t.write(dir, file, worktree)
end
vim.cmd.edit(dir .. "/" .. file)
return dir, vim.api.nvim_get_current_buf()
end
---@return integer?
local function find_float()
for _, w in ipairs(vim.api.nvim_tabpage_list_wins(0)) do
if vim.api.nvim_win_get_config(w).relative ~= "" then
return w
end
end
end
---@return integer float
local function wait_float()
local float ---@type integer?
t.wait_for(function()
float = find_float()
return float ~= nil
end, "blame popup float to open")
local found = assert(float)
return found
end
---@param pat string
local function wait_buf_name(pat)
t.wait_for(function()
return vim.api.nvim_buf_get_name(0):match(pat) ~= nil
end, "current buffer name to match " .. pat)
end
---@param buf integer
---@return ow.Git.Blame.BufState
local function populate_blame(buf)
vim.api.nvim_set_current_buf(buf)
vim.api.nvim_win_set_cursor(0, { 1, 0 })
local tick = vim.api.nvim_buf_get_changedtick(buf)
blame.line_popup(buf)
t.wait_for(function()
local s = blame.state(buf)
return s ~= nil and s.tick == tick
end, "blame to populate the buffer state")
for _, w in ipairs(vim.api.nvim_tabpage_list_wins(0)) do
if vim.api.nvim_win_get_config(w).relative ~= "" then
pcall(vim.api.nvim_win_close, w, true)
end
end
return (assert(blame.state(buf)))
end
t.test("line popup formats the datetime in the author timezone", function()
local _, buf = setup("alpha\nbeta\n")
vim.api.nvim_set_current_buf(buf)
vim.api.nvim_win_set_cursor(0, { 1, 0 })
blame.line_popup(buf)
local float = wait_float()
t.defer(function()
pcall(vim.api.nvim_win_close, float, true)
end)
local lines = vim.api.nvim_buf_get_lines(
vim.api.nvim_win_get_buf(float),
0,
-1,
false
)
t.truthy(
(lines[1] or ""):match(
"%d%d%d%d%-%d%d%-%d%d %d%d:%d%d:%d%d [+%-]%d%d%d%d$"
),
"the head line ends with an ISO datetime and numeric timezone"
)
end)
t.test("porcelain parse of a committed file", function()
local _, buf = setup("alpha\nbeta\ngamma\n")
local state = populate_blame(buf)
t.eq(vim.tbl_count(state.commits), 1, "one commit")
local sha = state.line_sha[1]
t.eq(state.line_sha[2], sha, "line 2 shares the commit")
t.eq(state.line_sha[3], sha, "line 3 shares the commit")
local commit = state.commits[sha]
t.eq(commit.author, "t", "author parsed from the porcelain")
t.eq(commit.summary, "init", "summary parsed from the porcelain")
t.truthy(#sha >= 40, "the full sha is recorded")
t.truthy(commit.author_time > 0, "author time parsed")
end)
t.test("multiple line groups reuse one commit entry", function()
local dir = h.make_repo({ ["a.txt"] = "a\nb\nc\n" })
t.write(dir, "a.txt", "a\nB\nc\n")
h.git(dir, "add", "a.txt")
h.git(dir, "commit", "-q", "-m", "change middle")
vim.cmd.edit(dir .. "/a.txt")
local buf = vim.api.nvim_get_current_buf()
local state = populate_blame(buf)
t.eq(vim.tbl_count(state.commits), 2, "two distinct commits")
t.eq(
state.line_sha[1],
state.line_sha[3],
"lines 1 and 3 share the original commit"
)
t.truthy(
state.line_sha[1] ~= state.line_sha[2],
"line 2 is a different commit"
)
end)
t.test("an edited line blames as the zero sha", function()
local _, buf = setup("a\nb\nc\n")
local state = populate_blame(buf)
t.falsy(is_zero(state.line_sha[2]), "line 2 starts committed")
vim.api.nvim_buf_set_lines(buf, 1, 2, false, { "EDITED" })
vim.api.nvim_win_set_cursor(0, { 2, 0 })
local refreshed = populate_blame(buf)
t.truthy(
is_zero(refreshed.line_sha[2]),
"the edited line blames as uncommitted on the next fetch"
)
end)
t.test("blame picks up a commit amend on the next fetch", function()
local dir, buf = setup("original\n")
local state = populate_blame(buf)
local sha1 = state.line_sha[1]
h.git(dir, "commit", "--amend", "-m", "amended")
local sha2 = h.git(dir, "rev-parse", "HEAD").stdout
t.truthy(sha1 ~= sha2, "the amend produced a new commit")
t.wait_for(function()
return assert(blame.state(buf)).tick == nil
end, "the cache to be invalidated by the repo change")
local refreshed = populate_blame(buf)
t.eq(refreshed.line_sha[1], sha2, "blame picks up the amended sha")
end)
t.test("an untracked file blames every line as uncommitted", function()
local dir = h.make_repo({ ["tracked.txt"] = "x\n" })
t.write(dir, "new.txt", "one\ntwo\nthree\n")
vim.cmd.edit(dir .. "/new.txt")
local buf = vim.api.nvim_get_current_buf()
local state = populate_blame(buf)
for i = 1, 3 do
t.truthy(is_zero(state.line_sha[i]), "line " .. i .. " is uncommitted")
end
t.eq(vim.tbl_count(state.commits), 1, "one synthesized commit")
end)
t.test("blame actions are no-ops off a worktree", function()
local buf = vim.api.nvim_create_buf(true, false)
t.defer(function()
pcall(vim.api.nvim_buf_delete, buf, { force = true })
end)
t.quietly(function()
blame.line_popup(buf)
end)
t.eq(blame.state(buf), nil, "no state created for a non-worktree buffer")
end)
t.test("line popup shows the commit for the cursor line", function()
local dir, buf = setup("alpha\nbeta\ngamma\n")
local sha = h.git(dir, "rev-parse", "HEAD").stdout
vim.api.nvim_set_current_buf(buf)
vim.api.nvim_win_set_cursor(0, { 1, 0 })
blame.line_popup(buf)
local float = wait_float()
t.defer(function()
pcall(vim.api.nvim_win_close, float, true)
end)
local lines = vim.api.nvim_buf_get_lines(
vim.api.nvim_win_get_buf(float),
0,
-1,
false
)
t.truthy(
vim.startswith(lines[1] or "", sha:sub(1, 8)),
"first line starts with the short sha"
)
t.truthy((lines[1] or ""):find("t", 1, true), "author shown")
end)
t.test("re-invoking the line popup focuses the open float", function()
local _, buf = setup("alpha\nbeta\n")
vim.api.nvim_set_current_buf(buf)
vim.api.nvim_win_set_cursor(0, { 1, 0 })
blame.line_popup(buf)
local float = wait_float()
t.defer(function()
pcall(vim.api.nvim_win_close, float, true)
end)
t.truthy(
vim.api.nvim_get_current_win() ~= float,
"the float opens unfocused"
)
blame.line_popup(buf)
t.eq(
vim.api.nvim_get_current_win(),
float,
"re-invoking focuses the existing float"
)
end)
t.test("line popup works in a git:// object buffer", function()
local dir = h.make_repo({ ["a.txt"] = "alpha\nbeta\ngamma\n" })
local sha = h.git(dir, "rev-parse", "HEAD").stdout
local r = assert(require("git.core.repo").resolve(dir))
local gbuf = require("git.object").buf_for(
r,
require("git.core.revision").new({ base = sha, path = "a.txt" })
)
vim.api.nvim_set_current_buf(gbuf)
vim.api.nvim_win_set_cursor(0, { 2, 0 })
blame.line_popup(gbuf)
local float = wait_float()
t.defer(function()
pcall(vim.api.nvim_win_close, float, true)
end)
local lines = vim.api.nvim_buf_get_lines(
vim.api.nvim_win_get_buf(float),
0,
-1,
false
)
t.truthy(
vim.startswith(lines[1] or "", sha:sub(1, 8)),
"the popup blames the commit even in a git:// buffer"
)
end)
t.test("open_commit opens the commit that last touched the line", function()
local _, buf = setup("alpha\nbeta\ngamma\n")
vim.api.nvim_set_current_buf(buf)
vim.api.nvim_win_set_cursor(0, { 1, 0 })
blame.open_commit()
wait_buf_name("^git://%x+$")
end)
t.test("open_file opens the file at the line's commit", function()
local _, buf = setup("alpha\nbeta\ngamma\n")
vim.api.nvim_set_current_buf(buf)
vim.api.nvim_win_set_cursor(0, { 2, 0 })
blame.open_file()
wait_buf_name("^git://%x+:a%.txt$")
end)
t.test("open_file_parent opens the file at the parent commit", function()
local dir = h.make_repo({ ["a.txt"] = "a\nb\nc\n" })
local root = h.git(dir, "rev-parse", "HEAD").stdout
t.write(dir, "a.txt", "a\nB\nc\n")
h.git(dir, "add", "a.txt")
h.git(dir, "commit", "-q", "-m", "change middle")
vim.cmd.edit(dir .. "/a.txt")
vim.api.nvim_win_set_cursor(0, { 2, 0 })
blame.open_file_parent()
t.wait_for(function()
return vim.api.nvim_buf_get_name(0) == "git://" .. root .. ":a.txt"
end, "the file at the parent commit to open")
end)
t.test("the drill actions refuse an uncommitted line", function()
local _, buf = setup("a\nb\nc\n")
vim.api.nvim_set_current_buf(buf)
vim.api.nvim_buf_set_lines(buf, 1, 2, false, { "EDITED" })
vim.api.nvim_win_set_cursor(0, { 2, 0 })
t.quietly(function()
blame.open_commit()
vim.wait(200)
end)
t.eq(
vim.api.nvim_get_current_buf(),
buf,
"no commit opened for an uncommitted line"
)
end)
t.test("drilling chains through git:// buffers", function()
local _, buf = setup("alpha\nbeta\ngamma\n")
vim.api.nvim_set_current_buf(buf)
vim.api.nvim_win_set_cursor(0, { 1, 0 })
blame.open_file()
wait_buf_name("^git://%x+:a%.txt$")
vim.api.nvim_win_set_cursor(0, { 1, 0 })
blame.open_commit()
wait_buf_name("^git://%x+$")
end)
t.test("detach drops the blame state", function()
local _, buf = setup("a\nb\nc\n")
populate_blame(buf)
blame.detach(buf)
t.eq(blame.state(buf), nil, "state dropped on detach")
end)
+668
View File
@@ -0,0 +1,668 @@
local cmd = require("git.cmd")
local h = require("test.git.helpers")
local t = require("test")
---@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.core.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.core.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.core.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.core.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 -1
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
---@param calls { history: boolean }[]
---@return boolean
local function any_history(calls)
for _, c in ipairs(calls) do
if c.history then
return true
end
end
return false
end
t.test("quiet :G success does not add to :messages history", function()
make_repo({ a = "x" })
with_echo_stub(function(calls)
cmd.run({ "config", "user.email" })
t.falsy(any_history(calls), "success path must not write history")
end)
end)
t.test("quiet :G silent failure adds 'git exited N' to history", function()
make_repo({ a = "x" })
with_echo_stub(function(calls)
cmd.run({ "config", "--get", "nonexistent.foo.bar" })
t.truthy(any_history(calls), "failure path must write history")
end)
end)
t.test("quiet :G stderr failure adds error to history", function()
make_repo({ a = "x" })
with_echo_stub(function(calls)
cmd.run({ "branch", "-d", "nonexistent-branch" })
t.truthy(any_history(calls), "failure path must write history")
end)
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
)
+97
View File
@@ -0,0 +1,97 @@
local t = require("test")
local M = {}
---@class test.git.SystemCompleted : vim.SystemCompleted
---@field stdout string
---@param dir string
---@return test.git.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
if r.stdout then
r.stdout = vim.trim(r.stdout)
else
r.stdout = ""
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<string, string>?
---@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.core.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
---Build an outer repo with one nested submodule at `sub/`. Both the
---outer and inner repo are committed and registered for cleanup.
---@return string outer
---@return string inner
function M.make_submodule_repo()
local inner = M.make_repo({ a = "x\n" })
local outer = M.make_repo({ x = "x\n" })
vim.system({
"git",
"-c",
"protocol.file.allow=always",
"submodule",
"add",
"--quiet",
inner,
"sub",
}, { cwd = outer, text = true }):wait()
M.git(outer, "commit", "-q", "-m", "add submodule")
return outer, inner
end
return M
+634
View File
@@ -0,0 +1,634 @@
local h = require("test.git.helpers")
local hunks = require("git.hunks")
local t = require("test")
---@param committed string
---@param worktree string
---@param file string?
---@return string dir
---@return integer buf
---@return ow.Git.Hunks.BufState state
local function setup(committed, worktree, file)
file = file or "a.txt"
local dir = h.make_repo({ [file] = committed })
t.write(dir, file, worktree)
vim.cmd.edit(dir .. "/" .. file)
local buf = vim.api.nvim_get_current_buf()
hunks.attach(buf)
hunks._flush(buf)
t.wait_for(function()
local s = hunks.state(buf)
return s ~= nil and s.index ~= nil and s.head ~= nil
end, "hunks to load the index and HEAD snapshots")
local state = assert(hunks.state(buf), "buffer state should exist")
return dir, buf, state
end
---@param buf integer
---@return { row: integer, sign: string, hl: string }[]
local function sign_marks(buf)
local ns = vim.api.nvim_get_namespaces()["ow.git.hunks"]
local out = {}
for _, m in ipairs(vim.api.nvim_buf_get_extmarks(buf, ns, 0, -1, {
details = true,
})) do
local d = assert(m[4])
table.insert(out, {
row = m[2],
sign = vim.trim(d.sign_text or ""),
hl = d.sign_hl_group,
})
end
table.sort(out, function(a, b)
return a.row < b.row
end)
return out
end
---@param buf integer
---@param ns_name string
---@return vim.api.keyset.get_extmark_item[]
local function detailed_marks(buf, ns_name)
local ns = vim.api.nvim_get_namespaces()[ns_name]
return vim.api.nvim_buf_get_extmarks(buf, ns, 0, -1, { details = true })
end
---@return integer?
local function find_float()
for _, w in ipairs(vim.api.nvim_tabpage_list_wins(0)) do
if vim.api.nvim_win_get_config(w).relative ~= "" then
return w
end
end
end
t.test("pure add: hunk shape and add signs", function()
local _, buf, state = setup("a\nd\n", "a\nb\nc\nd\n")
t.eq(#state.hunks, 1, "one hunk for a pure addition")
local hk = assert(state.hunks[1])
t.eq(hk.type, "add")
t.eq(hk.new_start, 2)
t.eq(hk.new_count, 2)
t.eq(sign_marks(buf), {
{ row = 1, sign = "", hl = "GitHunkAdded" },
{ row = 2, sign = "", hl = "GitHunkAdded" },
})
end)
t.test("pure delete (middle): hunk shape and delete sign", function()
local _, buf, state = setup("a\nb\nc\n", "a\nc\n")
t.eq(#state.hunks, 1)
local hk = assert(state.hunks[1])
t.eq(hk.type, "delete")
t.eq(hk.new_count, 0)
t.eq(hk.old_lines, { "b" })
t.eq(sign_marks(buf), {
{ row = 0, sign = "", hl = "GitHunkRemoved" },
})
end)
t.test("top-of-file delete: sign anchors on line 1", function()
local _, buf, state = setup("a\nb\nc\n", "b\nc\n")
t.eq(#state.hunks, 1)
local hk = assert(state.hunks[1])
t.eq(hk.type, "delete")
t.eq(hk.new_start, 0)
t.eq(hk.old_lines, { "a" })
t.eq(sign_marks(buf), {
{ row = 0, sign = "", hl = "GitHunkRemoved" },
})
end)
t.test("change of N lines: hunk shape and change signs", function()
local _, buf, state = setup("a\nb\nc\nd\n", "a\nB\nC\nd\n")
t.eq(#state.hunks, 1)
local hk = assert(state.hunks[1])
t.eq(hk.type, "change")
t.eq(hk.old_start, 2)
t.eq(hk.old_count, 2)
t.eq(hk.new_start, 2)
t.eq(hk.new_count, 2)
t.eq(hk.old_lines, { "b", "c" })
t.eq(hk.new_lines, { "B", "C" })
t.eq(sign_marks(buf), {
{ row = 1, sign = "", hl = "GitHunkChanged" },
{ row = 2, sign = "", hl = "GitHunkChanged" },
})
end)
t.test("multi-hunk file: two separate change hunks", function()
local _, buf, state = setup("a\nb\nc\nd\ne\n", "A\nb\nc\nd\nE\n")
t.eq(#state.hunks, 2, "two hunks for two disjoint changes")
t.eq(sign_marks(buf), {
{ row = 0, sign = "", hl = "GitHunkChanged" },
{ row = 4, sign = "", hl = "GitHunkChanged" },
})
end)
t.test("clean file produces no hunks or signs", function()
local _, buf, state = setup("a\nb\nc\n", "a\nb\nc\n")
t.eq(#state.hunks, 0)
t.eq(sign_marks(buf), {})
end)
t.test("editing the buffer refreshes signs", function()
local _, buf, state = setup("a\nb\nc\n", "a\nb\nc\n")
t.eq(#state.hunks, 0)
vim.api.nvim_buf_set_lines(buf, 1, 2, false, { "CHANGED" })
vim.api.nvim_exec_autocmds("TextChanged", { buffer = buf })
hunks._flush(buf)
t.wait_for(function()
local s = assert(hunks.state(buf))
return #s.hunks == 1
end, "hunks to pick up the in-buffer edit")
local hk = assert(assert(hunks.state(buf)).hunks[1])
t.eq(hk.type, "change")
end)
t.test("overlay: change hunk shows deletion and addition", function()
local _, buf = setup("a\nb\nc\n", "a\nB\nc\n")
hunks.toggle_overlay(buf)
---@type integer?
local add_row
---@type vim.api.keyset.extmark_details?
local add_d
---@type vim.api.keyset.extmark_details?
local virt_d
for _, m in ipairs(detailed_marks(buf, "ow.git.hunks.overlay")) do
local d = assert(m[4])
if d.line_hl_group then
add_row, add_d = m[2], d
elseif d.virt_lines then
virt_d = d
end
end
add_d = assert(add_d, "the added line should get a line highlight")
t.eq(add_row, 1, "addition highlighted on the changed line")
t.eq(add_d.line_hl_group, "GitHunkAddLine")
virt_d = assert(virt_d, "the deletion should render as virtual lines")
local piece = assert(assert(assert(virt_d.virt_lines)[1])[1])
t.truthy(vim.startswith(piece[1], "b"), "deleted line shows the old content")
t.eq(piece[2], "GitHunkDeleteLine")
end)
t.test("overlay: delete hunk shows only deletion lines", function()
local _, buf = setup("a\nb\nc\n", "a\nc\n")
hunks.toggle_overlay(buf)
local marks = detailed_marks(buf, "ow.git.hunks.overlay")
t.eq(#marks, 1, "a pure delete has no addition highlight")
local d = assert(assert(marks[1])[4])
local piece = assert(assert(assert(d.virt_lines)[1])[1])
t.truthy(vim.startswith(piece[1], "b"))
t.eq(piece[2], "GitHunkDeleteLine")
end)
t.test("overlay: add hunk highlights the added lines", function()
local _, buf = setup("a\nd\n", "a\nb\nc\nd\n")
hunks.toggle_overlay(buf)
local rows = {}
for _, m in ipairs(detailed_marks(buf, "ow.git.hunks.overlay")) do
local d = assert(m[4])
t.falsy(d.virt_lines, "a pure add has no deletion virtual lines")
t.eq(d.line_hl_group, "GitHunkAddLine")
table.insert(rows, m[2])
end
table.sort(rows)
t.eq(rows, { 1, 2 }, "both added lines highlighted")
end)
t.test("overlay: deleted lines are treesitter-highlighted", function()
local _, buf = setup(
"-- a note\nlocal x = 1\nlocal y = 2\n",
"local y = 2\n",
"a.lua"
)
t.truthy(
pcall(vim.treesitter.start, buf, "lua"),
"the lua parser should be available"
)
hunks.toggle_overlay(buf)
---@type table[]?
local virt
for _, m in ipairs(detailed_marks(buf, "ow.git.hunks.overlay")) do
local d = assert(m[4])
if d.virt_lines then
virt = d.virt_lines
end
end
virt = assert(virt, "a deletion virtual line should render")
---@type table<string, boolean>
local seen = {}
for _, line in ipairs(virt) do
for _, c in ipairs(line) do
local hl = c[2]
if
type(hl) == "table"
and hl[1] == "GitHunkDeleteLine"
and hl[2]
then
seen[hl[2]] = true
end
end
end
t.truthy(seen["@comment"], "the deleted comment keeps its @comment group")
t.truthy(seen["@keyword"], "deleted code keeps its syntax groups")
end)
t.test("overlay: toggling swaps gutter signs for the overlay", function()
local _, buf = setup("a\nb\nc\n", "a\nB\nc\n")
t.truthy(
#detailed_marks(buf, "ow.git.hunks") > 0,
"gutter signs present while the overlay is off"
)
t.eq(#detailed_marks(buf, "ow.git.hunks.overlay"), 0)
hunks.toggle_overlay(buf)
t.truthy(
#detailed_marks(buf, "ow.git.hunks.overlay") > 0,
"overlay present once it is on"
)
t.eq(
#detailed_marks(buf, "ow.git.hunks"),
0,
"gutter signs replaced while the overlay is on"
)
hunks.toggle_overlay(buf)
t.eq(#detailed_marks(buf, "ow.git.hunks.overlay"), 0)
t.truthy(
#detailed_marks(buf, "ow.git.hunks") > 0,
"gutter signs restored after toggling the overlay off"
)
end)
t.test("toggle_stage stages the change into the index", function()
local dir, buf = setup("a\nb\nc\n", "a\nB\nc\n")
vim.api.nvim_win_set_cursor(0, { 2, 0 })
hunks.toggle_stage(buf)
t.wait_for(function()
return h.git(dir, "diff", "--cached", "--name-only").stdout ~= ""
end, "stage to land in the index")
t.eq(h.git(dir, "diff", "--cached", "--name-only").stdout, "a.txt")
t.eq(
h.git(dir, "show", ":0:a.txt").stdout,
"a\nB\nc",
"index blob reflects the staged change"
)
end)
t.test("toggle_stage stages a pure addition", function()
local dir, buf = setup("a\nb\n", "a\nb\nc\n")
vim.api.nvim_win_set_cursor(0, { 3, 0 })
hunks.toggle_stage(buf)
t.wait_for(function()
return h.git(dir, "diff", "--cached", "--name-only").stdout ~= ""
end, "stage to land in the index")
t.eq(h.git(dir, "show", ":0:a.txt").stdout, "a\nb\nc")
end)
t.test("toggle_stage stages a deletion", function()
local dir, buf = setup("a\nb\nc\n", "a\nc\n")
vim.api.nvim_win_set_cursor(0, { 1, 0 })
hunks.toggle_stage(buf)
t.wait_for(function()
return h.git(dir, "diff", "--cached", "--name-only").stdout ~= ""
end, "stage to land in the index")
t.eq(h.git(dir, "show", ":0:a.txt").stdout, "a\nc")
end)
t.test("toggle_stage stages only the hunk under the cursor", function()
local committed = table.concat({
"local M = {}",
"",
"function M.first()",
" return 1",
"end",
"",
"function M.last()",
" return 9",
"end",
"",
"return M",
}, "\n") .. "\n"
local worktree = table.concat({
"local M = {}",
"",
"-- helpers",
"function M.first()",
" return 1",
"end",
"",
"function M.mid()",
" return 5",
"end",
"",
"function M.last()",
" return 9",
"end",
"",
"return M",
}, "\n") .. "\n"
local dir, buf = setup(committed, worktree)
vim.api.nvim_win_set_cursor(0, { 9, 0 })
hunks.toggle_stage(buf)
t.wait_for(function()
return h.git(dir, "diff", "--cached", "--name-only").stdout ~= ""
end, "the mid hunk to land in the index")
t.eq(
h.git(dir, "show", ":0:a.txt").stdout,
table.concat({
"local M = {}",
"",
"function M.first()",
" return 1",
"end",
"",
"function M.mid()",
" return 5",
"end",
"",
"function M.last()",
" return 9",
"end",
"",
"return M",
}, "\n"),
"only the cursor's hunk is staged, placed at the right line"
)
end)
t.test("toggle_stage stages a whole-file change with no context", function()
local dir, buf = setup("a\nb\nc\n", "x\ny\nz\n")
vim.api.nvim_win_set_cursor(0, { 2, 0 })
hunks.toggle_stage(buf)
t.wait_for(function()
return h.git(dir, "diff", "--cached", "--name-only").stdout ~= ""
end, "the change to land in the index")
t.eq(h.git(dir, "show", ":0:a.txt").stdout, "x\ny\nz")
end)
t.test("toggle_stage stages a change at the start of the file", function()
local dir, buf = setup("a\nb\nc\nd\ne\n", "A\nb\nc\nd\ne\n")
vim.api.nvim_win_set_cursor(0, { 1, 0 })
hunks.toggle_stage(buf)
t.wait_for(function()
return h.git(dir, "diff", "--cached", "--name-only").stdout ~= ""
end, "the change to land in the index")
t.eq(h.git(dir, "show", ":0:a.txt").stdout, "A\nb\nc\nd\ne")
end)
t.test("toggle_stage stages a change at the end of the file", function()
local dir, buf = setup("a\nb\nc\nd\ne\n", "a\nb\nc\nd\nE\n")
vim.api.nvim_win_set_cursor(0, { 5, 0 })
hunks.toggle_stage(buf)
t.wait_for(function()
return h.git(dir, "diff", "--cached", "--name-only").stdout ~= ""
end, "the change to land in the index")
t.eq(h.git(dir, "show", ":0:a.txt").stdout, "a\nb\nc\nd\nE")
end)
t.test("toggle_stage stages a deletion at the start of the file", function()
local dir, buf = setup("a\nb\nc\nd\n", "b\nc\nd\n")
vim.api.nvim_win_set_cursor(0, { 1, 0 })
hunks.toggle_stage(buf)
t.wait_for(function()
return h.git(dir, "diff", "--cached", "--name-only").stdout ~= ""
end, "the deletion to land in the index")
t.eq(h.git(dir, "show", ":0:a.txt").stdout, "b\nc\nd")
end)
t.test("toggle_stage leaves an adjacent unstaged hunk in place", function()
local dir, buf = setup("a\nb\nc\nd\ne\n", "A\nb\nC\nd\ne\n")
vim.api.nvim_win_set_cursor(0, { 3, 0 })
hunks.toggle_stage(buf)
t.wait_for(function()
return h.git(dir, "diff", "--cached", "--name-only").stdout ~= ""
end, "the line-3 hunk to land in the index")
t.eq(
h.git(dir, "show", ":0:a.txt").stdout,
"a\nb\nC\nd\ne",
"only line 3 is staged; the adjacent line-1 hunk is untouched"
)
end)
t.test("toggle_stage unstages one of two adjacent staged hunks", function()
local dir, buf = setup("a\nb\nc\nd\ne\n", "A\nb\nC\nd\ne\n")
vim.api.nvim_win_set_cursor(0, { 1, 0 })
hunks.toggle_stage(buf)
t.wait_for(function()
return #assert(hunks.state(buf)).staged == 1
end, "the line-1 hunk to be staged")
vim.api.nvim_win_set_cursor(0, { 3, 0 })
hunks.toggle_stage(buf)
t.wait_for(function()
local s = assert(hunks.state(buf))
return #s.hunks == 0 and #s.staged == 2
end, "both hunks to be staged")
vim.api.nvim_win_set_cursor(0, { 3, 0 })
hunks.toggle_stage(buf)
t.wait_for(function()
return #assert(hunks.state(buf)).staged == 1
end, "the line-3 hunk to be unstaged again")
t.eq(
h.git(dir, "show", ":0:a.txt").stdout,
"A\nb\nc\nd\ne",
"line 3 reverts to HEAD while the staged line-1 change remains"
)
end)
t.test("toggle_stage refreshes the gutter when status stays modified", function()
local _, buf = setup("a\nb\nc\nd\ne\n", "A\nb\nC\nd\nE\n")
t.eq(#assert(hunks.state(buf)).hunks, 3)
vim.api.nvim_win_set_cursor(0, { 1, 0 })
hunks.toggle_stage(buf)
t.wait_for(function()
return #assert(hunks.state(buf)).hunks == 2
end, "gutter to drop the first staged hunk")
vim.api.nvim_win_set_cursor(0, { 3, 0 })
hunks.toggle_stage(buf)
t.wait_for(function()
return #assert(hunks.state(buf)).hunks == 1
end, "gutter to drop the middle staged hunk")
end)
t.test("staged hunks show with the staged highlight", function()
local _, buf = setup("a\nb\nc\n", "a\nB\nc\n")
vim.api.nvim_win_set_cursor(0, { 2, 0 })
hunks.toggle_stage(buf)
t.wait_for(function()
local s = assert(hunks.state(buf))
return #s.hunks == 0 and #s.staged == 1
end, "the hunk to move from unstaged to staged")
t.eq(sign_marks(buf), {
{ row = 1, sign = "", hl = "GitHunkStagedChanged" },
})
end)
t.test("the gutter shows staged and unstaged hunks together", function()
local _, buf = setup("a\nb\nc\nd\ne\n", "A\nb\nC\nd\nE\n")
vim.api.nvim_win_set_cursor(0, { 1, 0 })
hunks.toggle_stage(buf)
t.wait_for(function()
return #assert(hunks.state(buf)).hunks == 2
end, "the first hunk to leave the unstaged set")
t.eq(sign_marks(buf), {
{ row = 0, sign = "", hl = "GitHunkStagedChanged" },
{ row = 2, sign = "", hl = "GitHunkChanged" },
{ row = 4, sign = "", hl = "GitHunkChanged" },
})
end)
t.test("toggle_stage toggles a staged hunk back to unstaged", function()
local _, buf = setup("a\nb\nc\n", "a\nB\nc\n")
vim.api.nvim_win_set_cursor(0, { 2, 0 })
hunks.toggle_stage(buf)
t.wait_for(function()
local s = assert(hunks.state(buf))
return #s.hunks == 0 and #s.staged == 1
end, "the hunk to become staged")
hunks.toggle_stage(buf)
t.wait_for(function()
local s = assert(hunks.state(buf))
return #s.hunks == 1 and #s.staged == 0
end, "the hunk to return to unstaged")
t.eq(sign_marks(buf), {
{ row = 1, sign = "", hl = "GitHunkChanged" },
})
end)
t.test("toggle_stage unstages correctly when buffer lines are shifted", function()
local dir, buf = setup("a\nb\nc\n", "a\nb\nC\n")
vim.api.nvim_win_set_cursor(0, { 3, 0 })
hunks.toggle_stage(buf)
t.wait_for(function()
return #assert(hunks.state(buf)).staged == 1
end, "the line-3 change to be staged")
vim.api.nvim_buf_set_lines(buf, 0, 0, false, { "NEW" })
vim.api.nvim_exec_autocmds("TextChanged", { buffer = buf })
hunks._flush(buf)
t.wait_for(function()
return #assert(hunks.state(buf)).hunks == 1
end, "the unstaged add at the top to register")
vim.api.nvim_win_set_cursor(0, { 4, 0 })
hunks.toggle_stage(buf)
t.wait_for(function()
return #assert(hunks.state(buf)).staged == 0
end, "the shifted staged hunk to be unstaged")
t.eq(
h.git(dir, "show", ":0:a.txt").stdout,
"a\nb\nc",
"the index reverts to HEAD content for the unstaged hunk"
)
end)
t.test("reset_hunk restores the index content for a change", function()
local _, buf, state = setup("a\nb\nc\n", "a\nB\nc\n")
vim.api.nvim_win_set_cursor(0, { 2, 0 })
hunks.reset_hunk(buf)
t.eq(
vim.api.nvim_buf_get_lines(buf, 0, -1, false),
state.index,
"buffer matches the index after reset"
)
end)
t.test("reset_hunk re-inserts deleted lines", function()
local _, buf = setup("a\nb\nc\n", "a\nc\n")
vim.api.nvim_win_set_cursor(0, { 1, 0 })
hunks.reset_hunk(buf)
t.eq(vim.api.nvim_buf_get_lines(buf, 0, -1, false), { "a", "b", "c" })
end)
t.test("reset_hunk removes a pure addition", function()
local _, buf = setup("a\nd\n", "a\nb\nc\nd\n")
vim.api.nvim_win_set_cursor(0, { 2, 0 })
hunks.reset_hunk(buf)
t.eq(vim.api.nvim_buf_get_lines(buf, 0, -1, false), { "a", "d" })
end)
t.test("git_hunk_signs overrides the sign character per kind", function()
local prev = vim.g.git_hunk_signs
vim.g.git_hunk_signs = { change = "C" }
t.defer(function()
vim.g.git_hunk_signs = prev
end)
local _, buf = setup("a\nb\nc\n", "a\nB\nc\n")
t.eq(sign_marks(buf), {
{ row = 1, sign = "C", hl = "GitHunkChanged" },
})
end)
t.test("git_hunk_signs falls back to the default for unset kinds", function()
local prev = vim.g.git_hunk_signs
vim.g.git_hunk_signs = { add = "A" }
t.defer(function()
vim.g.git_hunk_signs = prev
end)
local _, buf = setup("a\nb\nc\n", "a\nB\nc\n")
t.eq(sign_marks(buf), {
{ row = 1, sign = "", hl = "GitHunkChanged" },
})
end)
t.test("preview_hunk shows the hunk body without file headers", function()
local _, buf = setup("a\nb\nc\n", "a\nB\nc\n")
vim.api.nvim_set_current_buf(buf)
vim.api.nvim_win_set_cursor(0, { 2, 0 })
hunks.preview_hunk(buf)
local float = assert(find_float(), "preview float should open")
t.defer(function()
pcall(vim.api.nvim_win_close, float, true)
end)
local lines = vim.api.nvim_buf_get_lines(
vim.api.nvim_win_get_buf(float),
0,
-1,
false
)
t.truthy(
vim.startswith(lines[1] or "", "@@ "),
"first line is the @@ header"
)
for _, l in ipairs(lines) do
t.falsy(vim.startswith(l, "--- "), "no --- file header line")
t.falsy(vim.startswith(l, "+++ "), "no +++ file header line")
end
end)
t.test("preview_hunk re-invocation focuses the open float", function()
local _, buf = setup("a\nb\nc\n", "a\nB\nc\n")
vim.api.nvim_set_current_buf(buf)
vim.api.nvim_win_set_cursor(0, { 2, 0 })
hunks.preview_hunk(buf)
local float = assert(find_float(), "preview float should open")
t.defer(function()
pcall(vim.api.nvim_win_close, float, true)
end)
t.truthy(
vim.api.nvim_get_current_win() ~= float,
"the float opens unfocused"
)
hunks.preview_hunk(buf)
t.eq(
vim.api.nvim_get_current_win(),
float,
"re-invoking focuses the existing float"
)
end)
t.test("nav jumps to next and previous hunks with wrap", function()
local _, buf = setup("a\nb\nc\nd\ne\n", "A\nb\nc\nd\nE\n")
vim.api.nvim_set_current_buf(buf)
vim.api.nvim_win_set_cursor(0, { 1, 0 })
hunks.nav("next")
t.eq(vim.api.nvim_win_get_cursor(0)[1], 5, "next hunk is line 5")
hunks.nav("next")
t.eq(vim.api.nvim_win_get_cursor(0)[1], 1, "next wraps back to line 1")
hunks.nav("prev")
t.eq(vim.api.nvim_win_get_cursor(0)[1], 5, "prev wraps back to line 5")
end)
+188
View File
@@ -0,0 +1,188 @@
local Revision = require("git.core.revision")
local h = require("test.git.helpers")
local object = require("git.object")
local t = require("test")
---@return integer? buf
local function find_git_buf()
for _, b in ipairs(vim.api.nvim_list_bufs()) do
if vim.api.nvim_buf_get_name(b):match("^git://") then
return b
end
end
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("parse_uri / format_uri round-trip for base", function()
local uri = "git://HEAD"
local rev = assert(object.parse_uri(uri))
t.eq(object.format_uri(rev), uri)
end)
t.test("parse_uri / format_uri round-trip for base + path", function()
local uri = "git://HEAD:lua/foo.lua"
local rev = assert(object.parse_uri(uri))
t.eq(object.format_uri(rev), uri)
end)
t.test("parse_uri / format_uri round-trip for stage + path", function()
local uri = "git://:2:lua/foo.lua"
local rev = assert(object.parse_uri(uri))
t.eq(object.format_uri(rev), uri)
end)
t.test("parse_uri normalizes bare :path to stage 0", function()
local rev = assert(object.parse_uri("git://:foo"))
t.eq(rev.stage, 0)
t.eq(rev.path, "foo")
t.eq(object.format_uri(rev), "git://:0:foo")
end)
t.test("parse_uri returns nil for non-git URIs", function()
t.falsy(object.parse_uri("file:///tmp/x"))
t.falsy(object.parse_uri("/tmp/x"))
end)
t.test("M.open(HEAD) names buffer with full sha", function()
local dir = h.make_repo({ a = "first\n" })
local r = assert(require("git.core.repo").resolve(dir))
local sha = h.git(dir, "rev-parse", "HEAD").stdout
object.open(r, "HEAD", { split = false })
t.eq(vim.api.nvim_buf_get_name(0), "git://" .. sha)
end)
t.test("M.open(<short sha>) canonicalizes to full sha", function()
local dir = h.make_repo({ a = "first\n" })
local r = assert(require("git.core.repo").resolve(dir))
local sha = h.git(dir, "rev-parse", "HEAD").stdout
local short = h.git(dir, "rev-parse", "--short", "HEAD").stdout
t.truthy(#short < #sha, "short sha must be shorter than full")
object.open(r, short, { split = false })
t.eq(vim.api.nvim_buf_get_name(0), "git://" .. sha)
end)
t.test("M.open(HEAD:<path>) loads file content at HEAD", function()
local dir = h.make_repo({ ["a.txt"] = "first\nsecond\n" })
local r = assert(require("git.core.repo").resolve(dir))
local sha = h.git(dir, "rev-parse", "HEAD").stdout
object.open(r, "HEAD:a.txt", { split = false })
t.eq(vim.api.nvim_buf_get_name(0), "git://" .. sha .. ":a.txt")
t.eq(
vim.api.nvim_buf_get_lines(0, 0, -1, false),
{ "first", "second" }
)
end)
t.test("M.open on a merge commit diffs against the first parent only", function()
local dir = h.make_repo({ ["a.txt"] = "one\n" })
t.write(dir, "a.txt", "two\n")
h.git(dir, "stash")
local stash = h.git(dir, "rev-parse", "stash@{0}").stdout
local r = assert(require("git.core.repo").resolve(dir))
object.open(r, stash, { split = false })
local count = 0
for _, l in ipairs(vim.api.nvim_buf_get_lines(0, 0, -1, false)) do
if l:match("^diff %-%-git ") then
count = count + 1
end
end
t.eq(count, 1, "the stashed file's diff appears once, not per-parent")
end)
t.test("M.open errors on a bogus base, no buffer is opened", function()
local dir = h.make_repo({ a = "first\n" })
local r = assert(require("git.core.repo").resolve(dir))
t.quietly(function()
object.open(r, "deadbeefdeadbeef", { split = false })
end)
t.falsy(find_git_buf(), "no git:// buffer should exist")
end)
t.test("M.open errors on a missing path, no buffer is opened", function()
local dir = h.make_repo({ a = "first\n" })
local r = assert(require("git.core.repo").resolve(dir))
t.quietly(function()
object.open(r, "HEAD:does-not-exist", { split = false })
end)
t.falsy(find_git_buf(), "no git:// buffer should exist")
end)
t.test("read_uri opens stage-0 entry as a writable index buffer", function()
local dir = h.make_repo({ ["a.txt"] = "first\n" })
local r = assert(require("git.core.repo").resolve(dir))
local rev = Revision.new({ stage = 0, path = "a.txt" })
local buf = object.buf_for(r, rev)
t.eq(vim.bo[buf].buftype, "acwrite")
t.truthy(vim.bo[buf].modifiable)
t.eq(vim.api.nvim_buf_get_lines(buf, 0, -1, false), { "first" })
end)
t.test("open_under_cursor on a 'tree <sha>' line opens the tree", function()
local dir = h.make_repo({ a = "first\n" })
local r = assert(require("git.core.repo").resolve(dir))
local tree_sha = h.git(dir, "rev-parse", "HEAD^{tree}").stdout
object.open(r, "HEAD", { split = false })
local lnum = assert(find_line(0, "tree "), "expected a tree line")
vim.api.nvim_win_set_cursor(0, { lnum, 0 })
t.truthy(object.open_under_cursor())
t.eq(vim.api.nvim_buf_get_name(0), "git://" .. tree_sha)
end)
t.test("open_under_cursor on a 'parent <sha>' line opens the parent", function()
local dir = h.make_repo({ a = "first\n" })
t.write(dir, "a", "second\n")
h.git(dir, "add", "a")
h.git(dir, "commit", "-q", "-m", "second")
local r = assert(require("git.core.repo").resolve(dir))
local parent_sha = h.git(dir, "rev-parse", "HEAD~").stdout
object.open(r, "HEAD", { split = false })
local lnum = assert(find_line(0, "parent "), "expected a parent line")
vim.api.nvim_win_set_cursor(0, { lnum, 0 })
t.truthy(object.open_under_cursor())
t.eq(vim.api.nvim_buf_get_name(0), "git://" .. parent_sha)
end)
t.test("open_under_cursor on a '+++ b/<path>' line loads the blob", function()
local dir = h.make_repo({ ["a.txt"] = "first\n" })
local r = assert(require("git.core.repo").resolve(dir))
local blob_sha = h.git(dir, "rev-parse", "HEAD:a.txt").stdout
object.open(r, "HEAD", { split = false })
local lnum = assert(find_line(0, "+++ b/a.txt"), "expected a +++ line")
vim.api.nvim_win_set_cursor(0, { lnum, 0 })
t.truthy(object.open_under_cursor())
t.eq(vim.api.nvim_buf_get_name(0), "git://" .. blob_sha)
end)
t.test("open_under_cursor returns false on a non-dispatchable line", function()
local dir = h.make_repo({ a = "first\n" })
local r = assert(require("git.core.repo").resolve(dir))
object.open(r, "HEAD", { split = false })
local lnum = assert(find_line(0, "author "), "expected an author line")
vim.api.nvim_win_set_cursor(0, { lnum, 0 })
t.falsy(object.open_under_cursor())
end)
+369
View File
@@ -0,0 +1,369 @@
---@diagnostic disable: access-invisible
local h = require("test.git.helpers")
local t = require("test")
---@param r ow.Git.Repo
---@param key string
---@param timeout integer?
local function wait_cleared(r, key, timeout)
t.wait_for(function()
return r._cache[key] == nil
end, key .. " cache to clear", timeout or 2000)
end
t.test("list_refs returns heads, tags, remotes (no HEAD)", function()
local dir = h.make_repo({ a = "x" })
h.git(dir, "branch", "feature")
h.git(dir, "tag", "v1")
local r = assert(require("git.core.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 = h.make_repo({ a = "x" })
local r = assert(require("git.core.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 = h.make_repo({ a = "x" })
local r = assert(require("git.core.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 = h.make_repo({ a = "x" })
local r = assert(require("git.core.repo").resolve(dir))
t.eq(r:list_stash_refs(), {})
end)
t.test("list_stash_refs lists stash + entries when stash exists", function()
local dir = h.make_repo({ a = "x" })
t.write(dir, "a", "modified")
h.git(dir, "stash")
local r = assert(require("git.core.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 = h.make_repo({ a = "x" })
local r = assert(require("git.core.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("index_sha returns the blob sha and caches it", function()
local dir = h.make_repo({ a = "x\n" })
local r = assert(require("git.core.repo").resolve(dir))
local sha = r:index_sha("a")
t.truthy(sha and #sha > 0, "index_sha returns the stage-0 blob sha")
t.truthy(r._cache["index:a"] ~= nil, "the result is cached")
t.eq(r:index_sha("a"), sha, "a cached call returns the same sha")
end)
t.test("index_sha caches a negative result for an untracked path", function()
local dir = h.make_repo({ a = "x\n" })
local r = assert(require("git.core.repo").resolve(dir))
t.eq(r:index_sha("nope"), nil, "an untracked path has no index sha")
t.eq(r._cache["index:nope"], false, "the negative result is cached")
end)
t.test("index_sha cache clears when the index is written", function()
local dir = h.make_repo({ a = "x\n" })
local r = assert(require("git.core.repo").resolve(dir))
r:index_sha("a")
t.truthy(r._cache["index:a"] ~= nil, "sha is cached before the stage")
t.write(dir, "a", "y\n")
h.git(dir, "add", "a")
wait_cleared(r, "index:a", 2000)
end)
t.test("head_sha returns the blob sha and caches it", function()
local dir = h.make_repo({ a = "x\n" })
local r = assert(require("git.core.repo").resolve(dir))
local sha = r:head_sha("a")
t.truthy(sha and #sha > 0, "head_sha returns the HEAD blob sha")
t.truthy(r._cache["head_blob:a"] ~= nil, "the result is cached")
t.eq(r:head_sha("a"), sha, "a cached call returns the same sha")
end)
t.test("head_sha cache clears when HEAD moves", function()
local dir = h.make_repo({ a = "x\n" })
local r = assert(require("git.core.repo").resolve(dir))
r:head_sha("a")
t.truthy(r._cache["head_blob:a"] ~= nil, "sha is cached before the commit")
t.write(dir, "a", "y\n")
h.git(dir, "commit", "-aqm", "change")
wait_cleared(r, "head_blob:a", 2000)
end)
t.test("cache clears after top-level .git change (commit)", function()
local dir = h.make_repo({ a = "x" })
local r = assert(require("git.core.repo").resolve(dir))
local _ = r:list_refs()
t.truthy(r._cache.refs)
t.write(dir, "b", "y")
h.git(dir, "add", "b")
h.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 = h.make_repo({ a = "x" })
local r = assert(require("git.core.repo").resolve(dir))
local _ = r:list_refs()
t.truthy(r._cache.refs)
h.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 = h.make_repo({ a = "x" })
local r = assert(require("git.core.repo").resolve(dir))
local _ = r:list_refs()
h.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("resolve_sha returns ok + full sha for a known blob", function()
local dir = h.make_repo({ a = "hello\n" })
local r = assert(require("git.core.repo").resolve(dir))
local blob = h.git(dir, "rev-parse", "HEAD:a").stdout
local short = blob:sub(1, 7)
local full, status = r:resolve_sha(short)
t.eq(status, "ok")
t.eq(full, blob)
end)
t.test("resolve_sha returns missing for an unknown prefix", function()
local dir = h.make_repo({ a = "x" })
local r = assert(require("git.core.repo").resolve(dir))
local full, status = r:resolve_sha("0000deadbeef")
t.eq(full, nil)
t.eq(status, "missing")
end)
t.test("resolve_sha caches by prefix", function()
local dir = h.make_repo({ a = "x" })
local r = assert(require("git.core.repo").resolve(dir))
local blob = h.git(dir, "rev-parse", "HEAD:a").stdout
local short = blob:sub(1, 7)
local _, _ = r:resolve_sha(short)
t.truthy(r._cache["resolve:" .. short], "result should be cached")
end)
---@param r ow.Git.Repo
local function wait_initial(r)
t.wait_for(function()
return r.status.branch.head ~= nil
end, "initial fetch to complete", 2000)
end
t.test("_invalidate clears only matching keys for HEAD", function()
local dir = h.make_repo({ a = "x" })
local r = assert(require("git.core.repo").resolve(dir))
r._cache.head = "h"
r._cache.refs = { "main" }
r._cache.pseudo_refs = { "HEAD" }
r._cache.stash_refs = {}
r._cache["resolve:abc"] = { "deadbeef", "ok" }
r:_invalidate("HEAD")
t.eq(r._cache.head, nil)
t.eq(r._cache.pseudo_refs, nil)
t.eq(r._cache["resolve:abc"], nil)
t.truthy(r._cache.refs)
t.truthy(r._cache.stash_refs)
end)
t.test("_invalidate clears refs/head/resolve for refs/heads/*", function()
local dir = h.make_repo({ a = "x" })
local r = assert(require("git.core.repo").resolve(dir))
r._cache.head = "h"
r._cache.refs = { "main" }
r._cache.pseudo_refs = { "HEAD" }
r._cache.stash_refs = {}
r._cache["resolve:abc"] = { "deadbeef", "ok" }
r:_invalidate("refs/heads/feature")
t.eq(r._cache.head, nil)
t.eq(r._cache.refs, nil)
t.eq(r._cache["resolve:abc"], nil)
t.truthy(r._cache.pseudo_refs)
t.truthy(r._cache.stash_refs)
end)
t.test("_invalidate clears config on .git/config change", function()
local dir = h.make_repo({ a = "x" })
local r = assert(require("git.core.repo").resolve(dir))
r._cache.config = { core = {} }
r:_invalidate("config")
t.eq(r._cache.config, nil)
end)
t.test("status_entry_for: exact match on case-sensitive repo", function()
local dir = h.make_repo({ Foo = "x" })
t.write(dir, "Foo", "modified")
local r = assert(require("git.core.repo").resolve(dir))
wait_initial(r)
t.truthy(r:status_entry_for("Foo"))
t.eq(r:status_entry_for("foo"), nil, "case mismatch returns nil")
end)
t.test("status_entry_for: case-insensitive fallback when core.ignorecase=true", function()
local dir = h.make_repo({ Foo = "x" })
h.git(dir, "config", "core.ignorecase", "true")
t.write(dir, "Foo", "modified")
local r = assert(require("git.core.repo").resolve(dir))
wait_initial(r)
t.truthy(r:status_entry_for("Foo"), "exact match")
t.truthy(r:status_entry_for("foo"), "lowercase finds Foo")
t.truthy(r:status_entry_for("FOO"), "uppercase finds Foo")
end)
t.test("_invalidate matches stash_refs on refs/stash and logs/refs/stash", function()
local dir = h.make_repo({ a = "x" })
local r = assert(require("git.core.repo").resolve(dir))
r._cache.stash_refs = {}
r:_invalidate("refs/stash")
t.eq(r._cache.stash_refs, nil)
r._cache.stash_refs = {}
r:_invalidate("logs/refs/stash")
t.eq(r._cache.stash_refs, nil)
end)
t.test("refresh with invalidate=true wipes cache on next fetch", function()
local dir = h.make_repo({ a = "x" })
local r = assert(require("git.core.repo").resolve(dir))
wait_initial(r)
r._cache.head = "stale"
r._cache["resolve:abc"] = { "x", "ok" }
r:refresh({ invalidate = true })
t.wait_for(function()
return r._cache.head == nil
end, "cache wiped after invalidating refresh completes", 2000)
t.eq(r._cache.head, nil)
t.eq(r._cache["resolve:abc"], nil)
end)
t.test("refresh emits change.paths listing structurally-changed paths", function()
local dir = h.make_repo({ a = "1", b = "1" })
local r = assert(require("git.core.repo").resolve(dir))
wait_initial(r)
t.write(dir, "a", "2")
---@type ow.Git.Repo.Change?
local change_seen
local unsub = r:on("change", function(change)
change_seen = change
end)
r:refresh()
t.wait_for(function()
return change_seen ~= nil
end, "refresh emit", 2000)
unsub()
local change = assert(change_seen)
t.truthy(change.paths["a"])
t.falsy(change.paths["b"], "b is unchanged structurally")
end)
t.test("submodule: parent enumerates initialized submodules by default", function()
local outer_path = h.make_submodule_repo()
local outer = assert(require("git.core.repo").resolve(outer_path))
t.truthy(outer._submodules["sub"], "sub recorded as submodule")
end)
t.test("submodule: eagerly creates child Repos and subscribes by default", function()
local outer_path = h.make_submodule_repo()
local outer = assert(require("git.core.repo").resolve(outer_path))
wait_initial(outer)
local inner = require("git.core.repo").all()[outer_path .. "/sub"]
t.truthy(inner, "inner Repo eagerly created")
t.truthy(outer._submodules["sub"] and outer._submodules["sub"].unsub, "inner subscribed by outer")
t.write(outer_path .. "/sub", "a", "modified\n")
---@type ow.Git.Repo.Change?
local outer_change
local unsub = outer:on("change", function(change)
outer_change = change
end)
inner:refresh()
t.wait_for(function()
return outer_change ~= nil
end, "outer notified by inner refresh", 2000)
unsub()
local entry = outer.status.entries["sub"]
t.truthy(entry, "outer sub entry now present")
t.eq(entry.kind, "changed")
end)
t.test("submodule: no eager creation when flag is explicitly disabled", function()
vim.g.git_submodule_recursion = false
t.defer(function()
vim.g.git_submodule_recursion = nil
end)
local outer_path = h.make_submodule_repo()
local outer = assert(require("git.core.repo").resolve(outer_path))
wait_initial(outer)
t.eq(
require("git.core.repo").all()[outer_path .. "/sub"],
nil,
"inner Repo not created when flag is false"
)
t.eq(next(outer._submodules), nil)
end)
t.test("submodule: outer created after inner picks up existing child", function()
local outer_path = h.make_submodule_repo()
local inner = assert(
require("git.core.repo").resolve(outer_path .. "/sub")
)
wait_initial(inner)
local outer = assert(require("git.core.repo").resolve(outer_path))
wait_initial(outer)
t.truthy(outer._submodules["sub"] and outer._submodules["sub"].unsub, "outer subscribed to pre-existing inner")
end)
t.test("watcher cleans up after a slash-branch dir is removed", function()
local dir = h.make_repo({ a = "x" })
local r = assert(require("git.core.repo").resolve(dir))
h.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"
t.wait_for(function()
return r._watchers[feat_path] ~= nil
end, "watcher to be installed on feat/ subdir", 2000)
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.
h.git(dir, "branch", "-D", "feat/foo")
t.wait_for(function()
return r._watchers[feat_path] == nil
end, "watcher on feat/ subdir to close", 2000)
t.falsy(r._watchers[feat_path], "watcher should self-close")
end)
+387
View File
@@ -0,0 +1,387 @@
local t = require("test")
local status = require("git.core.status")
local NUL = "\0"
---@param parts string[]
---@return string
local function nul(parts)
return table.concat(parts, NUL) .. NUL
end
t.test("branch headers: initial repo, no commits", function()
local s = status.parse(nul({
"# branch.oid (initial)",
"# branch.head main",
}))
t.eq(s.branch.oid, nil)
t.eq(s.branch.head, "main")
t.eq(s.branch.upstream, nil)
t.eq(s.branch.ahead, 0)
t.eq(s.branch.behind, 0)
end)
t.test("branch headers: detached HEAD", function()
local s = status.parse(nul({
"# branch.oid 1234567890abcdef1234567890abcdef12345678",
"# branch.head (detached)",
}))
t.eq(s.branch.oid, "1234567890abcdef1234567890abcdef12345678")
t.eq(s.branch.head, nil)
end)
t.test("branch headers: with upstream and ahead/behind", function()
local s = status.parse(nul({
"# branch.oid abc123",
"# branch.head main",
"# branch.upstream origin/main",
"# branch.ab +3 -2",
}))
t.eq(s.branch.head, "main")
t.eq(s.branch.upstream, "origin/main")
t.eq(s.branch.ahead, 3)
t.eq(s.branch.behind, 2)
end)
t.test("type 1: staged-only modification", function()
local s = status.parse(nul({
"1 M. N... 100644 100644 100644 abc abc foo.lua",
}))
local e = s.entries["foo.lua"]
---@cast e ow.Git.Status.ChangedEntry
t.eq(e.kind, "changed")
t.eq(e.path, "foo.lua")
t.eq(e.staged, "modified")
t.eq(e.unstaged, nil)
t.eq(e.orig, nil)
end)
t.test("type 1: unstaged-only modification", function()
local s = status.parse(nul({
"1 .M N... 100644 100644 100644 abc abc foo.lua",
}))
local e = s.entries["foo.lua"]
---@cast e ow.Git.Status.ChangedEntry
t.eq(e.staged, nil)
t.eq(e.unstaged, "modified")
end)
t.test("type 1: both sides modified", function()
local s = status.parse(nul({
"1 MM N... 100644 100644 100644 abc abc foo.lua",
}))
local e = s.entries["foo.lua"]
---@cast e ow.Git.Status.ChangedEntry
t.eq(e.staged, "modified")
t.eq(e.unstaged, "modified")
end)
t.test("type 1: deleted (unstaged)", function()
local s = status.parse(nul({
"1 .D N... 100644 100644 000000 abc abc foo.lua",
}))
local e = s.entries["foo.lua"] --[[@as ow.Git.Status.ChangedEntry]]
t.eq(e.unstaged, "deleted")
end)
t.test("type 1: added (staged)", function()
local s = status.parse(nul({
"1 A. N... 000000 100644 100644 abc abc new.lua",
}))
local e = s.entries["new.lua"] --[[@as ow.Git.Status.ChangedEntry]]
t.eq(e.staged, "added")
end)
t.test("type 1: type-changed (unstaged)", function()
local s = status.parse(nul({
"1 .T N... 100644 100644 120000 abc abc foo.lua",
}))
local e = s.entries["foo.lua"] --[[@as ow.Git.Status.ChangedEntry]]
t.eq(e.unstaged, "type_changed")
end)
t.test("type 2: renamed with orig", function()
local s = status.parse(nul({
"2 R. N... 100644 100644 100644 abc abc R100 new.lua",
"old.lua",
}))
local e = s.entries["new.lua"]
---@cast e ow.Git.Status.ChangedEntry
t.eq(e.kind, "changed")
t.eq(e.path, "new.lua")
t.eq(e.staged, "renamed")
t.eq(e.orig, "old.lua")
end)
t.test("type 2: copied with orig", function()
local s = status.parse(nul({
"2 C. N... 100644 100644 100644 abc abc C90 copy.lua",
"src.lua",
}))
local e = s.entries["copy.lua"]
---@cast e ow.Git.Status.ChangedEntry
t.eq(e.staged, "copied")
t.eq(e.orig, "src.lua")
end)
t.test("type u: all seven conflict types", function()
local cases = {
{ xy = "DD", expected = "both_deleted" },
{ xy = "AU", expected = "added_by_us" },
{ xy = "UD", expected = "deleted_by_them" },
{ xy = "UA", expected = "added_by_them" },
{ xy = "DU", expected = "deleted_by_us" },
{ xy = "AA", expected = "both_added" },
{ xy = "UU", expected = "both_modified" },
}
for _, c in ipairs(cases) do
local s = status.parse(nul({
string.format(
"u %s N... 100644 100644 100644 100644 abc abc abc conflict.lua",
c.xy
),
}))
local e = s.entries["conflict.lua"]
t.eq(e.kind, "unmerged", "kind for " .. c.xy)
t.eq(
(e --[[@as ow.Git.Status.UnmergedEntry]]).conflict,
c.expected,
"conflict for " .. c.xy
)
end
end)
t.test("type ?: untracked", function()
local s = status.parse(nul({ "? new.txt" }))
local e = s.entries["new.txt"]
t.eq(e.kind, "untracked")
t.eq(e.path, "new.txt")
end)
t.test("type !: ignored", function()
local s = status.parse(nul({ "! .secret" }))
local e = s.entries[".secret"]
t.eq(e.kind, "ignored")
end)
t.test("mixed: branch + multiple variants", function()
local s = status.parse(nul({
"# branch.oid abc",
"# branch.head main",
"# branch.upstream origin/main",
"# branch.ab +0 -0",
"1 M. N... 100644 100644 100644 a a staged.lua",
"1 .M N... 100644 100644 100644 a a unstaged.lua",
"1 MM N... 100644 100644 100644 a a both.lua",
"u UU N... 100644 100644 100644 100644 a a a conflict.lua",
"? untracked.txt",
"! ignored.txt",
}))
t.eq(s.branch.head, "main")
local staged = s.entries["staged.lua"] --[[@as ow.Git.Status.ChangedEntry]]
local unstaged = s.entries["unstaged.lua"] --[[@as ow.Git.Status.ChangedEntry]]
local both = s.entries["both.lua"] --[[@as ow.Git.Status.ChangedEntry]]
t.eq(staged.staged, "modified")
t.eq(unstaged.unstaged, "modified")
t.eq(both.staged, "modified")
t.eq(both.unstaged, "modified")
t.eq(s.entries["conflict.lua"].kind, "unmerged")
t.eq(s.entries["untracked.txt"].kind, "untracked")
t.eq(s.entries["ignored.txt"].kind, "ignored")
end)
t.test("paths with spaces survive splitting", function()
local s = status.parse(nul({
"1 .M N... 100644 100644 100644 a a path with spaces.lua",
}))
local e = s.entries["path with spaces.lua"] --[[@as ow.Git.Status.ChangedEntry]]
t.eq(e.unstaged, "modified")
end)
t.test("mark_for: changed staged modified", function()
local entry = {
kind = "changed",
path = "x",
staged = "modified",
}
t.eq(status.mark_for(entry, "staged"), { char = "M", hl = "GitStagedModified" })
end)
t.test("mark_for: changed unstaged deleted uses GitUnstagedDeleted", function()
local entry = {
kind = "changed",
path = "x",
unstaged = "deleted",
}
t.eq(status.mark_for(entry, "unstaged"), { char = "D", hl = "GitUnstagedDeleted" })
end)
t.test("mark_for: changed renamed uses per-side renamed hl", function()
local entry = {
kind = "changed",
path = "x",
staged = "renamed",
orig = "y",
}
t.eq(status.mark_for(entry, "staged"), { char = "R", hl = "GitStagedRenamed" })
end)
t.test("mark_for: untracked / ignored / unmerged ignore side", function()
t.eq(
status.mark_for({ kind = "untracked", path = "x" } --[[@as ow.Git.Status.Entry]]),
{ char = "?", hl = "GitUntracked" }
)
t.eq(
status.mark_for({ kind = "ignored", path = "x" } --[[@as ow.Git.Status.Entry]]),
{ char = "i", hl = "GitIgnored" }
)
t.eq(
status.mark_for({
kind = "unmerged",
path = "x",
conflict = "both_modified",
} --[[@as ow.Git.Status.Entry]]),
{ char = "!", hl = "GitUnmergedBothModified" }
)
end)
t.test("marks_for: changed with both sides yields two marks", function()
local entry = {
kind = "changed",
path = "x",
staged = "modified",
unstaged = "modified",
}
local marks = status.marks_for(entry)
t.eq(#marks, 2)
t.eq(marks[1], { char = "M", hl = "GitStagedModified" })
t.eq(marks[2], { char = "M", hl = "GitUnstagedModified" })
end)
t.test("marks_for: changed one-sided yields one mark", function()
local entry = { kind = "changed", path = "x", staged = "added" }
local marks = status.marks_for(entry)
t.eq(#marks, 1)
t.eq(marks[1], { char = "A", hl = "GitStagedAdded" })
end)
t.test("marks_for: untracked yields one mark", function()
local entry = { kind = "untracked", path = "x" } --[[@as ow.Git.Status.Entry]]
local marks = status.marks_for(entry)
t.eq(#marks, 1)
t.eq(marks[1], { char = "?", hl = "GitUntracked" })
end)
t.test("Status:rows buckets by section", function()
local s = status.parse(nul({
"1 M. N... 100644 100644 100644 a a staged.lua",
"1 .M N... 100644 100644 100644 a a unstaged.lua",
"1 MM N... 100644 100644 100644 a a both.lua",
"? untracked.txt",
}))
t.eq(#s:rows("staged"), 2, "staged section: staged.lua + both.lua")
t.eq(#s:rows("unstaged"), 2, "unstaged section: unstaged.lua + both.lua")
t.eq(#s:rows("untracked"), 1)
t.eq(#s:rows("unmerged"), 0)
t.eq(#s:rows("ignored"), 0)
end)
t.test("Status:rows for staged carries side='staged'", function()
local s = status.parse(nul({
"1 M. N... 100644 100644 100644 a a x.lua",
}))
local row = assert(s:rows("staged")[1])
t.eq(row.section, "staged")
t.eq(row.side, "staged")
t.eq(row.entry.kind, "changed")
end)
t.test("Status:rows for untracked has nil side", function()
local s = status.parse(nul({ "? x.txt" }))
local row = assert(s:rows("untracked")[1])
t.eq(row.section, "untracked")
t.eq(row.side, nil)
end)
t.test("Status:aggregate_at dedups marks under prefix", function()
local s = status.parse(nul({
"1 .M N... 100644 100644 100644 a a sub/a.lua",
"1 .M N... 100644 100644 100644 a a sub/b.lua",
"? sub/c.txt",
}))
local marks = s:aggregate_at("sub")
t.eq(#marks, 2, "modified ('M') and untracked ('?') deduped")
local m1 = assert(marks[1])
local m2 = assert(marks[2])
local hls = { m1.hl, m2.hl }
table.sort(hls)
t.eq(hls, { "GitUnstagedModified", "GitUntracked" })
end)
t.test("Status:aggregate_at with prefix '.' includes everything", function()
local s = status.parse(nul({
"1 .M N... 100644 100644 100644 a a a.lua",
"? b.txt",
}))
t.eq(#s:aggregate_at("."), 2)
end)
t.test("entry_equal: identical changed entries", function()
local a = { kind = "changed", path = "x", staged = "modified" }
local b = { kind = "changed", path = "x", staged = "modified" }
t.truthy(status.entry_equal(a, b))
end)
t.test("entry_equal: differing staged side returns false", function()
local a = { kind = "changed", path = "x", staged = "modified" }
local b = { kind = "changed", path = "x", staged = "added" }
t.falsy(status.entry_equal(a, b))
end)
t.test("entry_equal: differing orig returns false", function()
local a = { kind = "changed", path = "x", staged = "renamed", orig = "y" }
local b = { kind = "changed", path = "x", staged = "renamed", orig = "z" }
t.falsy(status.entry_equal(a, b))
end)
t.test("entry_equal: nil vs nil is true", function()
t.truthy(status.entry_equal(nil, nil))
end)
t.test("entry_equal: nil vs entry is false", function()
t.falsy(status.entry_equal(nil, { kind = "untracked", path = "x" }))
end)
t.test("entry_equal: different kinds returns false", function()
local a = { kind = "untracked", path = "x" }
local b = { kind = "ignored", path = "x" }
t.falsy(status.entry_equal(a, b))
end)
t.test("entry_equal: differing unmerged conflict returns false", function()
local a = { kind = "unmerged", path = "x", conflict = "both_added" }
local b = { kind = "unmerged", path = "x", conflict = "both_modified" }
t.falsy(status.entry_equal(a, b))
end)
t.test("diff_entries: detects additions, removals, and modifications", function()
local prior = {
a = { kind = "changed", path = "a", staged = "modified" },
b = { kind = "untracked", path = "b" },
}
local next_ = {
a = { kind = "changed", path = "a", staged = "added" },
c = { kind = "untracked", path = "c" },
}
local changed = status.diff_entries(prior, next_)
t.truthy(changed.a, "a modified")
t.truthy(changed.b, "b removed")
t.truthy(changed.c, "c added")
end)
t.test("diff_entries: empty when entries match", function()
local prior = { a = { kind = "untracked", path = "a" } }
local next_ = { a = { kind = "untracked", path = "a" } }
t.eq(status.diff_entries(prior, next_), {})
end)
+487
View File
@@ -0,0 +1,487 @@
local h = require("test.git.helpers")
local t = require("test")
---Replicate the user's global cursor-restore autocmd. Scoped to a
---named augroup + cleanup so it doesn't leak between tests.
local function install_cursor_restore_autocmd()
local group =
vim.api.nvim_create_augroup("test.cursor_restore", { clear = true })
vim.api.nvim_create_autocmd("BufReadPost", {
group = group,
pattern = "*",
command = 'silent! normal! g`"zv',
})
t.defer(function()
pcall(vim.api.nvim_del_augroup_by_name, "test.cursor_restore")
end)
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
---Find the gitstatus sidebar window in the current tabpage.
---@return integer? sidebar_buf
---@return integer? sidebar_win
local function find_sidebar()
for _, w in ipairs(vim.api.nvim_tabpage_list_wins(0)) do
local b = vim.api.nvim_win_get_buf(w)
if vim.bo[b].filetype == "gitstatus" then
return b, w
end
end
end
---Find a diff window in the given tabpage (or current). "left" / "right"
---is determined by column position: the layout is [sidebar | left | right],
---so the leftmost &diff window is the left pane and the rightmost is the
---right pane.
---@param role "left"|"right"
---@param tab integer?
---@return integer?
local function find_diff_win(role, tab)
local diffs = {}
for _, w in ipairs(vim.api.nvim_tabpage_list_wins(tab or 0)) do
if vim.wo[w].diff then
table.insert(diffs, w)
end
end
table.sort(diffs, function(a, b)
return vim.api.nvim_win_get_position(a)[2]
< vim.api.nvim_win_get_position(b)[2]
end)
if role == "left" then
return diffs[1]
end
return diffs[#diffs]
end
---@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 })
t.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.core.repo").find(vim.fn.getcwd()),
"repo should resolve for the test worktree"
)
r:refresh()
t.wait_for(function()
return r.status and #r.status:rows("unstaged") > 0
end, "git status to report unstaged changes")
local entry_line = assert(
find_line(sidebar_buf, vim.pesc(file_path) .. "$"),
file_path .. " should appear in sidebar"
)
return sidebar_win, entry_line
end
t.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 })
t.press("<Tab>")
t.wait_for(function()
return find_diff_win("left") ~= nil
end, "diff windows to appear")
local r = assert(require("git.core.repo").find(vim.fn.getcwd()))
vim.api.nvim_set_current_win(sidebar_win)
t.press("s")
t.wait_for(function()
return #r.status:rows("staged") > 0
end, "stage to propagate to repo state")
t.eq(
vim.api.nvim_win_get_cursor(sidebar_win),
{ line, 0 },
"sidebar cursor should remain at the entry's original line"
)
end)
t.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 })
t.press("<Tab>")
t.wait_for(function()
return find_diff_win("left") ~= nil
end, "diff windows to appear")
local left_win = assert(find_diff_win("left"))
t.eq(
vim.wo[left_win].foldmethod,
"diff",
"left diff foldmethod should be 'diff' after Tab"
)
local r = assert(require("git.core.repo").find(vim.fn.getcwd()))
vim.api.nvim_set_current_win(sidebar_win)
t.press("s")
t.wait_for(function()
return #r.status:rows("staged") > 0
end, "stage to propagate to repo state")
t.eq(
vim.wo[left_win].foldmethod,
"diff",
"left diff foldmethod should still be 'diff' after stage refresh"
)
end
)
t.test(
"<Tab> in a second tabpage opens the diff inside that tabpage",
function()
local sidebar_win, line =
setup_sidebar_with_unstaged_file("foo.txt", "v1\n", "v2\n")
local tab1 = vim.api.nvim_get_current_tabpage()
vim.api.nvim_set_current_win(sidebar_win)
vim.api.nvim_win_set_cursor(sidebar_win, { line, 0 })
t.press("<Tab>")
t.wait_for(function()
return find_diff_win("left", tab1) ~= nil
end, "diff windows in tab1 to appear")
vim.cmd("tabnew")
require("git.status_view").open({ placement = "sidebar" })
local tab2 = vim.api.nvim_get_current_tabpage()
t.truthy(tab2 ~= tab1, "tabnew should produce a distinct tabpage")
local _, sidebar_win2 = find_sidebar()
assert(sidebar_win2, "sidebar window should exist in tab2")
vim.api.nvim_set_current_win(sidebar_win2)
vim.api.nvim_win_set_cursor(sidebar_win2, { line, 0 })
t.press("<Tab>")
t.wait_for(function()
return find_diff_win("left", tab2) ~= nil
end, "diff windows in tab2 to appear")
t.truthy(
find_diff_win("right", tab2),
"right diff window should be in tab2"
)
end
)
t.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 })
t.press("<Tab>")
t.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)
t.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)
t.press("s")
t.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")
t.eq(
vim.api.nvim_buf_get_lines(index_buf, 0, -1, false),
{ "v2" },
"index pane should reflect staged content after refresh"
)
end)
t.test(
"re-selecting same entry after close + diffsplit keeps fold state in sync",
function()
local committed, worktree = {}, {}
for i = 1, 30 do
committed[i] = "line " .. i
worktree[i] = i == 15 and "CHANGED" or ("line " .. i)
end
local sidebar_win, line = setup_sidebar_with_unstaged_file(
"foo.txt",
table.concat(committed, "\n") .. "\n",
table.concat(worktree, "\n") .. "\n"
)
local prev_foldlevel = vim.o.foldlevel
vim.o.foldlevel = 99
t.defer(function()
vim.o.foldlevel = prev_foldlevel
end)
vim.api.nvim_set_current_win(sidebar_win)
vim.api.nvim_win_set_cursor(sidebar_win, { line, 0 })
t.press("<Tab>")
t.wait_for(function()
return find_diff_win("left") ~= nil
and find_diff_win("right") ~= nil
end, "first diff pair to appear")
local first_left = assert(find_diff_win("left"))
vim.api.nvim_win_close(first_left, false)
local remaining
for _, w in ipairs(vim.api.nvim_tabpage_list_wins(0)) do
if w ~= sidebar_win then
remaining = w
break
end
end
if not remaining then
error("a non-sidebar window should remain after close")
end
vim.api.nvim_set_current_win(remaining)
require("git.diffsplit").open({ mods = { vertical = true } })
t.wait_for(function()
local count = 0
for _, w in ipairs(vim.api.nvim_tabpage_list_wins(0)) do
if vim.wo[w].diff then
count = count + 1
end
end
return count == 2
end, "diffsplit to produce a diff pair")
vim.api.nvim_set_current_win(sidebar_win)
vim.api.nvim_win_set_cursor(sidebar_win, { line, 0 })
t.press("<Tab>")
t.wait_for(function()
local count = 0
for _, w in ipairs(vim.api.nvim_tabpage_list_wins(0)) do
if vim.wo[w].diff then
count = count + 1
end
end
return count == 2
end, "diff pair after re-selecting entry")
local left_win = assert(find_diff_win("left"))
local right_win = assert(find_diff_win("right"))
t.eq(
vim.wo[left_win].foldlevel,
0,
"left pane foldlevel should be 0 after re-select"
)
t.eq(
vim.wo[right_win].foldlevel,
0,
"right pane foldlevel should be 0 after re-select"
)
end
)
t.test("sidebar buffer is named <worktree>/GitStatus", function()
local repo = h.make_repo({ ["foo.txt"] = "x\n" })
vim.cmd("cd " .. repo)
require("git.status_view").open({ placement = "sidebar" })
local r = assert(require("git.core.repo").find(vim.fn.getcwd()))
local buf = find_sidebar()
assert(buf, "sidebar buffer should exist")
t.eq(
vim.api.nvim_buf_get_name(buf),
r.worktree .. "/GitStatus",
"buffer name should be <worktree>/GitStatus"
)
end)
t.test(
"calling open twice without closing focuses the existing sidebar",
function()
local repo = h.make_repo({ ["foo.txt"] = "x\n" })
vim.cmd("cd " .. repo)
require("git.status_view").open({ placement = "sidebar" })
local first = find_sidebar()
assert(first, "first sidebar buffer should exist")
require("git.status_view").open({ placement = "sidebar" })
local second = find_sidebar()
assert(second, "second sidebar buffer should exist")
t.eq(
first,
second,
"consecutive opens should reuse the visible sidebar"
)
local count = 0
for _, b in ipairs(vim.api.nvim_list_bufs()) do
if vim.bo[b].filetype == "gitstatus" then
count = count + 1
end
end
t.eq(count, 1, "only one gitstatus buffer should exist")
end
)
t.test("opening for different worktrees creates separate buffers", function()
local repo_a = h.make_repo({ ["a.txt"] = "x\n" })
local repo_b = h.make_repo({ ["b.txt"] = "y\n" })
vim.cmd("cd " .. repo_a)
require("git.status_view").open({ placement = "sidebar" })
local buf_a = find_sidebar()
require("git.status_view").toggle()
vim.cmd("cd " .. repo_b)
require("git.status_view").open({ placement = "sidebar" })
local buf_b = find_sidebar()
assert(buf_a and buf_b)
t.truthy(
buf_a ~= buf_b,
"different worktrees should produce different buffers"
)
end)
t.test("sidebar buffer is buftype=nofile and not buflisted", function()
local repo = h.make_repo({ ["foo.txt"] = "x\n" })
vim.cmd("cd " .. repo)
require("git.status_view").open({ placement = "sidebar" })
local buf = find_sidebar()
assert(buf, "sidebar buffer should exist")
t.eq(vim.bo[buf].buftype, "nofile", "buftype should be nofile")
t.eq(vim.bo[buf].buflisted, false, "buflisted should be false")
end)
t.test("sidebar buffer name does not get written to disk", function()
local repo = h.make_repo({ ["foo.txt"] = "x\n" })
vim.cmd("cd " .. repo)
require("git.status_view").open({ placement = "sidebar" })
local buf = find_sidebar()
assert(buf, "sidebar buffer should exist")
local name = vim.api.nvim_buf_get_name(buf)
vim.api.nvim_buf_call(buf, function()
pcall(function()
vim.cmd("silent! write")
end)
end)
t.eq(
vim.uv.fs_stat(name),
nil,
"no real file should be created at the sidebar buffer's path"
)
end)
t.test(
"diffsplit from sidebar resets cursor so panes stay in sync",
function()
local committed, worktree = {}, {}
for i = 1, 100 do
committed[i] = "line " .. i
worktree[i] = i == 10
and "CHANGED " .. i
or i == 40 and "CHANGED " .. i
or i == 70 and "CHANGED " .. i
or i == 90 and "CHANGED " .. i
or ("line " .. i)
end
local repo = h.make_repo({
["file.txt"] = table.concat(committed, "\n") .. "\n",
})
t.write(repo, "file.txt", table.concat(worktree, "\n") .. "\n")
vim.cmd("cd " .. repo)
-- Open the worktree file in a normal window and position cursor in
-- what becomes a folded section after diff is set up.
vim.cmd("edit file.txt")
vim.api.nvim_win_set_cursor(0, { 50, 0 })
require("git.status_view").open({ placement = "sidebar" })
local sidebar_buf, sidebar_win = find_sidebar()
assert(sidebar_buf and sidebar_win)
local r = assert(require("git.core.repo").find(vim.fn.getcwd()))
r:refresh()
t.wait_for(function()
return r.status and #r.status:rows("unstaged") > 0
end, "git status to report unstaged changes")
local entry_line
for i, l in
ipairs(vim.api.nvim_buf_get_lines(sidebar_buf, 0, -1, false))
do
if l:match("file.txt$") then
entry_line = i
break
end
end
if not entry_line then
error("entry line should exist")
end
vim.api.nvim_set_current_win(sidebar_win)
vim.api.nvim_win_set_cursor(sidebar_win, { entry_line, 0 })
t.press("<Tab>")
t.wait_for(function()
return find_diff_win("left") ~= nil
and find_diff_win("right") ~= nil
end, "diff pair to appear")
local left_win = assert(find_diff_win("left"))
local right_win = assert(find_diff_win("right"))
local left_top =
vim.api.nvim_win_call(left_win, function() return vim.fn.line("w0") end)
local right_top = vim.api.nvim_win_call(
right_win,
function() return vim.fn.line("w0") end
)
t.eq(
left_top,
right_top,
"left and right panes should have the same topline after diffsplit"
)
t.eq(
vim.api.nvim_win_get_cursor(left_win),
{ 1, 0 },
"left pane should start at line 1"
)
t.eq(
vim.api.nvim_win_get_cursor(right_win),
{ 1, 0 },
"right pane should start at line 1"
)
end
)
+49
View File
@@ -0,0 +1,49 @@
local t = require("test")
local util = require("git.core.util")
local function fresh_buf()
local buf = vim.api.nvim_create_buf(false, true)
t.defer(function()
pcall(vim.api.nvim_buf_delete, buf, { force = true })
end)
return buf
end
t.test("set_buf_lines preserves modifiable=false", function()
local buf = fresh_buf()
vim.bo[buf].modifiable = false
util.set_buf_lines(buf, 0, -1, { "a", "b", "c" })
t.eq(
vim.api.nvim_buf_get_lines(buf, 0, -1, false),
{ "a", "b", "c" },
"lines should be replaced"
)
t.falsy(vim.bo[buf].modifiable, "modifiable should stay false")
t.falsy(vim.bo[buf].modified, "modified should be cleared")
end)
t.test("set_buf_lines preserves modifiable=true", function()
local buf = fresh_buf()
vim.bo[buf].modifiable = true
util.set_buf_lines(buf, 0, -1, { "a", "b" })
t.truthy(vim.bo[buf].modifiable, "modifiable should stay true")
t.falsy(vim.bo[buf].modified, "modified should be cleared")
end)
t.test("set_buf_lines partial range update", function()
local buf = fresh_buf()
vim.api.nvim_buf_set_lines(buf, 0, -1, false, { "a", "b", "c", "d" })
util.set_buf_lines(buf, 1, 3, { "X", "Y", "Z" })
t.eq(
vim.api.nvim_buf_get_lines(buf, 0, -1, false),
{ "a", "X", "Y", "Z", "d" },
"lines [1, 3) should be replaced"
)
end)
t.test("set_buf_lines errors on out-of-bounds (strict_indexing)", function()
local buf = fresh_buf()
vim.api.nvim_buf_set_lines(buf, 0, -1, false, { "a", "b" })
local ok = pcall(util.set_buf_lines, buf, 100, 200, { "x" })
t.falsy(ok, "out-of-bounds index should error")
end)