diff --git a/lua/git/init.lua b/lua/git/init.lua index 9f059b7..18c8a1d 100644 --- a/lua/git/init.lua +++ b/lua/git/init.lua @@ -29,26 +29,26 @@ function M.init() { group = group, callback = function(args) - require("git.repo").refresh_buf(args.buf) + require("git.watcher").refresh_buf(args.buf) end, } ) vim.api.nvim_create_autocmd({ "BufDelete", "BufWipeout" }, { group = group, callback = function(args) - require("git.repo").unregister(args.buf) + require("git.watcher").unregister(args.buf) end, }) vim.api.nvim_create_autocmd("FocusGained", { group = group, - callback = function() - require("git.repo").refresh_buf(vim.api.nvim_get_current_buf()) + callback = function(args) + require("git.watcher").refresh_buf(args.buf) end, }) vim.api.nvim_create_autocmd("VimLeavePre", { group = group, callback = function() - require("git.repo").stop_all() + require("git.watcher").stop_all() end, }) diff --git a/lua/git/repo.lua b/lua/git/repo.lua index 8aeec61..f70abae 100644 --- a/lua/git/repo.lua +++ b/lua/git/repo.lua @@ -41,16 +41,6 @@ function M.indicator(code) return "M", "GitUnstaged" end ----@param code string ----@return string? -local function format(code) - local char, hl = M.indicator(code) - if not char then - return nil - end - return string.format("%%#%s#%s%%*", hl, char) -end - ---@param path string ---@return string? gitdir ---@return string? worktree @@ -94,196 +84,6 @@ function M.resolve_cwd() return M.resolve(path) end ----@class ow.Git.Repo ----@field gitdir string ----@field worktree string ----@field buffers table ----@field watcher? uv.uv_fs_event_t ----@field refresh fun(self: ow.Git.Repo) ----@field refresh_handle ow.Git.Util.DebounceHandle -local Repo = {} -Repo.__index = Repo - -function Repo:start_watcher() - local watcher = assert(vim.uv.new_fs_event()) - assert(watcher:start(self.gitdir, {}, function(err, filename) - if err or (filename ~= "index" and filename ~= "HEAD") then - return - end - self:refresh() - end)) - self.watcher = watcher -end - -function Repo:stop_watcher() - -- Stop the libuv watcher first so no further fs-events can trigger - -- self:refresh(). Tearing down the debounce handle first leaves a - -- window where an in-flight watcher callback hits a closed timer. - if self.watcher then - self.watcher:stop() - self.watcher:close() - self.watcher = nil - end - self.refresh_handle.close() -end - ----@param buf integer -function Repo:add_buffer(buf) - self.buffers[buf] = true -end - ----@param buf integer -function Repo:remove_buffer(buf) - self.buffers[buf] = nil -end - ----@return boolean -function Repo:has_buffers() - return next(self.buffers) ~= nil -end - ----@param repo ow.Git.Repo -local function do_refresh(repo) - vim.system( - { - "git", - "-c", - "core.quotePath=false", - "status", - "--porcelain=v1", - "--branch", - }, - { cwd = repo.worktree, text = true }, - vim.schedule_wrap(function(obj) - local statuses = {} - if obj.code == 0 then - for line in (obj.stdout or ""):gmatch("[^\r\n]+") do - if line:sub(1, 2) ~= "##" then - local code = line:sub(1, 2) - local x = code:sub(1, 1) - local y = code:sub(2, 2) - local path_part = line:sub(4) - if x == "R" or x == "C" or y == "R" or y == "C" then - local arrow = path_part:find(" -> ", 1, true) - if arrow then - path_part = path_part:sub(arrow + 4) - end - end - statuses[vim.fs.joinpath(repo.worktree, path_part)] = - format(code) - end - end - else - util.warning( - "git status failed: %s", - vim.trim(obj.stderr or "") - ) - end - local dirty = false - for buf in pairs(repo.buffers) do - if not vim.api.nvim_buf_is_valid(buf) then - repo.buffers[buf] = nil - else - local status = statuses[vim.api.nvim_buf_get_name(buf)] - if vim.b[buf].git_status ~= status then - vim.b[buf].git_status = status - dirty = true - end - end - end - if dirty then - vim.cmd.redrawstatus({ bang = true }) - end - vim.api.nvim_exec_autocmds("User", { - pattern = "GitRefresh", - data = { - gitdir = repo.gitdir, - worktree = repo.worktree, - porcelain_stdout = obj.code == 0 and obj.stdout or nil, - }, - }) - end) - ) -end - ----@param gitdir string ----@param worktree string ----@return ow.Git.Repo -function Repo.new(gitdir, worktree) - local self = setmetatable({ - gitdir = gitdir, - worktree = worktree, - buffers = {}, - }, Repo) - self.refresh, self.refresh_handle = util.debounce(do_refresh, 50) - self:start_watcher() - return self -end - ----@type table -local repo_by_gitdir = {} - ----@type table -local repo_by_buf = {} - ----@param buf integer ----@return ow.Git.Repo? -local function register(buf) - local existing = repo_by_buf[buf] - if existing then - return existing - end - local path = vim.api.nvim_buf_get_name(buf) - if path == "" then - return nil - end - local gitdir, worktree = M.resolve(path) - if not gitdir or not worktree then - return nil - end - local repo = repo_by_gitdir[gitdir] - if not repo then - repo = Repo.new(gitdir, worktree) - repo_by_gitdir[gitdir] = repo - end - repo:add_buffer(buf) - repo_by_buf[buf] = repo - return repo -end - ----@param buf integer -function M.unregister(buf) - local repo = repo_by_buf[buf] - if not repo then - return - end - repo_by_buf[buf] = nil - repo:remove_buffer(buf) - if not repo:has_buffers() then - repo:stop_watcher() - repo_by_gitdir[repo.gitdir] = nil - end -end - ----@param buf integer -function M.refresh_buf(buf) - if not vim.api.nvim_buf_is_valid(buf) or vim.bo[buf].buftype ~= "" then - return - end - local repo = register(buf) - if not repo then - vim.b[buf].git_status = nil - return - end - repo:refresh() -end - -function M.stop_all() - for _, repo in pairs(repo_by_gitdir) do - repo:stop_watcher() - end -end - ---@param path string ---@return string? function M.head(path) diff --git a/lua/git/watcher.lua b/lua/git/watcher.lua new file mode 100644 index 0000000..03d7892 --- /dev/null +++ b/lua/git/watcher.lua @@ -0,0 +1,206 @@ +local repo = require("git.repo") +local util = require("git.util") + +local M = {} + +---@param code string +---@return string? +local function format(code) + local char, hl = repo.indicator(code) + if not char then + return nil + end + return string.format("%%#%s#%s%%*", hl, char) +end + +---@class ow.Git.Repo +---@field gitdir string +---@field worktree string +---@field buffers table +---@field watcher? uv.uv_fs_event_t +---@field refresh fun(self: ow.Git.Repo) +---@field refresh_handle ow.Git.Util.DebounceHandle +local Repo = {} +Repo.__index = Repo + +function Repo:start_watcher() + local watcher = assert(vim.uv.new_fs_event()) + assert(watcher:start(self.gitdir, {}, function(err, filename) + if err or (filename ~= "index" and filename ~= "HEAD") then + return + end + self:refresh() + end)) + self.watcher = watcher +end + +function Repo:stop_watcher() + -- Stop the libuv watcher first so no further fs-events can trigger + -- self:refresh(). Tearing down the debounce handle first leaves a + -- window where an in-flight watcher callback hits a closed timer. + if self.watcher then + self.watcher:stop() + self.watcher:close() + self.watcher = nil + end + self.refresh_handle.close() +end + +---@param buf integer +function Repo:add_buffer(buf) + self.buffers[buf] = true +end + +---@param buf integer +function Repo:remove_buffer(buf) + self.buffers[buf] = nil +end + +---@return boolean +function Repo:has_buffers() + return next(self.buffers) ~= nil +end + +---@param r ow.Git.Repo +local function do_refresh(r) + vim.system( + { + "git", + "-c", + "core.quotePath=false", + "status", + "--porcelain=v1", + "--branch", + }, + { cwd = r.worktree, text = true }, + vim.schedule_wrap(function(obj) + local statuses = {} + if obj.code == 0 then + for line in (obj.stdout or ""):gmatch("[^\r\n]+") do + if line:sub(1, 2) ~= "##" then + local code = line:sub(1, 2) + local x = code:sub(1, 1) + local y = code:sub(2, 2) + local path_part = line:sub(4) + if x == "R" or x == "C" or y == "R" or y == "C" then + local arrow = path_part:find(" -> ", 1, true) + if arrow then + path_part = path_part:sub(arrow + 4) + end + end + statuses[vim.fs.joinpath(r.worktree, path_part)] = + format(code) + end + end + else + util.warning( + "git status failed: %s", + vim.trim(obj.stderr or "") + ) + end + local dirty = false + for buf in pairs(r.buffers) do + if not vim.api.nvim_buf_is_valid(buf) then + r.buffers[buf] = nil + else + local status = statuses[vim.api.nvim_buf_get_name(buf)] + if vim.b[buf].git_status ~= status then + vim.b[buf].git_status = status + dirty = true + end + end + end + if dirty then + vim.cmd.redrawstatus({ bang = true }) + end + vim.api.nvim_exec_autocmds("User", { + pattern = "GitRefresh", + data = { + gitdir = r.gitdir, + worktree = r.worktree, + porcelain_stdout = obj.code == 0 and obj.stdout or nil, + }, + }) + end) + ) +end + +---@param gitdir string +---@param worktree string +---@return ow.Git.Repo +function Repo.new(gitdir, worktree) + local self = setmetatable({ + gitdir = gitdir, + worktree = worktree, + buffers = {}, + }, Repo) + self.refresh, self.refresh_handle = util.debounce(do_refresh, 50) + self:start_watcher() + return self +end + +---@type table +local repo_by_gitdir = {} + +---@type table +local repo_by_buf = {} + +---@param buf integer +---@return ow.Git.Repo? +local function register(buf) + local existing = repo_by_buf[buf] + if existing then + return existing + end + local path = vim.api.nvim_buf_get_name(buf) + if path == "" then + return nil + end + local gitdir, worktree = repo.resolve(path) + if not gitdir or not worktree then + return nil + end + local r = repo_by_gitdir[gitdir] + if not r then + r = Repo.new(gitdir, worktree) + repo_by_gitdir[gitdir] = r + end + r:add_buffer(buf) + repo_by_buf[buf] = r + return r +end + +---@param buf integer +function M.unregister(buf) + local r = repo_by_buf[buf] + if not r then + return + end + repo_by_buf[buf] = nil + r:remove_buffer(buf) + if not r:has_buffers() then + r:stop_watcher() + repo_by_gitdir[r.gitdir] = nil + end +end + +---@param buf integer +function M.refresh_buf(buf) + if not vim.api.nvim_buf_is_valid(buf) or vim.bo[buf].buftype ~= "" then + return + end + local r = register(buf) + if not r then + vim.b[buf].git_status = nil + return + end + r:refresh() +end + +function M.stop_all() + for _, r in pairs(repo_by_gitdir) do + r:stop_watcher() + end +end + +return M