From 30c57f0babcc74ef0580c5b5971d33b22afb9fc1 Mon Sep 17 00:00:00 2001 From: Lewis Russell Date: Thu, 21 Sep 2023 13:41:49 +0100 Subject: [PATCH] perf(blame): run blame for entire file instead of per line Previously current_line_blame would run a git-blame process per line (via the `-L` flag) in an attempt to be more efficient. However after some investigation it seems that running git-blame for the entire file rarely exceeds 2x the time it takes to run for a single line, even for large files. This change alters current_line_blame to run git-blame for the entire file after each buffer edit and caches that in memory. This makes the first git-blame after an edit ~2x slower, but makes any cursor movements after that instant. A follow-up to this would be for current_line_blame to track buffer updates to avoid the cache needing to be invalidated on every edit. --- doc/gitsigns.txt | 1 + lua/gitsigns/actions.lua | 10 +- lua/gitsigns/attach.lua | 1 + lua/gitsigns/cache.lua | 1 + lua/gitsigns/config.lua | 1 + lua/gitsigns/current_line_blame.lua | 70 ++++------- lua/gitsigns/git.lua | 173 ++++++++++++++++++---------- lua/gitsigns/util.lua | 9 ++ 8 files changed, 156 insertions(+), 110 deletions(-) diff --git a/doc/gitsigns.txt b/doc/gitsigns.txt index 44626873..a5fa9f15 100644 --- a/doc/gitsigns.txt +++ b/doc/gitsigns.txt @@ -828,6 +828,7 @@ current_line_blame_formatter *gitsigns-config-current_line_blame_formatter* • `summary`: string • `previous`: string • `filename`: string + • `boundary`: true? Note that the keys map onto the output of: `git blame --line-porcelain` diff --git a/lua/gitsigns/actions.lua b/lua/gitsigns/actions.lua index 5c09d860..0917fbee 100644 --- a/lua/gitsigns/actions.lua +++ b/lua/gitsigns/actions.lua @@ -808,12 +808,12 @@ M.get_hunks = function(bufnr) end --- @param repo Gitsigns.Repo ---- @param info Gitsigns.BlameInfo +--- @param info Gitsigns.BlameInfoPublic --- @return Gitsigns.Hunk.Hunk?, integer?, integer local function get_blame_hunk(repo, info) local a = {} -- If no previous so sha of blame added the file - if info.previous then + if info.previous_sha and info.previous_filename then a = repo:get_show_text(info.previous_sha .. ':' .. info.previous_filename) end local b = repo:get_show_text(info.sha .. ':' .. info.filename) @@ -884,12 +884,14 @@ M.blame_line = async.void(function(opts) local buftext = util.buf_lines(bufnr) local fileformat = vim.bo[bufnr].fileformat local lnum = api.nvim_win_get_cursor(0)[1] - local result = bcache.git_obj:run_blame(buftext, lnum, opts.ignore_whitespace) + local results = bcache.git_obj:run_blame(buftext, lnum, opts.ignore_whitespace) pcall(function() loading:close() end) - assert(result) + assert(results and results[lnum]) + + local result = util.convert_blame_info(results[lnum]) local is_committed = result.sha and tonumber('0x' .. result.sha) ~= 0 diff --git a/lua/gitsigns/attach.lua b/lua/gitsigns/attach.lua index f8dad72f..4dd7173c 100644 --- a/lua/gitsigns/attach.lua +++ b/lua/gitsigns/attach.lua @@ -337,6 +337,7 @@ local attach_throttled = throttle_by_id(function(cbuf, ctx, aucmd) end cache[cbuf] = CacheEntry.new({ + bufnr = cbuf, base = ctx and ctx.base or config.base, file = file, commit = commit, diff --git a/lua/gitsigns/cache.lua b/lua/gitsigns/cache.lua index 8dc2433d..0eee4bad 100644 --- a/lua/gitsigns/cache.lua +++ b/lua/gitsigns/cache.lua @@ -7,6 +7,7 @@ local M = { -- Timer object watching the gitdir --- @class Gitsigns.CacheEntry +--- @field bufnr integer --- @field file string --- @field base? string --- @field compare_text? string[] diff --git a/lua/gitsigns/config.lua b/lua/gitsigns/config.lua index fbaace24..65d695d2 100644 --- a/lua/gitsigns/config.lua +++ b/lua/gitsigns/config.lua @@ -658,6 +658,7 @@ M.schema = { • `summary`: string • `previous`: string • `filename`: string + • `boundary`: true? Note that the keys map onto the output of: `git blame --line-porcelain` diff --git a/lua/gitsigns/current_line_blame.lua b/lua/gitsigns/current_line_blame.lua index 602eae1c..1be79b86 100644 --- a/lua/gitsigns/current_line_blame.lua +++ b/lua/gitsigns/current_line_blame.lua @@ -20,52 +20,12 @@ local function reset(bufnr) vim.b[bufnr].gitsigns_blame_line_dict = nil end --- TODO: expose as config -local max_cache_size = 1000 - ---- @class Gitsigns.BlameCache ---- @field cache table ---- @field size integer +--- @class (exact) Gitsigns.BlameCache +--- @field cache Gitsigns.BlameInfo[]? --- @field tick integer -local BlameCache = {} - --- @type table -BlameCache.contents = {} - ---- @param bufnr integer ---- @param lnum integer ---- @param x? Gitsigns.BlameInfo -function BlameCache:add(bufnr, lnum, x) - if not x then - return - end - if not config._blame_cache then - return - end - local scache = self.contents[bufnr] - if scache.size <= max_cache_size then - scache.cache[lnum] = x - scache.size = scache.size + 1 - end -end - ---- @param bufnr integer ---- @param lnum integer ---- @return Gitsigns.BlameInfo? -function BlameCache:get(bufnr, lnum) - if not config._blame_cache then - return - end - - -- init and invalidate - local tick = vim.b[bufnr].changedtick - if not self.contents[bufnr] or self.contents[bufnr].tick ~= tick then - self.contents[bufnr] = { tick = tick, cache = {}, size = 0 } - end - - return self.contents[bufnr].cache[lnum] -end +local blame_cache = {} --- @param fmt string --- @param name string @@ -93,17 +53,29 @@ end --- @param opts Gitsigns.CurrentLineBlameOpts --- @return Gitsigns.BlameInfo? local function run_blame(bufnr, lnum, opts) - local result = BlameCache:get(bufnr, lnum) + -- init and invalidate + local tick = vim.b[bufnr].changedtick + if not blame_cache[bufnr] or blame_cache[bufnr].tick ~= tick then + blame_cache[bufnr] = { tick = tick } + end + + local result = blame_cache[bufnr].cache + if result then - return result + return result[lnum] end local buftext = util.buf_lines(bufnr) local bcache = cache[bufnr] - result = bcache.git_obj:run_blame(buftext, lnum, opts.ignore_whitespace) - BlameCache:add(bufnr, lnum, result) + result = bcache.git_obj:run_blame(buftext, nil, opts.ignore_whitespace) + + if not result then + return + end - return result + blame_cache[bufnr].cache = result + + return result[lnum] end --- @param bufnr integer @@ -111,6 +83,8 @@ end --- @param blame_info Gitsigns.BlameInfo --- @param opts Gitsigns.CurrentLineBlameOpts local function handle_blame_info(bufnr, lnum, blame_info, opts) + blame_info = util.convert_blame_info(blame_info) + vim.b[bufnr].gitsigns_blame_line_dict = blame_info local bcache = assert(cache[bufnr]) diff --git a/lua/gitsigns/git.lua b/lua/gitsigns/git.lua index 319bb766..bae514ef 100644 --- a/lua/gitsigns/git.lua +++ b/lua/gitsigns/git.lua @@ -14,6 +14,7 @@ local uv = vim.loop local startswith = vim.startswith local dprint = require('gitsigns.debug.log').dprint +local dprintf = require('gitsigns.debug.log').dprintf local eprint = require('gitsigns.debug.log').eprint local err = require('gitsigns.message').error @@ -564,13 +565,7 @@ Obj.unstage_file = function(self) self:command({ 'reset', self.file }) end ---- @class Gitsigns.BlameInfo ---- -- Info in header ---- @field sha string ---- @field abbrev_sha string ---- @field orig_lnum integer ---- @field final_lnum integer ---- Porcelain fields +--- @class Gitsigns.CommitInfo --- @field author string --- @field author_mail string --- @field author_time integer @@ -580,46 +575,57 @@ end --- @field committer_time integer --- @field committer_tz string --- @field summary string ---- @field previous string ---- @field previous_filename string ---- @field previous_sha string ---- @field filename string ---- ---- Custom fields +--- @field sha string +--- @field abbrev_sha string +--- @field boundary? true + +--- @class Gitsigns.BlameInfoPublic: Gitsigns.BlameInfo, Gitsigns.CommitInfo --- @field body? string[] --- @field hunk_no? integer --- @field num_hunks? integer --- @field hunk? string[] --- @field hunk_head? string +--- @class Gitsigns.BlameInfo +--- @field orig_lnum integer +--- @field final_lnum integer +--- @field commit Gitsigns.CommitInfo +--- @field filename string +--- @field previous_filename? string +--- @field previous_sha? string + +local NOT_COMMITTED = { + author = 'Not Committed Yet', + ['author_mail'] = '', + committer = 'Not Committed Yet', + ['committer_mail'] = '', +} + +---@param x any +---@return integer +local function asinteger(x) + return assert(tonumber(x)) +end + --- @param lines string[] ---- @param lnum integer ---- @param ignore_whitespace boolean ---- @return Gitsigns.BlameInfo? +--- @param lnum? integer +--- @param ignore_whitespace? boolean +--- @return table? function Obj:run_blame(lines, lnum, ignore_whitespace) - local not_committed = { - author = 'Not Committed Yet', - ['author_mail'] = '', - committer = 'Not Committed Yet', - ['committer_mail'] = '', - } - if not self.object_name or self.repo.abbrev_head == '' then -- As we support attaching to untracked files we need to return something if -- the file isn't isn't tracked in git. -- If abbrev_head is empty, then assume the repo has no commits - return not_committed + return NOT_COMMITTED end - local args = { - 'blame', - '--contents', - '-', - '-L', - lnum .. ',+1', - '--line-porcelain', - self.file, - } + local args = { 'blame', '--contents', '-', '--incremental' } + + if lnum then + vim.list_extend(args, { '-L', lnum..',+1' }) + end + + args[#args+1] = self.file if ignore_whitespace then args[#args + 1] = '-w' @@ -634,34 +640,85 @@ function Obj:run_blame(lines, lnum, ignore_whitespace) if #results == 0 then return end - local header = vim.split(table.remove(results, 1), ' ') - - --- @diagnostic disable-next-line:missing-fields - local ret = {} --- @type Gitsigns.BlameInfo - ret.sha = header[1] - ret.orig_lnum = tonumber(header[2]) --[[@as integer]] - ret.final_lnum = tonumber(header[3]) --[[@as integer]] - ret.abbrev_sha = string.sub(ret.sha, 1, 8) - for _, l in ipairs(results) do - if not startswith(l, '\t') then - local cols = vim.split(l, ' ') - --- @type string - local key = table.remove(cols, 1):gsub('-', '_') - --- @diagnostic disable-next-line:no-unknown - ret[key] = table.concat(cols, ' ') - if key == 'previous' then - ret.previous_sha = cols[1] - ret.previous_filename = cols[2] + + local ret = {} --- @type Gitsigns.BlameInfo[] + local commits = {} --- @type table + local i = 1 + + while i <= #results do + --- @param pat? string + --- @return string + local function get(pat) + local l = assert(results[i]) + i = i + 1 + if pat then + return l:match(pat) end + return l end - end - -- New in git 2.41: - -- The output given by "git blame" that attributes a line to contents - -- taken from the file specified by the "--contents" option shows it - -- differently from a line attributed to the working tree file. - if ret.author_mail == '' or ret.author_mail == 'External file (--contents)' then - ret = vim.tbl_extend('force', ret, not_committed) + local function peek(pat) + local l = results[i] + if l and pat then + return l:match(pat) + end + return l + end + + local sha, orig_lnum_str, final_lnum_str, size_str = get('(%x+) (%d+) (%d+) (%d+)') + local orig_lnum = asinteger(orig_lnum_str) + local final_lnum = asinteger(final_lnum_str) + local size = asinteger(size_str) + + if peek():match('^author ') then + --- @type table + local commit = { + sha = sha, + abbrev_sha = sha:sub(1, 8), + } + + -- filename terminates the entry + while peek() and not peek():match('^filename ') do + local l = get() + local key, value = l:match('^([^%s]+) (.*)') + if key then + key = key:gsub('%-', '_') --- @type string + commit[key] = value + else + commit[l] = true + if l ~= 'boundary' then + dprintf("Unknown tag: '%s'", l) + end + end + end + + -- New in git 2.41: + -- The output given by "git blame" that attributes a line to contents + -- taken from the file specified by the "--contents" option shows it + -- differently from a line attributed to the working tree file. + if commit.author_mail == '' or commit.author_mail == 'External file (--contents)' then + commit = vim.tbl_extend('force', commit, NOT_COMMITTED) + end + commits[sha] = commit + end + + local previous_sha, previous_filename = peek():match('^previous (%x+) (.*)') + if previous_sha then + get() + end + + local filename = assert(get():match('^filename (.*)')) + + for j = 0, size - 1 do + ret[final_lnum + j] = { + final_lnum = final_lnum + j, + orig_lnum = orig_lnum + j, + commit = commits[sha], + filename = filename, + previous_filename = previous_filename, + previous_sha = previous_filename + } + end end return ret diff --git a/lua/gitsigns/util.lua b/lua/gitsigns/util.lua index 52d6a37e..9101c6b6 100644 --- a/lua/gitsigns/util.lua +++ b/lua/gitsigns/util.lua @@ -243,4 +243,13 @@ function M.bufexists(buf) return vim.fn.bufexists(buf) == 1 end +--- @param x Gitsigns.BlameInfo +--- @return Gitsigns.BlameInfoPublic +function M.convert_blame_info(x) + --- @type Gitsigns.BlameInfoPublic + local ret = vim.tbl_extend('error', x, x.commit) + ret.commit = nil + return ret +end + return M