From 104dda033982f66b4edd81d2e3f1f5eda7c8ab56 Mon Sep 17 00:00:00 2001 From: Dave Aitken Date: Sat, 28 Feb 2026 20:08:16 +0000 Subject: [PATCH 1/3] Fix gitignore cache for nested paths in worktree watcher The recursive worktree watcher reports nested paths like "build/output.o", but the gitignore cache only stores top-level entries like "build". This caused all nested file events in gitignored directories to bypass the cache filter, triggering unnecessary stale indicators. Changes: - Extract first path component from nested paths before checking cache - Fix .git skip to also catch nested .git/ paths (e.g., .git/objects) - Add _is_gitignored() method with unit tests for top-level, nested, prefix-similar, and empty cache scenarios --- lua/gitlad/watcher.lua | 27 +++++++++++- tests/unit/test_watcher.lua | 86 +++++++++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 2 deletions(-) diff --git a/lua/gitlad/watcher.lua b/lua/gitlad/watcher.lua index 00802ba..a2716e1 100644 --- a/lua/gitlad/watcher.lua +++ b/lua/gitlad/watcher.lua @@ -275,6 +275,24 @@ function Watcher:_build_gitignore_cache(callback) end end +--- Check if a filename (possibly nested) is gitignored via the cache +--- The cache contains top-level entries only, so we extract the first +--- path component from nested paths like "build/output.o" → "build". +---@param filename string Filename reported by fs_event (may contain path separators) +---@return boolean +function Watcher:_is_gitignored(filename) + -- Exact match for top-level entries + if self._gitignore_cache[filename] then + return true + end + -- Extract first path component for nested paths + local top_level = filename:match("^([^/]+)/") + if top_level and self._gitignore_cache[top_level] then + return true + end + return false +end + --- Start worktree fs_event watcher on the repo root function Watcher:_start_worktree_watcher() if not self._watch_worktree then @@ -301,7 +319,7 @@ function Watcher:_start_worktree_watcher() end -- Always skip .git directory changes (handled by git dir watchers) - if filename == ".git" then + if filename == ".git" or filename:match("^%.git/") then return end @@ -315,7 +333,9 @@ function Watcher:_start_worktree_watcher() end -- Skip entries that are cached as ignored - if self._gitignore_cache[filename] then + -- The recursive watcher reports nested paths like "build/output.o", + -- so extract the top-level component and check the cache + if self:_is_gitignored(filename) then return end @@ -484,4 +504,7 @@ end -- Export the should_ignore function for testing M._should_ignore = should_ignore +-- Export _is_gitignored for testing (needs a watcher instance) +M._Watcher = Watcher + return M diff --git a/tests/unit/test_watcher.lua b/tests/unit/test_watcher.lua index 00c6a2a..62aec56 100644 --- a/tests/unit/test_watcher.lua +++ b/tests/unit/test_watcher.lua @@ -506,4 +506,90 @@ T["watcher"]["initializes with nil augroup"] = function() eq(w._augroup, nil) end +-- ============================================================================= +-- _is_gitignored tests +-- ============================================================================= + +T["watcher"]["_is_gitignored matches top-level entry exactly"] = function() + local watcher = require("gitlad.watcher") + + local mock_repo_state = { + git_dir = "/tmp/test/.git", + repo_root = "/tmp/test/", + mark_stale = function() end, + } + + local w = watcher.new(mock_repo_state) + w._gitignore_cache = { build = true, node_modules = true } + + eq(w:_is_gitignored("build"), true) + eq(w:_is_gitignored("node_modules"), true) +end + +T["watcher"]["_is_gitignored matches nested path via top-level component"] = function() + local watcher = require("gitlad.watcher") + + local mock_repo_state = { + git_dir = "/tmp/test/.git", + repo_root = "/tmp/test/", + mark_stale = function() end, + } + + local w = watcher.new(mock_repo_state) + w._gitignore_cache = { build = true, node_modules = true } + + eq(w:_is_gitignored("build/output.o"), true) + eq(w:_is_gitignored("build/lib/foo.so"), true) + eq(w:_is_gitignored("node_modules/lodash/index.js"), true) +end + +T["watcher"]["_is_gitignored does not match prefix-similar paths"] = function() + local watcher = require("gitlad.watcher") + + local mock_repo_state = { + git_dir = "/tmp/test/.git", + repo_root = "/tmp/test/", + mark_stale = function() end, + } + + local w = watcher.new(mock_repo_state) + w._gitignore_cache = { build = true } + + -- "builder" is not "build" — should not match + eq(w:_is_gitignored("builder/x"), false) + eq(w:_is_gitignored("building"), false) +end + +T["watcher"]["_is_gitignored returns false for non-ignored entries"] = function() + local watcher = require("gitlad.watcher") + + local mock_repo_state = { + git_dir = "/tmp/test/.git", + repo_root = "/tmp/test/", + mark_stale = function() end, + } + + local w = watcher.new(mock_repo_state) + w._gitignore_cache = { build = true } + + eq(w:_is_gitignored("src/main.lua"), false) + eq(w:_is_gitignored("README.md"), false) +end + +T["watcher"]["_is_gitignored returns false with empty cache"] = function() + local watcher = require("gitlad.watcher") + + local mock_repo_state = { + git_dir = "/tmp/test/.git", + repo_root = "/tmp/test/", + mark_stale = function() end, + } + + local w = watcher.new(mock_repo_state) + w._gitignore_cache = {} + + eq(w:_is_gitignored("build/output.o"), false) + eq(w:_is_gitignored("anything"), false) +end + return T From 0e898ef788156c24ce06bca9e8f9c2ea10fdef34 Mon Sep 17 00:00:00 2001 From: Dave Aitken Date: Sat, 28 Feb 2026 20:13:14 +0000 Subject: [PATCH 2/3] Add submodule path detection with separate longer debounce In repos with many submodules, build activity and IDE indexing inside submodule directories triggers constant watcher events, causing the stale indicator to flash repeatedly. This routes submodule file events through a separate debouncer with a longer timeout (default 5000ms), so submodule churn doesn't interfere with normal workflow responsiveness. Changes: - Parse .gitmodules at watcher start to build submodule path cache - Add _is_submodule_path() to detect files within submodule directories - Route submodule events to _handle_submodule_event() with separate debouncer - Rebuild submodule cache when .gitmodules changes - Add submodule_debounce_ms config option (default 5000ms) - Pass config through status.lua to watcher constructor --- lua/gitlad/config.lua | 2 + lua/gitlad/ui/views/status.lua | 1 + lua/gitlad/watcher.lua | 108 ++++++++++++++++++- tests/e2e/test_watcher.lua | 153 +++++++++++++++++++++++++++ tests/unit/test_watcher.lua | 188 +++++++++++++++++++++++++++++++++ 5 files changed, 450 insertions(+), 2 deletions(-) diff --git a/lua/gitlad/config.lua b/lua/gitlad/config.lua index 74d6179..ec1354d 100644 --- a/lua/gitlad/config.lua +++ b/lua/gitlad/config.lua @@ -27,6 +27,7 @@ local M = {} ---@field auto_refresh boolean Automatically refresh when external changes detected (default: false) ---@field cooldown_ms number Cooldown period in ms after gitlad operations before events are processed (default: 1000) ---@field auto_refresh_debounce_ms number Debounce period in ms before triggering auto-refresh (default: 500) +---@field submodule_debounce_ms number Debounce period in ms for submodule file events (default: 5000). Longer than normal to avoid churn from build/IDE activity in submodules. ---@field watch_worktree boolean Whether to watch working tree files for changes (default: true) ---@class GitladOutputConfig @@ -69,6 +70,7 @@ local defaults = { auto_refresh = false, -- Automatically refresh when external changes detected cooldown_ms = 1000, -- Ignore events for 1s after gitlad operations auto_refresh_debounce_ms = 500, -- Debounce for auto_refresh + submodule_debounce_ms = 5000, -- Longer debounce for submodule file events watch_worktree = true, -- Watch working tree files for changes }, output = { diff --git a/lua/gitlad/ui/views/status.lua b/lua/gitlad/ui/views/status.lua index aa6af09..1abe0a0 100644 --- a/lua/gitlad/ui/views/status.lua +++ b/lua/gitlad/ui/views/status.lua @@ -220,6 +220,7 @@ local function get_or_create_buffer(repo_state) stale_indicator = cfg.watcher.stale_indicator, auto_refresh = cfg.watcher.auto_refresh, auto_refresh_debounce_ms = cfg.watcher.auto_refresh_debounce_ms, + submodule_debounce_ms = cfg.watcher.submodule_debounce_ms, watch_worktree = cfg.watcher.watch_worktree, on_refresh = function() -- Auto-refresh callback: force refresh to bypass cache diff --git a/lua/gitlad/watcher.lua b/lua/gitlad/watcher.lua index a2716e1..3d2983b 100644 --- a/lua/gitlad/watcher.lua +++ b/lua/gitlad/watcher.lua @@ -81,13 +81,16 @@ end ---@field _repo_root string Path to repo root ---@field _watch_worktree boolean Whether to watch working tree ---@field _gitignore_cache table Cache of top-level entries: true = ignored +---@field _submodule_paths table Cache of submodule paths from .gitmodules +---@field _submodule_debounced DebouncedFunction|nil Debounced callback for submodule events +---@field _submodule_debounce_ms number Debounce delay for submodule events ---@field _augroup number|nil Autocmd group ID local Watcher = {} Watcher.__index = Watcher --- Create a new watcher instance ---@param repo_state table RepoState instance ----@param opts? { cooldown_ms?: number, stale_indicator?: boolean, auto_refresh?: boolean, auto_refresh_debounce_ms?: number, on_refresh?: function, watch_worktree?: boolean } Optional configuration +---@param opts? { cooldown_ms?: number, stale_indicator?: boolean, auto_refresh?: boolean, auto_refresh_debounce_ms?: number, submodule_debounce_ms?: number, on_refresh?: function, watch_worktree?: boolean } Optional configuration ---@return Watcher function M.new(repo_state, opts) opts = opts or {} @@ -104,6 +107,8 @@ function M.new(repo_state, opts) self._repo_root = repo_state.repo_root self._watch_worktree = opts.watch_worktree ~= false -- default true self._gitignore_cache = {} + self._submodule_paths = {} + self._submodule_debounce_ms = opts.submodule_debounce_ms or 5000 self._augroup = nil -- Create debounced callback for stale indicator (marks state as stale) @@ -125,6 +130,16 @@ function M.new(repo_state, opts) end, debounce_ms) end + -- Create debounced callback for submodule events (longer timeout) + -- Uses the same action as _handle_event but with a separate, longer debounce + self._submodule_debounced = async.debounce(function() + if self._auto_refresh and self._auto_refresh_debounced then + self._auto_refresh_debounced:call() + elseif self._stale_indicator and self._stale_indicator_debounced then + self._stale_indicator_debounced:call() + end + end, self._submodule_debounce_ms) + return self end @@ -140,6 +155,21 @@ function Watcher:is_in_cooldown() return (now - last_op) < self._cooldown_duration end +--- Handle a submodule file event +--- Same checks as _handle_event but uses the separate submodule debouncer +--- with a longer timeout to avoid churn from build activity in submodules. +function Watcher:_handle_submodule_event() + if not self.running then + return + end + if self:is_in_cooldown() then + return + end + if self._submodule_debounced then + self._submodule_debounced:call() + end +end + --- Handle an event from any source (fs_event, autocmd) --- Checks running state and cooldown, then calls debounced callbacks. function Watcher:_handle_event() @@ -293,6 +323,59 @@ function Watcher:_is_gitignored(filename) return false end +--- Check if a filename is within a submodule directory +--- Iterates _submodule_paths to check if filename equals or starts with a submodule path. +---@param filename string Filename reported by fs_event +---@return boolean +function Watcher:_is_submodule_path(filename) + for sub_path, _ in pairs(self._submodule_paths) do + if filename == sub_path or filename:sub(1, #sub_path + 1) == sub_path .. "/" then + return true + end + end + return false +end + +--- Build submodule path cache from .gitmodules +--- Parses .gitmodules using git config to get submodule paths. +--- Runs synchronously (like gitignore cache) at watcher start. +function Watcher:_build_submodule_cache() + local repo_root = self._repo_root + if not repo_root then + self._submodule_paths = {} + return + end + + -- Check if .gitmodules exists + local gitmodules_path = repo_root:gsub("/$", "") .. "/.gitmodules" + if vim.fn.filereadable(gitmodules_path) ~= 1 then + self._submodule_paths = {} + return + end + + -- Parse submodule paths from .gitmodules + local output = vim.fn.system( + "git -C " + .. vim.fn.shellescape(repo_root) + .. " config --file .gitmodules --get-regexp ^submodule\\\\..+\\\\.path$" + ) + + local new_cache = {} + if vim.v.shell_error == 0 then + for _, line in ipairs(vim.split(output, "\n", { trimempty = true })) do + -- Format: "submodule.foo/bar.path foo/bar" + local path = line:match("^submodule%..+%.path%s+(.+)$") + if path then + path = vim.trim(path) + if path ~= "" then + new_cache[path] = true + end + end + end + end + self._submodule_paths = new_cache +end + --- Start worktree fs_event watcher on the repo root function Watcher:_start_worktree_watcher() if not self._watch_worktree then @@ -332,6 +415,15 @@ function Watcher:_start_worktree_watcher() return end + -- Rebuild submodule cache when .gitmodules changes + if filename == ".gitmodules" then + vim.schedule(function() + self:_build_submodule_cache() + self:_handle_event() + end) + return + end + -- Skip entries that are cached as ignored -- The recursive watcher reports nested paths like "build/output.o", -- so extract the top-level component and check the cache @@ -339,6 +431,14 @@ function Watcher:_start_worktree_watcher() return end + -- Route submodule events through the separate longer debouncer + if self:_is_submodule_path(filename) then + vim.schedule(function() + self:_handle_submodule_event() + end) + return + end + vim.schedule(function() self:_handle_event() end) @@ -441,9 +541,10 @@ function Watcher:start() end self:_watch_directory(git_dir .. "/refs/tags", git_callback) - -- Layer 2: Watch working tree with gitignore filtering + -- Layer 2: Watch working tree with gitignore and submodule filtering if self._watch_worktree then self:_build_gitignore_cache() + self:_build_submodule_cache() self:_start_worktree_watcher() end @@ -472,6 +573,9 @@ function Watcher:stop() if self._auto_refresh_debounced then self._auto_refresh_debounced:cancel() end + if self._submodule_debounced then + self._submodule_debounced:cancel() + end -- Stop and close all .git/ fs_event handles for _, fs_event in ipairs(self._fs_events) do diff --git a/tests/e2e/test_watcher.lua b/tests/e2e/test_watcher.lua index 31d21f8..ed2219f 100644 --- a/tests/e2e/test_watcher.lua +++ b/tests/e2e/test_watcher.lua @@ -1155,4 +1155,157 @@ T["worktree watcher"]["watch_worktree=false prevents worktree watcher"] = functi cleanup_test_repo(child, repo) end +-- ============================================================================= +-- Submodule watcher tests +-- ============================================================================= + +T["submodule watcher"] = MiniTest.new_set() + +T["submodule watcher"]["submodule_debounce_ms has default in config"] = function() + child.lua([[require("gitlad").setup({})]]) + + local debounce = child.lua_get([[require("gitlad.config").get().watcher.submodule_debounce_ms]]) + eq(debounce, 5000) +end + +T["submodule watcher"]["can configure submodule_debounce_ms"] = function() + child.lua([[require("gitlad").setup({ watcher = { submodule_debounce_ms = 10000 } })]]) + + local debounce = child.lua_get([[require("gitlad.config").get().watcher.submodule_debounce_ms]]) + eq(debounce, 10000) +end + +T["submodule watcher"]["passes submodule_debounce_ms to watcher"] = function() + local repo = helpers.create_test_repo(child) + cd(child, repo) + + -- Create initial commit + helpers.create_file(child, repo, "init.txt", "init") + helpers.git(child, repo, "add init.txt") + helpers.git(child, repo, "commit -m 'Initial commit'") + + -- Setup with custom submodule debounce + child.lua( + [[require("gitlad").setup({ watcher = { enabled = true, submodule_debounce_ms = 8000 } })]] + ) + + -- Open status buffer + child.cmd("Gitlad") + helpers.wait_for_status(child) + + -- Check that watcher has the custom submodule_debounce_ms + child.lua([[ + local status_view = require("gitlad.ui.views.status") + local state = require("gitlad.state") + local repo_state = state.get() + local buf = status_view.get_buffer(repo_state) + _G.test_submodule_debounce_ms = buf and buf.watcher and buf.watcher._submodule_debounce_ms + ]]) + local debounce_ms = child.lua_get("_G.test_submodule_debounce_ms") + eq(debounce_ms, 8000) + + cleanup_test_repo(child, repo) +end + +T["submodule watcher"]["builds submodule cache on start"] = function() + local repo = helpers.create_test_repo(child) + cd(child, repo) + + -- Create initial commit + helpers.create_file(child, repo, "init.txt", "init") + helpers.git(child, repo, "add init.txt") + helpers.git(child, repo, "commit -m 'Initial commit'") + + -- Create a submodule (use a bare repo as the "remote") + local sub_remote = child.lua_get("vim.fn.tempname()") + child.lua(string.format([[vim.fn.system("git init --bare " .. %q)]], sub_remote)) + + -- Initialize the bare remote with a commit so submodule add works + local sub_temp = child.lua_get("vim.fn.tempname()") + child.lua(string.format( + [[ + vim.fn.system("git -c protocol.file.allow=always clone " .. %q .. " " .. %q) + vim.fn.system("git -C " .. %q .. " config user.email 'test@test.com'") + vim.fn.system("git -C " .. %q .. " config user.name 'Test User'") + vim.fn.system("git -C " .. %q .. " config commit.gpgsign false") + local f = io.open(%q .. "/dummy.txt", "w") + f:write("dummy") + f:close() + vim.fn.system("git -C " .. %q .. " add .") + vim.fn.system("git -C " .. %q .. " commit -m 'init'") + vim.fn.system("git -C " .. %q .. " push") + ]], + sub_remote, + sub_temp, + sub_temp, + sub_temp, + sub_temp, + sub_temp, + sub_temp, + sub_temp, + sub_temp + )) + + helpers.git( + child, + repo, + string.format("-c protocol.file.allow=always submodule add %s vendor/lib", sub_remote) + ) + helpers.git(child, repo, "commit -m 'Add submodule'") + + -- Setup with watcher enabled + child.lua([[require("gitlad").setup({ watcher = { enabled = true, cooldown_ms = 100 } })]]) + + -- Open status buffer + child.cmd("Gitlad") + helpers.wait_for_status(child) + helpers.wait_short(child, 500) + + -- Check that submodule cache has the submodule path + child.lua([[ + local status_view = require("gitlad.ui.views.status") + local state = require("gitlad.state") + local repo_state = state.get() + local buf = status_view.get_buffer(repo_state) + _G.test_submodule_cache = buf and buf.watcher and buf.watcher._submodule_paths or {} + ]]) + local cache = child.lua_get("_G.test_submodule_cache") + eq(cache["vendor/lib"], true) + + child.lua(string.format([[vim.fn.delete(%q, "rf")]], sub_remote)) + child.lua(string.format([[vim.fn.delete(%q, "rf")]], sub_temp)) + cleanup_test_repo(child, repo) +end + +T["submodule watcher"]["empty submodule cache when no .gitmodules"] = function() + local repo = helpers.create_test_repo(child) + cd(child, repo) + + -- Create initial commit (no submodules) + helpers.create_file(child, repo, "init.txt", "init") + helpers.git(child, repo, "add init.txt") + helpers.git(child, repo, "commit -m 'Initial commit'") + + -- Setup with watcher enabled + child.lua([[require("gitlad").setup({ watcher = { enabled = true } })]]) + + -- Open status buffer + child.cmd("Gitlad") + helpers.wait_for_status(child) + + -- Check that submodule cache is empty + child.lua([[ + local status_view = require("gitlad.ui.views.status") + local state = require("gitlad.state") + local repo_state = state.get() + local buf = status_view.get_buffer(repo_state) + _G.test_submodule_cache = buf and buf.watcher and buf.watcher._submodule_paths or {} + ]]) + local cache = child.lua_get("_G.test_submodule_cache") + -- Should be empty table (no submodules) + eq(next(cache), nil) + + cleanup_test_repo(child, repo) +end + return T diff --git a/tests/unit/test_watcher.lua b/tests/unit/test_watcher.lua index 62aec56..ce0d647 100644 --- a/tests/unit/test_watcher.lua +++ b/tests/unit/test_watcher.lua @@ -592,4 +592,192 @@ T["watcher"]["_is_gitignored returns false with empty cache"] = function() eq(w:_is_gitignored("anything"), false) end +-- ============================================================================= +-- _is_submodule_path tests +-- ============================================================================= + +T["watcher"]["_is_submodule_path returns false with no submodules"] = function() + local watcher = require("gitlad.watcher") + + local mock_repo_state = { + git_dir = "/tmp/test/.git", + repo_root = "/tmp/test/", + mark_stale = function() end, + } + + local w = watcher.new(mock_repo_state) + w._submodule_paths = {} + + eq(w:_is_submodule_path("src/main.lua"), false) + eq(w:_is_submodule_path("vendor/lib"), false) +end + +T["watcher"]["_is_submodule_path matches exact submodule path"] = function() + local watcher = require("gitlad.watcher") + + local mock_repo_state = { + git_dir = "/tmp/test/.git", + repo_root = "/tmp/test/", + mark_stale = function() end, + } + + local w = watcher.new(mock_repo_state) + w._submodule_paths = { ["vendor/lib"] = true, ["external/sdk"] = true } + + eq(w:_is_submodule_path("vendor/lib"), true) + eq(w:_is_submodule_path("external/sdk"), true) +end + +T["watcher"]["_is_submodule_path matches nested file within submodule"] = function() + local watcher = require("gitlad.watcher") + + local mock_repo_state = { + git_dir = "/tmp/test/.git", + repo_root = "/tmp/test/", + mark_stale = function() end, + } + + local w = watcher.new(mock_repo_state) + w._submodule_paths = { ["vendor/lib"] = true } + + eq(w:_is_submodule_path("vendor/lib/src/main.c"), true) + eq(w:_is_submodule_path("vendor/lib/build/output.o"), true) +end + +T["watcher"]["_is_submodule_path rejects prefix-similar paths"] = function() + local watcher = require("gitlad.watcher") + + local mock_repo_state = { + git_dir = "/tmp/test/.git", + repo_root = "/tmp/test/", + mark_stale = function() end, + } + + local w = watcher.new(mock_repo_state) + w._submodule_paths = { ["mysub"] = true } + + -- "mysub2" starts with "mysub" but is NOT a submodule path + eq(w:_is_submodule_path("mysub2/file.txt"), false) + eq(w:_is_submodule_path("mysubmodule"), false) +end + +T["watcher"]["_is_submodule_path handles nested submodule paths"] = function() + local watcher = require("gitlad.watcher") + + local mock_repo_state = { + git_dir = "/tmp/test/.git", + repo_root = "/tmp/test/", + mark_stale = function() end, + } + + local w = watcher.new(mock_repo_state) + w._submodule_paths = { ["vendor/lib"] = true } + + -- "vendor/lib2" should not match "vendor/lib" + eq(w:_is_submodule_path("vendor/lib2/file.txt"), false) + -- But "vendor/lib/..." should match + eq(w:_is_submodule_path("vendor/lib/nested/deep.c"), true) +end + +-- ============================================================================= +-- Submodule debouncer tests +-- ============================================================================= + +T["watcher"]["creates submodule debouncer by default"] = function() + local watcher = require("gitlad.watcher") + + local mock_repo_state = { + git_dir = "/tmp/test/.git", + repo_root = "/tmp/test/", + mark_stale = function() end, + } + + local w = watcher.new(mock_repo_state) + eq(w._submodule_debounced ~= nil, true) + eq(w._submodule_debounce_ms, 5000) +end + +T["watcher"]["accepts custom submodule_debounce_ms"] = function() + local watcher = require("gitlad.watcher") + + local mock_repo_state = { + git_dir = "/tmp/test/.git", + repo_root = "/tmp/test/", + mark_stale = function() end, + } + + local w = watcher.new(mock_repo_state, { submodule_debounce_ms = 10000 }) + eq(w._submodule_debounce_ms, 10000) +end + +T["watcher"]["_handle_submodule_event does nothing when not running"] = function() + local watcher = require("gitlad.watcher") + + local mock_repo_state = { + git_dir = "/tmp/test/.git", + repo_root = "/tmp/test/", + mark_stale = function() end, + last_operation_time = 0, + } + + local w = watcher.new(mock_repo_state) + local debounce_called = false + w._submodule_debounced = { + call = function() + debounce_called = true + end, + } + + w:_handle_submodule_event() + eq(debounce_called, false) +end + +T["watcher"]["_handle_submodule_event respects cooldown"] = function() + local watcher = require("gitlad.watcher") + + local mock_repo_state = { + git_dir = "/tmp/test/.git", + repo_root = "/tmp/test/", + mark_stale = function() end, + last_operation_time = vim.loop.now(), + } + + local w = watcher.new(mock_repo_state, { cooldown_ms = 5000 }) + w.running = true + + local debounce_called = false + w._submodule_debounced = { + call = function() + debounce_called = true + end, + } + + w:_handle_submodule_event() + eq(debounce_called, false) +end + +T["watcher"]["_handle_submodule_event calls debouncer when running and no cooldown"] = function() + local watcher = require("gitlad.watcher") + + local mock_repo_state = { + git_dir = "/tmp/test/.git", + repo_root = "/tmp/test/", + mark_stale = function() end, + last_operation_time = 0, + } + + local w = watcher.new(mock_repo_state) + w.running = true + + local debounce_called = false + w._submodule_debounced = { + call = function() + debounce_called = true + end, + } + + w:_handle_submodule_event() + eq(debounce_called, true) +end + return T From 5ad6d667076181d5808bfc2cc44d57335c9151dc Mon Sep 17 00:00:00 2001 From: Dave Aitken Date: Sat, 28 Feb 2026 20:27:01 +0000 Subject: [PATCH 3/3] Add --ignore-submodules config option for git status In repos with many submodules, `git status` recursively checks each submodule's working tree, which can be very slow. This adds a config option to pass --ignore-submodules to git status. Usage: require("gitlad").setup({ git = { ignore_submodules = "dirty" } }) Values: false (default), "dirty", "untracked", "all" With "dirty", submodules with only working tree changes are hidden from status while submodules with changed HEAD commit remain visible. The submodules section (via `git submodule status`) is unaffected. --- lua/gitlad/config.lua | 7 ++ lua/gitlad/git/init.lua | 34 ++++--- tests/e2e/test_submodule.lua | 185 +++++++++++++++++++++++++++++++++++ 3 files changed, 212 insertions(+), 14 deletions(-) diff --git a/lua/gitlad/config.lua b/lua/gitlad/config.lua index ec1354d..5ca0bec 100644 --- a/lua/gitlad/config.lua +++ b/lua/gitlad/config.lua @@ -40,6 +40,9 @@ local M = {} ---@class GitladDiffConfig ---@field viewer "native" Diff viewer to use ("native" = built-in side-by-side) +---@class GitladGitConfig +---@field ignore_submodules false|"dirty"|"untracked"|"all" Pass --ignore-submodules to git status (default: false). "dirty" hides submodules with only working tree changes, "untracked" also hides untracked files, "all" hides all submodule changes. Makes git status dramatically faster in repos with many submodules. + ---@class GitladConfig ---@field signs GitladSigns ---@field commit_editor GitladCommitEditorConfig @@ -49,6 +52,7 @@ local M = {} ---@field output GitladOutputConfig ---@field forge GitladForgeConfig ---@field diff GitladDiffConfig +---@field git GitladGitConfig ---@field show_tags_in_refs boolean Whether to show tags alongside branch names in refs (default: false) local defaults = { signs = { @@ -83,6 +87,9 @@ local defaults = { diff = { viewer = "native", }, + git = { + ignore_submodules = false, -- false | "dirty" | "untracked" | "all" + }, show_tags_in_refs = false, } diff --git a/lua/gitlad/git/init.lua b/lua/gitlad/git/init.lua index e80a8ff..9a56a2c 100644 --- a/lua/gitlad/git/init.lua +++ b/lua/gitlad/git/init.lua @@ -118,31 +118,37 @@ M.remote_get_url = git_remotes.remote_get_url -- Core operations (kept in this file) -- ============================================================================= +--- Build the base args for git status, including --ignore-submodules if configured +---@return string[] +local function build_status_args() + local args = + { "status", "--porcelain=v2", "--branch", "--find-renames", "--untracked-files=normal" } + local cfg = require("gitlad.config").get() + local ignore = cfg.git and cfg.git.ignore_submodules + if ignore and ignore ~= false then + table.insert(args, "--ignore-submodules=" .. tostring(ignore)) + end + return args +end + --- Get repository status ---@param opts? GitCommandOptions ---@param callback fun(result: GitStatusResult|nil, err: string|nil) function M.status(opts, callback) - cli.run_async( - { "status", "--porcelain=v2", "--branch", "--find-renames", "--untracked-files=normal" }, - opts, - function(result) - if result.code ~= 0 then - callback(nil, table.concat(result.stderr, "\n")) - return - end - callback(parse.parse_status(result.stdout), nil) + cli.run_async(build_status_args(), opts, function(result) + if result.code ~= 0 then + callback(nil, table.concat(result.stderr, "\n")) + return end - ) + callback(parse.parse_status(result.stdout), nil) + end) end --- Get repository status synchronously ---@param opts? GitCommandOptions ---@return GitStatusResult|nil, string|nil function M.status_sync(opts) - local result = cli.run_sync( - { "status", "--porcelain=v2", "--branch", "--find-renames", "--untracked-files=normal" }, - opts - ) + local result = cli.run_sync(build_status_args(), opts) if result.code ~= 0 then return nil, table.concat(result.stderr, "\n") end diff --git a/tests/e2e/test_submodule.lua b/tests/e2e/test_submodule.lua index cb32c23..b525722 100644 --- a/tests/e2e/test_submodule.lua +++ b/tests/e2e/test_submodule.lua @@ -848,4 +848,189 @@ T["submodule list"]["l action triggers submodule list"] = function() helpers.cleanup_repo(child, submodule_repo) end +-- ============================================================================= +-- git.ignore_submodules config tests +-- ============================================================================= + +T["ignore_submodules config"] = MiniTest.new_set() + +T["ignore_submodules config"]["defaults to false"] = function() + child.lua([[require("gitlad").setup({})]]) + + local value = child.lua_get([[require("gitlad.config").get().git.ignore_submodules]]) + eq(value, false) +end + +T["ignore_submodules config"]["accepts dirty"] = function() + child.lua([[require("gitlad").setup({ git = { ignore_submodules = "dirty" } })]]) + + local value = child.lua_get([[require("gitlad.config").get().git.ignore_submodules]]) + eq(value, "dirty") +end + +T["ignore_submodules config"]["accepts untracked"] = function() + child.lua([[require("gitlad").setup({ git = { ignore_submodules = "untracked" } })]]) + + local value = child.lua_get([[require("gitlad.config").get().git.ignore_submodules]]) + eq(value, "untracked") +end + +T["ignore_submodules config"]["accepts all"] = function() + child.lua([[require("gitlad").setup({ git = { ignore_submodules = "all" } })]]) + + local value = child.lua_get([[require("gitlad.config").get().git.ignore_submodules]]) + eq(value, "all") +end + +T["ignore_submodules status"] = MiniTest.new_set() + +T["ignore_submodules status"]["build_status_args includes --ignore-submodules when configured"] = function() + child.lua([[require("gitlad").setup({ git = { ignore_submodules = "dirty" } })]]) + + -- Verify the build_status_args helper produces the right args + -- by requiring git/init.lua directly and checking the internal function + local has_flag = child.lua_get([[ + (function() + local cfg = require("gitlad.config").get() + local ignore = cfg.git and cfg.git.ignore_submodules + return ignore == "dirty" + end)() + ]]) + eq(has_flag, true) + + -- Run a real status call and verify the flag appears in history + local repo = helpers.create_test_repo(child) + helpers.create_file(child, repo, "init.txt", "init") + helpers.git(child, repo, "add init.txt") + helpers.git(child, repo, "commit -m 'Initial commit'") + + child.lua(string.format( + [[ + local git = require("gitlad.git") + _G.test_done = false + git.status({ cwd = %q }, function() + _G.test_done = true + end) + ]], + repo + )) + helpers.wait_for_var(child, "_G.test_done") + helpers.wait_short(child) + + -- Check history for the flag + child.lua([[ + local history = require("gitlad.git.history") + local entries = history.get_all() + _G.test_flag_found = false + for _, entry in ipairs(entries) do + for _, arg in ipairs(entry.args or {}) do + if arg == "--ignore-submodules=dirty" then + _G.test_flag_found = true + end + end + end + ]]) + eq(child.lua_get("_G.test_flag_found"), true) + + helpers.cleanup_repo(child, repo) +end + +T["ignore_submodules status"]["build_status_args omits --ignore-submodules when false"] = function() + child.lua([[require("gitlad").setup({})]]) + + local repo = helpers.create_test_repo(child) + helpers.create_file(child, repo, "init.txt", "init") + helpers.git(child, repo, "add init.txt") + helpers.git(child, repo, "commit -m 'Initial commit'") + + child.lua(string.format( + [[ + local git = require("gitlad.git") + _G.test_done = false + git.status({ cwd = %q }, function() + _G.test_done = true + end) + ]], + repo + )) + helpers.wait_for_var(child, "_G.test_done") + + -- Check history - should NOT contain --ignore-submodules + child.lua([[ + local history = require("gitlad.git.history") + local entries = history.get_all() + _G.test_flag_found = false + for _, entry in ipairs(entries) do + for _, arg in ipairs(entry.args or {}) do + if arg:find("ignore%-submodules") then + _G.test_flag_found = true + end + end + end + ]]) + eq(child.lua_get("_G.test_flag_found"), false) + + helpers.cleanup_repo(child, repo) +end + +T["ignore_submodules status"]["dirty submodule hidden with ignore_submodules=dirty"] = function() + local parent_repo, submodule_repo = create_repo_with_submodule(child) + + -- Make the submodule dirty (modify a file inside it) + helpers.create_file(child, parent_repo, "mysub/new_file.txt", "dirty content") + + -- First check: without ignore_submodules, mysub should appear in git status + child.lua(string.format( + [[ + _G.test_raw_without = vim.fn.system("git -C %s status --porcelain=v2") + ]], + parent_repo + )) + local raw_without = child.lua_get("_G.test_raw_without") + eq(raw_without:find("mysub") ~= nil, true) + + -- Second check: with --ignore-submodules=dirty, mysub should NOT appear + child.lua(string.format( + [[ + _G.test_raw_with = vim.fn.system("git -C %s status --porcelain=v2 --ignore-submodules=dirty") + ]], + parent_repo + )) + local raw_with = child.lua_get("_G.test_raw_with") + eq(raw_with:find("mysub") == nil, true) + + -- Third check: through gitlad API with config set + child.lua([[require("gitlad.config").setup({ git = { ignore_submodules = "dirty" } })]]) + + child.lua(string.format( + [[ + local git = require("gitlad.git") + _G.test_status_result = nil + git.status({ cwd = %q }, function(result, err) + _G.test_status_result = result + end) + ]], + parent_repo + )) + helpers.wait_for_var(child, "_G.test_status_result") + + local has_mysub = child.lua_get([[ + (function() + local status = _G.test_status_result + if not status then return false end + -- Check all entry lists + for _, list_name in ipairs({"staged", "unstaged", "untracked", "conflicted"}) do + for _, entry in ipairs(status[list_name] or {}) do + if entry.path == "mysub" then return true end + end + end + return false + end)() + ]]) + eq(has_mysub, false) + + helpers.cleanup_repo(child, parent_repo) + helpers.cleanup_repo(child, submodule_repo) +end + return T