diff --git a/lua/gitlad/config.lua b/lua/gitlad/config.lua index 74d6179..5ca0bec 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 @@ -39,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 @@ -48,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 = { @@ -69,6 +74,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 = { @@ -81,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/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 00802ba..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() @@ -275,6 +305,77 @@ 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 + +--- 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 @@ -301,7 +402,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 @@ -314,8 +415,27 @@ 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 - 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 + + -- 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 @@ -421,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 @@ -452,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 @@ -484,4 +608,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/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 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 00c6a2a..ce0d647 100644 --- a/tests/unit/test_watcher.lua +++ b/tests/unit/test_watcher.lua @@ -506,4 +506,278 @@ 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 + +-- ============================================================================= +-- _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