Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions lua/gitlad/config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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 = {
Expand All @@ -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 = {
Expand All @@ -81,6 +87,9 @@ local defaults = {
diff = {
viewer = "native",
},
git = {
ignore_submodules = false, -- false | "dirty" | "untracked" | "all"
},
show_tags_in_refs = false,
}

Expand Down
34 changes: 20 additions & 14 deletions lua/gitlad/git/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions lua/gitlad/ui/views/status.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
135 changes: 131 additions & 4 deletions lua/gitlad/watcher.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, boolean> Cache of top-level entries: true = ignored
---@field _submodule_paths table<string, boolean> 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 {}
Expand All @@ -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)
Expand All @@ -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

Expand All @@ -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()
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Loading