1

Regenerate nvim config

This commit is contained in:
2024-06-02 03:29:20 +02:00
parent 75eea0c030
commit ef2e28883d
5576 changed files with 604886 additions and 503 deletions

View File

@ -0,0 +1,233 @@
local async = require('gitsigns.async')
local log = require('gitsigns.debug.log')
local gs_config = require('gitsigns.config')
local config = gs_config.config
local api = vim.api
local uv = vim.uv or vim.loop
local M = {}
local cwd_watcher ---@type uv.uv_fs_event_t?
--- @async
local function update_cwd_head()
if not uv.cwd() then
return
end
local paths = vim.fs.find('.git', {
limit = 1,
upward = true,
type = 'directory',
})
if #paths == 0 then
return
end
if cwd_watcher then
cwd_watcher:stop()
else
cwd_watcher = assert(uv.new_fs_event())
end
local cwd = assert(uv.cwd())
--- @type string, string
local gitdir, head
local gs_cache = require('gitsigns.cache')
-- Look in the cache first
for _, bcache in pairs(gs_cache.cache) do
local repo = bcache.git_obj.repo
if repo.toplevel == cwd then
head = repo.abbrev_head
gitdir = repo.gitdir
break
end
end
local git = require('gitsigns.git')
if not head or not gitdir then
local info = git.get_repo_info(cwd)
gitdir = info.gitdir
head = info.abbrev_head
end
async.scheduler()
api.nvim_exec_autocmds('User', {
pattern = 'GitSignsUpdate',
modeline = false,
})
vim.g.gitsigns_head = head
if not gitdir then
return
end
local towatch = gitdir .. '/HEAD'
if cwd_watcher:getpath() == towatch then
-- Already watching
return
end
local debounce_trailing = require('gitsigns.debounce').debounce_trailing
local update_head = debounce_trailing(
100,
async.create(function()
local new_head = git.get_repo_info(cwd).abbrev_head
async.scheduler()
vim.g.gitsigns_head = new_head
end)
)
-- Watch .git/HEAD to detect branch changes
cwd_watcher:start(
towatch,
{},
async.create(function(err)
local __FUNC__ = 'cwd_watcher_cb'
if err then
log.dprintf('Git dir update error: %s', err)
return
end
log.dprint('Git cwd dir update')
update_head()
end)
)
end
local function setup_cli()
api.nvim_create_user_command('Gitsigns', function(params)
require('gitsigns.cli').run(params)
end, {
force = true,
nargs = '*',
range = true,
complete = function(arglead, line)
return require('gitsigns.cli').complete(arglead, line)
end,
})
end
local function setup_debug()
log.debug_mode = config.debug_mode
log.verbose = config._verbose
end
--- @async
local function setup_attach()
if not config.auto_attach then
return
end
async.scheduler()
local attach_autocmd_disabled = false
api.nvim_create_autocmd({ 'BufRead', 'BufNewFile', 'BufWritePost' }, {
group = 'gitsigns',
callback = function(args)
local bufnr = args.buf --[[@as integer]]
if attach_autocmd_disabled then
local __FUNC__ = 'attach_autocmd'
log.dprint('Attaching is disabled')
return
end
require('gitsigns.attach').attach(bufnr, nil, args.event)
end,
})
--- vimpgrep creates and deletes lots of buffers so attaching to each one will
--- waste lots of resource and even slow down vimgrep.
api.nvim_create_autocmd({ 'QuickFixCmdPre', 'QuickFixCmdPost' }, {
group = 'gitsigns',
pattern = '*vimgrep*',
callback = function(args)
attach_autocmd_disabled = args.event == 'QuickFixCmdPre'
end,
})
-- Attach to all open buffers
for _, buf in ipairs(api.nvim_list_bufs()) do
if api.nvim_buf_is_loaded(buf) and api.nvim_buf_get_name(buf) ~= '' then
-- Make sure to run each attach in its on async context in case one of the
-- attaches is aborted.
require('gitsigns.attach').attach(buf, nil, 'setup')
end
end
end
--- @async
local function setup_cwd_head()
async.scheduler()
update_cwd_head()
local debounce = require('gitsigns.debounce').debounce_trailing
local update_cwd_head_debounced = debounce(100, async.create(update_cwd_head))
-- Need to debounce in case some plugin changes the cwd too often
-- (like vim-grepper)
api.nvim_create_autocmd('DirChanged', {
group = 'gitsigns',
callback = function()
update_cwd_head_debounced()
end,
})
end
--- Setup and start Gitsigns.
---
--- Attributes: ~
--- {async}
---
--- @param cfg table|nil Configuration for Gitsigns.
--- See |gitsigns-usage| for more details.
M.setup = async.create(1, function(cfg)
gs_config.build(cfg)
if vim.fn.executable('git') == 0 then
print('gitsigns: git not in path. Aborting setup')
return
end
api.nvim_create_augroup('gitsigns', {})
if vim.fn.has('nvim-0.9') == 0 then
require('gitsigns.git.version').check()
end
setup_debug()
setup_cli()
require('gitsigns.highlight').setup()
setup_attach()
setup_cwd_head()
end)
return setmetatable(M, {
__index = function(_, f)
local attach = require('gitsigns.attach')
if attach[f] then
return attach[f]
end
local actions = require('gitsigns.actions')
if actions[f] then
return actions[f]
end
if config.debug_mode then
local debug = require('gitsigns.debug')
if debug[f] then
return debug[f]
end
end
end,
})

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,196 @@
local M = {}
--- @class Gitsigns.Async_T
--- @field _current Gitsigns.Async_T
local Async_T = {}
-- Handle for an object currently running on the event loop.
-- The coroutine is paused while this is active.
-- Must provide methods cancel() and is_cancelled()
--
-- Handle gets updated on each call to a wrapped functions, so provide access
-- to it via a proxy
-- Coroutine.running() was changed between Lua 5.1 and 5.2:
-- - 5.1: Returns the running coroutine, or nil when called by the main thread.
-- - 5.2: Returns the running coroutine plus a boolean, true when the running
-- coroutine is the main one.
--
-- For LuaJIT, 5.2 behaviour is enabled with LUAJIT_ENABLE_LUA52COMPAT
--
-- We need to handle both.
--- Store all the async threads in a weak table so we don't prevent them from
--- being garbage collected
--- @type table<thread,Gitsigns.Async_T>
local handles = setmetatable({}, { __mode = 'k' })
--- Returns whether the current execution context is async.
function M.running()
local current = coroutine.running()
if current and handles[current] then
return true
end
return false
end
local function is_Async_T(handle)
if
handle
and type(handle) == 'table'
and vim.is_callable(handle.cancel)
and vim.is_callable(handle.is_cancelled)
then
return true
end
end
--- Analogous to uv.close
--- @param cb function
function Async_T:cancel(cb)
-- Cancel anything running on the event loop
if self._current and not self._current:is_cancelled() then
self._current:cancel(cb)
end
end
--- @param co thread
--- @return Gitsigns.Async_T
function Async_T.new(co)
local handle = setmetatable({}, { __index = Async_T })
handles[co] = handle
return handle
end
--- Analogous to uv.is_closing
--- @return boolean
function Async_T:is_cancelled()
return self._current and self._current:is_cancelled()
end
--- @param func function
--- @param callback? fun(...: any)
--- @param ... any
--- @return Gitsigns.Async_T
local function run(func, callback, ...)
local co = coroutine.create(func)
local handle = Async_T.new(co)
local function step(...)
local ret = { coroutine.resume(co, ...) }
local stat = ret[1]
if not stat then
local err = ret[2] --[[@as string]]
error(
string.format('The coroutine failed with this message: %s\n%s', err, debug.traceback(co))
)
end
if coroutine.status(co) == 'dead' then
if callback then
callback(unpack(ret, 2, table.maxn(ret)))
end
return
end
--- @type integer, fun(...: any): any
local nargs, fn = ret[2], ret[3]
assert(type(fn) == 'function', 'type error :: expected func')
local args = { select(4, unpack(ret)) }
args[nargs] = step
local r = fn(unpack(args, 1, nargs))
if is_Async_T(r) then
--- @cast r Gitsigns.Async_T
handle._current = r
end
end
step(...)
return handle
end
--- @param argc integer
--- @param func function
--- @param ... any
--- @return any ...
function M.wait(argc, func, ...)
-- Always run the wrapped functions in xpcall and re-raise the error in the
-- coroutine. This makes pcall work as normal.
local function pfunc(...)
local args = { ... } --- @type any[]
local cb = args[argc]
args[argc] = function(...)
cb(true, ...)
end
xpcall(func, function(err)
cb(false, err, debug.traceback())
end, unpack(args, 1, argc))
end
local ret = { coroutine.yield(argc, pfunc, ...) }
local ok = ret[1]
if not ok then
--- @type string, string
local err, traceback = ret[2], ret[3]
error(string.format('Wrapped function failed: %s\n%s', err, traceback))
end
return unpack(ret, 2, table.maxn(ret))
end
--- Creates an async function with a callback style function.
--- @param argc number The number of arguments of func. Must be included.
--- @param func function A callback style function to be converted. The last argument must be the callback.
--- @return function: Returns an async function
function M.wrap(argc, func)
assert(type(func) == 'function')
assert(type(argc) == 'number')
return function(...)
return M.wait(argc, func, ...)
end
end
--- create([argc, ] func)
---
--- Use this to create a function which executes in an async context but
--- called from a non-async context. Inherently this cannot return anything
--- since it is non-blocking
---
--- If argc is not provided, then the created async function cannot be continued
---
--- @generic F: function
--- @param argc_or_func F|integer
--- @param func? F
--- @return F
function M.create(argc_or_func, func)
local argc --- @type integer
if type(argc_or_func) == 'function' then
assert(not func)
func = argc_or_func
elseif type(argc_or_func) == 'number' then
assert(type(func) == 'function')
argc = argc_or_func
end
--- @cast func function
return function(...)
local callback = argc and select(argc + 1, ...) or nil
return run(func, callback, unpack({ ... }, 1, argc))
end
end
--- An async function that when called will yield to the Neovim scheduler to be
--- able to call the API.
M.scheduler = M.wrap(1, vim.schedule)
function M.run(func, ...)
return run(func, nil, ...)
end
return M

View File

