Files
git.nvim/lua/git/hunks.lua
T
Oscar 351b5690c2 feat(hunks)!: highlight inline overlay diffs
BREAKING CHANGE: rename :GitDiffOverlay to :GitHunkOverlay and <Plug>(git-diff-overlay) to <Plug>(git-hunk-overlay).
2026-05-29 14:59:18 +02:00

1269 lines
36 KiB
Lua

local popup = require("git.core.popup")
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")
local NS_PREVIEW = vim.api.nvim_create_namespace("ow.git.hunks.preview")
local NS_PREVIEW_OVERFLOW =
vim.api.nvim_create_namespace("ow.git.hunks.preview.overflow")
---@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<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
---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
---@return boolean
local function inline_diff_enabled()
for _, item in ipairs(vim.split(vim.o.diffopt, ",", { plain = true })) do
if item == "inline:none" then
return false
end
end
return true
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<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
---@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
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 }
---@alias ow.Git.Hunks.InlineSpan { start_col: integer, end_col: integer }
---@param line string
---@return { text: string, start_col: integer, end_col: integer }[]
local function char_pieces(line)
local out = {}
local start = 1
for c in line:gmatch("[%z\1-\127\194-\244][\128-\191]*") do
local stop = start + #c - 1
table.insert(out, {
text = c,
start_col = start - 1,
end_col = stop,
})
start = stop + 1
end
if start <= #line then
table.insert(out, {
text = line:sub(start),
start_col = start - 1,
end_col = #line,
})
end
return out
end
---@param pieces { text: string, start_col: integer, end_col: integer }[]
---@param start_idx integer
---@param count integer
---@return ow.Git.Hunks.InlineSpan?
local function char_span(pieces, start_idx, count)
if count <= 0 then
return nil
end
local first = pieces[start_idx]
local last = pieces[start_idx + count - 1]
if not first or not last then
return nil
end
return { start_col = first.start_col, end_col = last.end_col }
end
---@param old_line string
---@param new_line string
---@return ow.Git.Hunks.InlineSpan[] old_spans
---@return ow.Git.Hunks.InlineSpan[] new_spans
local function inline_spans(old_line, new_line)
if old_line == new_line or not inline_diff_enabled() then
return {}, {}
end
local old_pieces = char_pieces(old_line)
local new_pieces = char_pieces(new_line)
if #old_pieces == 0 then
return {},
#new_pieces > 0 and { { start_col = 0, end_col = #new_line } } or {}
end
if #new_pieces == 0 then
return { { start_col = 0, end_col = #old_line } }, {}
end
local old_text = {}
for _, c in ipairs(old_pieces) do
table.insert(old_text, c.text)
end
local new_text = {}
for _, c in ipairs(new_pieces) do
table.insert(new_text, c.text)
end
local raw = vim.text.diff(
table.concat(old_text, "\n"),
table.concat(new_text, "\n"),
diff_opts()
)
if type(raw) ~= "table" then
return { { start_col = 0, end_col = #old_line } }, {
{ start_col = 0, end_col = #new_line },
}
end
local old_spans = {}
local new_spans = {}
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 old_span = char_span(old_pieces, os_, oc)
if old_span then
table.insert(old_spans, old_span)
end
local new_span = char_span(new_pieces, ns_, nc)
if new_span then
table.insert(new_spans, new_span)
end
end
return old_spans, new_spans
end
---@param hl string|string[]
---@return string[]
local function hl_list(hl)
if type(hl) == "table" then
return vim.list_extend({}, hl)
end
return { hl }
end
---@param col integer
---@param spans ow.Git.Hunks.InlineSpan[]
---@return boolean
local function in_inline_span(col, spans)
for _, span in ipairs(spans) do
if col >= span.start_col and col < span.end_col then
return true
end
end
return false
end
---@param line string
---@param chunks table[]
---@param spans ow.Git.Hunks.InlineSpan[]?
---@return table[]
local function apply_inline_to_chunks(line, chunks, spans)
if not spans or #spans == 0 then
return chunks
end
local out = {}
local col = 0
for _, chunk in ipairs(chunks) do
local text = chunk[1]
local hl = chunk[2]
local pos = 1
while pos <= #text do
local inline = in_inline_span(col, spans)
local stop = pos + 1
while
stop <= #text
and in_inline_span(col + stop - pos, spans) == inline
do
stop = stop + 1
end
local piece = text:sub(pos, stop - 1)
local piece_hl = hl
if inline then
piece_hl = hl_list(hl)
table.insert(piece_hl, "DiffText")
end
table.insert(out, { piece, piece_hl })
col = col + #piece
pos = stop
end
end
if col < #line then
return chunks
end
return out
end
---@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<integer, table<integer, string>>
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
---@param inline_by_line table<integer, ow.Git.Hunks.InlineSpan[]>?
---@return table[]
local function delete_virt_lines(h, hl_lines, inline_by_line)
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]
local inline = inline_by_line and inline_by_line[i]
if cached then
local chunks = apply_inline_to_chunks(
line,
vim.list_extend({}, cached),
inline
)
table.insert(chunks, {
string.rep(" ", pad),
"GitHunkDeleteLine",
})
table.insert(virt, chunks)
else
local chunks = apply_inline_to_chunks(
line,
{ { line, "GitHunkDeleteLine" } },
inline
)
table.insert(chunks, {
string.rep(" ", pad),
"GitHunkDeleteLine",
})
table.insert(virt, chunks)
end
end
return virt
end
---@param h ow.Git.Hunks.Hunk
---@return table<integer, ow.Git.Hunks.InlineSpan[]> old_by_line
---@return table<integer, ow.Git.Hunks.InlineSpan[]> new_by_line
local function hunk_inline_spans(h)
if h.type ~= "change" or #h.old_lines ~= #h.new_lines then
return {}, {}
end
local old_by_line = {}
local new_by_line = {}
for i = 1, math.min(#h.old_lines, #h.new_lines) do
local old_line = h.old_lines[i] or ""
local new_line = h.new_lines[i] or ""
local old_spans, new_spans = inline_spans(old_line, new_line)
old_by_line[i] = old_spans
new_by_line[i] = new_spans
end
return old_by_line, new_by_line
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
local old_inline, new_inline = hunk_inline_spans(h)
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,
}
)
for _, span in ipairs(new_inline[r - h.new_start + 1] or {}) do
pcall(
vim.api.nvim_buf_set_extmark,
buf,
NS_OVERLAY,
row,
span.start_col,
{
end_col = span.end_col,
hl_group = "DiffText",
priority = 101,
}
)
end
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, old_inline),
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 = false,
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 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
---@param include_header boolean?
---@param context integer?
---@return string[] patch lines
---@return boolean zero_context
local function build_patch_lines(h, old_lines, rel, include_header, context)
context = context or PATCH_CONTEXT
local old_before, new_before = hunk_offsets(h)
local pre = {}
for i = math.max(old_before - 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 + 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 = {}
if include_header ~= false then
lines = { "--- a/" .. rel, "+++ b/" .. rel }
end
vim.list_extend(lines, body)
return lines, #pre == 0 and #post == 0
end
---@param hunks ow.Git.Hunks.Hunk[]
---@param old_lines string[]
---@param rel string
---@param context integer?
---@return string patch
---@return boolean zero_context
local function build_patch(hunks, old_lines, rel, context)
local lines = { "--- a/" .. rel, "+++ b/" .. rel }
local zero_context = false
for _, h in ipairs(hunks) do
local body, zero = build_patch_lines(h, old_lines, rel, false, context)
vim.list_extend(lines, body)
zero_context = zero_context or zero
end
return table.concat(lines, "\n") .. "\n", zero_context
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 h ow.Git.Hunks.Hunk
---@return integer first 1-indexed buffer line
---@return integer last 1-indexed buffer line
local function hunk_range(h)
if h.type == "delete" then
local line = math.max(h.new_start, 1)
return line, line
end
return h.new_start, h.new_start + h.new_count - 1
end
---@param h ow.Git.Hunks.Hunk
---@param first integer
---@param last integer
---@return boolean
local function hunk_overlaps(h, first, last)
local h_first, h_last = hunk_range(h)
return h_first <= last and h_last >= first
end
---@param state ow.Git.Hunks.BufState
---@param buf integer
---@param first integer
---@param last integer
---@return ow.Git.Hunks.Hunk[]
local function staged_hunks_in_range(state, buf, first, last)
local out = {}
local seen = {}
local line_count = vim.api.nvim_buf_line_count(buf)
for _, s in ipairs(staged_signs(state, line_count)) do
local line = s.row + 1
if line >= first and line <= last and not seen[s.hunk] then
seen[s.hunk] = true
table.insert(out, s.hunk)
end
end
return out
end
---@param first integer
---@param last integer
---@param buf? integer
function M.toggle_stage_range(first, last, buf)
buf = resolve_buf(buf)
local state = states[buf]
if not state or not state.index then
return
end
if first > last then
first, last = last, first
end
local unstaged = {}
for _, h in ipairs(state.hunks) do
if hunk_overlaps(h, first, last) then
table.insert(unstaged, h)
end
end
if #unstaged > 0 then
local context = #unstaged > 1 and 0 or nil
local patch, zero =
build_patch(unstaged, state.index, state.rel, context)
apply_patch(state, buf, patch, zero)
return
end
local staged = staged_hunks_in_range(state, buf, first, last)
if #staged > 0 then
local inverted = {}
for _, h in ipairs(staged) do
table.insert(inverted, invert(h))
end
local context = #inverted > 1 and 0 or nil
local patch, zero =
build_patch(inverted, state.index, state.rel, context)
apply_patch(state, buf, patch, zero)
return
end
util.warning("git hunks: no hunk in selection")
end
---@param buf? integer
function M.toggle_stage(buf)
local row = vim.api.nvim_win_get_cursor(0)[1]
M.toggle_stage_range(row, row, buf)
end
---@param buf? integer
function M.toggle_stage_selection(buf)
local cursor_line = vim.api.nvim_win_get_cursor(0)[1]
M.toggle_stage_range(vim.fn.line("v"), cursor_line, buf)
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].bufhidden = "wipe"
util.paint_diff_lines(pbuf, NS_PREVIEW, lines, 0)
local width, height = popup.size_for(lines, { min_width = 40, padding = 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
popup.paint_overflow(pbuf, win, NS_PREVIEW_OVERFLOW)
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.api.nvim_create_autocmd("WinScrolled", {
group = group,
pattern = tostring(win),
callback = function()
popup.paint_overflow(pbuf, win, NS_PREVIEW_OVERFLOW)
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