From 5f956401c1774dd9253fdcf510ed375d3d6ccd0c Mon Sep 17 00:00:00 2001 From: Oscar Wallberg Date: Tue, 19 May 2026 09:50:31 +0200 Subject: [PATCH] feat(git): per-key cache invalidation and optional submodule tracking --- lua/git/core/repo.lua | 374 ++++++++++++++++++++++++++++++++------- lua/git/core/status.lua | 43 +++++ lua/git/statusline.lua | 28 ++- plugin/git.lua | 4 +- test/git/helpers.lua | 21 +++ test/git/repo_test.lua | 150 ++++++++++++++++ test/git/status_test.lua | 60 +++++++ 7 files changed, 611 insertions(+), 69 deletions(-) diff --git a/lua/git/core/repo.lua b/lua/git/core/repo.lua index 645e8a1..1b7fe11 100644 --- a/lua/git/core/repo.lua +++ b/lua/git/core/repo.lua @@ -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 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 + +---@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 +---@field private _fetch_epoch integer +---@field private _pending_invalidate boolean +---@field package _submodules table 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 +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>? +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 keyed by worktree -local repos = {} - ---@type table 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 +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 diff --git a/lua/git/core/status.lua b/lua/git/core/status.lua index 376b3d7..7e626ec 100644 --- a/lua/git/core/status.lua +++ b/lua/git/core/status.lua @@ -274,6 +274,49 @@ local function strip_dir_slash(path) return path 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 +---@param next_ table +---@return table +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 ---@return ow.Git.Status function M.parse(stdout) diff --git a/lua/git/statusline.lua b/lua/git/statusline.lua index 43b2c4d..99fb2c7 100644 --- a/lua/git/statusline.lua +++ b/lua/git/statusline.lua @@ -34,6 +34,15 @@ local function clear(buf) vim.b[buf].git_status_string = nil 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 r ow.Git.Repo? local function update_buf(buf, r) @@ -52,18 +61,25 @@ local function update_buf(buf, r) if not rel then return clear(buf) end - local entry = r.status.entries[rel] - vim.b[buf].git_status = { head = r:head(), entry = entry } - vim.b[buf].git_status_string = render(entry) + set_status(buf, r, rel) end repo.on("refresh", function(r) local any_visible = false for buf in pairs(r.buffers) do if vim.api.nvim_buf_is_loaded(buf) then - update_buf(buf, r) - if not any_visible and #vim.fn.win_findbuf(buf) > 0 then - any_visible = true + local name = vim.api.nvim_buf_get_name(buf) + 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 + end + end end end end diff --git a/plugin/git.lua b/plugin/git.lua index 8ad35c5..8455db3 100644 --- a/plugin/git.lua +++ b/plugin/git.lua @@ -56,7 +56,9 @@ vim.api.nvim_create_autocmd({ "BufWritePost", "FileChangedShellPost" }, { vim.api.nvim_create_autocmd({ "ShellCmdPost", "TermLeave", "FocusGained" }, { group = group, callback = function() - require("git.core.repo").refresh_all() + for _, r in pairs(require("git.core.repo").all()) do + r:refresh({ invalidate = true }) + end end, }) vim.api.nvim_create_autocmd({ "BufDelete", "BufWipeout" }, { diff --git a/test/git/helpers.lua b/test/git/helpers.lua index ca69df8..e916502 100644 --- a/test/git/helpers.lua +++ b/test/git/helpers.lua @@ -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 diff --git a/test/git/repo_test.lua b/test/git/repo_test.lua index 28c81b6..5ac0c57 100644 --- a/test/git/repo_test.lua +++ b/test/git/repo_test.lua @@ -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)) diff --git a/test/git/status_test.lua b/test/git/status_test.lua index d682b95..fab1ab9 100644 --- a/test/git/status_test.lua +++ b/test/git/status_test.lua @@ -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) +