971 lines
27 KiB
Lua
971 lines
27 KiB
Lua
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<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
|
|
|
|
---@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 }
|
|
|
|
---@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
|
|
---@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
|