From ebfa15c276fbdb4b78a10bd91475c504d8332ecd Mon Sep 17 00:00:00 2001 From: Oscar Wallberg Date: Tue, 26 May 2026 15:28:22 +0200 Subject: [PATCH] refactor(git): remove blame gutter --- lua/core/keymap.lua | 1 - lua/git/blame.lua | 266 +--------------------------------------- plugin/git.lua | 6 - test/git/blame_test.lua | 203 ------------------------------ 4 files changed, 3 insertions(+), 473 deletions(-) diff --git a/lua/core/keymap.lua b/lua/core/keymap.lua index ed2b1bc..890be47 100644 --- a/lua/core/keymap.lua +++ b/lua/core/keymap.lua @@ -235,7 +235,6 @@ 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-gutter)") 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 index 3dc56f0..0aa34f8 100644 --- a/lua/git/blame.lua +++ b/lua/git/blame.lua @@ -8,15 +8,6 @@ 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 @@ -41,14 +32,10 @@ local PREFERRED_WIDTH = SHA_WIDTH + AUTHOR_MAX + DATE_WIDTH + 3 * #GAP ---@field revision string? nil = working tree, else the blamed revision ---@field commits table ---@field line_sha table ----@field blame_text table? ----@field blame_width integer? ----@field blame_blank string? ---@field tick integer? ---@field epoch integer ---@field pending fun()[] ---@field inline boolean ----@field gutter boolean ---@field autocmds integer[] ---@type table @@ -116,17 +103,6 @@ local function format_author_time(ts, tz) return os.date("!%Y-%m-%d %T ", ts + offset) .. tz 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) @@ -284,14 +260,10 @@ local function ensure_state(buf) revision = src.revision, commits = {}, line_sha = {}, - blame_text = nil, - blame_width = nil, - blame_blank = nil, tick = nil, epoch = 0, pending = {}, inline = false, - gutter = false, autocmds = {}, } states[buf] = state @@ -340,7 +312,6 @@ local function run_blame(state, buf, done) 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 = {} @@ -390,196 +361,14 @@ local function render_inline(buf) }) 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 ----gutter 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 = os.date("%Y-%m-%d", commit.author_time) - end - text[lnum] = "%#GitBlameSha#" - .. sha:sub(1, sha_w) - .. "%#GitBlameAuthor#" - .. GAP - .. (pad(author, author_w):gsub("%%", "%%%%")) - .. "%#GitBlameDate#" - .. 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 gutter 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.gutter or not state.blame_text then - return "" - end - return (virtnum == 0 and state.blame_text[lnum]) or state.blame_blank or "" -end - -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 = {} - ----Set a window's `'statuscolumn'` with `scope = "local"`, so the global ----value stays clean and splits and new windows do not inherit the gutter. ----@param win integer ----@param value string -local function set_statuscolumn(win, value) - vim.api.nvim_set_option_value( - "statuscolumn", - value, - { win = win, scope = "local" } - ) -end - ----Reconcile every window's `'statuscolumn'` with the gutter state. ----Gutter windows get the blame statuscolumn, and a window that has it ----but should not (a split inherits window options) is restored. -local function refresh_gutter_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.gutter - local has_blame = vim.startswith(vim.wo[win].statuscolumn, BLAME_EXPR) - if on and not has_blame then - saved_statuscolumn[win] = saved_statuscolumn[win] - or vim.wo[win].statuscolumn - set_statuscolumn( - win, - BLAME_EXPR .. "%C%s%l" .. (native_width(win) > 0 and " " or "") - ) - elseif not on and has_blame then - set_statuscolumn( - win, - saved_statuscolumn[win] or vim.go.statuscolumn - ) - 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.gutter 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 - set_statuscolumn( - win, - BLAME_EXPR .. "%C%s%l" .. (native_width(win) > 0 and " " or "") - ) - end - end -end - ---@param buf integer local function reblame(buf) local state = states[buf] - if not state or (not state.inline and not state.gutter) then + if not state or not state.inline then return end run_blame(state, buf, function() - render(buf) + render_inline(buf) end) end @@ -648,28 +437,6 @@ function M.toggle_inline(buf) if vim.api.nvim_buf_is_valid(buf) then vim.api.nvim_buf_clear_namespace(buf, NS_INLINE, 0, -1) end - if not state.gutter then - detach_autocmds(buf, state) - end - end -end - ----@param buf integer? -function M.toggle_gutter(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.gutter = not state.gutter - refresh_gutter_columns() - if state.gutter then - attach_autocmds(buf, state) - run_blame(state, buf, function() - render(buf) - end) - elseif not state.inline then detach_autocmds(buf, state) end end @@ -920,35 +687,8 @@ function M.detach(buf) detach_autocmds(buf, state) state.epoch = state.epoch + 1 states[buf] = nil - refresh_gutter_columns() end -local augroup = vim.api.nvim_create_augroup("ow.git.blame", { clear = true }) -vim.api.nvim_create_autocmd("BufWinEnter", { - group = augroup, - callback = refresh_gutter_columns, -}) - --- The blame budget depends on the gutter option widths, so re-render an --- gutter 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.gutter 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 @@ -961,7 +701,7 @@ repo.on("change", function(r, change) and (change.paths[state.rel] or change.branch_changed) then state.tick = nil - if state.inline or state.gutter then + if state.inline then schedule(buf) end end diff --git a/plugin/git.lua b/plugin/git.lua index 06cba9b..4f467cf 100644 --- a/plugin/git.lua +++ b/plugin/git.lua @@ -322,9 +322,6 @@ 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-gutter)", function() - require("git.blame").toggle_gutter() -end, { silent = true, desc = "Toggle the full-file git blame gutter" }) vim.keymap.set("n", "(git-blame-line)", function() require("git.blame").toggle_inline() end, { silent = true, desc = "Toggle inline git blame" }) @@ -344,9 +341,6 @@ end, { desc = "Open this file at the parent of the line's commit", }) -vim.api.nvim_create_user_command("GitBlame", function() - require("git.blame").toggle_gutter() -end, { desc = "Toggle the full-file git blame gutter 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 index 3bb97b0..5d59423 100644 --- a/test/git/blame_test.lua +++ b/test/git/blame_test.lua @@ -197,7 +197,6 @@ t.test("blame actions are no-ops off a worktree", function() t.quietly(function() blame.line_popup(buf) blame.toggle_inline(buf) - blame.toggle_gutter(buf) end) t.eq(blame.state(buf), nil, "no state created for a non-worktree buffer") end) @@ -301,204 +300,6 @@ t.test("inline annotation follows the cursor", function() t.eq(assert(inline_marks(buf)[1])[2], 2, "annotation moved to line 3") end) -t.test("gutter 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_gutter(buf) - t.truthy( - vim.wo[win].statuscolumn ~= "", - "the gutter sets the window statuscolumn" - ) - blame.toggle_gutter(buf) - t.eq(vim.wo[win].statuscolumn, "", "toggling off clears it") -end) - -t.test("gutter 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_gutter(buf) - t.truthy( - vim.wo[win].statuscolumn ~= "%l custom", - "the gutter overrides a custom statuscolumn" - ) - t.eq( - vim.wo[win].signcolumn, - "yes:2", - "the gutter leaves signcolumn untouched" - ) - blame.toggle_gutter(buf) - t.eq(vim.wo[win].statuscolumn, "%l custom", "statuscolumn restored") -end) - -t.test("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_gutter(buf) - t.wait_for(function() - local s = blame.state(buf) - return s ~= nil and s.blame_width ~= nil - end, "the gutter 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("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_gutter(buf) - t.wait_for(function() - local s = blame.state(buf) - return s ~= nil and s.blame_width ~= nil - end, "the gutter blame to render") - local width = assert(assert(blame.state(buf)).blame_width) - local native = 18 -- signcolumn=yes:9 reserves 2*9 cells - 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("gutter 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_gutter(buf) - t.wait_for(function() - local s = blame.state(buf) - return s ~= nil and s.blame_width ~= nil - end, "the gutter 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("the gutter statuscolumn does not leak into other windows", function() - local _, buf = setup("a\nb\nc\n") - vim.api.nvim_set_current_buf(buf) - blame.toggle_gutter(buf) - t.wait_for(function() - local s = blame.state(buf) - return s ~= nil and s.blame_width ~= nil - end, "the gutter to render") - t.falsy( - vim.go.statuscolumn:find("git.blame", 1, true), - "the gutter leaves the global statuscolumn untouched" - ) - - vim.cmd("new") - local plain = vim.api.nvim_get_current_win() - t.defer(function() - if vim.api.nvim_win_is_valid(plain) then - vim.api.nvim_win_close(plain, true) - end - end) - t.falsy( - vim.wo[plain].statuscolumn:find("git.blame", 1, true), - "a new window does not inherit the blame gutter" - ) -end) - -t.test("gutter shows sha, author and an absolute date", function() - local _, buf = setup("a\nb\nc\n") - vim.api.nvim_set_current_buf(buf) - blame.toggle_gutter(buf) - t.wait_for(function() - local s = blame.state(buf) - return s ~= nil and s.tick ~= nil - end, "the gutter blame to populate") - local state = assert(blame.state(buf)) - local g = assert(state.blame_text)[1] or "" - 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("gutter is blank on virtual lines", function() - local _, buf = setup("a\nb\nc\n") - vim.api.nvim_set_current_buf(buf) - blame.toggle_gutter(buf) - t.wait_for(function() - local s = blame.state(buf) - return s ~= nil and s.tick ~= nil - end, "the gutter blame to populate") - local state = assert(blame.state(buf)) - local blank = assert(state.blame_blank) - t.falsy(blank:match("%x%x%x%x%x%x%x%x"), "no sha on a virtual line") - t.falsy(blank:match("%S"), "blank segment is whitespace") -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_gutter(buf) - t.wait_for(function() - local s = blame.state(buf) - return s ~= nil and s.tick ~= nil - end, "the gutter 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) @@ -559,12 +360,8 @@ 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_gutter(buf) - t.truthy(vim.wo[win].statuscolumn ~= "", "the gutter 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, "", "gutter statuscolumn cleared") end)