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 1-indexed first old line ---@field old_count integer ---@field new_start integer 1-indexed first new line ---@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 head string[]? ---@field head_sha string? ---@field index_hl { src: string[], lines: table[][]? }? ---@field hunks ow.Git.Hunks.Hunk[] ---@field staged 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 ---Mirror the hunk-affecting parts of the user's 'diffopt' so the gutter ---lines up with what `:diffsplit` shows. ---@return table local function diff_opts() local opts = { result_type = "indices", algorithm = "myers" } for _, item in ipairs(vim.split(vim.o.diffopt, ",", { plain = true })) do if item == "indent-heuristic" then opts.indent_heuristic = true else local algorithm = item:match("^algorithm:(.+)$") if algorithm then opts.algorithm = algorithm end local linematch = item:match("^linematch:(%d+)$") if linematch then opts.linematch = tonumber(linematch) end end end return opts end ---@param old_lines string[] ---@param new_lines string[] ---@return ow.Git.Hunks.Hunk[] local function compute_hunks(old_lines, new_lines) local raw = vim.text.diff( table.concat(old_lines, "\n"), table.concat(new_lines, "\n"), diff_opts() ) ---@type ow.Git.Hunks.Hunk[] local hunks = {} if type(raw) ~= "table" then return hunks 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 = {} if typ ~= "add" then for i = os_, os_ + oc - 1 do table.insert(old, old_lines[i] or "") end end local new = {} if typ ~= "delete" then for i = ns_, ns_ + nc - 1 do table.insert(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, new_lines = new, }) end return 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 ---@type table local SIGN_HL = { add = "GitHunkAdded", change = "GitHunkChanged", delete = "GitHunkRemoved", } ---@type table local STAGED_SIGN_HL = { add = "GitHunkStagedAdded", change = "GitHunkStagedChanged", delete = "GitHunkStagedRemoved", } ---@param h ow.Git.Hunks.Hunk ---@param line_count integer ---@return integer[] 0-indexed buffer rows for the hunk local function hunk_rows(h, line_count) 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 return { row } end local rows = {} 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 table.insert(rows, row) end end return rows end ---@param h ow.Git.Hunks.Hunk ---@return integer 1-indexed last index line the hunk occupies local function index_end(h) if h.old_count == 0 then return h.old_start end return h.old_start + h.old_count - 1 end ---@param unstaged ow.Git.Hunks.Hunk[] ---@param iline integer 1-indexed index line ---@return integer? 1-indexed buffer line local function index_to_buffer(unstaged, iline) local delta = 0 for _, h in ipairs(unstaged) do if h.old_count > 0 and iline >= h.old_start and iline <= index_end(h) then return nil end if iline > index_end(h) then delta = delta + h.new_count - h.old_count end end return iline + delta end ---@param state ow.Git.Hunks.BufState ---@param line_count integer ---@return { row: integer, hunk: ow.Git.Hunks.Hunk }[] row is a 0-indexed buffer row local function staged_signs(state, line_count) local out = {} for _, h in ipairs(state.staged) do local index_lines = {} if h.type == "delete" then table.insert(index_lines, math.max(h.new_start, 1)) else for i = h.new_start, h.new_start + h.new_count - 1 do table.insert(index_lines, i) end end for _, iline in ipairs(index_lines) do local bline = index_to_buffer(state.hunks, iline) if bline then local row = math.min(math.max(bline - 1, 0), line_count - 1) table.insert(out, { row = row, hunk = h }) end end end return out 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) local signed = {} for _, h in ipairs(state.hunks) do for _, row in ipairs(hunk_rows(h, line_count)) do signed[row] = true pcall(vim.api.nvim_buf_set_extmark, buf, NS_SIGNS, row, 0, { sign_text = signs[h.type], sign_hl_group = SIGN_HL[h.type], priority = 100, }) end end for _, s in ipairs(staged_signs(state, line_count)) do if not signed[s.row] then pcall(vim.api.nvim_buf_set_extmark, buf, NS_SIGNS, s.row, 0, { sign_text = signs[s.hunk.type], sign_hl_group = STAGED_SIGN_HL[s.hunk.type], priority = 100, }) end end end local SKIP_CAPTURES = { spell = true, nospell = true, conceal = true } ---@param buf integer ---@param lines string[] ---@return table[][]? local function highlight_index(buf, lines) if not vim.treesitter.highlighter.active[buf] then return nil end local got, parser = pcall(vim.treesitter.get_parser, buf) if not got or not parser then return nil end local lang = parser:lang() local query = vim.treesitter.query.get(lang, "highlights") if not query then return nil end local source = table.concat(lines, "\n") local got_root, root = pcall(function() local trees = vim.treesitter.get_string_parser(source, lang):parse() local tree = trees and trees[1] return tree and tree:root() end) if not got_root or not root then return nil end ---@type table> local groups = {} for id, node in query:iter_captures(root, source) do local name = query.captures[id] if name and name:sub(1, 1) ~= "_" and not SKIP_CAPTURES[name] then local sr, sc, er, ec = node:range() for row = sr, math.min(er, #lines - 1) do local row_groups = groups[row] or {} groups[row] = row_groups local from = row == sr and sc or 0 local to = row == er and ec or #(lines[row + 1] or "") for col = from, to - 1 do row_groups[col] = name end end end end local out = {} for row = 0, #lines - 1 do local line = lines[row + 1] or "" local row_groups = groups[row] or {} local chunks = {} local col = 0 while col < #line do local name = row_groups[col] local stop = col + 1 while stop < #line and row_groups[stop] == name do stop = stop + 1 end local hl ---@type string|string[] if name then hl = { "GitHunkDeleteLine", "@" .. name } else hl = "GitHunkDeleteLine" end table.insert(chunks, { line:sub(col + 1, stop), hl }) col = stop end out[row + 1] = chunks end return out end ---@param h ow.Git.Hunks.Hunk ---@param hl_lines table[][]? per-index-line syntax chunks, or nil ---@return table[] local function delete_virt_lines(h, hl_lines) local width = vim.o.columns local virt = {} for i, line in ipairs(h.old_lines) do local pad = math.max(width - vim.api.nvim_strwidth(line), 0) local cached = hl_lines and hl_lines[h.old_start + i - 1] if cached then local chunks = vim.list_extend({}, cached) table.insert(chunks, { string.rep(" ", pad), "GitHunkDeleteLine", }) table.insert(virt, chunks) else table.insert(virt, { { line .. string.rep(" ", pad), "GitHunkDeleteLine" }, }) end end return virt end ---@param state ow.Git.Hunks.BufState ---@param buf integer ---@return table[][]? local function index_spans(state, buf) if not state.index then return nil end local cache = state.index_hl if cache and cache.src == state.index then return cache.lines end local lines = highlight_index(buf, state.index) state.index_hl = { src = state.index, lines = lines } return lines 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) local hl_lines = index_spans(state, 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, hl_lines), 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 rev string ---@param want string? the wanted blob sha ---@param have string? the currently-loaded blob sha ---@param apply fun(lines: string[]?, sha: string?) ---@param after fun() local function ensure_content(state, buf, rev, want, have, apply, after) if not want then apply(nil, nil) return after() end if want == have then return after() end util.git({ "cat-file", "-p", rev }, { 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 apply(util.split_lines(res.stdout or ""), want) else apply(nil, nil) end after() 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 ensure_content( state, buf, ":0:" .. state.rel, r:index_sha(state.rel), state.index_sha, function(lines, sha) state.index = lines state.index_sha = sha end, function() ensure_content( state, buf, "HEAD:" .. state.rel, r:head_sha(state.rel), state.head_sha, function(lines, sha) state.head = lines state.head_sha = sha end, function() local new = vim.api.nvim_buf_get_lines(buf, 0, -1, false) state.hunks = state.index and compute_hunks(state.index, new) or {} state.staged = state.head and state.index and compute_hunks(state.head, state.index) or {} render(buf) end ) end ) end local schedule, sched_handle = util.keyed_debounce(recompute, 100) ---@param buf integer function M._flush(buf) sched_handle.flush(buf) end ---@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, head = nil, head_sha = nil, hunks = {}, staged = {}, 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 hunks ow.Git.Hunks.Hunk[] ---@param row integer 1-indexed cursor line ---@return ow.Git.Hunks.Hunk? local function hunk_at(hunks, row) for _, h in ipairs(hunks) do if h.type == "delete" then if math.max(h.new_start, 1) == row then return h end elseif row >= h.new_start and row <= h.new_start + h.new_count - 1 then return h end end return nil end ---@param state ow.Git.Hunks.BufState ---@param buf integer ---@param row integer 1-indexed cursor line ---@return ow.Git.Hunks.Hunk? local function staged_hunk_at(state, buf, row) local line_count = vim.api.nvim_buf_line_count(buf) for _, s in ipairs(staged_signs(state, line_count)) do if s.row == row - 1 then return s.hunk 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(state.hunks, vim.api.nvim_win_get_cursor(0)[1]) end ---@param h ow.Git.Hunks.Hunk ---@return integer 1-indexed buffer line to anchor the cursor on 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 ---@return string[] local function hunk_body(h) local lines = { 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 lines end local PATCH_CONTEXT = 3 ---@param h ow.Git.Hunks.Hunk ---@return integer old_before count of old lines before the hunk's changed content ---@return integer new_before count of new lines before the hunk's changed content local function hunk_offsets(h) if h.type == "add" then return h.old_start, h.new_start - 1 elseif h.type == "delete" then return h.old_start - 1, h.new_start end return h.old_start - 1, h.new_start - 1 end ---@param h ow.Git.Hunks.Hunk ---@return ow.Git.Hunks.Hunk local function invert(h) local typ ---@type ow.Git.Hunks.HunkType if h.type == "add" then typ = "delete" elseif h.type == "delete" then typ = "add" else typ = "change" end return { old_start = h.new_start, old_count = h.new_count, new_start = h.old_start, new_count = h.old_count, type = typ, old_lines = h.new_lines, new_lines = h.old_lines, } end ---@param h ow.Git.Hunks.Hunk ---@param old_lines string[] ---@param rel string ---@return string patch ---@return boolean zero_context local function build_patch(h, old_lines, rel) local old_before, new_before = hunk_offsets(h) local pre = {} for i = math.max(old_before - PATCH_CONTEXT + 1, 1), old_before do pre[#pre + 1] = old_lines[i] or "" end local post = {} local after = old_before + h.old_count for i = after + 1, math.min(after + PATCH_CONTEXT, #old_lines) do post[#post + 1] = old_lines[i] or "" end local old_n = #pre + h.old_count + #post local new_n = #pre + h.new_count + #post local old_start = old_n > 0 and old_before - #pre + 1 or old_before local new_start = new_n > 0 and new_before - #pre + 1 or new_before local body = { string.format( "@@ -%d,%d +%d,%d @@", old_start, old_n, new_start, new_n ), } for _, l in ipairs(pre) do body[#body + 1] = " " .. l end for _, l in ipairs(h.old_lines) do body[#body + 1] = "-" .. l end for _, l in ipairs(h.new_lines) do body[#body + 1] = "+" .. l end for _, l in ipairs(post) do body[#body + 1] = " " .. l end local lines = { "--- a/" .. rel, "+++ b/" .. rel } vim.list_extend(lines, body) return table.concat(lines, "\n") .. "\n", #pre == 0 and #post == 0 end ---@param state ow.Git.Hunks.BufState ---@param buf integer ---@param patch string ---@param zero_context boolean local function apply_patch(state, buf, patch, zero_context) local args = { "apply", "--cached" } if zero_context then table.insert(args, "--unidiff-zero") end table.insert(args, "-") util.git(args, { cwd = state.repo.worktree, stdin = patch, on_exit = function(res) if res.code ~= 0 then util.error("git apply failed: %s", vim.trim(res.stderr or "")) return end local s = states[buf] if s then s.index_sha = nil schedule(buf) end end, }) end ---@param buf? integer function M.toggle_stage(buf) buf = resolve_buf(buf) local state = states[buf] if not state then return end local row = vim.api.nvim_win_get_cursor(0)[1] local unstaged = hunk_at(state.hunks, row) if unstaged and state.index then local patch, zero = build_patch(unstaged, state.index, state.rel) apply_patch(state, buf, patch, zero) return end local staged = staged_hunk_at(state, buf, row) if staged and state.index then local patch, zero = build_patch(invert(staged), state.index, state.rel) apply_patch(state, buf, patch, zero) return end util.warning("git hunks: no hunk at cursor") 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 local preview_win ---@type integer? ---@param buf? integer function M.preview_hunk(buf) if preview_win and vim.api.nvim_win_is_valid(preview_win) then vim.api.nvim_set_current_win(preview_win) return end local target, state, h = cursor_hunk(buf) if not state then return end if not h then return end local lines = hunk_body(h) 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", }) preview_win = win local function close() if vim.api.nvim_win_is_valid(win) then vim.api.nvim_win_close(win, true) end end local group = vim.api.nvim_create_augroup("ow.git.hunks.preview", { clear = true }) vim.api.nvim_create_autocmd( { "CursorMoved", "CursorMovedI", "InsertEnter" }, { group = group, buffer = target, callback = close } ) vim.api.nvim_create_autocmd("WinLeave", { group = group, buffer = pbuf, callback = close, }) vim.api.nvim_create_autocmd("WinClosed", { group = group, pattern = tostring(win), callback = function() preview_win = nil pcall(vim.api.nvim_del_augroup_by_name, "ow.git.hunks.preview") end, }) vim.keymap.set("n", "q", close, { 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