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) t.wait_for(function() local s = hunks.state(buf) return s ~= nil and s.index ~= nil end, "hunks to compute the index snapshot") 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 = "GitHunkAdd" }, { row = 2, sign = "┃", hl = "GitHunkAdd" }, }) 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 = "GitHunkDelete" }, }) 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 = "GitHunkDelete" }, }) 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 = "GitHunkChange" }, { row = 2, sign = "┃", hl = "GitHunkChange" }, }) 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 = "GitHunkChange" }, { row = 4, sign = "┃", hl = "GitHunkChange" }, }) 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 }) 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 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("stage_hunk 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.stage_hunk(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("stage_hunk 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.stage_hunk(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("stage_hunk 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.stage_hunk(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("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 = "GitHunkChange" }, }) 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 = "GitHunkChange" }, }) 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)