Skip to content

fix(#2382): git watcher handles worktrees and submodules, via --absolute-git-dir when it is available #2389

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Sep 2, 2023
8 changes: 4 additions & 4 deletions lua/nvim-tree/actions/reloaders/reloaders.lua
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ local function refresh_nodes(node, projects, unloaded_bufnr)
Iterator.builder({ node })
:applier(function(n)
if n.open and n.nodes then
local project_root = git.get_project_root(n.cwd or n.link_to or n.absolute_path)
explorer_module.reload(n, projects[project_root] or {}, unloaded_bufnr)
local toplevel = git.get_toplevel(n.cwd or n.link_to or n.absolute_path)
explorer_module.reload(n, projects[toplevel] or {}, unloaded_bufnr)
end
end)
:recursor(function(n)
Expand All @@ -23,8 +23,8 @@ local function refresh_nodes(node, projects, unloaded_bufnr)
end

function M.reload_node_status(parent_node, projects)
local project_root = git.get_project_root(parent_node.absolute_path)
local status = projects[project_root] or {}
local toplevel = git.get_toplevel(parent_node.absolute_path)
local status = projects[toplevel] or {}
for _, node in ipairs(parent_node.nodes) do
explorer_node.update_git_status(node, explorer_node.is_git_ignored(parent_node), status)
if node.nodes and #node.nodes > 0 then
Expand Down
18 changes: 9 additions & 9 deletions lua/nvim-tree/explorer/reload.lua
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,10 @@ local function update_status(nodes_by_path, node_ignored, status)
end

local function reload_and_get_git_project(path, callback)
local project_root = git.get_project_root(path)
local toplevel = git.get_toplevel(path)

git.reload_project(project_root, path, function()
callback(project_root, git.get_project(project_root) or {})
git.reload_project(toplevel, path, function()
callback(toplevel, git.get_project(toplevel) or {})
end)
end

Expand All @@ -38,7 +38,7 @@ local function update_parent_statuses(node, project, root)
break
end

root = git.get_project_root(node.parent.absolute_path)
root = git.get_toplevel(node.parent.absolute_path)

-- stop when no more projects
if not root then
Expand Down Expand Up @@ -174,10 +174,10 @@ function M.refresh_node(node, callback)

local parent_node = utils.get_parent_of_group(node)

reload_and_get_git_project(node.absolute_path, function(project_root, project)
reload_and_get_git_project(node.absolute_path, function(toplevel, project)
require("nvim-tree.explorer.reload").reload(parent_node, project)

update_parent_statuses(parent_node, project, project_root)
update_parent_statuses(parent_node, project, toplevel)

callback()
end)
Expand Down Expand Up @@ -211,11 +211,11 @@ function M.refresh_parent_nodes_for_path(path)

-- refresh in order; this will expand groups as needed
for _, node in ipairs(parent_nodes) do
local project_root = git.get_project_root(node.absolute_path)
local project = git.get_project(project_root) or {}
local toplevel = git.get_toplevel(node.absolute_path)
local project = git.get_project(toplevel) or {}

M.reload(node, project)
update_parent_statuses(node, project, project_root)
update_parent_statuses(node, project, toplevel)
end

log.profile_end(profile)
Expand Down
143 changes: 92 additions & 51 deletions lua/nvim-tree/git/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,15 @@ local explorer_node = require "nvim-tree.explorer.node"

local M = {
config = {},
projects = {},
cwd_to_project_root = {},

-- all projects keyed by toplevel
_projects_by_toplevel = {},

-- index of paths inside toplevels, false when not inside a project
_toplevels_by_path = {},

-- git dirs by toplevel
_git_dirs_by_toplevel = {},
}

-- Files under .git that should result in a reload when changed.
Expand All @@ -22,7 +29,7 @@ local WATCHED_FILES = {
"index", -- staging area
}

local function reload_git_status(project_root, path, project, git_status)
local function reload_git_status(toplevel, path, project, git_status)
if path then
for p in pairs(project.files) do
if p:find(path, 1, true) == 1 then
Expand All @@ -34,7 +41,7 @@ local function reload_git_status(project_root, path, project, git_status)
project.files = git_status
end

project.dirs = git_utils.file_status_to_dir_status(project.files, project_root)
project.dirs = git_utils.file_status_to_dir_status(project.files, toplevel)
end

--- Is this path in a known ignored directory?
Expand All @@ -56,85 +63,106 @@ local function path_ignored_in_project(path, project)
return false
end

--- Reload all projects
--- @return table projects maybe empty
function M.reload()
if not M.config.git.enable then
return {}
end

for project_root in pairs(M.projects) do
M.reload_project(project_root)
for toplevel in pairs(M._projects_by_toplevel) do
M.reload_project(toplevel)
end

return M.projects
return M._projects_by_toplevel
end

function M.reload_project(project_root, path, callback)
local project = M.projects[project_root]
if not project or not M.config.git.enable then
--- Reload one project. Does nothing when no project or path is ignored
--- @param toplevel string|nil
--- @param path string|nil optional path to update only
--- @param callback function|nil
function M.reload_project(toplevel, path, callback)
local project = M._projects_by_toplevel[toplevel]
if not toplevel or not project or not M.config.git.enable then
if callback then
callback()
end
return
end

if path and (path:find(project_root, 1, true) ~= 1 or path_ignored_in_project(path, project)) then
if path and (path:find(toplevel, 1, true) ~= 1 or path_ignored_in_project(path, project)) then
if callback then
callback()
end
return
end

local opts = {
project_root = project_root,
toplevel = toplevel,
path = path,
list_untracked = git_utils.should_show_untracked(project_root),
list_untracked = git_utils.should_show_untracked(toplevel),
list_ignored = true,
timeout = M.config.git.timeout,
}

if callback then
Runner.run(opts, function(git_status)
reload_git_status(project_root, path, project, git_status)
reload_git_status(toplevel, path, project, git_status)
callback()
end)
else
-- TODO use callback once async/await is available
local git_status = Runner.run(opts)
reload_git_status(project_root, path, project, git_status)
reload_git_status(toplevel, path, project, git_status)
end
end

function M.get_project(project_root)
return M.projects[project_root]
--- Retrieve a known project
--- @return table|nil project
function M.get_project(toplevel)
return M._projects_by_toplevel[toplevel]
end

function M.get_project_root(cwd)
--- Retrieve the toplevel for a path. nil on:
--- git disabled
--- not part of a project
--- not a directory
--- path in git.disable_for_dirs
--- @param path string absolute
--- @return string|nil
function M.get_toplevel(path)
if not M.config.git.enable then
return nil
end

if M.cwd_to_project_root[cwd] then
return M.cwd_to_project_root[cwd]
if M._toplevels_by_path[path] then
return M._toplevels_by_path[path]
end

if M.cwd_to_project_root[cwd] == false then
if M._toplevels_by_path[path] == false then
return nil
end

local stat, _ = vim.loop.fs_stat(cwd)
local stat, _ = vim.loop.fs_stat(path)
if not stat or stat.type ~= "directory" then
return nil
end

-- short-circuit any known ignored paths
for root, project in pairs(M.projects) do
if project and path_ignored_in_project(cwd, project) then
M.cwd_to_project_root[cwd] = root
for root, project in pairs(M._projects_by_toplevel) do
if project and path_ignored_in_project(path, project) then
M._toplevels_by_path[path] = root
return root
end
end

local toplevel = git_utils.get_toplevel(cwd)
-- attempt to fetch toplevel
local toplevel, git_dir = git_utils.get_toplevel(path)
if not toplevel or not git_dir then
return nil
end

-- ignore disabled paths
for _, disabled_for_dir in ipairs(M.config.git.disable_for_dirs) do
local toplevel_norm = vim.fn.fnamemodify(toplevel, ":p")
local disabled_norm = vim.fn.fnamemodify(disabled_for_dir, ":p")
Expand All @@ -143,23 +171,24 @@ function M.get_project_root(cwd)
end
end

M.cwd_to_project_root[cwd] = toplevel
return M.cwd_to_project_root[cwd]
M._toplevels_by_path[path] = toplevel
M._git_dirs_by_toplevel[toplevel] = git_dir
return M._toplevels_by_path[path]
end

local function reload_tree_at(project_root)
if not M.config.git.enable then
local function reload_tree_at(toplevel)
if not M.config.git.enable or not toplevel then
return nil
end

log.line("watcher", "git event executing '%s'", project_root)
local root_node = utils.get_node_from_path(project_root)
log.line("watcher", "git event executing '%s'", toplevel)
local root_node = utils.get_node_from_path(toplevel)
if not root_node then
return
end

M.reload_project(project_root, nil, function()
local git_status = M.get_project(project_root)
M.reload_project(toplevel, nil, function()
local git_status = M.get_project(toplevel)

Iterator.builder(root_node.nodes)
:hidden()
Expand All @@ -176,25 +205,29 @@ local function reload_tree_at(project_root)
end)
end

function M.load_project_status(cwd)
--- Load the project status for a path. Does nothing when no toplevel for path.
--- Only fetches project status when unknown, otherwise returns existing.
--- @param path string absolute
--- @return table project maybe empty
function M.load_project_status(path)
if not M.config.git.enable then
return {}
end

local project_root = M.get_project_root(cwd)
if not project_root then
M.cwd_to_project_root[cwd] = false
local toplevel = M.get_toplevel(path)
if not toplevel then
M._toplevels_by_path[path] = false
return {}
end

local status = M.projects[project_root]
local status = M._projects_by_toplevel[toplevel]
if status then
return status
end

local git_status = Runner.run {
project_root = project_root,
list_untracked = git_utils.should_show_untracked(project_root),
toplevel = toplevel,
list_untracked = git_utils.should_show_untracked(toplevel),
list_ignored = true,
timeout = M.config.git.timeout,
}
Expand All @@ -204,33 +237,41 @@ function M.load_project_status(cwd)
log.line("watcher", "git start")

local callback = function(w)
log.line("watcher", "git event scheduled '%s'", w.project_root)
utils.debounce("git:watcher:" .. w.project_root, M.config.filesystem_watchers.debounce_delay, function()
log.line("watcher", "git event scheduled '%s'", w.toplevel)
utils.debounce("git:watcher:" .. w.toplevel, M.config.filesystem_watchers.debounce_delay, function()
if w.destroyed then
return
end
reload_tree_at(w.project_root)
reload_tree_at(w.toplevel)
end)
end

local git_dir = vim.env.GIT_DIR or utils.path_join { project_root, ".git" }
local git_dir = vim.env.GIT_DIR or M._git_dirs_by_toplevel[toplevel] or utils.path_join { toplevel, ".git" }
watcher = Watcher:new(git_dir, WATCHED_FILES, callback, {
project_root = project_root,
toplevel = toplevel,
})
end

M.projects[project_root] = {
M._projects_by_toplevel[toplevel] = {
files = git_status,
dirs = git_utils.file_status_to_dir_status(git_status, project_root),
dirs = git_utils.file_status_to_dir_status(git_status, toplevel),
watcher = watcher,
}
return M.projects[project_root]
return M._projects_by_toplevel[toplevel]
end

function M.purge_state()
log.line("git", "purge_state")
M.projects = {}
M.cwd_to_project_root = {}

for _, project in pairs(M._projects_by_toplevel) do
if project.watcher then
project.watcher:destroy()
end
end

M._projects_by_toplevel = {}
M._toplevels_by_path = {}
M._git_dirs_by_toplevel = {}
end

--- Disable git integration permanently
Expand Down
Loading