diff --git a/doc/gitsigns.txt b/doc/gitsigns.txt index 1652376b..a5fa9f15 100644 --- a/doc/gitsigns.txt +++ b/doc/gitsigns.txt @@ -32,7 +32,7 @@ For basic setup with all batteries included: Configuration can be passed to the setup function. Here is an example with most of the default settings: -> +>lua require('gitsigns').setup { signs = { add = { text = '│' }, @@ -98,7 +98,7 @@ setup({cfg}) *gitsigns.setup()* {async} Parameters: ~ - {cfg} (table): Configuration for Gitsigns. + {cfg} (table|nil): Configuration for Gitsigns. See |gitsigns-usage| for more details. attach({bufnr}, {ctx}) *gitsigns.attach()* @@ -200,7 +200,7 @@ show({revision}) *gitsigns.show()* If {base} is the index, then the opened buffer is editable and any written changes will update the index accordingly. - Examples: > + Examples: >vim " View the index version of the file :Gitsigns show @@ -221,7 +221,7 @@ diffthis({base}, {opts}) *gitsigns.diffthis()* If {base} is the index, then the opened buffer is editable and any written changes will update the index accordingly. - Examples: > + Examples: >vim " Diff against the index :Gitsigns diffthis @@ -261,7 +261,7 @@ change_base({base}, {global}) *gitsigns.change_base()* Attributes: ~ {async} - Examples: > + Examples: >vim " Change base to 1 commit behind head :lua require('gitsigns').change_base('HEAD~1') @@ -537,7 +537,7 @@ worktrees *gitsigns-config-worktrees* If normal attaching fails, then each entry in the table is attempted with the work tree details set. - Example: > + Example: >lua worktrees = { { toplevel = vim.env.HOME, @@ -553,7 +553,7 @@ on_attach *gitsigns-config-on_attach* This callback can return `false` to prevent attaching to the buffer. - Example: > + Example: >lua on_attach = function(bufnr) if vim.api.nvim_buf_get_name(bufnr):match() then -- Don't attach to specific buffers whose name matches a pattern @@ -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/etc/doc_template.txt b/etc/doc_template.txt index e8d78606..d7d054a1 100644 --- a/etc/doc_template.txt +++ b/etc/doc_template.txt @@ -32,7 +32,7 @@ For basic setup with all batteries included: Configuration can be passed to the setup function. Here is an example with most of the default settings: -> +>lua {{SETUP}} < diff --git a/lua/gitsigns/actions.lua b/lua/gitsigns/actions.lua index a32d0748..91e5f338 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 @@ -927,7 +929,7 @@ end --- Attributes: ~ --- {async} --- ---- Examples: > +--- Examples: >vim --- " Change base to 1 commit behind head --- :lua require('gitsigns').change_base('HEAD~1') --- @@ -995,7 +997,7 @@ end --- If {base} is the index, then the opened buffer is editable and --- any written changes will update the index accordingly. --- ---- Examples: > +--- Examples: >vim --- " Diff against the index --- :Gitsigns diffthis --- @@ -1064,7 +1066,7 @@ CP.diffthis = complete_heads --- If {base} is the index, then the opened buffer is editable and --- any written changes will update the index accordingly. --- ---- Examples: > +--- Examples: >vim --- " View the index version of the file --- :Gitsigns show --- 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 c965c625..65d695d2 100644 --- a/lua/gitsigns/config.lua +++ b/lua/gitsigns/config.lua @@ -245,7 +245,7 @@ M.schema = { If normal attaching fails, then each entry in the table is attempted with the work tree details set. - Example: > + Example: >lua worktrees = { { toplevel = vim.env.HOME, @@ -265,7 +265,7 @@ M.schema = { This callback must call its callback argument. The callback argument can accept an optional table argument with the keys: 'gitdir' and 'toplevel'. - Example: > + Example: >lua on_attach_pre = function(bufnr, callback) ... callback { @@ -286,7 +286,7 @@ M.schema = { This callback can return `false` to prevent attaching to the buffer. - Example: > + Example: >lua on_attach = function(bufnr) if vim.api.nvim_buf_get_name(bufnr):match() then -- Don't attach to specific buffers whose name matches a pattern @@ -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 617cd825..1be79b86 100644 --- a/lua/gitsigns/current_line_blame.lua +++ b/lua/gitsigns/current_line_blame.lua @@ -2,41 +2,17 @@ local async = require('gitsigns.async') local cache = require('gitsigns.cache').cache local config = require('gitsigns.config').config local util = require('gitsigns.util') -local uv = vim.loop local api = vim.api -local current_buf = api.nvim_get_current_buf +local debounce = require('gitsigns.debounce') local namespace = api.nvim_create_namespace('gitsigns_blame') -local timer = assert(uv.new_timer()) - local M = {} -local wait_timer = async.wrap(uv.timer_start, 4) - ---- @param bufnr integer ---- @param row integer ---- @param opts? table -local function set_extmark(bufnr, row, opts) - opts = opts or {} - opts.id = 1 - api.nvim_buf_set_extmark(bufnr, namespace, row - 1, 0, opts) -end - --- @param bufnr integer ---- @return integer? id -local function get_extmark(bufnr) - local pos = api.nvim_buf_get_extmark_by_id(bufnr, namespace, 1, {}) - if pos[1] then - return pos[1] + 1 - end -end - ---- @param bufnr? integer local function reset(bufnr) - bufnr = bufnr or current_buf() if not api.nvim_buf_is_valid(bufnr) then return end @@ -44,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 @@ -112,30 +48,34 @@ local function flatten_virt_text(virt_text) return table.concat(res) end -local running = false - --- @param bufnr integer --- @param lnum integer --- @param opts Gitsigns.CurrentLineBlameOpts --- @return Gitsigns.BlameInfo? local function run_blame(bufnr, lnum, opts) - local result = BlameCache:get(bufnr, lnum) - if result then - return result + -- 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 - if running then - return + local result = blame_cache[bufnr].cache + + if result then + return result[lnum] end - running = true 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) - running = false + 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 @@ -143,10 +83,11 @@ end --- @param blame_info Gitsigns.BlameInfo --- @param opts Gitsigns.CurrentLineBlameOpts local function handle_blame_info(bufnr, lnum, blame_info, opts) - local bcache = cache[bufnr] - if not bcache then - return - end + blame_info = util.convert_blame_info(blame_info) + + vim.b[bufnr].gitsigns_blame_line_dict = blame_info + + local bcache = assert(cache[bufnr]) local virt_text ---@type {[1]: string, [2]: string}[] local clb_formatter = blame_info.author == 'Not Committed Yet' and config.current_line_blame_formatter_nc @@ -169,7 +110,8 @@ local function handle_blame_info(bufnr, lnum, blame_info, opts) vim.b[bufnr].gitsigns_blame_line = flatten_virt_text(virt_text) if opts.virt_text then - set_extmark(bufnr, lnum, { + api.nvim_buf_set_extmark(bufnr, namespace, lnum - 1, 0, { + id = 1, virt_text = virt_text, virt_text_pos = opts.virt_text_pos, priority = opts.virt_text_priority, @@ -178,75 +120,83 @@ local function handle_blame_info(bufnr, lnum, blame_info, opts) end end +--- @param winid integer --- @return integer lnum -local function get_lnum() - return api.nvim_win_get_cursor(0)[1] +local function get_lnum(winid) + return api.nvim_win_get_cursor(winid)[1] end --- Update function, must be called in async context -local function update0() - local bufnr = current_buf() - local lnum = get_lnum() +--- @param winid integer +--- @param lnum integer +--- @return boolean +local function foldclosed(winid, lnum) + ---@return boolean + return api.nvim_win_call(winid, function() + return vim.fn.foldclosed(lnum) ~= -1 + end) +end - local old_lnum = get_extmark(bufnr) - if old_lnum and lnum == old_lnum and BlameCache:get(bufnr, lnum) then - -- Don't update if on the same line and we already have results +---@return boolean +local function insert_mode() + return api.nvim_get_mode().mode == 'i' +end + +--- Update function, must be called in async context +--- @param bufnr integer +local function update0(bufnr) + async.scheduler_if_buf_valid(bufnr) + + if insert_mode() then return end - if api.nvim_get_mode().mode == 'i' then - reset(bufnr) + local winid = vim.fn.bufwinid(bufnr) + if winid == -1 then return end - -- Set an empty extmark to save the line number. - -- This will also clear virt_text. - -- Only do this if there was already an extmark to avoid clearing the intro - -- text. - if get_extmark(bufnr) then - reset(bufnr) - set_extmark(bufnr, lnum) - end + local lnum = get_lnum(winid) -- Can't show extmarks on folded lines so skip - if vim.fn.foldclosed(lnum) ~= -1 then + if foldclosed(winid, lnum) then return end - local opts = config.current_line_blame_opts - - -- Note because the same timer is re-used, this call has a debouncing effect. - wait_timer(timer, opts.delay, 0) - async.scheduler() - local bcache = cache[bufnr] if not bcache or not bcache.git_obj.object_name then return end + local opts = config.current_line_blame_opts + local blame_info = run_blame(bufnr, lnum, opts) - async.scheduler_if_buf_valid(bufnr) - local lnum1 = get_lnum() - if bufnr == current_buf() and lnum ~= lnum1 then - -- Cursor has moved during events; abort and tr-trigger another update - -- since it's likely blame jobs where skipped - update0() + if not blame_info then return end - vim.b[bufnr].gitsigns_blame_line_dict = blame_info + async.scheduler_if_buf_valid(bufnr) - if blame_info then - handle_blame_info(bufnr, lnum, blame_info, opts) + if lnum ~= get_lnum(winid) then + -- Cursor has moved during events; abort and tr-trigger another update + update0(bufnr) + return end + + handle_blame_info(bufnr, lnum, blame_info, opts) end -local update = async.void(update0) +local update = async.void(debounce.throttle_by_id(update0)) + +--- @type fun(bufnr: integer) +local update_debounced function M.setup() local group = api.nvim_create_augroup('gitsigns_blame', {}) + local opts = config.current_line_blame_opts + update_debounced = debounce.debounce_trailing(opts.delay, update) + for k, _ in pairs(cache) do reset(k) end @@ -254,19 +204,20 @@ function M.setup() if config.current_line_blame then api.nvim_create_autocmd({ 'FocusGained', 'BufEnter', 'CursorMoved', 'CursorMovedI' }, { group = group, - callback = update, + callback = function(args) + reset(args.buf) + update_debounced(args.buf) + end }) api.nvim_create_autocmd({ 'InsertEnter', 'FocusLost', 'BufLeave' }, { group = group, - callback = function() - reset() + callback = function(args) + reset(args.buf) end, }) - -- Call via vim.schedule to avoid the debounce timer killing the async - -- coroutine - vim.schedule(update) + update_debounced(api.nvim_get_current_buf()) end end 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 c2e24a57..86186731 100644 --- a/lua/gitsigns/util.lua +++ b/lua/gitsigns/util.lua @@ -250,4 +250,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 diff --git a/test/gitsigns_spec.lua b/test/gitsigns_spec.lua index b1255300..25ab9c97 100644 --- a/test/gitsigns_spec.lua +++ b/test/gitsigns_spec.lua @@ -95,7 +95,7 @@ describe('gitsigns', function() check { status = {head='', added=18, changed=0, removed=0}, - signs = {untracked=8} + signs = {untracked=nvim_ver == 10 and 7 or 8} } git{"add", test_file}