From f1e84d4a54fd2bce87bda77e8daa39a3e7764928 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20Gottz=C3=A9n?= Date: Thu, 20 Jan 2022 14:04:43 +0100 Subject: [PATCH 1/2] fix(files): fixes #78, correctly handle .gitignore Plenary's scandir only checks for .gitignore files in the directories it scans. Since directories are only scanned when opened any .gitignore in any parent directory isn't applied. Implements parsing the output of `git status --ignored=matching`. --- lua/neo-tree/git/init.lua | 176 ++++++++++++++++++ lua/neo-tree/sources/buffers/init.lua | 3 +- lua/neo-tree/sources/filesystem/README.md | 4 +- lua/neo-tree/sources/filesystem/init.lua | 3 +- .../sources/filesystem/lib/fs_scan.lua | 12 +- lua/neo-tree/sources/git_status/lib/items.lua | 4 +- lua/neo-tree/ui/highlights.lua | 2 +- lua/neo-tree/utils.lua | 106 ----------- 8 files changed, 193 insertions(+), 117 deletions(-) create mode 100644 lua/neo-tree/git/init.lua diff --git a/lua/neo-tree/git/init.lua b/lua/neo-tree/git/init.lua new file mode 100644 index 000000000..bdc6f2842 --- /dev/null +++ b/lua/neo-tree/git/init.lua @@ -0,0 +1,176 @@ +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) + + if vim.v.shell_error ~= 0 or 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. From 8e22bbba7f645b6996f67819f7fea66f5e985b52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20Gottz=C3=A9n?= Date: Tue, 25 Jan 2022 14:39:58 +0100 Subject: [PATCH 2/2] Handle empty reponses from git --- lua/neo-tree/git/init.lua | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lua/neo-tree/git/init.lua b/lua/neo-tree/git/init.lua index bdc6f2842..18a60d1cd 100644 --- a/lua/neo-tree/git/init.lua +++ b/lua/neo-tree/git/init.lua @@ -8,7 +8,8 @@ local M = {} local function execute_command(cmd) local result = vim.fn.systemlist(cmd) - if vim.v.shell_error ~= 0 or vim.startswith(result[1], "fatal:") then + -- 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