Compare commits

..

22 Commits

Author SHA1 Message Date
warg 942dbdcaa0 refactor(git): rename stage_hunk to toggle_stage 2026-05-20 13:19:05 +02:00
warg 1a582045f6 feat(git): show staged hunks in the gutter with a stage toggle 2026-05-20 12:46:45 +02:00
warg 7c92b5eff6 feat(git/repo): add Repo:head_sha for cached HEAD blob lookups 2026-05-20 12:46:05 +02:00
warg 6230c2663c fix(onedark): override GitUnstaged 2026-05-20 11:28:34 +02:00
warg 5b869334d6 fix(onedark): remove overridden diff/git highlights 2026-05-20 10:19:29 +02:00
warg 01ca0025dd fix(git): refresh the gutter after staging a hunk 2026-05-20 08:09:07 +02:00
warg b52f34ce9a fix(git): use GitHunkRemoved for the delete-hunk sign 2026-05-20 08:00:56 +02:00
warg e050896dc0 test(git): speed up the hunks test setup with a flush hook 2026-05-20 07:56:07 +02:00
warg 4c8b3f0d3e fix(git): align hunk signs with :diffsplit 2026-05-20 07:55:55 +02:00
warg 72ab9059fa fix(git): rename hunk highlights 2026-05-20 07:53:51 +02:00
warg 7c8975af10 fix(git): show merge commits diffed against the first parent 2026-05-20 07:14:37 +02:00
warg 2064c629ed feat(git): syntax-highlight deleted lines in the diff overlay 2026-05-20 06:42:42 +02:00
warg aaef6621dd fix(hunks): change delete sign 2026-05-20 06:27:52 +02:00
warg d629302625 perf(git): cache index blob sha, drop rev-parse from the edit path 2026-05-20 06:25:15 +02:00
warg f77d26db6b feat(git): trim hunk preview header, focus float on re-invoke 2026-05-20 06:17:58 +02:00
warg f4181b89fc feat(git): add in-house hunks module, replace gitsigns.nvim 2026-05-20 06:10:18 +02:00
warg d979c961a2 test(git): remove unused git uri 2026-05-19 20:42:06 +02:00
warg d132c00032 feat(git): bind gd to open-under-cursor in log and object views 2026-05-19 20:30:23 +02:00
warg 73fa92afc8 fix(core): reset foldlevel on entering a diff window 2026-05-19 20:26:59 +02:00
warg 3b8951758e fix(git): reset diff-pair cursor on sidebar selection 2026-05-19 19:55:03 +02:00
warg b692f23fe2 refactor(git): rework log_view, drop URI scheme 2026-05-19 19:40:13 +02:00
warg ffd5584a05 refactor(git): replace status_view URI scheme with path-style name 2026-05-19 16:29:10 +02:00
19 changed files with 2123 additions and 250 deletions
-1
View File
@@ -30,7 +30,6 @@ require("pack").setup({
"https://github.com/owallb/mason-auto-install.nvim", "https://github.com/owallb/mason-auto-install.nvim",
"https://github.com/mfussenegger/nvim-dap", "https://github.com/mfussenegger/nvim-dap",
"https://github.com/numToStr/Comment.nvim", "https://github.com/numToStr/Comment.nvim",
"https://github.com/lewis6991/gitsigns.nvim",
"https://github.com/MagicDuck/grug-far.nvim", "https://github.com/MagicDuck/grug-far.nvim",
"https://github.com/nvim-tree/nvim-tree.lua", "https://github.com/nvim-tree/nvim-tree.lua",
"https://github.com/stevearc/oil.nvim", "https://github.com/stevearc/oil.nvim",
+12
View File
@@ -66,6 +66,18 @@ vim.api.nvim_create_autocmd({ "BufReadPost" }, {
command = 'silent! normal! g`"zv', command = 'silent! normal! g`"zv',
}) })
vim.api.nvim_create_autocmd("BufWinEnter", {
desc = "Reset foldlevel to 0 when entering a diff window."
.. " Vim's partial diff-state restoration on buffer re-entry"
.. " (e.g. via <C-o>) doesn't re-apply foldlevel=0, so"
.. " foldlevelstart leaks through and folds appear open.",
callback = function()
if vim.wo.diff then
vim.wo.foldlevel = 0
end
end,
})
vim.api.nvim_create_autocmd("FileType", { vim.api.nvim_create_autocmd("FileType", {
pattern = { "c" }, pattern = { "c" },
callback = function() callback = function()
+11 -4
View File
@@ -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", "grt", vim.lsp.buf.type_definition)
vim.keymap.set("n", "gd", vim.lsp.buf.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-diffsplit-vertical)")
vim.keymap.set("n", "<leader>gD", "<Plug>(git-diff-vertical-head)") vim.keymap.set("n", "<leader>gD", "<Plug>(git-diffsplit-vertical-head)")
vim.keymap.set("n", "<leader>gh", "<Plug>(git-diff-horizontal)") vim.keymap.set("n", "<leader>gh", "<Plug>(git-diffsplit-horizontal)")
vim.keymap.set("n", "<leader>gH", "<Plug>(git-diff-horizontal-head)") 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>gg", "<Plug>(git-status-toggle)")
vim.keymap.set("n", "<leader>gc", "<Plug>(git-commit)") vim.keymap.set("n", "<leader>gc", "<Plug>(git-commit)")
vim.keymap.set("n", "<leader>ga", "<Plug>(git-commit-amend)") vim.keymap.set("n", "<leader>ga", "<Plug>(git-commit-amend)")
vim.keymap.set("n", "<leader>gl", "<Plug>(git-log)") 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-toggle)")
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)")
+34 -6
View File
@@ -123,6 +123,16 @@ local function affects_resolve(relpath)
or relpath == "FETCH_HEAD" or relpath == "FETCH_HEAD"
end 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 ---@private
---@param relpath string ---@param relpath string
function Repo:_invalidate(relpath) function Repo:_invalidate(relpath)
@@ -132,11 +142,11 @@ function Repo:_invalidate(relpath)
end end
end end
if affects_resolve(relpath) then if affects_resolve(relpath) then
for key in pairs(self._cache) do self:_clear_cache_prefix("resolve:")
if vim.startswith(key, "resolve:") then self:_clear_cache_prefix("head_blob:")
self._cache[key] = nil
end
end end
if relpath == "index" then
self:_clear_cache_prefix("index:")
end end
end end
@@ -587,6 +597,24 @@ function Repo:rev_parse(rev, short)
return trimmed ~= "" and trimmed or nil return trimmed ~= "" and trimmed or nil
end 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
---@param rel string worktree-relative path
---@return string?
function Repo:head_sha(rel)
local sha = self:get_cached("head_blob:" .. rel, function(self)
return self:rev_parse("HEAD:" .. rel, false) or false
end)
return sha or nil
end
---@alias ow.Git.Repo.ResolveStatus "ok"|"ambiguous"|"missing" ---@alias ow.Git.Repo.ResolveStatus "ok"|"ambiguous"|"missing"
---@param abbrev string ---@param abbrev string
@@ -819,7 +847,7 @@ end
---@param buf integer ---@param buf integer
---@return boolean ---@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 if not vim.api.nvim_buf_is_valid(buf) or vim.bo[buf].buftype ~= "" then
return false return false
end end
@@ -830,7 +858,7 @@ end
---@param buf? integer ---@param buf? integer
function M.track(buf) function M.track(buf)
buf = expand_buf(buf) buf = expand_buf(buf)
if not is_worktree_buf(buf) then if not M.is_worktree_buf(buf) then
return return
end end
local r = M.resolve(buf) local r = M.resolve(buf)
+59
View File
@@ -194,6 +194,65 @@ function M.debounce(fn, delay)
} }
end 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 ---@class ow.Git.Util.ExecOpts
---@field cwd string? ---@field cwd string?
---@field stdin string? ---@field stdin string?
+3 -3
View File
@@ -5,7 +5,7 @@ local util = require("git.core.util")
local M = {} local M = {}
---@class ow.Git.Diff.SplitOpts ---@class ow.Git.Diffsplit.OpenOpts
---@field target string? ---@field target string?
---@field mods vim.api.keyset.cmd.mods? ---@field mods vim.api.keyset.cmd.mods?
@@ -107,8 +107,8 @@ local function default_split(cur_buf, target)
return nil return nil
end end
---@param opts? ow.Git.Diff.SplitOpts ---@param opts? ow.Git.Diffsplit.OpenOpts
function M.split(opts) function M.open(opts)
opts = opts or {} opts = opts or {}
local cur_buf = vim.api.nvim_get_current_buf() local cur_buf = vim.api.nvim_get_current_buf()
local target, err local target, err
+970
View File
@@ -0,0 +1,970 @@
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
+33 -65
View File
@@ -3,29 +3,36 @@ local util = require("git.core.util")
local M = {} local M = {}
M.URI_PREFIX = "gitlog://"
local LOG_FORMAT = "%h %ad {%an}%d %s" local LOG_FORMAT = "%h %ad {%an}%d %s"
local cr = vim.api.nvim_replace_termcodes("<CR>", true, false, true) local cr = vim.api.nvim_replace_termcodes("<CR>", true, false, true)
---@param buf integer ---@param buf integer
local function attach_dispatch(buf) ---@return boolean opened
vim.keymap.set("n", "<CR>", function() local function open_under_cursor(buf)
local r = repo.resolve(buf) local r = repo.resolve(buf)
-- Anchor past the leading graph chars (matches the leading sha column, -- Anchor past the leading graph chars (matches the leading sha column,
-- not any hex word that happens to appear later in the subject). -- not any hex word that happens to appear later in the subject).
local sha = r local sha = r
and vim.api and vim.api.nvim_get_current_line():match("^[*|/\\_ ]*(%x%x%x%x%x%x%x+)")
.nvim_get_current_line() if not sha then
:match("^[*|/\\_ ]*(%x%x%x%x%x%x%x+)") return false
if sha then end
---@cast r -nil ---@cast r -nil
require("git.object").open(r, sha, { split = false }) require("git.object").open(r, sha, { split = false })
else return true
end
---@param buf integer
local function attach_dispatch(buf)
vim.keymap.set("n", "<CR>", function()
if not open_under_cursor(buf) then
vim.api.nvim_feedkeys(cr, "n", false) vim.api.nvim_feedkeys(cr, "n", false)
end end
end, { buffer = buf, silent = true, desc = "Open commit" }) end, { buffer = buf, silent = true, desc = "Open commit" })
vim.keymap.set("n", "gd", function()
open_under_cursor(buf)
end, { buffer = buf, silent = true, desc = "Open commit" })
end end
---@param worktree string ---@param worktree string
@@ -56,50 +63,7 @@ local function populate(buf, r)
if not stdout then if not stdout then
return return
end end
local new_lines = util.split_lines(stdout) util.set_buf_lines(buf, 0, -1, util.split_lines(stdout))
local old_str = table.concat(
vim.api.nvim_buf_get_lines(buf, 0, -1, false),
"\n"
) .. "\n"
local new_str = table.concat(new_lines, "\n") .. "\n"
local hunks = vim.text.diff(old_str, new_str, {
result_type = "indices",
algorithm = "histogram",
})
---@cast hunks [integer, integer, integer, integer][]
if #hunks == 0 then
return
end
for i = #hunks, 1, -1 do
local sa, ca, sb, cb = unpack(hunks[i])
local start = ca == 0 and sa or sa - 1
util.set_buf_lines(
buf,
start,
start + ca,
vim.list_slice(new_lines, sb, sb + cb - 1)
)
end
end
---@param buf integer
function M.read_uri(buf)
local name = vim.api.nvim_buf_get_name(buf)
local worktree = name:sub(#M.URI_PREFIX + 1)
if worktree == "" then
return
end
local r = repo.resolve(worktree)
if not r then
return
end
repo.bind(buf, r)
util.setup_scratch(buf, { bufhidden = "hide" })
vim.bo[buf].filetype = "gitlog"
attach_dispatch(buf)
populate(buf, r)
end end
---@class ow.Git.Log.OpenOpts ---@class ow.Git.Log.OpenOpts
@@ -120,19 +84,25 @@ function M.open(opts)
end end
max_counts[r.worktree] = opts.max_count max_counts[r.worktree] = opts.max_count
local buf = vim.fn.bufadd(M.URI_PREFIX .. r.worktree) local buf = vim.fn.bufadd(r.worktree .. "/GitLog")
local was_loaded = vim.api.nvim_buf_is_loaded(buf)
local win = vim.fn.bufwinid(buf) local visible = vim.fn.bufwinid(buf)
if win == -1 then if visible ~= -1 then
util.place_buf(buf, nil) vim.api.nvim_set_current_win(visible)
else
vim.api.nvim_set_current_win(win)
end
if was_loaded then
populate(buf, r) populate(buf, r)
vim.api.nvim_win_set_cursor(visible, { 1, 0 })
return
end end
vim.fn.bufload(buf)
repo.bind(buf, r)
util.setup_scratch(buf, { bufhidden = "hide" })
vim.bo[buf].filetype = "gitlog"
attach_dispatch(buf)
local win = util.place_buf(buf, nil)
vim.api.nvim_win_set_cursor(win, { 1, 0 })
populate(buf, r)
end end
---@param cmd_opts table ---@param cmd_opts table
@@ -170,6 +140,4 @@ function M.complete_glog(arg_lead)
return matches return matches
end end
repo.on_uri_change(M.URI_PREFIX, populate)
return M return M
+5 -3
View File
@@ -150,6 +150,9 @@ function M.attach_dispatch(buf)
vim.api.nvim_feedkeys(cr, "n", false) vim.api.nvim_feedkeys(cr, "n", false)
end end
end, { buffer = buf, silent = true, desc = "Open file at commit" }) end, { buffer = buf, silent = true, desc = "Open file at commit" })
vim.keymap.set("n", "gd", function()
M.open_under_cursor()
end, { buffer = buf, silent = true, desc = "Open file at commit" })
end end
---@param r ow.Git.Repo ---@param r ow.Git.Repo
@@ -181,8 +184,7 @@ local function populate(buf, r, rev, state, rev_sha)
local patch = util.git({ local patch = util.git({
"diff-tree", "diff-tree",
"-p", "-p",
"-m", "--diff-merges=first-parent",
"--first-parent",
"--root", "--root",
"--no-commit-id", "--no-commit-id",
commit_sha, commit_sha,
@@ -336,7 +338,7 @@ local function open_section(r, section)
if left and right then if left and right then
vim.cmd.normal({ "m'", bang = true }) vim.cmd.normal({ "m'", bang = true })
vim.api.nvim_set_current_buf(right) vim.api.nvim_set_current_buf(right)
require("git.diff").split({ require("git.diffsplit").open({
target = vim.api.nvim_buf_get_name(left), target = vim.api.nvim_buf_get_name(left),
mods = { vertical = true }, mods = { vertical = true },
}) })
+22 -78
View File
@@ -1,5 +1,5 @@
local Revision = require("git.core.revision") local Revision = require("git.core.revision")
local diff = require("git.diff") local diffsplit = require("git.diffsplit")
local object = require("git.object") local object = require("git.object")
local repo = require("git.core.repo") local repo = require("git.core.repo")
local status = require("git.core.status") local status = require("git.core.status")
@@ -7,8 +7,6 @@ local util = require("git.core.util")
local M = {} local M = {}
M.URI_PREFIX = "gitstatus://"
---@type ow.Git.StatusView.Placement[] ---@type ow.Git.StatusView.Placement[]
M.PLACEMENTS = { "sidebar", "split", "current" } M.PLACEMENTS = { "sidebar", "split", "current" }
@@ -16,14 +14,10 @@ M.PLACEMENTS = { "sidebar", "split", "current" }
local SECTIONS = { "untracked", "unstaged", "staged", "unmerged" } local SECTIONS = { "untracked", "unstaged", "staged", "unmerged" }
local WINDOW_WIDTH = 50 local WINDOW_WIDTH = 50
---@param name string ---@param r ow.Git.Repo
---@return integer? bufnr ---@return string
local function find_buf(name) local function buf_name_for(r)
for _, b in ipairs(vim.api.nvim_list_bufs()) do return r.worktree .. "/GitStatus"
if vim.api.nvim_buf_get_name(b) == name then
return b
end
end
end end
---@alias ow.Git.StatusView.Placement "sidebar"|"split"|"current" ---@alias ow.Git.StatusView.Placement "sidebar"|"split"|"current"
@@ -405,12 +399,15 @@ local function view_row(s, row, focus_left)
local older = left.name or vim.api.nvim_buf_get_name(left.buf) local older = left.name or vim.api.nvim_buf_get_name(left.buf)
local left_win local left_win
vim.api.nvim_win_call(target, function() vim.api.nvim_win_call(target, function()
diff.split({ diffsplit.open({
target = older, target = older,
mods = { vertical = true }, mods = { vertical = true },
}) })
left_win = vim.api.nvim_get_current_win() left_win = vim.api.nvim_get_current_win()
vim.api.nvim_win_set_cursor(left_win, { 1, 0 })
vim.api.nvim_win_set_cursor(target, { 1, 0 })
end) end)
---@cast left_win -nil
vim.api.nvim_set_current_win(focus_left and left_win or status_win) vim.api.nvim_set_current_win(focus_left and left_win or status_win)
end end
@@ -697,8 +694,9 @@ function M.open(opts)
util.error("not in a git repository") util.error("not in a git repository")
return return
end end
local previous_win = vim.api.nvim_get_current_win() local previous_win = vim.api.nvim_get_current_win()
local buf = vim.fn.bufadd(M.URI_PREFIX .. r.worktree) local buf = vim.fn.bufadd(buf_name_for(r))
local visible = vim.fn.bufwinid(buf) local visible = vim.fn.bufwinid(buf)
if visible ~= -1 then if visible ~= -1 then
@@ -707,78 +705,24 @@ function M.open(opts)
return return
end end
local was_loaded = vim.api.nvim_buf_is_loaded(buf) if not state[buf] then
local win = place(buf, placement) vim.fn.bufload(buf)
repo.bind(buf, r)
vim.bo[buf].bufhidden = placement == "sidebar" and "wipe" or "hide" util.setup_scratch(buf, {})
local s = state[buf] vim.bo[buf].filetype = "gitstatus"
if s then setup_buffer(buf, r, placement)
s.win = win
s.placement = placement
end end
vim.bo[buf].bufhidden = placement == "sidebar" and "wipe" or "hide"
local win = place(buf, placement)
state[buf].win = win
state[buf].placement = placement
set_keymaps(buf, placement) set_keymaps(buf, placement)
if placement == "sidebar" then if placement == "sidebar" then
vim.api.nvim_set_current_win(previous_win) vim.api.nvim_set_current_win(previous_win)
end end
if was_loaded then
refresh(buf)
end
r:refresh()
end
---@param buf integer
function M.read_uri(buf)
local name = vim.api.nvim_buf_get_name(buf)
local raw = name:sub(#M.URI_PREFIX + 1)
if raw == "" then
return
end
local worktree = vim.fs.abspath(raw)
local r = repo.resolve(worktree)
if not r then
util.error("not a git worktree: %s", raw)
return
end
if r.worktree ~= worktree then
util.warning("%s is not a worktree root, using %s", raw, r.worktree)
end
local canonical = M.URI_PREFIX .. r.worktree
if name ~= canonical then
local existing = find_buf(canonical)
if existing and existing ~= buf then
local win = vim.api.nvim_get_current_win()
if vim.api.nvim_win_get_buf(win) == buf then
vim.api.nvim_win_set_buf(win, existing)
end
vim.api.nvim_buf_delete(buf, { force = true })
local s = state[existing]
if s then
s.win = win
s.placement = "current"
end
refresh(existing)
r:refresh()
return
end
pcall(vim.api.nvim_buf_set_name, buf, canonical)
end
repo.bind(buf, r)
util.setup_scratch(buf, { bufhidden = "hide" })
vim.bo[buf].filetype = "gitstatus"
---@type integer?
local win = vim.fn.bufwinid(buf)
if win == -1 then
win = nil
end
if not state[buf] then
setup_buffer(buf, r, "current", win)
else
state[buf].win = win
end
refresh(buf) refresh(buf)
r:refresh() r:refresh()
end end
+12
View File
@@ -134,6 +134,10 @@
"rev": "ae5199db47757f785e43a14b332118a5474de1a2", "rev": "ae5199db47757f785e43a14b332118a5474de1a2",
"src": "https://github.com/tree-sitter-grammars/tree-sitter-svelte" "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": { "tree-sitter-typescript": {
"rev": "75b3874edb2dc714fb1fd77a32013d0f8699989f", "rev": "75b3874edb2dc714fb1fd77a32013d0f8699989f",
"src": "https://github.com/tree-sitter/tree-sitter-typescript" "src": "https://github.com/tree-sitter/tree-sitter-typescript"
@@ -149,6 +153,14 @@
"tree-sitter-zsh": { "tree-sitter-zsh": {
"rev": "86b37f8d515a529722411bc7bf3c9e993a4743bf", "rev": "86b37f8d515a529722411bc7bf3c9e993a4743bf",
"src": "https://github.com/georgeharker/tree-sitter-zsh" "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"
} }
} }
} }
+85 -28
View File
@@ -34,17 +34,59 @@ local DEFAULT_HIGHLIGHTS = {
GitUnmergedBothModified = "GitUnmerged", GitUnmergedBothModified = "GitUnmerged",
GitUnmergedDeletedByThem = "GitUnmerged", GitUnmergedDeletedByThem = "GitUnmerged",
GitUnmergedDeletedByUs = "GitUnmerged", GitUnmergedDeletedByUs = "GitUnmerged",
GitHunkAdded = "Added",
GitHunkChanged = "Changed",
GitHunkRemoved = "Removed",
GitHunkAddLine = "DiffAdd",
GitHunkDeleteLine = "DiffDelete",
} }
local STAGED_HUNK_HL = {
GitHunkStagedAdded = "GitHunkAdded",
GitHunkStagedChanged = "GitHunkChanged",
GitHunkStagedRemoved = "GitHunkRemoved",
}
local function blend(a, b, t)
local function mix(shift)
local x = bit.band(bit.rshift(a, shift), 0xff)
local y = bit.band(bit.rshift(b, shift), 0xff)
return bit.lshift(math.floor(x + (y - x) * t + 0.5), shift)
end
return mix(16) + mix(8) + mix(0)
end
local function apply_highlights()
for name, link in pairs(DEFAULT_HIGHLIGHTS) do for name, link in pairs(DEFAULT_HIGHLIGHTS) do
vim.api.nvim_set_hl(0, name, { link = link, default = true }) vim.api.nvim_set_hl(0, name, { link = link, default = true })
end end
local bg = vim.api.nvim_get_hl(0, { name = "Normal" }).bg or 0x000000
for name, base in pairs(STAGED_HUNK_HL) do
local src = vim.api.nvim_get_hl(0, { name = base, link = false })
local hl = {}
if src.fg then
hl.fg = blend(src.fg, bg, 0.45)
end
if src.bg then
hl.bg = blend(src.bg, bg, 0.45)
end
vim.api.nvim_set_hl(0, name, hl)
end
end
apply_highlights()
local group = vim.api.nvim_create_augroup("ow.git", { clear = true }) local group = vim.api.nvim_create_augroup("ow.git", { clear = true })
vim.api.nvim_create_autocmd("ColorScheme", {
group = group,
callback = apply_highlights,
})
vim.api.nvim_create_autocmd({ "BufReadPost", "BufNewFile" }, { vim.api.nvim_create_autocmd({ "BufReadPost", "BufNewFile" }, {
group = group, group = group,
callback = function(args) callback = function(args)
require("git.core.repo").track(args.buf) require("git.core.repo").track(args.buf)
require("git.hunks").attach(args.buf)
end, end,
}) })
vim.api.nvim_create_autocmd({ "BufWritePost", "FileChangedShellPost" }, { vim.api.nvim_create_autocmd({ "BufWritePost", "FileChangedShellPost" }, {
@@ -64,6 +106,7 @@ vim.api.nvim_create_autocmd({ "ShellCmdPost", "TermLeave", "FocusGained" }, {
vim.api.nvim_create_autocmd({ "BufDelete", "BufWipeout" }, { vim.api.nvim_create_autocmd({ "BufDelete", "BufWipeout" }, {
group = group, group = group,
callback = function(args) callback = function(args)
require("git.hunks").detach(args.buf)
require("git.core.repo").unbind(args.buf) require("git.core.repo").unbind(args.buf)
end, end,
}) })
@@ -96,21 +139,6 @@ vim.api.nvim_create_autocmd("BufReadCmd", {
require("git.object").read_uri(args.buf) require("git.object").read_uri(args.buf)
end, end,
}) })
vim.api.nvim_create_autocmd("BufReadCmd", {
pattern = "gitlog://*",
group = group,
callback = function(args)
require("git.log_view").read_uri(args.buf)
end,
})
vim.api.nvim_create_autocmd("BufReadCmd", {
pattern = "gitstatus://*",
group = group,
callback = function(args)
require("git.status_view").read_uri(args.buf)
end,
})
vim.api.nvim_create_user_command("G", function(opts) vim.api.nvim_create_user_command("G", function(opts)
local cmd = require("git.cmd") local cmd = require("git.cmd")
cmd.run(cmd.parse_args(opts.args), { bang = opts.bang }) cmd.run(cmd.parse_args(opts.args), { bang = opts.bang })
@@ -153,7 +181,7 @@ vim.api.nvim_create_user_command("Gdiffsplit", function(opts)
mods = { vertical = false } mods = { vertical = false }
rev_idx = 2 rev_idx = 2
end end
require("git.diff").split({ target = fargs[rev_idx], mods = mods }) require("git.diffsplit").open({ target = fargs[rev_idx], mods = mods })
end, { end, {
nargs = "*", nargs = "*",
complete = function(arg_lead, cmd_line, _) complete = function(arg_lead, cmd_line, _)
@@ -214,25 +242,28 @@ vim.keymap.set("n", "<Plug>(git-edit)", function()
}) })
end, { silent = true, desc = "Edit a git object" }) end, { silent = true, desc = "Edit a git object" })
vim.keymap.set("n", "<Plug>(git-diff-vertical)", function() vim.keymap.set("n", "<Plug>(git-diffsplit-vertical)", function()
require("git.diff").split({ mods = { vertical = true } }) require("git.diffsplit").open({ mods = { vertical = true } })
end, { silent = true, desc = "Diff against index (vertical)" }) end, { silent = true, desc = "Open a diff split against index (vertical)" })
vim.keymap.set("n", "<Plug>(git-diff-horizontal)", function() vim.keymap.set("n", "<Plug>(git-diffsplit-horizontal)", function()
require("git.diff").split({ mods = { vertical = false } }) require("git.diffsplit").open({ mods = { vertical = false } })
end, { silent = true, desc = "Diff against index (horizontal)" }) end, { silent = true, desc = "Open a diff split against index (horizontal)" })
vim.keymap.set("n", "<Plug>(git-diff-vertical-head)", function() vim.keymap.set("n", "<Plug>(git-diffsplit-vertical-head)", function()
require("git.diff").split({ require("git.diffsplit").open({
target = "HEAD", target = "HEAD",
mods = { vertical = true }, mods = { vertical = true },
}) })
end, { silent = true, desc = "Diff against HEAD (vertical)" }) end, { silent = true, desc = "Open a diff split against HEAD (vertical)" })
vim.keymap.set("n", "<Plug>(git-diff-horizontal-head)", function() vim.keymap.set("n", "<Plug>(git-diffsplit-horizontal-head)", function()
require("git.diff").split({ require("git.diffsplit").open({
target = "HEAD", target = "HEAD",
mods = { vertical = false }, 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()
end, { silent = true, desc = "Open git status sidebar" })
vim.keymap.set("n", "<Plug>(git-status-toggle)", function() vim.keymap.set("n", "<Plug>(git-status-toggle)", function()
require("git.status_view").toggle() require("git.status_view").toggle()
end, { silent = true, desc = "Toggle git status sidebar" }) end, { silent = true, desc = "Toggle git status sidebar" })
@@ -257,3 +288,29 @@ if vim.g.git_statusline ~= false then
end, end,
}) })
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-toggle)", function()
require("git.hunks").toggle_stage()
end, { silent = true, desc = "Stage or unstage the 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" })
-48
View File
@@ -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,
})
+1 -3
View File
@@ -45,10 +45,8 @@ local highlights = {
TabLineFill = { bg = c.bg1 }, TabLineFill = { bg = c.bg1 },
EndOfBuffer = { fg = "NONE", bg = "NONE" }, EndOfBuffer = { fg = "NONE", bg = "NONE" },
DiffAdd = { bg = "#1a2f22" }, DiffAdd = { bg = "#1a2f22" },
DiffChange = { bg = "#15304a" },
DiffDelete = { bg = "#311c1e" },
Changed = { fg = c.yellow },
NvimTreeIndentMarker = { fg = c.bg3 }, NvimTreeIndentMarker = { fg = c.bg3 },
GitUnstaged = { fg = c.yellow },
} }
for kind, color in pairs(completion_kind_colors) do for kind, color in pairs(completion_kind_colors) do
highlights["LspKind" .. kind] = { fg = color } highlights["LspKind" .. kind] = { fg = color }
+2 -2
View File
@@ -338,10 +338,10 @@ t.test("<leader>gl log buffer refills after jumping back", function()
h.git(dir, "commit", "-q", "-m", "second") h.git(dir, "commit", "-q", "-m", "second")
require("git.log_view").open({ max_count = 1000 }) require("git.log_view").open({ max_count = 1000 })
wait_buf_populated("^gitlog://") wait_buf_populated("/GitLog$")
local log_buf = vim.api.nvim_get_current_buf() local log_buf = vim.api.nvim_get_current_buf()
local log_win = vim.api.nvim_get_current_win() local log_win = vim.api.nvim_get_current_win()
t.truthy(vim.api.nvim_buf_get_name(log_buf):match("^gitlog://")) t.truthy(vim.api.nvim_buf_get_name(log_buf):match("/GitLog$"))
local initial_lines = #vim.api.nvim_buf_get_lines(log_buf, 0, -1, false) local initial_lines = #vim.api.nvim_buf_get_lines(log_buf, 0, -1, false)
t.truthy(initial_lines >= 2) t.truthy(initial_lines >= 2)
+634
View File
@@ -0,0 +1,634 @@
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 and s.head ~= nil
end, "hunks to load the index and HEAD snapshots")
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("toggle_stage 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.toggle_stage(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("toggle_stage 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.toggle_stage(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("toggle_stage 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.toggle_stage(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("toggle_stage stages only the hunk under the cursor", function()
local committed = table.concat({
"local M = {}",
"",
"function M.first()",
" return 1",
"end",
"",
"function M.last()",
" return 9",
"end",
"",
"return M",
}, "\n") .. "\n"
local worktree = table.concat({
"local M = {}",
"",
"-- helpers",
"function M.first()",
" return 1",
"end",
"",
"function M.mid()",
" return 5",
"end",
"",
"function M.last()",
" return 9",
"end",
"",
"return M",
}, "\n") .. "\n"
local dir, buf = setup(committed, worktree)
vim.api.nvim_win_set_cursor(0, { 9, 0 })
hunks.toggle_stage(buf)
t.wait_for(function()
return h.git(dir, "diff", "--cached", "--name-only").stdout ~= ""
end, "the mid hunk to land in the index")
t.eq(
h.git(dir, "show", ":0:a.txt").stdout,
table.concat({
"local M = {}",
"",
"function M.first()",
" return 1",
"end",
"",
"function M.mid()",
" return 5",
"end",
"",
"function M.last()",
" return 9",
"end",
"",
"return M",
}, "\n"),
"only the cursor's hunk is staged, placed at the right line"
)
end)
t.test("toggle_stage stages a whole-file change with no context", function()
local dir, buf = setup("a\nb\nc\n", "x\ny\nz\n")
vim.api.nvim_win_set_cursor(0, { 2, 0 })
hunks.toggle_stage(buf)
t.wait_for(function()
return h.git(dir, "diff", "--cached", "--name-only").stdout ~= ""
end, "the change to land in the index")
t.eq(h.git(dir, "show", ":0:a.txt").stdout, "x\ny\nz")
end)
t.test("toggle_stage stages a change at the start of the file", function()
local dir, buf = setup("a\nb\nc\nd\ne\n", "A\nb\nc\nd\ne\n")
vim.api.nvim_win_set_cursor(0, { 1, 0 })
hunks.toggle_stage(buf)
t.wait_for(function()
return h.git(dir, "diff", "--cached", "--name-only").stdout ~= ""
end, "the change to land in the index")
t.eq(h.git(dir, "show", ":0:a.txt").stdout, "A\nb\nc\nd\ne")
end)
t.test("toggle_stage stages a change at the end of the file", function()
local dir, buf = setup("a\nb\nc\nd\ne\n", "a\nb\nc\nd\nE\n")
vim.api.nvim_win_set_cursor(0, { 5, 0 })
hunks.toggle_stage(buf)
t.wait_for(function()
return h.git(dir, "diff", "--cached", "--name-only").stdout ~= ""
end, "the change to land in the index")
t.eq(h.git(dir, "show", ":0:a.txt").stdout, "a\nb\nc\nd\nE")
end)
t.test("toggle_stage stages a deletion at the start of the file", function()
local dir, buf = setup("a\nb\nc\nd\n", "b\nc\nd\n")
vim.api.nvim_win_set_cursor(0, { 1, 0 })
hunks.toggle_stage(buf)
t.wait_for(function()
return h.git(dir, "diff", "--cached", "--name-only").stdout ~= ""
end, "the deletion to land in the index")
t.eq(h.git(dir, "show", ":0:a.txt").stdout, "b\nc\nd")
end)
t.test("toggle_stage leaves an adjacent unstaged hunk in place", function()
local dir, buf = setup("a\nb\nc\nd\ne\n", "A\nb\nC\nd\ne\n")
vim.api.nvim_win_set_cursor(0, { 3, 0 })
hunks.toggle_stage(buf)
t.wait_for(function()
return h.git(dir, "diff", "--cached", "--name-only").stdout ~= ""
end, "the line-3 hunk to land in the index")
t.eq(
h.git(dir, "show", ":0:a.txt").stdout,
"a\nb\nC\nd\ne",
"only line 3 is staged; the adjacent line-1 hunk is untouched"
)
end)
t.test("toggle_stage unstages one of two adjacent staged hunks", function()
local dir, buf = setup("a\nb\nc\nd\ne\n", "A\nb\nC\nd\ne\n")
vim.api.nvim_win_set_cursor(0, { 1, 0 })
hunks.toggle_stage(buf)
t.wait_for(function()
return #assert(hunks.state(buf)).staged == 1
end, "the line-1 hunk to be staged")
vim.api.nvim_win_set_cursor(0, { 3, 0 })
hunks.toggle_stage(buf)
t.wait_for(function()
local s = assert(hunks.state(buf))
return #s.hunks == 0 and #s.staged == 2
end, "both hunks to be staged")
vim.api.nvim_win_set_cursor(0, { 3, 0 })
hunks.toggle_stage(buf)
t.wait_for(function()
return #assert(hunks.state(buf)).staged == 1
end, "the line-3 hunk to be unstaged again")
t.eq(
h.git(dir, "show", ":0:a.txt").stdout,
"A\nb\nc\nd\ne",
"line 3 reverts to HEAD while the staged line-1 change remains"
)
end)
t.test("toggle_stage 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.toggle_stage(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.toggle_stage(buf)
t.wait_for(function()
return #assert(hunks.state(buf)).hunks == 1
end, "gutter to drop the middle staged hunk")
end)
t.test("staged hunks show with the staged highlight", function()
local _, buf = setup("a\nb\nc\n", "a\nB\nc\n")
vim.api.nvim_win_set_cursor(0, { 2, 0 })
hunks.toggle_stage(buf)
t.wait_for(function()
local s = assert(hunks.state(buf))
return #s.hunks == 0 and #s.staged == 1
end, "the hunk to move from unstaged to staged")
t.eq(sign_marks(buf), {
{ row = 1, sign = "", hl = "GitHunkStagedChanged" },
})
end)
t.test("the gutter shows staged and unstaged hunks together", function()
local _, buf = setup("a\nb\nc\nd\ne\n", "A\nb\nC\nd\nE\n")
vim.api.nvim_win_set_cursor(0, { 1, 0 })
hunks.toggle_stage(buf)
t.wait_for(function()
return #assert(hunks.state(buf)).hunks == 2
end, "the first hunk to leave the unstaged set")
t.eq(sign_marks(buf), {
{ row = 0, sign = "", hl = "GitHunkStagedChanged" },
{ row = 2, sign = "", hl = "GitHunkChanged" },
{ row = 4, sign = "", hl = "GitHunkChanged" },
})
end)
t.test("toggle_stage toggles a staged hunk back to unstaged", function()
local _, buf = setup("a\nb\nc\n", "a\nB\nc\n")
vim.api.nvim_win_set_cursor(0, { 2, 0 })
hunks.toggle_stage(buf)
t.wait_for(function()
local s = assert(hunks.state(buf))
return #s.hunks == 0 and #s.staged == 1
end, "the hunk to become staged")
hunks.toggle_stage(buf)
t.wait_for(function()
local s = assert(hunks.state(buf))
return #s.hunks == 1 and #s.staged == 0
end, "the hunk to return to unstaged")
t.eq(sign_marks(buf), {
{ row = 1, sign = "", hl = "GitHunkChanged" },
})
end)
t.test("toggle_stage unstages correctly when buffer lines are shifted", function()
local dir, buf = setup("a\nb\nc\n", "a\nb\nC\n")
vim.api.nvim_win_set_cursor(0, { 3, 0 })
hunks.toggle_stage(buf)
t.wait_for(function()
return #assert(hunks.state(buf)).staged == 1
end, "the line-3 change to be staged")
vim.api.nvim_buf_set_lines(buf, 0, 0, false, { "NEW" })
vim.api.nvim_exec_autocmds("TextChanged", { buffer = buf })
hunks._flush(buf)
t.wait_for(function()
return #assert(hunks.state(buf)).hunks == 1
end, "the unstaged add at the top to register")
vim.api.nvim_win_set_cursor(0, { 4, 0 })
hunks.toggle_stage(buf)
t.wait_for(function()
return #assert(hunks.state(buf)).staged == 0
end, "the shifted staged hunk to be unstaged")
t.eq(
h.git(dir, "show", ":0:a.txt").stdout,
"a\nb\nc",
"the index reverts to HEAD content for the unstaged 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)
+17 -1
View File
@@ -51,7 +51,6 @@ end)
t.test("parse_uri returns nil for non-git URIs", function() t.test("parse_uri returns nil for non-git URIs", function()
t.falsy(object.parse_uri("file:///tmp/x")) t.falsy(object.parse_uri("file:///tmp/x"))
t.falsy(object.parse_uri("/tmp/x")) t.falsy(object.parse_uri("/tmp/x"))
t.falsy(object.parse_uri("gitlog:///tmp/x"))
end) end)
t.test("M.open(HEAD) names buffer with full sha", function() t.test("M.open(HEAD) names buffer with full sha", function()
@@ -87,6 +86,23 @@ t.test("M.open(HEAD:<path>) loads file content at HEAD", function()
) )
end) 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() t.test("M.open errors on a bogus base, no buffer is opened", function()
local dir = h.make_repo({ a = "first\n" }) local dir = h.make_repo({ a = "first\n" })
local r = assert(require("git.core.repo").resolve(dir)) local r = assert(require("git.core.repo").resolve(dir))
+45
View File
@@ -71,6 +71,51 @@ t.test("get_cached memoizes by key", function()
t.truthy(v1 == v2, "second call should return cached table") t.truthy(v1 == v2, "second call should return cached table")
end) 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("head_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:head_sha("a")
t.truthy(sha and #sha > 0, "head_sha returns the HEAD blob sha")
t.truthy(r._cache["head_blob:a"] ~= nil, "the result is cached")
t.eq(r:head_sha("a"), sha, "a cached call returns the same sha")
end)
t.test("head_sha cache clears when HEAD moves", function()
local dir = h.make_repo({ a = "x\n" })
local r = assert(require("git.core.repo").resolve(dir))
r:head_sha("a")
t.truthy(r._cache["head_blob:a"] ~= nil, "sha is cached before the commit")
t.write(dir, "a", "y\n")
h.git(dir, "commit", "-aqm", "change")
wait_cleared(r, "head_blob:a", 2000)
end)
t.test("cache clears after top-level .git change (commit)", function() t.test("cache clears after top-level .git change (commit)", function()
local dir = h.make_repo({ a = "x" }) local dir = h.make_repo({ a = "x" })
local r = assert(require("git.core.repo").resolve(dir)) local r = assert(require("git.core.repo").resolve(dir))
+171 -1
View File
@@ -277,7 +277,7 @@ t.test(
error("a non-sidebar window should remain after close") error("a non-sidebar window should remain after close")
end end
vim.api.nvim_set_current_win(remaining) 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() t.wait_for(function()
local count = 0 local count = 0
for _, w in ipairs(vim.api.nvim_tabpage_list_wins(0)) do for _, w in ipairs(vim.api.nvim_tabpage_list_wins(0)) do
@@ -315,3 +315,173 @@ t.test(
) )
end end
) )
t.test("sidebar buffer is named <worktree>/GitStatus", function()
local repo = h.make_repo({ ["foo.txt"] = "x\n" })
vim.cmd("cd " .. repo)
require("git.status_view").open({ placement = "sidebar" })
local r = assert(require("git.core.repo").find(vim.fn.getcwd()))
local buf = find_sidebar()
assert(buf, "sidebar buffer should exist")
t.eq(
vim.api.nvim_buf_get_name(buf),
r.worktree .. "/GitStatus",
"buffer name should be <worktree>/GitStatus"
)
end)
t.test(
"calling open twice without closing focuses the existing sidebar",
function()
local repo = h.make_repo({ ["foo.txt"] = "x\n" })
vim.cmd("cd " .. repo)
require("git.status_view").open({ placement = "sidebar" })
local first = find_sidebar()
assert(first, "first sidebar buffer should exist")
require("git.status_view").open({ placement = "sidebar" })
local second = find_sidebar()
assert(second, "second sidebar buffer should exist")
t.eq(
first,
second,
"consecutive opens should reuse the visible sidebar"
)
local count = 0
for _, b in ipairs(vim.api.nvim_list_bufs()) do
if vim.bo[b].filetype == "gitstatus" then
count = count + 1
end
end
t.eq(count, 1, "only one gitstatus buffer should exist")
end
)
t.test("opening for different worktrees creates separate buffers", function()
local repo_a = h.make_repo({ ["a.txt"] = "x\n" })
local repo_b = h.make_repo({ ["b.txt"] = "y\n" })
vim.cmd("cd " .. repo_a)
require("git.status_view").open({ placement = "sidebar" })
local buf_a = find_sidebar()
require("git.status_view").toggle()
vim.cmd("cd " .. repo_b)
require("git.status_view").open({ placement = "sidebar" })
local buf_b = find_sidebar()
assert(buf_a and buf_b)
t.truthy(
buf_a ~= buf_b,
"different worktrees should produce different buffers"
)
end)
t.test("sidebar buffer is buftype=nofile and not buflisted", function()
local repo = h.make_repo({ ["foo.txt"] = "x\n" })
vim.cmd("cd " .. repo)
require("git.status_view").open({ placement = "sidebar" })
local buf = find_sidebar()
assert(buf, "sidebar buffer should exist")
t.eq(vim.bo[buf].buftype, "nofile", "buftype should be nofile")
t.eq(vim.bo[buf].buflisted, false, "buflisted should be false")
end)
t.test("sidebar buffer name does not get written to disk", function()
local repo = h.make_repo({ ["foo.txt"] = "x\n" })
vim.cmd("cd " .. repo)
require("git.status_view").open({ placement = "sidebar" })
local buf = find_sidebar()
assert(buf, "sidebar buffer should exist")
local name = vim.api.nvim_buf_get_name(buf)
vim.api.nvim_buf_call(buf, function()
pcall(function()
vim.cmd("silent! write")
end)
end)
t.eq(
vim.uv.fs_stat(name),
nil,
"no real file should be created at the sidebar buffer's path"
)
end)
t.test(
"diffsplit from sidebar resets cursor so panes stay in sync",
function()
local committed, worktree = {}, {}
for i = 1, 100 do
committed[i] = "line " .. i
worktree[i] = i == 10
and "CHANGED " .. i
or i == 40 and "CHANGED " .. i
or i == 70 and "CHANGED " .. i
or i == 90 and "CHANGED " .. i
or ("line " .. i)
end
local repo = h.make_repo({
["file.txt"] = table.concat(committed, "\n") .. "\n",
})
t.write(repo, "file.txt", table.concat(worktree, "\n") .. "\n")
vim.cmd("cd " .. repo)
-- Open the worktree file in a normal window and position cursor in
-- what becomes a folded section after diff is set up.
vim.cmd("edit file.txt")
vim.api.nvim_win_set_cursor(0, { 50, 0 })
require("git.status_view").open({ placement = "sidebar" })
local sidebar_buf, sidebar_win = find_sidebar()
assert(sidebar_buf and sidebar_win)
local r = assert(require("git.core.repo").find(vim.fn.getcwd()))
r:refresh()
t.wait_for(function()
return r.status and #r.status:rows("unstaged") > 0
end, "git status to report unstaged changes")
local entry_line
for i, l in
ipairs(vim.api.nvim_buf_get_lines(sidebar_buf, 0, -1, false))
do
if l:match("file.txt$") then
entry_line = i
break
end
end
if not entry_line then
error("entry line should exist")
end
vim.api.nvim_set_current_win(sidebar_win)
vim.api.nvim_win_set_cursor(sidebar_win, { entry_line, 0 })
t.press("<Tab>")
t.wait_for(function()
return find_diff_win("left") ~= nil
and find_diff_win("right") ~= nil
end, "diff pair to appear")
local left_win = assert(find_diff_win("left"))
local right_win = assert(find_diff_win("right"))
local left_top =
vim.api.nvim_win_call(left_win, function() return vim.fn.line("w0") end)
local right_top = vim.api.nvim_win_call(
right_win,
function() return vim.fn.line("w0") end
)
t.eq(
left_top,
right_top,
"left and right panes should have the same topline after diffsplit"
)
t.eq(
vim.api.nvim_win_get_cursor(left_win),
{ 1, 0 },
"left pane should start at line 1"
)
t.eq(
vim.api.nvim_win_get_cursor(right_win),
{ 1, 0 },
"right pane should start at line 1"
)
end
)