diff --git a/lua/core/keymap.lua b/lua/core/keymap.lua index 0d1150b..452ef73 100644 --- a/lua/core/keymap.lua +++ b/lua/core/keymap.lua @@ -234,6 +234,8 @@ vim.keymap.set("n", "gg", "(git-status-toggle)") vim.keymap.set("n", "gc", "(git-commit)") vim.keymap.set("n", "ga", "(git-commit-amend)") vim.keymap.set("n", "gl", "(git-log)") +vim.keymap.set("n", "gb", "(git-blame-popup)") +vim.keymap.set("n", "gB", "(git-blame)") vim.keymap.set("n", "gv", "(git-hunk-select)") vim.keymap.set("n", "gs", "(git-hunk-stage-toggle)") vim.keymap.set("n", "gr", "(git-hunk-reset)") diff --git a/lua/git/blame.lua b/lua/git/blame.lua new file mode 100644 index 0000000..738682d --- /dev/null +++ b/lua/git/blame.lua @@ -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 +---@field line_sha table + +---@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 +---@field line_sha table +---@field blame_text table? 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 +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 + local commits = {} + ---@type table + 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 + 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://:` object (blame +---that revision). A `git://` 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 + 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 +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 +---@param line_sha table +---@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://:` 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 diff --git a/plugin/git.lua b/plugin/git.lua index c84a238..cb79c85 100644 --- a/plugin/git.lua +++ b/plugin/git.lua @@ -40,6 +40,9 @@ local DEFAULT_HIGHLIGHTS = { GitHunkRemoved = "Removed", GitHunkAddLine = "DiffAdd", GitHunkDeleteLine = "DiffDelete", + + GitBlame = "Comment", + GitBlameSha = "GitSha", } local STAGED_HUNK_HL = { GitHunkStagedAdded = "GitHunkAdded", @@ -107,6 +110,7 @@ vim.api.nvim_create_autocmd({ "BufDelete", "BufWipeout" }, { group = group, callback = function(args) require("git.hunks").detach(args.buf) + require("git.blame").detach(args.buf) require("git.core.repo").unbind(args.buf) end, }) @@ -220,8 +224,7 @@ end, { vim.api.nvim_create_user_command("Gstatus", function(opts) require("git.status_view").open({ - placement = opts.fargs[1] --[[@as ow.Git.StatusView.Placement]] - or "split", + placement = opts.fargs[1] --[[@as ow.Git.StatusView.Placement]] or "split", }) end, { nargs = "?", @@ -314,3 +317,32 @@ 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" }) + +vim.keymap.set("n", "(git-blame)", function() + require("git.blame").toggle_overlay() +end, { silent = true, desc = "Toggle the full-file git blame overlay" }) +vim.keymap.set("n", "(git-blame-line)", function() + require("git.blame").toggle_inline() +end, { silent = true, desc = "Toggle inline git blame" }) +vim.keymap.set("n", "(git-blame-popup)", function() + require("git.blame").line_popup() +end, { silent = true, desc = "Show git blame for the current line" }) +vim.keymap.set("n", "(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", "(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", "(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" }) diff --git a/test/git/blame_test.lua b/test/git/blame_test.lua new file mode 100644 index 0000000..316c94d --- /dev/null +++ b/test/git/blame_test.lua @@ -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)