feat(git): per-key cache invalidation and optional submodule tracking

This commit is contained in:
2026-05-19 09:50:31 +02:00
parent c66b2f04d2
commit 5f956401c1
7 changed files with 611 additions and 69 deletions
+21
View File
@@ -73,4 +73,25 @@ function M.make_repo(files, opts)
return dir
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
+150
View File
@@ -134,6 +134,156 @@ t.test("resolve_sha caches by prefix", function()
t.truthy(r._cache["resolve:" .. short], "result should be cached")
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()
local dir = h.make_repo({ a = "x" })
local r = assert(require("git.core.repo").resolve(dir))
+60
View File
@@ -325,3 +325,63 @@ t.test("Status:aggregate_at with prefix '.' includes everything", function()
}))
t.eq(#s:aggregate_at("."), 2)
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)