feat(git): show staged hunks in the gutter with a stage toggle

This commit is contained in:
2026-05-20 12:46:44 +02:00
parent 7c92b5eff6
commit 1a582045f6
3 changed files with 578 additions and 122 deletions
+325 -117
View File
@@ -9,9 +9,9 @@ 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_start integer 1-indexed first old line
---@field old_count integer
---@field new_start integer
---@field new_start integer 1-indexed first new line
---@field new_count integer
---@field type ow.Git.Hunks.HunkType
---@field old_lines string[]
@@ -22,8 +22,11 @@ local NS_OVERLAY = vim.api.nvim_create_namespace("ow.git.hunks.overlay")
---@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[]
@@ -64,17 +67,19 @@ local function diff_opts()
return opts
end
---@param state ow.Git.Hunks.BufState
---@param old_lines string[]
---@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, diff_opts())
---@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
state.hunks = hunks
return
return hunks
end
for _, h in ipairs(raw) do
local os_ = h[1] --[[@as integer]]
@@ -89,16 +94,16 @@ local function compute_hunks(state, new_lines)
else
typ = "change"
end
local old_lines = {}
if typ ~= "add" and state.index then
local old = {}
if typ ~= "add" then
for i = os_, os_ + oc - 1 do
table.insert(old_lines, state.index[i] or "")
table.insert(old, old_lines[i] or "")
end
end
local hunk_new = {}
local new = {}
if typ ~= "delete" then
for i = ns_, ns_ + nc - 1 do
table.insert(hunk_new, new_lines[i] or "")
table.insert(new, new_lines[i] or "")
end
end
table.insert(hunks, {
@@ -107,11 +112,11 @@ local function compute_hunks(state, new_lines)
new_start = ns_,
new_count = nc,
type = typ,
old_lines = old_lines,
new_lines = hunk_new,
old_lines = old,
new_lines = new,
})
end
state.hunks = hunks
return hunks
end
---@type table<ow.Git.Hunks.HunkType, string>
@@ -126,6 +131,95 @@ local function resolve_signs()
return vim.tbl_extend("force", DEFAULT_SIGNS, cfg)
end
---@type table<ow.Git.Hunks.HunkType, string>
local SIGN_HL = {
add = "GitHunkAdded",
change = "GitHunkChanged",
delete = "GitHunkRemoved",
}
---@type table<ow.Git.Hunks.HunkType, string>
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
@@ -138,37 +232,24 @@ local function render_signs(buf)
end
local signs = resolve_signs()
local line_count = vim.api.nvim_buf_line_count(buf)
local signed = {}
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
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.delete,
sign_hl_group = "GitHunkRemoved",
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,
})
else
local hl = h.type == "add" and "GitHunkAdded" or "GitHunkChanged"
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
@@ -346,27 +427,32 @@ 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 }, {
---@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
state.index = {}
state.index_sha = nil
state.hunks = {}
render(buf)
return
if res.code == 0 then
apply(util.split_lines(res.stdout or ""), want)
else
apply(nil, nil)
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)
after()
end,
})
end
@@ -380,21 +466,43 @@ local function recompute(buf)
if not state then
return
end
local new_sha = state.repo:index_sha(state.rel)
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)
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)
@@ -426,7 +534,10 @@ function M.attach(buf)
rel = rel,
index = nil,
index_sha = nil,
head = nil,
head_sha = nil,
hunks = {},
staged = {},
overlay = vim.g.git_hunk_overlay_default == true,
autocmds = {},
}
@@ -488,27 +599,31 @@ function M.toggle_overlay(buf)
render(buf)
end
---@param buf? integer
---@param hunks ow.Git.Hunks.Hunk[]
---@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
local function hunk_at(hunks, row)
for _, h in ipairs(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
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
@@ -524,11 +639,11 @@ local function cursor_hunk(buf)
if not state then
return buf, nil, nil
end
return buf, state, hunk_at(buf, vim.api.nvim_win_get_cursor(0)[1])
return buf, state, hunk_at(state.hunks, vim.api.nvim_win_get_cursor(0)[1])
end
---@param h ow.Git.Hunks.Hunk
---@return integer
---@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)
@@ -588,43 +703,136 @@ local function hunk_body(h)
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
---@return string
local function build_patch(h, state)
local lines = { "--- a/" .. state.rel, "+++ b/" .. state.rel }
vim.list_extend(lines, hunk_body(h))
return table.concat(lines, "\n") .. "\n"
---@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.stage_hunk(buf)
local target, state, h = cursor_hunk(buf)
buf = resolve_buf(buf)
local state = states[buf]
if not state then
return
end
if not h then
util.warning("git hunks: no hunk at cursor")
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
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 "")
)
return
end
local s = states[target]
if s then
s.index_sha = nil
schedule(target)
end
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