feat: move out git package to separate plugin
This commit is contained in:
@@ -1,312 +0,0 @@
|
||||
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)
|
||||
@@ -1,668 +0,0 @@
|
||||
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
|
||||
)
|
||||
@@ -1,97 +0,0 @@
|
||||
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
|
||||
@@ -1,634 +0,0 @@
|
||||
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)
|
||||
@@ -1,188 +0,0 @@
|
||||
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)
|
||||
@@ -1,369 +0,0 @@
|
||||
---@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)
|
||||
@@ -1,387 +0,0 @@
|
||||
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)
|
||||
|
||||
@@ -1,487 +0,0 @@
|
||||
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
|
||||
)
|
||||
@@ -1,49 +0,0 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user