Compare commits
12 Commits
d979c961a2
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5b869334d6 | |||
| 01ca0025dd | |||
| b52f34ce9a | |||
| e050896dc0 | |||
| 4c8b3f0d3e | |||
| 72ab9059fa | |||
| 7c8975af10 | |||
| 2064c629ed | |||
| aaef6621dd | |||
| d629302625 | |||
| f77d26db6b | |||
| f4181b89fc |
@@ -30,7 +30,6 @@ require("pack").setup({
|
||||
"https://github.com/owallb/mason-auto-install.nvim",
|
||||
"https://github.com/mfussenegger/nvim-dap",
|
||||
"https://github.com/numToStr/Comment.nvim",
|
||||
"https://github.com/lewis6991/gitsigns.nvim",
|
||||
"https://github.com/MagicDuck/grug-far.nvim",
|
||||
"https://github.com/nvim-tree/nvim-tree.lua",
|
||||
"https://github.com/stevearc/oil.nvim",
|
||||
|
||||
+11
-4
@@ -226,11 +226,18 @@ vim.keymap.set("n", "<leader>fD", vim.diagnostic.setqflist)
|
||||
vim.keymap.set("n", "grt", vim.lsp.buf.type_definition)
|
||||
vim.keymap.set("n", "gd", vim.lsp.buf.definition)
|
||||
|
||||
vim.keymap.set("n", "<leader>gd", "<Plug>(git-diff-vertical)")
|
||||
vim.keymap.set("n", "<leader>gD", "<Plug>(git-diff-vertical-head)")
|
||||
vim.keymap.set("n", "<leader>gh", "<Plug>(git-diff-horizontal)")
|
||||
vim.keymap.set("n", "<leader>gH", "<Plug>(git-diff-horizontal-head)")
|
||||
vim.keymap.set("n", "<leader>gd", "<Plug>(git-diffsplit-vertical)")
|
||||
vim.keymap.set("n", "<leader>gD", "<Plug>(git-diffsplit-vertical-head)")
|
||||
vim.keymap.set("n", "<leader>gh", "<Plug>(git-diffsplit-horizontal)")
|
||||
vim.keymap.set("n", "<leader>gH", "<Plug>(git-diffsplit-horizontal-head)")
|
||||
vim.keymap.set("n", "<leader>gg", "<Plug>(git-status-toggle)")
|
||||
vim.keymap.set("n", "<leader>gc", "<Plug>(git-commit)")
|
||||
vim.keymap.set("n", "<leader>ga", "<Plug>(git-commit-amend)")
|
||||
vim.keymap.set("n", "<leader>gl", "<Plug>(git-log)")
|
||||
vim.keymap.set("n", "<leader>gv", "<Plug>(git-hunk-select)")
|
||||
vim.keymap.set("n", "<leader>gs", "<Plug>(git-hunk-stage)")
|
||||
vim.keymap.set("n", "<leader>gr", "<Plug>(git-hunk-reset)")
|
||||
vim.keymap.set("n", "<C-w>g", "<Plug>(git-hunk-preview)")
|
||||
vim.keymap.set("n", "<leader>go", "<Plug>(git-overlay-toggle)")
|
||||
vim.keymap.set({ "n", "x" }, "]g", "<Plug>(git-hunk-next)")
|
||||
vim.keymap.set({ "n", "x" }, "[g", "<Plug>(git-hunk-prev)")
|
||||
|
||||
+24
-6
@@ -123,6 +123,16 @@ local function affects_resolve(relpath)
|
||||
or relpath == "FETCH_HEAD"
|
||||
end
|
||||
|
||||
---@private
|
||||
---@param prefix string
|
||||
function Repo:_clear_cache_prefix(prefix)
|
||||
for key in pairs(self._cache) do
|
||||
if vim.startswith(key, prefix) then
|
||||
self._cache[key] = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
---@private
|
||||
---@param relpath string
|
||||
function Repo:_invalidate(relpath)
|
||||
@@ -132,11 +142,10 @@ function Repo:_invalidate(relpath)
|
||||
end
|
||||
end
|
||||
if affects_resolve(relpath) then
|
||||
for key in pairs(self._cache) do
|
||||
if vim.startswith(key, "resolve:") then
|
||||
self._cache[key] = nil
|
||||
end
|
||||
self:_clear_cache_prefix("resolve:")
|
||||
end
|
||||
if relpath == "index" then
|
||||
self:_clear_cache_prefix("index:")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -587,6 +596,15 @@ function Repo:rev_parse(rev, short)
|
||||
return trimmed ~= "" and trimmed or nil
|
||||
end
|
||||
|
||||
---@param rel string worktree-relative path
|
||||
---@return string?
|
||||
function Repo:index_sha(rel)
|
||||
local sha = self:get_cached("index:" .. rel, function(self)
|
||||
return self:rev_parse(":" .. rel, false) or false
|
||||
end)
|
||||
return sha or nil
|
||||
end
|
||||
|
||||
---@alias ow.Git.Repo.ResolveStatus "ok"|"ambiguous"|"missing"
|
||||
|
||||
---@param abbrev string
|
||||
@@ -819,7 +837,7 @@ end
|
||||
|
||||
---@param buf integer
|
||||
---@return boolean
|
||||
local function is_worktree_buf(buf)
|
||||
function M.is_worktree_buf(buf)
|
||||
if not vim.api.nvim_buf_is_valid(buf) or vim.bo[buf].buftype ~= "" then
|
||||
return false
|
||||
end
|
||||
@@ -830,7 +848,7 @@ end
|
||||
---@param buf? integer
|
||||
function M.track(buf)
|
||||
buf = expand_buf(buf)
|
||||
if not is_worktree_buf(buf) then
|
||||
if not M.is_worktree_buf(buf) then
|
||||
return
|
||||
end
|
||||
local r = M.resolve(buf)
|
||||
|
||||
@@ -194,6 +194,65 @@ function M.debounce(fn, delay)
|
||||
}
|
||||
end
|
||||
|
||||
---@class ow.Git.Util.KeyedDebounceHandle<K>
|
||||
---@field cancel fun(key: K)
|
||||
---@field flush fun(key: K)
|
||||
---@field pending fun(key: K): boolean
|
||||
---@field close fun()
|
||||
|
||||
---@generic K, F: fun(key: K, ...)
|
||||
---@param fn F
|
||||
---@param delay integer
|
||||
---@return F, ow.Git.Util.KeyedDebounceHandle<K>
|
||||
function M.keyed_debounce(fn, delay)
|
||||
---@type table<any, { call: fun(...), handle: ow.Git.Util.DebounceHandle }>
|
||||
local slots = {}
|
||||
|
||||
local function call(key, ...)
|
||||
local t = type(key)
|
||||
assert(
|
||||
t == "string" or t == "number" or t == "boolean",
|
||||
"key must be a primitive (string, number, boolean)"
|
||||
)
|
||||
local slot = slots[key]
|
||||
if not slot then
|
||||
local c, h = M.debounce(function(...)
|
||||
fn(key, ...)
|
||||
end, delay)
|
||||
slot = { call = c, handle = h }
|
||||
slots[key] = slot
|
||||
end
|
||||
slot.call(...)
|
||||
end
|
||||
|
||||
return call,
|
||||
{
|
||||
cancel = function(key)
|
||||
local slot = slots[key]
|
||||
if slot then
|
||||
slot.handle.close()
|
||||
slots[key] = nil
|
||||
end
|
||||
end,
|
||||
flush = function(key)
|
||||
local slot = slots[key]
|
||||
if slot then
|
||||
slot.handle.flush()
|
||||
end
|
||||
end,
|
||||
pending = function(key)
|
||||
local slot = slots[key]
|
||||
return slot ~= nil and slot.handle.pending()
|
||||
end,
|
||||
close = function()
|
||||
for _, slot in pairs(slots) do
|
||||
slot.handle.close()
|
||||
end
|
||||
slots = {}
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
---@class ow.Git.Util.ExecOpts
|
||||
---@field cwd string?
|
||||
---@field stdin string?
|
||||
|
||||
@@ -5,7 +5,7 @@ local util = require("git.core.util")
|
||||
|
||||
local M = {}
|
||||
|
||||
---@class ow.Git.Diff.SplitOpts
|
||||
---@class ow.Git.Diffsplit.OpenOpts
|
||||
---@field target string?
|
||||
---@field mods vim.api.keyset.cmd.mods?
|
||||
|
||||
@@ -107,8 +107,8 @@ local function default_split(cur_buf, target)
|
||||
return nil
|
||||
end
|
||||
|
||||
---@param opts? ow.Git.Diff.SplitOpts
|
||||
function M.split(opts)
|
||||
---@param opts? ow.Git.Diffsplit.OpenOpts
|
||||
function M.open(opts)
|
||||
opts = opts or {}
|
||||
local cur_buf = vim.api.nvim_get_current_buf()
|
||||
local target, err
|
||||
@@ -0,0 +1,762 @@
|
||||
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 index_hl { src: string[], lines: table[][]? }?
|
||||
---@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
|
||||
|
||||
---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 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, diff_opts())
|
||||
---@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 = "GitHunkRemoved",
|
||||
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
|
||||
|
||||
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 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 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)
|
||||
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,
|
||||
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
|
||||
---@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
|
||||
|
||||
---@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 }
|
||||
vim.list_extend(lines, hunk_body(h))
|
||||
return table.concat(lines, "\n") .. "\n"
|
||||
end
|
||||
|
||||
---@param buf? integer
|
||||
function M.stage_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
|
||||
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,
|
||||
})
|
||||
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
|
||||
+2
-3
@@ -184,8 +184,7 @@ local function populate(buf, r, rev, state, rev_sha)
|
||||
local patch = util.git({
|
||||
"diff-tree",
|
||||
"-p",
|
||||
"-m",
|
||||
"--first-parent",
|
||||
"--diff-merges=first-parent",
|
||||
"--root",
|
||||
"--no-commit-id",
|
||||
commit_sha,
|
||||
@@ -339,7 +338,7 @@ local function open_section(r, section)
|
||||
if left and right then
|
||||
vim.cmd.normal({ "m'", bang = true })
|
||||
vim.api.nvim_set_current_buf(right)
|
||||
require("git.diff").split({
|
||||
require("git.diffsplit").open({
|
||||
target = vim.api.nvim_buf_get_name(left),
|
||||
mods = { vertical = true },
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
local Revision = require("git.core.revision")
|
||||
local diff = require("git.diff")
|
||||
local diffsplit = require("git.diffsplit")
|
||||
local object = require("git.object")
|
||||
local repo = require("git.core.repo")
|
||||
local status = require("git.core.status")
|
||||
@@ -399,7 +399,7 @@ local function view_row(s, row, focus_left)
|
||||
local older = left.name or vim.api.nvim_buf_get_name(left.buf)
|
||||
local left_win
|
||||
vim.api.nvim_win_call(target, function()
|
||||
diff.split({
|
||||
diffsplit.open({
|
||||
target = older,
|
||||
mods = { vertical = true },
|
||||
})
|
||||
|
||||
@@ -134,6 +134,10 @@
|
||||
"rev": "ae5199db47757f785e43a14b332118a5474de1a2",
|
||||
"src": "https://github.com/tree-sitter-grammars/tree-sitter-svelte"
|
||||
},
|
||||
"tree-sitter-tumblr": {
|
||||
"rev": "45938c25e96351adf4140dce42795e61e944904e",
|
||||
"src": "https://git.owall.dev/warg/tree-sitter-tumblr.git"
|
||||
},
|
||||
"tree-sitter-typescript": {
|
||||
"rev": "75b3874edb2dc714fb1fd77a32013d0f8699989f",
|
||||
"src": "https://github.com/tree-sitter/tree-sitter-typescript"
|
||||
@@ -149,6 +153,14 @@
|
||||
"tree-sitter-zsh": {
|
||||
"rev": "86b37f8d515a529722411bc7bf3c9e993a4743bf",
|
||||
"src": "https://github.com/georgeharker/tree-sitter-zsh"
|
||||
},
|
||||
"vim-flog": {
|
||||
"rev": "665b16ac8915f746bc43c9572b4581a5e9047216",
|
||||
"src": "https://github.com/rbong/vim-flog"
|
||||
},
|
||||
"vim-fugitive": {
|
||||
"rev": "3b753cf8c6a4dcde6edee8827d464ba9b8c4a6f0",
|
||||
"src": "https://github.com/tpope/vim-fugitive"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+47
-13
@@ -34,6 +34,12 @@ local DEFAULT_HIGHLIGHTS = {
|
||||
GitUnmergedBothModified = "GitUnmerged",
|
||||
GitUnmergedDeletedByThem = "GitUnmerged",
|
||||
GitUnmergedDeletedByUs = "GitUnmerged",
|
||||
|
||||
GitHunkAdded = "Added",
|
||||
GitHunkChanged = "Changed",
|
||||
GitHunkRemoved = "Removed",
|
||||
GitHunkAddLine = "DiffAdd",
|
||||
GitHunkDeleteLine = "DiffDelete",
|
||||
}
|
||||
for name, link in pairs(DEFAULT_HIGHLIGHTS) do
|
||||
vim.api.nvim_set_hl(0, name, { link = link, default = true })
|
||||
@@ -45,6 +51,7 @@ vim.api.nvim_create_autocmd({ "BufReadPost", "BufNewFile" }, {
|
||||
group = group,
|
||||
callback = function(args)
|
||||
require("git.core.repo").track(args.buf)
|
||||
require("git.hunks").attach(args.buf)
|
||||
end,
|
||||
})
|
||||
vim.api.nvim_create_autocmd({ "BufWritePost", "FileChangedShellPost" }, {
|
||||
@@ -64,6 +71,7 @@ vim.api.nvim_create_autocmd({ "ShellCmdPost", "TermLeave", "FocusGained" }, {
|
||||
vim.api.nvim_create_autocmd({ "BufDelete", "BufWipeout" }, {
|
||||
group = group,
|
||||
callback = function(args)
|
||||
require("git.hunks").detach(args.buf)
|
||||
require("git.core.repo").unbind(args.buf)
|
||||
end,
|
||||
})
|
||||
@@ -138,7 +146,7 @@ vim.api.nvim_create_user_command("Gdiffsplit", function(opts)
|
||||
mods = { vertical = false }
|
||||
rev_idx = 2
|
||||
end
|
||||
require("git.diff").split({ target = fargs[rev_idx], mods = mods })
|
||||
require("git.diffsplit").open({ target = fargs[rev_idx], mods = mods })
|
||||
end, {
|
||||
nargs = "*",
|
||||
complete = function(arg_lead, cmd_line, _)
|
||||
@@ -199,24 +207,24 @@ vim.keymap.set("n", "<Plug>(git-edit)", function()
|
||||
})
|
||||
end, { silent = true, desc = "Edit a git object" })
|
||||
|
||||
vim.keymap.set("n", "<Plug>(git-diff-vertical)", function()
|
||||
require("git.diff").split({ mods = { vertical = true } })
|
||||
end, { silent = true, desc = "Diff against index (vertical)" })
|
||||
vim.keymap.set("n", "<Plug>(git-diff-horizontal)", function()
|
||||
require("git.diff").split({ mods = { vertical = false } })
|
||||
end, { silent = true, desc = "Diff against index (horizontal)" })
|
||||
vim.keymap.set("n", "<Plug>(git-diff-vertical-head)", function()
|
||||
require("git.diff").split({
|
||||
vim.keymap.set("n", "<Plug>(git-diffsplit-vertical)", function()
|
||||
require("git.diffsplit").open({ mods = { vertical = true } })
|
||||
end, { silent = true, desc = "Open a diff split against index (vertical)" })
|
||||
vim.keymap.set("n", "<Plug>(git-diffsplit-horizontal)", function()
|
||||
require("git.diffsplit").open({ mods = { vertical = false } })
|
||||
end, { silent = true, desc = "Open a diff split against index (horizontal)" })
|
||||
vim.keymap.set("n", "<Plug>(git-diffsplit-vertical-head)", function()
|
||||
require("git.diffsplit").open({
|
||||
target = "HEAD",
|
||||
mods = { vertical = true },
|
||||
})
|
||||
end, { silent = true, desc = "Diff against HEAD (vertical)" })
|
||||
vim.keymap.set("n", "<Plug>(git-diff-horizontal-head)", function()
|
||||
require("git.diff").split({
|
||||
end, { silent = true, desc = "Open a diff split against HEAD (vertical)" })
|
||||
vim.keymap.set("n", "<Plug>(git-diffsplit-horizontal-head)", function()
|
||||
require("git.diffsplit").open({
|
||||
target = "HEAD",
|
||||
mods = { vertical = false },
|
||||
})
|
||||
end, { silent = true, desc = "Diff against HEAD (horizontal)" })
|
||||
end, { silent = true, desc = "Open a diff split against HEAD (horizontal)" })
|
||||
|
||||
vim.keymap.set("n", "<Plug>(git-status-open)", function()
|
||||
require("git.status_view").open()
|
||||
@@ -245,3 +253,29 @@ if vim.g.git_statusline ~= false then
|
||||
end,
|
||||
})
|
||||
end
|
||||
|
||||
vim.keymap.set({ "n", "x" }, "<Plug>(git-hunk-next)", function()
|
||||
require("git.hunks").nav("next")
|
||||
end, { silent = true, desc = "Jump to next git hunk" })
|
||||
vim.keymap.set({ "n", "x" }, "<Plug>(git-hunk-prev)", function()
|
||||
require("git.hunks").nav("prev")
|
||||
end, { silent = true, desc = "Jump to previous git hunk" })
|
||||
vim.keymap.set("n", "<Plug>(git-hunk-stage)", function()
|
||||
require("git.hunks").stage_hunk()
|
||||
end, { silent = true, desc = "Stage hunk under cursor" })
|
||||
vim.keymap.set("n", "<Plug>(git-hunk-reset)", function()
|
||||
require("git.hunks").reset_hunk()
|
||||
end, { silent = true, desc = "Reset hunk under cursor" })
|
||||
vim.keymap.set("n", "<Plug>(git-hunk-preview)", function()
|
||||
require("git.hunks").preview_hunk()
|
||||
end, { silent = true, desc = "Preview hunk under cursor" })
|
||||
vim.keymap.set("n", "<Plug>(git-hunk-select)", function()
|
||||
require("git.hunks").select_hunk()
|
||||
end, { silent = true, desc = "Select hunk under cursor" })
|
||||
vim.keymap.set("n", "<Plug>(git-overlay-toggle)", function()
|
||||
require("git.hunks").toggle_overlay()
|
||||
end, { silent = true, desc = "Toggle the git diff overlay" })
|
||||
|
||||
vim.api.nvim_create_user_command("GitDiffOverlay", function()
|
||||
require("git.hunks").toggle_overlay()
|
||||
end, { desc = "Toggle the git diff overlay in the current buffer" })
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
require("gitsigns").setup({
|
||||
preview_config = {
|
||||
border = "single",
|
||||
},
|
||||
on_attach = function(bufnr)
|
||||
local gs = require("gitsigns")
|
||||
vim.keymap.set("n", "<leader>gv", gs.select_hunk, { buffer = bufnr })
|
||||
vim.keymap.set("n", "<leader>gs", gs.stage_hunk, { buffer = bufnr })
|
||||
vim.keymap.set("x", "<leader>gs", function()
|
||||
gs.stage_hunk({ vim.fn.line("."), vim.fn.line("v") })
|
||||
end, { buffer = bufnr })
|
||||
vim.keymap.set("n", "<leader>gr", gs.reset_hunk, { buffer = bufnr })
|
||||
vim.keymap.set(
|
||||
"x",
|
||||
"<leader>gr",
|
||||
":Gitsigns reset_hunk<CR>",
|
||||
{ buffer = bufnr }
|
||||
)
|
||||
vim.keymap.set("n", "<leader>g?", gs.preview_hunk, { buffer = bufnr })
|
||||
vim.keymap.set("n", "<leader>gb", function()
|
||||
gs.blame_line({ full = true, ignore_whitespace = true })
|
||||
end, { buffer = bufnr })
|
||||
vim.keymap.set({ "n", "x" }, "]g", function()
|
||||
gs.nav_hunk("next", {
|
||||
wrap = true,
|
||||
foldopen = true,
|
||||
navigation_message = true,
|
||||
greedy = true,
|
||||
preview = true,
|
||||
count = 1,
|
||||
target = "all",
|
||||
})
|
||||
end)
|
||||
vim.keymap.set({ "n", "x" }, "[g", function()
|
||||
gs.nav_hunk("prev", {
|
||||
wrap = true,
|
||||
foldopen = true,
|
||||
navigation_message = true,
|
||||
greedy = true,
|
||||
preview = true,
|
||||
count = 1,
|
||||
target = "all",
|
||||
})
|
||||
end)
|
||||
end,
|
||||
attach_to_untracked = false,
|
||||
sign_priority = 100,
|
||||
})
|
||||
@@ -45,9 +45,6 @@ local highlights = {
|
||||
TabLineFill = { bg = c.bg1 },
|
||||
EndOfBuffer = { fg = "NONE", bg = "NONE" },
|
||||
DiffAdd = { bg = "#1a2f22" },
|
||||
DiffChange = { bg = "#15304a" },
|
||||
DiffDelete = { bg = "#311c1e" },
|
||||
Changed = { fg = c.yellow },
|
||||
NvimTreeIndentMarker = { fg = c.bg3 },
|
||||
}
|
||||
for kind, color in pairs(completion_kind_colors) do
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
local h = require("test.git.helpers")
|
||||
local hunks = require("git.hunks")
|
||||
local t = require("test")
|
||||
|
||||
---@param committed string
|
||||
---@param worktree string
|
||||
---@param file string?
|
||||
---@return string dir
|
||||
---@return integer buf
|
||||
---@return ow.Git.Hunks.BufState state
|
||||
local function setup(committed, worktree, file)
|
||||
file = file or "a.txt"
|
||||
local dir = h.make_repo({ [file] = committed })
|
||||
t.write(dir, file, worktree)
|
||||
vim.cmd.edit(dir .. "/" .. file)
|
||||
local buf = vim.api.nvim_get_current_buf()
|
||||
hunks.attach(buf)
|
||||
hunks._flush(buf)
|
||||
t.wait_for(function()
|
||||
local s = hunks.state(buf)
|
||||
return s ~= nil and s.index ~= nil
|
||||
end, "hunks to compute the index snapshot")
|
||||
local state = assert(hunks.state(buf), "buffer state should exist")
|
||||
return dir, buf, state
|
||||
end
|
||||
|
||||
---@param buf integer
|
||||
---@return { row: integer, sign: string, hl: string }[]
|
||||
local function sign_marks(buf)
|
||||
local ns = vim.api.nvim_get_namespaces()["ow.git.hunks"]
|
||||
local out = {}
|
||||
for _, m in ipairs(vim.api.nvim_buf_get_extmarks(buf, ns, 0, -1, {
|
||||
details = true,
|
||||
})) do
|
||||
local d = assert(m[4])
|
||||
table.insert(out, {
|
||||
row = m[2],
|
||||
sign = vim.trim(d.sign_text or ""),
|
||||
hl = d.sign_hl_group,
|
||||
})
|
||||
end
|
||||
table.sort(out, function(a, b)
|
||||
return a.row < b.row
|
||||
end)
|
||||
return out
|
||||
end
|
||||
|
||||
---@param buf integer
|
||||
---@param ns_name string
|
||||
---@return vim.api.keyset.get_extmark_item[]
|
||||
local function detailed_marks(buf, ns_name)
|
||||
local ns = vim.api.nvim_get_namespaces()[ns_name]
|
||||
return vim.api.nvim_buf_get_extmarks(buf, ns, 0, -1, { details = true })
|
||||
end
|
||||
|
||||
---@return integer?
|
||||
local function find_float()
|
||||
for _, w in ipairs(vim.api.nvim_tabpage_list_wins(0)) do
|
||||
if vim.api.nvim_win_get_config(w).relative ~= "" then
|
||||
return w
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
t.test("pure add: hunk shape and add signs", function()
|
||||
local _, buf, state = setup("a\nd\n", "a\nb\nc\nd\n")
|
||||
t.eq(#state.hunks, 1, "one hunk for a pure addition")
|
||||
local hk = assert(state.hunks[1])
|
||||
t.eq(hk.type, "add")
|
||||
t.eq(hk.new_start, 2)
|
||||
t.eq(hk.new_count, 2)
|
||||
t.eq(sign_marks(buf), {
|
||||
{ row = 1, sign = "┃", hl = "GitHunkAdded" },
|
||||
{ row = 2, sign = "┃", hl = "GitHunkAdded" },
|
||||
})
|
||||
end)
|
||||
|
||||
t.test("pure delete (middle): hunk shape and delete sign", function()
|
||||
local _, buf, state = setup("a\nb\nc\n", "a\nc\n")
|
||||
t.eq(#state.hunks, 1)
|
||||
local hk = assert(state.hunks[1])
|
||||
t.eq(hk.type, "delete")
|
||||
t.eq(hk.new_count, 0)
|
||||
t.eq(hk.old_lines, { "b" })
|
||||
t.eq(sign_marks(buf), {
|
||||
{ row = 0, sign = "▁", hl = "GitHunkRemoved" },
|
||||
})
|
||||
end)
|
||||
|
||||
t.test("top-of-file delete: sign anchors on line 1", function()
|
||||
local _, buf, state = setup("a\nb\nc\n", "b\nc\n")
|
||||
t.eq(#state.hunks, 1)
|
||||
local hk = assert(state.hunks[1])
|
||||
t.eq(hk.type, "delete")
|
||||
t.eq(hk.new_start, 0)
|
||||
t.eq(hk.old_lines, { "a" })
|
||||
t.eq(sign_marks(buf), {
|
||||
{ row = 0, sign = "▁", hl = "GitHunkRemoved" },
|
||||
})
|
||||
end)
|
||||
|
||||
t.test("change of N lines: hunk shape and change signs", function()
|
||||
local _, buf, state = setup("a\nb\nc\nd\n", "a\nB\nC\nd\n")
|
||||
t.eq(#state.hunks, 1)
|
||||
local hk = assert(state.hunks[1])
|
||||
t.eq(hk.type, "change")
|
||||
t.eq(hk.old_start, 2)
|
||||
t.eq(hk.old_count, 2)
|
||||
t.eq(hk.new_start, 2)
|
||||
t.eq(hk.new_count, 2)
|
||||
t.eq(hk.old_lines, { "b", "c" })
|
||||
t.eq(hk.new_lines, { "B", "C" })
|
||||
t.eq(sign_marks(buf), {
|
||||
{ row = 1, sign = "┃", hl = "GitHunkChanged" },
|
||||
{ row = 2, sign = "┃", hl = "GitHunkChanged" },
|
||||
})
|
||||
end)
|
||||
|
||||
t.test("multi-hunk file: two separate change hunks", function()
|
||||
local _, buf, state = setup("a\nb\nc\nd\ne\n", "A\nb\nc\nd\nE\n")
|
||||
t.eq(#state.hunks, 2, "two hunks for two disjoint changes")
|
||||
t.eq(sign_marks(buf), {
|
||||
{ row = 0, sign = "┃", hl = "GitHunkChanged" },
|
||||
{ row = 4, sign = "┃", hl = "GitHunkChanged" },
|
||||
})
|
||||
end)
|
||||
|
||||
t.test("clean file produces no hunks or signs", function()
|
||||
local _, buf, state = setup("a\nb\nc\n", "a\nb\nc\n")
|
||||
t.eq(#state.hunks, 0)
|
||||
t.eq(sign_marks(buf), {})
|
||||
end)
|
||||
|
||||
t.test("editing the buffer refreshes signs", function()
|
||||
local _, buf, state = setup("a\nb\nc\n", "a\nb\nc\n")
|
||||
t.eq(#state.hunks, 0)
|
||||
vim.api.nvim_buf_set_lines(buf, 1, 2, false, { "CHANGED" })
|
||||
vim.api.nvim_exec_autocmds("TextChanged", { buffer = buf })
|
||||
hunks._flush(buf)
|
||||
t.wait_for(function()
|
||||
local s = assert(hunks.state(buf))
|
||||
return #s.hunks == 1
|
||||
end, "hunks to pick up the in-buffer edit")
|
||||
local hk = assert(assert(hunks.state(buf)).hunks[1])
|
||||
t.eq(hk.type, "change")
|
||||
end)
|
||||
|
||||
t.test("overlay: change hunk shows deletion and addition", function()
|
||||
local _, buf = setup("a\nb\nc\n", "a\nB\nc\n")
|
||||
hunks.toggle_overlay(buf)
|
||||
---@type integer?
|
||||
local add_row
|
||||
---@type vim.api.keyset.extmark_details?
|
||||
local add_d
|
||||
---@type vim.api.keyset.extmark_details?
|
||||
local virt_d
|
||||
for _, m in ipairs(detailed_marks(buf, "ow.git.hunks.overlay")) do
|
||||
local d = assert(m[4])
|
||||
if d.line_hl_group then
|
||||
add_row, add_d = m[2], d
|
||||
elseif d.virt_lines then
|
||||
virt_d = d
|
||||
end
|
||||
end
|
||||
add_d = assert(add_d, "the added line should get a line highlight")
|
||||
t.eq(add_row, 1, "addition highlighted on the changed line")
|
||||
t.eq(add_d.line_hl_group, "GitHunkAddLine")
|
||||
virt_d = assert(virt_d, "the deletion should render as virtual lines")
|
||||
local piece = assert(assert(assert(virt_d.virt_lines)[1])[1])
|
||||
t.truthy(vim.startswith(piece[1], "b"), "deleted line shows the old content")
|
||||
t.eq(piece[2], "GitHunkDeleteLine")
|
||||
end)
|
||||
|
||||
t.test("overlay: delete hunk shows only deletion lines", function()
|
||||
local _, buf = setup("a\nb\nc\n", "a\nc\n")
|
||||
hunks.toggle_overlay(buf)
|
||||
local marks = detailed_marks(buf, "ow.git.hunks.overlay")
|
||||
t.eq(#marks, 1, "a pure delete has no addition highlight")
|
||||
local d = assert(assert(marks[1])[4])
|
||||
local piece = assert(assert(assert(d.virt_lines)[1])[1])
|
||||
t.truthy(vim.startswith(piece[1], "b"))
|
||||
t.eq(piece[2], "GitHunkDeleteLine")
|
||||
end)
|
||||
|
||||
t.test("overlay: add hunk highlights the added lines", function()
|
||||
local _, buf = setup("a\nd\n", "a\nb\nc\nd\n")
|
||||
hunks.toggle_overlay(buf)
|
||||
local rows = {}
|
||||
for _, m in ipairs(detailed_marks(buf, "ow.git.hunks.overlay")) do
|
||||
local d = assert(m[4])
|
||||
t.falsy(d.virt_lines, "a pure add has no deletion virtual lines")
|
||||
t.eq(d.line_hl_group, "GitHunkAddLine")
|
||||
table.insert(rows, m[2])
|
||||
end
|
||||
table.sort(rows)
|
||||
t.eq(rows, { 1, 2 }, "both added lines highlighted")
|
||||
end)
|
||||
|
||||
t.test("overlay: deleted lines are treesitter-highlighted", function()
|
||||
local _, buf = setup(
|
||||
"-- a note\nlocal x = 1\nlocal y = 2\n",
|
||||
"local y = 2\n",
|
||||
"a.lua"
|
||||
)
|
||||
t.truthy(
|
||||
pcall(vim.treesitter.start, buf, "lua"),
|
||||
"the lua parser should be available"
|
||||
)
|
||||
hunks.toggle_overlay(buf)
|
||||
---@type table[]?
|
||||
local virt
|
||||
for _, m in ipairs(detailed_marks(buf, "ow.git.hunks.overlay")) do
|
||||
local d = assert(m[4])
|
||||
if d.virt_lines then
|
||||
virt = d.virt_lines
|
||||
end
|
||||
end
|
||||
virt = assert(virt, "a deletion virtual line should render")
|
||||
---@type table<string, boolean>
|
||||
local seen = {}
|
||||
for _, line in ipairs(virt) do
|
||||
for _, c in ipairs(line) do
|
||||
local hl = c[2]
|
||||
if
|
||||
type(hl) == "table"
|
||||
and hl[1] == "GitHunkDeleteLine"
|
||||
and hl[2]
|
||||
then
|
||||
seen[hl[2]] = true
|
||||
end
|
||||
end
|
||||
end
|
||||
t.truthy(seen["@comment"], "the deleted comment keeps its @comment group")
|
||||
t.truthy(seen["@keyword"], "deleted code keeps its syntax groups")
|
||||
end)
|
||||
|
||||
t.test("overlay: toggling swaps gutter signs for the overlay", function()
|
||||
local _, buf = setup("a\nb\nc\n", "a\nB\nc\n")
|
||||
t.truthy(
|
||||
#detailed_marks(buf, "ow.git.hunks") > 0,
|
||||
"gutter signs present while the overlay is off"
|
||||
)
|
||||
t.eq(#detailed_marks(buf, "ow.git.hunks.overlay"), 0)
|
||||
|
||||
hunks.toggle_overlay(buf)
|
||||
t.truthy(
|
||||
#detailed_marks(buf, "ow.git.hunks.overlay") > 0,
|
||||
"overlay present once it is on"
|
||||
)
|
||||
t.eq(
|
||||
#detailed_marks(buf, "ow.git.hunks"),
|
||||
0,
|
||||
"gutter signs replaced while the overlay is on"
|
||||
)
|
||||
|
||||
hunks.toggle_overlay(buf)
|
||||
t.eq(#detailed_marks(buf, "ow.git.hunks.overlay"), 0)
|
||||
t.truthy(
|
||||
#detailed_marks(buf, "ow.git.hunks") > 0,
|
||||
"gutter signs restored after toggling the overlay off"
|
||||
)
|
||||
end)
|
||||
|
||||
t.test("stage_hunk stages the change into the index", function()
|
||||
local dir, buf = setup("a\nb\nc\n", "a\nB\nc\n")
|
||||
vim.api.nvim_win_set_cursor(0, { 2, 0 })
|
||||
hunks.stage_hunk(buf)
|
||||
t.wait_for(function()
|
||||
return h.git(dir, "diff", "--cached", "--name-only").stdout ~= ""
|
||||
end, "stage to land in the index")
|
||||
t.eq(h.git(dir, "diff", "--cached", "--name-only").stdout, "a.txt")
|
||||
t.eq(
|
||||
h.git(dir, "show", ":0:a.txt").stdout,
|
||||
"a\nB\nc",
|
||||
"index blob reflects the staged change"
|
||||
)
|
||||
end)
|
||||
|
||||
t.test("stage_hunk stages a pure addition", function()
|
||||
local dir, buf = setup("a\nb\n", "a\nb\nc\n")
|
||||
vim.api.nvim_win_set_cursor(0, { 3, 0 })
|
||||
hunks.stage_hunk(buf)
|
||||
t.wait_for(function()
|
||||
return h.git(dir, "diff", "--cached", "--name-only").stdout ~= ""
|
||||
end, "stage to land in the index")
|
||||
t.eq(h.git(dir, "show", ":0:a.txt").stdout, "a\nb\nc")
|
||||
end)
|
||||
|
||||
t.test("stage_hunk stages a deletion", function()
|
||||
local dir, buf = setup("a\nb\nc\n", "a\nc\n")
|
||||
vim.api.nvim_win_set_cursor(0, { 1, 0 })
|
||||
hunks.stage_hunk(buf)
|
||||
t.wait_for(function()
|
||||
return h.git(dir, "diff", "--cached", "--name-only").stdout ~= ""
|
||||
end, "stage to land in the index")
|
||||
t.eq(h.git(dir, "show", ":0:a.txt").stdout, "a\nc")
|
||||
end)
|
||||
|
||||
t.test("stage_hunk refreshes the gutter when status stays modified", function()
|
||||
local _, buf = setup("a\nb\nc\nd\ne\n", "A\nb\nC\nd\nE\n")
|
||||
t.eq(#assert(hunks.state(buf)).hunks, 3)
|
||||
|
||||
vim.api.nvim_win_set_cursor(0, { 1, 0 })
|
||||
hunks.stage_hunk(buf)
|
||||
t.wait_for(function()
|
||||
return #assert(hunks.state(buf)).hunks == 2
|
||||
end, "gutter to drop the first staged hunk")
|
||||
|
||||
vim.api.nvim_win_set_cursor(0, { 3, 0 })
|
||||
hunks.stage_hunk(buf)
|
||||
t.wait_for(function()
|
||||
return #assert(hunks.state(buf)).hunks == 1
|
||||
end, "gutter to drop the middle staged hunk")
|
||||
end)
|
||||
|
||||
t.test("reset_hunk restores the index content for a change", function()
|
||||
local _, buf, state = setup("a\nb\nc\n", "a\nB\nc\n")
|
||||
vim.api.nvim_win_set_cursor(0, { 2, 0 })
|
||||
hunks.reset_hunk(buf)
|
||||
t.eq(
|
||||
vim.api.nvim_buf_get_lines(buf, 0, -1, false),
|
||||
state.index,
|
||||
"buffer matches the index after reset"
|
||||
)
|
||||
end)
|
||||
|
||||
t.test("reset_hunk re-inserts deleted lines", function()
|
||||
local _, buf = setup("a\nb\nc\n", "a\nc\n")
|
||||
vim.api.nvim_win_set_cursor(0, { 1, 0 })
|
||||
hunks.reset_hunk(buf)
|
||||
t.eq(vim.api.nvim_buf_get_lines(buf, 0, -1, false), { "a", "b", "c" })
|
||||
end)
|
||||
|
||||
t.test("reset_hunk removes a pure addition", function()
|
||||
local _, buf = setup("a\nd\n", "a\nb\nc\nd\n")
|
||||
vim.api.nvim_win_set_cursor(0, { 2, 0 })
|
||||
hunks.reset_hunk(buf)
|
||||
t.eq(vim.api.nvim_buf_get_lines(buf, 0, -1, false), { "a", "d" })
|
||||
end)
|
||||
|
||||
t.test("git_hunk_signs overrides the sign character per kind", function()
|
||||
local prev = vim.g.git_hunk_signs
|
||||
vim.g.git_hunk_signs = { change = "C" }
|
||||
t.defer(function()
|
||||
vim.g.git_hunk_signs = prev
|
||||
end)
|
||||
local _, buf = setup("a\nb\nc\n", "a\nB\nc\n")
|
||||
t.eq(sign_marks(buf), {
|
||||
{ row = 1, sign = "C", hl = "GitHunkChanged" },
|
||||
})
|
||||
end)
|
||||
|
||||
t.test("git_hunk_signs falls back to the default for unset kinds", function()
|
||||
local prev = vim.g.git_hunk_signs
|
||||
vim.g.git_hunk_signs = { add = "A" }
|
||||
t.defer(function()
|
||||
vim.g.git_hunk_signs = prev
|
||||
end)
|
||||
local _, buf = setup("a\nb\nc\n", "a\nB\nc\n")
|
||||
t.eq(sign_marks(buf), {
|
||||
{ row = 1, sign = "┃", hl = "GitHunkChanged" },
|
||||
})
|
||||
end)
|
||||
|
||||
t.test("preview_hunk shows the hunk body without file headers", function()
|
||||
local _, buf = setup("a\nb\nc\n", "a\nB\nc\n")
|
||||
vim.api.nvim_set_current_buf(buf)
|
||||
vim.api.nvim_win_set_cursor(0, { 2, 0 })
|
||||
hunks.preview_hunk(buf)
|
||||
local float = assert(find_float(), "preview float should open")
|
||||
t.defer(function()
|
||||
pcall(vim.api.nvim_win_close, float, true)
|
||||
end)
|
||||
local lines = vim.api.nvim_buf_get_lines(
|
||||
vim.api.nvim_win_get_buf(float),
|
||||
0,
|
||||
-1,
|
||||
false
|
||||
)
|
||||
t.truthy(
|
||||
vim.startswith(lines[1] or "", "@@ "),
|
||||
"first line is the @@ header"
|
||||
)
|
||||
for _, l in ipairs(lines) do
|
||||
t.falsy(vim.startswith(l, "--- "), "no --- file header line")
|
||||
t.falsy(vim.startswith(l, "+++ "), "no +++ file header line")
|
||||
end
|
||||
end)
|
||||
|
||||
t.test("preview_hunk re-invocation focuses the open float", function()
|
||||
local _, buf = setup("a\nb\nc\n", "a\nB\nc\n")
|
||||
vim.api.nvim_set_current_buf(buf)
|
||||
vim.api.nvim_win_set_cursor(0, { 2, 0 })
|
||||
hunks.preview_hunk(buf)
|
||||
local float = assert(find_float(), "preview float should open")
|
||||
t.defer(function()
|
||||
pcall(vim.api.nvim_win_close, float, true)
|
||||
end)
|
||||
t.truthy(
|
||||
vim.api.nvim_get_current_win() ~= float,
|
||||
"the float opens unfocused"
|
||||
)
|
||||
hunks.preview_hunk(buf)
|
||||
t.eq(
|
||||
vim.api.nvim_get_current_win(),
|
||||
float,
|
||||
"re-invoking focuses the existing float"
|
||||
)
|
||||
end)
|
||||
|
||||
t.test("nav jumps to next and previous hunks with wrap", function()
|
||||
local _, buf = setup("a\nb\nc\nd\ne\n", "A\nb\nc\nd\nE\n")
|
||||
vim.api.nvim_set_current_buf(buf)
|
||||
vim.api.nvim_win_set_cursor(0, { 1, 0 })
|
||||
hunks.nav("next")
|
||||
t.eq(vim.api.nvim_win_get_cursor(0)[1], 5, "next hunk is line 5")
|
||||
hunks.nav("next")
|
||||
t.eq(vim.api.nvim_win_get_cursor(0)[1], 1, "next wraps back to line 1")
|
||||
hunks.nav("prev")
|
||||
t.eq(vim.api.nvim_win_get_cursor(0)[1], 5, "prev wraps back to line 5")
|
||||
end)
|
||||
@@ -86,6 +86,23 @@ t.test("M.open(HEAD:<path>) loads file content at HEAD", function()
|
||||
)
|
||||
end)
|
||||
|
||||
t.test("M.open on a merge commit diffs against the first parent only", function()
|
||||
local dir = h.make_repo({ ["a.txt"] = "one\n" })
|
||||
t.write(dir, "a.txt", "two\n")
|
||||
h.git(dir, "stash")
|
||||
local stash = h.git(dir, "rev-parse", "stash@{0}").stdout
|
||||
local r = assert(require("git.core.repo").resolve(dir))
|
||||
|
||||
object.open(r, stash, { split = false })
|
||||
local count = 0
|
||||
for _, l in ipairs(vim.api.nvim_buf_get_lines(0, 0, -1, false)) do
|
||||
if l:match("^diff %-%-git ") then
|
||||
count = count + 1
|
||||
end
|
||||
end
|
||||
t.eq(count, 1, "the stashed file's diff appears once, not per-parent")
|
||||
end)
|
||||
|
||||
t.test("M.open errors on a bogus base, no buffer is opened", function()
|
||||
local dir = h.make_repo({ a = "first\n" })
|
||||
local r = assert(require("git.core.repo").resolve(dir))
|
||||
|
||||
@@ -71,6 +71,32 @@ t.test("get_cached memoizes by key", function()
|
||||
t.truthy(v1 == v2, "second call should return cached table")
|
||||
end)
|
||||
|
||||
t.test("index_sha returns the blob sha and caches it", function()
|
||||
local dir = h.make_repo({ a = "x\n" })
|
||||
local r = assert(require("git.core.repo").resolve(dir))
|
||||
local sha = r:index_sha("a")
|
||||
t.truthy(sha and #sha > 0, "index_sha returns the stage-0 blob sha")
|
||||
t.truthy(r._cache["index:a"] ~= nil, "the result is cached")
|
||||
t.eq(r:index_sha("a"), sha, "a cached call returns the same sha")
|
||||
end)
|
||||
|
||||
t.test("index_sha caches a negative result for an untracked path", function()
|
||||
local dir = h.make_repo({ a = "x\n" })
|
||||
local r = assert(require("git.core.repo").resolve(dir))
|
||||
t.eq(r:index_sha("nope"), nil, "an untracked path has no index sha")
|
||||
t.eq(r._cache["index:nope"], false, "the negative result is cached")
|
||||
end)
|
||||
|
||||
t.test("index_sha cache clears when the index is written", function()
|
||||
local dir = h.make_repo({ a = "x\n" })
|
||||
local r = assert(require("git.core.repo").resolve(dir))
|
||||
r:index_sha("a")
|
||||
t.truthy(r._cache["index:a"] ~= nil, "sha is cached before the stage")
|
||||
t.write(dir, "a", "y\n")
|
||||
h.git(dir, "add", "a")
|
||||
wait_cleared(r, "index:a", 2000)
|
||||
end)
|
||||
|
||||
t.test("cache clears after top-level .git change (commit)", function()
|
||||
local dir = h.make_repo({ a = "x" })
|
||||
local r = assert(require("git.core.repo").resolve(dir))
|
||||
|
||||
@@ -277,7 +277,7 @@ t.test(
|
||||
error("a non-sidebar window should remain after close")
|
||||
end
|
||||
vim.api.nvim_set_current_win(remaining)
|
||||
require("git.diff").split({ mods = { vertical = true } })
|
||||
require("git.diffsplit").open({ mods = { vertical = true } })
|
||||
t.wait_for(function()
|
||||
local count = 0
|
||||
for _, w in ipairs(vim.api.nvim_tabpage_list_wins(0)) do
|
||||
|
||||
Reference in New Issue
Block a user