feat(git): per-key cache invalidation and optional submodule tracking
This commit is contained in:
+310
-60
@@ -20,10 +20,36 @@ end
|
|||||||
---@field index_writer boolean?
|
---@field index_writer boolean?
|
||||||
---@field index_mode string?
|
---@field index_mode string?
|
||||||
|
|
||||||
---@alias ow.Git.Repo.Event "refresh"
|
---@alias ow.Git.Repo.Event
|
||||||
|
---| "refresh"
|
||||||
|
|
||||||
local global = util.Emitter.new()
|
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
|
---@class ow.Git.Repo
|
||||||
---@field gitdir string
|
---@field gitdir string
|
||||||
---@field worktree string
|
---@field worktree string
|
||||||
@@ -35,6 +61,9 @@ local global = util.Emitter.new()
|
|||||||
---@field private _schedule_refresh fun(self: ow.Git.Repo)
|
---@field private _schedule_refresh fun(self: ow.Git.Repo)
|
||||||
---@field private _refresh_handle ow.Git.Util.DebounceHandle
|
---@field private _refresh_handle ow.Git.Util.DebounceHandle
|
||||||
---@field private _cache table<string, any>
|
---@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 = {}
|
local Repo = {}
|
||||||
Repo.__index = Repo
|
Repo.__index = Repo
|
||||||
|
|
||||||
@@ -50,12 +79,128 @@ local STATUS_ARGS = {
|
|||||||
"-z",
|
"-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
|
---@private
|
||||||
function Repo:_fetch_status()
|
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, {
|
util.git(STATUS_ARGS, {
|
||||||
cwd = self.worktree,
|
cwd = self.worktree,
|
||||||
on_exit = function(result)
|
on_exit = function(result)
|
||||||
self._cache = {}
|
if epoch ~= self._fetch_epoch then
|
||||||
|
return
|
||||||
|
end
|
||||||
if result.code ~= 0 then
|
if result.code ~= 0 then
|
||||||
util.error(
|
util.error(
|
||||||
"git status failed: %s",
|
"git status failed: %s",
|
||||||
@@ -64,13 +209,23 @@ function Repo:_fetch_status()
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
self.status = status.parse(result.stdout or "")
|
self.status = status.parse(result.stdout or "")
|
||||||
self._events:emit("refresh", self.status)
|
local change = {
|
||||||
global:emit("refresh", self, self.status)
|
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,
|
||||||
})
|
})
|
||||||
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()
|
self:_schedule_refresh()
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -86,11 +241,20 @@ function Repo.new(gitdir, worktree)
|
|||||||
status = status.parse(""),
|
status = status.parse(""),
|
||||||
_events = util.Emitter.new(),
|
_events = util.Emitter.new(),
|
||||||
_cache = {},
|
_cache = {},
|
||||||
|
_fetch_epoch = 0,
|
||||||
|
_pending_invalidate = false,
|
||||||
|
_submodules = {},
|
||||||
}, Repo)
|
}, Repo)
|
||||||
self._schedule_refresh, self._refresh_handle =
|
self._schedule_refresh, self._refresh_handle =
|
||||||
util.debounce(Repo._fetch_status, 50)
|
util.debounce(Repo._fetch_status, 50)
|
||||||
self:start_watcher()
|
self:start_watcher()
|
||||||
self:refresh()
|
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
|
return self
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -130,9 +294,114 @@ local function start_fs_event(path, on_event)
|
|||||||
end
|
end
|
||||||
|
|
||||||
---@private
|
---@private
|
||||||
---@param path string
|
---@param name string
|
||||||
---@param on_change fun()
|
function Repo:_unregister_submodule(name)
|
||||||
function Repo:_watch_tree(path, on_change)
|
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
|
if self._watchers[path] then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -150,11 +419,14 @@ function Repo:_watch_tree(path, on_change)
|
|||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
on_change()
|
|
||||||
if filename then
|
if filename then
|
||||||
|
local child = vim.fs.joinpath(relpath, filename)
|
||||||
|
self:_handle_fs_event(child)
|
||||||
vim.schedule(function()
|
vim.schedule(function()
|
||||||
self:_watch_tree(vim.fs.joinpath(path, filename), on_change)
|
self:_watch_tree(child)
|
||||||
end)
|
end)
|
||||||
|
else
|
||||||
|
self:refresh({ invalidate = true })
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
if not watcher then
|
if not watcher then
|
||||||
@@ -171,7 +443,7 @@ function Repo:_watch_tree(path, on_change)
|
|||||||
break
|
break
|
||||||
end
|
end
|
||||||
if typ == "directory" then
|
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
|
end
|
||||||
end
|
end
|
||||||
@@ -179,22 +451,18 @@ end
|
|||||||
function Repo:start_watcher()
|
function Repo:start_watcher()
|
||||||
self._watchers = {}
|
self._watchers = {}
|
||||||
local top = start_fs_event(self.gitdir, function(filename)
|
local top = start_fs_event(self.gitdir, function(filename)
|
||||||
if
|
if not filename then
|
||||||
filename
|
self:refresh({ invalidate = true })
|
||||||
and (filename:match("^objects") or filename:match("^logs"))
|
|
||||||
then
|
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
self:refresh()
|
self:_handle_fs_event(filename)
|
||||||
end)
|
end)
|
||||||
if not top then
|
if not top then
|
||||||
util.error("git: failed to watch %s", self.gitdir)
|
util.error("git: failed to watch %s", self.gitdir)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
self._watchers[self.gitdir] = top
|
self._watchers[self.gitdir] = top
|
||||||
self:_watch_tree(vim.fs.joinpath(self.gitdir, "refs"), function()
|
self:_watch_tree("refs")
|
||||||
self:refresh()
|
|
||||||
end)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
function Repo:close()
|
function Repo:close()
|
||||||
@@ -203,13 +471,12 @@ function Repo:close()
|
|||||||
watcher:close()
|
watcher:close()
|
||||||
end
|
end
|
||||||
self._watchers = {}
|
self._watchers = {}
|
||||||
|
self:_stop_modules_watcher()
|
||||||
self._refresh_handle.close()
|
self._refresh_handle.close()
|
||||||
self._events:clear()
|
self._events:clear()
|
||||||
end
|
end
|
||||||
|
|
||||||
---@param event ow.Git.Repo.Event
|
---@overload fun(event: "refresh", fn: fun(change: ow.Git.Repo.Change, status: ow.Git.Status)): fun()
|
||||||
---@param fn fun(...)
|
|
||||||
---@return fun() unsubscribe
|
|
||||||
function Repo:on(event, fn)
|
function Repo:on(event, fn)
|
||||||
return self._events:on(event, fn)
|
return self._events:on(event, fn)
|
||||||
end
|
end
|
||||||
@@ -261,16 +528,6 @@ function Repo:list_refs()
|
|||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
local PSEUDO_REFS = {
|
|
||||||
"HEAD",
|
|
||||||
"FETCH_HEAD",
|
|
||||||
"ORIG_HEAD",
|
|
||||||
"MERGE_HEAD",
|
|
||||||
"REBASE_HEAD",
|
|
||||||
"CHERRY_PICK_HEAD",
|
|
||||||
"REVERT_HEAD",
|
|
||||||
}
|
|
||||||
|
|
||||||
---@return string[]
|
---@return string[]
|
||||||
function Repo:list_pseudo_refs()
|
function Repo:list_pseudo_refs()
|
||||||
return self:get_cached("pseudo_refs", function(self)
|
return self:get_cached("pseudo_refs", function(self)
|
||||||
@@ -320,13 +577,13 @@ end
|
|||||||
|
|
||||||
---@alias ow.Git.Repo.ResolveStatus "ok"|"ambiguous"|"missing"
|
---@alias ow.Git.Repo.ResolveStatus "ok"|"ambiguous"|"missing"
|
||||||
|
|
||||||
---@param prefix string
|
---@param abbrev string
|
||||||
---@return string? full_sha
|
---@return string? full_sha
|
||||||
---@return ow.Git.Repo.ResolveStatus
|
---@return ow.Git.Repo.ResolveStatus
|
||||||
function Repo:resolve_sha(prefix)
|
function Repo:resolve_sha(abbrev)
|
||||||
local result = self:get_cached("resolve:" .. prefix, function(self)
|
local result = self:get_cached("resolve:" .. abbrev, function(self)
|
||||||
local out = util.git(
|
local out = util.git(
|
||||||
{ "rev-parse", "--disambiguate=" .. prefix },
|
{ "rev-parse", "--disambiguate=" .. abbrev },
|
||||||
{ cwd = self.worktree, silent = true }
|
{ cwd = self.worktree, silent = true }
|
||||||
)
|
)
|
||||||
local trimmed = out and vim.trim(out) or ""
|
local trimmed = out and vim.trim(out) or ""
|
||||||
@@ -342,15 +599,10 @@ function Repo:resolve_sha(prefix)
|
|||||||
return result[1], result[2]
|
return result[1], result[2]
|
||||||
end
|
end
|
||||||
|
|
||||||
---@type table<string, ow.Git.Repo> keyed by worktree
|
|
||||||
local repos = {}
|
|
||||||
|
|
||||||
---@type table<string, true>
|
---@type table<string, true>
|
||||||
local no_repo_dirs = {}
|
local no_repo_dirs = {}
|
||||||
|
|
||||||
---@param event ow.Git.Repo.Event
|
---@overload fun(event: "refresh", fn: fun(r: ow.Git.Repo, change: ow.Git.Repo.Change, status: ow.Git.Status)): fun()
|
||||||
---@param fn fun(...)
|
|
||||||
---@return fun() unsubscribe
|
|
||||||
function M.on(event, fn)
|
function M.on(event, fn)
|
||||||
return global:on(event, fn)
|
return global:on(event, fn)
|
||||||
end
|
end
|
||||||
@@ -371,16 +623,9 @@ function M.on_uri_refresh(prefix, fn)
|
|||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
---@param r ow.Git.Repo
|
---@return table<string, ow.Git.Repo>
|
||||||
local function release(r)
|
function M.all()
|
||||||
if repos[r.worktree] ~= r then
|
return repos
|
||||||
return
|
|
||||||
end
|
|
||||||
if next(r.buffers) ~= nil or next(r.tabs) ~= nil then
|
|
||||||
return
|
|
||||||
end
|
|
||||||
r:close()
|
|
||||||
repos[r.worktree] = nil
|
|
||||||
end
|
end
|
||||||
|
|
||||||
---@param buf integer
|
---@param buf integer
|
||||||
@@ -403,12 +648,15 @@ local function find_by_path(path)
|
|||||||
if repos[path] then
|
if repos[path] then
|
||||||
return repos[path]
|
return repos[path]
|
||||||
end
|
end
|
||||||
for wt, r in pairs(repos) do
|
local best
|
||||||
|
for wt in pairs(repos) do
|
||||||
if path:sub(1, #wt + 1) == wt .. "/" then
|
if path:sub(1, #wt + 1) == wt .. "/" then
|
||||||
return r
|
if not best or #wt > #best then
|
||||||
|
best = wt
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
return nil
|
end
|
||||||
|
return best and repos[best] or nil
|
||||||
end
|
end
|
||||||
|
|
||||||
---@param buf integer
|
---@param buf integer
|
||||||
@@ -434,10 +682,12 @@ end
|
|||||||
---@param arg? integer | string bufnr (default current) or worktree path
|
---@param arg? integer | string bufnr (default current) or worktree path
|
||||||
---@return ow.Git.Repo?
|
---@return ow.Git.Repo?
|
||||||
function M.resolve(arg)
|
function M.resolve(arg)
|
||||||
local existing = M.find(arg)
|
if type(arg) ~= "string" then
|
||||||
|
local existing = find_by_buf(expand_buf(arg))
|
||||||
if existing then
|
if existing then
|
||||||
return existing
|
return existing
|
||||||
end
|
end
|
||||||
|
end
|
||||||
local path
|
local path
|
||||||
if type(arg) == "string" then
|
if type(arg) == "string" then
|
||||||
path = vim.fn.resolve(arg)
|
path = vim.fn.resolve(arg)
|
||||||
@@ -508,7 +758,7 @@ function M.bind(buf, r)
|
|||||||
end
|
end
|
||||||
if prev then
|
if prev then
|
||||||
prev.buffers[buf] = nil
|
prev.buffers[buf] = nil
|
||||||
release(prev)
|
release_if_unused(prev)
|
||||||
end
|
end
|
||||||
r.buffers[buf] = { repo = r }
|
r.buffers[buf] = { repo = r }
|
||||||
end
|
end
|
||||||
@@ -521,7 +771,7 @@ function M.unbind(buf)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
r.buffers[buf] = nil
|
r.buffers[buf] = nil
|
||||||
release(r)
|
release_if_unused(r)
|
||||||
end
|
end
|
||||||
|
|
||||||
---@param buf integer
|
---@param buf integer
|
||||||
@@ -576,7 +826,7 @@ function M.update_cwd_repo()
|
|||||||
end
|
end
|
||||||
if old then
|
if old then
|
||||||
old.tabs[tab] = nil
|
old.tabs[tab] = nil
|
||||||
release(old)
|
release_if_unused(old)
|
||||||
end
|
end
|
||||||
if new then
|
if new then
|
||||||
new.tabs[tab] = true
|
new.tabs[tab] = true
|
||||||
@@ -589,7 +839,7 @@ function M.release_tab(tab)
|
|||||||
for _, r in pairs(repos) do
|
for _, r in pairs(repos) do
|
||||||
if r.tabs[tab] then
|
if r.tabs[tab] then
|
||||||
r.tabs[tab] = nil
|
r.tabs[tab] = nil
|
||||||
release(r)
|
release_if_unused(r)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -274,6 +274,49 @@ local function strip_dir_slash(path)
|
|||||||
return path
|
return path
|
||||||
end
|
end
|
||||||
|
|
||||||
|
---@param a ow.Git.Status.Entry?
|
||||||
|
---@param b ow.Git.Status.Entry?
|
||||||
|
---@return boolean
|
||||||
|
function M.entry_equal(a, b)
|
||||||
|
if a == nil or b == nil then
|
||||||
|
return a == b
|
||||||
|
end
|
||||||
|
if a.kind ~= b.kind or a.path ~= b.path then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
if a.kind == "changed" then
|
||||||
|
---@cast a ow.Git.Status.ChangedEntry
|
||||||
|
---@cast b ow.Git.Status.ChangedEntry
|
||||||
|
return a.staged == b.staged
|
||||||
|
and a.unstaged == b.unstaged
|
||||||
|
and a.orig == b.orig
|
||||||
|
end
|
||||||
|
if a.kind == "unmerged" then
|
||||||
|
---@cast a ow.Git.Status.UnmergedEntry
|
||||||
|
---@cast b ow.Git.Status.UnmergedEntry
|
||||||
|
return a.conflict == b.conflict
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param prior table<string, ow.Git.Status.Entry>
|
||||||
|
---@param next_ table<string, ow.Git.Status.Entry>
|
||||||
|
---@return table<string, true>
|
||||||
|
function M.diff_entries(prior, next_)
|
||||||
|
local paths = {}
|
||||||
|
for path, entry in pairs(next_) do
|
||||||
|
if not M.entry_equal(prior[path], entry) then
|
||||||
|
paths[path] = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
for path in pairs(prior) do
|
||||||
|
if next_[path] == nil then
|
||||||
|
paths[path] = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return paths
|
||||||
|
end
|
||||||
|
|
||||||
---@param stdout string
|
---@param stdout string
|
||||||
---@return ow.Git.Status
|
---@return ow.Git.Status
|
||||||
function M.parse(stdout)
|
function M.parse(stdout)
|
||||||
|
|||||||
+21
-5
@@ -34,6 +34,15 @@ local function clear(buf)
|
|||||||
vim.b[buf].git_status_string = nil
|
vim.b[buf].git_status_string = nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
---@param buf integer
|
||||||
|
---@param r ow.Git.Repo
|
||||||
|
---@param rel string
|
||||||
|
local function set_status(buf, r, rel)
|
||||||
|
local entry = r.status.entries[rel]
|
||||||
|
vim.b[buf].git_status = { head = r:head(), entry = entry }
|
||||||
|
vim.b[buf].git_status_string = render(entry)
|
||||||
|
end
|
||||||
|
|
||||||
---@param buf integer
|
---@param buf integer
|
||||||
---@param r ow.Git.Repo?
|
---@param r ow.Git.Repo?
|
||||||
local function update_buf(buf, r)
|
local function update_buf(buf, r)
|
||||||
@@ -52,21 +61,28 @@ local function update_buf(buf, r)
|
|||||||
if not rel then
|
if not rel then
|
||||||
return clear(buf)
|
return clear(buf)
|
||||||
end
|
end
|
||||||
local entry = r.status.entries[rel]
|
set_status(buf, r, rel)
|
||||||
vim.b[buf].git_status = { head = r:head(), entry = entry }
|
|
||||||
vim.b[buf].git_status_string = render(entry)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
repo.on("refresh", function(r)
|
repo.on("refresh", function(r)
|
||||||
local any_visible = false
|
local any_visible = false
|
||||||
for buf in pairs(r.buffers) do
|
for buf in pairs(r.buffers) do
|
||||||
if vim.api.nvim_buf_is_loaded(buf) then
|
if vim.api.nvim_buf_is_loaded(buf) then
|
||||||
update_buf(buf, r)
|
local name = vim.api.nvim_buf_get_name(buf)
|
||||||
if not any_visible and #vim.fn.win_findbuf(buf) > 0 then
|
if name ~= "" and not util.is_uri(name) then
|
||||||
|
local rel = vim.fs.relpath(r.worktree, vim.fn.resolve(name))
|
||||||
|
if rel then
|
||||||
|
set_status(buf, r, rel)
|
||||||
|
if
|
||||||
|
not any_visible
|
||||||
|
and #vim.fn.win_findbuf(buf) > 0
|
||||||
|
then
|
||||||
any_visible = true
|
any_visible = true
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
if any_visible then
|
if any_visible then
|
||||||
vim.cmd.redrawstatus({ bang = true })
|
vim.cmd.redrawstatus({ bang = true })
|
||||||
end
|
end
|
||||||
|
|||||||
+3
-1
@@ -56,7 +56,9 @@ vim.api.nvim_create_autocmd({ "BufWritePost", "FileChangedShellPost" }, {
|
|||||||
vim.api.nvim_create_autocmd({ "ShellCmdPost", "TermLeave", "FocusGained" }, {
|
vim.api.nvim_create_autocmd({ "ShellCmdPost", "TermLeave", "FocusGained" }, {
|
||||||
group = group,
|
group = group,
|
||||||
callback = function()
|
callback = function()
|
||||||
require("git.core.repo").refresh_all()
|
for _, r in pairs(require("git.core.repo").all()) do
|
||||||
|
r:refresh({ invalidate = true })
|
||||||
|
end
|
||||||
end,
|
end,
|
||||||
})
|
})
|
||||||
vim.api.nvim_create_autocmd({ "BufDelete", "BufWipeout" }, {
|
vim.api.nvim_create_autocmd({ "BufDelete", "BufWipeout" }, {
|
||||||
|
|||||||
@@ -73,4 +73,25 @@ function M.make_repo(files, opts)
|
|||||||
return dir
|
return dir
|
||||||
end
|
end
|
||||||
|
|
||||||
|
---Build an outer repo with one nested submodule at `sub/`. Both the
|
||||||
|
---outer and inner repo are committed and registered for cleanup.
|
||||||
|
---@return string outer
|
||||||
|
---@return string inner
|
||||||
|
function M.make_submodule_repo()
|
||||||
|
local inner = M.make_repo({ a = "x\n" })
|
||||||
|
local outer = M.make_repo({ x = "x\n" })
|
||||||
|
vim.system({
|
||||||
|
"git",
|
||||||
|
"-c",
|
||||||
|
"protocol.file.allow=always",
|
||||||
|
"submodule",
|
||||||
|
"add",
|
||||||
|
"--quiet",
|
||||||
|
inner,
|
||||||
|
"sub",
|
||||||
|
}, { cwd = outer, text = true }):wait()
|
||||||
|
M.git(outer, "commit", "-q", "-m", "add submodule")
|
||||||
|
return outer, inner
|
||||||
|
end
|
||||||
|
|
||||||
return M
|
return M
|
||||||
|
|||||||
@@ -134,6 +134,156 @@ t.test("resolve_sha caches by prefix", function()
|
|||||||
t.truthy(r._cache["resolve:" .. short], "result should be cached")
|
t.truthy(r._cache["resolve:" .. short], "result should be cached")
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
---@param r ow.Git.Repo
|
||||||
|
local function wait_initial(r)
|
||||||
|
t.wait_for(function()
|
||||||
|
return r.status.branch.head ~= nil
|
||||||
|
end, "initial fetch to complete", 2000)
|
||||||
|
end
|
||||||
|
|
||||||
|
t.test("_invalidate clears only matching keys for HEAD", function()
|
||||||
|
local dir = h.make_repo({ a = "x" })
|
||||||
|
local r = assert(require("git.core.repo").resolve(dir))
|
||||||
|
r._cache.head = "h"
|
||||||
|
r._cache.refs = { "main" }
|
||||||
|
r._cache.pseudo_refs = { "HEAD" }
|
||||||
|
r._cache.stash_refs = {}
|
||||||
|
r._cache["resolve:abc"] = { "deadbeef", "ok" }
|
||||||
|
r:_invalidate("HEAD")
|
||||||
|
t.eq(r._cache.head, nil)
|
||||||
|
t.eq(r._cache.pseudo_refs, nil)
|
||||||
|
t.eq(r._cache["resolve:abc"], nil)
|
||||||
|
t.truthy(r._cache.refs)
|
||||||
|
t.truthy(r._cache.stash_refs)
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("_invalidate clears refs/head/resolve for refs/heads/*", function()
|
||||||
|
local dir = h.make_repo({ a = "x" })
|
||||||
|
local r = assert(require("git.core.repo").resolve(dir))
|
||||||
|
r._cache.head = "h"
|
||||||
|
r._cache.refs = { "main" }
|
||||||
|
r._cache.pseudo_refs = { "HEAD" }
|
||||||
|
r._cache.stash_refs = {}
|
||||||
|
r._cache["resolve:abc"] = { "deadbeef", "ok" }
|
||||||
|
r:_invalidate("refs/heads/feature")
|
||||||
|
t.eq(r._cache.head, nil)
|
||||||
|
t.eq(r._cache.refs, nil)
|
||||||
|
t.eq(r._cache["resolve:abc"], nil)
|
||||||
|
t.truthy(r._cache.pseudo_refs)
|
||||||
|
t.truthy(r._cache.stash_refs)
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("_invalidate matches stash_refs on refs/stash and logs/refs/stash", function()
|
||||||
|
local dir = h.make_repo({ a = "x" })
|
||||||
|
local r = assert(require("git.core.repo").resolve(dir))
|
||||||
|
r._cache.stash_refs = {}
|
||||||
|
r:_invalidate("refs/stash")
|
||||||
|
t.eq(r._cache.stash_refs, nil)
|
||||||
|
r._cache.stash_refs = {}
|
||||||
|
r:_invalidate("logs/refs/stash")
|
||||||
|
t.eq(r._cache.stash_refs, nil)
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("refresh with invalidate=true wipes cache on next fetch", function()
|
||||||
|
local dir = h.make_repo({ a = "x" })
|
||||||
|
local r = assert(require("git.core.repo").resolve(dir))
|
||||||
|
wait_initial(r)
|
||||||
|
r._cache.head = "stale"
|
||||||
|
r._cache["resolve:abc"] = { "x", "ok" }
|
||||||
|
r:refresh({ invalidate = true })
|
||||||
|
t.wait_for(function()
|
||||||
|
return r._cache.head == nil
|
||||||
|
end, "cache wiped after invalidating refresh completes", 2000)
|
||||||
|
t.eq(r._cache.head, nil)
|
||||||
|
t.eq(r._cache["resolve:abc"], nil)
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("refresh emits change.paths listing structurally-changed paths", function()
|
||||||
|
local dir = h.make_repo({ a = "1", b = "1" })
|
||||||
|
local r = assert(require("git.core.repo").resolve(dir))
|
||||||
|
wait_initial(r)
|
||||||
|
t.write(dir, "a", "2")
|
||||||
|
---@type ow.Git.Repo.Change?
|
||||||
|
local change_seen
|
||||||
|
local unsub = r:on("refresh", function(change)
|
||||||
|
change_seen = change
|
||||||
|
end)
|
||||||
|
r:refresh()
|
||||||
|
t.wait_for(function()
|
||||||
|
return change_seen ~= nil
|
||||||
|
end, "refresh emit", 2000)
|
||||||
|
unsub()
|
||||||
|
local change = assert(change_seen)
|
||||||
|
t.truthy(change.paths["a"])
|
||||||
|
t.falsy(change.paths["b"], "b is unchanged structurally")
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("submodule: parent enumerates initialized submodules with flag on", function()
|
||||||
|
vim.g.git_submodule_recursion = true
|
||||||
|
t.defer(function()
|
||||||
|
vim.g.git_submodule_recursion = nil
|
||||||
|
end)
|
||||||
|
local outer_path = h.make_submodule_repo()
|
||||||
|
local outer = assert(require("git.core.repo").resolve(outer_path))
|
||||||
|
t.truthy(outer._submodules["sub"], "sub recorded as submodule")
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("submodule: recursion flag eagerly creates child Repos and subscribes", function()
|
||||||
|
vim.g.git_submodule_recursion = true
|
||||||
|
t.defer(function()
|
||||||
|
vim.g.git_submodule_recursion = nil
|
||||||
|
end)
|
||||||
|
local outer_path = h.make_submodule_repo()
|
||||||
|
local outer = assert(require("git.core.repo").resolve(outer_path))
|
||||||
|
wait_initial(outer)
|
||||||
|
local inner = require("git.core.repo").all()[outer_path .. "/sub"]
|
||||||
|
t.truthy(inner, "inner Repo eagerly created")
|
||||||
|
t.truthy(outer._submodules["sub"] and outer._submodules["sub"].unsub, "inner subscribed by outer")
|
||||||
|
|
||||||
|
t.write(outer_path .. "/sub", "a", "modified\n")
|
||||||
|
---@type ow.Git.Repo.Change?
|
||||||
|
local outer_change
|
||||||
|
local unsub = outer:on("refresh", function(change)
|
||||||
|
outer_change = change
|
||||||
|
end)
|
||||||
|
inner:refresh()
|
||||||
|
t.wait_for(function()
|
||||||
|
return outer_change ~= nil
|
||||||
|
end, "outer notified by inner refresh", 2000)
|
||||||
|
unsub()
|
||||||
|
|
||||||
|
local entry = outer.status.entries["sub"]
|
||||||
|
t.truthy(entry, "outer sub entry now present")
|
||||||
|
t.eq(entry.kind, "changed")
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("submodule: no eager creation when flag is off", function()
|
||||||
|
local outer_path = h.make_submodule_repo()
|
||||||
|
local outer = assert(require("git.core.repo").resolve(outer_path))
|
||||||
|
wait_initial(outer)
|
||||||
|
t.eq(
|
||||||
|
require("git.core.repo").all()[outer_path .. "/sub"],
|
||||||
|
nil,
|
||||||
|
"inner Repo not created when flag off"
|
||||||
|
)
|
||||||
|
t.eq(next(outer._submodules), nil)
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("submodule: outer created after inner picks up existing child", function()
|
||||||
|
vim.g.git_submodule_recursion = true
|
||||||
|
t.defer(function()
|
||||||
|
vim.g.git_submodule_recursion = nil
|
||||||
|
end)
|
||||||
|
local outer_path = h.make_submodule_repo()
|
||||||
|
local inner = assert(
|
||||||
|
require("git.core.repo").resolve(outer_path .. "/sub")
|
||||||
|
)
|
||||||
|
wait_initial(inner)
|
||||||
|
local outer = assert(require("git.core.repo").resolve(outer_path))
|
||||||
|
wait_initial(outer)
|
||||||
|
t.truthy(outer._submodules["sub"] and outer._submodules["sub"].unsub, "outer subscribed to pre-existing inner")
|
||||||
|
end)
|
||||||
|
|
||||||
t.test("watcher cleans up after a slash-branch dir is removed", function()
|
t.test("watcher cleans up after a slash-branch dir is removed", function()
|
||||||
local dir = h.make_repo({ a = "x" })
|
local dir = h.make_repo({ a = "x" })
|
||||||
local r = assert(require("git.core.repo").resolve(dir))
|
local r = assert(require("git.core.repo").resolve(dir))
|
||||||
|
|||||||
@@ -325,3 +325,63 @@ t.test("Status:aggregate_at with prefix '.' includes everything", function()
|
|||||||
}))
|
}))
|
||||||
t.eq(#s:aggregate_at("."), 2)
|
t.eq(#s:aggregate_at("."), 2)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
t.test("entry_equal: identical changed entries", function()
|
||||||
|
local a = { kind = "changed", path = "x", staged = "modified" }
|
||||||
|
local b = { kind = "changed", path = "x", staged = "modified" }
|
||||||
|
t.truthy(status.entry_equal(a, b))
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("entry_equal: differing staged side returns false", function()
|
||||||
|
local a = { kind = "changed", path = "x", staged = "modified" }
|
||||||
|
local b = { kind = "changed", path = "x", staged = "added" }
|
||||||
|
t.falsy(status.entry_equal(a, b))
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("entry_equal: differing orig returns false", function()
|
||||||
|
local a = { kind = "changed", path = "x", staged = "renamed", orig = "y" }
|
||||||
|
local b = { kind = "changed", path = "x", staged = "renamed", orig = "z" }
|
||||||
|
t.falsy(status.entry_equal(a, b))
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("entry_equal: nil vs nil is true", function()
|
||||||
|
t.truthy(status.entry_equal(nil, nil))
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("entry_equal: nil vs entry is false", function()
|
||||||
|
t.falsy(status.entry_equal(nil, { kind = "untracked", path = "x" }))
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("entry_equal: different kinds returns false", function()
|
||||||
|
local a = { kind = "untracked", path = "x" }
|
||||||
|
local b = { kind = "ignored", path = "x" }
|
||||||
|
t.falsy(status.entry_equal(a, b))
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("entry_equal: differing unmerged conflict returns false", function()
|
||||||
|
local a = { kind = "unmerged", path = "x", conflict = "both_added" }
|
||||||
|
local b = { kind = "unmerged", path = "x", conflict = "both_modified" }
|
||||||
|
t.falsy(status.entry_equal(a, b))
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("diff_entries: detects additions, removals, and modifications", function()
|
||||||
|
local prior = {
|
||||||
|
a = { kind = "changed", path = "a", staged = "modified" },
|
||||||
|
b = { kind = "untracked", path = "b" },
|
||||||
|
}
|
||||||
|
local next_ = {
|
||||||
|
a = { kind = "changed", path = "a", staged = "added" },
|
||||||
|
c = { kind = "untracked", path = "c" },
|
||||||
|
}
|
||||||
|
local changed = status.diff_entries(prior, next_)
|
||||||
|
t.truthy(changed.a, "a modified")
|
||||||
|
t.truthy(changed.b, "b removed")
|
||||||
|
t.truthy(changed.c, "c added")
|
||||||
|
end)
|
||||||
|
|
||||||
|
t.test("diff_entries: empty when entries match", function()
|
||||||
|
local prior = { a = { kind = "untracked", path = "a" } }
|
||||||
|
local next_ = { a = { kind = "untracked", path = "a" } }
|
||||||
|
t.eq(status.diff_entries(prior, next_), {})
|
||||||
|
end)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user