From f4181b89fc7d1d09b3751f85706cebc7f3017501 Mon Sep 17 00:00:00 2001 From: Oscar Wallberg Date: Wed, 20 May 2026 06:10:17 +0200 Subject: [PATCH] feat(git): add in-house hunks module, replace gitsigns.nvim --- init.lua | 1 - lua/core/keymap.lua | 15 +- lua/git/core/repo.lua | 4 +- lua/git/core/util.lua | 59 +++ lua/git/{diff.lua => diffsplit.lua} | 6 +- lua/git/hunks.lua | 608 ++++++++++++++++++++++++++++ lua/git/object.lua | 2 +- lua/git/status_view.lua | 4 +- nvim-pack-lock.json | 12 + plugin/git.lua | 60 ++- plugins/gitsigns.lua | 48 --- test/git/hunks_test.lua | 309 ++++++++++++++ test/git/status_view_test.lua | 2 +- 13 files changed, 1055 insertions(+), 75 deletions(-) rename lua/git/{diff.lua => diffsplit.lua} (97%) create mode 100644 lua/git/hunks.lua delete mode 100644 plugins/gitsigns.lua create mode 100644 test/git/hunks_test.lua diff --git a/init.lua b/init.lua index b93b4b9..e7b9ea2 100644 --- a/init.lua +++ b/init.lua @@ -30,7 +30,6 @@ require("pack").setup({ "https://github.com/owallb/mason-auto-install.nvim", "https://github.com/mfussenegger/nvim-dap", "https://github.com/numToStr/Comment.nvim", - "https://github.com/lewis6991/gitsigns.nvim", "https://github.com/MagicDuck/grug-far.nvim", "https://github.com/nvim-tree/nvim-tree.lua", "https://github.com/stevearc/oil.nvim", diff --git a/lua/core/keymap.lua b/lua/core/keymap.lua index 71d42a7..f73e76e 100644 --- a/lua/core/keymap.lua +++ b/lua/core/keymap.lua @@ -226,11 +226,18 @@ vim.keymap.set("n", "fD", vim.diagnostic.setqflist) vim.keymap.set("n", "grt", vim.lsp.buf.type_definition) vim.keymap.set("n", "gd", vim.lsp.buf.definition) -vim.keymap.set("n", "gd", "(git-diff-vertical)") -vim.keymap.set("n", "gD", "(git-diff-vertical-head)") -vim.keymap.set("n", "gh", "(git-diff-horizontal)") -vim.keymap.set("n", "gH", "(git-diff-horizontal-head)") +vim.keymap.set("n", "gd", "(git-diffsplit-vertical)") +vim.keymap.set("n", "gD", "(git-diffsplit-vertical-head)") +vim.keymap.set("n", "gh", "(git-diffsplit-horizontal)") +vim.keymap.set("n", "gH", "(git-diffsplit-horizontal-head)") vim.keymap.set("n", "gg", "(git-status-toggle)") vim.keymap.set("n", "gc", "(git-commit)") vim.keymap.set("n", "ga", "(git-commit-amend)") vim.keymap.set("n", "gl", "(git-log)") +vim.keymap.set("n", "gv", "(git-hunk-select)") +vim.keymap.set("n", "gs", "(git-hunk-stage)") +vim.keymap.set("n", "gr", "(git-hunk-reset)") +vim.keymap.set("n", "g", "(git-hunk-preview)") +vim.keymap.set("n", "go", "(git-overlay-toggle)") +vim.keymap.set({ "n", "x" }, "]g", "(git-hunk-next)") +vim.keymap.set({ "n", "x" }, "[g", "(git-hunk-prev)") diff --git a/lua/git/core/repo.lua b/lua/git/core/repo.lua index 26e7a1c..6766a00 100644 --- a/lua/git/core/repo.lua +++ b/lua/git/core/repo.lua @@ -819,7 +819,7 @@ end ---@param buf integer ---@return boolean -local function is_worktree_buf(buf) +function M.is_worktree_buf(buf) if not vim.api.nvim_buf_is_valid(buf) or vim.bo[buf].buftype ~= "" then return false end @@ -830,7 +830,7 @@ end ---@param buf? integer function M.track(buf) buf = expand_buf(buf) - if not is_worktree_buf(buf) then + if not M.is_worktree_buf(buf) then return end local r = M.resolve(buf) diff --git a/lua/git/core/util.lua b/lua/git/core/util.lua index f75291a..ac18937 100644 --- a/lua/git/core/util.lua +++ b/lua/git/core/util.lua @@ -194,6 +194,65 @@ function M.debounce(fn, delay) } end +---@class ow.Git.Util.KeyedDebounceHandle +---@field cancel fun(key: K) +---@field flush fun(key: K) +---@field pending fun(key: K): boolean +---@field close fun() + +---@generic K, F: fun(key: K, ...) +---@param fn F +---@param delay integer +---@return F, ow.Git.Util.KeyedDebounceHandle +function M.keyed_debounce(fn, delay) + ---@type table + local slots = {} + + local function call(key, ...) + local t = type(key) + assert( + t == "string" or t == "number" or t == "boolean", + "key must be a primitive (string, number, boolean)" + ) + local slot = slots[key] + if not slot then + local c, h = M.debounce(function(...) + fn(key, ...) + end, delay) + slot = { call = c, handle = h } + slots[key] = slot + end + slot.call(...) + end + + return call, + { + cancel = function(key) + local slot = slots[key] + if slot then + slot.handle.close() + slots[key] = nil + end + end, + flush = function(key) + local slot = slots[key] + if slot then + slot.handle.flush() + end + end, + pending = function(key) + local slot = slots[key] + return slot ~= nil and slot.handle.pending() + end, + close = function() + for _, slot in pairs(slots) do + slot.handle.close() + end + slots = {} + end, + } +end + ---@class ow.Git.Util.ExecOpts ---@field cwd string? ---@field stdin string? diff --git a/lua/git/diff.lua b/lua/git/diffsplit.lua similarity index 97% rename from lua/git/diff.lua rename to lua/git/diffsplit.lua index 97f7a8d..d16e562 100644 --- a/lua/git/diff.lua +++ b/lua/git/diffsplit.lua @@ -5,7 +5,7 @@ local util = require("git.core.util") local M = {} ----@class ow.Git.Diff.SplitOpts +---@class ow.Git.Diffsplit.OpenOpts ---@field target string? ---@field mods vim.api.keyset.cmd.mods? @@ -107,8 +107,8 @@ local function default_split(cur_buf, target) return nil end ----@param opts? ow.Git.Diff.SplitOpts -function M.split(opts) +---@param opts? ow.Git.Diffsplit.OpenOpts +function M.open(opts) opts = opts or {} local cur_buf = vim.api.nvim_get_current_buf() local target, err diff --git a/lua/git/hunks.lua b/lua/git/hunks.lua new file mode 100644 index 0000000..6869dba --- /dev/null +++ b/lua/git/hunks.lua @@ -0,0 +1,608 @@ +local repo = require("git.core.repo") +local util = require("git.core.util") + +local M = {} + +local NS_SIGNS = vim.api.nvim_create_namespace("ow.git.hunks") +local NS_OVERLAY = vim.api.nvim_create_namespace("ow.git.hunks.overlay") + +---@alias ow.Git.Hunks.HunkType "add"|"change"|"delete" + +---@class ow.Git.Hunks.Hunk +---@field old_start integer +---@field old_count integer +---@field new_start integer +---@field new_count integer +---@field type ow.Git.Hunks.HunkType +---@field old_lines string[] +---@field new_lines string[] + +---@class ow.Git.Hunks.BufState +---@field repo ow.Git.Repo +---@field rel string +---@field index string[]? +---@field index_sha string? +---@field hunks ow.Git.Hunks.Hunk[] +---@field overlay boolean +---@field autocmds integer[] + +---@type table +local states = {} + +---@param buf integer +---@return ow.Git.Hunks.BufState? +function M.state(buf) + return states[buf] +end + +---@param buf integer? +---@return integer +local function resolve_buf(buf) + return buf and buf ~= 0 and buf or vim.api.nvim_get_current_buf() +end + +---@param state ow.Git.Hunks.BufState +---@param new_lines string[] +local function compute_hunks(state, new_lines) + local old = table.concat(state.index or {}, "\n") + local new = table.concat(new_lines, "\n") + local raw = vim.text.diff(old, new, { + result_type = "indices", + algorithm = "histogram", + }) + ---@type ow.Git.Hunks.Hunk[] + local hunks = {} + if type(raw) ~= "table" then + state.hunks = hunks + return + end + for _, h in ipairs(raw) do + local os_ = h[1] --[[@as integer]] + local oc = h[2] --[[@as integer]] + local ns_ = h[3] --[[@as integer]] + local nc = h[4] --[[@as integer]] + local typ ---@type ow.Git.Hunks.HunkType + if oc == 0 then + typ = "add" + elseif nc == 0 then + typ = "delete" + else + typ = "change" + end + local old_lines = {} + if typ ~= "add" and state.index then + for i = os_, os_ + oc - 1 do + table.insert(old_lines, state.index[i] or "") + end + end + local hunk_new = {} + if typ ~= "delete" then + for i = ns_, ns_ + nc - 1 do + table.insert(hunk_new, new_lines[i] or "") + end + end + table.insert(hunks, { + old_start = os_, + old_count = oc, + new_start = ns_, + new_count = nc, + type = typ, + old_lines = old_lines, + new_lines = hunk_new, + }) + end + state.hunks = hunks +end + +---@type table +local DEFAULT_SIGNS = { add = "┃", change = "┃", delete = "┃" } + +---@return table +local function resolve_signs() + local cfg = vim.g.git_hunk_signs + if type(cfg) ~= "table" then + return DEFAULT_SIGNS + end + return vim.tbl_extend("force", DEFAULT_SIGNS, cfg) +end + +---@param buf integer +local function render_signs(buf) + if not vim.api.nvim_buf_is_valid(buf) then + return + end + vim.api.nvim_buf_clear_namespace(buf, NS_SIGNS, 0, -1) + local state = states[buf] + if not state or state.overlay then + return + end + local signs = resolve_signs() + local line_count = vim.api.nvim_buf_line_count(buf) + for _, h in ipairs(state.hunks) do + if h.type == "delete" then + local row = math.max(h.new_start, 1) - 1 + if row >= line_count then + row = math.max(line_count - 1, 0) + end + pcall(vim.api.nvim_buf_set_extmark, buf, NS_SIGNS, row, 0, { + sign_text = signs.delete, + sign_hl_group = "GitHunkDelete", + priority = 100, + }) + else + local hl = h.type == "add" and "GitHunkAdd" or "GitHunkChange" + local sign = h.type == "add" and signs.add or signs.change + for r = h.new_start, h.new_start + h.new_count - 1 do + local row = r - 1 + if row >= 0 and row < line_count then + pcall( + vim.api.nvim_buf_set_extmark, + buf, + NS_SIGNS, + row, + 0, + { + sign_text = sign, + sign_hl_group = hl, + priority = 100, + } + ) + end + end + end + end +end + +---@param h ow.Git.Hunks.Hunk +---@return table[] +local function delete_virt_lines(h) + local width = vim.o.columns + local virt = {} + for _, line in ipairs(h.old_lines) do + local pad = math.max(width - vim.api.nvim_strwidth(line), 0) + table.insert(virt, { + { line .. string.rep(" ", pad), "GitHunkDeleteLine" }, + }) + end + return virt +end + +---@param buf integer +local function render_overlay(buf) + if not vim.api.nvim_buf_is_valid(buf) then + return + end + vim.api.nvim_buf_clear_namespace(buf, NS_OVERLAY, 0, -1) + local state = states[buf] + if not state or not state.overlay then + return + end + local line_count = vim.api.nvim_buf_line_count(buf) + for _, h in ipairs(state.hunks) do + if h.type ~= "delete" then + for r = h.new_start, h.new_start + h.new_count - 1 do + local row = r - 1 + if row >= 0 and row < line_count then + pcall( + vim.api.nvim_buf_set_extmark, + buf, + NS_OVERLAY, + row, + 0, + { + line_hl_group = "GitHunkAddLine", + priority = 100, + } + ) + end + end + end + if h.type ~= "add" then + local row, above + if h.type == "delete" then + if h.new_start <= 0 then + row, above = 0, true + elseif h.new_start >= line_count then + row, above = math.max(line_count - 1, 0), false + else + row, above = h.new_start, true + end + else + row, above = math.max(h.new_start - 1, 0), true + end + pcall(vim.api.nvim_buf_set_extmark, buf, NS_OVERLAY, row, 0, { + virt_lines = delete_virt_lines(h), + virt_lines_above = above, + right_gravity = false, + invalidate = true, + }) + end + end +end + +---@param buf integer +local function render(buf) + render_signs(buf) + render_overlay(buf) +end + +---@param state ow.Git.Hunks.BufState +---@param buf integer +---@param new_sha string +local function load_index_and_render(state, buf, new_sha) + util.git({ "cat-file", "-p", ":0:" .. state.rel }, { + cwd = state.repo.worktree, + silent = true, + on_exit = function(res) + if states[buf] ~= state or not vim.api.nvim_buf_is_valid(buf) then + return + end + if res.code ~= 0 then + state.index = {} + state.index_sha = nil + state.hunks = {} + render(buf) + return + end + state.index = util.split_lines(res.stdout or "") + state.index_sha = new_sha + local new_lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) + compute_hunks(state, new_lines) + render(buf) + end, + }) +end + +---@param buf integer +local function recompute(buf) + if not vim.api.nvim_buf_is_valid(buf) then + return + end + local state = states[buf] + if not state then + return + end + local r = state.repo + local new_sha = r:rev_parse(":" .. state.rel, true) + if not new_sha then + state.index = nil + state.index_sha = nil + state.hunks = {} + render(buf) + return + end + if new_sha == state.index_sha and state.index then + local new_lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) + compute_hunks(state, new_lines) + render(buf) + return + end + load_index_and_render(state, buf, new_sha) +end + +local schedule, sched_handle = util.keyed_debounce(recompute, 100) + +---@param buf integer +function M.attach(buf) + if states[buf] then + return + end + if not repo.is_worktree_buf(buf) then + return + end + local r = repo.find(buf) + if not r then + return + end + local rel = vim.fs.relpath(r.worktree, vim.fn.resolve(vim.api.nvim_buf_get_name(buf))) + if not rel then + return + end + ---@type ow.Git.Hunks.BufState + local state = { + repo = r, + rel = rel, + index = nil, + index_sha = nil, + hunks = {}, + overlay = vim.g.git_hunk_overlay_default == true, + autocmds = {}, + } + states[buf] = state + + local group = + vim.api.nvim_create_augroup("ow.git.hunks." .. buf, { clear = true }) + table.insert( + state.autocmds, + vim.api.nvim_create_autocmd({ "TextChanged", "TextChangedI" }, { + group = group, + buffer = buf, + callback = function() + schedule(buf) + end, + }) + ) + table.insert( + state.autocmds, + vim.api.nvim_create_autocmd("BufWritePost", { + group = group, + buffer = buf, + callback = function() + schedule(buf) + end, + }) + ) + + schedule(buf) +end + +---@param buf integer +function M.detach(buf) + local state = states[buf] + if not state then + return + end + if vim.api.nvim_buf_is_valid(buf) then + vim.api.nvim_buf_clear_namespace(buf, NS_SIGNS, 0, -1) + vim.api.nvim_buf_clear_namespace(buf, NS_OVERLAY, 0, -1) + end + for _, id in ipairs(state.autocmds) do + pcall(vim.api.nvim_del_autocmd, id) + end + pcall(vim.api.nvim_del_augroup_by_name, "ow.git.hunks." .. buf) + sched_handle.cancel(buf) + states[buf] = nil +end + +---@param buf integer? +function M.toggle_overlay(buf) + buf = resolve_buf(buf) + local state = states[buf] + if not state then + util.warning("git hunks: buffer not attached") + return + end + state.overlay = not state.overlay + render(buf) +end + +---@param buf? integer +---@param row integer 1-indexed cursor line +---@return ow.Git.Hunks.Hunk? +local function hunk_at(buf, row) + buf = buf or vim.api.nvim_get_current_buf() + local state = states[buf] + if not state then + return nil + end + for _, h in ipairs(state.hunks) do + if h.type == "delete" then + local anchor = math.max(h.new_start, 1) + if anchor == row then + return h + end + else + local lo = h.new_start + local hi = h.new_start + h.new_count - 1 + if row >= lo and row <= hi then + return h + end + end + end + return nil +end + +---@param buf integer? +---@return integer buf +---@return ow.Git.Hunks.BufState? state +---@return ow.Git.Hunks.Hunk? hunk +local function cursor_hunk(buf) + buf = resolve_buf(buf) + local state = states[buf] + if not state then + return buf, nil, nil + end + return buf, state, hunk_at(buf, vim.api.nvim_win_get_cursor(0)[1]) +end + +---@param h ow.Git.Hunks.Hunk +---@return integer +local function anchor_line(h) + if h.type == "delete" then + return math.max(h.new_start, 1) + end + return h.new_start +end + +---@param direction "next"|"prev" +function M.nav(direction) + local buf = vim.api.nvim_get_current_buf() + local state = states[buf] + if not state or #state.hunks == 0 then + return + end + local cur = vim.api.nvim_win_get_cursor(0)[1] + local hunks = state.hunks + local target = direction == "next" and hunks[1] or hunks[#hunks] + if direction == "next" then + for _, h in ipairs(hunks) do + if anchor_line(h) > cur then + target = h + break + end + end + else + for i = #hunks, 1, -1 do + if anchor_line(hunks[i]) < cur then + target = hunks[i] + break + end + end + end + if not target then + return + end + vim.api.nvim_win_set_cursor(0, { anchor_line(target), 0 }) +end + +---@param h ow.Git.Hunks.Hunk +---@param state ow.Git.Hunks.BufState +---@return string +local function build_patch(h, state) + local lines = { + "--- a/" .. state.rel, + "+++ b/" .. state.rel, + string.format( + "@@ -%d,%d +%d,%d @@", + h.old_start, + h.old_count, + h.new_start, + h.new_count + ), + } + for _, l in ipairs(h.old_lines) do + table.insert(lines, "-" .. l) + end + for _, l in ipairs(h.new_lines) do + table.insert(lines, "+" .. l) + end + return table.concat(lines, "\n") .. "\n" +end + +---@param buf? integer +function M.stage_hunk(buf) + local _, state, h = cursor_hunk(buf) + if not state then + return + end + if not h then + util.warning("git hunks: no hunk at cursor") + return + end + util.git({ "apply", "--cached", "--unidiff-zero", "-" }, { + cwd = state.repo.worktree, + stdin = build_patch(h, state), + on_exit = function(res) + if res.code ~= 0 then + util.error( + "git apply failed: %s", + vim.trim(res.stderr or "") + ) + end + end, + }) +end + +---@param buf? integer +function M.reset_hunk(buf) + local target, state, h = cursor_hunk(buf) + if not state then + return + end + if not h then + util.warning("git hunks: no hunk at cursor") + return + end + if h.type == "add" then + vim.api.nvim_buf_set_lines( + target, + h.new_start - 1, + h.new_start - 1 + h.new_count, + false, + {} + ) + elseif h.type == "delete" then + vim.api.nvim_buf_set_lines( + target, + h.new_start, + h.new_start, + false, + h.old_lines + ) + else + vim.api.nvim_buf_set_lines( + target, + h.new_start - 1, + h.new_start - 1 + h.new_count, + false, + h.old_lines + ) + end +end + +---@param buf? integer +function M.select_hunk(buf) + local _, _, h = cursor_hunk(buf) + if not h or h.type == "delete" then + return + end + local first = h.new_start + local last = h.new_start + math.max(h.new_count, 1) - 1 + vim.api.nvim_win_set_cursor(0, { first, 0 }) + vim.cmd("normal! V") + vim.api.nvim_win_set_cursor(0, { last, 0 }) +end + +---@param buf? integer +function M.preview_hunk(buf) + local _, state, h = cursor_hunk(buf) + if not state then + return + end + if not h then + return + end + local lines = util.split_lines(build_patch(h, state)) + local pbuf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(pbuf, 0, -1, false, lines) + vim.bo[pbuf].filetype = "diff" + vim.bo[pbuf].bufhidden = "wipe" + local width = 0 + for _, l in ipairs(lines) do + if #l > width then + width = #l + end + end + width = math.min(math.max(width + 2, 40), vim.o.columns - 4) + local height = math.min(#lines, math.floor(vim.o.lines / 2)) + local win = vim.api.nvim_open_win(pbuf, false, { + relative = "cursor", + row = 1, + col = 0, + width = width, + height = height, + style = "minimal", + }) + vim.api.nvim_create_autocmd({ "CursorMoved", "InsertEnter", "BufLeave" }, { + once = true, + callback = function() + if vim.api.nvim_win_is_valid(win) then + vim.api.nvim_win_close(win, true) + end + end, + }) + vim.keymap.set("n", "q", function() + if vim.api.nvim_win_is_valid(win) then + vim.api.nvim_win_close(win, true) + end + end, { buffer = pbuf, nowait = true }) +end + +repo.on("change", function(r, change) + for buf, state in pairs(states) do + if + state.repo == r + and (change.paths[state.rel] or change.branch_changed) + then + schedule(buf) + end + end +end) + +for _, buf in ipairs(vim.api.nvim_list_bufs()) do + if vim.api.nvim_buf_is_loaded(buf) then + M.attach(buf) + end +end + +return M diff --git a/lua/git/object.lua b/lua/git/object.lua index 7eb1325..7e03e7b 100644 --- a/lua/git/object.lua +++ b/lua/git/object.lua @@ -339,7 +339,7 @@ local function open_section(r, section) if left and right then vim.cmd.normal({ "m'", bang = true }) vim.api.nvim_set_current_buf(right) - require("git.diff").split({ + require("git.diffsplit").open({ target = vim.api.nvim_buf_get_name(left), mods = { vertical = true }, }) diff --git a/lua/git/status_view.lua b/lua/git/status_view.lua index 1e88cc6..cf951a5 100644 --- a/lua/git/status_view.lua +++ b/lua/git/status_view.lua @@ -1,5 +1,5 @@ local Revision = require("git.core.revision") -local diff = require("git.diff") +local diffsplit = require("git.diffsplit") local object = require("git.object") local repo = require("git.core.repo") local status = require("git.core.status") @@ -399,7 +399,7 @@ local function view_row(s, row, focus_left) local older = left.name or vim.api.nvim_buf_get_name(left.buf) local left_win vim.api.nvim_win_call(target, function() - diff.split({ + diffsplit.open({ target = older, mods = { vertical = true }, }) diff --git a/nvim-pack-lock.json b/nvim-pack-lock.json index 6279d0f..d439fda 100644 --- a/nvim-pack-lock.json +++ b/nvim-pack-lock.json @@ -134,6 +134,10 @@ "rev": "ae5199db47757f785e43a14b332118a5474de1a2", "src": "https://github.com/tree-sitter-grammars/tree-sitter-svelte" }, + "tree-sitter-tumblr": { + "rev": "45938c25e96351adf4140dce42795e61e944904e", + "src": "https://git.owall.dev/warg/tree-sitter-tumblr.git" + }, "tree-sitter-typescript": { "rev": "75b3874edb2dc714fb1fd77a32013d0f8699989f", "src": "https://github.com/tree-sitter/tree-sitter-typescript" @@ -149,6 +153,14 @@ "tree-sitter-zsh": { "rev": "86b37f8d515a529722411bc7bf3c9e993a4743bf", "src": "https://github.com/georgeharker/tree-sitter-zsh" + }, + "vim-flog": { + "rev": "665b16ac8915f746bc43c9572b4581a5e9047216", + "src": "https://github.com/rbong/vim-flog" + }, + "vim-fugitive": { + "rev": "3b753cf8c6a4dcde6edee8827d464ba9b8c4a6f0", + "src": "https://github.com/tpope/vim-fugitive" } } } diff --git a/plugin/git.lua b/plugin/git.lua index 5e9d967..17c7202 100644 --- a/plugin/git.lua +++ b/plugin/git.lua @@ -34,6 +34,12 @@ local DEFAULT_HIGHLIGHTS = { GitUnmergedBothModified = "GitUnmerged", GitUnmergedDeletedByThem = "GitUnmerged", GitUnmergedDeletedByUs = "GitUnmerged", + + GitHunkAdd = "Added", + GitHunkChange = "Changed", + GitHunkDelete = "Removed", + GitHunkAddLine = "DiffAdd", + GitHunkDeleteLine = "DiffDelete", } for name, link in pairs(DEFAULT_HIGHLIGHTS) do vim.api.nvim_set_hl(0, name, { link = link, default = true }) @@ -45,6 +51,7 @@ vim.api.nvim_create_autocmd({ "BufReadPost", "BufNewFile" }, { group = group, callback = function(args) require("git.core.repo").track(args.buf) + require("git.hunks").attach(args.buf) end, }) vim.api.nvim_create_autocmd({ "BufWritePost", "FileChangedShellPost" }, { @@ -64,6 +71,7 @@ vim.api.nvim_create_autocmd({ "ShellCmdPost", "TermLeave", "FocusGained" }, { vim.api.nvim_create_autocmd({ "BufDelete", "BufWipeout" }, { group = group, callback = function(args) + require("git.hunks").detach(args.buf) require("git.core.repo").unbind(args.buf) end, }) @@ -138,7 +146,7 @@ vim.api.nvim_create_user_command("Gdiffsplit", function(opts) mods = { vertical = false } rev_idx = 2 end - require("git.diff").split({ target = fargs[rev_idx], mods = mods }) + require("git.diffsplit").open({ target = fargs[rev_idx], mods = mods }) end, { nargs = "*", complete = function(arg_lead, cmd_line, _) @@ -199,24 +207,24 @@ vim.keymap.set("n", "(git-edit)", function() }) end, { silent = true, desc = "Edit a git object" }) -vim.keymap.set("n", "(git-diff-vertical)", function() - require("git.diff").split({ mods = { vertical = true } }) -end, { silent = true, desc = "Diff against index (vertical)" }) -vim.keymap.set("n", "(git-diff-horizontal)", function() - require("git.diff").split({ mods = { vertical = false } }) -end, { silent = true, desc = "Diff against index (horizontal)" }) -vim.keymap.set("n", "(git-diff-vertical-head)", function() - require("git.diff").split({ +vim.keymap.set("n", "(git-diffsplit-vertical)", function() + require("git.diffsplit").open({ mods = { vertical = true } }) +end, { silent = true, desc = "Open a diff split against index (vertical)" }) +vim.keymap.set("n", "(git-diffsplit-horizontal)", function() + require("git.diffsplit").open({ mods = { vertical = false } }) +end, { silent = true, desc = "Open a diff split against index (horizontal)" }) +vim.keymap.set("n", "(git-diffsplit-vertical-head)", function() + require("git.diffsplit").open({ target = "HEAD", mods = { vertical = true }, }) -end, { silent = true, desc = "Diff against HEAD (vertical)" }) -vim.keymap.set("n", "(git-diff-horizontal-head)", function() - require("git.diff").split({ +end, { silent = true, desc = "Open a diff split against HEAD (vertical)" }) +vim.keymap.set("n", "(git-diffsplit-horizontal-head)", function() + require("git.diffsplit").open({ target = "HEAD", mods = { vertical = false }, }) -end, { silent = true, desc = "Diff against HEAD (horizontal)" }) +end, { silent = true, desc = "Open a diff split against HEAD (horizontal)" }) vim.keymap.set("n", "(git-status-open)", function() require("git.status_view").open() @@ -245,3 +253,29 @@ if vim.g.git_statusline ~= false then end, }) end + +vim.keymap.set({ "n", "x" }, "(git-hunk-next)", function() + require("git.hunks").nav("next") +end, { silent = true, desc = "Jump to next git hunk" }) +vim.keymap.set({ "n", "x" }, "(git-hunk-prev)", function() + require("git.hunks").nav("prev") +end, { silent = true, desc = "Jump to previous git hunk" }) +vim.keymap.set("n", "(git-hunk-stage)", function() + require("git.hunks").stage_hunk() +end, { silent = true, desc = "Stage hunk under cursor" }) +vim.keymap.set("n", "(git-hunk-reset)", function() + require("git.hunks").reset_hunk() +end, { silent = true, desc = "Reset hunk under cursor" }) +vim.keymap.set("n", "(git-hunk-preview)", function() + require("git.hunks").preview_hunk() +end, { silent = true, desc = "Preview hunk under cursor" }) +vim.keymap.set("n", "(git-hunk-select)", function() + require("git.hunks").select_hunk() +end, { silent = true, desc = "Select hunk under cursor" }) +vim.keymap.set("n", "(git-overlay-toggle)", function() + require("git.hunks").toggle_overlay() +end, { silent = true, desc = "Toggle the git diff overlay" }) + +vim.api.nvim_create_user_command("GitDiffOverlay", function() + require("git.hunks").toggle_overlay() +end, { desc = "Toggle the git diff overlay in the current buffer" }) diff --git a/plugins/gitsigns.lua b/plugins/gitsigns.lua deleted file mode 100644 index f936be9..0000000 --- a/plugins/gitsigns.lua +++ /dev/null @@ -1,48 +0,0 @@ -require("gitsigns").setup({ - preview_config = { - border = "single", - }, - on_attach = function(bufnr) - local gs = require("gitsigns") - vim.keymap.set("n", "gv", gs.select_hunk, { buffer = bufnr }) - vim.keymap.set("n", "gs", gs.stage_hunk, { buffer = bufnr }) - vim.keymap.set("x", "gs", function() - gs.stage_hunk({ vim.fn.line("."), vim.fn.line("v") }) - end, { buffer = bufnr }) - vim.keymap.set("n", "gr", gs.reset_hunk, { buffer = bufnr }) - vim.keymap.set( - "x", - "gr", - ":Gitsigns reset_hunk", - { buffer = bufnr } - ) - vim.keymap.set("n", "g?", gs.preview_hunk, { buffer = bufnr }) - vim.keymap.set("n", "gb", function() - gs.blame_line({ full = true, ignore_whitespace = true }) - end, { buffer = bufnr }) - vim.keymap.set({ "n", "x" }, "]g", function() - gs.nav_hunk("next", { - wrap = true, - foldopen = true, - navigation_message = true, - greedy = true, - preview = true, - count = 1, - target = "all", - }) - end) - vim.keymap.set({ "n", "x" }, "[g", function() - gs.nav_hunk("prev", { - wrap = true, - foldopen = true, - navigation_message = true, - greedy = true, - preview = true, - count = 1, - target = "all", - }) - end) - end, - attach_to_untracked = false, - sign_priority = 100, -}) diff --git a/test/git/hunks_test.lua b/test/git/hunks_test.lua new file mode 100644 index 0000000..a43a45f --- /dev/null +++ b/test/git/hunks_test.lua @@ -0,0 +1,309 @@ +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 + +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: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("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) diff --git a/test/git/status_view_test.lua b/test/git/status_view_test.lua index 16a77a6..371780d 100644 --- a/test/git/status_view_test.lua +++ b/test/git/status_view_test.lua @@ -277,7 +277,7 @@ t.test( error("a non-sidebar window should remain after close") end vim.api.nvim_set_current_win(remaining) - require("git.diff").split({ mods = { vertical = true } }) + 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