From ac2962f509743dacde90daefac7ab5d4d303a192 Mon Sep 17 00:00:00 2001 From: Lewis Russell Date: Mon, 22 Sep 2025 14:05:58 +0100 Subject: [PATCH 1/2] feat: overhaul repo watcher - Fix issue of watcher callback not being called when running with Watchman due to filename ignore check being done in the debounced section of the handler. - Only update abbrev_head when `.git/HEAD` is changed - Reduce watcher timer debounce interval to 200ms. - Add handwritten implementation of `git rev-parse --abbrev-ref HEAD` which reads `.git/HEAD` directly. --- lua/gitsigns/git/repo.lua | 205 ++++++++++++++++++++++------------- test/gitdir_watcher_spec.lua | 3 - 2 files changed, 132 insertions(+), 76 deletions(-) diff --git a/lua/gitsigns/git/repo.lua b/lua/gitsigns/git/repo.lua index d17381c4e..01201faf8 100644 --- a/lua/gitsigns/git/repo.lua +++ b/lua/gitsigns/git/repo.lua @@ -3,7 +3,6 @@ local git_command = require('gitsigns.git.cmd') local log = require('gitsigns.debug.log') local util = require('gitsigns.util') local errors = require('gitsigns.git.errors') -local debounce_trailing = require('gitsigns.debounce').debounce_trailing local check_version = require('gitsigns.git.version').check @@ -20,11 +19,56 @@ local uv = vim.uv or vim.loop ---@diagnostic disable-line: deprecated --- Username configured for the repo. --- Needed for to determine "You" in current line blame. --- @field username string ---- @field package _watcher_callbacks table ---- @field package _watcher uv.uv_fs_event_t ---- @field package _gc userdata Used for garbage collection +--- @field private _watcher_callbacks table +--- @field private _watcher uv.uv_fs_event_t +--- Used for the debounced section of the watcher handler +--- @field private _watcher_timer? uv.uv_timer_t +--- @field private _watcher_gc userdata Used for garbage collection local M = {} +--- @param gitdir string +--- @return boolean +local function is_rebasing(gitdir) + return util.Path.exists(util.Path.join(gitdir, 'rebase-merge')) + or util.Path.exists(util.Path.join(gitdir, 'rebase-apply')) +end + +--- Return the abbreviated ref for HEAD (or short SHA if detached). +--- Equivalent to `git rev-parse --abbrev-ref HEAD` +--- @param gitdir string Must be an absolute path to the .git directory +--- @return string abbrev_head +local function abbrev_head(gitdir) + local head_path = util.Path.join(gitdir, 'HEAD') + + -- TODO(lewis6991): should this be async? + vim.wait(1000, function() + return not util.Path.exists(head_path .. '.lock') + end, 10, true) + + local f = assert(io.open(head_path, 'r')) + local head = f:read('*l') + f:close() + + -- HEAD content is either: + -- "ref: refs/heads/" + -- "" (detached HEAD) + local refpath = head:match('^ref:%s*(.+)$') + if refpath then + -- Extract last path component (branch name) + return refpath:match('([^/]+)$') or refpath + end + + assert(head:find('^[%x]+$'), 'Invalid HEAD content: ' .. head) + + -- Detached HEAD -> like `git rev-parse --abbrev-ref HEAD`, return literal "HEAD" + local short_sha = log.debug_mode() and 'HEAD' or head:sub(1, 7) + + if is_rebasing(gitdir) then + short_sha = short_sha .. '(rebasing)' + end + return short_sha +end + --- vim.inspect but on one line --- @param x any --- @return string @@ -116,17 +160,6 @@ function M:get_show_text(object, encoding) return stdout, stderr end ---- @async ---- @package -function M:update_abbrev_head() - local info, err = M.get_info(self.toplevel) - if not info then - log.eprintf('Could not get info for repo at %s: %s', self.gitdir, err or '') - return - end - self.abbrev_head = info.abbrev_head -end - --- @type table local repo_cache = setmetatable({}, { __mode = 'v' }) @@ -138,54 +171,94 @@ local function gc_proxy(fn) return proxy end ---- @generic T1, T, R ---- @param fn fun(_:T1, _:T...): R... ---- @param arg1 T1 ---- @return fun(_:T...): R... -local function curry1(fn, arg1) - return function(...) - return fn(arg1, ...) - end +--- @param cb fun() +function M:_start_watcher_timer(cb) + -- Debounced section + self._watcher_timer = self._watcher_timer or assert(uv.new_timer()) + self._watcher_timer:start(200, 0, function() + self._watcher_timer:stop() + self._watcher_timer:close() + self._watcher_timer = nil + vim.schedule(cb) + end) end ---- @param gitdir string ---- @param err? string ---- @param filename string ---- @param events { change?: boolean, rename?: boolean } -local function watcher_cb(gitdir, err, filename, events) - local __FUNC__ = 'watcher_cb' - -- do not use `self` here as it prevents garbage collection. Must use a - -- weak reference. - local repo = repo_cache[gitdir] - if not repo then - return -- garbage collected - end +function M:_start_watcher() + self._watcher_callbacks = {} + self._watcher = assert(uv.new_fs_event()) - if err then - log.dprintf('Git dir update error: %s', err) - return - end + local gitdir = self.gitdir - -- 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 not filename then - log.eprint('No filename') - return - end + -- Keep track of changed files, so the debounced section has information + -- about what changed. + local changed_files = {} --- @type table - log.dprintf("Git dir update: '%s' %s", filename, inspect(events)) + self._watcher:start(gitdir, {}, function(err, filename, events) + local __FUNC__ = 'watcher_cb' - if vim.startswith(filename, '.watchman-cookie') then - return - end + -- Do not use `self` in luv callbacks as it prevents garbage collection. + -- Must use a weak reference. + local repo = repo_cache[gitdir] + if not repo then + return -- garbage collected + end - async.run(function() - repo:update_abbrev_head() + if err then + log.dprintf('Git dir update error: %s', err) + return + end - for cb in pairs(repo._watcher_callbacks) do - vim.schedule(cb) + -- 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 not filename then + log.eprint('No filename') + return end + + for _, ex in ipairs({ + '.watchman-cookie', + 'index.lock', + }) do + if vim.startswith(filename, ex) then + log.dprintf("Git dir update: '%s' %s (ignoring)", filename, inspect(events)) + return + end + end + + log.dprintf("Git dir update: '%s' %s", filename, inspect(events)) + + changed_files[filename] = true + + -- Debounced section + repo:_start_watcher_timer(function() + local __FUNC__ = 'watcher (debounced)' + + -- Do not use `self` in luv callbacks as it prevents garbage collection. + -- Must use a weak reference. + repo = repo_cache[gitdir] + if not repo then + return -- garbage collected + end + + local head_changed = changed_files.HEAD or false + + changed_files = {} + + if head_changed then + repo.abbrev_head = abbrev_head(gitdir) + log.dprintf('HEAD changed, updating abbrev_head to %s', repo.abbrev_head) + end + + for cb in pairs(repo._watcher_callbacks) do + vim.schedule(cb) + end + end) + end) + + self._watcher_gc = gc_proxy(function() + self._watcher:stop() + self._watcher:close() end) end @@ -198,19 +271,7 @@ local function new(info) --- @cast self Gitsigns.Repo self.username = self:command({ 'config', 'user.name' }, { ignore_error = true })[1] - - do -- gitdir watcher - self._watcher_callbacks = {} - self._watcher = assert(uv.new_fs_event()) - - local debounced_handler = debounce_trailing(1000, curry1(watcher_cb, self.gitdir)) - self._watcher:start(self.gitdir, {}, debounced_handler) - - self._gc = gc_proxy(function() - self._watcher:stop() - self._watcher:close() - end) - end + self:_start_watcher() return self end @@ -239,12 +300,12 @@ function M.get(cwd, gitdir, toplevel) end --- @async ---- @param gitdir? string +--- @param gitdir string --- @param head_str string --- @param cwd string --- @return string local function process_abbrev_head(gitdir, head_str, cwd) - if not gitdir or head_str ~= 'HEAD' then + if head_str ~= 'HEAD' then return head_str end @@ -253,14 +314,12 @@ local function process_abbrev_head(gitdir, head_str, cwd) cwd = cwd, })[1] or '' + -- Make tests easier if short_sha ~= '' and log.debug_mode() then short_sha = 'HEAD' end - if - util.Path.exists(util.Path.join(gitdir, 'rebase-merge')) - or util.Path.exists(util.Path.join(gitdir, 'rebase-apply')) - then + if is_rebasing(gitdir) then return short_sha .. '(rebasing)' end diff --git a/test/gitdir_watcher_spec.lua b/test/gitdir_watcher_spec.lua index 82eb8b2d7..51dff7ff7 100644 --- a/test/gitdir_watcher_spec.lua +++ b/test/gitdir_watcher_spec.lua @@ -63,7 +63,6 @@ describe('gitdir_watcher', function() match_debug_messages({ p('watcher_cb: Git dir update: .*'), - np(revparse_pat), np('run_job: git .* ls%-files .* ' .. vim.pesc(test_file)), np('run_job: git .* diff %-%-name%-status .* %-%-cached'), n('handle_moved(1): File moved to dummy.txt2'), @@ -82,7 +81,6 @@ describe('gitdir_watcher', function() match_debug_messages({ p('watcher_cb: Git dir update: .*'), - np(revparse_pat), np('run_job: git .* ls%-files .* ' .. vim.pesc(test_file2)), np('run_job: git .* diff %-%-name%-status .* %-%-cached'), n('handle_moved(1): File moved to dummy.txt3'), @@ -99,7 +97,6 @@ describe('gitdir_watcher', function() match_debug_messages({ p('watcher_cb: Git dir update: .*'), - np(revparse_pat), np('run_job: git .* ls%-files .* ' .. vim.pesc(test_file3)), np('run_job: git .* diff %-%-name%-status .* %-%-cached'), np('run_job: git .* ls%-files .* ' .. vim.pesc(test_file)), From 038572650b3e0a96262f662825151312d476c9d2 Mon Sep 17 00:00:00 2001 From: Lewis Russell Date: Mon, 22 Sep 2025 15:25:02 +0100 Subject: [PATCH 2/2] ci: fix and update --- .github/workflows/ci.yml | 2 +- test/gitsigns_spec.lua | 27 ++++++++++++++------------- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7bc0a44c5..0ed3d0d28 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,7 @@ jobs: matrix: neovim_branch: - 'v0.10.4' - - 'v0.11.0' + - 'v0.11.4' - 'nightly' env: diff --git a/test/gitsigns_spec.lua b/test/gitsigns_spec.lua index 29e53804b..607c5b183 100644 --- a/test/gitsigns_spec.lua +++ b/test/gitsigns_spec.lua @@ -59,29 +59,30 @@ describe('gitsigns (with screen)', function() local default_attrs = { [1] = { foreground = Screen.colors.DarkBlue, background = Screen.colors.WebGray }, - [2] = { foreground = Screen.colors.NvimDarkCyan }, - [3] = { foreground = Screen.colors.NvimDarkGreen }, - [4] = { foreground = Screen.colors.NvimDarkRed }, + [2] = { foreground = Screen.colors.DodgerBlue }, + [3] = { foreground = Screen.colors.SeaGreen }, + [4] = { foreground = Screen.colors.Red }, [5] = { foreground = Screen.colors.Brown }, [6] = { foreground = Screen.colors.Blue1, bold = true }, [7] = { bold = true }, [8] = { foreground = Screen.colors.White, background = Screen.colors.Red }, [9] = { foreground = Screen.colors.SeaGreen, bold = true }, - [10] = { foreground = Screen.colors.Red }, - [11] = { foreground = Screen.colors.NvimDarkRed, background = Screen.colors.WebGray }, - [12] = { foreground = Screen.colors.NvimDarkCyan, background = Screen.colors.WebGray }, + [11] = { foreground = Screen.colors.Red1, background = Screen.colors.WebGray }, + [12] = { foreground = Screen.colors.DodgerBlue, background = Screen.colors.WebGray }, } -- Use the classic vim colorscheme, not the new defaults in nvim >= 0.10 - if fn.has('nvim-0.10') > 0 then - command('colorscheme vim') - else - default_attrs[2] = { background = Screen.colors.LightMagenta } - default_attrs[3] = { background = Screen.colors.LightBlue } - default_attrs[4] = - { background = Screen.colors.LightCyan1, bold = true, foreground = Screen.colors.Blue1 } + if fn.has('nvim-0.12') == 0 then + default_attrs[2].foreground = Screen.colors.NvimDarkCyan + default_attrs[3].foreground = Screen.colors.NvimDarkGreen + default_attrs[4].foreground = Screen.colors.NvimDarkRed + default_attrs[11].foreground = Screen.colors.NvimDarkRed + default_attrs[12] = + { foreground = Screen.colors.NvimDarkCyan, background = Screen.colors.Gray } end + command('colorscheme vim') + screen:set_default_attr_ids(default_attrs) config = vim.deepcopy(test_config)