diff --git a/lua/neo-tree/git/init.lua b/lua/neo-tree/git/init.lua new file mode 100644 index 000000000..18a60d1cd --- /dev/null +++ b/lua/neo-tree/git/init.lua @@ -0,0 +1,177 @@ +local Path = require("plenary.path") +local utils = require("neo-tree.utils") + +local os_sep = Path.path.sep + +local M = {} + +local function execute_command(cmd) + local result = vim.fn.systemlist(cmd) + + -- An empty result is ok + if vim.v.shell_error ~= 0 or (#result > 0 and vim.startswith(result[1], "fatal:")) then + return false, {} + else + return true, result + end +end + +local function windowize_path(path) + return path:gsub("/", "\\") +end + +M.get_repository_root = function(path) + local cmd = "git rev-parse --show-toplevel" + if utils.truthy(path) then + cmd = "git -C " .. path .. " rev-parse --show-toplevel" + end + local ok, git_root = execute_command(cmd) + if not ok then + return nil + end + git_root = git_root[1] + + if utils.is_windows then + git_root = windowize_path(git_root) + end + + return git_root +end + +local function get_simple_git_status_code(status) + -- Prioritze M then A over all others + if status:match("U") or status == "AA" or status == "DD" then + return "U" + elseif status:match("M") then + return "M" + elseif status:match("[ACR]") then + return "A" + elseif status:match("!$") then + return "!" + elseif status:match("?$") then + return "?" + else + local len = #status + while len > 0 do + local char = status:sub(len, len) + if char ~= " " then + return char + end + len = len - 1 + end + return status + end +end + +local function get_priority_git_status_code(status, other_status) + if not status then + return other_status + elseif not other_status then + return status + elseif status == "U" or other_status == "U" then + return "U" + elseif status == "?" or other_status == "?" then + return "?" + elseif status == "M" or other_status == "M" then + return "M" + elseif status == "A" or other_status == "A" then + return "A" + else + return status + end +end + +---Parse "git status" output for the current working directory. +---@return table table Table with the path as key and the status as value. +M.status = function(exclude_directories) + local git_root = M.get_repository_root() + if not git_root then + return {} + end + local ok, result = execute_command("git status --porcelain=v1") + if not ok then + return {} + end + + local git_status = {} + for _, line in ipairs(result) do + local status = line:sub(1, 2) + local relative_path = line:sub(4) + local arrow_pos = relative_path:find(" -> ") + if arrow_pos ~= nil then + relative_path = line:sub(arrow_pos + 5) + end + -- remove any " due to whitespace in the path + relative_path = relative_path:gsub('^"', ""):gsub('$"', "") + if utils.is_windows == true then + relative_path = windowize_path(relative_path) + end + local absolute_path = string.format("%s%s%s", git_root, os_sep, relative_path) + git_status[absolute_path] = status + + if not exclude_directories then + -- Now bubble this status up to the parent directories + local file_status = get_simple_git_status_code(status) + local parents = Path:new(absolute_path):parents() + for i = #parents, 1, -1 do + local path = parents[i] + local path_status = git_status[path] + git_status[path] = get_priority_git_status_code(path_status, file_status) + end + end + end + + return git_status, git_root +end + +M.load_ignored = function(path) + local git_root = M.get_repository_root(path) + if not git_root then + return {} + end + local ok, result = execute_command( + "git --no-optional-locks status --porcelain=v1 --ignored=matching --untracked-files=normal" + ) + if not ok then + return {} + end + + local ignored = {} + for _, v in ipairs(result) do + -- git ignore format: + -- !! path/to/file + -- !! path/to/path/ + -- with paths relative to the repository root + if v:sub(1, 2) == "!!" then + local entry = v:sub(4) + -- remove any " due to whitespace in the path + entry = entry:gsub('^"', ""):gsub('$"', "") + if utils.is_windows then + entry = windowize_path(entry) + end + -- use the absolute path + table.insert(ignored, string.format("%s%s%s", git_root, os_sep, entry)) + end + end + + return ignored +end + +M.is_ignored = function(ignored, path, _type) + path = _type == "directory" and (path .. os_sep) or path + for _, v in ipairs(ignored) do + if v:sub(-1) == os_sep then + -- directory ignore + if vim.startswith(path, v) then + return true + end + else + -- file ignore + if path == v then + return true + end + end + end +end + +return M diff --git a/lua/neo-tree/sources/buffers/init.lua b/lua/neo-tree/sources/buffers/init.lua index 63d919e18..503661b5a 100644 --- a/lua/neo-tree/sources/buffers/init.lua +++ b/lua/neo-tree/sources/buffers/init.lua @@ -7,6 +7,7 @@ local renderer = require("neo-tree.ui.renderer") local items = require("neo-tree.sources.buffers.lib.items") local events = require("neo-tree.events") local manager = require("neo-tree.sources.manager") +local git = require("neo-tree.git") local M = { name = "buffers" } @@ -75,7 +76,7 @@ M.setup = function(config, global_config) handler = function(state) local this_state = get_state() if state == this_state then - state.git_status_lookup = utils.get_git_status() + state.git_status_lookup = git.status() end end, }) diff --git a/lua/neo-tree/sources/filesystem/README.md b/lua/neo-tree/sources/filesystem/README.md index ee2af1a16..bbca6a503 100644 --- a/lua/neo-tree/sources/filesystem/README.md +++ b/lua/neo-tree/sources/filesystem/README.md @@ -61,8 +61,8 @@ require("neo-tree").setup({ -- This function is called after the file system has been scanned, -- but before the tree is rendered. You can use this to gather extra -- data that can be used in the renderers. - local utils = require("neo-tree.utils") - state.git_status_lookup = utils.get_git_status() + local git = require("neo-tree.git") + state.git_status_lookup = git.status() end, -- The components section provides custom functions that may be called by -- the renderers below. Each componment is a function that takes the diff --git a/lua/neo-tree/sources/filesystem/init.lua b/lua/neo-tree/sources/filesystem/init.lua index 2ae0aa7bd..f47dcb58a 100644 --- a/lua/neo-tree/sources/filesystem/init.lua +++ b/lua/neo-tree/sources/filesystem/init.lua @@ -9,6 +9,7 @@ local inputs = require("neo-tree.ui.inputs") local events = require("neo-tree.events") local log = require("neo-tree.log") local manager = require("neo-tree.sources.manager") +local git = require("neo-tree.git") local M = { name = "filesystem" } @@ -251,7 +252,7 @@ M.setup = function(config, global_config) handler = function(state) local this_state = get_state() if state == this_state then - state.git_status_lookup = utils.get_git_status() + state.git_status_lookup = git.status() end end, }) diff --git a/lua/neo-tree/sources/filesystem/lib/fs_scan.lua b/lua/neo-tree/sources/filesystem/lib/fs_scan.lua index 087e099fc..287de4735 100644 --- a/lua/neo-tree/sources/filesystem/lib/fs_scan.lua +++ b/lua/neo-tree/sources/filesystem/lib/fs_scan.lua @@ -7,6 +7,7 @@ local filter_external = require("neo-tree.sources.filesystem.lib.filter_external local file_items = require("neo-tree.sources.common.file-items") local log = require("neo-tree.log") local fs_watch = require("neo-tree.sources.filesystem.lib.fs_watch") +local git = require("neo-tree.git") local M = {} @@ -20,14 +21,15 @@ local function do_scan(context, path_to_scan) scan.scan_dir_async(path_to_scan, { hidden = filters.show_hidden or false, - respect_gitignore = filters.respect_gitignore or false, search_pattern = state.search_pattern or nil, add_dirs = true, depth = 1, on_insert = function(path, _type) - local success, _ = pcall(file_items.create_item, context, path, _type) - if not success then - log.error("error creating item for ", path) + if not filters.respect_gitignore or not git.is_ignored(state.git_ignored, path, _type) then + local success, _ = pcall(file_items.create_item, context, path, _type) + if not success then + log.error("error creating item for ", path) + end end end, on_exit = vim.schedule_wrap(function() @@ -145,6 +147,8 @@ M.get_items_async = function(state, parent_id, path_to_reveal, callback) end) context.paths_to_load = utils.unique(context.paths_to_load) end + + state.git_ignored = state.filters.respect_gitignore and git.load_ignored(state.path) or {} end do_scan(context, parent_id or state.path) end diff --git a/lua/neo-tree/sources/git_status/lib/items.lua b/lua/neo-tree/sources/git_status/lib/items.lua index 8fff5a579..97ae71e53 100644 --- a/lua/neo-tree/sources/git_status/lib/items.lua +++ b/lua/neo-tree/sources/git_status/lib/items.lua @@ -1,9 +1,9 @@ local vim = vim local renderer = require("neo-tree.ui.renderer") -local utils = require("neo-tree.utils") local file_items = require("neo-tree.sources.common.file-items") local popups = require("neo-tree.ui.popups") local log = require("neo-tree.log") +local git = require("neo-tree.git") local M = {} @@ -14,7 +14,7 @@ M.get_git_status = function(state) return end state.loading = true - local status_lookup, project_root = utils.get_git_status(true) + local status_lookup, project_root = git.status(true) state.path = project_root or state.path or vim.fn.getcwd() local context = file_items.create_context(state) -- Create root folder diff --git a/lua/neo-tree/ui/highlights.lua b/lua/neo-tree/ui/highlights.lua index 322a8570b..fe2fb59d1 100644 --- a/lua/neo-tree/ui/highlights.lua +++ b/lua/neo-tree/ui/highlights.lua @@ -21,7 +21,7 @@ M.NORMALNC = "NeoTreeNormalNC" M.ROOT_NAME = "NeoTreeRootName" M.TITLE_BAR = "NeoTreeTitleBar" -function dec_to_hex(n) +local function dec_to_hex(n) local hex = string.format("%06x", n) if n < 16 then hex = "0" .. hex diff --git a/lua/neo-tree/utils.lua b/lua/neo-tree/utils.lua index e80caf2d8..63ded78e0 100644 --- a/lua/neo-tree/utils.lua +++ b/lua/neo-tree/utils.lua @@ -8,49 +8,6 @@ table.unpack = table.unpack or unpack local M = {} -local function get_simple_git_status_code(status) - -- Prioritze M then A over all others - if status:match("U") or status == "AA" or status == "DD" then - return "U" - elseif status:match("M") then - return "M" - elseif status:match("[ACR]") then - return "A" - elseif status:match("!$") then - return "!" - elseif status:match("?$") then - return "?" - else - local len = #status - while len > 0 do - local char = status:sub(len, len) - if char ~= " " then - return char - end - len = len - 1 - end - return status - end -end - -local function get_priority_git_status_code(status, other_status) - if not status then - return other_status - elseif not other_status then - return status - elseif status == "U" or other_status == "U" then - return "U" - elseif status == "?" or other_status == "?" then - return "?" - elseif status == "M" or other_status == "M" then - return "M" - elseif status == "A" or other_status == "A" then - return "A" - else - return status - end -end - local diag_severity_to_string = function(severity) if severity == vim.diagnostic.severity.ERROR then return "Error" @@ -168,69 +125,6 @@ M.get_diagnostic_counts = function() return lookup end -M.get_git_project_root = function(path) - local cmd = "git rev-parse --show-toplevel" - if M.truthy(path) then - cmd = "cd " .. vim.fn.shellescape(path) .. " && " .. cmd - end - return vim.fn.systemlist(cmd)[1] -end - ----Parse "git status" output for the current working directory. ----@return table table Table with the path as key and the status as value. -M.get_git_status = function(exclude_directories) - local project_root = M.get_git_project_root() - local git_output = vim.fn.systemlist("git status --porcelain") - local git_status = {} - local codes = "[ACDMRTU!%?%s]" - codes = codes .. codes - - for _, line in ipairs(git_output) do - local status = line:match("^(" .. codes .. ")%s") - local relative_path = line:match("^" .. codes .. "%s+(.+)$") - if not relative_path then - if line:match("fatal: not a git repository") then - return {} - else - log.error("Error parsing git status for: " .. line) - end - break - end - local renamed = line:match("^" .. codes .. "%s+.*%s->%s(.*)$") - if renamed then - relative_path = renamed - end - if relative_path:sub(1, 1) == '"' then - -- path was quoted, remove quoting - relative_path = relative_path:match('^"(.+)".*') - end - if M.is_windows == true then - project_root = project_root:gsub("/", M.path_separator) - relative_path = relative_path:gsub("/", M.path_separator) - end - local absolute_path = project_root .. M.path_separator .. relative_path - git_status[absolute_path] = status - - if not exclude_directories then - -- Now bubble this status up to the parent directories - local parts = M.split(absolute_path, M.path_separator) - table.remove(parts) -- pop the last part so we don't override the file's status - M.reduce(parts, "", function(acc, part) - local path = acc .. M.path_separator .. part - if M.is_windows == true then - path = path:gsub("^" .. M.path_separator, "") - end - local path_status = git_status[path] - local file_status = get_simple_git_status_code(status) - git_status[path] = get_priority_git_status_code(path_status, file_status) - return path - end) - end - end - - return git_status, project_root -end - ---Resolves some variable to a string. The object can be either a string or a --function that returns a string. ---@param functionOrString any The object to resolve.