@ -0,0 +1,423 @@
local async = require('gitsigns.async')
local git = require('gitsigns.git')
local manager = require('gitsigns.manager')
local log = require('gitsigns.debug.log')
local dprintf = log.dprintf
local dprint = log.dprint
local gs_cache = require('gitsigns.cache')
local cache = gs_cache.cache
local Status = require('gitsigns.status')
local config = require('gitsigns.config').config
local util = require('gitsigns.util')
local throttle_by_id = require('gitsigns.debounce').throttle_by_id
local api = vim.api
local uv = vim.loop
local M = {}
--- @param name string
--- @return string? buffer
--- @return string? commit
local function parse_fugitive_uri(name)
if vim.fn.exists('*FugitiveReal') == 0 then
dprint('Fugitive not installed')
return
end
---@type string
local path = vim.fn.FugitiveReal(name)
---@type string?
local commit = vim.fn.FugitiveParse(name)[1]:match('([^:]+):.*')
if commit == '0' then
-- '0' means the index so clear commit so we attach normally
commit = nil
end
return path, commit
end
--- @param name string
--- @return string buffer
--- @return string? commit
local function parse_gitsigns_uri(name)
-- TODO(lewis6991): Support submodules
--- @type any, any, string?, string?, string
local _, _, root_path, commit, rel_path = name:find([[^gitsigns://(.*)/%.git/(.*):(.*)]])
commit = util.norm_base(commit)
if root_path then
name = root_path .. '/' .. rel_path
end
return name, commit
end
--- @param bufnr integer
--- @return string buffer
--- @return string? commit
--- @return boolean? force_attach
local function get_buf_path(bufnr)
local file = uv.fs_realpath(api.nvim_buf_get_name(bufnr))
or api.nvim_buf_call(bufnr, function()
return vim.fn.expand('%:p')
end)
if vim.startswith(file, 'fugitive://') then
local path, commit = parse_fugitive_uri(file)
dprintf("Fugitive buffer for file '%s' from path '%s'", path, file)
if path then
local realpath = uv.fs_realpath(path)
if realpath then
return realpath, commit, true
end
end
end
if vim.startswith(file, 'gitsigns://') then
local path, commit = parse_gitsigns_uri(file)
dprintf("Gitsigns buffer for file '%s' from path '%s' on commit '%s'", path, file, commit)
local realpath = uv.fs_realpath(path)
if realpath then
return realpath, commit, true
end
end
return file
end
local function on_lines(_, bufnr, _, first, last_orig, last_new, byte_count)
if first == last_orig and last_orig == last_new and byte_count == 0 then
-- on_lines can be called twice for undo events; ignore the second
-- call which indicates no changes.
return
end
return manager.on_lines(bufnr, first, last_orig, last_new)
end
--- @param _ 'reload'
--- @param bufnr integer
local function on_reload(_, bufnr)
local __FUNC__ = 'on_reload'
cache[bufnr]:invalidate()
dprint('Reload')
manager.update_debounced(bufnr)
end
--- @param _ 'detach'
--- @param bufnr integer
local function on_detach(_, bufnr)
api.nvim_clear_autocmds({ group = 'gitsigns', buffer = bufnr })
M.detach(bufnr, true)
end
--- @param bufnr integer
--- @return string?
--- @return string?
local function on_attach_pre(bufnr)
--- @type string?, string?
local gitdir, toplevel
if config._on_attach_pre then
--- @type {gitdir: string?, toplevel: string?}
local res = async.wait(2, config._on_attach_pre, bufnr)
dprintf('ran on_attach_pre with result %s', vim.inspect(res))
if type(res) == 'table' then
if type(res.gitdir) == 'string' then
gitdir = res.gitdir
end
if type(res.toplevel) == 'string' then
toplevel = res.toplevel
end
end
end
return gitdir, toplevel
end
--- @param _bufnr integer
--- @param file string
--- @param encoding string
--- @return Gitsigns.GitObj?
local function try_worktrees(_bufnr, file, encoding)
if not config.worktrees then
return
end
for _, wt in ipairs(config.worktrees) do
local git_obj = git.Obj.new(file, encoding, wt.gitdir, wt.toplevel)
if git_obj and git_obj.object_name then
dprintf('Using worktree %s', vim.inspect(wt))
return git_obj
end
end
end
local setup = util.once(function()
manager.setup()
api.nvim_create_autocmd('OptionSet', {
group = 'gitsigns',
pattern = { 'fileformat', 'bomb', 'eol' },
callback = function()
local buf = vim.api.nvim_get_current_buf()
local bcache = cache[buf]
if not bcache then
return
end
bcache:invalidate(true)
manager.update(buf)
end,
})
require('gitsigns.current_line_blame').setup()
api.nvim_create_autocmd('VimLeavePre', {
group = 'gitsigns',
callback = M.detach_all,
})
end)
--- @class Gitsigns.GitContext
--- @field file string
--- @field toplevel? string
--- @field gitdir? string
--- @field base? string
--- @param bufnr integer
--- @return Gitsigns.GitContext? ctx
--- @return string? err
local function get_buf_context(bufnr)
if api.nvim_buf_line_count(bufnr) > config.max_file_length then
return nil, 'Exceeds max_file_length'
end
local file, commit, force_attach = get_buf_path(bufnr)
if vim.bo[bufnr].buftype ~= '' and not force_attach then
return nil, 'Non-normal buffer'
end
local file_dir = util.dirname(file)
if not file_dir or not util.path_exists(file_dir) then
return nil, 'Not a path'
end
local gitdir, toplevel = on_attach_pre(bufnr)
return {
file = file,
gitdir = gitdir,
toplevel = toplevel,
-- Commit buffers have there base set back one revision with '^'
-- Stage buffers always compare against the common ancestor (':1')
-- :0: index
-- :1: common ancestor
-- :2: target commit (HEAD)
-- :3: commit which is being merged
base = commit and (commit:match('^:[1-3]') and ':1' or commit .. '^') or nil,
}
end
--- Ensure attaches cannot be interleaved for the same buffer.
--- Since attaches are asynchronous we need to make sure an attach isn't
--- performed whilst another one is in progress.
--- @param cbuf integer
--- @param ctx? Gitsigns.GitContext
--- @param aucmd? string
local attach_throttled = throttle_by_id(function(cbuf, ctx, aucmd)
local __FUNC__ = 'attach'
local passed_ctx = ctx ~= nil
setup()
if cache[cbuf] then
dprint('Already attached')
return
end
if aucmd then
dprintf('Attaching (trigger=%s)', aucmd)
else
dprint('Attaching')
end
if not api.nvim_buf_is_loaded(cbuf) then
dprint('Non-loaded buffer')
return
end
if not ctx then
local err
ctx, err = get_buf_context(cbuf)
if err then
dprint(err)
return
end
assert(ctx)
end
local encoding = vim.bo[cbuf].fileencoding
if encoding == '' then
encoding = 'utf-8'
end
local file = ctx.file
if not vim.startswith(file, '/') and ctx.toplevel then
file = ctx.toplevel .. util.path_sep .. file
end
local revision = ctx.base or config.base
local git_obj = git.Obj.new(file, revision, encoding, ctx.gitdir, ctx.toplevel)
if not git_obj and not passed_ctx then
git_obj = try_worktrees(cbuf, file, encoding)
async.scheduler()
if not api.nvim_buf_is_valid(cbuf) then
return
end
end
if not git_obj then
dprint('Empty git obj')
return
end
async.scheduler()
if not api.nvim_buf_is_valid(cbuf) then
return
end
Status:update(cbuf, {
head = git_obj.repo.abbrev_head,
root = git_obj.repo.toplevel,
gitdir = git_obj.repo.gitdir,
})
if not passed_ctx and (not util.path_exists(file) or uv.fs_stat(file).type == 'directory') then
dprint('Not a file')
return
end
if not git_obj.relpath then
dprint('Cannot resolve file in repo')
return
end
if not config.attach_to_untracked and git_obj.object_name == nil then
dprint('File is untracked')
return
end
-- On windows os.tmpname() crashes in callback threads so initialise this
-- variable on the main thread.
async.scheduler()
if not api.nvim_buf_is_valid(cbuf) then
return
end
if config.on_attach and config.on_attach(cbuf) == false then
dprint('User on_attach() returned false')
return
end
cache[cbuf] = gs_cache.new({
bufnr = cbuf,
file = file,
git_obj = git_obj,
})
if config.watch_gitdir.enable then
local watcher = require('gitsigns.watcher')
cache[cbuf].gitdir_watcher = watcher.watch_gitdir(cbuf, git_obj.repo.gitdir)
end
if not api.nvim_buf_is_loaded(cbuf) then
dprint('Un-loaded buffer')
return
end
-- Make sure to attach before the first update (which is async) so we pick up
-- changes from BufReadCmd.
api.nvim_buf_attach(cbuf, false, {
on_lines = on_lines,
on_reload = on_reload,
on_detach = on_detach,
})
api.nvim_create_autocmd('BufWrite', {
group = 'gitsigns',
buffer = cbuf,
callback = function()
manager.update_debounced(cbuf)
end,
})
-- Initial update
manager.update(cbuf)
end)
--- Detach Gitsigns from all buffers it is attached to.
function M.detach_all()
for k, _ in pairs(cache) do
M.detach(k)
end
end
--- Detach Gitsigns from the buffer {bufnr}. If {bufnr} is not
--- provided then the current buffer is used.
---
--- @param bufnr integer Buffer number
--- @param _keep_signs? boolean
function M.detach(bufnr, _keep_signs)
-- When this is called interactively (with no arguments) we want to remove all
-- the signs, however if called via a detach event (due to nvim_buf_attach)
-- then we don't want to clear the signs in case the buffer is just being
-- updated due to the file externally changing. When this happens a detach and
-- attach event happen in sequence and so we keep the old signs to stop the
-- sign column width moving about between updates.
bufnr = bufnr or api.nvim_get_current_buf()
dprint('Detached')
local bcache = cache[bufnr]
if not bcache then
dprint('Cache was nil')
return
end
manager.detach(bufnr, _keep_signs)
-- Clear status variables
Status:clear(bufnr)
gs_cache.destroy(bufnr)
end
--- Attach Gitsigns to the buffer.
---
--- Attributes: ~
--- {async}
---
--- @param bufnr integer Buffer number
--- @param ctx Gitsigns.GitContext|nil
--- Git context data that may optionally be used to attach to any
--- buffer that represents a real git object.
--- • {file}: (string)
--- Path to the file represented by the buffer, relative to the
--- top-level.
--- • {toplevel}: (string?)
--- Path to the top-level of the parent git repository.
--- • {gitdir}: (string?)
--- Path to the git directory of the parent git repository
--- (typically the ".git/" directory).
--- • {commit}: (string?)
--- The git revision that the file belongs to.
--- • {base}: (string?)
--- The git revision that the file should be compared to.
--- @param _trigger? string
M.attach = async.create(3, function(bufnr, ctx, _trigger)
attach_throttled(bufnr or api.nvim_get_current_buf(), ctx, _trigger)
end)
return M

View File

@ -0,0 +1,151 @@
local async = require('gitsigns.async')
local util = require('gitsigns.util')
local M = {
CacheEntry = {},
}
--- @class (exact) Gitsigns.CacheEntry
--- @field bufnr integer
--- @field file string
--- @field compare_text? string[]
--- @field hunks? Gitsigns.Hunk.Hunk[]
--- @field force_next_update? boolean
--- @field file_mode? boolean
---
--- @field compare_text_head? string[]
--- @field hunks_staged? Gitsigns.Hunk.Hunk[]
---
--- @field staged_diffs? Gitsigns.Hunk.Hunk[]
--- @field gitdir_watcher? uv.uv_fs_event_t
--- @field git_obj Gitsigns.GitObj
--- @field blame? table<integer,Gitsigns.BlameInfo?>
local CacheEntry = M.CacheEntry
function CacheEntry:get_rev_bufname(rev)
rev = rev or self.git_obj.revision or ':0'
return string.format('gitsigns://%s/%s:%s', self.git_obj.repo.gitdir, rev, self.git_obj.relpath)
end
--- 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 M.new(o)
o.staged_diffs = o.staged_diffs or {}
return setmetatable(o, { __index = CacheEntry })
end
local sleep = async.wrap(2, function(duration, cb)
vim.defer_fn(cb, duration)
end)
--- @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
-- If a file contains has up to this amount of lines, then
-- always blame the whole file, otherwise only blame one line
-- at a time.
local BLAME_THRESHOLD_LEN = 1000000
--- @private
--- @param lnum integer
--- @param opts Gitsigns.BlameOpts
--- @return table<integer,Gitsigns.BlameInfo?>?
function CacheEntry:run_blame(lnum, opts)
local bufnr = self.bufnr
local blame_cache --- @type table<integer,Gitsigns.BlameInfo?>?
repeat
local buftext = util.buf_lines(bufnr)
local tick = vim.b[bufnr].changedtick
local lnum0 = #buftext > BLAME_THRESHOLD_LEN and lnum or nil
-- TODO(lewis6991): Cancel blame on changedtick
blame_cache = self.git_obj:run_blame(buftext, lnum0, opts)
async.scheduler()
if not vim.api.nvim_buf_is_valid(bufnr) then
return
end
until vim.b[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.BlameOpts
--- @return Gitsigns.BlameInfo?
function CacheEntry:get_blame(lnum, opts)
if opts.rev then
local buftext = util.buf_lines(self.bufnr)
return self.git_obj:run_blame(buftext, lnum, opts)[lnum]
end
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(lnum, 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
w:close()
end
end
---@type table<integer,Gitsigns.CacheEntry>
M.cache = {}
--- @param bufnr integer
function M.destroy(bufnr)
M.cache[bufnr]:destroy()
M.cache[bufnr] = nil
end
return M

View File

@ -0,0 +1,108 @@
local async = require('gitsigns.async')
local log = require('gitsigns.debug.log')
local dprintf = log.dprintf
local message = require('gitsigns.message')
local parse_args = require('gitsigns.cli.argparse').parse_args
local actions = require('gitsigns.actions')
local attach = require('gitsigns.attach')
local gs_debug = require('gitsigns.debug')
--- @type table<table<string,function>,boolean>
local sources = {
[actions] = true,
[attach] = false,
[gs_debug] = false,
}
-- try to parse each argument as a lua boolean, nil or number, if fails then
-- keep argument as a string:
--
-- 'false' -> false
-- 'nil' -> nil
-- '100' -> 100
-- 'HEAD~300' -> 'HEAD~300'
local function parse_to_lua(a)
if tonumber(a) then
return tonumber(a)
elseif a == 'false' or a == 'true' then
return a == 'true'
elseif a == 'nil' then
return nil
end
return a
end
local M = {}
function M.complete(arglead, line)
local words = vim.split(line, '%s+')
local n = #words
local matches = {}
if n == 2 then
for m, _ in pairs(sources) do
for func, _ in pairs(m) do
if not func:match('^[a-z]') then
-- exclude
elseif vim.startswith(func, arglead) then
table.insert(matches, func)
end
end
end
elseif n > 2 then
-- Subcommand completion
local cmp_func = actions._get_cmp_func(words[2])
if cmp_func then
return cmp_func(arglead)
end
end
return matches
end
M.run = async.create(1, function(params)
local __FUNC__ = 'cli.run'
local pos_args_raw, named_args_raw = parse_args(params.args)
local func = pos_args_raw[1]
if not func then
func = async.wait(3, vim.ui.select, M.complete('', 'Gitsigns '), {}) --[[@as string]]
if not func then
return
end
end
local pos_args = vim.tbl_map(parse_to_lua, vim.list_slice(pos_args_raw, 2))
local named_args = vim.tbl_map(parse_to_lua, named_args_raw)
local args = vim.tbl_extend('error', pos_args, named_args)
dprintf(
"Running action '%s' with arguments %s",
func,
vim.inspect(args, { newline = ' ', indent = '' })
)
local cmd_func = actions._get_cmd_func(func)
if cmd_func then
-- Action has a specialised mapping function from command form to lua
-- function
cmd_func(args, params)
return
end
for m, has_named in pairs(sources) do
local f = m[func]
if type(f) == 'function' then
-- Note functions here do not have named arguments
f(unpack(pos_args), has_named and named_args or nil)
return
end
end
message.error('%s is not a valid function or action', func)
end)
return M

View File

@ -0,0 +1,108 @@
local M = {}
local function is_char(x)
return x:match('[^=\'"%s]') ~= nil
end
-- Return positional arguments and named arguments
--- @param x string
--- @return string[], table<string,string|boolean>
function M.parse_args(x)
--- @type string[], table<string,string|boolean>
local pos_args, named_args = {}, {}
local state = 'in_arg'
local cur_arg = ''
local cur_val = ''
local cur_quote = ''
local function peek(idx)
return x:sub(idx + 1, idx + 1)
end
local i = 1
while i <= #x do
local ch = x:sub(i, i)
-- dprintf('L(%d)(%s): cur_arg="%s" ch="%s"', i, state, cur_arg, ch)
if state == 'in_arg' then
if is_char(ch) then
if ch == '-' and peek(i) == '-' then
state = 'in_flag'
cur_arg = ''
i = i + 1
else
cur_arg = cur_arg .. ch
end
elseif ch:match('%s') then
pos_args[#pos_args + 1] = cur_arg
state = 'in_ws'
elseif ch == '=' then
cur_val = ''
local next_ch = peek(i)
if next_ch == "'" or next_ch == '"' then
cur_quote = next_ch
i = i + 1
state = 'in_quote'
else
state = 'in_value'
end
end
elseif state == 'in_flag' then
if ch:match('%s') then
named_args[cur_arg] = true
state = 'in_ws'
else
cur_arg = cur_arg .. ch
end
elseif state == 'in_ws' then
if is_char(ch) then
if ch == '-' and peek(i) == '-' then
state = 'in_flag'
cur_arg = ''
i = i + 1
else
state = 'in_arg'
cur_arg = ch
end
end
elseif state == 'in_value' then
if is_char(ch) then
cur_val = cur_val .. ch
elseif ch:match('%s') then
named_args[cur_arg] = cur_val
cur_arg = ''
state = 'in_ws'
end
elseif state == 'in_quote' then
local next_ch = peek(i)
if ch == '\\' and next_ch == cur_quote then
cur_val = cur_val .. next_ch
i = i + 1
elseif ch == cur_quote then
named_args[cur_arg] = cur_val
state = 'in_ws'
if next_ch ~= '' and not next_ch:match('%s') then
error('malformed argument: ' .. next_ch)
end
else
cur_val = cur_val .. ch
end
end
i = i + 1
end
if #cur_arg > 0 then
if state == 'in_arg' then
pos_args[#pos_args + 1] = cur_arg
elseif state == 'in_flag' then
named_args[cur_arg] = true
elseif state == 'in_value' then
named_args[cur_arg] = cur_val
end
end
return pos_args, named_args
end
return M

View File

@ -0,0 +1,952 @@
--- @class (exact) Gitsigns.SchemaElem.Deprecated
---
--- Used for renaming fields.
--- @field new_field? string
---
--- Documentation for deprecation. Will be added to the help file and used in
--- the notification if `hard = true`.
--- @field message? string
---
--- Emit a message via vim.notify
--- @field hard? boolean
--- @class (exact) Gitsigns.SchemaElem
--- @field type string|string[]
--- @field refresh? fun(cb: fun()) Function to refresh the config value
--- @field deep_extend? boolean
--- @field default any
--- @field deprecated? boolean|Gitsigns.SchemaElem.Deprecated
--- @field default_help? string
--- @field description string
--- @class (exact) Gitsigns.DiffOpts
--- @field algorithm string
--- @field internal boolean
--- @field indent_heuristic boolean
--- @field vertical boolean
--- @field linematch? integer
--- @field ignore_whitespace_change? true
--- @field ignore_whitespace? true
--- @field ignore_whitespace_change_at_eol? true
--- @field ignore_blank_lines? true
--- @class (exact) Gitsigns.SignConfig
--- @field show_count boolean
--- @field hl string
--- @field text string
--- @field numhl string
--- @field linehl string
--- @alias Gitsigns.SignType
--- | 'add'
--- | 'change'
--- | 'delete'
--- | 'topdelete'
--- | 'changedelete'
--- | 'untracked'
--- @class (exact) Gitsigns.CurrentLineBlameFmtOpts
--- @field relative_time boolean
--- @alias Gitsigns.CurrentLineBlameFmtFun fun(user: string, info: table<string,any>, opts: Gitsigns.CurrentLineBlameFmtOpts): {[1]:string,[2]:string}[]
--- @class (exact) Gitsigns.CurrentLineBlameOpts : Gitsigns.BlameOpts
--- @field virt_text? boolean
--- @field virt_text_pos? 'eol'|'overlay'|'right_align'
--- @field delay? integer
--- @field virt_text_priority? integer
--- @class (exact) Gitsigns.BlameOpts
--- @field ignore_whitespace? boolean
--- @field rev? string
--- @field extra_opts? string[]
--- @class (exact) Gitsigns.LineBlameOpts : Gitsigns.BlameOpts
--- @field full? boolean
--- @class (exact) Gitsigns.Config
--- @field debug_mode boolean
--- @field diff_opts Gitsigns.DiffOpts
--- @field base? string
--- @field signs table<Gitsigns.SignType,Gitsigns.SignConfig>
--- @field _signs_staged table<Gitsigns.SignType,Gitsigns.SignConfig>
--- @field _signs_staged_enable boolean
--- @field count_chars table<string|integer,string>
--- @field signcolumn boolean
--- @field numhl boolean
--- @field linehl boolean
--- @field show_deleted boolean
--- @field sign_priority integer
--- @field _on_attach_pre fun(bufnr: integer, callback: fun(_: table))
--- @field on_attach fun(bufnr: integer)
--- @field watch_gitdir { enable: boolean, follow_files: boolean }
--- @field max_file_length integer
--- @field update_debounce integer
--- @field status_formatter fun(_: table<string,any>): string
--- @field current_line_blame boolean
--- @field current_line_blame_formatter_opts { relative_time: boolean }
--- @field current_line_blame_formatter string|Gitsigns.CurrentLineBlameFmtFun
--- @field current_line_blame_formatter_nc string|Gitsigns.CurrentLineBlameFmtFun
--- @field current_line_blame_opts Gitsigns.CurrentLineBlameOpts
--- @field preview_config table<string,any>
--- @field auto_attach boolean
--- @field attach_to_untracked boolean
--- @field yadm { enable: boolean }
--- @field worktrees {toplevel: string, gitdir: string}[]
--- @field word_diff boolean
--- @field trouble boolean
--- -- Undocumented
--- @field _refresh_staged_on_update boolean
--- @field _threaded_diff boolean
--- @field _inline2 boolean
--- @field _git_version string
--- @field _verbose boolean
--- @field _test_mode boolean
local M = {
Config = {
DiffOpts = {},
SignConfig = {},
watch_gitdir = {},
current_line_blame_formatter_opts = {},
current_line_blame_opts = {},
yadm = {},
Worktree = {},
},
}
--- @param v Gitsigns.SchemaElem
--- @return any
local function resolve_default(v)
if type(v.default) == 'function' and v.type ~= 'function' then
return v.default()
else
return v.default
end
end
--- @return Gitsigns.DiffOpts
local function parse_diffopt()
--- @type Gitsigns.DiffOpts
local r = {
algorithm = 'myers',
internal = false,
indent_heuristic = false,
vertical = true,
}
local optmap = {
['indent-heuristic'] = 'indent_heuristic',
internal = 'internal',
iwhite = 'ignore_whitespace_change',
iblank = 'ignore_blank_lines',
iwhiteeol = 'ignore_whitespace_change_at_eol',
iwhiteall = 'ignore_whitespace',
}
local diffopt = vim.opt.diffopt:get() --[[@as string[] ]]
for _, o in ipairs(diffopt) do
if optmap[o] then
r[optmap[o]] = true
elseif o == 'horizontal' then
r.vertical = false
elseif vim.startswith(o, 'algorithm:') then
r.algorithm = string.sub(o, ('algorithm:'):len() + 1)
elseif vim.startswith(o, 'linematch:') then
r.linematch = tonumber(string.sub(o, ('linematch:'):len() + 1))
end
end
return r
end
--- @type Gitsigns.Config
M.config = setmetatable({}, {
__index = function(t, k)
if rawget(t, k) == nil then
local field = M.schema[k]
if not field then
return
end
rawset(t, k, resolve_default(field))
if field.refresh then
field.refresh(function()
rawset(t, k, resolve_default(field))
end)
end
end
return rawget(t, k)
end,
})
--- @type table<string,Gitsigns.SchemaElem>
M.schema = {
signs = {
type = 'table',
deep_extend = true,
default = {
add = { hl = 'GitSignsAdd', text = '', numhl = 'GitSignsAddNr', linehl = 'GitSignsAddLn' },
change = {
hl = 'GitSignsChange',
text = '',
numhl = 'GitSignsChangeNr',
linehl = 'GitSignsChangeLn',
},
delete = {
hl = 'GitSignsDelete',
text = '',
numhl = 'GitSignsDeleteNr',
linehl = 'GitSignsDeleteLn',
},
topdelete = {
hl = 'GitSignsTopdelete',
text = '',
numhl = 'GitSignsTopdeleteNr',
linehl = 'GitSignsTopdeleteLn',
},
changedelete = {
hl = 'GitSignsChangedelete',
text = '~',
numhl = 'GitSignsChangedeleteNr',
linehl = 'GitSignsChangedeleteLn',
},
untracked = {
hl = 'GitSignsUntracked',
text = '',
numhl = 'GitSignsUntrackedNr',
linehl = 'GitSignsUntrackedLn',
},
},
default_help = [[{
add = { text = '┃' },
change = { text = '┃' },
delete = { text = '▁' },
topdelete = { text = '▔' },
changedelete = { text = '~' },
untracked = { text = '┆' },
}]],
description = [[
Configuration for signs:
• `text` specifies the character to use for the sign.
• `show_count` to enable showing count of hunk, e.g. number of deleted
lines.
The highlights `GitSigns[kind][type]` is used for each kind of sign. E.g.
'add' signs uses the highlights:
• `GitSignsAdd` (for normal text signs)
• `GitSignsAddNr` (for signs when `config.numhl == true`)
• `GitSignsAddLn `(for signs when `config.linehl == true`)
See |gitsigns-highlight-groups|.
]],
},
_signs_staged = {
type = 'table',
deep_extend = true,
default = {
add = {
hl = 'GitSignsStagedAdd',
text = '',
numhl = 'GitSignsStagedAddNr',
linehl = 'GitSignsStagedAddLn',
},
change = {
hl = 'GitSignsStagedChange',
text = '',
numhl = 'GitSignsStagedChangeNr',
linehl = 'GitSignsStagedChangeLn',
},
delete = {
hl = 'GitSignsStagedDelete',
text = '',
numhl = 'GitSignsStagedDeleteNr',
linehl = 'GitSignsStagedDeleteLn',
},
topdelete = {
hl = 'GitSignsStagedTopdelete',
text = '',
numhl = 'GitSignsStagedTopdeleteNr',
linehl = 'GitSignsStagedTopdeleteLn',
},
changedelete = {
hl = 'GitSignsStagedChangedelete',
text = '~',
numhl = 'GitSignsStagedChangedeleteNr',
linehl = 'GitSignsStagedChangedeleteLn',
},
},
default_help = [[{
add = { text = '┃' },
change = { text = '┃' },
delete = { text = '▁' },
topdelete = { text = '▔' },
changedelete = { text = '~' },
}]],
description = [[
Configuration for signs of staged hunks.
See |gitsigns-config-signs|.
]],
},
_signs_staged_enable = {
type = 'boolean',
default = false,
description = [[
Show signs for staged hunks.
When enabled the signs defined in |git-config-signs_staged|` are used.
]],
},
worktrees = {
type = 'table',
default = nil,
description = [[
Detached working trees.
Array of tables with the keys `gitdir` and `toplevel`.
If normal attaching fails, then each entry in the table is attempted
with the work tree details set.
Example: >lua
worktrees = {
{
toplevel = vim.env.HOME,
gitdir = vim.env.HOME .. '/projects/dotfiles/.git'
}
}
]],
},
_on_attach_pre = {
type = 'function',
default = nil,
description = [[
Asynchronous hook called before attaching to a buffer. Mainly used to
configure detached worktrees.
This callback must call its callback argument. The callback argument can
accept an optional table argument with the keys: 'gitdir' and 'toplevel'.
Example: >lua
on_attach_pre = function(bufnr, callback)
...
callback {
gitdir = ...,
toplevel = ...
}
end
<
]],
},
on_attach = {
type = 'function',
default = nil,
description = [[
Callback called when attaching to a buffer. Mainly used to setup keymaps.
The buffer number is passed as the first argument.
This callback can return `false` to prevent attaching to the buffer.
Example: >lua
on_attach = function(bufnr)
if vim.api.nvim_buf_get_name(bufnr):match(<PATTERN>) then
-- Don't attach to specific buffers whose name matches a pattern
return false
end
-- Setup keymaps
vim.api.nvim_buf_set_keymap(bufnr, 'n', 'hs', '<cmd>lua require"gitsigns".stage_hunk()<CR>', {})
... -- More keymaps
end
<
]],
},
watch_gitdir = {
type = 'table',
deep_extend = true,
default = {
enable = true,
follow_files = true,
},
description = [[
When opening a file, a libuv watcher is placed on the respective
`.git` directory to detect when changes happen to use as a trigger to
update signs.
Fields: ~
• `enable`:
Whether the watcher is enabled.
• `follow_files`:
If a file is moved with `git mv`, switch the buffer to the new location.
]],
},
sign_priority = {
type = 'number',
default = 6,
description = [[
Priority to use for signs.
]],
},
signcolumn = {
type = 'boolean',
default = true,
description = [[
Enable/disable symbols in the sign column.
When enabled the highlights defined in `signs.*.hl` and symbols defined
in `signs.*.text` are used.
]],
},
numhl = {
type = 'boolean',
default = false,
description = [[
Enable/disable line number highlights.
When enabled the highlights defined in `signs.*.numhl` are used. If
the highlight group does not exist, then it is automatically defined
and linked to the corresponding highlight group in `signs.*.hl`.
]],
},
linehl = {
type = 'boolean',
default = false,
description = [[
Enable/disable line highlights.
When enabled the highlights defined in `signs.*.linehl` are used. If
the highlight group does not exist, then it is automatically defined
and linked to the corresponding highlight group in `signs.*.hl`.
]],
},
show_deleted = {
type = 'boolean',
default = false,
description = [[
Show the old version of hunks inline in the buffer (via virtual lines).
Note: Virtual lines currently use the highlight `GitSignsDeleteVirtLn`.
]],
},
diff_opts = {
type = 'table',
deep_extend = true,
refresh = function(callback)
vim.api.nvim_create_autocmd('OptionSet', {
group = vim.api.nvim_create_augroup('gitsigns.config.diff_opts', {}),
pattern = 'diffopt',
callback = callback,
})
end,
default = parse_diffopt,
default_help = "derived from 'diffopt'",
description = [[
Diff options. If the default value is used, then changes to 'diffopt' are
automatically applied.
Fields: ~
• algorithm: string
Diff algorithm to use. Values:
• "myers" the default algorithm
• "minimal" spend extra time to generate the
smallest possible diff
• "patience" patience diff algorithm
• "histogram" histogram diff algorithm
• internal: boolean
Use Neovim's built in xdiff library for running diffs.
• indent_heuristic: boolean
Use the indent heuristic for the internal
diff library.
• vertical: boolean
Start diff mode with vertical splits.
• linematch: integer
Enable second-stage diff on hunks to align lines.
Requires `internal=true`.
• ignore_blank_lines: boolean
Ignore changes where lines are blank.
• ignore_whitespace_change: boolean
Ignore changes in amount of white space.
It should ignore adding trailing white space,
but not leading white space.
• ignore_whitespace: boolean
Ignore all white space changes.
• ignore_whitespace_change_at_eol: boolean
Ignore white space changes at end of line.
]],
},
base = {
type = 'string',
default = nil,
default_help = 'index',
description = [[
The object/revision to diff against.
See |gitsigns-revision|.
]],
},
count_chars = {
type = 'table',
default = {
[1] = '1', -- '₁',
[2] = '2', -- '₂',
[3] = '3', -- '₃',
[4] = '4', -- '₄',
[5] = '5', -- '₅',
[6] = '6', -- '₆',
[7] = '7', -- '₇',
[8] = '8', -- '₈',
[9] = '9', -- '₉',
['+'] = '>', -- '₊',
},
description = [[
The count characters used when `signs.*.show_count` is enabled. The
`+` entry is used as a fallback. With the default, any count outside
of 1-9 uses the `>` character in the sign.
Possible use cases for this field:
• to specify unicode characters for the counts instead of 1-9.
• to define characters to be used for counts greater than 9.
]],
},
status_formatter = {
type = 'function',
--- @param status Gitsigns.StatusObj
--- @return string
default = function(status)
local added, changed, removed = status.added, status.changed, status.removed
local status_txt = {}
if added and added > 0 then
table.insert(status_txt, '+' .. added)
end
if changed and changed > 0 then
table.insert(status_txt, '~' .. changed)
end
if removed and removed > 0 then
table.insert(status_txt, '-' .. removed)
end
return table.concat(status_txt, ' ')
end,
default_help = [[function(status)
local added, changed, removed = status.added, status.changed, status.removed
local status_txt = {}
if added and added > 0 then table.insert(status_txt, '+'..added ) end
if changed and changed > 0 then table.insert(status_txt, '~'..changed) end
if removed and removed > 0 then table.insert(status_txt, '-'..removed) end
return table.concat(status_txt, ' ')
end]],
description = [[
Function used to format `b:gitsigns_status`.
]],
},
max_file_length = {
type = 'number',
default = 40000,
description = [[
Max file length (in lines) to attach to.
]],
},
preview_config = {
type = 'table',
deep_extend = true,
default = {
border = 'single',
style = 'minimal',
relative = 'cursor',
row = 0,
col = 1,
},
description = [[
Option overrides for the Gitsigns preview window. Table is passed directly
to `nvim_open_win`.
]],
},
auto_attach = {
type = 'boolean',
default = true,
description = [[
Automatically attach to files.
]],
},
attach_to_untracked = {
type = 'boolean',
default = false,
description = [[
Attach to untracked files.
]],
},
update_debounce = {
type = 'number',
default = 100,
description = [[
Debounce time for updates (in milliseconds).
]],
},
current_line_blame = {
type = 'boolean',
default = false,
description = [[
Adds an unobtrusive and customisable blame annotation at the end of
the current line.
The highlight group used for the text is `GitSignsCurrentLineBlame`.
]],
},
current_line_blame_opts = {
type = 'table',
deep_extend = true,
default = {
virt_text = true,
virt_text_pos = 'eol',
virt_text_priority = 100,
delay = 1000,
},
description = [[
Options for the current line blame annotation.
Fields: ~
• virt_text: boolean
Whether to show a virtual text blame annotation.
• virt_text_pos: string
Blame annotation position. Available values:
`eol` Right after eol character.
`overlay` Display over the specified column, without
shifting the underlying text.
`right_align` Display right aligned in the window.
• delay: integer
Sets the delay (in milliseconds) before blame virtual text is
displayed.
• ignore_whitespace: boolean
Ignore whitespace when running blame.
• virt_text_priority: integer
Priority of virtual text.
• extra_opts: string[]
Extra options passed to `git-blame`.
]],
},
current_line_blame_formatter_opts = {
type = 'table',
deep_extend = true,
deprecated = true,
default = {
relative_time = false,
},
description = [[
Options for the current line blame annotation formatter.
Fields: ~
• relative_time: boolean
]],
},
current_line_blame_formatter = {
type = { 'string', 'function' },
default = ' <author>, <author_time> - <summary> ',
description = [[
String or function used to format the virtual text of
|gitsigns-config-current_line_blame|.
When a string, accepts the following format specifiers:
• `<abbrev_sha>`
• `<orig_lnum>`
• `<final_lnum>`
• `<author>`
• `<author_mail>`
• `<author_time>` or `<author_time:FORMAT>`
• `<author_tz>`
• `<committer>`
• `<committer_mail>`
• `<committer_time>` or `<committer_time:FORMAT>`
• `<committer_tz>`
• `<summary>`
• `<previous>`
• `<filename>`
For `<author_time:FORMAT>` and `<committer_time:FORMAT>`, `FORMAT` can
be any valid date format that is accepted by `os.date()` with the
addition of `%R` (defaults to `%Y-%m-%d`):
• `%a` abbreviated weekday name (e.g., Wed)
• `%A` full weekday name (e.g., Wednesday)
• `%b` abbreviated month name (e.g., Sep)
• `%B` full month name (e.g., September)
• `%c` date and time (e.g., 09/16/98 23:48:10)
• `%d` day of the month (16) [01-31]
• `%H` hour, using a 24-hour clock (23) [00-23]
• `%I` hour, using a 12-hour clock (11) [01-12]
• `%M` minute (48) [00-59]
• `%m` month (09) [01-12]
• `%p` either "am" or "pm" (pm)
• `%S` second (10) [00-61]
• `%w` weekday (3) [0-6 = Sunday-Saturday]
• `%x` date (e.g., 09/16/98)
• `%X` time (e.g., 23:48:10)
• `%Y` full year (1998)
• `%y` two-digit year (98) [00-99]
• `%%` the character `%´
• `%R` relative (e.g., 4 months ago)
When a function:
Parameters: ~
{name} Git user name returned from `git config user.name` .
{blame_info} Table with the following keys:
• `abbrev_sha`: string
• `orig_lnum`: integer
• `final_lnum`: integer
• `author`: string
• `author_mail`: string
• `author_time`: integer
• `author_tz`: string
• `committer`: string
• `committer_mail`: string
• `committer_time`: integer
• `committer_tz`: string
• `summary`: string
• `previous`: string
• `filename`: string
• `boundary`: true?
Note that the keys map onto the output of:
`git blame --line-porcelain`
{opts} Passed directly from
|gitsigns-config-current_line_blame_formatter_opts|.
Return: ~
The result of this function is passed directly to the `opts.virt_text`
field of |nvim_buf_set_extmark| and thus must be a list of
[text, highlight] tuples.
]],
},
current_line_blame_formatter_nc = {
type = { 'string', 'function' },
default = ' <author>',
description = [[
String or function used to format the virtual text of
|gitsigns-config-current_line_blame| for lines that aren't committed.
See |gitsigns-config-current_line_blame_formatter| for more information.
]],
},
trouble = {
type = 'boolean',
default = function()
local has_trouble = pcall(require, 'trouble')
return has_trouble
end,
default_help = 'true if installed',
description = [[
When using setqflist() or setloclist(), open Trouble instead of the
quickfix/location list window.
]],
},
yadm = {
type = 'table',
deprecated = {
message = 'Please use |gitsigns-config-on_attach_pre| instead',
},
default = { enable = false },
description = [[
yadm configuration.
]],
},
_git_version = {
type = 'string',
default = 'auto',
description = [[
Version of git available. Set to 'auto' to automatically detect.
]],
},
_verbose = {
type = 'boolean',
default = false,
description = [[
More verbose debug message. Requires debug_mode=true.
]],
},
_test_mode = {
description = 'Enable test mode',
type = 'boolean',
default = false,
},
word_diff = {
type = 'boolean',
default = false,
description = [[
Highlight intra-line word differences in the buffer.
Requires `config.diff_opts.internal = true` .
Uses the highlights:
• For word diff in previews:
• `GitSignsAddInline`
• `GitSignsChangeInline`
• `GitSignsDeleteInline`
• For word diff in buffer:
• `GitSignsAddLnInline`
• `GitSignsChangeLnInline`
• `GitSignsDeleteLnInline`
• For word diff in virtual lines (e.g. show_deleted):
• `GitSignsAddVirtLnInline`
• `GitSignsChangeVirtLnInline`
• `GitSignsDeleteVirtLnInline`
]],
},
_refresh_staged_on_update = {
type = 'boolean',
default = false,
description = [[
Always refresh the staged file on each update. Disabling this will cause
the staged file to only be refreshed when an update to the index is
detected.
]],
},
_threaded_diff = {
type = 'boolean',
default = true,
description = [[
Run diffs on a separate thread
]],
},
_inline2 = {
type = 'boolean',
default = true,
description = [[
Enable enhanced version of preview_hunk_inline()
]],
},
debug_mode = {
type = 'boolean',
default = false,
description = [[
Enables debug logging and makes the following functions
available: `dump_cache`, `debug_messages`, `clear_debug`.
]],
},
}
local function warn(s, ...)
vim.notify(s:format(...), vim.log.levels.WARN, { title = 'gitsigns' })
end
--- @param config Gitsigns.Config
local function validate_config(config)
--- @diagnostic disable-next-line:no-unknown
for k, v in pairs(config) do
local kschema = M.schema[k]
if kschema == nil then
warn("gitsigns: Ignoring invalid configuration field '%s'", k)
elseif kschema.type then
if type(kschema.type) == 'string' then
vim.validate({
[k] = { v, kschema.type },
})
end
end
end
end
--- @param cfg table<any, any>
local function handle_deprecated(cfg)
for k, v in pairs(M.schema) do
local dep = v.deprecated
if dep and cfg[k] ~= nil then
if type(dep) == 'table' then
if dep.new_field then
local opts_key, field = dep.new_field:match('(.*)%.(.*)')
if opts_key and field then
-- Field moved to an options table
local opts = (cfg[opts_key] or {}) --[[@as table<any,any>]]
opts[field] = cfg[k]
cfg[opts_key] = opts
else
-- Field renamed
cfg[dep.new_field] = cfg[k]
end
end
if dep.hard then
if dep.message then
warn(dep.message)
elseif dep.new_field then
warn('%s is now deprecated, please use %s', k, dep.new_field)
else
warn('%s is now deprecated; ignoring', k)
end
end
end
end
end
end
--- @param k string
--- @param v Gitsigns.SchemaElem
--- @param user_val any
local function build_field(k, v, user_val)
local config = M.config --[[@as table<string,any>]]
if v.deep_extend then
local d = resolve_default(v)
config[k] = vim.tbl_deep_extend('force', d, user_val)
else
config[k] = user_val
end
end
--- @param user_config Gitsigns.Config|nil
function M.build(user_config)
user_config = user_config or {}
handle_deprecated(user_config)
validate_config(user_config)
for k, v in pairs(M.schema) do
if user_config[k] ~= nil then
build_field(k, v, user_config[k])
if v.refresh then
v.refresh(function()
build_field(k, v, user_config[k])
end)
end
end
end
end
return M

View File

@ -0,0 +1,242 @@
local async = require('gitsigns.async')
local cache = require('gitsigns.cache').cache
local config = require('gitsigns.config').config
local util = require('gitsigns.util')
local api = vim.api
local debounce = require('gitsigns.debounce')
local namespace = api.nvim_create_namespace('gitsigns_blame')
local M = {}
--- @param bufnr integer
local function reset(bufnr)
if not api.nvim_buf_is_valid(bufnr) then
return
end
api.nvim_buf_del_extmark(bufnr, namespace, 1)
vim.b[bufnr].gitsigns_blame_line_dict = nil
end
--- @param fmt string
--- @param name string
--- @param info Gitsigns.BlameInfoPublic
--- @return string
local function expand_blame_format(fmt, name, info)
if info.author == name then
info.author = 'You'
end
return util.expand_format(fmt, info, config.current_line_blame_formatter_opts.relative_time)
end
--- @param virt_text {[1]: string, [2]: string}[]
--- @return string
local function flatten_virt_text(virt_text)
local res = {} ---@type string[]
for _, part in ipairs(virt_text) do
res[#res + 1] = part[1]
end
return table.concat(res)
end
--- @param winid integer
--- @return integer
local function win_width()
local winid = api.nvim_get_current_win()
local wininfo = vim.fn.getwininfo(winid)[1]
local textoff = wininfo and wininfo.textoff or 0
return api.nvim_win_get_width(winid) - textoff
end
--- @param bufnr integer
--- @param lnum integer
--- @return integer
local function line_len(bufnr, lnum)
local line = api.nvim_buf_get_lines(bufnr, lnum - 1, lnum, true)[1]
return api.nvim_strwidth(line)
end
--- @param fmt string
--- @return Gitsigns.CurrentLineBlameFmtFun
local function default_formatter(fmt)
return function(username, blame_info, _opts)
return {
{
expand_blame_format(fmt, username, blame_info),
'GitSignsCurrentLineBlame',
},
}
end
end
---@param bufnr integer
---@param blame_info Gitsigns.BlameInfoPublic
---@return {[1]: string, [2]:string}[]
local function get_blame_virt_text(bufnr, blame_info)
local git_obj = assert(cache[bufnr]).git_obj
local clb_formatter = blame_info.author == 'Not Committed Yet'
and config.current_line_blame_formatter_nc
or config.current_line_blame_formatter
if type(clb_formatter) == 'string' then
clb_formatter = default_formatter(clb_formatter)
end
return clb_formatter(git_obj.repo.username, blame_info, config.current_line_blame_formatter_opts)
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)
local virt_text = get_blame_virt_text(bufnr, blame_info)
local virt_text_str = flatten_virt_text(virt_text)
vim.b[bufnr].gitsigns_blame_line_dict = blame_info
vim.b[bufnr].gitsigns_blame_line = virt_text_str
if opts.virt_text then
local virt_text_pos = opts.virt_text_pos
if virt_text_pos == 'right_align' then
if api.nvim_strwidth(virt_text_str) > (win_width() - line_len(bufnr, lnum)) then
virt_text_pos = 'eol'
end
end
api.nvim_buf_set_extmark(bufnr, namespace, lnum - 1, 0, {
id = 1,
virt_text = virt_text,
virt_text_pos = virt_text_pos,
priority = opts.virt_text_priority,
hl_mode = 'combine',
})
end
end
--- @param winid integer
--- @return integer lnum
local function get_lnum(winid)
return api.nvim_win_get_cursor(winid)[1]
end
--- @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
---@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 not api.nvim_buf_is_valid(bufnr) then
return
end
if insert_mode() then
return
end
local winid = api.nvim_get_current_win()
if bufnr ~= api.nvim_win_get_buf(winid) then
return
end
local lnum = get_lnum(winid)
-- Can't show extmarks on folded lines so skip
if foldclosed(winid, lnum) then
return
end
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 = bcache:get_blame(lnum, opts)
if not api.nvim_win_is_valid(winid) or bufnr ~= api.nvim_win_get_buf(winid) then
return
end
if not blame_info then
return
end
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.create(1, 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
if config.current_line_blame then
local events = { 'FocusGained', 'BufEnter', 'CursorMoved', 'CursorMovedI' }
if vim.fn.exists('#WinResized') == 1 then
-- For nvim 0.9+
events[#events + 1] = 'WinResized'
end
api.nvim_create_autocmd(events, {
group = group,
callback = function(args)
reset(args.buf)
update_debounced(args.buf)
end,
})
api.nvim_create_autocmd({ 'InsertEnter', 'FocusLost', 'BufLeave' }, {
group = group,
callback = function(args)
reset(args.buf)
end,
})
api.nvim_create_autocmd('OptionSet', {
group = group,
pattern = { 'fileformat', 'bomb', 'eol' },
callback = function(args)
reset(args.buf)
end,
})
update_debounced(api.nvim_get_current_buf())
end
end
return M

View File

@ -0,0 +1,74 @@
local uv = vim.loop
local M = {}
--- Debounces a function on the trailing edge.
---
--- @generic F: function
--- @param ms number Timeout in ms
--- @param fn F Function to debounce
--- @param hash? integer|fun(...): any Function that determines id from arguments to fn
--- @return F Debounced function.
function M.debounce_trailing(ms, fn, hash)
local running = {} --- @type table<any,uv.uv_timer_t>
if type(hash) == 'number' then
local hash_i = hash
hash = function(...)
return select(hash_i, ...)
end
end
return function(...)
local id = hash and hash(...) or true
if running[id] == nil then
running[id] = assert(uv.new_timer())
end
local timer = running[id]
local argv = { ... }
timer:start(ms, 0, function()
timer:stop()
running[id] = nil
fn(unpack(argv, 1, table.maxn(argv)))
end)
end
end
--- Throttles a function using the first argument as an ID
---
--- If function is already running then the function will be scheduled to run
--- again once the running call has finished.
---
--- fn#1 _/‾\__/‾\_/‾\_____________________________
--- throttled#1 _/‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾\/‾‾‾‾‾‾‾‾‾‾\____________
--
--- fn#2 ______/‾\___________/‾\___________________
--- throttled#2 ______/‾‾‾‾‾‾‾‾‾‾\__/‾‾‾‾‾‾‾‾‾‾\__________
---
---
--- @generic F: function
--- @param fn F Function to throttle
--- @param schedule? boolean
--- @return F throttled function.
function M.throttle_by_id(fn, schedule)
local scheduled = {} --- @type table<any,boolean>
local running = {} --- @type table<any,boolean>
return function(id, ...)
if scheduled[id] then
-- If fn is already scheduled, then drop
return
end
if not running[id] or schedule then
scheduled[id] = true
end
if running[id] then
return
end
while scheduled[id] do
scheduled[id] = nil
running[id] = true
fn(id, ...)
running[id] = nil
end
end
end
return M

View File

@ -0,0 +1,62 @@
local log = require('gitsigns.debug.log')
local M = {}
--- @param raw_item any
--- @param path string[]
--- @return any
local function process(raw_item, path)
--- @diagnostic disable-next-line:undefined-field
if path[#path] == vim.inspect.METATABLE then
return
elseif type(raw_item) == 'function' then
return
elseif type(raw_item) ~= 'table' then
return raw_item
end
--- @cast raw_item table<any,any>
local key = path[#path]
if
vim.tbl_contains({
'compare_text',
'compare_text_head',
'hunks',
'hunks_staged',
'staged_diffs',
}, key)
then
return { '...', length = #vim.tbl_keys(raw_item), head = raw_item[next(raw_item)] }
elseif key == 'blame' then
return { '...', length = #vim.tbl_keys(raw_item) }
end
return raw_item
end
--- @return any
function M.dump_cache()
-- TODO(lewis6991): hack: use package.loaded to avoid circular deps
local cache = (require('gitsigns.cache')).cache
--- @type string
local text = vim.inspect(cache, { process = process })
vim.api.nvim_echo({ { text } }, false, {})
end
--- @param noecho boolean
--- @return string[]?
function M.debug_messages(noecho)
if noecho then
return log.messages
else
for _, m in ipairs(log.messages) do
vim.api.nvim_echo({ { m } }, false, {})
end
end
end
function M.clear_debug()
log.messages = {}
end
return M

View File

@ -0,0 +1,144 @@
local M = {
debug_mode = false,
verbose = false,
messages = {}, --- @type string[]
}
--- @param name string
--- @param lvl integer
--- @return any
local function getvarvalue(name, lvl)
lvl = lvl + 1
local value --- @type any?
local found --- @type boolean?
-- try local variables
local i = 1
while true do
local n, v = debug.getlocal(lvl, i)
if not n then
break
end
if n == name then
value = v
found = true
end
i = i + 1
end
if found then
return value
end
-- try upvalues
local func = debug.getinfo(lvl).func
i = 1
while true do
local n, v = debug.getupvalue(func, i)
if not n then
break
end
if n == name then
return v
end
i = i + 1
end
-- not found; get global
--- @diagnostic disable-next-line:deprecated
return getfenv(func)[name]
end
--- @param lvl integer
--- @return {name:string, bufnr: integer}
local function get_context(lvl)
lvl = lvl + 1
local ret = {} --- @type {name:string, bufnr: integer}
ret.name = getvarvalue('__FUNC__', lvl)
if not ret.name then
local name0 = debug.getinfo(lvl, 'n').name or ''
ret.name = name0:gsub('(.*)%d+$', '%1')
end
ret.bufnr = getvarvalue('bufnr', lvl)
or getvarvalue('_bufnr', lvl)
or getvarvalue('cbuf', lvl)
or getvarvalue('buf', lvl)
return ret
end
-- If called in a callback then make sure the callback defines a __FUNC__
-- variable which can be used to identify the name of the function.
--- @param obj any
--- @param lvl integer
local function cprint(obj, lvl)
lvl = lvl + 1
local msg = type(obj) == 'string' and obj or vim.inspect(obj)
local ctx = get_context(lvl)
local msg2 --- @type string
if ctx.bufnr then
msg2 = string.format('%s(%s): %s', ctx.name, ctx.bufnr, msg)
else
msg2 = string.format('%s: %s', ctx.name, msg)
end
table.insert(M.messages, msg2)
end
function M.dprint(obj)
if not M.debug_mode then
return
end
cprint(obj, 2)
end
function M.dprintf(obj, ...)
if not M.debug_mode then
return
end
cprint(obj:format(...), 2)
end
function M.vprint(obj)
if not (M.debug_mode and M.verbose) then
return
end
cprint(obj, 2)
end
function M.vprintf(obj, ...)
if not (M.debug_mode and M.verbose) then
return
end
cprint(obj:format(...), 2)
end
local function eprint(msg, level)
local info = debug.getinfo(level + 2, 'Sl')
if info then
msg = string.format('(ERROR) %s(%d): %s', info.short_src, info.currentline, msg)
end
M.messages[#M.messages + 1] = debug.traceback(msg)
if M.debug_mode then
error(msg, 3)
end
end
function M.eprint(msg)
eprint(msg, 1)
end
function M.eprintf(fmt, ...)
eprint(fmt:format(...), 1)
end
--- @param cond boolean
--- @param msg string
--- @return boolean
function M.assert(cond, msg)
if not cond then
eprint(msg, 1)
end
return not cond
end
return M

View File

@ -0,0 +1,32 @@
local config = require('gitsigns.config').config
--- @alias Gitsigns.Difffn fun(fa: string[], fb: string[], linematch?: integer): Gitsigns.Hunk.Hunk[]
--- @param a string[]
--- @param b string[]
--- @param linematch? boolean
--- @return Gitsigns.Hunk.Hunk[] hunks
return function(a, b, linematch)
-- -- Short circuit optimization
-- if not a or #a == 0 then
-- local Hunks = require('gitsigns.hunks')
-- local hunk = Hunks.create_hunk(0, 0, 1, #b)
-- hunk.added.lines = b
-- return { hunk }
-- end
local diff_opts = config.diff_opts
local f --- @type Gitsigns.Difffn
if diff_opts.internal then
f = require('gitsigns.diff_int').run_diff
else
f = require('gitsigns.diff_ext').run_diff
end
local linematch0 --- @type integer?
if linematch ~= false then
linematch0 = diff_opts.linematch
end
return f(a, b, linematch0)
end

View File

@ -0,0 +1,80 @@
local config = require('gitsigns.config').config
local git_diff = require('gitsigns.git').diff
local gs_hunks = require('gitsigns.hunks')
local util = require('gitsigns.util')
local scheduler = require('gitsigns.async').scheduler
local M = {}
-- Async function
--- @param path string
--- @param text string[]
local function write_to_file(path, text)
local f, err = io.open(path, 'wb')
if f == nil then
error(err)
end
for _, l in ipairs(text) do
f:write(l)
f:write('\n')
end
f:close()
end
--- @async
--- @param text_cmp string[]
--- @param text_buf string[]
--- @return Gitsigns.Hunk.Hunk[]
function M.run_diff(text_cmp, text_buf)
local results = {} --- @type Gitsigns.Hunk.Hunk[]
-- tmpname must not be called in a callback
if vim.in_fast_event() then
scheduler()
end
local file_buf = util.tmpname()
local file_cmp = util.tmpname()
write_to_file(file_buf, text_buf)
write_to_file(file_cmp, text_cmp)
-- Taken from gitgutter, diff.vim:
--
-- If a file has CRLF line endings and git's core.autocrlf is true, the file
-- in git's object store will have LF line endings. Writing it out via
-- git-show will produce a file with LF line endings.
--
-- If this last file is one of the files passed to git-diff, git-diff will
-- convert its line endings to CRLF before diffing -- which is what we want
-- but also by default outputs a warning on stderr.
--
-- warning: LF will be replace by CRLF in <temp file>.
-- The file will have its original line endings in your working directory.
--
-- We can safely ignore the warning, we turn it off by passing the '-c
-- "core.safecrlf=false"' argument to git-diff.
local opts = config.diff_opts
local out = git_diff(file_cmp, file_buf, opts.indent_heuristic, opts.algorithm)
for _, line in ipairs(out) do
if vim.startswith(line, '@@') then
results[#results + 1] = gs_hunks.parse_diff_line(line)
elseif #results > 0 then
local r = results[#results]
if line:sub(1, 1) == '-' then
r.removed.lines[#r.removed.lines + 1] = line:sub(2)
elseif line:sub(1, 1) == '+' then
r.added.lines[#r.added.lines + 1] = line:sub(2)
end
end
end
os.remove(file_buf)
os.remove(file_cmp)
return results
end
return M

View File

@ -0,0 +1,191 @@
local create_hunk = require('gitsigns.hunks').create_hunk
local config = require('gitsigns.config').config
local async = require('gitsigns.async')
local M = {}
--- @alias Gitsigns.Region {[1]:integer, [2]:string, [3]:integer, [4]:integer}
--- @alias Gitsigns.RawHunk {[1]:integer, [2]:integer, [3]:integer, [4]:integer}
--- @alias Gitsigns.RawDifffn fun(a: string, b: string, linematch?: integer): Gitsigns.RawHunk[]
--- @type Gitsigns.RawDifffn
local run_diff_xdl = function(a, b, linematch)
local opts = config.diff_opts
return vim.diff(a, b, {
result_type = 'indices',
algorithm = opts.algorithm,
indent_heuristic = opts.indent_heuristic,
ignore_whitespace = opts.ignore_whitespace,
ignore_whitespace_change = opts.ignore_whitespace_change,
ignore_whitespace_change_at_eol = opts.ignore_whitespace_change_at_eol,
ignore_blank_lines = opts.ignore_blank_lines,
linematch = linematch,
}) --[[@as Gitsigns.RawHunk[] ]]
end
--- @type Gitsigns.RawDifffn
local run_diff_xdl_async = async.wrap(
4,
--- @param a string
--- @param b string
--- @param linematch? integer
--- @param callback fun(hunks: Gitsigns.RawHunk[])
function(a, b, linematch, callback)
local opts = config.diff_opts
local function toflag(f, pos)
return f and bit.lshift(1, pos) or 0
end
local flags = toflag(opts.indent_heuristic, 0)
+ toflag(opts.ignore_whitespace, 1)
+ toflag(opts.ignore_whitespace_change, 2)
+ toflag(opts.ignore_whitespace_change_at_eol, 3)
+ toflag(opts.ignore_blank_lines, 4)
vim.loop
.new_work(
--- @param a0 string
--- @param b0 string
--- @param algorithm string
--- @param flags0 integer
--- @param linematch0 integer
--- @return string
function(a0, b0, algorithm, flags0, linematch0)
local function flagval(pos)
return bit.band(flags0, bit.lshift(1, pos)) ~= 0
end
--- @diagnostic disable-next-line:redundant-return-value
return vim.mpack.encode(vim.diff(a0, b0, {
result_type = 'indices',
algorithm = algorithm,
linematch = linematch0,
indent_heuristic = flagval(0),
ignore_whitespace = flagval(1),
ignore_whitespace_change = flagval(2),
ignore_whitespace_change_at_eol = flagval(3),
ignore_blank_lines = flagval(4),
}))
end,
--- @param r string
function(r)
callback(vim.mpack.decode(r) --[[@as Gitsigns.RawHunk[] ]])
end
)
:queue(a, b, opts.algorithm, flags, linematch)
end
)
--- @param fa string[]
--- @param fb string[]
--- @param linematch? integer
--- @return Gitsigns.Hunk.Hunk[]
function M.run_diff(fa, fb, linematch)
local run_diff0 --- @type Gitsigns.RawDifffn
if config._threaded_diff and vim.is_thread then
run_diff0 = run_diff_xdl_async
else
run_diff0 = run_diff_xdl
end
local a = table.concat(fa, '\n')
local b = table.concat(fb, '\n')
local results = run_diff0(a, b, linematch)
local hunks = {} --- @type Gitsigns.Hunk.Hunk[]
for _, r in ipairs(results) do
local rs, rc, as, ac = r[1], r[2], r[3], r[4]
local hunk = create_hunk(rs, rc, as, ac)
if rc > 0 then
for i = rs, rs + rc - 1 do
hunk.removed.lines[#hunk.removed.lines + 1] = fa[i] or ''
end
if rs + rc >= #fa and fa[#fa] ~= '' then
hunk.removed.no_nl_at_eof = true
end
end
if ac > 0 then
for i = as, as + ac - 1 do
hunk.added.lines[#hunk.added.lines + 1] = fb[i] or ''
end
if as + ac >= #fb and fb[#fb] ~= '' then
hunk.added.no_nl_at_eof = true
end
end
hunks[#hunks + 1] = hunk
end
return hunks
end
local gaps_between_regions = 5
--- @param hunks Gitsigns.Hunk.Hunk[]
--- @return Gitsigns.Hunk.Hunk[]
local function denoise_hunks(hunks)
-- Denoise the hunks
local ret = { hunks[1] } --- @type Gitsigns.Hunk.Hunk[]
for j = 2, #hunks do
local h, n = ret[#ret], hunks[j]
if not h or not n then
break
end
if n.added.start - h.added.start - h.added.count < gaps_between_regions then
h.added.count = n.added.start + n.added.count - h.added.start
h.removed.count = n.removed.start + n.removed.count - h.removed.start
if h.added.count > 0 or h.removed.count > 0 then
h.type = 'change'
end
else
ret[#ret + 1] = n
end
end
return ret
end
--- @param removed string[]
--- @param added string[]
--- @return Gitsigns.Region[] removed
--- @return Gitsigns.Region[] added
function M.run_word_diff(removed, added)
local adds = {} --- @type Gitsigns.Region[]
local rems = {} --- @type Gitsigns.Region[]
if #removed ~= #added then
return rems, adds
end
for i = 1, #removed do
-- pair lines by position
local a = table.concat(vim.split(removed[i], ''), '\n')
local b = table.concat(vim.split(added[i], ''), '\n')
local hunks = {} --- @type Gitsigns.Hunk.Hunk[]
for _, r in ipairs(run_diff_xdl(a, b)) do
local rs, rc, as, ac = r[1], r[2], r[3], r[4]
-- Balance of the unknown offset done in hunk_func
if rc == 0 then
rs = rs + 1
end
if ac == 0 then
as = as + 1
end
hunks[#hunks + 1] = create_hunk(rs, rc, as, ac)
end
hunks = denoise_hunks(hunks)
for _, h in ipairs(hunks) do
adds[#adds + 1] = { i, h.type, h.added.start, h.added.start + h.added.count }
rems[#rems + 1] = { i, h.type, h.removed.start, h.removed.start + h.removed.count }
end
end
return rems, adds
end
return M

View File

@ -0,0 +1,234 @@
local api = vim.api
local async = require('gitsigns.async')
local cache = require('gitsigns.cache').cache
local util = require('gitsigns.util')
local manager = require('gitsigns.manager')
local message = require('gitsigns.message')
local Status = require('gitsigns.status')
local throttle_by_id = require('gitsigns.debounce').throttle_by_id
local M = {}
--- @async
--- @param bufnr integer
--- @param dbufnr integer
--- @param base string?
local function bufread(bufnr, dbufnr, base)
local bcache = cache[bufnr]
base = util.norm_base(base)
local text --- @type string[]
if base == bcache.git_obj.revision then
text = assert(bcache.compare_text)
else
local err
text, err = bcache.git_obj:get_show_text(base)
if err then
error(err, 2)
end
async.scheduler()
if not api.nvim_buf_is_valid(bufnr) then
return
end
end
vim.bo[dbufnr].fileformat = vim.bo[bufnr].fileformat
vim.bo[dbufnr].filetype = vim.bo[bufnr].filetype
vim.bo[dbufnr].bufhidden = 'wipe'
local modifiable = vim.bo[dbufnr].modifiable
vim.bo[dbufnr].modifiable = true
Status:update(dbufnr, { head = base })
util.set_lines(dbufnr, 0, -1, text)
vim.bo[dbufnr].modifiable = modifiable
vim.bo[dbufnr].modified = false
require('gitsigns.attach').attach(dbufnr, nil, 'BufReadCmd')
end
--- @param bufnr integer
--- @param dbufnr integer
--- @param base string?
local bufwrite = async.create(3, function(bufnr, dbufnr, base)
local bcache = cache[bufnr]
local buftext = util.buf_lines(dbufnr)
base = util.norm_base(base)
bcache.git_obj:stage_lines(buftext)
async.scheduler()
if not api.nvim_buf_is_valid(bufnr) then
return
end
vim.bo[dbufnr].modified = false
-- If diff buffer base matches the git_obj revision then also update the
-- signs.
if base == bcache.git_obj.revision then
bcache.compare_text = buftext
manager.update(bufnr)
end
end)
--- @async
--- Create a gitsigns buffer for a certain revision of a file
--- @param bufnr integer
--- @param base string?
--- @return string? buf Buffer name
local function create_show_buf(bufnr, base)
local bcache = assert(cache[bufnr])
base = util.norm_base(base)
local bufname = bcache:get_rev_bufname(base)
if util.bufexists(bufname) then
return bufname
end
local dbuf = api.nvim_create_buf(false, true)
api.nvim_buf_set_name(dbuf, bufname)
local ok, err = pcall(bufread, bufnr, dbuf, base)
if not ok then
message.error(err --[[@as string]])
async.scheduler()
api.nvim_buf_delete(dbuf, { force = true })
return
end
-- allow editing the index revision
if not bcache.git_obj.revision then
vim.bo[dbuf].buftype = 'acwrite'
api.nvim_create_autocmd('BufReadCmd', {
group = 'gitsigns',
buffer = dbuf,
callback = function()
async.run(bufread, bufnr, dbuf, base)
end,
})
api.nvim_create_autocmd('BufWriteCmd', {
group = 'gitsigns',
buffer = dbuf,
callback = function()
bufwrite(bufnr, dbuf, base)
end,
})
else
vim.bo[dbuf].buftype = 'nowrite'
vim.bo[dbuf].modifiable = false
end
return bufname
end
--- @class Gitsigns.DiffthisOpts
--- @field vertical boolean
--- @field split string
--- @async
--- @param base string?
--- @param opts? Gitsigns.DiffthisOpts
local function diffthis_rev(base, opts)
local bufnr = api.nvim_get_current_buf()
local bufname = create_show_buf(bufnr, base)
if not bufname then
return
end
opts = opts or {}
vim.cmd(table.concat({
'keepalt',
opts.split or 'aboveleft',
opts.vertical and 'vertical' or '',
'diffsplit',
bufname,
}, ' '))
end
--- @param base string?
--- @param opts Gitsigns.DiffthisOpts
M.diffthis = async.create(2, function(base, opts)
if vim.wo.diff then
return
end
local bufnr = api.nvim_get_current_buf()
local bcache = cache[bufnr]
if not bcache then
return
end
local cwin = api.nvim_get_current_win()
if not base and bcache.git_obj.has_conflicts then
diffthis_rev(':2', opts)
api.nvim_set_current_win(cwin)
opts.split = 'belowright'
diffthis_rev(':3', opts)
else
diffthis_rev(base, opts)
end
api.nvim_set_current_win(cwin)
end)
--- @param bufnr integer
--- @param base string
M.show = async.create(2, function(bufnr, base)
local bufname = create_show_buf(bufnr, base)
if not bufname then
return
end
vim.cmd.edit(bufname)
end)
--- @param bufnr integer
--- @return boolean
local function should_reload(bufnr)
if not vim.bo[bufnr].modified then
return true
end
local response --- @type string?
while not vim.tbl_contains({ 'O', 'L' }, response) do
response = async.wait(2, vim.ui.input, {
prompt = 'Warning: The git index has changed and the buffer was changed as well. [O]K, (L)oad File:',
})
end
return response == 'L'
end
-- This function needs to be throttled as there is a call to vim.ui.input
--- @param bufnr integer
M.update = throttle_by_id(async.create(1, function(bufnr)
if not vim.wo.diff then
return
end
local bcache = cache[bufnr]
-- Note this will be the bufname for the currently set base
-- which are the only ones we want to update
local bufname = bcache:get_rev_bufname()
for _, w in ipairs(api.nvim_list_wins()) do
if api.nvim_win_is_valid(w) then
local b = api.nvim_win_get_buf(w)
local bname = api.nvim_buf_get_name(b)
local is_fugitive_diff_window = vim.startswith(bname, 'fugitive://')
and vim.fn.exists('*FugitiveParse')
and vim.fn.FugitiveParse(bname)[1] ~= ':'
if bname == bufname or is_fugitive_diff_window then
if should_reload(b) then
api.nvim_buf_call(b, function()
vim.cmd.doautocmd('BufReadCmd')
vim.cmd.diffthis()
end)
end
end
end
end
end))
return M

View File

@ -0,0 +1,905 @@
local async = require('gitsigns.async')
local scheduler = require('gitsigns.async').scheduler
local log = require('gitsigns.debug.log')
local util = require('gitsigns.util')
local system = require('gitsigns.system').system
local gs_config = require('gitsigns.config')
local config = gs_config.config
local uv = vim.uv or vim.loop
local dprint = log.dprint
local dprintf = log.dprintf
local error_once = require('gitsigns.message').error_once
local check_version = require('gitsigns.git.version').check
local M = {}
--- @type fun(cmd: string[], opts?: vim.SystemOpts): vim.SystemCompleted
local asystem = async.wrap(3, system)
--- @param file string
--- @return boolean
local function in_git_dir(file)
for _, p in ipairs(vim.split(file, util.path_sep)) do
if p == '.git' then
return true
end
end
return false
end
--- @class Gitsigns.GitObj
--- @field file string
--- @field encoding string
--- @field i_crlf boolean Object has crlf
--- @field w_crlf boolean Working copy has crlf
--- @field mode_bits string
--- @field revision? string Revision the object is tracking against. Nil for index
--- @field object_name string The fixed object name to use.
--- @field relpath string
--- @field orig_relpath? string Use for tracking moved files
--- @field repo Gitsigns.Repo
--- @field has_conflicts? boolean
local Obj = {}
M.Obj = Obj
--- @class Gitsigns.RepoInfo
--- @field gitdir string
--- @field toplevel string
--- @field detached boolean
--- @field abbrev_head string
--- @class Gitsigns.Repo : Gitsigns.RepoInfo
--- @field username string
local Repo = {}
M.Repo = Repo
--- @class Gitsigns.Git.JobSpec : vim.SystemOpts
--- @field command? string
--- @field ignore_error? boolean
--- @async
--- @param args string[]
--- @param spec? Gitsigns.Git.JobSpec
--- @return string[] stdout, string? stderr
local function git_command(args, spec)
spec = spec or {}
local cmd = {
spec.command or 'git',
'--no-pager',
'--no-optional-locks',
'--literal-pathspecs',
'-c',
'gc.auto=0', -- Disable auto-packing which emits messages to stderr
unpack(args),
}
if spec.text == nil then
spec.text = true
end
-- Fix #895. Only needed for Nvim 0.9 and older
spec.clear_env = true
--- @type vim.SystemCompleted
local obj = asystem(cmd, spec)
local stdout = obj.stdout
local stderr = obj.stderr
if not spec.ignore_error and obj.code > 0 then
local cmd_str = table.concat(cmd, ' ')
log.eprintf("Received exit code %d when running command\n'%s':\n%s", obj.code, cmd_str, stderr)
end
local stdout_lines = vim.split(stdout or '', '\n')
if spec.text then
-- If stdout ends with a newline, then remove the final empty string after
-- the split
if stdout_lines[#stdout_lines] == '' then
stdout_lines[#stdout_lines] = nil
end
end
if log.verbose then
log.vprintf('%d lines:', #stdout_lines)
for i = 1, math.min(10, #stdout_lines) do
log.vprintf('\t%s', stdout_lines[i])
end
end
if stderr == '' then
stderr = nil
end
return stdout_lines, stderr
end
--- @async
--- @param file_cmp string
--- @param file_buf string
--- @param indent_heuristic? boolean
--- @param diff_algo string
--- @return string[] stdout, string? stderr
function M.diff(file_cmp, file_buf, indent_heuristic, diff_algo)
return git_command({
'-c',
'core.safecrlf=false',
'diff',
'--color=never',
'--' .. (indent_heuristic and '' or 'no-') .. 'indent-heuristic',
'--diff-algorithm=' .. diff_algo,
'--patch-with-raw',
'--unified=0',
file_cmp,
file_buf,
}, {
-- git-diff implies --exit-code
ignore_error = true,
})
end
--- @async
--- @param gitdir string
--- @param head_str string
--- @param cwd string
--- @param cmd? string
--- @return string
local function process_abbrev_head(gitdir, head_str, cwd, cmd)
if not gitdir then
return head_str
end
if head_str == 'HEAD' then
local short_sha = git_command({ 'rev-parse', '--short', 'HEAD' }, {
command = cmd,
ignore_error = true,
cwd = cwd,
})[1] or ''
if log.debug_mode and short_sha ~= '' then
short_sha = 'HEAD'
end
if
util.path_exists(gitdir .. '/rebase-merge')
or util.path_exists(gitdir .. '/rebase-apply')
then
return short_sha .. '(rebasing)'
end
return short_sha
end
return head_str
end
local has_cygpath = jit and jit.os == 'Windows' and vim.fn.executable('cygpath') == 1
local cygpath_convert ---@type fun(path: string): string
if has_cygpath then
cygpath_convert = function(path)
--- @type vim.SystemCompleted
local obj = asystem({ 'cygpath', '-aw', path })
return obj.stdout
end
end
--- @param path string
--- @return string
local function normalize_path(path)
if path and has_cygpath and not uv.fs_stat(path) then
-- If on windows and path isn't recognizable as a file, try passing it
-- through cygpath
path = cygpath_convert(path)
end
return path
end
--- @async
--- @param cwd string
--- @param cmd? string
--- @param gitdir? string
--- @param toplevel? string
--- @return Gitsigns.RepoInfo
function M.get_repo_info(cwd, cmd, gitdir, toplevel)
-- Does git rev-parse have --absolute-git-dir, added in 2.13:
-- https://public-inbox.org/git/20170203024829.8071-16-szeder.dev@gmail.com/
local has_abs_gd = check_version({ 2, 13 })
local git_dir_opt = has_abs_gd and '--absolute-git-dir' or '--git-dir'
-- Wait for internal scheduler to settle before running command (#215)
scheduler()
local args = {}
if gitdir then
vim.list_extend(args, { '--git-dir', gitdir })
end
if toplevel then
vim.list_extend(args, { '--work-tree', toplevel })
end
vim.list_extend(args, {
'rev-parse',
'--show-toplevel',
git_dir_opt,
'--abbrev-ref',
'HEAD',
})
local results = git_command(args, {
command = cmd,
ignore_error = true,
cwd = toplevel or cwd,
})
local toplevel_r = normalize_path(results[1])
local gitdir_r = normalize_path(results[2])
if gitdir_r and not has_abs_gd then
gitdir_r = assert(uv.fs_realpath(gitdir_r))
end
return {
toplevel = toplevel_r,
gitdir = gitdir_r,
abbrev_head = process_abbrev_head(gitdir_r, results[3], cwd, cmd),
detached = toplevel_r and gitdir_r ~= toplevel_r .. '/.git',
}
end
--------------------------------------------------------------------------------
-- Git repo object methods
--------------------------------------------------------------------------------
--- Run git command the with the objects gitdir and toplevel
--- @async
--- @param args string[]
--- @param spec? Gitsigns.Git.JobSpec
--- @return string[] stdout, string? stderr
function Repo:command(args, spec)
spec = spec or {}
spec.cwd = self.toplevel
local args1 = {
'--git-dir',
self.gitdir,
}
if self.detached then
vim.list_extend(args1, { '--work-tree', self.toplevel })
end
vim.list_extend(args1, args)
return git_command(args1, spec)
end
--- @return string[]
function Repo:files_changed()
--- @type string[]
local results = self:command({ 'status', '--porcelain', '--ignore-submodules' })
local ret = {} --- @type string[]
for _, line in ipairs(results) do
if line:sub(1, 2):match('^.M') then
ret[#ret + 1] = line:sub(4, -1)
end
end
return ret
end
--- @param encoding string
--- @return boolean
local function iconv_supported(encoding)
-- TODO(lewis6991): needs https://github.com/neovim/neovim/pull/21924
if vim.startswith(encoding, 'utf-16') then
return false
elseif vim.startswith(encoding, 'utf-32') then
return false
end
return true
end
--- Get version of file in the index, return array lines
--- @param object string
--- @param encoding? string
--- @return string[] stdout, string? stderr
function Repo:get_show_text(object, encoding)
local stdout, stderr = self:command({ 'show', object }, { text = false, ignore_error = true })
if encoding and encoding ~= 'utf-8' and iconv_supported(encoding) then
for i, l in ipairs(stdout) do
--- @diagnostic disable-next-line:param-type-mismatch
stdout[i] = vim.iconv(l, encoding, 'utf-8')
end
end
return stdout, stderr
end
--- @async
function Repo:update_abbrev_head()
self.abbrev_head = M.get_repo_info(self.toplevel).abbrev_head
end
--- @private
--- @param dir string
--- @param gitdir? string
--- @param toplevel? string
function Repo:try_yadm(dir, gitdir, toplevel)
if not config.yadm.enable or self.gitdir then
return
end
local home = os.getenv('HOME')
if not home or not vim.startswith(dir, home) then
return
end
if #git_command({ 'ls-files', dir }, { command = 'yadm' }) == 0 then
return
end
M.get_repo_info(dir, 'yadm', gitdir, toplevel)
local yadm_info = M.get_repo_info(dir, 'yadm', gitdir, toplevel)
for k, v in
pairs(yadm_info --[[@as table<string,any>]])
do
---@diagnostic disable-next-line:no-unknown
self[k] = v
end
end
--- @async
--- @param dir string
--- @param gitdir? string
--- @param toplevel? string
--- @return Gitsigns.Repo
function Repo.new(dir, gitdir, toplevel)
local self = setmetatable({}, { __index = Repo })
self.username = git_command({ 'config', 'user.name' }, { ignore_error = true })[1]
local info = M.get_repo_info(dir, nil, gitdir, toplevel)
for k, v in
pairs(info --[[@as table<string,any>]])
do
---@diagnostic disable-next-line:no-unknown
self[k] = v
end
self:try_yadm(dir, gitdir, toplevel)
return self
end
--------------------------------------------------------------------------------
-- Git object methods
--------------------------------------------------------------------------------
--- Run git command the with the objects gitdir and toplevel
--- @param args string[]
--- @param spec? Gitsigns.Git.JobSpec
--- @return string[] stdout, string? stderr
function Obj:command(args, spec)
return self.repo:command(args, spec)
end
--- @param revision? string
function Obj:update_revision(revision)
revision = util.norm_base(revision)
self.revision = revision
self:update()
end
--- @param update_relpath? boolean
--- @param silent? boolean
--- @return boolean
function Obj:update(update_relpath, silent)
local old_object_name = self.object_name
local props = self:file_info(self.file, silent)
if update_relpath then
self.relpath = props.relpath
end
self.object_name = props.object_name
self.mode_bits = props.mode_bits
self.has_conflicts = props.has_conflicts
self.i_crlf = props.i_crlf
self.w_crlf = props.w_crlf
return old_object_name ~= self.object_name
end
--- @class (exact) Gitsigns.FileInfo
--- @field relpath string
--- @field i_crlf? boolean
--- @field w_crlf? boolean
--- @field mode_bits? string
--- @field object_name? string
--- @field has_conflicts? true
--- @param file? string
--- @param silent? boolean
--- @return Gitsigns.FileInfo
function Obj:file_info(file, silent)
if self.revision and not vim.startswith(self.revision, ':') then
return self:file_info_tree(file, silent)
else
return self:file_info_index(file, silent)
end
end
--- @private
--- @param file? string
--- @param silent? boolean
--- @return Gitsigns.FileInfo
function Obj:file_info_index(file, silent)
local has_eol = check_version({ 2, 9 })
local cmd = {
'-c',
'core.quotepath=off',
'ls-files',
'--stage',
'--others',
'--exclude-standard',
}
if has_eol then
cmd[#cmd + 1] = '--eol'
end
cmd[#cmd + 1] = file or self.file
local results, stderr = self:command(cmd, { ignore_error = true })
if stderr and not silent then
-- ignore_error for the cases when we run:
-- git ls-files --others exists/nonexist
if not stderr:match('^warning: could not open directory .*: No such file or directory') then
log.eprint(stderr)
end
end
local relpath_idx = has_eol and 2 or 1
local result = {}
for _, line in ipairs(results) do
local parts = vim.split(line, '\t')
if #parts > relpath_idx then -- tracked file
local attrs = vim.split(parts[1], '%s+')
local stage = tonumber(attrs[3])
if stage <= 1 then
result.mode_bits = attrs[1]
result.object_name = attrs[2]
else
result.has_conflicts = true
end
if has_eol then
result.relpath = parts[3]
local eol = vim.split(parts[2], '%s+')
result.i_crlf = eol[1] == 'i/crlf'
result.w_crlf = eol[2] == 'w/crlf'
else
result.relpath = parts[2]
end
else -- untracked file
result.relpath = parts[relpath_idx]
end
end
return result
end
--- @private
--- @param file? string
--- @param silent? boolean
--- @return Gitsigns.FileInfo
function Obj:file_info_tree(file, silent)
local results, stderr = self:command({
'-c',
'core.quotepath=off',
'ls-tree',
self.revision,
file or self.file,
}, { ignore_error = true })
if stderr then
if not silent then
log.eprint(stderr)
end
return {}
end
local info, relpath = unpack(vim.split(results[1], '\t'))
local mode_bits, objtype, object_name = unpack(vim.split(info, '%s+'))
assert(objtype == 'blob')
return {
mode_bits = mode_bits,
object_name = object_name,
relpath = relpath,
}
end
--- @param revision? string
--- @return string[] stdout, string? stderr
function Obj:get_show_text(revision)
if revision and not self.relpath then
dprint('no relpath')
return {}
end
local object = revision and (revision .. ':' .. self.relpath) or self.object_name
if not object then
dprint('no revision or object_name')
return { '' }
end
local stdout, stderr = self.repo:get_show_text(object, self.encoding)
if not self.i_crlf and self.w_crlf then
-- Add cr
-- Do not add cr to the newline at the end of file
for i = 1, #stdout - 1 do
stdout[i] = stdout[i] .. '\r'
end
end
return stdout, stderr
end
local function autocmd_changed(file)
vim.schedule(function()
vim.api.nvim_exec_autocmds('User', {
pattern = 'GitSignsChanged',
modeline = false,
data = { file = file },
})
end)
end
function Obj:unstage_file()
self:command({ 'reset', self.file })
autocmd_changed(self.file)
end
--- @class Gitsigns.CommitInfo
--- @field author string
--- @field author_mail string
--- @field author_time integer
--- @field author_tz string
--- @field committer string
--- @field committer_mail string
--- @field committer_time integer
--- @field committer_tz string
--- @field summary string
--- @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 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
---@return integer
local function asinteger(x)
return assert(tonumber(x))
end
--- @param lines string[]
--- @param lnum? integer
--- @param opts? Gitsigns.BlameOpts
--- @return table<integer,Gitsigns.BlameInfo?>?
function Obj:run_blame(lines, lnum, opts)
local ret = {} --- @type table<integer,Gitsigns.BlameInfo>
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
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' }
opts = opts or {}
if opts.ignore_whitespace then
args[#args + 1] = '-w'
end
if lnum then
vim.list_extend(args, { '-L', lnum .. ',+1' })
end
if opts.extra_opts then
vim.list_extend(args, opts.extra_opts)
end
local ignore_file = self.repo.toplevel .. '/.git-blame-ignore-revs'
if uv.fs_stat(ignore_file) then
vim.list_extend(args, { '--ignore-revs-file', ignore_file })
end
args[#args + 1] = opts.rev
args[#args + 1] = '--'
args[#args + 1] = self.file
local results, stderr = self:command(args, { stdin = lines, ignore_error = true })
if stderr then
error_once('Error running git-blame: ' .. stderr)
return
end
if #results == 0 then
return
end
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
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 ') or peek():match('^previous ')) do
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
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_sha,
}
end
end
return ret
end
--- @param obj Gitsigns.GitObj
local function ensure_file_in_index(obj)
if obj.object_name and not obj.has_conflicts then
return
end
if not obj.object_name then
-- If there is no object_name then it is not yet in the index so add it
obj:command({ 'add', '--intent-to-add', obj.file })
else
-- Update the index with the common ancestor (stage 1) which is what bcache
-- stores
local info = string.format('%s,%s,%s', obj.mode_bits, obj.object_name, obj.relpath)
obj:command({ 'update-index', '--add', '--cacheinfo', info })
end
obj:update()
end
--- Stage 'lines' as the entire contents of the file
--- @param lines string[]
function Obj:stage_lines(lines)
local stdout = self:command({
'hash-object',
'-w',
'--path',
self.relpath,
'--stdin',
}, { stdin = lines })
local new_object = stdout[1]
self:command({
'update-index',
'--cacheinfo',
string.format('%s,%s,%s', self.mode_bits, new_object, self.relpath),
})
autocmd_changed(self.file)
end
--- @param hunks Gitsigns.Hunk.Hunk[]
--- @param invert? boolean
function Obj:stage_hunks(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
-- Remove cr
for i = 1, #patch do
patch[i] = patch[i]:gsub('\r$', '')
end
end
self:command({
'apply',
'--whitespace=nowarn',
'--cached',
'--unidiff-zero',
'-',
}, {
stdin = patch,
})
autocmd_changed(self.file)
end
--- @return string?
function Obj:has_moved()
local out = self:command({ 'diff', '--name-status', '-C', '--cached' })
local orig_relpath = self.orig_relpath or self.relpath
for _, l in ipairs(out) do
local parts = vim.split(l, '%s+')
if #parts == 3 then
local orig, new = parts[2], parts[3]
if orig_relpath == orig then
self.orig_relpath = orig_relpath
self.relpath = new
self.file = self.repo.toplevel .. '/' .. new
return new
end
end
end
end
--- @param file string
--- @param revision string?
--- @param encoding string
--- @param gitdir string?
--- @param toplevel string?
--- @return Gitsigns.GitObj?
function Obj.new(file, revision, encoding, gitdir, toplevel)
if in_git_dir(file) then
dprint('In git dir')
return nil
end
local self = setmetatable({}, { __index = Obj })
if not vim.startswith(file, '/') and toplevel then
file = toplevel .. util.path_sep .. file
end
self.file = file
self.revision = util.norm_base(revision)
self.encoding = encoding
self.repo = Repo.new(util.dirname(file), gitdir, toplevel)
if not self.repo.gitdir then
dprint('Not in git repo')
return nil
end
-- When passing gitdir and toplevel, suppress stderr when resolving the file
local silent = gitdir ~= nil and toplevel ~= nil
self:update(true, silent)
return self
end
return M

View File

@ -0,0 +1,101 @@
local async = require('gitsigns.async')
local gs_config = require('gitsigns.config')
local log = require('gitsigns.debug.log')
local err = require('gitsigns.message').error
local system = require('gitsigns.system').system
local M = {}
--- @type fun(cmd: string[], opts?: vim.SystemOpts): vim.SystemCompleted
local asystem = async.wrap(3, system)
--- @class (exact) Gitsigns.Version
--- @field major integer
--- @field minor integer
--- @field patch integer
--- @param version string
--- @return Gitsigns.Version
local function parse_version(version)
assert(version:match('%d+%.%d+%.%w+'), 'Invalid git version: ' .. version)
local ret = {}
local parts = vim.split(version, '%.')
ret.major = assert(tonumber(parts[1]))
ret.minor = assert(tonumber(parts[2]))
if parts[3] == 'GIT' then
ret.patch = 0
else
local patch_ver = vim.split(parts[3], '-')
ret.patch = assert(tonumber(patch_ver[1]))
end
return ret
end
local function set_version()
local version = gs_config.config._git_version
if version ~= 'auto' then
local ok, ret = pcall(parse_version, version)
if ok then
M.version = ret
else
err(ret --[[@as string]])
end
return
end
--- @type vim.SystemCompleted
local obj = asystem({ 'git', '--version' })
async.scheduler()
local line = vim.split(obj.stdout or '', '\n')[1]
if not line then
err("Unable to detect git version as 'git --version' failed to return anything")
log.eprint(obj.stderr)
return
end
-- Sometime 'git --version' returns an empty string (#948)
if log.assert(type(line) == 'string', 'Unexpected output: ' .. line) then
return
end
if log.assert(vim.startswith(line, 'git version'), 'Unexpected output: ' .. line) then
return
end
local parts = vim.split(line, '%s+')
M.version = parse_version(parts[3])
end
--- Usage: check_version{2,3}
--- @param version {[1]: integer, [2]:integer, [3]:integer}?
--- @return boolean
function M.check(version)
if not M.version then
set_version()
end
if not M.version then
return false
end
if not version then
return false
end
if M.version.major < version[1] then
return false
end
if version[2] and M.version.minor < version[2] then
return false
end
if version[3] and M.version.patch < version[3] then
return false
end
return true
end
return M

View File

@ -0,0 +1,342 @@
local api = vim.api
--- @class Gitsigns.Hldef
--- @field [integer] string
--- @field desc string
--- @field hidden boolean
--- @field fg_factor number
local nvim10 = vim.fn.has('nvim-0.10') > 0
local M = {}
--- Use array of dict so we can iterate deterministically
--- Export for docgen
--- @type table<string,Gitsigns.Hldef>[]
M.hls = {
{
GitSignsAdd = {
'GitGutterAdd',
'SignifySignAdd',
'DiffAddedGutter',
nvim10 and 'Added' or 'diffAdded',
'DiffAdd',
desc = "Used for the text of 'add' signs.",
},
},
{
GitSignsChange = {
'GitGutterChange',
'SignifySignChange',
'DiffModifiedGutter',
nvim10 and 'Changed' or 'diffChanged',
'DiffChange',
desc = "Used for the text of 'change' signs.",
},
},
{
GitSignsDelete = {
'GitGutterDelete',
'SignifySignDelete',
'DiffRemovedGutter',
nvim10 and 'Removed' or 'diffRemoved',
'DiffDelete',
desc = "Used for the text of 'delete' signs.",
},
},
{
GitSignsChangedelete = {
'GitSignsChange',
desc = "Used for the text of 'changedelete' signs.",
},
},
{ GitSignsTopdelete = { 'GitSignsDelete', desc = "Used for the text of 'topdelete' signs." } },
{ GitSignsUntracked = { 'GitSignsAdd', desc = "Used for the text of 'untracked' signs." } },
{
GitSignsAddNr = {
'GitGutterAddLineNr',
'GitSignsAdd',
desc = "Used for number column (when `config.numhl == true`) of 'add' signs.",
},
},
{
GitSignsChangeNr = {
'GitGutterChangeLineNr',
'GitSignsChange',
desc = "Used for number column (when `config.numhl == true`) of 'change' signs.",
},
},
{
GitSignsDeleteNr = {
'GitGutterDeleteLineNr',
'GitSignsDelete',
desc = "Used for number column (when `config.numhl == true`) of 'delete' signs.",
},
},
{
GitSignsChangedeleteNr = {
'GitSignsChangeNr',
desc = "Used for number column (when `config.numhl == true`) of 'changedelete' signs.",
},
},
{
GitSignsTopdeleteNr = {
'GitSignsDeleteNr',
desc = "Used for number column (when `config.numhl == true`) of 'topdelete' signs.",
},
},
{
GitSignsUntrackedNr = {
'GitSignsAddNr',
desc = "Used for number column (when `config.numhl == true`) of 'untracked' signs.",
},
},
{
GitSignsAddLn = {
'GitGutterAddLine',
'SignifyLineAdd',
'DiffAdd',
desc = "Used for buffer line (when `config.linehl == true`) of 'add' signs.",
},
},
{
GitSignsChangeLn = {
'GitGutterChangeLine',
'SignifyLineChange',
'DiffChange',
desc = "Used for buffer line (when `config.linehl == true`) of 'change' signs.",
},
},
{
GitSignsChangedeleteLn = {
'GitSignsChangeLn',
desc = "Used for buffer line (when `config.linehl == true`) of 'changedelete' signs.",
},
},
{
GitSignsUntrackedLn = {
'GitSignsAddLn',
desc = "Used for buffer line (when `config.linehl == true`) of 'untracked' signs.",
},
},
-- Don't set GitSignsDeleteLn by default
-- {GitSignsDeleteLn = {}},
{ GitSignsStagedAdd = { 'GitSignsAdd', fg_factor = 0.5, hidden = true } },
{ GitSignsStagedChange = { 'GitSignsChange', fg_factor = 0.5, hidden = true } },
{ GitSignsStagedDelete = { 'GitSignsDelete', fg_factor = 0.5, hidden = true } },
{ GitSignsStagedChangedelete = { 'GitSignsChangedelete', fg_factor = 0.5, hidden = true } },
{ GitSignsStagedTopdelete = { 'GitSignsTopdelete', fg_factor = 0.5, hidden = true } },
{ GitSignsStagedAddNr = { 'GitSignsAddNr', fg_factor = 0.5, hidden = true } },
{ GitSignsStagedChangeNr = { 'GitSignsChangeNr', fg_factor = 0.5, hidden = true } },
{ GitSignsStagedDeleteNr = { 'GitSignsDeleteNr', fg_factor = 0.5, hidden = true } },
{ GitSignsStagedChangedeleteNr = { 'GitSignsChangedeleteNr', fg_factor = 0.5, hidden = true } },
{ GitSignsStagedTopdeleteNr = { 'GitSignsTopdeleteNr', fg_factor = 0.5, hidden = true } },
{ GitSignsStagedAddLn = { 'GitSignsAddLn', fg_factor = 0.5, hidden = true } },
{ GitSignsStagedChangeLn = { 'GitSignsChangeLn', fg_factor = 0.5, hidden = true } },
{ GitSignsStagedDeleteLn = { 'GitSignsDeleteLn', fg_factor = 0.5, hidden = true } },
{ GitSignsStagedChangedeleteLn = { 'GitSignsChangedeleteLn', fg_factor = 0.5, hidden = true } },
{ GitSignsStagedTopdeleteLn = { 'GitSignsTopdeleteLn', fg_factor = 0.5, hidden = true } },
{
GitSignsAddPreview = {
'GitGutterAddLine',
'SignifyLineAdd',
'DiffAdd',
desc = 'Used for added lines in previews.',
},
},
{
GitSignsDeletePreview = {
'GitGutterDeleteLine',
'SignifyLineDelete',
'DiffDelete',
desc = 'Used for deleted lines in previews.',
},
},
{ GitSignsCurrentLineBlame = { 'NonText', desc = 'Used for current line blame.' } },
{
GitSignsAddInline = {
'TermCursor',
desc = 'Used for added word diff regions in inline previews.',
},
},
{
GitSignsDeleteInline = {
'TermCursor',
desc = 'Used for deleted word diff regions in inline previews.',
},
},
{
GitSignsChangeInline = {
'TermCursor',
desc = 'Used for changed word diff regions in inline previews.',
},
},
{
GitSignsAddLnInline = {
'GitSignsAddInline',
desc = 'Used for added word diff regions when `config.word_diff == true`.',
},
},
{
GitSignsChangeLnInline = {
'GitSignsChangeInline',
desc = 'Used for changed word diff regions when `config.word_diff == true`.',
},
},
{
GitSignsDeleteLnInline = {
'GitSignsDeleteInline',
desc = 'Used for deleted word diff regions when `config.word_diff == true`.',
},
},
-- Currently unused
-- {GitSignsAddLnVirtLn = {'GitSignsAddLn'}},
-- {GitSignsChangeVirtLn = {'GitSignsChangeLn'}},
-- {GitSignsAddLnVirtLnInLine = {'GitSignsAddLnInline', }},
-- {GitSignsChangeVirtLnInLine = {'GitSignsChangeLnInline', }},
{
GitSignsDeleteVirtLn = {
'GitGutterDeleteLine',
'SignifyLineDelete',
'DiffDelete',
desc = 'Used for deleted lines shown by inline `preview_hunk_inline()` or `show_deleted()`.',
},
},
{
GitSignsDeleteVirtLnInLine = {
'GitSignsDeleteLnInline',
desc = 'Used for word diff regions in lines shown by inline `preview_hunk_inline()` or `show_deleted()`.',
},
},
{
GitSignsVirtLnum = {
'GitSignsDeleteVirtLn',
desc = 'Used for line numbers in inline hunks previews.',
},
},
}
---@param name string
---@return table<string, any>
local function get_hl(name)
--- @diagnostic disable-next-line:deprecated
return api.nvim_get_hl_by_name(name, true)
end
--- @param hl_name string
--- @return boolean
local function is_hl_set(hl_name)
-- TODO: this only works with `set termguicolors`
local exists, hl = pcall(get_hl, hl_name)
if not exists then
return false
end
local color = hl.foreground or hl.background or hl.reverse
return color ~= nil
end
--- @param x? number
--- @param factor number
--- @return number?
local function cmul(x, factor)
if not x or factor == 1 then
return x
end
local r = math.floor(x / 2 ^ 16)
local x1 = x - (r * 2 ^ 16)
local g = math.floor(x1 / 2 ^ 8)
local b = math.floor(x1 - (g * 2 ^ 8))
return math.floor(
math.floor(r * factor) * 2 ^ 16 + math.floor(g * factor) * 2 ^ 8 + math.floor(b * factor)
)
end
local function dprintf(fmt, ...)
require('gitsigns.debug.log').dprintf(fmt, ...)
end
--- @param hl string
--- @param hldef Gitsigns.Hldef
local function derive(hl, hldef)
for _, d in ipairs(hldef) do
if is_hl_set(d) then
dprintf('Deriving %s from %s', hl, d)
if hldef.fg_factor then
hldef.fg_factor = hldef.fg_factor or 1
local dh = get_hl(d)
api.nvim_set_hl(0, hl, {
default = true,
fg = cmul(dh.foreground, hldef.fg_factor),
bg = dh.background,
})
else
api.nvim_set_hl(0, hl, { default = true, link = d })
end
return
end
end
if hldef[1] and not hldef.fg_factor then
-- No fallback found which is set. Just link to the first fallback
-- if there are no modifiers
dprintf('Deriving %s from %s', hl, hldef[1])
api.nvim_set_hl(0, hl, { default = true, link = hldef[1] })
else
dprintf('Could not derive %s', hl)
end
end
--- Setup a GitSign* highlight by deriving it from other potentially present
--- highlights.
function M.setup_highlights()
for _, hlg in ipairs(M.hls) do
for hl, hldef in pairs(hlg) do
if is_hl_set(hl) then
-- Already defined
dprintf('Highlight %s is already defined', hl)
else
derive(hl, hldef)
end
end
end
end
function M.setup()
M.setup_highlights()
api.nvim_create_autocmd('ColorScheme', {
group = 'gitsigns',
callback = M.setup_highlights,
})
end
return M

View File

@ -0,0 +1,482 @@
local util = require('gitsigns.util')
local min, max = math.min, math.max
--- @alias Gitsigns.Hunk.Type
--- | "add"
--- | "change"
--- | "delete"
--- @class (exact) Gitsigns.Hunk.Node
--- @field start integer
--- @field count integer
--- @field lines string[]
--- @field no_nl_at_eof? true
--- @class (exact) Gitsigns.Hunk.Hunk
--- @field type Gitsigns.Hunk.Type
--- @field head string
--- @field added Gitsigns.Hunk.Node
--- @field removed Gitsigns.Hunk.Node
--- @field vend integer
--- @class (exact) Gitsigns.Hunk.Hunk_Public
--- @field type Gitsigns.Hunk.Type
--- @field head string
--- @field lines string[]
--- @field added Gitsigns.Hunk.Node
--- @field removed Gitsigns.Hunk.Node
local M = {}
--- @param old_start integer
--- @param old_count integer
--- @param new_start integer
--- @param new_count integer
--- @return Gitsigns.Hunk.Hunk
function M.create_hunk(old_start, old_count, new_start, new_count)
return {
removed = { start = old_start, count = old_count, lines = {} },
added = { start = new_start, count = new_count, lines = {} },
head = ('@@ -%d%s +%d%s @@'):format(
old_start,
old_count > 0 and ',' .. old_count or '',
new_start,
new_count > 0 and ',' .. new_count or ''
),
vend = new_start + math.max(new_count - 1, 0),
type = new_count == 0 and 'delete' or old_count == 0 and 'add' or 'change',
}
end
--- @param hunks Gitsigns.Hunk.Hunk[]
--- @param top integer
--- @param bot integer
--- @return Gitsigns.Hunk.Hunk
function M.create_partial_hunk(hunks, top, bot)
local pretop, precount = top, bot - top + 1
for _, h in ipairs(hunks) do
local added_in_hunk = h.added.count - h.removed.count
local added_in_range = 0
if h.added.start >= top and h.vend <= bot then
-- Range contains hunk
added_in_range = added_in_hunk
else
local added_above_bot = max(0, bot + 1 - (h.added.start + h.removed.count))
local added_above_top = max(0, top - (h.added.start + h.removed.count))
if h.added.start >= top and h.added.start <= bot then
-- Range top intersects hunk
added_in_range = added_above_bot
elseif h.vend >= top and h.vend <= bot then
-- Range bottom intersects hunk
added_in_range = added_in_hunk - added_above_top
pretop = pretop - added_above_top
elseif h.added.start <= top and h.vend >= bot then
-- Range within hunk
added_in_range = added_above_bot - added_above_top
pretop = pretop - added_above_top
end
if top > h.vend then
pretop = pretop - added_in_hunk
end
end
precount = precount - added_in_range
end
if precount == 0 then
pretop = pretop - 1
end
return M.create_hunk(pretop, precount, top, bot - top + 1)
end
--- @param hunk Gitsigns.Hunk.Hunk
--- @param fileformat string
--- @return string[]
function M.patch_lines(hunk, fileformat)
local lines = {} --- @type string[]
for _, l in ipairs(hunk.removed.lines) do
lines[#lines + 1] = '-' .. l
end
for _, l in ipairs(hunk.added.lines) do
lines[#lines + 1] = '+' .. l
end
if fileformat == 'dos' then
lines = util.strip_cr(lines)
end
return lines
end
local function tointeger(x)
return tonumber(x) --[[@as integer]]
end
--- @param line string
--- @return Gitsigns.Hunk.Hunk
function M.parse_diff_line(line)
local diffkey = vim.trim(vim.split(line, '@@', { plain = true })[2])
-- diffKey: "-xx,n +yy"
-- pre: {xx, n}, now: {yy}
--- @type string[], string[]
local pre, now = unpack(vim.tbl_map(
--- @param s string
--- @return string[]
function(s)
return vim.split(string.sub(s, 2), ',')
end,
vim.split(diffkey, ' ')
))
local hunk = M.create_hunk(
tointeger(pre[1]),
(tointeger(pre[2]) or 1),
tointeger(now[1]),
(tointeger(now[2]) or 1)
)
hunk.head = line
return hunk
end
--- @param hunk Gitsigns.Hunk.Hunk
--- @return integer
local function change_end(hunk)
if hunk.added.count == 0 then
-- delete
return hunk.added.start
elseif hunk.removed.count == 0 then
-- add
return hunk.added.start + hunk.added.count - 1
else
-- change
return hunk.added.start + min(hunk.added.count, hunk.removed.count) - 1
end
end
--- Calculate signs needed to be applied from a hunk for a specified line range.
--- @param hunk Gitsigns.Hunk.Hunk
--- @param next Gitsigns.Hunk.Hunk?
--- @param min_lnum integer
--- @param max_lnum integer
--- @param untracked boolean
--- @return Gitsigns.Sign[]
function M.calc_signs(hunk, next, min_lnum, max_lnum, untracked)
assert(
not untracked or hunk.type == 'add',
string.format('Invalid hunk with untracked=%s hunk="%s"', untracked, hunk.head)
)
min_lnum = min_lnum or 1
max_lnum = max_lnum or math.huge
local start, added, removed = hunk.added.start, hunk.added.count, hunk.removed.count
if hunk.type == 'delete' and start == 0 then
if min_lnum <= 1 then
-- topdelete signs get placed one row lower
return { { type = 'topdelete', count = removed, lnum = 1 } }
else
return {}
end
end
--- @type Gitsigns.Sign[]
local signs = {}
local cend = change_end(hunk)
-- if this is a change hunk, mark changedelete if lines were removed or if the
-- next hunk removes on this hunks last line
local changedelete = false
if hunk.type == 'change' then
changedelete = removed > added
if next ~= nil and next.type == 'delete' then
changedelete = changedelete or hunk.added.start + hunk.added.count - 1 == next.added.start
end
end
for lnum = max(start, min_lnum), min(cend, max_lnum) do
signs[#signs + 1] = {
type = (changedelete and lnum == cend) and 'changedelete'
or untracked and 'untracked'
or hunk.type,
count = lnum == start and (hunk.type == 'add' and added or removed) or nil,
lnum = lnum,
}
end
if hunk.type == 'change' and added > removed and hunk.vend >= min_lnum and cend <= max_lnum then
for lnum = max(cend, min_lnum), min(hunk.vend, max_lnum) do
signs[#signs + 1] = {
type = 'add',
count = lnum == hunk.vend and (added - removed) or nil,
lnum = lnum,
}
end
end
return signs
end
--- @param relpath string
--- @param hunks Gitsigns.Hunk.Hunk[]
--- @param mode_bits string
--- @param invert? boolean
--- @return string[]
function M.create_patch(relpath, hunks, mode_bits, invert)
invert = invert or false
local results = {
string.format('diff --git a/%s b/%s', relpath, relpath),
'index 000000..000000 ' .. mode_bits,
'--- a/' .. relpath,
'+++ b/' .. relpath,
}
local offset = 0
for _, process_hunk in ipairs(hunks) do
local start, pre_count, now_count =
process_hunk.removed.start, process_hunk.removed.count, process_hunk.added.count
if process_hunk.type == 'add' then
start = start + 1
end
local pre_lines = process_hunk.removed.lines
local now_lines = process_hunk.added.lines
if invert then
pre_count, now_count = now_count, pre_count --- @type integer, integer
pre_lines, now_lines = now_lines, pre_lines --- @type string[], string[]
end
table.insert(
results,
string.format('@@ -%s,%s +%s,%s @@', start, pre_count, start + offset, now_count)
)
for _, l in ipairs(pre_lines) do
results[#results + 1] = '-' .. l
end
if process_hunk.removed.no_nl_at_eof then
results[#results + 1] = '\\ No newline at end of file'
end
for _, l in ipairs(now_lines) do
results[#results + 1] = '+' .. l
end
if process_hunk.added.no_nl_at_eof then
results[#results + 1] = '\\ No newline at end of file'
end
process_hunk.removed.start = start + offset
offset = offset + (now_count - pre_count)
end
return results
end
--- @param hunks Gitsigns.Hunk.Hunk[]
--- @return Gitsigns.StatusObj
function M.get_summary(hunks)
--- @type Gitsigns.StatusObj
local status = { added = 0, changed = 0, removed = 0 }
for _, hunk in ipairs(hunks or {}) do
if hunk.type == 'add' then
status.added = status.added + hunk.added.count
elseif hunk.type == 'delete' then
status.removed = status.removed + hunk.removed.count
elseif hunk.type == 'change' then
local add, remove = hunk.added.count, hunk.removed.count
local delta = min(add, remove)
status.changed = status.changed + delta
status.added = status.added + add - delta
status.removed = status.removed + remove - delta
end
end
return status
end
--- @param lnum integer
--- @param hunks Gitsigns.Hunk.Hunk[]
--- @return Gitsigns.Hunk.Hunk?, integer?
function M.find_hunk(lnum, hunks)
for i, hunk in ipairs(hunks or {}) do
if lnum == 1 and hunk.added.start == 0 and hunk.vend == 0 then
return hunk, i
end
if hunk.added.start <= lnum and hunk.vend >= lnum then
return hunk, i
end
end
end
--- @param lnum integer
--- @param hunks Gitsigns.Hunk.Hunk[]
--- @param direction 'first'|'last'|'next'|'prev'
--- @param wrap boolean
--- @return integer?
function M.find_nearest_hunk(lnum, hunks, direction, wrap)
if direction == 'first' then
return 1
elseif direction == 'last' then
return #hunks
elseif direction == 'next' then
if hunks[1].added.start > lnum then
return 1
end
for i = #hunks, 1, -1 do
if hunks[i].added.start <= lnum then
if i + 1 <= #hunks and hunks[i + 1].added.start > lnum then
return i + 1
elseif wrap then
return 1
end
end
end
elseif direction == 'prev' then
if math.max(hunks[#hunks].vend) < lnum then
return #hunks
end
for i = 1, #hunks do
if lnum <= math.max(hunks[i].vend, 1) then
if i > 1 and math.max(hunks[i - 1].vend, 1) < lnum then
return i - 1
elseif wrap then
return #hunks
end
end
end
end
end
--- @param a Gitsigns.Hunk.Hunk[]?
--- @param b Gitsigns.Hunk.Hunk[]?
--- @return boolean
function M.compare_heads(a, b)
if (a == nil) ~= (b == nil) then
return true
elseif a and #a ~= #b then
return true
end
for i, ah in ipairs(a or {}) do
--- @diagnostic disable-next-line:need-check-nil
if b[i].head ~= ah.head then
return true
end
end
return false
end
--- @param a Gitsigns.Hunk.Hunk
--- @param b Gitsigns.Hunk.Hunk
--- @return boolean
local function compare_new(a, b)
if a.added.start ~= b.added.start then
return false
end
if a.added.count ~= b.added.count then
return false
end
for i = 1, a.added.count do
if a.added.lines[i] ~= b.added.lines[i] then
return false
end
end
return true
end
--- Return hunks in a using b's hunks as a filter. Only compare the 'new' section
--- of the hunk.
---
--- Eg. Given:
---
--- a = {
--- 1 = '@@ -24 +25,1 @@',
--- 2 = '@@ -32 +34,1 @@',
--- 3 = '@@ -37 +40,1 @@'
--- }
---
--- b = {
--- 1 = '@@ -26 +25,1 @@'
--- }
---
--- Since a[1] and b[1] introduce the same changes to the buffer (both have
--- +25,1), we exclude this hunk in the output so we return:
---
--- {
--- 1 = '@@ -32 +34,1 @@',
--- 2 = '@@ -37 +40,1 @@'
--- }
---
--- @param a Gitsigns.Hunk.Hunk[]
--- @param b Gitsigns.Hunk.Hunk[]
--- @return Gitsigns.Hunk.Hunk[]?
function M.filter_common(a, b)
if not a and not b then
return
end
a, b = a or {}, b or {}
local max_iter = math.max(#a, #b)
local a_i = 1
local b_i = 1
--- @type Gitsigns.Hunk.Hunk[]
local ret = {}
for _ = 1, max_iter do
local a_h, b_h = a[a_i], b[b_i]
if not a_h then
-- Reached the end of a
break
end
if not b_h then
-- Reached the end of b, add remainder of a
for i = a_i, #a do
ret[#ret + 1] = a[i]
end
break
end
if a_h.added.start > b_h.added.start then
-- a pointer is ahead of b; increment b pointer
b_i = b_i + 1
elseif a_h.added.start < b_h.added.start then
-- b pointer is ahead of a; add a_h to ret and increment a pointer
ret[#ret + 1] = a_h
a_i = a_i + 1
else -- a_h.start == b_h.start
-- a_h and b_h start on the same line, if hunks have the same changes then
-- skip (filtered) otherwise add a_h to ret. Increment both hunk
-- pointers
-- TODO(lewis6991): Be smarter; if bh intercepts then break down ah.
if not compare_new(a_h, b_h) then
ret[#ret + 1] = a_h
end
a_i = a_i + 1
b_i = b_i + 1
end
end
return ret
end
return M

View File

@ -0,0 +1,591 @@
local async = require('gitsigns.async')
local gs_cache = require('gitsigns.cache')
local cache = gs_cache.cache
local Signs = require('gitsigns.signs')
local Status = require('gitsigns.status')
local debounce_trailing = require('gitsigns.debounce').debounce_trailing
local throttle_by_id = require('gitsigns.debounce').throttle_by_id
local log = require('gitsigns.debug.log')
local dprint = log.dprint
local util = require('gitsigns.util')
local run_diff = require('gitsigns.diff')
local gs_hunks = require('gitsigns.hunks')
local config = require('gitsigns.config').config
local api = vim.api
local signs_normal --- @type Gitsigns.Signs
local signs_staged --- @type Gitsigns.Signs
local M = {}
--- @param bufnr integer
--- @param signs Gitsigns.Signs
--- @param hunks Gitsigns.Hunk.Hunk[]
--- @param top integer
--- @param bot integer
--- @param clear boolean
--- @param untracked boolean
local function apply_win_signs0(bufnr, signs, hunks, top, bot, clear, untracked)
if clear then
signs:remove(bufnr) -- Remove all signs
end
for i, hunk in ipairs(hunks or {}) do
--- @type Gitsigns.Hunk.Hunk?
local next = hunks[i + 1]
-- To stop the sign column width changing too much, if there are signs to be
-- added but none of them are visible in the window, then make sure to add at
-- least one sign. Only do this on the first call after an update when we all
-- the signs have been cleared.
if clear and i == 1 then
signs:add(
bufnr,
gs_hunks.calc_signs(hunk, next, hunk.added.start, hunk.added.start, untracked)
)
end
if top <= hunk.vend and bot >= hunk.added.start then
signs:add(bufnr, gs_hunks.calc_signs(hunk, next, top, bot, untracked))
end
if hunk.added.start > bot then
break
end
end
end
--- @param bufnr integer
--- @param top integer
--- @param bot integer
--- @param clear boolean
local function apply_win_signs(bufnr, top, bot, clear)
local bcache = cache[bufnr]
if not bcache then
return
end
local untracked = bcache.git_obj.object_name == nil
apply_win_signs0(bufnr, signs_normal, bcache.hunks, top, bot, clear, untracked)
if signs_staged then
apply_win_signs0(bufnr, signs_staged, bcache.hunks_staged, top, bot, clear, false)
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 first integer
--- @param last_orig integer
--- @param last_new integer
--- @return true?
function M.on_lines(buf, first, last_orig, last_new)
local bcache = cache[buf]
if not bcache then
dprint('Cache for buffer was nil. Detaching')
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)
end
-- Signs in changed regions get invalidated so we need to force a redraw if
-- any signs get removed.
if bcache.hunks and signs_normal:contains(buf, first, last_new) then
-- Force a sign redraw on the next update (fixes #521)
bcache.force_next_update = true
end
if signs_staged then
if bcache.hunks_staged and signs_staged:contains(buf, first, last_new) then
-- Force a sign redraw on the next update (fixes #521)
bcache.force_next_update = true
end
end
M.update_debounced(buf)
end
local ns = api.nvim_create_namespace('gitsigns')
--- @param bufnr integer
--- @param row integer
local function apply_word_diff(bufnr, row)
-- Don't run on folded lines
if vim.fn.foldclosed(row + 1) ~= -1 then
return
end
local bcache = cache[bufnr]
if not bcache or not bcache.hunks then
return
end
local line = api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1]
if not line then
-- Invalid line
return
end
local lnum = row + 1
local hunk = gs_hunks.find_hunk(lnum, bcache.hunks)
if not hunk then
-- No hunk at line
return
end
if hunk.added.count ~= hunk.removed.count then
-- Only word diff if added count == removed
return
end
local pos = lnum - hunk.added.start + 1
local added_line = hunk.added.lines[pos]
local removed_line = hunk.removed.lines[pos]
local _, added_regions = require('gitsigns.diff_int').run_word_diff(
{ removed_line },
{ added_line }
)
local cols = #line
for _, region in ipairs(added_regions) do
local rtype, scol, ecol = region[2], region[3] - 1, region[4] - 1
if ecol == scol then
-- Make sure region is at least 1 column wide so deletes can be shown
ecol = scol + 1
end
local hl_group = rtype == 'add' and 'GitSignsAddLnInline'
or rtype == 'change' and 'GitSignsChangeLnInline'
or 'GitSignsDeleteLnInline'
local opts = {
ephemeral = true,
priority = 1000,
}
if ecol > cols and ecol == scol + 1 then
-- delete on last column, use virtual text instead
opts.virt_text = { { ' ', hl_group } }
opts.virt_text_pos = 'overlay'
else
opts.end_col = ecol
opts.hl_group = hl_group
end
api.nvim_buf_set_extmark(bufnr, ns, row, scol, opts)
util.redraw({ buf = bufnr, range = { row, row + 1 } })
end
end
local ns_rm = api.nvim_create_namespace('gitsigns_removed')
local VIRT_LINE_LEN = 300
--- @param bufnr integer
local function clear_deleted(bufnr)
local marks = api.nvim_buf_get_extmarks(bufnr, ns_rm, 0, -1, {})
for _, mark in ipairs(marks) do
api.nvim_buf_del_extmark(bufnr, ns_rm, mark[1])
end
end
--- @param bufnr integer
--- @param nsd integer
--- @param hunk Gitsigns.Hunk.Hunk
function M.show_deleted(bufnr, nsd, hunk)
local virt_lines = {} --- @type {[1]: string, [2]: string}[][]
for i, line in ipairs(hunk.removed.lines) do
local vline = {} --- @type {[1]: string, [2]: string}[]
local last_ecol = 1
if config.word_diff then
local regions = require('gitsigns.diff_int').run_word_diff(
{ hunk.removed.lines[i] },
{ hunk.added.lines[i] }
)
for _, region in ipairs(regions) do
local rline, scol, ecol = region[1], region[3], region[4]
if rline > 1 then
break
end
vline[#vline + 1] = { line:sub(last_ecol, scol - 1), 'GitSignsDeleteVirtLn' }
vline[#vline + 1] = { line:sub(scol, ecol - 1), 'GitSignsDeleteVirtLnInline' }
last_ecol = ecol
end
end
if #line > 0 then
vline[#vline + 1] = { line:sub(last_ecol, -1), 'GitSignsDeleteVirtLn' }
end
-- Add extra padding so the entire line is highlighted
local padding = string.rep(' ', VIRT_LINE_LEN - #line)
vline[#vline + 1] = { padding, 'GitSignsDeleteVirtLn' }
virt_lines[i] = vline
end
local topdelete = hunk.added.start == 0 and hunk.type == 'delete'
local row = topdelete and 0 or hunk.added.start - 1
api.nvim_buf_set_extmark(bufnr, nsd, row, -1, {
virt_lines = virt_lines,
-- TODO(lewis6991): Note virt_lines_above doesn't work on row 0 neovim/neovim#16166
virt_lines_above = hunk.type ~= 'delete' or topdelete,
})
end
--- @param win integer
--- @param lnum integer
--- @param width integer
--- @return string str
--- @return {group:string, start:integer}[]? highlights
local function build_lno_str(win, lnum, width)
local has_col, statuscol =
pcall(api.nvim_get_option_value, 'statuscolumn', { win = win, scope = 'local' })
if has_col and statuscol and statuscol ~= '' then
local ok, data = pcall(api.nvim_eval_statusline, statuscol, {
winid = win,
use_statuscol_lnum = lnum,
highlights = true,
})
if ok then
return data.str, data.highlights
end
end
return string.format('%' .. width .. 'd', lnum)
end
--- @param bufnr integer
--- @param nsd integer
--- @param hunk Gitsigns.Hunk.Hunk
--- @param staged boolean?
--- @return integer winid
function M.show_deleted_in_float(bufnr, nsd, hunk, staged)
local cwin = api.nvim_get_current_win()
local virt_lines = {} --- @type {[1]: string, [2]: string}[][]
local textoff = vim.fn.getwininfo(cwin)[1].textoff --[[@as integer]]
for i = 1, hunk.removed.count do
local sc = build_lno_str(cwin, hunk.removed.start + i, textoff - 1)
virt_lines[i] = { { sc, 'LineNr' } }
end
local topdelete = hunk.added.start == 0 and hunk.type == 'delete'
local virt_lines_above = hunk.type ~= 'delete' or topdelete
local row = topdelete and 0 or hunk.added.start - 1
api.nvim_buf_set_extmark(bufnr, nsd, row, -1, {
virt_lines = virt_lines,
-- TODO(lewis6991): Note virt_lines_above doesn't work on row 0 neovim/neovim#16166
virt_lines_above = virt_lines_above,
virt_lines_leftcol = true,
})
local bcache = cache[bufnr]
local pbufnr = api.nvim_create_buf(false, true)
local text = staged and bcache.compare_text_head or bcache.compare_text
api.nvim_buf_set_lines(pbufnr, 0, -1, false, assert(text))
local width = api.nvim_win_get_width(0)
local bufpos_offset = virt_lines_above and not topdelete and 1 or 0
local pwinid = api.nvim_open_win(pbufnr, false, {
relative = 'win',
win = cwin,
width = width - textoff,
height = hunk.removed.count,
anchor = 'SW',
bufpos = { hunk.added.start - bufpos_offset, 0 },
style = 'minimal',
})
vim.bo[pbufnr].filetype = vim.bo[bufnr].filetype
vim.bo[pbufnr].bufhidden = 'wipe'
vim.wo[pwinid].scrolloff = 0
api.nvim_win_call(pwinid, function()
-- Expand folds
vim.cmd('normal ' .. 'zR')
-- Navigate to hunk
vim.cmd('normal ' .. tostring(hunk.removed.start) .. 'gg')
vim.cmd('normal ' .. vim.api.nvim_replace_termcodes('z<CR>', true, false, true))
end)
local last_lnum = api.nvim_buf_line_count(bufnr)
-- Apply highlights
for i = hunk.removed.start, hunk.removed.start + hunk.removed.count do
api.nvim_buf_set_extmark(pbufnr, nsd, i - 1, 0, {
hl_group = 'GitSignsDeleteVirtLn',
hl_eol = true,
end_row = i,
strict = i == last_lnum,
priority = 1000,
})
end
local removed_regions =
require('gitsigns.diff_int').run_word_diff(hunk.removed.lines, hunk.added.lines)
for _, region in ipairs(removed_regions) do
local start_row = (hunk.removed.start - 1) + (region[1] - 1)
local start_col = region[3] - 1
local end_col = region[4] - 1
api.nvim_buf_set_extmark(pbufnr, nsd, start_row, start_col, {
hl_group = 'GitSignsDeleteVirtLnInline',
end_col = end_col,
end_row = start_row,
priority = 1001,
})
end
return pwinid
end
--- @param bufnr integer
--- @param nsw integer
--- @param hunk Gitsigns.Hunk.Hunk
function M.show_added(bufnr, nsw, hunk)
local start_row = hunk.added.start - 1
for offset = 0, hunk.added.count - 1 do
local row = start_row + offset
api.nvim_buf_set_extmark(bufnr, nsw, row, 0, {
end_row = row + 1,
hl_group = 'GitSignsAddPreview',
hl_eol = true,
priority = 1000,
})
end
local _, added_regions =
require('gitsigns.diff_int').run_word_diff(hunk.removed.lines, hunk.added.lines)
for _, region in ipairs(added_regions) do
local offset, rtype, scol, ecol = region[1] - 1, region[2], region[3] - 1, region[4] - 1
-- Special case to handle cr at eol in buffer but not in show text
local cr_at_eol_change = rtype == 'change' and vim.endswith(hunk.added.lines[offset + 1], '\r')
api.nvim_buf_set_extmark(bufnr, nsw, start_row + offset, scol, {
end_col = ecol,
strict = not cr_at_eol_change,
hl_group = rtype == 'add' and 'GitSignsAddInline'
or rtype == 'change' and 'GitSignsChangeInline'
or 'GitSignsDeleteInline',
priority = 1001,
})
end
end
--- @param bufnr integer
local function update_show_deleted(bufnr)
local bcache = cache[bufnr]
clear_deleted(bufnr)
if config.show_deleted then
for _, hunk in ipairs(bcache.hunks or {}) do
M.show_deleted(bufnr, ns_rm, hunk)
end
end
end
--- @async
--- @nodiscard
--- @param bufnr integer
--- @param check_compare_text? boolean
--- @return boolean
function M.schedule(bufnr, check_compare_text)
async.scheduler()
if not api.nvim_buf_is_valid(bufnr) then
dprint('Buffer not valid, aborting')
return false
end
if not cache[bufnr] then
dprint('Has detached, aborting')
return false
end
if check_compare_text and not cache[bufnr].compare_text then
dprint('compare_text was invalid, aborting')
return false
end
return true
end
--- Ensure updates cannot be interleaved.
--- Since updates are asynchronous we need to make sure an update isn't performed
--- whilst another one is in progress. If this happens then schedule another
--- update after the current one has completed.
--- @param bufnr integer
M.update = throttle_by_id(function(bufnr)
if not M.schedule(bufnr) then
return
end
local bcache = cache[bufnr]
local old_hunks, old_hunks_staged = bcache.hunks, bcache.hunks_staged
bcache.hunks, bcache.hunks_staged = nil, nil
local git_obj = bcache.git_obj
local file_mode = bcache.file_mode
if not bcache.compare_text or config._refresh_staged_on_update or file_mode then
if file_mode then
bcache.compare_text = util.file_lines(git_obj.file)
else
bcache.compare_text = git_obj:get_show_text()
end
if not M.schedule(bufnr, true) then
return
end
end
local buftext = util.buf_lines(bufnr)
bcache.hunks = run_diff(bcache.compare_text, buftext)
if not M.schedule(bufnr) then
return
end
if config._signs_staged_enable and not file_mode and not git_obj.revision then
if not bcache.compare_text_head or config._refresh_staged_on_update then
bcache.compare_text_head = git_obj:get_show_text('HEAD')
if not M.schedule(bufnr, true) then
return
end
end
local hunks_head = run_diff(bcache.compare_text_head, buftext)
if not M.schedule(bufnr) then
return
end
bcache.hunks_staged = gs_hunks.filter_common(hunks_head, bcache.hunks)
end
-- Note the decoration provider may have invalidated bcache.hunks at this
-- point
if
bcache.force_next_update
or gs_hunks.compare_heads(bcache.hunks, old_hunks)
or gs_hunks.compare_heads(bcache.hunks_staged, old_hunks_staged)
then
-- Apply signs to the window. Other signs will be added by the decoration
-- provider as they are drawn.
apply_win_signs(bufnr, vim.fn.line('w0'), vim.fn.line('w$'), true)
update_show_deleted(bufnr)
bcache.force_next_update = false
local summary = gs_hunks.get_summary(bcache.hunks)
summary.head = git_obj.repo.abbrev_head
Status:update(bufnr, summary)
end
end, true)
--- @param bufnr integer
--- @param keep_signs? boolean
function M.detach(bufnr, keep_signs)
if not keep_signs then
-- Remove all signs
signs_normal:remove(bufnr)
if signs_staged then
signs_staged:remove(bufnr)
end
end
end
function M.reset_signs()
-- Remove all signs
if signs_normal then
signs_normal:reset()
end
if signs_staged then
signs_staged:reset()
end
end
--- @param _cb 'win'
--- @param _winid integer
--- @param bufnr integer
--- @param topline integer
--- @param botline_guess integer
--- @return false?
local function on_win(_cb, _winid, bufnr, topline, botline_guess)
local bcache = cache[bufnr]
if not bcache or not bcache.hunks then
return false
end
local botline = math.min(botline_guess, api.nvim_buf_line_count(bufnr))
apply_win_signs(bufnr, topline + 1, botline + 1, false)
if not (config.word_diff and config.diff_opts.internal) then
return false
end
end
--- @param _cb 'line'
--- @param _winid integer
--- @param bufnr integer
--- @param row integer
local function on_line(_cb, _winid, bufnr, row)
apply_word_diff(bufnr, row)
end
function M.setup()
-- Calling this before any await calls will stop nvim's intro messages being
-- displayed
api.nvim_set_decoration_provider(ns, {
on_win = on_win,
on_line = on_line,
})
signs_normal = Signs.new(config.signs)
if config._signs_staged_enable then
signs_staged = Signs.new(config._signs_staged, 'staged')
end
M.update_debounced = debounce_trailing(config.update_debounce, async.create(1, M.update))
end
return M

View File

@ -0,0 +1,20 @@
local levels = vim.log.levels
local M = {}
--- @type fun(fmt: string, ...: string)
M.warn = vim.schedule_wrap(function(fmt, ...)
vim.notify(fmt:format(...), levels.WARN, { title = 'gitsigns' })
end)
--- @type fun(fmt: string, ...: string)
M.error = vim.schedule_wrap(function(fmt, ...)
vim.notify(fmt:format(...), vim.log.levels.ERROR, { title = 'gitsigns' })
end)
--- @type fun(fmt: string, ...: string)
M.error_once = vim.schedule_wrap(function(fmt, ...)
vim.notify_once(fmt:format(...), vim.log.levels.ERROR, { title = 'gitsigns' })
end)
return M

View File

@ -0,0 +1,282 @@
local M = {}
local api = vim.api
--- @param bufnr integer
--- @param lines string[]
--- @return integer
local function bufnr_calc_width(bufnr, lines)
return api.nvim_buf_call(bufnr, function()
local width = 0
for _, l in ipairs(lines) do
if vim.fn.type(l) == vim.v.t_string then
local len = vim.fn.strdisplaywidth(l)
if len > width then
width = len
end
end
end
return width + 1 -- Add 1 for some miinor padding
end)
end
-- Expand height until all lines are visible to account for wrapped lines.
--- @param winid integer
--- @param nlines integer
--- @param border string
local function expand_height(winid, nlines, border)
local newheight = 0
local maxheight = vim.o.lines - vim.o.cmdheight - (border ~= '' and 2 or 0)
for _ = 0, 50 do
local winheight = api.nvim_win_get_height(winid)
if newheight > winheight then
-- Window must be max height
break
end
--- @type integer
local wd = api.nvim_win_call(winid, function()
return vim.fn.line('w$')
end)
if wd >= nlines then
break
end
newheight = winheight + nlines - wd
if newheight > maxheight then
api.nvim_win_set_height(winid, maxheight)
break
end
api.nvim_win_set_height(winid, newheight)
end
end
--- @class (exact) Gitsigns.HlMark
--- @field hl_group string
--- @field start_row? integer
--- @field start_col? integer
--- @field end_row? integer
--- @field end_col? integer
--- Each element represents a multi-line segment
--- @alias Gitsigns.LineSpec { [1]: string, [2]: Gitsigns.HlMark[]}[][]
--- @param hlmarks Gitsigns.HlMark[]
--- @param row_offset integer
local function offset_hlmarks(hlmarks, row_offset)
for _, h in ipairs(hlmarks) do
h.start_row = (h.start_row or 0) + row_offset
if h.end_row then
h.end_row = h.end_row + row_offset
end
end
end
--- Partition the text and Gitsigns.HlMarks from a Gitsigns.LineSpec
--- @param fmt Gitsigns.LineSpec
--- @return string[]
--- @return Gitsigns.HlMark[]
local function partition_linesspec(fmt)
local lines = {} --- @type string[]
local ret = {} --- @type Gitsigns.HlMark[]
local row = 0
for _, section in ipairs(fmt) do
local section_text = {} --- @type string[]
local col = 0
for _, part in ipairs(section) do
local text, hls = part[1], part[2]
section_text[#section_text + 1] = text
local _, no_lines = text:gsub('\n', '')
local end_row = row + no_lines --- @type integer
local end_col = no_lines > 0 and 0 or col + #text --- @type integer
if type(hls) == 'string' then
ret[#ret + 1] = {
hl_group = hls,
start_row = row,
end_row = end_row,
start_col = col,
end_col = end_col,
}
else -- hl is Gitsigns.HlMark[]
offset_hlmarks(hls, row)
vim.list_extend(ret, hls)
end
row = end_row
col = end_col
end
local section_lines = vim.split(table.concat(section_text), '\n', { plain = true })
vim.list_extend(lines, section_lines)
row = row + 1
end
return lines, ret
end
--- @param id string|true
local function close_all_but(id)
for _, winid in ipairs(api.nvim_list_wins()) do
if vim.w[winid].gitsigns_preview ~= nil and vim.w[winid].gitsigns_preview ~= id then
pcall(api.nvim_win_close, winid, true)
end
end
end
--- @param id string
function M.close(id)
for _, winid in ipairs(api.nvim_list_wins()) do
if vim.w[winid].gitsigns_preview == id then
pcall(api.nvim_win_close, winid, true)
end
end
end
local ns = api.nvim_create_namespace('gitsigns_popup')
--- @param lines string[]
--- @param highlights Gitsigns.HlMark[]
--- @return integer bufnr
local function create_buf(lines, highlights)
local ts = vim.bo.tabstop
local bufnr = api.nvim_create_buf(false, true)
assert(bufnr, 'Failed to create buffer')
-- In case nvim was opened with '-M'
vim.bo[bufnr].modifiable = true
api.nvim_buf_set_lines(bufnr, 0, -1, true, lines)
vim.bo[bufnr].modifiable = false
-- Set tabstop before calculating the buffer width so that the correct width
-- is calculated
vim.bo[bufnr].tabstop = ts
for _, hl in ipairs(highlights) do
local ok, err = pcall(api.nvim_buf_set_extmark, bufnr, ns, hl.start_row, hl.start_col or 0, {
hl_group = hl.hl_group,
end_row = hl.end_row,
end_col = hl.end_col,
hl_eol = true,
})
if not ok then
error(vim.inspect(hl) .. '\n' .. err)
end
end
return bufnr
end
--- @param bufnr integer
--- @param opts table
--- @param id? string|true
--- @return integer winid
local function create_win(bufnr, opts, id)
id = id or true
-- Close any popups not matching id
close_all_but(id)
local lines = api.nvim_buf_get_lines(bufnr, 0, -1, true)
local opts1 = vim.deepcopy(opts or {})
opts1.height = opts1.height or #lines -- Guess, adjust later
opts1.width = opts1.width or bufnr_calc_width(bufnr, lines)
local winid = api.nvim_open_win(bufnr, false, opts1)
vim.w[winid].gitsigns_preview = id
if not opts.height then
expand_height(winid, #lines, opts.border)
end
if opts1.style == 'minimal' then
-- If 'signcolumn' = auto:1-2, then a empty signcolumn will appear and cause
-- line wrapping.
vim.wo[winid].signcolumn = 'no'
end
-- Close the popup when navigating to any window which is not the preview
-- itself.
local group = 'gitsigns_popup'
local group_id = api.nvim_create_augroup(group, {})
local old_cursor = api.nvim_win_get_cursor(0)
vim.keymap.set('n', 'q', '<cmd>quit!<cr>', { silent = true, buffer = bufnr })
api.nvim_create_autocmd({ 'CursorMoved', 'CursorMovedI' }, {
group = group_id,
callback = function()
local cursor = api.nvim_win_get_cursor(0)
-- Did the cursor REALLY change (neovim/neovim#12923)
if
(old_cursor[1] ~= cursor[1] or old_cursor[2] ~= cursor[2])
and api.nvim_get_current_win() ~= winid
then
-- Clear the augroup
api.nvim_create_augroup(group, {})
pcall(api.nvim_win_close, winid, true)
return
end
old_cursor = cursor
end,
})
api.nvim_create_autocmd('WinClosed', {
pattern = tostring(winid),
group = group_id,
callback = function()
-- Clear the augroup
api.nvim_create_augroup(group, {})
end,
})
-- update window position to follow the cursor when scrolling
api.nvim_create_autocmd('WinScrolled', {
buffer = api.nvim_get_current_buf(),
group = group_id,
callback = function()
if api.nvim_win_is_valid(winid) then
api.nvim_win_set_config(winid, opts1)
end
end,
})
return winid
end
--- @param lines_spec {[1]: string, [2]: string|Gitsigns.HlMark[]}[][]
--- @param opts table
--- @param id? string
--- @return integer winid, integer bufnr
function M.create(lines_spec, opts, id)
local lines, highlights = partition_linesspec(lines_spec)
local bufnr = create_buf(lines, highlights)
local winid = create_win(bufnr, opts, id)
return winid, bufnr
end
--- @param id string
--- @return integer? winid
function M.is_open(id)
for _, winid in ipairs(api.nvim_list_wins()) do
if vim.w[winid].gitsigns_preview == id then
return winid
end
end
end
--- @param id string
--- @return integer? winid
function M.focus_open(id)
local winid = M.is_open(id)
if winid then
api.nvim_set_current_win(winid)
end
return winid
end
return M

View File

@ -0,0 +1,26 @@
local M = {}
function M.mk_repeatable(fn)
return function(...)
local args = { ... }
local nargs = select('#', ...)
vim.go.operatorfunc = "v:lua.require'gitsigns.repeat'.repeat_action"
M.repeat_action = function()
fn(unpack(args, 1, nargs))
if vim.fn.exists('*repeat#set') == 1 then
local action = vim.api.nvim_replace_termcodes(
string.format('<cmd>call %s()<cr>', vim.go.operatorfunc),
true,
true,
true
)
vim.fn['repeat#set'](action, -1)
end
end
vim.cmd('normal! g@l')
end
end
return M

View File

@ -0,0 +1,135 @@
local api = vim.api
local config = require('gitsigns.config').config
--- @class Gitsigns.Sign
--- @field type Gitsigns.SignType
--- @field count? integer
--- @field lnum integer
--- @class Gitsigns.Signs
--- @field hls table<Gitsigns.SignType,Gitsigns.SignConfig>
--- @field name string
--- @field group string
--- @field config table<string,Gitsigns.SignConfig>
--- @field ns integer
local M = {}
--- @param buf integer
--- @param last_orig? integer
--- @param last_new? integer
function M:on_lines(buf, _, last_orig, last_new)
-- Remove extmarks on line deletions to mimic
-- the behaviour of vim signs.
if last_orig > last_new then
self:remove(buf, last_new + 1, last_orig)
end
end
--- @param bufnr integer
--- @param start_lnum? integer
--- @param end_lnum? integer
function M:remove(bufnr, start_lnum, end_lnum)
if start_lnum then
api.nvim_buf_clear_namespace(bufnr, self.ns, start_lnum - 1, end_lnum or start_lnum)
else
api.nvim_buf_clear_namespace(bufnr, self.ns, 0, -1)
end
end
---@param bufnr integer
---@param signs Gitsigns.Sign[]
function M:add(bufnr, signs)
if not config.signcolumn and not config.numhl and not config.linehl then
-- Don't place signs if it won't show anything
return
end
for _, s in ipairs(signs) do
if not self:contains(bufnr, s.lnum) then
local cs = self.config[s.type]
local text = cs.text
if config.signcolumn and cs.show_count and s.count then
local count = s.count
local cc = config.count_chars
local count_char = cc[count] or cc['+'] or ''
text = cs.text .. count_char
end
local hls = self.hls[s.type]
local ok, err = pcall(api.nvim_buf_set_extmark, bufnr, self.ns, s.lnum - 1, -1, {
id = s.lnum,
sign_text = config.signcolumn and text or '',
priority = config.sign_priority,
sign_hl_group = hls.hl,
number_hl_group = config.numhl and hls.numhl or nil,
line_hl_group = config.linehl and hls.linehl or nil,
})
if not ok and config.debug_mode then
vim.schedule(function()
error(table.concat({
string.format('Error placing extmark on line %d', s.lnum),
err,
}, '\n'))
end)
end
end
end
end
---@param bufnr integer
---@param start integer
---@param last? integer
---@return boolean
function M:contains(bufnr, start, last)
local marks = api.nvim_buf_get_extmarks(
bufnr,
self.ns,
{ start - 1, 0 },
{ last or start, 0 },
{ limit = 1 }
)
return #marks > 0
end
function M:reset()
for _, buf in ipairs(api.nvim_list_bufs()) do
self:remove(buf)
end
end
-- local function capitalise_word(x: string): string
-- return x:sub(1, 1):upper()..x:sub(2)
-- end
function M.new(cfg, name)
local __FUNC__ = 'signs.init'
-- Add when config.signs.*.[hl,numhl,linehl] are removed
-- for _, t in ipairs {
-- 'add',
-- 'change',
-- 'delete',
-- 'topdelete',
-- 'changedelete',
-- 'untracked',
-- } do
-- local hl = string.format('GitSigns%s%s', name, capitalise_word(t))
-- obj.hls[t] = {
-- hl = hl,
-- numhl = hl..'Nr',
-- linehl = hl..'Ln',
-- }
-- end
local self = setmetatable({}, { __index = M })
self.config = cfg
self.hls = name == 'staged' and config._signs_staged or config.signs
self.group = 'gitsigns_signs_' .. (name or '')
self.ns = api.nvim_create_namespace(self.group)
return self
end
return M

View File

@ -0,0 +1,52 @@
local api = vim.api
--- @class (exact) Gitsigns.StatusObj
--- @field added? integer
--- @field removed? integer
--- @field changed? integer
--- @field head? string
--- @field root? string
--- @field gitdir? string
local M = {}
--- @param bufnr integer
local function autocmd_update(bufnr)
api.nvim_exec_autocmds('User', {
pattern = 'GitSignsUpdate',
modeline = false,
data = { buffer = bufnr },
})
end
--- @param bufnr integer
--- @param status Gitsigns.StatusObj
function M:update(bufnr, status)
if not api.nvim_buf_is_loaded(bufnr) then
return
end
local bstatus = vim.b[bufnr].gitsigns_status_dict
if bstatus then
status = vim.tbl_extend('force', bstatus, status)
end
vim.b[bufnr].gitsigns_head = status.head or ''
vim.b[bufnr].gitsigns_status_dict = status
local config = require('gitsigns.config').config
vim.b[bufnr].gitsigns_status = config.status_formatter(status)
autocmd_update(bufnr)
end
function M:clear(bufnr)
if not api.nvim_buf_is_loaded(bufnr) then
return
end
vim.b[bufnr].gitsigns_head = nil
vim.b[bufnr].gitsigns_status_dict = nil
vim.b[bufnr].gitsigns_status = nil
autocmd_update(bufnr)
end
return M

View File

@ -0,0 +1,19 @@
local log = require('gitsigns.debug.log')
local M = {}
local system = vim.system or require('gitsigns.system.compat')
--- @param cmd string[]
--- @param opts vim.SystemOpts
--- @param on_exit fun(obj: vim.SystemCompleted)
--- @return vim.SystemObj
function M.system(cmd, opts, on_exit)
local __FUNC__ = 'run_job'
if log.debug_mode then
log.dprint(table.concat(cmd, ' '))
end
return system(cmd, opts, on_exit)
end
return M

View File

@ -0,0 +1,338 @@
local uv = vim.loop
--- @param handle uv.uv_handle_t?
local function close_handle(handle)
if handle and not handle:is_closing() then
handle:close()
end
end
--- @type vim.SystemSig
local SIG = {
HUP = 1, -- Hangup
INT = 2, -- Interrupt from keyboard
KILL = 9, -- Kill signal
TERM = 15, -- Termination signal
-- STOP = 17,19,23 -- Stop the process
}
--- @param state vim.SystemState
local function close_handles(state)
close_handle(state.handle)
close_handle(state.stdin)
close_handle(state.stdout)
close_handle(state.stderr)
close_handle(state.timer)
end
--- @class Gitsigns.SystemObj : vim.SystemObj
--- @field private _state vim.SystemState
local SystemObj = {}
--- @param state vim.SystemState
--- @return vim.SystemObj
local function new_systemobj(state)
return setmetatable({
pid = state.pid,
_state = state,
}, { __index = SystemObj })
end
--- @param signal integer|string
function SystemObj:kill(signal)
self._state.handle:kill(signal)
end
--- @package
--- @param signal? vim.SystemSig
function SystemObj:_timeout(signal)
self._state.done = 'timeout'
self:kill(signal or SIG.TERM)
end
local MAX_TIMEOUT = 2 ^ 31
--- @param timeout? integer
--- @return vim.SystemCompleted
function SystemObj:wait(timeout)
local state = self._state
local done = vim.wait(timeout or state.timeout or MAX_TIMEOUT, function()
return state.result ~= nil
end, nil, true)
if not done then
-- Send sigkill since this cannot be caught
self:_timeout(SIG.KILL)
vim.wait(timeout or state.timeout or MAX_TIMEOUT, function()
return state.result ~= nil
end, nil, true)
end
return state.result
end
--- @param data string[]|string|nil
function SystemObj:write(data)
local stdin = self._state.stdin
if not stdin then
error('stdin has not been opened on this object')
end
if type(data) == 'table' then
for _, v in ipairs(data) do
stdin:write(v)
stdin:write('\n')
end
elseif type(data) == 'string' then
stdin:write(data)
elseif data == nil then
-- Shutdown the write side of the duplex stream and then close the pipe.
-- Note shutdown will wait for all the pending write requests to complete
-- TODO(lewis6991): apparently shutdown doesn't behave this way.
-- (https://github.com/neovim/neovim/pull/17620#discussion_r820775616)
stdin:write('', function()
stdin:shutdown(function()
if stdin then
stdin:close()
end
end)
end)
end
end
--- @return boolean
function SystemObj:is_closing()
local handle = self._state.handle
return handle == nil or handle:is_closing() or false
end
--- @param output fun(err:string?, data: string?)|false
--- @return uv.uv_stream_t?
--- @return fun(err:string?, data: string?)? Handler
local function setup_output(output)
if output == nil then
return assert(uv.new_pipe(false)), nil
end
if type(output) == 'function' then
return assert(uv.new_pipe(false)), output
end
assert(output == false)
return nil, nil
end
--- @param input string|string[]|true|nil
--- @return uv.uv_stream_t?
--- @return string|string[]?
local function setup_input(input)
if not input then
return
end
local towrite --- @type string|string[]?
if type(input) == 'string' or type(input) == 'table' then
towrite = input
end
return assert(uv.new_pipe(false)), towrite
end
--- @return table<string,string>
local function base_env()
local env = vim.fn.environ() --- @type table<string,string>
env['NVIM'] = vim.v.servername
env['NVIM_LISTEN_ADDRESS'] = nil
return env
end
--- uv.spawn will completely overwrite the environment
--- when we just want to modify the existing one, so
--- make sure to prepopulate it with the current env.
--- @param env? table<string,string|number>
--- @param clear_env? boolean
--- @return string[]?
local function setup_env(env, clear_env)
if clear_env then
return env
end
--- @type table<string,string|number>
env = vim.tbl_extend('force', base_env(), env or {})
local renv = {} --- @type string[]
for k, v in pairs(env) do
renv[#renv + 1] = string.format('%s=%s', k, tostring(v))
end
return renv
end
--- @param stream uv.uv_stream_t
--- @param text? boolean
--- @param bucket string[]
--- @return fun(err: string?, data: string?)
local function default_handler(stream, text, bucket)
return function(err, data)
if err then
error(err)
end
if data ~= nil then
if text then
bucket[#bucket + 1] = data:gsub('\r\n', '\n')
else
bucket[#bucket + 1] = data
end
else
stream:read_stop()
stream:close()
end
end
end
--- @param cmd string
--- @param opts uv.spawn.options
--- @param on_exit fun(code: integer, signal: integer)
--- @param on_error fun()
--- @return uv.uv_process_t, integer
local function spawn(cmd, opts, on_exit, on_error)
local handle, pid_or_err = uv.spawn(cmd, opts, on_exit)
if not handle then
on_error()
error(pid_or_err)
end
return handle, pid_or_err --[[@as integer]]
end
--- @param timeout integer
--- @param cb fun()
--- @return uv.uv_timer_t
local function timer_oneshot(timeout, cb)
local timer = assert(uv.new_timer())
timer:start(timeout, 0, function()
timer:stop()
timer:close()
cb()
end)
return timer
end
--- @param state vim.SystemState
--- @param code integer
--- @param signal integer
--- @param on_exit fun(result: vim.SystemCompleted)?
local function _on_exit(state, code, signal, on_exit)
close_handles(state)
local check = assert(uv.new_check())
check:start(function()
for _, pipe in pairs({ state.stdin, state.stdout, state.stderr }) do
if not pipe:is_closing() then
return
end
end
check:stop()
check:close()
if state.done == nil then
state.done = true
end
if (code == 0 or code == 1) and state.done == 'timeout' then
-- Unix: code == 0
-- Windows: code == 1
code = 124
end
local stdout_data = state.stdout_data
local stderr_data = state.stderr_data
state.result = {
code = code,
signal = signal,
stdout = stdout_data and table.concat(stdout_data) or nil,
stderr = stderr_data and table.concat(stderr_data) or nil,
}
if on_exit then
on_exit(state.result)
end
end)
end
--- Run a system command
---
--- @param cmd string[]
--- @param opts? vim.SystemOpts
--- @param on_exit? fun(out: vim.SystemCompleted)
--- @return vim.SystemObj
local function system(cmd, opts, on_exit)
local __FUNC__ = 'run_job'
vim.validate({
cmd = { cmd, 'table' },
opts = { opts, 'table', true },
on_exit = { on_exit, 'function', true },
})
opts = opts or {}
local stdout, stdout_handler = setup_output(opts.stdout)
local stderr, stderr_handler = setup_output(opts.stderr)
local stdin, towrite = setup_input(opts.stdin)
--- @type vim.SystemState
local state = {
done = false,
cmd = cmd,
timeout = opts.timeout,
stdin = stdin,
stdout = stdout,
stderr = stderr,
}
--- @diagnostic disable-next-line:missing-fields
state.handle, state.pid = spawn(cmd[1], {
args = vim.list_slice(cmd, 2),
stdio = { stdin, stdout, stderr },
cwd = opts.cwd,
--- @diagnostic disable-next-line:assign-type-mismatch
env = setup_env(opts.env, opts.clear_env),
detached = opts.detach,
hide = true,
}, function(code, signal)
_on_exit(state, code, signal, on_exit)
end, function()
close_handles(state)
end)
if stdout then
state.stdout_data = {}
stdout:read_start(stdout_handler or default_handler(stdout, opts.text, state.stdout_data))
end
if stderr then
state.stderr_data = {}
stderr:read_start(stderr_handler or default_handler(stderr, opts.text, state.stderr_data))
end
local obj = new_systemobj(state)
if towrite then
obj:write(towrite)
obj:write(nil) -- close the stream
end
if opts.timeout then
state.timer = timer_oneshot(opts.timeout, function()
if state.handle and state.handle:is_active() then
obj:_timeout()
end
end)
end
return obj
end
return system

View File

@ -0,0 +1,34 @@
local M = {}
local function eq(act, exp)
assert(act == exp, string.format('%s != %s', act, exp))
end
M._tests = {}
M._tests.expand_format = function()
local util = require('gitsigns.util')
assert('hello % world % 2021' == util.expand_format('<var1> % <var2> % <var_time:%Y>', {
var1 = 'hello',
var2 = 'world',
var_time = 1616838297,
}))
end
M._tests.test_args = function()
local parse_args = require('gitsigns.cli.argparse').parse_args
local pos_args, named_args = parse_args('hello there key=value, key1="a b c"')
eq(pos_args[1], 'hello')
eq(pos_args[2], 'there')
eq(named_args.key, 'value,')
eq(named_args.key1, 'a b c')
pos_args, named_args = parse_args('base=HEAD~1 posarg')
eq(named_args.base, 'HEAD~1')
eq(pos_args[1], 'posarg')
end
return M

View File

@ -0,0 +1,369 @@
local M = {}
function M.path_exists(path)
return vim.loop.fs_stat(path) and true or false
end
local jit_os --- @type string
if jit then
jit_os = jit.os:lower()
end
local is_unix = false
if jit_os then
is_unix = jit_os == 'linux' or jit_os == 'osx' or jit_os == 'bsd'
else
local binfmt = package.cpath:match('%p[\\|/]?%p(%a+)')
is_unix = binfmt ~= 'dll'
end
--- @param file string
--- @return string
function M.dirname(file)
return file:match(string.format('^(.+)%s[^%s]+', M.path_sep, M.path_sep))
end
--- @param path string
--- @return string[]
function M.file_lines(path)
local file = assert(io.open(path, 'rb'))
local contents = file:read('*a')
file:close()
return vim.split(contents, '\n')
end
M.path_sep = package.config:sub(1, 1)
--- @param ... integer
--- @return string
local function make_bom(...)
local r = {}
---@diagnostic disable-next-line:no-unknown
for i, a in ipairs({ ... }) do
---@diagnostic disable-next-line:no-unknown
r[i] = string.char(a)
end
return table.concat(r)
end
local BOM_TABLE = {
['utf-8'] = make_bom(0xef, 0xbb, 0xbf),
['utf-16le'] = make_bom(0xff, 0xfe),
['utf-16'] = make_bom(0xfe, 0xff),
['utf-16be'] = make_bom(0xfe, 0xff),
['utf-32le'] = make_bom(0xff, 0xfe, 0x00, 0x00),
['utf-32'] = make_bom(0xff, 0xfe, 0x00, 0x00),
['utf-32be'] = make_bom(0x00, 0x00, 0xfe, 0xff),
['utf-7'] = make_bom(0x2b, 0x2f, 0x76),
['utf-1'] = make_bom(0xf7, 0x54, 0x4c),
}
---@param x string
---@param encoding string
---@return string
local function add_bom(x, encoding)
local bom = BOM_TABLE[encoding]
if bom then
return bom .. x
end
return x
end
--- @param bufnr integer
--- @return string[]
function M.buf_lines(bufnr)
-- nvim_buf_get_lines strips carriage returns if fileformat==dos
local buftext = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
local dos = vim.bo[bufnr].fileformat == 'dos'
if dos then
for i = 1, #buftext - 1 do
buftext[i] = buftext[i] .. '\r'
end
end
if vim.bo[bufnr].endofline then
-- Add CR to the last line
if dos then
buftext[#buftext] = buftext[#buftext] .. '\r'
end
buftext[#buftext + 1] = ''
end
if vim.bo[bufnr].bomb then
buftext[1] = add_bom(buftext[1], vim.bo[bufnr].fileencoding)
end
return buftext
end
--- @param buf integer
local function delete_alt(buf)
local alt = vim.api.nvim_buf_call(buf, function()
return vim.fn.bufnr('#')
end)
if alt ~= buf and alt ~= -1 then
pcall(vim.api.nvim_buf_delete, alt, { force = true })
end
end
--- @param bufnr integer
--- @param name string
function M.buf_rename(bufnr, name)
vim.api.nvim_buf_set_name(bufnr, name)
delete_alt(bufnr)
end
--- @param bufnr integer
--- @param start_row integer
--- @param end_row integer
--- @param lines string[]
function M.set_lines(bufnr, start_row, end_row, lines)
if vim.bo[bufnr].fileformat == 'dos' then
lines = M.strip_cr(lines)
end
if start_row == 0 and end_row == -1 and lines[#lines] == '' then
lines = vim.deepcopy(lines)
lines[#lines] = nil
end
vim.api.nvim_buf_set_lines(bufnr, start_row, end_row, false, lines)
end
--- @return string
function M.tmpname()
if is_unix then
return os.tmpname()
end
return vim.fn.tempname()
end
--- @param time number
--- @param divisor integer
--- @param time_word string
--- @return string
local function to_relative_string(time, divisor, time_word)
local num = math.floor(time / divisor)
if num > 1 then
time_word = time_word .. 's'
end
return num .. ' ' .. time_word .. ' ago'
end
--- @param timestamp number
--- @return string
function M.get_relative_time(timestamp)
local current_timestamp = os.time()
local elapsed = current_timestamp - timestamp
if elapsed == 0 then
return 'a while ago'
end
local minute_seconds = 60
local hour_seconds = minute_seconds * 60
local day_seconds = hour_seconds * 24
local month_seconds = day_seconds * 30
local year_seconds = month_seconds * 12
if elapsed < minute_seconds then
return to_relative_string(elapsed, 1, 'second')
elseif elapsed < hour_seconds then
return to_relative_string(elapsed, minute_seconds, 'minute')
elseif elapsed < day_seconds then
return to_relative_string(elapsed, hour_seconds, 'hour')
elseif elapsed < month_seconds then
return to_relative_string(elapsed, day_seconds, 'day')
elseif elapsed < year_seconds then
return to_relative_string(elapsed, month_seconds, 'month')
else
return to_relative_string(elapsed, year_seconds, 'year')
end
end
--- @param opts vim.api.keyset.redraw
function M.redraw(opts)
if vim.fn.has('nvim-0.10') == 1 then
vim.api.nvim__redraw(opts)
else
vim.api.nvim__buf_redraw_range(opts.buf, opts.range[1], opts.range[2])
end
end
--- @param xs string[]
--- @return boolean
local function is_dos(xs)
-- Do not check CR at EOF
for i = 1, #xs - 1 do
if xs[i]:sub(-1) ~= '\r' then
return false
end
end
return true
end
--- Strip '\r' from the EOL of each line only if all lines end with '\r'
--- @param xs0 string[]
--- @return string[]
function M.strip_cr(xs0)
if not is_dos(xs0) then
-- don't strip, return early
return xs0
end
-- all lines end with '\r', need to strip
local xs = vim.deepcopy(xs0)
for i = 1, #xs do
xs[i] = xs[i]:sub(1, -2)
end
return xs
end
--- @param base? string
--- @return string?
function M.norm_base(base)
if base == ':0' then
return
end
if base and base:sub(1, 1):match('[~\\^]') then
base = 'HEAD' .. base
end
return base
end
function M.emptytable()
return setmetatable({}, {
---@param t table<any,any>
---@param k any
---@return any
__index = function(t, k)
t[k] = {}
return t[k]
end,
})
end
local function expand_date(fmt, time)
if fmt == '%R' then
return M.get_relative_time(time)
end
return os.date(fmt, time)
end
---@param fmt string
---@param info table<string,any>
---@param reltime? boolean Use relative time as the default date format
---@return string
function M.expand_format(fmt, info, reltime)
local ret = {} --- @type string[]
for _ = 1, 20 do -- loop protection
-- Capture <name> or <name:format>
local scol, ecol, match, key, time_fmt = fmt:find('(<([^:>]+):?([^>]*)>)')
if not match then
break
end
--- @cast key string
ret[#ret + 1], fmt = fmt:sub(1, scol - 1), fmt:sub(ecol + 1)
local v = info[key]
if v then
if type(v) == 'table' then
v = table.concat(v, '\n')
end
if vim.endswith(key, '_time') then
if time_fmt == '' then
time_fmt = reltime and '%R' or '%Y-%m-%d'
end
v = expand_date(time_fmt, v)
end
match = tostring(v)
end
ret[#ret + 1] = match
end
ret[#ret + 1] = fmt
return table.concat(ret, '')
end
--- @param buf string
--- @return boolean
function M.bufexists(buf)
--- @diagnostic disable-next-line:param-type-mismatch
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
--- 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
--- Run a function once and ignore subsequent calls
--- @generic F: function
--- @param fn F
--- @return F
function M.once(fn)
local called = false
return function(...)
if called then
return
end
called = true
return fn(...)
end
end
return M

View File

@ -0,0 +1,150 @@
local api = vim.api
local uv = vim.loop
local Status = require('gitsigns.status')
local async = require('gitsigns.async')
local log = require('gitsigns.debug.log')
local util = require('gitsigns.util')
local cache = require('gitsigns.cache').cache
local config = require('gitsigns.config').config
local debounce_trailing = require('gitsigns.debounce').debounce_trailing
local manager = require('gitsigns.manager')
local dprint = log.dprint
local dprintf = log.dprintf
--- @param bufnr integer
--- @param old_relpath string
local function handle_moved(bufnr, old_relpath)
local bcache = cache[bufnr]
local git_obj = bcache.git_obj
local new_name = git_obj:has_moved()
if new_name then
dprintf('File moved to %s', new_name)
git_obj.relpath = new_name
if not git_obj.orig_relpath then
git_obj.orig_relpath = old_relpath
end
elseif git_obj.orig_relpath then
local orig_file = git_obj.repo.toplevel .. util.path_sep .. git_obj.orig_relpath
if not git_obj:file_info(orig_file).relpath then
return
end
--- File was moved in the index, but then reset
dprintf('Moved file reset')
git_obj.relpath = git_obj.orig_relpath
git_obj.orig_relpath = nil
else
-- File removed from index, do nothing
return
end
git_obj.file = git_obj.repo.toplevel .. util.path_sep .. git_obj.relpath
bcache.file = git_obj.file
git_obj:update()
if not manager.schedule(bufnr) then
return
end
local bufexists = util.bufexists(bcache.file)
local old_name = api.nvim_buf_get_name(bufnr)
if not bufexists then
util.buf_rename(bufnr, bcache.file)
end
local msg = bufexists and 'Cannot rename' or 'Renamed'
dprintf('%s buffer %d from %s to %s', msg, bufnr, old_name, bcache.file)
end
--- @param bufnr integer
local watcher_handler = async.create(1, function(bufnr)
local __FUNC__ = 'watcher_handler'
-- Avoid cache hit for detached buffer
-- ref: https://github.com/lewis6991/gitsigns.nvim/issues/956
if not manager.schedule(bufnr) then
return
end
local git_obj = cache[bufnr].git_obj
git_obj.repo:update_abbrev_head()
if not manager.schedule(bufnr) then
return
end
Status:update(bufnr, { head = git_obj.repo.abbrev_head })
local was_tracked = git_obj.object_name ~= nil
local old_relpath = git_obj.relpath
git_obj:update()
if not manager.schedule(bufnr) then
return
end
if config.watch_gitdir.follow_files and was_tracked and not git_obj.object_name then
-- File was tracked but is no longer tracked. Must of been removed or
-- moved. Check if it was moved and switch to it.
handle_moved(bufnr, old_relpath)
if not manager.schedule(bufnr) then
return
end
end
cache[bufnr]:invalidate(true)
require('gitsigns.manager').update(bufnr)
end)
local watcher_handler_debounced = debounce_trailing(200, watcher_handler, 1)
--- vim.inspect but on one line
--- @param x any
--- @return string
local function inspect(x)
return vim.inspect(x, { indent = '', newline = ' ' })
end
local M = {}
local WATCH_IGNORE = {
ORIG_HEAD = true,
FETCH_HEAD = true,
}
--- @param bufnr integer
--- @param gitdir string
--- @return uv.uv_fs_event_t
function M.watch_gitdir(bufnr, gitdir)
dprintf('Watching git dir')
local w = assert(uv.new_fs_event())
w:start(gitdir, {}, function(err, filename, events)
local __FUNC__ = 'watcher_cb'
if err then
dprintf('Git dir update error: %s', err)
return
end
local info = string.format("Git dir update: '%s' %s", filename, inspect(events))
-- The luv docs say filename is passed as a string but it has been observed
-- to sometimes be nil.
-- https://github.com/lewis6991/gitsigns.nvim/issues/848
if filename == nil or WATCH_IGNORE[filename] or vim.endswith(filename, '.lock') then
dprintf('%s (ignoring)', info)
return
end
dprint(info)
watcher_handler_debounced(bufnr)
end)
return w
end
return M