Compare commits

..

3 Commits

Author SHA1 Message Date
warg 432682409e feat(git): add in-house git blame 2026-05-22 16:38:50 +02:00
warg a786d8458d refactor(git): move the zero-sha check into core/util 2026-05-22 16:38:49 +02:00
warg a401e0a12b fix(onedark): add back DiffDelete hl override 2026-05-20 14:33:37 +02:00
7 changed files with 1569 additions and 9 deletions
+2
View File
@@ -234,6 +234,8 @@ 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>gb", "<Plug>(git-blame-popup)")
vim.keymap.set("n", "<leader>gB", "<Plug>(git-blame)")
vim.keymap.set("n", "<leader>gv", "<Plug>(git-hunk-select)") 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>gs", "<Plug>(git-hunk-stage-toggle)")
vim.keymap.set("n", "<leader>gr", "<Plug>(git-hunk-reset)") vim.keymap.set("n", "<leader>gr", "<Plug>(git-hunk-reset)")
+953
View File
@@ -0,0 +1,953 @@
local object = require("git.object")
local repo = require("git.core.repo")
local util = require("git.core.util")
local M = {}
local NS_INLINE = vim.api.nvim_create_namespace("ow.git.blame.inline")
local NS_POPUP = vim.api.nvim_create_namespace("ow.git.blame.popup")
local ZERO_SHA = string.rep("0", 40)
local BLAME_EXPR = "%{%v:lua.require('git.blame').statuscolumn()%}"
-- Neovim collapses the gutter once the statuscolumn passes 47 cells.
local STATUSCOLUMN_MAX = 47
local GAP = " "
local SHA_WIDTH = 8
local AUTHOR_MAX = 16
local DATE_WIDTH = 10
local PREFERRED_WIDTH = SHA_WIDTH + AUTHOR_MAX + DATE_WIDTH + 3 * #GAP
---@class ow.Git.Blame.Commit
---@field sha string
---@field author string
---@field author_mail string
---@field author_time integer
---@field author_tz string
---@field summary string
---@class ow.Git.Blame.Result
---@field commits table<string, ow.Git.Blame.Commit>
---@field line_sha table<integer, string>
---@class ow.Git.Blame.Source
---@field repo ow.Git.Repo
---@field rel string
---@field revision string?
---@class ow.Git.Blame.BufState
---@field repo ow.Git.Repo
---@field rel string
---@field revision string? nil = working tree, else the blamed revision
---@field commits table<string, ow.Git.Blame.Commit>
---@field line_sha table<integer, string>
---@field blame_text table<integer, string>? cached overlay gutter text
---@field blame_width integer? display width of each cached segment
---@field blame_blank string? a blank segment of that width
---@field tick integer?
---@field epoch integer
---@field pending fun()[]
---@field inline boolean
---@field overlay boolean
---@field autocmds integer[]
---@type table<integer, ow.Git.Blame.BufState>
local states = {}
---@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
---@param buf integer
---@return ow.Git.Blame.BufState?
function M.state(buf)
return states[buf]
end
---@param n integer
---@param unit string
---@return string
local function plural(n, unit)
return string.format("%d %s%s ago", n, unit, n == 1 and "" or "s")
end
---@param unix_ts integer
---@return string
local function relative_time(unix_ts)
local diff = os.time() - unix_ts
if diff < 0 then
diff = 0
end
if diff < 45 then
return "just now"
elseif diff < 90 then
return "a minute ago"
elseif diff < 45 * 60 then
return plural(math.floor(diff / 60 + 0.5), "minute")
elseif diff < 90 * 60 then
return "an hour ago"
elseif diff < 22 * 3600 then
return plural(math.floor(diff / 3600 + 0.5), "hour")
elseif diff < 36 * 3600 then
return "a day ago"
elseif diff < 7 * 86400 then
return plural(math.floor(diff / 86400 + 0.5), "day")
elseif diff < 30 * 86400 then
return plural(math.floor(diff / (7 * 86400) + 0.5), "week")
elseif diff < 365 * 86400 then
return plural(math.floor(diff / (30 * 86400) + 0.5), "month")
end
return plural(math.floor(diff / (365 * 86400) + 0.5), "year")
end
M.relative_time = relative_time
---@param ts integer
---@return string
local function format_date(ts)
return os.date("%Y-%m-%d", ts) --[[@as string]]
end
---@param s string
---@param width integer
---@return string
local function pad(s, width)
local w = vim.api.nvim_strwidth(s)
if w > width then
return vim.fn.strcharpart(s, 0, width)
end
return s .. string.rep(" ", width - w)
end
---@param stdout string
---@return ow.Git.Blame.Result
local function parse_porcelain(stdout)
---@type table<string, ow.Git.Blame.Commit>
local commits = {}
---@type table<integer, string>
local line_sha = {}
local cur_sha ---@type string?
local cur_lnum ---@type integer?
for _, line in ipairs(util.split_lines(stdout)) do
if line:sub(1, 1) == "\t" then
if cur_sha and cur_lnum then
line_sha[cur_lnum] = cur_sha
end
cur_sha = nil
cur_lnum = nil
else
local sha, final = line:match("^(%x+) %d+ (%d+)")
if sha and #sha >= 40 then
cur_sha = sha
cur_lnum = tonumber(final) --[[@as integer?]]
if not commits[sha] then
commits[sha] = {
sha = sha,
author = "",
author_mail = "",
author_time = 0,
author_tz = "",
summary = "",
}
end
else
local key, value = line:match("^(%S+) (.*)$")
local commit = cur_sha and commits[cur_sha]
if commit and key then
if key == "author" then
commit.author = value
elseif key == "author-mail" then
commit.author_mail = value
elseif key == "author-time" then
commit.author_time = math.floor(tonumber(value) or 0)
elseif key == "author-tz" then
commit.author_tz = value
elseif key == "summary" then
commit.summary = value
end
end
end
end
end
return { commits = commits, line_sha = line_sha }
end
---@param line_count integer
---@return ow.Git.Blame.Result
local function synth_uncommitted(line_count)
---@type table<integer, string>
local line_sha = {}
for i = 1, line_count do
line_sha[i] = ZERO_SHA
end
return {
commits = {
[ZERO_SHA] = {
sha = ZERO_SHA,
author = "Not Committed Yet",
author_mail = "",
author_time = os.time() --[[@as integer]],
author_tz = "",
summary = "",
},
},
line_sha = line_sha,
}
end
---@param r ow.Git.Repo
---@param rel string
---@param opts { rev: string?, contents: string? }
---@param done fun(result: ow.Git.Blame.Result?)
local function fetch_blame(r, rel, opts, done)
local args = { "--no-pager", "blame", "--porcelain" }
if opts.contents then
table.insert(args, "--contents")
table.insert(args, "-")
end
if opts.rev then
table.insert(args, opts.rev)
end
table.insert(args, "--")
table.insert(args, rel)
util.git(args, {
cwd = r.worktree,
stdin = opts.contents,
silent = true,
on_exit = function(res)
if res.code ~= 0 then
done(nil)
else
done(parse_porcelain(res.stdout or ""))
end
end,
})
end
---Work out what a buffer should be blamed against: a worktree file
---(blame the buffer contents) or a `git://<rev>:<path>` object (blame
---that revision). A `git://<rev>` object with no path is not blameable.
---@param buf integer
---@return ow.Git.Blame.Source?
local function resolve_source(buf)
if not vim.api.nvim_buf_is_valid(buf) then
return nil
end
local name = vim.api.nvim_buf_get_name(buf)
if util.is_uri(name) then
local rev = object.parse_uri(name)
if not rev or not rev.base or not rev.path then
return nil
end
local r = repo.find(buf)
if not r then
return nil
end
return { repo = r, rel = rev.path, revision = rev.base }
end
if not repo.is_worktree_buf(buf) then
return nil
end
local r = repo.find(buf)
if not r then
return nil
end
local rel = vim.fs.relpath(r.worktree, vim.fn.resolve(name))
if not rel then
return nil
end
return { repo = r, rel = rel }
end
---@param buf integer
---@return ow.Git.Blame.BufState?
local function ensure_state(buf)
if states[buf] then
return states[buf]
end
local src = resolve_source(buf)
if not src then
return nil
end
---@type ow.Git.Blame.BufState
local state = {
repo = src.repo,
rel = src.rel,
revision = src.revision,
commits = {},
line_sha = {},
blame_text = nil,
blame_width = nil,
blame_blank = nil,
tick = nil,
epoch = 0,
pending = {},
inline = false,
overlay = false,
autocmds = {},
}
states[buf] = state
return state
end
---Blame the buffer and cache the result, keyed by `changedtick`. Worktree
---buffers blame the live buffer contents; `git://` buffers blame their
---revision. `done` runs once the cache is populated.
---@param state ow.Git.Blame.BufState
---@param buf integer
---@param done fun()?
local function run_blame(state, buf, done)
local tick = vim.api.nvim_buf_get_changedtick(buf)
if state.tick == tick then
if done then
done()
end
return
end
if done then
table.insert(state.pending, done)
end
state.epoch = state.epoch + 1
local epoch = state.epoch
local opts ---@type { rev: string?, contents: string? }
if state.revision then
opts = { rev = state.revision }
else
opts = {
contents = table.concat(
vim.api.nvim_buf_get_lines(buf, 0, -1, false),
"\n"
) .. "\n",
}
end
fetch_blame(state.repo, state.rel, opts, function(result)
if
states[buf] ~= state
or epoch ~= state.epoch
or not vim.api.nvim_buf_is_valid(buf)
then
return
end
local data = result
or synth_uncommitted(vim.api.nvim_buf_line_count(buf))
state.commits = data.commits
state.line_sha = data.line_sha
state.blame_text = nil
state.tick = tick
local pending = state.pending
state.pending = {}
for _, fn in ipairs(pending) do
fn()
end
end)
end
---@param buf integer
local function render_inline(buf)
if not vim.api.nvim_buf_is_valid(buf) then
return
end
vim.api.nvim_buf_clear_namespace(buf, NS_INLINE, 0, -1)
local state = states[buf]
if not state or not state.inline then
return
end
local win = vim.api.nvim_get_current_buf() == buf
and vim.api.nvim_get_current_win()
or vim.fn.bufwinid(buf)
if win == -1 then
return
end
local lnum = vim.api.nvim_win_get_cursor(win)[1]
local sha = state.line_sha[lnum]
local commit = sha and state.commits[sha]
if not commit then
return
end
local text
if util.is_zero_sha(sha) then
text = " You - Not Committed Yet"
else
text = string.format(
" %s, %s - %s",
commit.author,
relative_time(commit.author_time),
commit.summary
)
end
pcall(vim.api.nvim_buf_set_extmark, buf, NS_INLINE, lnum - 1, 0, {
virt_text = { { text, "GitBlame" } },
virt_text_pos = "eol",
hl_mode = "combine",
})
end
---The native fold / sign / number column items, each emitted only when
---its window option is on.
---@param win integer
---@return string
local function native_items(win)
local wo = vim.wo[win]
local items = ""
if wo.foldcolumn ~= "0" then
items = items .. "%C"
end
if wo.signcolumn ~= "no" then
items = items .. "%s"
end
if wo.number or wo.relativenumber then
items = items .. "%l"
end
return items
end
---The maximum width the native fold / sign / number columns can occupy.
---Computed from the window options, not evaluated: evaluating a
---statuscolumn reports the window's current gutter width, which the
---overlay itself has already inflated.
---@param win integer
---@return integer
local function native_width(win)
local wo = vim.wo[win]
local width = 0
if wo.number or wo.relativenumber then
local buf = vim.api.nvim_win_get_buf(win)
local digits = #tostring(vim.api.nvim_buf_line_count(buf)) + 1
width = math.max(wo.numberwidth, digits)
end
local sc = wo.signcolumn
if sc:find("^yes") or sc:find("^auto") then
width = width + 2 * math.floor(tonumber(sc:match("(%d)$")) or 1)
end
local fc = tonumber(wo.foldcolumn:match("(%d)$"))
if fc then
width = width + math.floor(fc)
elseif wo.foldcolumn:find("^auto") then
width = width + 1
end
return width
end
---Split a `budget` of display cells across the blame fields. The author
---absorbs the squeeze first, then the date, then the sha.
---@param budget integer
---@return integer sha_w
---@return integer author_w
---@return integer date_w
local function layout(budget)
local body = budget - 3 * #GAP
if body <= 0 then
return 0, 0, 0
end
local sha_w = math.min(body, SHA_WIDTH)
local rest = body - sha_w
local date_w = math.min(rest, DATE_WIDTH)
return sha_w, rest - date_w, date_w
end
---Precompute the per-line blame segment so the statuscolumn expression
---stays a table lookup. Blame shares Neovim's 47-cell statuscolumn cap
---with the native columns, so it is budgeted into what they leave free.
---@param state ow.Git.Blame.BufState
---@param win integer
local function build_blame_text(state, win)
local total = math.max(
0,
math.min(STATUSCOLUMN_MAX - native_width(win), PREFERRED_WIDTH)
)
local sha_w, author_w, date_w = layout(total)
local blank = string.rep(" ", total)
---@type table<integer, string>
local text = {}
for lnum, sha in pairs(state.line_sha) do
local commit = state.commits[sha]
if commit then
if sha_w == 0 then
text[lnum] = blank
else
local author, date
if util.is_zero_sha(sha) then
author, date = "Uncommitted", ""
else
author = commit.author
date = format_date(commit.author_time)
end
text[lnum] = "%#GitBlameSha#"
.. sha:sub(1, sha_w)
.. "%#GitBlame#"
.. GAP
.. (pad(author, author_w):gsub("%%", "%%%%"))
.. GAP
.. pad(date, date_w)
.. GAP
end
end
end
state.blame_text = text
state.blame_width = total
state.blame_blank = blank
end
---Render the blame segment for one screen line. Wired into the window's
---`'statuscolumn'` while the overlay is on, so the cursor never enters
---it - it lives outside the text area, unlike inline virtual text.
---@param win integer?
---@param lnum integer
---@param virtnum integer
---@return string
local function gutter(win, lnum, virtnum)
if not win or win == 0 or not vim.api.nvim_win_is_valid(win) then
return ""
end
local state = states[vim.api.nvim_win_get_buf(win)]
if not state or not state.overlay or not state.blame_text then
return ""
end
return (virtnum == 0 and state.blame_text[lnum]) or state.blame_blank or ""
end
M._gutter = gutter
M._layout = layout
M._native_width = native_width
function M.statuscolumn()
local ok, result =
pcall(gutter, vim.api.nvim_get_current_win(), vim.v.lnum, vim.v.virtnum)
return ok and result or ""
end
---@type table<integer, string>
local saved_statuscolumn = {}
---Reconcile every window's `'statuscolumn'` with the overlay state: a
---window showing an overlay buffer gets the blame statuscolumn, and its
---previous value is saved so it can be restored on toggle-off.
local function refresh_overlay_columns()
for win in pairs(saved_statuscolumn) do
if not vim.api.nvim_win_is_valid(win) then
saved_statuscolumn[win] = nil
end
end
for _, win in ipairs(vim.api.nvim_list_wins()) do
local state = states[vim.api.nvim_win_get_buf(win)]
local on = state ~= nil and state.overlay
if on and saved_statuscolumn[win] == nil then
saved_statuscolumn[win] = vim.wo[win].statuscolumn
vim.wo[win].statuscolumn = BLAME_EXPR .. native_items(win)
elseif not on and saved_statuscolumn[win] ~= nil then
vim.wo[win].statuscolumn = saved_statuscolumn[win]
saved_statuscolumn[win] = nil
end
end
end
---@param buf integer
local function render(buf)
render_inline(buf)
local state = states[buf]
if not state or not state.overlay then
return
end
-- Rebuild against the current native column widths, then re-set
-- `'statuscolumn'` so the redraw picks up the new text instead of the
-- cached gutter.
local built = false
for _, win in ipairs(vim.api.nvim_list_wins()) do
if vim.api.nvim_win_get_buf(win) == buf then
if not built then
build_blame_text(state, win)
built = true
end
vim.wo[win].statuscolumn = BLAME_EXPR .. native_items(win)
end
end
end
---@param buf integer
local function reblame(buf)
local state = states[buf]
if not state or (not state.inline and not state.overlay) then
return
end
run_blame(state, buf, function()
render(buf)
end)
end
local schedule, sched_handle = util.keyed_debounce(reblame, 150)
---@param buf integer
function M._flush(buf)
sched_handle.flush(buf)
end
---@param buf integer
---@param state ow.Git.Blame.BufState
local function attach_autocmds(buf, state)
if #state.autocmds > 0 then
return
end
local group =
vim.api.nvim_create_augroup("ow.git.blame." .. buf, { clear = true })
state.autocmds = {
vim.api.nvim_create_autocmd({ "CursorMoved", "CursorMovedI" }, {
group = group,
buffer = buf,
callback = function()
render_inline(buf)
end,
}),
vim.api.nvim_create_autocmd(
{ "TextChanged", "TextChangedI", "BufWritePost" },
{
group = group,
buffer = buf,
callback = function()
schedule(buf)
end,
}
),
}
end
---@param buf integer
---@param state ow.Git.Blame.BufState
local function detach_autocmds(buf, state)
for _, id in ipairs(state.autocmds) do
pcall(vim.api.nvim_del_autocmd, id)
end
state.autocmds = {}
pcall(vim.api.nvim_del_augroup_by_name, "ow.git.blame." .. buf)
sched_handle.cancel(buf)
end
---@param buf integer?
function M.toggle_inline(buf)
buf = resolve_buf(buf)
local state = ensure_state(buf)
if not state then
util.warning("git blame: nothing to blame in this buffer")
return
end
state.inline = not state.inline
if state.inline then
attach_autocmds(buf, state)
run_blame(state, buf, function()
render_inline(buf)
end)
else
if vim.api.nvim_buf_is_valid(buf) then
vim.api.nvim_buf_clear_namespace(buf, NS_INLINE, 0, -1)
end
if not state.overlay then
detach_autocmds(buf, state)
end
end
end
---@param buf integer?
function M.toggle_overlay(buf)
buf = resolve_buf(buf)
local state = ensure_state(buf)
if not state then
util.warning("git blame: nothing to blame in this buffer")
return
end
state.overlay = not state.overlay
refresh_overlay_columns()
if state.overlay then
attach_autocmds(buf, state)
run_blame(state, buf, function()
render(buf)
end)
elseif not state.inline then
detach_autocmds(buf, state)
end
end
---@param lines string[]
---@return integer width
---@return integer height
local function size_for(lines)
local width = 1
for _, l in ipairs(lines) do
local w = vim.api.nvim_strwidth(l)
if w > width then
width = w
end
end
width = math.min(math.max(width + 1, 30), vim.o.columns - 4)
local height = math.min(math.max(#lines, 1), math.floor(vim.o.lines / 2))
return width, height
end
local popup_win ---@type integer?
local function close_popup()
if popup_win and vim.api.nvim_win_is_valid(popup_win) then
vim.api.nvim_win_close(popup_win, true)
end
popup_win = nil
end
---@param pbuf integer
---@param win integer
---@param head string[]
---@param body string[]?
---@param sha_len integer?
local function apply_popup(pbuf, win, head, body, sha_len)
local lines = {}
vim.list_extend(lines, head)
if body then
vim.list_extend(lines, body)
end
vim.bo[pbuf].modifiable = true
vim.api.nvim_buf_set_lines(pbuf, 0, -1, false, lines)
vim.bo[pbuf].modifiable = false
vim.api.nvim_buf_clear_namespace(pbuf, NS_POPUP, 0, -1)
if sha_len then
pcall(vim.api.nvim_buf_set_extmark, pbuf, NS_POPUP, 0, 0, {
end_col = sha_len,
hl_group = "GitBlameSha",
})
pcall(vim.api.nvim_buf_set_extmark, pbuf, NS_POPUP, 1, 0, {
end_col = #(head[2] or ""),
hl_group = "GitBlame",
})
end
local width, height = size_for(lines)
pcall(vim.api.nvim_win_set_width, win, width)
pcall(vim.api.nvim_win_set_height, win, height)
end
---@param watch_buf integer
---@param pbuf integer
---@param win integer
local function setup_popup_autocmds(watch_buf, pbuf, win)
local group =
vim.api.nvim_create_augroup("ow.git.blame.popup", { clear = true })
vim.api.nvim_create_autocmd(
{ "CursorMoved", "CursorMovedI", "InsertEnter" },
{ group = group, buffer = watch_buf, callback = close_popup }
)
vim.api.nvim_create_autocmd("WinLeave", {
group = group,
buffer = pbuf,
callback = close_popup,
})
vim.api.nvim_create_autocmd("WinClosed", {
group = group,
pattern = tostring(win),
callback = function()
popup_win = nil
pcall(vim.api.nvim_del_augroup_by_name, "ow.git.blame.popup")
end,
})
vim.keymap.set("n", "q", close_popup, { buffer = pbuf, nowait = true })
end
---@param r ow.Git.Repo
---@param commits table<string, ow.Git.Blame.Commit>
---@param line_sha table<integer, string>
---@param lnum integer
---@param watch_buf integer
local function open_popup(r, commits, line_sha, lnum, watch_buf)
close_popup()
local sha = line_sha[lnum]
local commit = sha and commits[sha]
if not commit then
util.warning("git blame: no blame information for line %d", lnum)
return
end
local head ---@type string[]
local sha_len ---@type integer?
if util.is_zero_sha(sha) then
head = { "Not Committed Yet" }
else
local short = sha:sub(1, 8)
sha_len = #short
head = {
short .. " " .. commit.author,
commit.author_mail .. " " .. relative_time(commit.author_time),
"",
}
end
local body = sha_len and { commit.summary } or nil
local lines = {}
vim.list_extend(lines, head)
if body then
vim.list_extend(lines, body)
end
local width, height = size_for(lines)
local pbuf = vim.api.nvim_create_buf(false, true)
vim.bo[pbuf].bufhidden = "wipe"
local win = vim.api.nvim_open_win(pbuf, false, {
relative = "cursor",
row = 1,
col = 0,
width = width,
height = height,
style = "minimal",
})
popup_win = win
apply_popup(pbuf, win, head, body, sha_len)
setup_popup_autocmds(watch_buf, pbuf, win)
if not sha_len then
return
end
util.git({ "show", "-s", "--format=%B", sha }, {
cwd = r.worktree,
silent = true,
on_exit = function(res)
if
popup_win ~= win
or not vim.api.nvim_win_is_valid(win)
or not vim.api.nvim_buf_is_valid(pbuf)
or res.code ~= 0
then
return
end
local msg = util.split_lines(res.stdout or "")
if #msg > 0 then
apply_popup(pbuf, win, head, msg, sha_len)
end
end,
})
end
---@param buf integer?
function M.line_popup(buf)
buf = resolve_buf(buf)
if popup_win and vim.api.nvim_win_is_valid(popup_win) then
vim.api.nvim_set_current_win(popup_win)
return
end
local state = ensure_state(buf)
if not state then
util.warning("git blame: nothing to blame in this buffer")
return
end
local lnum = vim.api.nvim_win_get_cursor(0)[1]
run_blame(state, buf, function()
if
not vim.api.nvim_buf_is_valid(buf)
or vim.api.nvim_get_current_buf() ~= buf
or vim.api.nvim_win_get_cursor(0)[1] ~= lnum
then
return
end
open_popup(state.repo, state.commits, state.line_sha, lnum, buf)
end)
end
---Blame the current line of the current buffer, then hand the commit to
---`done`. Works in worktree files and `git://<rev>:<path>` buffers
---alike, so the open-* actions chain through history.
---@param done fun(state: ow.Git.Blame.BufState, sha: string)
local function blame_line(done)
local buf = vim.api.nvim_get_current_buf()
local state = ensure_state(buf)
if not state then
util.warning("git blame: nothing to blame in this buffer")
return
end
local lnum = vim.api.nvim_win_get_cursor(0)[1]
run_blame(state, buf, function()
if not vim.api.nvim_buf_is_valid(buf) then
return
end
local sha = state.line_sha[lnum]
if not sha or util.is_zero_sha(sha) then
util.warning("git blame: line is not committed yet")
return
end
done(state, sha)
end)
end
function M.open_commit()
blame_line(function(state, sha)
object.open(state.repo, sha, { split = false })
end)
end
function M.open_file()
blame_line(function(state, sha)
object.open(state.repo, sha .. ":" .. state.rel, { split = false })
end)
end
function M.open_file_parent()
blame_line(function(state, sha)
local parent = state.repo:rev_parse(sha .. "^", false)
if not parent then
util.warning("git blame: %s has no parent commit", sha:sub(1, 8))
return
end
object.open(state.repo, parent .. ":" .. state.rel, { split = false })
end)
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_INLINE, 0, -1)
end
detach_autocmds(buf, state)
state.epoch = state.epoch + 1
states[buf] = nil
refresh_overlay_columns()
end
local augroup = vim.api.nvim_create_augroup("ow.git.blame", { clear = true })
vim.api.nvim_create_autocmd("BufWinEnter", {
group = augroup,
callback = refresh_overlay_columns,
})
-- The blame budget depends on the gutter option widths, so re-render an
-- overlay buffer when one of them changes.
vim.api.nvim_create_autocmd("OptionSet", {
group = augroup,
pattern = {
"number",
"relativenumber",
"numberwidth",
"signcolumn",
"foldcolumn",
},
callback = function()
local buf = vim.api.nvim_get_current_buf()
local state = states[buf]
if state and state.overlay then
render(buf)
end
end,
})
-- The blame cache is keyed by `changedtick`, which a commit / checkout /
-- rebase does not bump. Drop the cache for affected worktree buffers on
-- a repo change so the next blame re-fetches; re-blame eagerly if a mode
-- is showing. `git://` buffers blame a fixed revision and are skipped.
repo.on("change", function(r, change)
for buf, state in pairs(states) do
if
state.repo == r
and not state.revision
and (change.paths[state.rel] or change.branch_changed)
then
state.tick = nil
if state.inline or state.overlay then
schedule(buf)
end
end
end
end)
return M
+6
View File
@@ -26,6 +26,12 @@ function M.is_uri(name)
return name:match("^%a+://") ~= nil return name:match("^%a+://") ~= nil
end end
---@param sha string?
---@return boolean
function M.is_zero_sha(sha)
return sha == nil or sha:match("^0+$") ~= nil
end
---@param buf integer ---@param buf integer
---@param name string ---@param name string
function M.set_buf_name(buf, name) function M.set_buf_name(buf, name)
+1 -7
View File
@@ -65,12 +65,6 @@ local function diff_section()
} }
end end
---@param sha string?
---@return boolean
local function is_zero(sha)
return sha == nil or sha:match("^0+$") ~= nil
end
---@param rev ow.Git.Revision ---@param rev ow.Git.Revision
---@return boolean ---@return boolean
local function is_immutable_rev(rev) local function is_immutable_rev(rev)
@@ -291,7 +285,7 @@ end
---@param path string ---@param path string
---@return integer? ---@return integer?
local function side_buf(r, blob, path) local function side_buf(r, blob, path)
if not blob or is_zero(blob) then if not blob or util.is_zero_sha(blob) then
return nil return nil
end end
local full, status = r:resolve_sha(blob) local full, status = r:resolve_sha(blob)
+34 -2
View File
@@ -40,6 +40,9 @@ local DEFAULT_HIGHLIGHTS = {
GitHunkRemoved = "Removed", GitHunkRemoved = "Removed",
GitHunkAddLine = "DiffAdd", GitHunkAddLine = "DiffAdd",
GitHunkDeleteLine = "DiffDelete", GitHunkDeleteLine = "DiffDelete",
GitBlame = "Comment",
GitBlameSha = "GitSha",
} }
local STAGED_HUNK_HL = { local STAGED_HUNK_HL = {
GitHunkStagedAdded = "GitHunkAdded", GitHunkStagedAdded = "GitHunkAdded",
@@ -107,6 +110,7 @@ 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.hunks").detach(args.buf)
require("git.blame").detach(args.buf)
require("git.core.repo").unbind(args.buf) require("git.core.repo").unbind(args.buf)
end, end,
}) })
@@ -220,8 +224,7 @@ end, {
vim.api.nvim_create_user_command("Gstatus", function(opts) vim.api.nvim_create_user_command("Gstatus", function(opts)
require("git.status_view").open({ require("git.status_view").open({
placement = opts.fargs[1] --[[@as ow.Git.StatusView.Placement]] placement = opts.fargs[1] --[[@as ow.Git.StatusView.Placement]] or "split",
or "split",
}) })
end, { end, {
nargs = "?", nargs = "?",
@@ -314,3 +317,32 @@ end, { silent = true, desc = "Toggle the git diff overlay" })
vim.api.nvim_create_user_command("GitDiffOverlay", function() vim.api.nvim_create_user_command("GitDiffOverlay", function()
require("git.hunks").toggle_overlay() require("git.hunks").toggle_overlay()
end, { desc = "Toggle the git diff overlay in the current buffer" }) end, { desc = "Toggle the git diff overlay in the current buffer" })
vim.keymap.set("n", "<Plug>(git-blame)", function()
require("git.blame").toggle_overlay()
end, { silent = true, desc = "Toggle the full-file git blame overlay" })
vim.keymap.set("n", "<Plug>(git-blame-line)", function()
require("git.blame").toggle_inline()
end, { silent = true, desc = "Toggle inline git blame" })
vim.keymap.set("n", "<Plug>(git-blame-popup)", function()
require("git.blame").line_popup()
end, { silent = true, desc = "Show git blame for the current line" })
vim.keymap.set("n", "<Plug>(git-blame-commit)", function()
require("git.blame").open_commit()
end, { silent = true, desc = "Open the commit that last touched this line" })
vim.keymap.set("n", "<Plug>(git-blame-file)", function()
require("git.blame").open_file()
end, { silent = true, desc = "Open this file at the line's commit" })
vim.keymap.set("n", "<Plug>(git-blame-file-parent)", function()
require("git.blame").open_file_parent()
end, {
silent = true,
desc = "Open this file at the parent of the line's commit",
})
vim.api.nvim_create_user_command("GitBlame", function()
require("git.blame").toggle_overlay()
end, { desc = "Toggle the full-file git blame overlay in the current buffer" })
vim.api.nvim_create_user_command("GitBlameLine", function()
require("git.blame").toggle_inline()
end, { desc = "Toggle inline git blame in the current buffer" })
+1
View File
@@ -45,6 +45,7 @@ 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" },
DiffDelete = { bg = "#311c1e" },
NvimTreeIndentMarker = { fg = c.bg3 }, NvimTreeIndentMarker = { fg = c.bg3 },
GitUnstaged = { fg = c.yellow }, GitUnstaged = { fg = c.yellow },
} }
+572
View File
@@ -0,0 +1,572 @@
local blame = require("git.blame")
local h = require("test.git.helpers")
local t = require("test")
---@param sha string
---@return boolean
local function is_zero(sha)
return sha:match("^0+$") ~= nil
end
---@param committed string
---@param worktree string?
---@param file string?
---@return string dir
---@return integer buf
local function setup(committed, worktree, file)
file = file or "a.txt"
local dir = h.make_repo({ [file] = committed })
if worktree then
t.write(dir, file, worktree)
end
vim.cmd.edit(dir .. "/" .. file)
return dir, vim.api.nvim_get_current_buf()
end
---@param buf integer
---@return ow.Git.Blame.BufState
local function enable_blame(buf)
blame.toggle_inline(buf)
t.wait_for(function()
local s = blame.state(buf)
return s ~= nil and s.tick ~= nil
end, "blame to populate the buffer state")
local s = assert(blame.state(buf))
return s
end
---@param buf integer
---@param ns_name string
---@return vim.api.keyset.get_extmark_item[]
local function 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
---@param buf integer
---@return vim.api.keyset.get_extmark_item[]
local function inline_marks(buf)
return marks(buf, "ow.git.blame.inline")
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
---@return integer float
local function wait_float()
local float ---@type integer?
t.wait_for(function()
float = find_float()
return float ~= nil
end, "blame popup float to open")
local found = assert(float)
return found
end
---@param pat string
local function wait_buf_name(pat)
t.wait_for(function()
return vim.api.nvim_buf_get_name(0):match(pat) ~= nil
end, "current buffer name to match " .. pat)
end
t.test("relative_time buckets", function()
local now = os.time()
t.eq(blame.relative_time(now), "just now")
t.eq(blame.relative_time(now - 10), "just now")
t.eq(blame.relative_time(now - 60), "a minute ago")
t.eq(blame.relative_time(now - 5 * 60), "5 minutes ago")
t.eq(blame.relative_time(now - 60 * 60), "an hour ago")
t.eq(blame.relative_time(now - 3 * 3600), "3 hours ago")
t.eq(blame.relative_time(now - 26 * 3600), "a day ago")
t.eq(blame.relative_time(now - 3 * 86400), "3 days ago")
t.eq(blame.relative_time(now - 14 * 86400), "2 weeks ago")
t.eq(blame.relative_time(now - 60 * 86400), "2 months ago")
t.eq(blame.relative_time(now - 400 * 86400), "1 year ago")
end)
t.test("blame layout squeezes the author before date and sha", function()
local sha, author, date = blame._layout(40)
t.eq(sha, 8, "full budget: sha at its preference")
t.eq(author, 16, "full budget: author at its preference")
t.eq(date, 10, "full budget: date at its preference")
sha, author, date = blame._layout(32)
t.eq(sha, 8, "tight: the sha is untouched")
t.eq(date, 10, "tight: the date is untouched")
t.eq(author, 8, "tight: the author absorbs the squeeze first")
sha, author, date = blame._layout(20)
t.eq(sha, 8, "tighter: the sha is still untouched")
t.eq(author, 0, "tighter: the author is squeezed out")
t.eq(date, 6, "tighter: the date shrinks next")
sha, author, date = blame._layout(4)
t.eq(sha, 0, "degenerate: no room even for separators")
t.eq(author, 0)
t.eq(date, 0)
end)
t.test("native_width measures the gutter from window options", function()
local b = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(b, 0, -1, false, { "a", "b", "c" })
vim.api.nvim_set_current_buf(b)
local win = vim.api.nvim_get_current_win()
t.defer(function()
if vim.api.nvim_win_is_valid(win) then
vim.wo[win].number = false
vim.wo[win].relativenumber = false
vim.wo[win].signcolumn = "auto"
vim.wo[win].foldcolumn = "0"
end
pcall(vim.api.nvim_buf_delete, b, { force = true })
end)
vim.wo[win].number = false
vim.wo[win].relativenumber = false
vim.wo[win].signcolumn = "no"
vim.wo[win].foldcolumn = "0"
t.eq(blame._native_width(win), 0, "no gutter columns")
vim.wo[win].number = true
vim.wo[win].numberwidth = 4
vim.wo[win].signcolumn = "yes:2"
t.eq(blame._native_width(win), 8, "number column plus signcolumn yes:2")
vim.wo[win].signcolumn = "auto:3"
vim.wo[win].foldcolumn = "2"
t.eq(
blame._native_width(win),
4 + 6 + 2,
"auto:3 and a numeric foldcolumn reserve their maximum"
)
end)
t.test("porcelain parse of a committed file", function()
local _, buf = setup("alpha\nbeta\ngamma\n")
local state = enable_blame(buf)
t.eq(vim.tbl_count(state.commits), 1, "one commit")
local sha = state.line_sha[1]
t.eq(state.line_sha[2], sha, "line 2 shares the commit")
t.eq(state.line_sha[3], sha, "line 3 shares the commit")
local commit = state.commits[sha]
t.eq(commit.author, "t", "author parsed from the porcelain")
t.eq(commit.summary, "init", "summary parsed from the porcelain")
t.truthy(#sha >= 40, "the full sha is recorded")
t.truthy(commit.author_time > 0, "author time parsed")
end)
t.test("multiple line groups reuse one commit entry", function()
local dir = h.make_repo({ ["a.txt"] = "a\nb\nc\n" })
t.write(dir, "a.txt", "a\nB\nc\n")
h.git(dir, "add", "a.txt")
h.git(dir, "commit", "-q", "-m", "change middle")
vim.cmd.edit(dir .. "/a.txt")
local buf = vim.api.nvim_get_current_buf()
local state = enable_blame(buf)
t.eq(vim.tbl_count(state.commits), 2, "two distinct commits")
t.eq(
state.line_sha[1],
state.line_sha[3],
"lines 1 and 3 share the original commit"
)
t.truthy(
state.line_sha[1] ~= state.line_sha[2],
"line 2 is a different commit"
)
end)
t.test("an edited line blames as the zero sha", function()
local _, buf = setup("a\nb\nc\n")
local state = enable_blame(buf)
t.falsy(is_zero(state.line_sha[2]), "line 2 starts committed")
vim.api.nvim_buf_set_lines(buf, 1, 2, false, { "EDITED" })
vim.api.nvim_exec_autocmds("TextChanged", { buffer = buf })
blame._flush(buf)
t.wait_for(function()
local s = assert(blame.state(buf))
return s.line_sha[2] ~= nil and is_zero(s.line_sha[2])
end, "the edited line to blame as uncommitted")
end)
t.test("blame refreshes after a git event", function()
local dir, buf = setup("original\n")
local state = enable_blame(buf)
local sha1 = state.line_sha[1]
h.git(dir, "commit", "--amend", "-m", "amended")
local sha2 = h.git(dir, "rev-parse", "HEAD").stdout
t.truthy(sha1 ~= sha2, "the amend produced a new commit")
t.wait_for(function()
local s = blame.state(buf)
return s ~= nil and s.line_sha[1] == sha2
end, "blame to pick up the amended commit", 2000)
end)
t.test("an untracked file blames every line as uncommitted", function()
local dir = h.make_repo({ ["tracked.txt"] = "x\n" })
t.write(dir, "new.txt", "one\ntwo\nthree\n")
vim.cmd.edit(dir .. "/new.txt")
local buf = vim.api.nvim_get_current_buf()
local state = enable_blame(buf)
for i = 1, 3 do
t.truthy(is_zero(state.line_sha[i]), "line " .. i .. " is uncommitted")
end
t.eq(vim.tbl_count(state.commits), 1, "one synthesized commit")
end)
t.test("blame actions are no-ops off a worktree", function()
local buf = vim.api.nvim_create_buf(true, false)
t.defer(function()
pcall(vim.api.nvim_buf_delete, buf, { force = true })
end)
t.quietly(function()
blame.line_popup(buf)
blame.toggle_inline(buf)
blame.toggle_overlay(buf)
end)
t.eq(blame.state(buf), nil, "no state created for a non-worktree buffer")
end)
t.test("line popup shows the commit for the cursor line", function()
local dir, buf = setup("alpha\nbeta\ngamma\n")
local sha = h.git(dir, "rev-parse", "HEAD").stdout
vim.api.nvim_set_current_buf(buf)
vim.api.nvim_win_set_cursor(0, { 1, 0 })
blame.line_popup(buf)
local float = wait_float()
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 "", sha:sub(1, 8)),
"first line starts with the short sha"
)
t.truthy((lines[1] or ""):find("t", 1, true), "author shown")
end)
t.test("re-invoking the line popup focuses the open float", function()
local _, buf = setup("alpha\nbeta\n")
vim.api.nvim_set_current_buf(buf)
vim.api.nvim_win_set_cursor(0, { 1, 0 })
blame.line_popup(buf)
local float = wait_float()
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"
)
blame.line_popup(buf)
t.eq(
vim.api.nvim_get_current_win(),
float,
"re-invoking focuses the existing float"
)
end)
t.test("line popup works in a git:// object buffer", function()
local dir = h.make_repo({ ["a.txt"] = "alpha\nbeta\ngamma\n" })
local sha = h.git(dir, "rev-parse", "HEAD").stdout
local r = assert(require("git.core.repo").resolve(dir))
local gbuf = require("git.object").buf_for(
r,
require("git.core.revision").new({ base = sha, path = "a.txt" })
)
vim.api.nvim_set_current_buf(gbuf)
vim.api.nvim_win_set_cursor(0, { 2, 0 })
blame.line_popup(gbuf)
local float = wait_float()
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 "", sha:sub(1, 8)),
"the popup blames the commit even in a git:// buffer"
)
end)
t.test("inline toggle adds and removes the annotation", function()
local _, buf = setup("alpha\nbeta\ngamma\n")
vim.api.nvim_set_current_buf(buf)
vim.api.nvim_win_set_cursor(0, { 1, 0 })
enable_blame(buf)
t.wait_for(function()
return #inline_marks(buf) == 1
end, "an inline annotation on the current line")
t.eq(assert(inline_marks(buf)[1])[2], 0, "annotation on line 1")
blame.toggle_inline(buf)
t.eq(#inline_marks(buf), 0, "annotation cleared when toggled off")
end)
t.test("inline annotation follows the cursor", function()
local _, buf = setup("alpha\nbeta\ngamma\n")
vim.api.nvim_set_current_buf(buf)
vim.api.nvim_win_set_cursor(0, { 1, 0 })
enable_blame(buf)
t.wait_for(function()
return #inline_marks(buf) == 1
end, "the initial annotation")
t.eq(assert(inline_marks(buf)[1])[2], 0)
vim.api.nvim_win_set_cursor(0, { 3, 0 })
vim.api.nvim_exec_autocmds("CursorMoved", { buffer = buf })
t.eq(#inline_marks(buf), 1, "still one annotation")
t.eq(assert(inline_marks(buf)[1])[2], 2, "annotation moved to line 3")
end)
t.test("overlay toggle sets and clears the statuscolumn", function()
local _, buf = setup("a\nb\nc\nd\n")
vim.api.nvim_set_current_buf(buf)
local win = vim.api.nvim_get_current_win()
blame.toggle_overlay(buf)
t.truthy(
vim.wo[win].statuscolumn ~= "",
"the overlay sets the window statuscolumn"
)
blame.toggle_overlay(buf)
t.eq(vim.wo[win].statuscolumn, "", "toggling off clears it")
end)
t.test("overlay saves and restores the statuscolumn", function()
local _, buf = setup("a\nb\nc\n")
vim.api.nvim_set_current_buf(buf)
local win = vim.api.nvim_get_current_win()
t.defer(function()
if vim.api.nvim_win_is_valid(win) then
vim.wo[win].statuscolumn = ""
vim.wo[win].signcolumn = "auto"
end
end)
vim.wo[win].statuscolumn = "%l custom"
vim.wo[win].signcolumn = "yes:2"
blame.toggle_overlay(buf)
t.truthy(
vim.wo[win].statuscolumn ~= "%l custom",
"the overlay overrides a custom statuscolumn"
)
t.eq(
vim.wo[win].signcolumn,
"yes:2",
"the overlay leaves signcolumn untouched"
)
blame.toggle_overlay(buf)
t.eq(vim.wo[win].statuscolumn, "%l custom", "statuscolumn restored")
end)
t.test("overlay gutter uses the full preferred width when it can", function()
local _, buf = setup("a\nb\nc\n")
vim.api.nvim_set_current_buf(buf)
local win = vim.api.nvim_get_current_win()
t.defer(function()
if vim.api.nvim_win_is_valid(win) then
vim.wo[win].statuscolumn = ""
end
end)
vim.wo[win].number = false
vim.wo[win].relativenumber = false
vim.wo[win].signcolumn = "no"
vim.wo[win].foldcolumn = "0"
blame.toggle_overlay(buf)
t.wait_for(function()
local s = blame.state(buf)
return s ~= nil and s.blame_width ~= nil
end, "the overlay blame to render")
t.eq(
assert(blame.state(buf)).blame_width,
40,
"with no native columns the blame takes its full preferred width"
)
end)
t.test("overlay gutter is budgeted under the 47-cell cap", function()
local _, buf = setup("a\nb\nc\n")
vim.api.nvim_set_current_buf(buf)
local win = vim.api.nvim_get_current_win()
t.defer(function()
if vim.api.nvim_win_is_valid(win) then
vim.wo[win].statuscolumn = ""
vim.wo[win].signcolumn = "auto"
end
end)
vim.wo[win].number = false
vim.wo[win].relativenumber = false
vim.wo[win].foldcolumn = "0"
vim.wo[win].signcolumn = "yes:9"
blame.toggle_overlay(buf)
t.wait_for(function()
local s = blame.state(buf)
return s ~= nil and s.blame_width ~= nil
end, "the overlay blame to render")
local native = blame._native_width(win)
local width = assert(assert(blame.state(buf)).blame_width)
t.eq(native, 18, "signcolumn=yes:9 reserves an 18-cell sign column")
t.eq(width, 47 - native, "the blame is budgeted into the cells left free")
t.truthy(width + native <= 47, "blame plus native columns fits the cap")
end)
t.test("overlay re-budgets when a gutter option changes", function()
local _, buf = setup("a\nb\nc\n")
vim.api.nvim_set_current_buf(buf)
local win = vim.api.nvim_get_current_win()
t.defer(function()
if vim.api.nvim_win_is_valid(win) then
vim.wo[win].statuscolumn = ""
vim.wo[win].signcolumn = "auto"
end
end)
vim.wo[win].number = false
vim.wo[win].relativenumber = false
vim.wo[win].foldcolumn = "0"
vim.wo[win].signcolumn = "no"
blame.toggle_overlay(buf)
t.wait_for(function()
local s = blame.state(buf)
return s ~= nil and s.blame_width ~= nil
end, "the overlay blame to render")
t.eq(
assert(blame.state(buf)).blame_width,
40,
"a clear gutter leaves the full preferred width"
)
vim.wo[win].signcolumn = "yes:9"
t.wait_for(function()
return assert(blame.state(buf)).blame_width == 47 - 18
end, "the blame to re-budget for the widened signcolumn")
end)
t.test("overlay gutter shows sha, author and an absolute date", function()
local _, buf = setup("a\nb\nc\n")
vim.api.nvim_set_current_buf(buf)
local win = vim.api.nvim_get_current_win()
blame.toggle_overlay(buf)
t.wait_for(function()
local s = blame.state(buf)
return s ~= nil and s.tick ~= nil
end, "the overlay blame to populate")
local g = blame._gutter(win, 1, 0)
t.truthy(g:match("%x%x%x%x%x%x%x%x"), "the gutter shows a short sha")
t.truthy(g:find("t", 1, true), "the gutter shows the author")
t.truthy(
g:match("%d%d%d%d%-%d%d%-%d%d"),
"the gutter shows a YYYY-MM-DD date"
)
end)
t.test("overlay gutter is blank on virtual lines", function()
local _, buf = setup("a\nb\nc\n")
vim.api.nvim_set_current_buf(buf)
local win = vim.api.nvim_get_current_win()
blame.toggle_overlay(buf)
t.wait_for(function()
local s = blame.state(buf)
return s ~= nil and s.tick ~= nil
end, "the overlay blame to populate")
local g = blame._gutter(win, 1, -1)
t.falsy(g:match("%x%x%x%x%x%x%x%x"), "no sha on a virtual line")
end)
t.test("the statuscolumn expression renders the blame gutter", function()
local dir, buf = setup("a\nb\nc\n")
local sha = h.git(dir, "rev-parse", "HEAD").stdout
vim.api.nvim_set_current_buf(buf)
local win = vim.api.nvim_get_current_win()
blame.toggle_overlay(buf)
t.wait_for(function()
local s = blame.state(buf)
return s ~= nil and s.tick ~= nil
end, "the overlay blame to populate")
local rendered = vim.api.nvim_eval_statusline(
"%{%v:lua.require('git.blame').statuscolumn()%}",
{ winid = win, use_statuscol_lnum = 1 }
)
t.truthy(
rendered.str:find(sha:sub(1, 8), 1, true),
"the statuscolumn renders the commit's short sha"
)
end)
t.test("open_commit opens the commit that last touched the line", function()
local _, buf = setup("alpha\nbeta\ngamma\n")
vim.api.nvim_set_current_buf(buf)
vim.api.nvim_win_set_cursor(0, { 1, 0 })
blame.open_commit()
wait_buf_name("^git://%x+$")
end)
t.test("open_file opens the file at the line's commit", function()
local _, buf = setup("alpha\nbeta\ngamma\n")
vim.api.nvim_set_current_buf(buf)
vim.api.nvim_win_set_cursor(0, { 2, 0 })
blame.open_file()
wait_buf_name("^git://%x+:a%.txt$")
end)
t.test("open_file_parent opens the file at the parent commit", function()
local dir = h.make_repo({ ["a.txt"] = "a\nb\nc\n" })
local root = h.git(dir, "rev-parse", "HEAD").stdout
t.write(dir, "a.txt", "a\nB\nc\n")
h.git(dir, "add", "a.txt")
h.git(dir, "commit", "-q", "-m", "change middle")
vim.cmd.edit(dir .. "/a.txt")
vim.api.nvim_win_set_cursor(0, { 2, 0 })
blame.open_file_parent()
t.wait_for(function()
return vim.api.nvim_buf_get_name(0) == "git://" .. root .. ":a.txt"
end, "the file at the parent commit to open")
end)
t.test("the drill actions refuse an uncommitted line", function()
local _, buf = setup("a\nb\nc\n")
vim.api.nvim_set_current_buf(buf)
vim.api.nvim_buf_set_lines(buf, 1, 2, false, { "EDITED" })
vim.api.nvim_win_set_cursor(0, { 2, 0 })
t.quietly(function()
blame.open_commit()
vim.wait(200)
end)
t.eq(
vim.api.nvim_get_current_buf(),
buf,
"no commit opened for an uncommitted line"
)
end)
t.test("drilling chains through git:// buffers", function()
local _, buf = setup("alpha\nbeta\ngamma\n")
vim.api.nvim_set_current_buf(buf)
vim.api.nvim_win_set_cursor(0, { 1, 0 })
blame.open_file()
wait_buf_name("^git://%x+:a%.txt$")
vim.api.nvim_win_set_cursor(0, { 1, 0 })
blame.open_commit()
wait_buf_name("^git://%x+$")
end)
t.test("detach clears blame state and annotations", function()
local _, buf = setup("a\nb\nc\n")
vim.api.nvim_set_current_buf(buf)
local win = vim.api.nvim_get_current_win()
enable_blame(buf)
blame.toggle_overlay(buf)
t.truthy(vim.wo[win].statuscolumn ~= "", "the overlay statuscolumn set")
blame.detach(buf)
t.eq(blame.state(buf), nil, "state dropped on detach")
t.eq(#inline_marks(buf), 0, "inline annotation cleared")
t.eq(vim.wo[win].statuscolumn, "", "overlay statuscolumn cleared")
end)