Skip to content

Commit

Permalink
perf(blame): run blame for entire file instead of per line
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
lewis6991 committed Sep 22, 2023
1 parent d9af115 commit 05cb146
Show file tree
Hide file tree
Showing 8 changed files with 157 additions and 109 deletions.
1 change: 1 addition & 0 deletions doc/gitsigns.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
8 changes: 5 additions & 3 deletions lua/gitsigns/actions.lua
Original file line number Diff line number Diff line change
Expand Up @@ -813,7 +813,7 @@ end
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)
Expand Down Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions lua/gitsigns/attach.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions lua/gitsigns/cache.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand Down
1 change: 1 addition & 0 deletions lua/gitsigns/config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
70 changes: 22 additions & 48 deletions lua/gitsigns/current_line_blame.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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<integer,Gitsigns.BlameInfo>
--- @field size integer
--- @class (exact) Gitsigns.BlameCache
--- @field cache Gitsigns.BlameInfo[]?
--- @field tick integer

local BlameCache = {}

--- @type table<integer,Gitsigns.BlameCache>
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
Expand Down Expand Up @@ -93,24 +53,38 @@ 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
--- @param lnum integer
--- @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])
Expand Down
175 changes: 117 additions & 58 deletions lua/gitsigns/git.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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'] = '<not.committed.yet>',
committer = 'Not Committed Yet',
['committer_mail'] = '<not.committed.yet>',
}

---@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 Gitsigns.BlameInfo[]?
function Obj:run_blame(lines, lnum, ignore_whitespace)
local not_committed = {
author = 'Not Committed Yet',
['author_mail'] = '<not.committed.yet>',
committer = 'Not Committed Yet',
['committer_mail'] = '<not.committed.yet>',
}

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'
Expand All @@ -634,36 +640,89 @@ 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<string,Gitsigns.CommitInfo>
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 == '<external.file>' 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<string,string|true>
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 == '<external.file>' 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

assert(vim.tbl_isarray(ret))

return ret
end

Expand Down
9 changes: 9 additions & 0 deletions lua/gitsigns/util.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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

0 comments on commit 05cb146

Please sign in to comment.