perf(blame): better cache invalidation

The blame cache is now maintained in the CacheEntry object
and invalidated incrementally on buffer updates.

In addition git-blame is bypassed if the cursor line is within a hunk.
This commit is contained in:
Lewis Russell 2023-09-24 11:02:52 +01:00 committed by Lewis Russell
parent 9bec6e1ef5
commit bdeba1cec3
9 changed files with 213 additions and 96 deletions

View File

@ -288,7 +288,7 @@ M.stage_hunk = mk_repeatable(async.void(function(range, opts)
table.insert(bcache.staged_diffs, hunk) table.insert(bcache.staged_diffs, hunk)
bcache:invalidate() bcache:invalidate(true)
update(bufnr) update(bufnr)
end)) end))
@ -371,7 +371,7 @@ M.undo_stage_hunk = async.void(function()
end end
bcache.git_obj:stage_hunks({ hunk }, true) bcache.git_obj:stage_hunks({ hunk }, true)
bcache:invalidate() bcache:invalidate(true)
update(bufnr) update(bufnr)
end) end)
@ -389,7 +389,7 @@ M.stage_buffer = async.void(function()
-- Only process files with existing hunks -- Only process files with existing hunks
local hunks = bcache.hunks local hunks = bcache.hunks
if #hunks == 0 then if not hunks or #hunks == 0 then
print('No unstaged changes in file to stage') print('No unstaged changes in file to stage')
return return
end end
@ -405,7 +405,7 @@ M.stage_buffer = async.void(function()
table.insert(bcache.staged_diffs, hunk) table.insert(bcache.staged_diffs, hunk)
end end
bcache:invalidate() bcache:invalidate(true)
update(bufnr) update(bufnr)
end) end)
@ -432,7 +432,7 @@ M.reset_buffer_index = async.void(function()
bcache.git_obj:unstage_file() bcache.git_obj:unstage_file()
bcache:invalidate() bcache:invalidate(true)
update(bufnr) update(bufnr)
end) end)
@ -893,17 +893,16 @@ M.blame_line = async.void(function(opts)
end, 1000) end, 1000)
async.scheduler_if_buf_valid() async.scheduler_if_buf_valid()
local buftext = util.buf_lines(bufnr)
local fileformat = vim.bo[bufnr].fileformat local fileformat = vim.bo[bufnr].fileformat
local lnum = api.nvim_win_get_cursor(0)[1] local lnum = api.nvim_win_get_cursor(0)[1]
local results = bcache.git_obj:run_blame(buftext, lnum, opts.ignore_whitespace) local result = bcache:get_blame(lnum, opts)
pcall(function() pcall(function()
loading:close() loading:close()
end) end)
assert(results and results[lnum]) assert(result)
local result = util.convert_blame_info(results[lnum]) result = util.convert_blame_info(result)
local is_committed = result.sha and tonumber('0x' .. result.sha) ~= 0 local is_committed = result.sha and tonumber('0x' .. result.sha) ~= 0
@ -934,10 +933,12 @@ C.blame_line = function(args, _)
M.blame_line(args) M.blame_line(args)
end end
local function update_buf_base(buf, bcache, base) ---@param bcache Gitsigns.CacheEntry
---@param base string?
local function update_buf_base(bcache, base)
bcache.base = base bcache.base = base
bcache:invalidate() bcache:invalidate(true)
update(buf) update(bcache.bufnr)
end end
--- Change the base revision to diff against. If {base} is not --- Change the base revision to diff against. If {base} is not
@ -978,8 +979,8 @@ M.change_base = async.void(function(base, global)
if global then if global then
config.base = base config.base = base
for bufnr, bcache in pairs(cache) do for _, bcache in pairs(cache) do
update_buf_base(bufnr, bcache, base) update_buf_base(bcache, base)
end end
else else
local bufnr = current_buf() local bufnr = current_buf()
@ -988,7 +989,7 @@ M.change_base = async.void(function(base, global)
return return
end end
update_buf_base(bufnr, bcache, base) update_buf_base(bcache, base)
end end
end) end)
@ -1317,7 +1318,7 @@ M.refresh = async.void(function()
require('gitsigns.highlight').setup_highlights() require('gitsigns.highlight').setup_highlights()
require('gitsigns.current_line_blame').setup() require('gitsigns.current_line_blame').setup()
for k, v in pairs(cache) do for k, v in pairs(cache) do
v:invalidate() v:invalidate(true)
manager.update(k) manager.update(k)
end end
end) end)

