From bdeba1cec3faddd89146690c10b9a87949c0ee66 Mon Sep 17 00:00:00 2001 From: Lewis Russell Date: Sun, 24 Sep 2023 11:02:52 +0100 Subject: [PATCH] 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. --- lua/gitsigns/actions.lua | 33 +++++----- lua/gitsigns/attach.lua | 4 +- lua/gitsigns/cache.lua | 94 ++++++++++++++++++++++++++--- lua/gitsigns/config.lua | 9 --- lua/gitsigns/current_line_blame.lua | 41 +------------ lua/gitsigns/git.lua | 44 ++++++++++++-- lua/gitsigns/manager.lua | 24 ++++++++ lua/gitsigns/util.lua | 58 ++++++++++++++---- lua/gitsigns/watcher.lua | 2 +- 9 files changed, 213 insertions(+), 96 deletions(-) diff --git a/lua/gitsigns/actions.lua b/lua/gitsigns/actions.lua index a9bcbc21..f9e4f72d 100644 --- a/lua/gitsigns/actions.lua +++ b/lua/gitsigns/actions.lua @@ -288,7 +288,7 @@ M.stage_hunk = mk_repeatable(async.void(function(range, opts) table.insert(bcache.staged_diffs, hunk) - bcache:invalidate() + bcache:invalidate(true) update(bufnr) end)) @@ -371,7 +371,7 @@ M.undo_stage_hunk = async.void(function() end bcache.git_obj:stage_hunks({ hunk }, true) - bcache:invalidate() + bcache:invalidate(true) update(bufnr) end) @@ -389,7 +389,7 @@ M.stage_buffer = async.void(function() -- Only process files with existing hunks local hunks = bcache.hunks - if #hunks == 0 then + if not hunks or #hunks == 0 then print('No unstaged changes in file to stage') return end @@ -405,7 +405,7 @@ M.stage_buffer = async.void(function() table.insert(bcache.staged_diffs, hunk) end - bcache:invalidate() + bcache:invalidate(true) update(bufnr) end) @@ -432,7 +432,7 @@ M.reset_buffer_index = async.void(function() bcache.git_obj:unstage_file() - bcache:invalidate() + bcache:invalidate(true) update(bufnr) end) @@ -893,17 +893,16 @@ M.blame_line = async.void(function(opts) end, 1000) async.scheduler_if_buf_valid() - local buftext = util.buf_lines(bufnr) local fileformat = vim.bo[bufnr].fileformat 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() loading:close() 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 @@ -934,10 +933,12 @@ C.blame_line = function(args, _) M.blame_line(args) 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:invalidate() - update(buf) + bcache:invalidate(true) + update(bcache.bufnr) end --- 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 config.base = base - for bufnr, bcache in pairs(cache) do - update_buf_base(bufnr, bcache, base) + for _, bcache in pairs(cache) do + update_buf_base(bcache, base) end else local bufnr = current_buf() @@ -988,7 +989,7 @@ M.change_base = async.void(function(base, global) return end - update_buf_base(bufnr, bcache, base) + update_buf_base(bcache, base) end end) @@ -1317,7 +1318,7 @@ M.refresh = async.void(function() require('gitsigns.highlight').setup_highlights() require('gitsigns.current_line_blame').setup() for k, v in pairs(cache) do - v:invalidate() + v:invalidate(true) manager.update(k) end end) diff --git a/lua/gitsigns/attach.lua b/lua/gitsigns/attach.lua index 4dd7173c..4a7fc74e 100644 --- a/lua/gitsigns/attach.lua +++ b/lua/gitsigns/attach.lua @@ -10,7 +10,6 @@ local hl = require('gitsigns.highlight') local gs_cache = require('gitsigns.cache') local cache = gs_cache.cache -local CacheEntry = gs_cache.CacheEntry local Status = require('gitsigns.status') local gs_config = require('gitsigns.config') @@ -111,6 +110,7 @@ end --- @param bufnr integer local function on_reload(_, bufnr) local __FUNC__ = 'on_reload' + cache[bufnr]:invalidate() dprint('Reload') manager.update_debounced(bufnr) end @@ -336,7 +336,7 @@ local attach_throttled = throttle_by_id(function(cbuf, ctx, aucmd) return end - cache[cbuf] = CacheEntry.new({ + cache[cbuf] = gs_cache.new({ bufnr = cbuf, base = ctx and ctx.base or config.base, file = file, diff --git a/lua/gitsigns/cache.lua b/lua/gitsigns/cache.lua index 0eee4bad..cdeb8419 100644 --- a/lua/gitsigns/cache.lua +++ b/lua/gitsigns/cache.lua @@ -1,26 +1,27 @@ +local async = require('gitsigns.async') local config = require('gitsigns.config').config +local util = require('gitsigns.util') local M = { CacheEntry = {}, } --- Timer object watching the gitdir - ---- @class Gitsigns.CacheEntry +--- @class (exact) Gitsigns.CacheEntry --- @field bufnr integer --- @field file string --- @field base? string --- @field compare_text? string[] ---- @field hunks Gitsigns.Hunk.Hunk[] +--- @field hunks? Gitsigns.Hunk.Hunk[] --- @field force_next_update? boolean --- --- @field compare_text_head? string[] --- @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 git_obj Gitsigns.GitObj --- @field commit? string +--- @field blame? table local CacheEntry = M.CacheEntry 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) end -function CacheEntry:invalidate() - self.compare_text = nil - self.compare_text_head = nil +--- Invalidate any state dependent on the buffer content. +--- If 'all' is passed, then invalidate everything. +--- @param all? boolean +function CacheEntry:invalidate(all) self.hunks = 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 --- @param o Gitsigns.CacheEntry --- @return Gitsigns.CacheEntry -function CacheEntry.new(o) +function M.new(o) o.staged_diffs = o.staged_diffs or {} return setmetatable(o, { __index = CacheEntry }) 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? +function CacheEntry:run_blame(opts) + local blame_cache --- @type table? + 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() local w = self.gitdir_watcher if w and not w:is_closing() then diff --git a/lua/gitsigns/config.lua b/lua/gitsigns/config.lua index 5344bb2a..ba03e911 100644 --- a/lua/gitsigns/config.lua +++ b/lua/gitsigns/config.lua @@ -70,7 +70,6 @@ --- @field trouble boolean --- -- Undocumented --- @field _refresh_staged_on_update boolean ---- @field _blame_cache boolean --- @field _threaded_diff boolean --- @field _inline2 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 = { type = 'boolean', default = true, diff --git a/lua/gitsigns/current_line_blame.lua b/lua/gitsigns/current_line_blame.lua index cf44a961..064461bb 100644 --- a/lua/gitsigns/current_line_blame.lua +++ b/lua/gitsigns/current_line_blame.lua @@ -20,13 +20,6 @@ local function reset(bufnr) vim.b[bufnr].gitsigns_blame_line_dict = nil end ---- @class (exact) Gitsigns.BlameCache ---- @field cache Gitsigns.BlameInfo[]? ---- @field tick integer - ---- @type table -local blame_cache = {} - --- @param fmt string --- @param name string --- @param info Gitsigns.BlameInfoPublic @@ -48,36 +41,6 @@ local function flatten_virt_text(virt_text) return table.concat(res) 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 --- @return integer local function win_width(winid) @@ -203,14 +166,12 @@ local function update0(bufnr) 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 return end - async.scheduler_if_buf_valid(bufnr) - if lnum ~= get_lnum(winid) then -- Cursor has moved during events; abort and tr-trigger another update update0(bufnr) diff --git a/lua/gitsigns/git.lua b/lua/gitsigns/git.lua index 518ec4a6..367431be 100644 --- a/lua/gitsigns/git.lua +++ b/lua/gitsigns/git.lua @@ -8,8 +8,6 @@ local subprocess = require('gitsigns.subprocess') local gs_config = require('gitsigns.config') local config = gs_config.config -local gs_hunks = require('gitsigns.hunks') - local uv = vim.loop local startswith = vim.startswith @@ -569,11 +567,30 @@ end local NOT_COMMITTED = { author = 'Not Committed Yet', - ['author_mail'] = '', + author_mail = '', committer = 'Not Committed Yet', - ['committer_mail'] = '', + committer_mail = '', } +--- @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 = '', + author_tz = '+0000', + author_time = time, + committer = 'Not Committed Yet', + committer_time = time, + committer_mail = '', + committer_tz = '+0000', + summary = 'Version of ' .. file, + } +end + ---@param x any ---@return integer local function asinteger(x) @@ -585,11 +602,22 @@ end --- @param ignore_whitespace? boolean --- @return table? function Obj:run_blame(lines, lnum, ignore_whitespace) + local ret = {} --- @type table + 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 + 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 local args = { 'blame', '--contents', '-', '--incremental' } @@ -619,7 +647,6 @@ function Obj:run_blame(lines, lnum, ignore_whitespace) return end - local ret = {} --- @type Gitsigns.BlameInfo[] local commits = {} --- @type table local i = 1 @@ -660,6 +687,9 @@ function Obj:run_blame(lines, lnum, ignore_whitespace) local l = get() local key, value = l:match('^([^%s]+) (.*)') if key then + if vim.endswith(key, '_time') then + value = tonumber(value) + end key = key:gsub('%-', '_') --- @type string commit[key] = value else @@ -749,6 +779,8 @@ end function Obj.stage_hunks(self, hunks, invert) ensure_file_in_index(self) + local gs_hunks = require('gitsigns.hunks') + local patch = gs_hunks.create_patch(self.relpath, hunks, self.mode_bits, invert) if not self.i_crlf and self.w_crlf then diff --git a/lua/gitsigns/manager.lua b/lua/gitsigns/manager.lua index ac655035..848d9edc 100644 --- a/lua/gitsigns/manager.lua +++ b/lua/gitsigns/manager.lua @@ -75,6 +75,28 @@ local function apply_win_signs(bufnr, top, bot, clear) end end +--- @param blame table? +--- @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 first integer --- @param last_orig integer @@ -87,6 +109,8 @@ function M.on_lines(buf, first, last_orig, last_new) return true end + on_lines_blame(bcache.blame, first, last_orig, last_new) + signs_normal:on_lines(buf, first, last_orig, last_new) if signs_staged then signs_staged:on_lines(buf, first, last_orig, last_new) diff --git a/lua/gitsigns/util.lua b/lua/gitsigns/util.lua index 14882f41..2798b274 100644 --- a/lua/gitsigns/util.lua +++ b/lua/gitsigns/util.lua @@ -191,19 +191,6 @@ function M.get_relative_time(timestamp) 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' --- @param xs0 string[] --- @return string[] @@ -304,4 +291,49 @@ function M.convert_blame_info(x) return ret 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 diff --git a/lua/gitsigns/watcher.lua b/lua/gitsigns/watcher.lua index 699c61a8..aa7173f9 100644 --- a/lua/gitsigns/watcher.lua +++ b/lua/gitsigns/watcher.lua @@ -87,7 +87,7 @@ local handler = debounce_trailing( buf_check(bufnr) end - cache[bufnr]:invalidate() + cache[bufnr]:invalidate(true) require('gitsigns.manager').update(bufnr) end),