feat(git): add in-house hunks module, replace gitsigns.nvim

This commit is contained in:
2026-05-20 06:10:17 +02:00
parent d979c961a2
commit f4181b89fc
13 changed files with 1055 additions and 75 deletions
+608
View File
@@ -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<integer, ow.Git.Hunks.BufState>
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<ow.Git.Hunks.HunkType, string>
local DEFAULT_SIGNS = { add = "", change = "", delete = "" }
---@return table<ow.Git.Hunks.HunkType, string>
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