View File

@ -10,7 +10,6 @@ local hl = require('gitsigns.highlight')
local gs_cache = require('gitsigns.cache') local gs_cache = require('gitsigns.cache')
local cache = gs_cache.cache local cache = gs_cache.cache
local CacheEntry = gs_cache.CacheEntry
local Status = require('gitsigns.status') local Status = require('gitsigns.status')
local gs_config = require('gitsigns.config') local gs_config = require('gitsigns.config')
@ -111,6 +110,7 @@ end
--- @param bufnr integer --- @param bufnr integer
local function on_reload(_, bufnr) local function on_reload(_, bufnr)
local __FUNC__ = 'on_reload' local __FUNC__ = 'on_reload'
cache[bufnr]:invalidate()
dprint('Reload') dprint('Reload')
manager.update_debounced(bufnr) manager.update_debounced(bufnr)
end end
@ -336,7 +336,7 @@ local attach_throttled = throttle_by_id(function(cbuf, ctx, aucmd)
return return
end end
cache[cbuf] = CacheEntry.new({ cache[cbuf] = gs_cache.new({
bufnr = cbuf, bufnr = cbuf,
base = ctx and ctx.base or config.base, base = ctx and ctx.base or config.base,
file = file, file = file,

View File

@ -1,26 +1,27 @@
local async = require('gitsigns.async')
local config = require('gitsigns.config').config local config = require('gitsigns.config').config
local util = require('gitsigns.util')
local M = { local M = {
CacheEntry = {}, CacheEntry = {},
} }
-- Timer object watching the gitdir --- @class (exact) Gitsigns.CacheEntry
--- @class Gitsigns.CacheEntry
--- @field bufnr integer --- @field bufnr integer
--- @field file string --- @field file string
--- @field base? string --- @field base? string
--- @field compare_text? string[] --- @field compare_text? string[]
--- @field hunks Gitsigns.Hunk.Hunk[] --- @field hunks? Gitsigns.Hunk.Hunk[]
--- @field force_next_update? boolean --- @field force_next_update? boolean
--- ---
--- @field compare_text_head? string[] --- @field compare_text_head? string[]
--- @field hunks_staged? Gitsigns.Hunk.Hunk[] --- @field hunks_staged? Gitsigns.Hunk.Hunk[]
--- ---
--- @field staged_diffs Gitsigns.Hunk.Hunk[] --- @field staged_diffs? Gitsigns.Hunk.Hunk[]
--- @field gitdir_watcher? uv.uv_fs_event_t --- @field gitdir_watcher? uv.uv_fs_event_t
--- @field git_obj Gitsigns.GitObj --- @field git_obj Gitsigns.GitObj
--- @field commit? string --- @field commit? string
--- @field blame? table<integer,Gitsigns.BlameInfo?>
local CacheEntry = M.CacheEntry local CacheEntry = M.CacheEntry
function CacheEntry:get_compare_rev(base) function CacheEntry:get_compare_rev(base)
@ -47,20 +48,95 @@ function CacheEntry:get_rev_bufname(rev)
return string.format('gitsigns://%s/%s:%s', self.git_obj.repo.gitdir, rev, self.git_obj.relpath) return string.format('gitsigns://%s/%s:%s', self.git_obj.repo.gitdir, rev, self.git_obj.relpath)
end end
function CacheEntry:invalidate() --- Invalidate any state dependent on the buffer content.
self.compare_text = nil --- If 'all' is passed, then invalidate everything.
self.compare_text_head = nil --- @param all? boolean
function CacheEntry:invalidate(all)
self.hunks = nil self.hunks = nil
self.hunks_staged = nil self.hunks_staged = nil
self.blame = nil
if all then
-- The below doesn't need to be invalidated
-- if the buffer changes
self.compare_text = nil
self.compare_text_head = nil
end
end end
--- @param o Gitsigns.CacheEntry --- @param o Gitsigns.CacheEntry
--- @return Gitsigns.CacheEntry --- @return Gitsigns.CacheEntry
function CacheEntry.new(o) function M.new(o)
o.staged_diffs = o.staged_diffs or {} o.staged_diffs = o.staged_diffs or {}
return setmetatable(o, { __index = CacheEntry }) return setmetatable(o, { __index = CacheEntry })
end end
local sleep = async.wrap(function(duration, cb)
vim.defer_fn(cb, duration)
end, 2)
--- @private
function CacheEntry:wait_for_hunks()
local loop_protect = 0
while not self.hunks and loop_protect < 10 do
loop_protect = loop_protect + 1
sleep(100)
end
end
--- @private
--- @param opts Gitsigns.CurrentLineBlameOpts
--- @return table<integer,Gitsigns.BlameInfo?>?
function CacheEntry:run_blame(opts)
local blame_cache --- @type table<integer,Gitsigns.BlameInfo?>?
repeat
local buftext = util.buf_lines(self.bufnr)
local tick = vim.b[self.bufnr].changedtick
-- TODO(lewis6991): Cancel blame on changedtick
blame_cache = self.git_obj:run_blame(buftext, nil, opts.ignore_whitespace)
async.scheduler_if_buf_valid(self.bufnr)
until vim.b[self.bufnr].changedtick == tick
return blame_cache
end
--- @param file string
--- @param lnum integer
--- @return Gitsigns.BlameInfo
local function get_blame_nc(file, lnum)
local Git = require('gitsigns.git')
return {
orig_lnum = 0,
final_lnum = lnum,
commit = Git.not_commited(file),
filename = file,
}
end
--- @param lnum integer
--- @param opts Gitsigns.CurrentLineBlameOpts
--- @return Gitsigns.BlameInfo?
function CacheEntry:get_blame(lnum, opts)
local blame_cache = self.blame
if not blame_cache or not blame_cache[lnum] then
self:wait_for_hunks()
local Hunks = require('gitsigns.hunks')
if Hunks.find_hunk(lnum, self.hunks) then
--- Bypass running blame (which can be expensive) if we know lnum is in a hunk
blame_cache = blame_cache or {}
blame_cache[lnum] = get_blame_nc(self.git_obj.relpath, lnum)
else
-- Refresh cache
blame_cache = self:run_blame(opts)
end
self.blame = blame_cache
end
if blame_cache then
return blame_cache[lnum]
end
end
function CacheEntry:destroy() function CacheEntry:destroy()
local w = self.gitdir_watcher local w = self.gitdir_watcher
if w and not w:is_closing() then if w and not w:is_closing() then

View File

@ -70,7 +70,6 @@
--- @field trouble boolean --- @field trouble boolean
--- -- Undocumented --- -- Undocumented
--- @field _refresh_staged_on_update boolean --- @field _refresh_staged_on_update boolean
--- @field _blame_cache boolean
--- @field _threaded_diff boolean --- @field _threaded_diff boolean
--- @field _inline2 boolean --- @field _inline2 boolean
--- @field _extmark_signs boolean --- @field _extmark_signs boolean
@ -760,14 +759,6 @@ M.schema = {
]], ]],
}, },
_blame_cache = {
type = 'boolean',
default = true,
description = [[
Cache blame results for current_line_blame
]],
},
_threaded_diff = { _threaded_diff = {
type = 'boolean', type = 'boolean',
default = true, default = true,

View File

@ -20,13 +20,6 @@ local function reset(bufnr)
vim.b[bufnr].gitsigns_blame_line_dict = nil vim.b[bufnr].gitsigns_blame_line_dict = nil
end end
--- @class (exact) Gitsigns.BlameCache
--- @field cache Gitsigns.BlameInfo[]?
--- @field tick integer
--- @type table<integer,Gitsigns.BlameCache>
local blame_cache = {}
--- @param fmt string --- @param fmt string
--- @param name string --- @param name string
--- @param info Gitsigns.BlameInfoPublic --- @param info Gitsigns.BlameInfoPublic
@ -48,36 +41,6 @@ local function flatten_virt_text(virt_text)
return table.concat(res) return table.concat(res)
end end
--- @param bufnr integer
--- @param lnum integer
--- @param opts Gitsigns.CurrentLineBlameOpts
--- @return Gitsigns.BlameInfo?
local function run_blame(bufnr, lnum, opts)
-- 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[lnum]
end
local buftext = util.buf_lines(bufnr)
local bcache = cache[bufnr]
result = bcache.git_obj:run_blame(buftext, nil, opts.ignore_whitespace)
if not result then
return
end
blame_cache[bufnr].cache = result
return result[lnum]
end
--- @param winid integer --- @param winid integer
--- @return integer --- @return integer
local function win_width(winid) local function win_width(winid)
@ -203,14 +166,12 @@ local function update0(bufnr)
local opts = config.current_line_blame_opts local opts = config.current_line_blame_opts
local blame_info = run_blame(bufnr, lnum, opts) local blame_info = bcache:get_blame(lnum, opts)
if not blame_info then if not blame_info then
return return
end end
async.scheduler_if_buf_valid(bufnr)
if lnum ~= get_lnum(winid) then if lnum ~= get_lnum(winid) then
-- Cursor has moved during events; abort and tr-trigger another update -- Cursor has moved during events; abort and tr-trigger another update
update0(bufnr) update0(bufnr)

View File

@ -8,8 +8,6 @@ local subprocess = require('gitsigns.subprocess')
local gs_config = require('gitsigns.config') local gs_config = require('gitsigns.config')
local config = gs_config.config local config = gs_config.config
local gs_hunks = require('gitsigns.hunks')
local uv = vim.loop local uv = vim.loop
local startswith = vim.startswith local startswith = vim.startswith
@ -569,11 +567,30 @@ end
local NOT_COMMITTED = { local NOT_COMMITTED = {
author = 'Not Committed Yet', author = 'Not Committed Yet',
['author_mail'] = '<not.committed.yet>', author_mail = '<not.committed.yet>',
committer = 'Not Committed Yet', committer = 'Not Committed Yet',
['committer_mail'] = '<not.committed.yet>', committer_mail = '<not.committed.yet>',
} }
--- @param file string
--- @return Gitsigns.CommitInfo
function M.not_commited(file)
local time = os.time()
return {
sha = string.rep('0', 40),
abbrev_sha = string.rep('0', 8),
author = 'Not Committed Yet',
author_mail = '<not.committed.yet>',
author_tz = '+0000',
author_time = time,
committer = 'Not Committed Yet',
committer_time = time,
committer_mail = '<not.committed.yet>',
committer_tz = '+0000',
summary = 'Version of ' .. file,
}
end
---@param x any ---@param x any
---@return integer ---@return integer
local function asinteger(x) local function asinteger(x)
@ -585,11 +602,22 @@ end
--- @param ignore_whitespace? boolean --- @param ignore_whitespace? boolean
--- @return table<integer,Gitsigns.BlameInfo?>? --- @return table<integer,Gitsigns.BlameInfo?>?
function Obj:run_blame(lines, lnum, ignore_whitespace) function Obj:run_blame(lines, lnum, ignore_whitespace)
local ret = {} --- @type table<integer,Gitsigns.BlameInfo>
if not self.object_name or self.repo.abbrev_head == '' then if not self.object_name or self.repo.abbrev_head == '' then
-- As we support attaching to untracked files we need to return something if -- As we support attaching to untracked files we need to return something if
-- the file isn't isn't tracked in git. -- the file isn't isn't tracked in git.
-- If abbrev_head is empty, then assume the repo has no commits -- If abbrev_head is empty, then assume the repo has no commits
return NOT_COMMITTED local commit = M.not_commited(self.file)
for i in ipairs(lines) do
ret[i] = {
orig_lnum = 0,
final_lnum = i,
commit = commit,
filename = self.file,
}
end
return ret
end end
local args = { 'blame', '--contents', '-', '--incremental' } local args = { 'blame', '--contents', '-', '--incremental' }
@ -619,7 +647,6 @@ function Obj:run_blame(lines, lnum, ignore_whitespace)
return return
end end
local ret = {} --- @type Gitsigns.BlameInfo[]
local commits = {} --- @type table<string,Gitsigns.CommitInfo> local commits = {} --- @type table<string,Gitsigns.CommitInfo>
local i = 1 local i = 1
@ -660,6 +687,9 @@ function Obj:run_blame(lines, lnum, ignore_whitespace)
local l = get() local l = get()
local key, value = l:match('^([^%s]+) (.*)') local key, value = l:match('^([^%s]+) (.*)')
if key then if key then
if vim.endswith(key, '_time') then
value = tonumber(value)
end
key = key:gsub('%-', '_') --- @type string key = key:gsub('%-', '_') --- @type string
commit[key] = value commit[key] = value
else else
@ -749,6 +779,8 @@ end
function Obj.stage_hunks(self, hunks, invert) function Obj.stage_hunks(self, hunks, invert)
ensure_file_in_index(self) ensure_file_in_index(self)
local gs_hunks = require('gitsigns.hunks')
local patch = gs_hunks.create_patch(self.relpath, hunks, self.mode_bits, invert) local patch = gs_hunks.create_patch(self.relpath, hunks, self.mode_bits, invert)
if not self.i_crlf and self.w_crlf then if not self.i_crlf and self.w_crlf then

View File

@ -75,6 +75,28 @@ local function apply_win_signs(bufnr, top, bot, clear)
end end
end end
--- @param blame table<integer,Gitsigns.BlameInfo?>?
--- @param first integer
--- @param last_orig integer
--- @param last_new integer
local function on_lines_blame(blame, first, last_orig, last_new)
if not blame then
return
end
if last_new ~= last_orig then
if last_new < last_orig then
util.list_remove(blame, last_new, last_orig)
else
util.list_insert(blame, last_orig, last_new)
end
end
for i = math.min(first + 1, last_new), math.max(first + 1, last_new) do
blame[i] = nil
end
end
--- @param buf integer --- @param buf integer
--- @param first integer --- @param first integer
--- @param last_orig integer --- @param last_orig integer
@ -87,6 +109,8 @@ function M.on_lines(buf, first, last_orig, last_new)
return true return true
end end
on_lines_blame(bcache.blame, first, last_orig, last_new)
signs_normal:on_lines(buf, first, last_orig, last_new) signs_normal:on_lines(buf, first, last_orig, last_new)
if signs_staged then if signs_staged then
signs_staged:on_lines(buf, first, last_orig, last_new) signs_staged:on_lines(buf, first, last_orig, last_new)

View File

@ -191,19 +191,6 @@ function M.get_relative_time(timestamp)
end end
end end
--- @generic T
--- @param x T[]
--- @return T[]
function M.copy_array(x)
local r = {}
--- @diagnostic disable-next-line:no-unknown
for i, e in ipairs(x) do
--- @diagnostic disable-next-line:no-unknown
r[i] = e
end
return r
end
--- Strip '\r' from the EOL of each line only if all lines end with '\r' --- Strip '\r' from the EOL of each line only if all lines end with '\r'
--- @param xs0 string[] --- @param xs0 string[]
--- @return string[] --- @return string[]
@ -304,4 +291,49 @@ function M.convert_blame_info(x)
return ret return ret
end end
--- Efficiently remove items from middle of a list a list.
---
--- Calling table.remove() in a loop will re-index the tail of the table on
--- every iteration, instead this function will re-index the table exactly
--- once.
---
--- Based on https://stackoverflow.com/questions/12394841/safely-remove-items-from-an-array-table-while-iterating/53038524#53038524
---
---@param t any[]
---@param first integer
---@param last integer
function M.list_remove(t, first, last)
local n = #t
for i = 0, n - first do
t[first + i] = t[last + 1 + i]
t[last + 1 + i] = nil
end
end
--- Efficiently insert items into the middle of a list.
---
--- Calling table.insert() in a loop will re-index the tail of the table on
--- every iteration, instead this function will re-index the table exactly
--- once.
---
--- Based on https://stackoverflow.com/questions/12394841/safely-remove-items-from-an-array-table-while-iterating/53038524#53038524
---
---@param t any[]
---@param first integer
---@param last integer
---@param v any
function M.list_insert(t, first, last, v)
local n = #t
-- Shift table forward
for i = n - first, 0, -1 do
t[last + 1 + i] = t[first + i]
end
-- Fill in new values
for i = first, last do
t[i] = v
end
end
return M return M

View File

@ -87,7 +87,7 @@ local handler = debounce_trailing(
buf_check(bufnr) buf_check(bufnr)
end end
cache[bufnr]:invalidate() cache[bufnr]:invalidate(true)
require('gitsigns.manager').update(bufnr) require('gitsigns.manager').update(bufnr)
end), end),