feat(git): per-key cache invalidation and optional submodule tracking
This commit is contained in:
+312
-62
@@ -20,10 +20,36 @@ end
|
||||
---@field index_writer boolean?
|
||||
---@field index_mode string?
|
||||
|
||||
---@alias ow.Git.Repo.Event "refresh"
|
||||
---@alias ow.Git.Repo.Event
|
||||
---| "refresh"
|
||||
|
||||
local global = util.Emitter.new()
|
||||
|
||||
---@type table<string, ow.Git.Repo> keyed by worktree
|
||||
local repos = {}
|
||||
|
||||
---@param r ow.Git.Repo
|
||||
local function release_if_unused(r)
|
||||
if repos[r.worktree] ~= r then
|
||||
return
|
||||
end
|
||||
if next(r.buffers) ~= nil or next(r.tabs) ~= nil then
|
||||
return
|
||||
end
|
||||
r:close()
|
||||
repos[r.worktree] = nil
|
||||
end
|
||||
|
||||
---@class ow.Git.Repo.Change
|
||||
---@field paths table<string, true>
|
||||
|
||||
---@class ow.Git.Repo.RefreshOpts
|
||||
---@field invalidate boolean?
|
||||
|
||||
---@class ow.Git.Repo.SubmoduleEntry
|
||||
---@field worktree string
|
||||
---@field unsub fun()?
|
||||
|
||||
---@class ow.Git.Repo
|
||||
---@field gitdir string
|
||||
---@field worktree string
|
||||
@@ -35,6 +61,9 @@ local global = util.Emitter.new()
|
||||
---@field private _schedule_refresh fun(self: ow.Git.Repo)
|
||||
---@field private _refresh_handle ow.Git.Util.DebounceHandle
|
||||
---@field private _cache table<string, any>
|
||||
---@field private _fetch_epoch integer
|
||||
---@field private _pending_invalidate boolean
|
||||
---@field package _submodules table<string, ow.Git.Repo.SubmoduleEntry>
|
||||
local Repo = {}
|
||||
Repo.__index = Repo
|
||||
|
||||
@@ -50,12 +79,128 @@ local STATUS_ARGS = {
|
||||
"-z",
|
||||
}
|
||||
|
||||
local PSEUDO_REFS = {
|
||||
"HEAD",
|
||||
"FETCH_HEAD",
|
||||
"ORIG_HEAD",
|
||||
"MERGE_HEAD",
|
||||
"REBASE_HEAD",
|
||||
"CHERRY_PICK_HEAD",
|
||||
"REVERT_HEAD",
|
||||
}
|
||||
|
||||
---@type table<string, fun(relpath: string): boolean>
|
||||
local INVALIDATION_RULES = {
|
||||
head = function(relpath)
|
||||
return relpath == "HEAD"
|
||||
or vim.startswith(relpath, "refs/heads/")
|
||||
or relpath == "packed-refs"
|
||||
end,
|
||||
refs = function(relpath)
|
||||
return vim.startswith(relpath, "refs/heads/")
|
||||
or vim.startswith(relpath, "refs/tags/")
|
||||
or vim.startswith(relpath, "refs/remotes/")
|
||||
or relpath == "packed-refs"
|
||||
end,
|
||||
pseudo_refs = function(relpath)
|
||||
return vim.tbl_contains(PSEUDO_REFS, relpath)
|
||||
end,
|
||||
stash_refs = function(relpath)
|
||||
return relpath == "refs/stash" or relpath == "logs/refs/stash"
|
||||
end,
|
||||
}
|
||||
|
||||
---@param relpath string
|
||||
---@return boolean
|
||||
local function affects_resolve(relpath)
|
||||
return vim.startswith(relpath, "refs/")
|
||||
or relpath == "packed-refs"
|
||||
or relpath == "HEAD"
|
||||
or relpath == "FETCH_HEAD"
|
||||
end
|
||||
|
||||
---@private
|
||||
---@param relpath string
|
||||
function Repo:_invalidate(relpath)
|
||||
for key, affects in pairs(INVALIDATION_RULES) do
|
||||
if self._cache[key] ~= nil and affects(relpath) then
|
||||
self._cache[key] = nil
|
||||
end
|
||||
end
|
||||
if affects_resolve(relpath) then
|
||||
for key in pairs(self._cache) do
|
||||
if vim.startswith(key, "resolve:") then
|
||||
self._cache[key] = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
---@param path string
|
||||
---@return table<string, table<string, string>>?
|
||||
local function read_git_config(path)
|
||||
local f = io.open(path, "r")
|
||||
if not f then
|
||||
return nil
|
||||
end
|
||||
local content = f:read("*a")
|
||||
f:close()
|
||||
local out = {}
|
||||
local section
|
||||
for line in content:gmatch("[^\n]+") do
|
||||
local trimmed = line:match("^%s*(.-)%s*$")
|
||||
if trimmed ~= "" and not trimmed:match("^[#;]") then
|
||||
local s = trimmed:match("^%[(.-)%]$")
|
||||
if s then
|
||||
section = s
|
||||
out[section] = out[section] or {}
|
||||
elseif section then
|
||||
local key, value =
|
||||
trimmed:match("^(%S+)%s*=%s*(.-)$")
|
||||
if key then
|
||||
out[section][key] = value
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
---@param gitdir string
|
||||
---@return string[]
|
||||
local function find_submodules(gitdir)
|
||||
local handle = vim.uv.fs_scandir(vim.fs.joinpath(gitdir, "modules"))
|
||||
if not handle then
|
||||
return {}
|
||||
end
|
||||
local out = {}
|
||||
while true do
|
||||
local name, typ = vim.uv.fs_scandir_next(handle)
|
||||
if not name then
|
||||
break
|
||||
end
|
||||
if typ == "directory" then
|
||||
table.insert(out, name)
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
---@private
|
||||
function Repo:_fetch_status()
|
||||
if self._pending_invalidate then
|
||||
self._cache = {}
|
||||
self._pending_invalidate = false
|
||||
end
|
||||
local prior_entries = self.status.entries
|
||||
self._fetch_epoch = self._fetch_epoch + 1
|
||||
local epoch = self._fetch_epoch
|
||||
util.git(STATUS_ARGS, {
|
||||
cwd = self.worktree,
|
||||
on_exit = function(result)
|
||||
self._cache = {}
|
||||
if epoch ~= self._fetch_epoch then
|
||||
return
|
||||
end
|
||||
if result.code ~= 0 then
|
||||
util.error(
|
||||
"git status failed: %s",
|
||||
@@ -64,13 +209,23 @@ function Repo:_fetch_status()
|
||||
return
|
||||
end
|
||||
self.status = status.parse(result.stdout or "")
|
||||
self._events:emit("refresh", self.status)
|
||||
global:emit("refresh", self, self.status)
|
||||
local change = {
|
||||
paths = status.diff_entries(
|
||||
prior_entries,
|
||||
self.status.entries
|
||||
),
|
||||
}
|
||||
self._events:emit("refresh", change, self.status)
|
||||
global:emit("refresh", self, change, self.status)
|
||||
end,
|
||||
})
|
||||
end
|
||||
|
||||
function Repo:refresh()
|
||||
---@param opts ow.Git.Repo.RefreshOpts?
|
||||
function Repo:refresh(opts)
|
||||
if opts and opts.invalidate then
|
||||
self._pending_invalidate = true
|
||||
end
|
||||
self:_schedule_refresh()
|
||||
end
|
||||
|
||||
@@ -86,11 +241,20 @@ function Repo.new(gitdir, worktree)
|
||||
status = status.parse(""),
|
||||
_events = util.Emitter.new(),
|
||||
_cache = {},
|
||||
_fetch_epoch = 0,
|
||||
_pending_invalidate = false,
|
||||
_submodules = {},
|
||||
}, Repo)
|
||||
self._schedule_refresh, self._refresh_handle =
|
||||
util.debounce(Repo._fetch_status, 50)
|
||||
self:start_watcher()
|
||||
self:refresh()
|
||||
if vim.g.git_submodule_recursion then
|
||||
self:_start_modules_watcher()
|
||||
for _, name in ipairs(find_submodules(gitdir)) do
|
||||
self:_register_submodule(name)
|
||||
end
|
||||
end
|
||||
return self
|
||||
end
|
||||
|
||||
@@ -130,9 +294,114 @@ local function start_fs_event(path, on_event)
|
||||
end
|
||||
|
||||
---@private
|
||||
---@param path string
|
||||
---@param on_change fun()
|
||||
function Repo:_watch_tree(path, on_change)
|
||||
---@param name string
|
||||
function Repo:_unregister_submodule(name)
|
||||
local entry = self._submodules[name]
|
||||
if not entry then
|
||||
return
|
||||
end
|
||||
self._submodules[name] = nil
|
||||
if entry.unsub then
|
||||
entry.unsub()
|
||||
end
|
||||
local child = repos[entry.worktree]
|
||||
if child then
|
||||
release_if_unused(child)
|
||||
end
|
||||
end
|
||||
|
||||
---@private
|
||||
---@param name string
|
||||
function Repo:_register_submodule(name)
|
||||
local sub_gitdir = vim.fs.joinpath(self.gitdir, "modules", name)
|
||||
local cfg = read_git_config(vim.fs.joinpath(sub_gitdir, "config"))
|
||||
local raw = cfg and cfg.core and cfg.core.worktree
|
||||
if not raw then
|
||||
return
|
||||
end
|
||||
local wt = raw:match("^/") and raw or vim.fs.joinpath(sub_gitdir, raw)
|
||||
wt = vim.fs.normalize(wt)
|
||||
local existing = self._submodules[name]
|
||||
if existing and existing.worktree == wt then
|
||||
return
|
||||
end
|
||||
if existing then
|
||||
self:_unregister_submodule(name)
|
||||
end
|
||||
local child = repos[wt] or M.resolve(wt)
|
||||
if not child then
|
||||
return
|
||||
end
|
||||
self._submodules[name] = {
|
||||
worktree = wt,
|
||||
unsub = child:on("refresh", function()
|
||||
self:refresh()
|
||||
end),
|
||||
}
|
||||
end
|
||||
|
||||
---@private
|
||||
function Repo:_start_modules_watcher()
|
||||
local dir = vim.fs.joinpath(self.gitdir, "modules")
|
||||
if self._watchers[dir] then
|
||||
return
|
||||
end
|
||||
if not vim.uv.fs_stat(dir) then
|
||||
return
|
||||
end
|
||||
self._watchers[dir] = start_fs_event(dir, function(filename)
|
||||
if not filename then
|
||||
return
|
||||
end
|
||||
if vim.uv.fs_stat(vim.fs.joinpath(dir, filename)) then
|
||||
self:_register_submodule(filename)
|
||||
else
|
||||
self:_unregister_submodule(filename)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
---@private
|
||||
function Repo:_stop_modules_watcher()
|
||||
local dir = vim.fs.joinpath(self.gitdir, "modules")
|
||||
local w = self._watchers[dir]
|
||||
if w then
|
||||
w:stop()
|
||||
w:close()
|
||||
self._watchers[dir] = nil
|
||||
end
|
||||
for _, name in ipairs(vim.tbl_keys(self._submodules)) do
|
||||
self:_unregister_submodule(name)
|
||||
end
|
||||
end
|
||||
|
||||
---@private
|
||||
---@param relpath string
|
||||
function Repo:_handle_fs_event(relpath)
|
||||
if vim.startswith(relpath, "objects") then
|
||||
return
|
||||
end
|
||||
self:_invalidate(relpath)
|
||||
if relpath == "modules" and vim.g.git_submodule_recursion then
|
||||
if vim.uv.fs_stat(vim.fs.joinpath(self.gitdir, "modules")) then
|
||||
self:_start_modules_watcher()
|
||||
for _, name in ipairs(find_submodules(self.gitdir)) do
|
||||
self:_register_submodule(name)
|
||||
end
|
||||
else
|
||||
self:_stop_modules_watcher()
|
||||
end
|
||||
end
|
||||
if vim.startswith(relpath, "logs") then
|
||||
return
|
||||
end
|
||||
self:refresh()
|
||||
end
|
||||
|
||||
---@private
|
||||
---@param relpath string gitdir-relative path of the directory to watch
|
||||
function Repo:_watch_tree(relpath)
|
||||
local path = vim.fs.joinpath(self.gitdir, relpath)
|
||||
if self._watchers[path] then
|
||||
return
|
||||
end
|
||||
@@ -150,11 +419,14 @@ function Repo:_watch_tree(path, on_change)
|
||||
end
|
||||
return
|
||||
end
|
||||
on_change()
|
||||
if filename then
|
||||
local child = vim.fs.joinpath(relpath, filename)
|
||||
self:_handle_fs_event(child)
|
||||
vim.schedule(function()
|
||||
self:_watch_tree(vim.fs.joinpath(path, filename), on_change)
|
||||
self:_watch_tree(child)
|
||||
end)
|
||||
else
|
||||
self:refresh({ invalidate = true })
|
||||
end
|
||||
end)
|
||||
if not watcher then
|
||||
@@ -171,7 +443,7 @@ function Repo:_watch_tree(path, on_change)
|
||||
break
|
||||
end
|
||||
if typ == "directory" then
|
||||
self:_watch_tree(vim.fs.joinpath(path, name), on_change)
|
||||
self:_watch_tree(vim.fs.joinpath(relpath, name))
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -179,22 +451,18 @@ end
|
||||
function Repo:start_watcher()
|
||||
self._watchers = {}
|
||||
local top = start_fs_event(self.gitdir, function(filename)
|
||||
if
|
||||
filename
|
||||
and (filename:match("^objects") or filename:match("^logs"))
|
||||
then
|
||||
if not filename then
|
||||
self:refresh({ invalidate = true })
|
||||
return
|
||||
end
|
||||
self:refresh()
|
||||
self:_handle_fs_event(filename)
|
||||
end)
|
||||
if not top then
|
||||
util.error("git: failed to watch %s", self.gitdir)
|
||||
return
|
||||
end
|
||||
self._watchers[self.gitdir] = top
|
||||
self:_watch_tree(vim.fs.joinpath(self.gitdir, "refs"), function()
|
||||
self:refresh()
|
||||
end)
|
||||
self:_watch_tree("refs")
|
||||
end
|
||||
|
||||
function Repo:close()
|
||||
@@ -203,13 +471,12 @@ function Repo:close()
|
||||
watcher:close()
|
||||
end
|
||||
self._watchers = {}
|
||||
self:_stop_modules_watcher()
|
||||
self._refresh_handle.close()
|
||||
self._events:clear()
|
||||
end
|
||||
|
||||
---@param event ow.Git.Repo.Event
|
||||
---@param fn fun(...)
|
||||
---@return fun() unsubscribe
|
||||
---@overload fun(event: "refresh", fn: fun(change: ow.Git.Repo.Change, status: ow.Git.Status)): fun()
|
||||
function Repo:on(event, fn)
|
||||
return self._events:on(event, fn)
|
||||
end
|
||||
@@ -261,16 +528,6 @@ function Repo:list_refs()
|
||||
end)
|
||||
end
|
||||
|
||||
local PSEUDO_REFS = {
|
||||
"HEAD",
|
||||
"FETCH_HEAD",
|
||||
"ORIG_HEAD",
|
||||
"MERGE_HEAD",
|
||||
"REBASE_HEAD",
|
||||
"CHERRY_PICK_HEAD",
|
||||
"REVERT_HEAD",
|
||||
}
|
||||
|
||||
---@return string[]
|
||||
function Repo:list_pseudo_refs()
|
||||
return self:get_cached("pseudo_refs", function(self)
|
||||
@@ -320,13 +577,13 @@ end
|
||||
|
||||
---@alias ow.Git.Repo.ResolveStatus "ok"|"ambiguous"|"missing"
|
||||
|
||||
---@param prefix string
|
||||
---@param abbrev string
|
||||
---@return string? full_sha
|
||||
---@return ow.Git.Repo.ResolveStatus
|
||||
function Repo:resolve_sha(prefix)
|
||||
local result = self:get_cached("resolve:" .. prefix, function(self)
|
||||
function Repo:resolve_sha(abbrev)
|
||||
local result = self:get_cached("resolve:" .. abbrev, function(self)
|
||||
local out = util.git(
|
||||
{ "rev-parse", "--disambiguate=" .. prefix },
|
||||
{ "rev-parse", "--disambiguate=" .. abbrev },
|
||||
{ cwd = self.worktree, silent = true }
|
||||
)
|
||||
local trimmed = out and vim.trim(out) or ""
|
||||
@@ -342,15 +599,10 @@ function Repo:resolve_sha(prefix)
|
||||
return result[1], result[2]
|
||||
end
|
||||
|
||||
---@type table<string, ow.Git.Repo> keyed by worktree
|
||||
local repos = {}
|
||||
|
||||
---@type table<string, true>
|
||||
local no_repo_dirs = {}
|
||||
|
||||
---@param event ow.Git.Repo.Event
|
||||
---@param fn fun(...)
|
||||
---@return fun() unsubscribe
|
||||
---@overload fun(event: "refresh", fn: fun(r: ow.Git.Repo, change: ow.Git.Repo.Change, status: ow.Git.Status)): fun()
|
||||
function M.on(event, fn)
|
||||
return global:on(event, fn)
|
||||
end
|
||||
@@ -371,16 +623,9 @@ function M.on_uri_refresh(prefix, fn)
|
||||
end)
|
||||
end
|
||||
|
||||
---@param r ow.Git.Repo
|
||||
local function release(r)
|
||||
if repos[r.worktree] ~= r then
|
||||
return
|
||||
end
|
||||
if next(r.buffers) ~= nil or next(r.tabs) ~= nil then
|
||||
return
|
||||
end
|
||||
r:close()
|
||||
repos[r.worktree] = nil
|
||||
---@return table<string, ow.Git.Repo>
|
||||
function M.all()
|
||||
return repos
|
||||
end
|
||||
|
||||
---@param buf integer
|
||||
@@ -403,12 +648,15 @@ local function find_by_path(path)
|
||||
if repos[path] then
|
||||
return repos[path]
|
||||
end
|
||||
for wt, r in pairs(repos) do
|
||||
local best
|
||||
for wt in pairs(repos) do
|
||||
if path:sub(1, #wt + 1) == wt .. "/" then
|
||||
return r
|
||||
if not best or #wt > #best then
|
||||
best = wt
|
||||
end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
return best and repos[best] or nil
|
||||
end
|
||||
|
||||
---@param buf integer
|
||||
@@ -434,9 +682,11 @@ end
|
||||
---@param arg? integer | string bufnr (default current) or worktree path
|
||||
---@return ow.Git.Repo?
|
||||
function M.resolve(arg)
|
||||
local existing = M.find(arg)
|
||||
if existing then
|
||||
return existing
|
||||
if type(arg) ~= "string" then
|
||||
local existing = find_by_buf(expand_buf(arg))
|
||||
if existing then
|
||||
return existing
|
||||
end
|
||||
end
|
||||
local path
|
||||
if type(arg) == "string" then
|
||||
@@ -508,7 +758,7 @@ function M.bind(buf, r)
|
||||
end
|
||||
if prev then
|
||||
prev.buffers[buf] = nil
|
||||
release(prev)
|
||||
release_if_unused(prev)
|
||||
end
|
||||
r.buffers[buf] = { repo = r }
|
||||
end
|
||||
@@ -521,7 +771,7 @@ function M.unbind(buf)
|
||||
return
|
||||
end
|
||||
r.buffers[buf] = nil
|
||||
release(r)
|
||||
release_if_unused(r)
|
||||
end
|
||||
|
||||
---@param buf integer
|
||||
@@ -576,7 +826,7 @@ function M.update_cwd_repo()
|
||||
end
|
||||
if old then
|
||||
old.tabs[tab] = nil
|
||||
release(old)
|
||||
release_if_unused(old)
|
||||
end
|
||||
if new then
|
||||
new.tabs[tab] = true
|
||||
@@ -589,7 +839,7 @@ function M.release_tab(tab)
|
||||
for _, r in pairs(repos) do
|
||||
if r.tabs[tab] then
|
||||
r.tabs[tab] = nil
|
||||
release(r)
|
||||
release_if_unused(r)
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user