From c84503ec158324df0792d74a54eba6ea2914387e Mon Sep 17 00:00:00 2001 From: pynappo Date: Wed, 8 Oct 2025 02:30:19 -0700 Subject: [PATCH 01/13] parse git status porcelain v2 instead of 3 separate cmds --- lua/neo-tree/defaults.lua | 2 +- lua/neo-tree/git/status.lua | 166 +++++++++++++++++++++++++++++++++--- 2 files changed, 157 insertions(+), 11 deletions(-) diff --git a/lua/neo-tree/defaults.lua b/lua/neo-tree/defaults.lua index 68ce4d52..f6d28dc8 100644 --- a/lua/neo-tree/defaults.lua +++ b/lua/neo-tree/defaults.lua @@ -23,7 +23,7 @@ local config = { enable_opened_markers = true, -- Enable tracking of opened files. Required for `components.name.highlight_opened_files` enable_refresh_on_write = true, -- Refresh the tree when a file is written. Only used if `use_libuv_file_watcher` is false. enable_cursor_hijack = false, -- If enabled neotree will keep the cursor on the first letter of the filename when moving in the tree. - git_status_async = true, + git_status_async = false, -- These options are for people with VERY large git repos git_status_async_options = { batch_size = 1000, -- how many lines of git status results to process at a time diff --git a/lua/neo-tree/git/status.lua b/lua/neo-tree/git/status.lua index 3e4cc1ac..8e556138 100644 --- a/lua/neo-tree/git/status.lua +++ b/lua/neo-tree/git/status.lua @@ -123,10 +123,11 @@ local parse_git_status_line = function(context, line) end) end end + ---Parse "git status" output for the current working directory. ----@base git ref base ----@exclude_directories boolean Whether to skip bubling up status to directories ----@path string Path to run the git status command in, defaults to cwd. +---@param base string git ref base +---@param exclude_directories boolean Whether to skip bubling up status to directories +---@param path string Path to run the git status command in, defaults to cwd. ---@return neotree.git.Status, string? git_status the neotree.Git.Status of the given root M.status = function(base, exclude_directories, path) local git_root = git_utils.get_repository_root(path) @@ -134,18 +135,160 @@ M.status = function(base, exclude_directories, path) return {} end - local C = git_root - local staged_cmd = { "git", "-C", C, "diff", "--staged", "--name-status", base, "--" } + local s1 = vim.uv.hrtime() + local status_cmd = + { "git", "-c", "status.relativePaths=true", "-C", git_root, "status", "--porcelain=v2", "-z" } + local status_result = vim.fn.system(status_cmd) + + local status_ok = vim.v.shell_error == 0 + if not status_ok then + return {} + end + + local git_root_dir = git_root .. utils.path_separator + ---@type table + local gs = {} + -- system() replaces \000 with \001 + local status_iter = vim.gsplit(status_result, "\001", { plain = true }) + local prev_line = "" + for line in status_iter do + -- Example status: + -- 1 D. N... 100644 000000 000000 ade2881afa1dcb156a3aa576024aa0fecf789191 0000000000000000000000000000000000000000 deleted_staged.txt + -- 1 .D N... 100644 100644 000000 9c13483e67ceff219800303ec7af39c4f0301a5b 9c13483e67ceff219800303ec7af39c4f0301a5b deleted_unstaged.txt + -- 1 MM N... 100644 100644 100644 4417f3aca512ffdf247662e2c611ee03ff9255cc 29c0e9846cd6410a44c4ca3fdaf5623818bd2838 modified_mixed.txt + -- 1 M. N... 100644 100644 100644 f784736eecdd43cd8eb665615163cfc6506fca5f 8d6fad5bd11ac45c7c9e62d4db1c427889ed515b modified_staged.txt + -- 1 .M N... 100644 100644 100644 c9e1e027aa9430cb4ffccccf45844286d10285c1 c9e1e027aa9430cb4ffccccf45844286d10285c1 modified_unstaged.txt + -- 1 A. N... 000000 100644 100644 0000000000000000000000000000000000000000 89cae60d74c222609086441e29985f959b6ec546 new_staged_file.txt + -- 2 R. N... 100644 100644 100644 3454a7dc6b93d1098e3c3f3ec369589412abdf99 3454a7dc6b93d1098e3c3f3ec369589412abdf99 R100 renamed_staged_new.txt + -- renamed_staged_old.txt + -- 1 .T N... 100644 100644 120000 192f10ed8c11efb70155e8eb4cae6ec677347623 192f10ed8c11efb70155e8eb4cae6ec677347623 type_change.txt + -- ? .gitignore + -- ? untracked.txt + + -- 1 + -- 2 + local t = line:sub(1, 1) + if t == "1" then + local XY = line:sub(3, 4) + -- local submodule_state = line:sub(6, 9) + -- local mH = line:sub(11, 16) + -- local mI = line:sub(18, 23) + -- local mW = line:sub(25, 30) + -- local hH = line:sub(32, 71) + -- local hI = line:sub(73, 112) + local pathname = line:sub(114) + gs[git_root_dir .. pathname] = XY + elseif t == "2" then + -- local XY = line:sub(3, 4) + -- local submodule_state = line:sub(6, 9) + -- local mH = line:sub(11, 16) + -- local mI = line:sub(18, 23) + -- local mW = line:sub(25, 30) + -- local hH = line:sub(32, 71) + -- local hI = line:sub(73, 112) + local rest = line:sub(114) + local score_and_pathname = vim.split(rest, " ", { plain = true }) + -- iterate over the original path + gs[git_root_dir .. score_and_pathname[2]] = score_and_pathname[1] + status_iter() + elseif t == "u" then + local XY = line:sub(3, 4) + -- local submodule_state = line:sub(6, 9) + -- local m1 = line:sub(11, 16) + -- local m2 = line:sub(18, 23) + -- local m3 = line:sub(25, 30) + -- local mW = line:sub(32, 37) + -- local h1 = line:sub(39, 78) + -- local h2 = line:sub(80, 119) + -- local h3 = line:sub(121, 160) + local pathname = line:sub(162) + gs[git_root_dir .. pathname] = XY + else + prev_line = line + break + end + -- X Y Meaning + -- ------------------------------------------------- + -- [AMD] not updated + -- M [ MTD] updated in index + -- T [ MTD] type changed in index + -- A [ MTD] added to index + -- D deleted from index + -- R [ MTD] renamed in index + -- C [ MTD] copied in index + -- [MTARC] index and work tree matches + -- [ MTARC] M work tree changed since index + -- [ MTARC] T type changed in work tree since index + -- [ MTARC] D deleted in work tree + -- R renamed in work tree + -- C copied in work tree + -- ------------------------------------------------- + -- D D unmerged, both deleted + -- A U unmerged, added by us + -- U D unmerged, deleted by them + -- U A unmerged, added by them + -- D U unmerged, deleted by us + -- A A unmerged, both added + -- U U unmerged, both modified + -- ------------------------------------------------- + -- ? ? untracked + -- ! ! ignored + -- ------------------------------------------------- + end + + if prev_line:sub(1, 1) == "?" then + -- untracked + gs[git_root_dir .. prev_line:sub(3)] = "?" + for line in status_iter do + if line:sub(1, 1) ~= "?" then + prev_line = line + break + end + gs[git_root_dir .. prev_line:sub(3)] = "?" + end + end + + -- local gitignored = {} + if prev_line:sub(1, 1) == "!" then + -- gitignored[#gitignored + 1] = prev_line:sub(3) + gs[git_root_dir .. prev_line:sub(3)] = "!" + for line in status_iter do + -- gitignored[#gitignored + 1] = line:sub(3) + gs[git_root_dir .. line:sub(3)] = "!" + end + end + + -- bubble up every status + for dir, status in pairs(gs) do + for parent in utils.path_parents(dir) do + local parent_status = gs[parent] + if not parent_status then + gs[parent] = status + else + local better_status = + get_priority_git_status_code(parent_status:sub(1, 1), status:sub(1, 1)) + if better_status == parent_status then + break -- stop bubbling + else + gs[parent] = better_status + end + end + end + end + local e1 = vim.uv.hrtime() + + local s2 = vim.uv.hrtime() + local staged_cmd = { "git", "-C", git_root, "diff", "--staged", "--name-status", base, "--" } local staged_ok, staged_result = utils.execute_command(staged_cmd) if not staged_ok then return {} end - local unstaged_cmd = { "git", "-C", C, "diff", "--name-status" } + local unstaged_cmd = { "git", "-C", git_root, "diff", "--name-status" } local unstaged_ok, unstaged_result = utils.execute_command(unstaged_cmd) if not unstaged_ok then return {} end - local untracked_cmd = { "git", "-C", C, "ls-files", "--exclude-standard", "--others" } + local untracked_cmd = { "git", "-C", git_root, "ls-files", "--exclude-standard", "--others" } local untracked_ok, untracked_result = utils.execute_command(untracked_cmd) if not untracked_ok then return {} @@ -173,6 +316,11 @@ M.status = function(base, exclude_directories, path) end parse_git_status_line(context, line) end + local e2 = vim.uv.hrtime() + print(e1 - s1) + print(vim.inspect(gs)) + print(e2 - s2) + print(vim.inspect(context.git_status)) return context.git_status, git_root end @@ -265,9 +413,7 @@ M.status_async = function(path, base, opts) enable_recording = false, maximium_results = context.max_lines, on_stdout = function(err, line, job) - if should_process(err, line, job, "status_async staged error:") then - table.insert(context.lines, line) - end + table.insert(context.lines, line) end, on_stderr = function(err, line) if err and err > 0 then From 32ac86365e35ec29ba6ffd32f6ad5c4beebd598a Mon Sep 17 00:00:00 2001 From: pynappo Date: Wed, 8 Oct 2025 03:21:45 -0700 Subject: [PATCH 02/13] fixes --- lua/neo-tree/git/status.lua | 40 ++++++++++++++++++++++++------------- lua/neo-tree/utils/init.lua | 31 ++++++++++++++++++++++------ 2 files changed, 51 insertions(+), 20 deletions(-) diff --git a/lua/neo-tree/git/status.lua b/lua/neo-tree/git/status.lua index 8e556138..4defd75d 100644 --- a/lua/neo-tree/git/status.lua +++ b/lua/neo-tree/git/status.lua @@ -134,10 +134,21 @@ M.status = function(base, exclude_directories, path) if not utils.truthy(git_root) then return {} end + local git_root_dir = git_root .. utils.path_separator local s1 = vim.uv.hrtime() - local status_cmd = - { "git", "-c", "status.relativePaths=true", "-C", git_root, "status", "--porcelain=v2", "-z" } + local status_cmd = { + "git", + "-c", + "status.relativePaths=true", + "-C", + git_root, + "status", + "--porcelain=v2", + "--untracked-files=normal", + "--ignored=traditional", + "-z", + } local status_result = vim.fn.system(status_cmd) local status_ok = vim.v.shell_error == 0 @@ -145,7 +156,7 @@ M.status = function(base, exclude_directories, path) return {} end - local git_root_dir = git_root .. utils.path_separator + local m1 = vim.uv.hrtime() ---@type table local gs = {} -- system() replaces \000 with \001 @@ -237,23 +248,19 @@ M.status = function(base, exclude_directories, path) end if prev_line:sub(1, 1) == "?" then - -- untracked gs[git_root_dir .. prev_line:sub(3)] = "?" for line in status_iter do if line:sub(1, 1) ~= "?" then prev_line = line break end - gs[git_root_dir .. prev_line:sub(3)] = "?" + gs[git_root_dir .. line:sub(3)] = "?" end end - -- local gitignored = {} if prev_line:sub(1, 1) == "!" then - -- gitignored[#gitignored + 1] = prev_line:sub(3) gs[git_root_dir .. prev_line:sub(3)] = "!" for line in status_iter do - -- gitignored[#gitignored + 1] = line:sub(3) gs[git_root_dir .. line:sub(3)] = "!" end end @@ -265,13 +272,12 @@ M.status = function(base, exclude_directories, path) if not parent_status then gs[parent] = status else - local better_status = - get_priority_git_status_code(parent_status:sub(1, 1), status:sub(1, 1)) + local better_status + if status == "?" then if better_status == parent_status then break -- stop bubbling - else - gs[parent] = better_status end + gs[parent] = better_status end end end @@ -293,6 +299,7 @@ M.status = function(base, exclude_directories, path) if not untracked_ok then return {} end + local m2 = vim.uv.hrtime() local context = { git_root = git_root, @@ -317,9 +324,14 @@ M.status = function(base, exclude_directories, path) parse_git_status_line(context, line) end local e2 = vim.uv.hrtime() - print(e1 - s1) + print("entire:", e1 - s1) + print("cmd:", m1 - s1) + print("parse:", e1 - m1) print(vim.inspect(gs)) - print(e2 - s2) + + print("entire:", e2 - s2) + print("cmd:", m2 - s2) + print("parse:", e2 - m2) print(vim.inspect(context.git_status)) return context.git_status, git_root diff --git a/lua/neo-tree/utils/init.lua b/lua/neo-tree/utils/init.lua index 1296504b..8335c76b 100644 --- a/lua/neo-tree/utils/init.lua +++ b/lua/neo-tree/utils/init.lua @@ -14,6 +14,11 @@ end local M = {} +---The file system path separator for the current platform. +M.is_windows = vim.fn.has("win32") == 1 or vim.fn.has("win32unix") == 1 +M.is_macos = vim.fn.has("mac") == 1 +M.path_separator = M.is_windows and "\\" or "/" + local diag_severity_to_string = function(severity) if severity == vim.diagnostic.severity.ERROR then return "Error" @@ -997,25 +1002,39 @@ M.fs_parent = function(path, loose) return (M.split_path(realpath)) end +M.str_lastindexof = function(str, needle) + local i, j + local k = 0 + repeat + i = j + j, k = haystack:find(needle, k + 1, true) + until j == nil + + return i +end ---Finds all paths that are parents of the current path, naively by removing the tail segments ---@param path string +---@param fast boolean ---@return fun():string?,string? -M.path_parents = function(path) +M.path_parents = function(path, fast) path = M.normalize_path(path) ---@type string? local parent = path local tail + if fast then + local seperator_indices = {} + seperator_indices = {} + return function() + parent:find(M.path_se) + return parent, tail + end + end return function() parent, tail = M.split_path(parent) return parent, tail end end ----The file system path separator for the current platform. -M.is_windows = vim.fn.has("win32") == 1 or vim.fn.has("win32unix") == 1 -M.is_macos = vim.fn.has("mac") == 1 -M.path_separator = M.is_windows and "\\" or "/" - ---Remove the path separator from the end of a path in a cross-platform way. ---@param path string The path to remove the separator from. ---@return string string The path without any trailing separator. From af90f1e99d40c8a61a328ebde5d710d59e61ab66 Mon Sep 17 00:00:00 2001 From: pynappo Date: Wed, 8 Oct 2025 04:17:58 -0700 Subject: [PATCH 03/13] optimizations --- lua/neo-tree/git/status.lua | 44 +++++++++++++++---------------------- lua/neo-tree/utils/init.lua | 37 ++++++++++++++++++++++++------- 2 files changed, 47 insertions(+), 34 deletions(-) diff --git a/lua/neo-tree/git/status.lua b/lua/neo-tree/git/status.lua index 4defd75d..9735f544 100644 --- a/lua/neo-tree/git/status.lua +++ b/lua/neo-tree/git/status.lua @@ -136,7 +136,6 @@ M.status = function(base, exclude_directories, path) end local git_root_dir = git_root .. utils.path_separator - local s1 = vim.uv.hrtime() local status_cmd = { "git", "-c", @@ -156,7 +155,6 @@ M.status = function(base, exclude_directories, path) return {} end - local m1 = vim.uv.hrtime() ---@type table local gs = {} -- system() replaces \000 with \001 @@ -265,25 +263,30 @@ M.status = function(base, exclude_directories, path) end end - -- bubble up every status + -- bubble up every status besides ignored + local status_prio = { "U", "?", "M", "A" } for dir, status in pairs(gs) do - for parent in utils.path_parents(dir) do - local parent_status = gs[parent] - if not parent_status then - gs[parent] = status - else - local better_status - if status == "?" then - if better_status == parent_status then - break -- stop bubbling + if status ~= "!" then + for parent in utils.path_parents(dir, true) do + local parent_status = gs[parent] + if not parent_status then + gs[parent] = status + else + local p = parent_status:sub(1, 1) + local s = status:sub(1, 1) + for _, c in ipairs(status_prio) do + if p == c then + break + end + if s == c then + gs[parent] = c + end + end end - gs[parent] = better_status end end end - local e1 = vim.uv.hrtime() - local s2 = vim.uv.hrtime() local staged_cmd = { "git", "-C", git_root, "diff", "--staged", "--name-status", base, "--" } local staged_ok, staged_result = utils.execute_command(staged_cmd) if not staged_ok then @@ -299,7 +302,6 @@ M.status = function(base, exclude_directories, path) if not untracked_ok then return {} end - local m2 = vim.uv.hrtime() local context = { git_root = git_root, @@ -323,16 +325,6 @@ M.status = function(base, exclude_directories, path) end parse_git_status_line(context, line) end - local e2 = vim.uv.hrtime() - print("entire:", e1 - s1) - print("cmd:", m1 - s1) - print("parse:", e1 - m1) - print(vim.inspect(gs)) - - print("entire:", e2 - s2) - print("cmd:", m2 - s2) - print("parse:", e2 - m2) - print(vim.inspect(context.git_status)) return context.git_status, git_root end diff --git a/lua/neo-tree/utils/init.lua b/lua/neo-tree/utils/init.lua index 8335c76b..6417eb5a 100644 --- a/lua/neo-tree/utils/init.lua +++ b/lua/neo-tree/utils/init.lua @@ -1014,21 +1014,42 @@ M.str_lastindexof = function(str, needle) end ---Finds all paths that are parents of the current path, naively by removing the tail segments ---@param path string ----@param fast boolean +---@param fast boolean? ---@return fun():string?,string? M.path_parents = function(path, fast) - path = M.normalize_path(path) - ---@type string? - local parent = path - local tail + if not fast then + path = M.normalize_path(path) + end + local prefix = M.abspath_prefix(path) if fast then + local parent = path local seperator_indices = {} - seperator_indices = {} + do + local res + local i = 1 + repeat + res = path:find(M.path_separator, i, true) + seperator_indices[#seperator_indices + 1] = res + if res then + i = res + 1 + end + until not res + end + local i = #seperator_indices return function() - parent:find(M.path_se) - return parent, tail + local idx = seperator_indices[i] + i = i - 1 + if not idx or #parent <= #prefix then + return nil + end + + parent = parent:sub(1, idx - 1) + return parent, parent:sub(idx + 1) end end + ---@type string? + local parent = path + local tail return function() parent, tail = M.split_path(parent) return parent, tail From 25d09cd75a43d7d20d6bb57d6dc814a02080e9f1 Mon Sep 17 00:00:00 2001 From: pynappo Date: Wed, 8 Oct 2025 21:50:34 -0700 Subject: [PATCH 04/13] wip --- lua/neo-tree/git/status.lua | 350 ++++++++++++++++-------------------- lua/neo-tree/log.lua | 3 + 2 files changed, 156 insertions(+), 197 deletions(-) diff --git a/lua/neo-tree/git/status.lua b/lua/neo-tree/git/status.lua index 9735f544..6a6118de 100644 --- a/lua/neo-tree/git/status.lua +++ b/lua/neo-tree/git/status.lua @@ -3,6 +3,7 @@ local events = require("neo-tree.events") local Job = require("plenary.job") local log = require("neo-tree.log") local git_utils = require("neo-tree.git.utils") +local uv = vim.uv or vim.loop local M = {} @@ -57,73 +58,6 @@ end ---@alias neotree.git.Status table ----@param context neotree.git.Context -local parse_git_status_line = function(context, line) - context.lines_parsed = context.lines_parsed + 1 - if type(line) ~= "string" then - return - end - if #line < 3 then - return - end - local git_root = context.git_root - local git_status = context.git_status - local exclude_directories = context.exclude_directories - - local line_parts = vim.split(line, " ") - if #line_parts < 2 then - return - end - local status = line_parts[1] - local relative_path = line_parts[2] - - -- rename output is `R000 from/filename to/filename` - if status:match("^R") then - relative_path = line_parts[3] - end - - -- remove any " due to whitespace or utf-8 in the path - relative_path = relative_path:gsub('^"', ""):gsub('"$', "") - -- convert octal encoded lines to utf-8 - relative_path = git_utils.octal_to_utf8(relative_path) - - if utils.is_windows == true then - relative_path = utils.windowize_path(relative_path) - end - local absolute_path = utils.path_join(git_root, relative_path) - -- merge status result if there are results from multiple passes - local existing_status = git_status[absolute_path] - if existing_status then - local merged = "" - local i = 0 - while i < 2 do - i = i + 1 - local existing_char = #existing_status >= i and existing_status:sub(i, i) or "" - local new_char = #status >= i and status:sub(i, i) or "" - local merged_char = get_priority_git_status_code(existing_char, new_char) - merged = merged .. merged_char - end - status = merged - end - git_status[absolute_path] = status - - if not exclude_directories then - -- Now bubble this status up to the parent directories - local parts = utils.split(absolute_path, utils.path_separator) - table.remove(parts) -- pop the last part so we don't override the file's status - utils.reduce(parts, "", function(acc, part) - local path = acc .. utils.path_separator .. part - if utils.is_windows == true then - path = path:gsub("^" .. utils.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 - ---Parse "git status" output for the current working directory. ---@param base string git ref base ---@param exclude_directories boolean Whether to skip bubling up status to directories @@ -138,8 +72,6 @@ M.status = function(base, exclude_directories, path) local status_cmd = { "git", - "-c", - "status.relativePaths=true", "-C", git_root, "status", @@ -239,12 +171,12 @@ M.status = function(base, exclude_directories, path) -- D U unmerged, deleted by us -- A A unmerged, both added -- U U unmerged, both modified - -- ------------------------------------------------- - -- ? ? untracked - -- ! ! ignored - -- ------------------------------------------------- end + -- ------------------------------------------------- + -- ? ? untracked + -- ! ! ignored + -- ------------------------------------------------- if prev_line:sub(1, 1) == "?" then gs[git_root_dir .. prev_line:sub(3)] = "?" for line in status_iter do @@ -267,13 +199,16 @@ M.status = function(base, exclude_directories, path) local status_prio = { "U", "?", "M", "A" } for dir, status in pairs(gs) do if status ~= "!" then + local s = status:sub(1, 1) for parent in utils.path_parents(dir, true) do + if parent == git_root then + break + end local parent_status = gs[parent] if not parent_status then gs[parent] = status else local p = parent_status:sub(1, 1) - local s = status:sub(1, 1) for _, c in ipairs(status_prio) do if p == c then break @@ -287,45 +222,12 @@ M.status = function(base, exclude_directories, path) end end - local staged_cmd = { "git", "-C", git_root, "diff", "--staged", "--name-status", base, "--" } - local staged_ok, staged_result = utils.execute_command(staged_cmd) - if not staged_ok then - return {} - end - local unstaged_cmd = { "git", "-C", git_root, "diff", "--name-status" } - local unstaged_ok, unstaged_result = utils.execute_command(unstaged_cmd) - if not unstaged_ok then - return {} - end - local untracked_cmd = { "git", "-C", git_root, "ls-files", "--exclude-standard", "--others" } - local untracked_ok, untracked_result = utils.execute_command(untracked_cmd) - if not untracked_ok then - return {} - end - local context = { git_root = git_root, - git_status = {}, - exclude_directories = exclude_directories, + git_status = gs, lines_parsed = 0, } - for _, line in ipairs(staged_result) do - parse_git_status_line(context, line) - end - for _, line in ipairs(unstaged_result) do - if line then - line = " " .. line - end - parse_git_status_line(context, line) - end - for _, line in ipairs(untracked_result) do - if line then - line = "? " .. line - end - parse_git_status_line(context, line) - end - return context.git_status, git_root end @@ -362,6 +264,9 @@ local function parse_lines_batch(context, job_complete_callback) end end +---@param path string path to run commands in +---@param base string git ref base +---@param opts neotree.Config.GitStatusAsync M.status_async = function(path, base, opts) git_utils.get_repository_root(path, function(git_root) if utils.truthy(git_root) then @@ -376,7 +281,6 @@ M.status_async = function(path, base, opts) local context = { git_root = git_root, git_status = {}, - exclude_directories = false, lines = {}, lines_parsed = 0, batch_size = opts.batch_size or 1000, @@ -384,18 +288,6 @@ M.status_async = function(path, base, opts) max_lines = opts.max_lines or 100000, } - local should_process = function(err, line, job, err_msg) - if vim.v.dying > 0 or vim.v.exiting ~= vim.NIL then - job:shutdown() - return false - end - if err and err > 0 then - log.error(err_msg, err, line) - return false - end - return true - end - local job_complete_callback = function() vim.schedule(function() events.fire_event(events.GIT_STATUS_CHANGED, { @@ -410,87 +302,151 @@ M.status_async = function(path, base, opts) end) utils.debounce(event_id, function() + -- ---@diagnostic disable-next-line: missing-fields + -- local staged_job = Job:new({ + -- command = "git", + -- args = { "-C", git_root, "diff", "--staged", "--name-status", base, "--" }, + -- enable_recording = false, + -- maximium_results = context.max_lines, + -- on_stdout = function(err, line, job) + -- table.insert(context.lines, line) + -- end, + -- on_stderr = function(err, line) + -- if err and err > 0 then + -- log.error("status_async staged error: ", err, line) + -- end + -- end, + -- }) + -- + -- ---@diagnostic disable-next-line: missing-fields + -- local unstaged_job = Job:new({ + -- command = "git", + -- args = { "-C", git_root, "diff", "--name-status" }, + -- enable_recording = false, + -- maximium_results = context.max_lines, + -- on_stdout = function(err, line, job) + -- if should_process(err, line, job, "status_async unstaged error:") then + -- if line then + -- line = " " .. line + -- end + -- table.insert(context.lines, line) + -- end + -- end, + -- on_stderr = function(err, line) + -- if err and err > 0 then + -- log.error("status_async unstaged error: ", err, line) + -- end + -- end, + -- }) + -- + -- ---@diagnostic disable-next-line: missing-fields + -- local untracked_job = Job:new({ + -- command = "git", + -- args = { "-C", git_root, "ls-files", "--exclude-standard", "--others" }, + -- enable_recording = false, + -- maximium_results = context.max_lines, + -- on_stdout = function(err, line, job) + -- if should_process(err, line, job, "status_async untracked error:") then + -- if line then + -- line = "? " .. line + -- end + -- table.insert(context.lines, line) + -- end + -- end, + -- on_stderr = function(err, line) + -- if err and err > 0 then + -- log.error("status_async untracked error: ", err, line) + -- end + -- end, + -- }) + -- + -- ---@diagnostic disable-next-line: missing-fields + -- Job:new({ + -- command = "git", + -- args = { + -- "-C", + -- git_root, + -- "config", + -- "--get", + -- "status.showUntrackedFiles", + -- }, + -- enabled_recording = true, + -- on_exit = function(self, _, _) + -- local result = self:result() + -- log.debug("git status.showUntrackedFiles =", result[1]) + -- if result[1] == "no" then + -- unstaged_job:after(parse_lines) + -- Job.chain(staged_job, unstaged_job) + -- else + -- untracked_job:after(parse_lines) + -- Job.chain(staged_job, unstaged_job, untracked_job) + -- end + -- end, + -- }):start() + -- + -- Job:new({ + -- command = "git", + -- args = { + -- "-C", + -- git_root, + -- "status", + -- "--porcelain=v2", + -- "--untracked-files=normal", + -- "--ignored=traditional", + -- "-z", + -- }, + -- on_stdout = function(err, line, job) + -- if vim.v.dying > 0 or vim.v.exiting ~= vim.NIL then + -- job:shutdown() + -- return + -- end + -- if err and err > 0 then + -- log.error("status_async error:", err, line) + -- return + -- end + -- table.insert(context.lines, line) + -- end, + -- on_stderr = function(err, line) + -- if err and err > 0 then + -- log.error("status_async untracked error: ", err, line) + -- end + -- end, + -- on_exit = function(self) + -- local result = self:result() + -- end, + -- }):start() + local stdin = log.assert(uv.new_pipe()) + local stdout = log.assert(uv.new_pipe()) + local stderr = log.assert(uv.new_pipe()) ---@diagnostic disable-next-line: missing-fields - local staged_job = Job:new({ - command = "git", - args = { "-C", git_root, "diff", "--staged", "--name-status", base, "--" }, - enable_recording = false, - maximium_results = context.max_lines, - on_stdout = function(err, line, job) - table.insert(context.lines, line) - end, - on_stderr = function(err, line) - if err and err > 0 then - log.error("status_async staged error: ", err, line) - end - end, - }) - - ---@diagnostic disable-next-line: missing-fields - local unstaged_job = Job:new({ - command = "git", - args = { "-C", git_root, "diff", "--name-status" }, - enable_recording = false, - maximium_results = context.max_lines, - on_stdout = function(err, line, job) - if should_process(err, line, job, "status_async unstaged error:") then - if line then - line = " " .. line - end - table.insert(context.lines, line) - end - end, - on_stderr = function(err, line) - if err and err > 0 then - log.error("status_async unstaged error: ", err, line) - end - end, - }) - - ---@diagnostic disable-next-line: missing-fields - local untracked_job = Job:new({ - command = "git", - args = { "-C", git_root, "ls-files", "--exclude-standard", "--others" }, - enable_recording = false, - maximium_results = context.max_lines, - on_stdout = function(err, line, job) - if should_process(err, line, job, "status_async untracked error:") then - if line then - line = "? " .. line - end - table.insert(context.lines, line) - end - end, - on_stderr = function(err, line) - if err and err > 0 then - log.error("status_async untracked error: ", err, line) - end - end, - }) - - ---@diagnostic disable-next-line: missing-fields - Job:new({ - command = "git", + local handle = uv.spawn("git", { args = { "-C", git_root, - "config", - "--get", - "status.showUntrackedFiles", + "status", + "--porcelain=v2", + "--untracked-files=normal", + "--ignored=traditional", + "-z", }, - enabled_recording = true, - on_exit = function(self, _, _) - local result = self:result() - log.debug("git status.showUntrackedFiles =", result[1]) - if result[1] == "no" then - unstaged_job:after(parse_lines) - Job.chain(staged_job, unstaged_job) - else - untracked_job:after(parse_lines) - Job.chain(staged_job, unstaged_job, untracked_job) - end - end, - }):start() + stdio = { stdin, stdout, stderr }, + }, function(code, signal) end) + + stdout:read_start(function(err, data) + log.assert(not err, err) + if data then + print("data\n") + print(data) + else + print("stdout end") + end + end) + + stderr:read_start(function(err, data) end) + + uv.shutdown(stdin, function() + uv.close(handle, function() end) + end) end, 1000, utils.debounce_strategy.CALL_FIRST_AND_LAST, utils.debounce_action.START_ASYNC_JOB) return true diff --git a/lua/neo-tree/log.lua b/lua/neo-tree/log.lua index 047a6d63..ca804967 100644 --- a/lua/neo-tree/log.lua +++ b/lua/neo-tree/log.lua @@ -374,7 +374,10 @@ log_maker.new = function(config) else errmsg = "assertion failed!" end + local temp = config.use_console + config.use_console = false log.error(errmsg) + config.use_console = temp return assert(v, errmsg) end From 568d1a7f8a0258572c05e8ae4b02582298c16573 Mon Sep 17 00:00:00 2001 From: pynappo Date: Fri, 10 Oct 2025 04:40:28 -0700 Subject: [PATCH 05/13] tiny log fix and remove unused --- lua/neo-tree/log.lua | 3 ++- lua/neo-tree/utils/init.lua | 10 ---------- 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/lua/neo-tree/log.lua b/lua/neo-tree/log.lua index ca804967..da0dc606 100644 --- a/lua/neo-tree/log.lua +++ b/lua/neo-tree/log.lua @@ -378,7 +378,8 @@ log_maker.new = function(config) config.use_console = false log.error(errmsg) config.use_console = temp - return assert(v, errmsg) + -- actually raise the error so execution stops + error(errmsg, 2) end ---@param context string diff --git a/lua/neo-tree/utils/init.lua b/lua/neo-tree/utils/init.lua index 6417eb5a..7026afca 100644 --- a/lua/neo-tree/utils/init.lua +++ b/lua/neo-tree/utils/init.lua @@ -1002,16 +1002,6 @@ M.fs_parent = function(path, loose) return (M.split_path(realpath)) end -M.str_lastindexof = function(str, needle) - local i, j - local k = 0 - repeat - i = j - j, k = haystack:find(needle, k + 1, true) - until j == nil - - return i -end ---Finds all paths that are parents of the current path, naively by removing the tail segments ---@param path string ---@param fast boolean? From 6534dbfb14ccdafb37ece9a5af77a652536caec1 Mon Sep 17 00:00:00 2001 From: pynappo Date: Fri, 10 Oct 2025 04:40:39 -0700 Subject: [PATCH 06/13] use porcelain for async and sync --- lua/neo-tree/git/status.lua | 323 ++++++++++++++++++++++-------------- lua/neo-tree/git/utils.lua | 3 + 2 files changed, 199 insertions(+), 127 deletions(-) diff --git a/lua/neo-tree/git/status.lua b/lua/neo-tree/git/status.lua index 6a6118de..d16d42d6 100644 --- a/lua/neo-tree/git/status.lua +++ b/lua/neo-tree/git/status.lua @@ -1,9 +1,9 @@ local utils = require("neo-tree.utils") local events = require("neo-tree.events") -local Job = require("plenary.job") local log = require("neo-tree.log") local git_utils = require("neo-tree.git.utils") local uv = vim.uv or vim.loop +local co = coroutine local M = {} @@ -50,48 +50,35 @@ local function get_priority_git_status_code(status, other_status) end end ----@class (exact) neotree.git.Context +---@class (exact) neotree.git.Context : neotree.Config.GitStatusAsync ---@field git_status neotree.git.Status ---@field git_root string ----@field exclude_directories boolean ---@field lines_parsed integer ----@alias neotree.git.Status table - ----Parse "git status" output for the current working directory. ----@param base string git ref base ----@param exclude_directories boolean Whether to skip bubling up status to directories ----@param path string Path to run the git status command in, defaults to cwd. ----@return neotree.git.Status, string? git_status the neotree.Git.Status of the given root -M.status = function(base, exclude_directories, path) - local git_root = git_utils.get_repository_root(path) - if not utils.truthy(git_root) then - return {} +---@param git_root string +---@param status_iter fun():string? +---@param batch_size integer? This will use coroutine.yield if provided. +---@param skip_bubbling boolean? +---@return neotree.git.Status +local parse_porcelain_output = function(git_root, status_iter, batch_size, skip_bubbling) + local git_root_dir = utils.normalize_path(git_root) .. utils.path_separator + local prev_line = "" + local num_in_batch = 0 + local git_status = {} + if batch_size == 0 then + batch_size = nil end - local git_root_dir = git_root .. utils.path_separator - - local status_cmd = { - "git", - "-C", - git_root, - "status", - "--porcelain=v2", - "--untracked-files=normal", - "--ignored=traditional", - "-z", - } - local status_result = vim.fn.system(status_cmd) + local yield_if_batch_completed = function() end - local status_ok = vim.v.shell_error == 0 - if not status_ok then - return {} + if batch_size then + yield_if_batch_completed = function() + num_in_batch = num_in_batch + 1 + if num_in_batch > batch_size then + num_in_batch = 0 + coroutine.yield(git_status) + end + end end - - ---@type table - local gs = {} - -- system() replaces \000 with \001 - local status_iter = vim.gsplit(status_result, "\001", { plain = true }) - local prev_line = "" for line in status_iter do -- Example status: -- 1 D. N... 100644 000000 000000 ade2881afa1dcb156a3aa576024aa0fecf789191 0000000000000000000000000000000000000000 deleted_staged.txt @@ -117,8 +104,8 @@ M.status = function(base, exclude_directories, path) -- local mW = line:sub(25, 30) -- local hH = line:sub(32, 71) -- local hI = line:sub(73, 112) - local pathname = line:sub(114) - gs[git_root_dir .. pathname] = XY + local path = line:sub(114) + git_status[git_root_dir .. path] = XY elseif t == "2" then -- local XY = line:sub(3, 4) -- local submodule_state = line:sub(6, 9) @@ -128,9 +115,11 @@ M.status = function(base, exclude_directories, path) -- local hH = line:sub(32, 71) -- local hI = line:sub(73, 112) local rest = line:sub(114) - local score_and_pathname = vim.split(rest, " ", { plain = true }) - -- iterate over the original path - gs[git_root_dir .. score_and_pathname[2]] = score_and_pathname[1] + local first_space = rest:find(" ", 1, true) + local Xscore = rest:sub(1, first_space - 1) + local path = rest:sub(first_space + 1) + git_status[git_root_dir .. path] = Xscore + -- ignore the original path status_iter() elseif t == "u" then local XY = line:sub(3, 4) @@ -142,8 +131,8 @@ M.status = function(base, exclude_directories, path) -- local h1 = line:sub(39, 78) -- local h2 = line:sub(80, 119) -- local h3 = line:sub(121, 160) - local pathname = line:sub(162) - gs[git_root_dir .. pathname] = XY + local path = line:sub(162) + git_status[git_root_dir .. path] = XY else prev_line = line break @@ -171,6 +160,7 @@ M.status = function(base, exclude_directories, path) -- D U unmerged, deleted by us -- A A unmerged, both added -- U U unmerged, both modified + yield_if_batch_completed() end -- ------------------------------------------------- @@ -178,49 +168,92 @@ M.status = function(base, exclude_directories, path) -- ! ! ignored -- ------------------------------------------------- if prev_line:sub(1, 1) == "?" then - gs[git_root_dir .. prev_line:sub(3)] = "?" + git_status[git_root_dir .. prev_line:sub(3)] = "?" for line in status_iter do if line:sub(1, 1) ~= "?" then prev_line = line break end - gs[git_root_dir .. line:sub(3)] = "?" + git_status[git_root_dir .. line:sub(3)] = "?" end + yield_if_batch_completed() end - if prev_line:sub(1, 1) == "!" then - gs[git_root_dir .. prev_line:sub(3)] = "!" - for line in status_iter do - gs[git_root_dir .. line:sub(3)] = "!" - end - end + if not skip_bubbling then + -- bubble up every status besides ignored + local status_prio = { "U", "?", "M", "A" } - -- bubble up every status besides ignored - local status_prio = { "U", "?", "M", "A" } - for dir, status in pairs(gs) do - if status ~= "!" then - local s = status:sub(1, 1) - for parent in utils.path_parents(dir, true) do - if parent == git_root then - break - end - local parent_status = gs[parent] - if not parent_status then - gs[parent] = status - else - local p = parent_status:sub(1, 1) - for _, c in ipairs(status_prio) do - if p == c then - break - end - if s == c then - gs[parent] = c + for dir, status in pairs(git_status) do + if status ~= "!" then + local s = status:sub(1, 1) + for parent in utils.path_parents(dir, true) do + if parent == git_root then + break + end + + local parent_status = git_status[parent] + if not parent_status then + git_status[parent] = status + else + -- Bubble up the most important status + local p = parent_status:sub(1, 1) + for _, c in ipairs(status_prio) do + if p == c then + break + end + if s == c then + git_status[parent] = c + end end end end end + yield_if_batch_completed() end end + if prev_line:sub(1, 1) == "!" then + git_status[git_root_dir .. prev_line:sub(3)] = "!" + for line in status_iter do + git_status[git_root_dir .. line:sub(3)] = "!" + end + yield_if_batch_completed() + end + + return git_status +end +---@alias neotree.git.Status table + +---Parse "git status" output for the current working directory. +---@param base string git ref base +---@param skip_bubbling boolean? Whether to skip bubling up status to directories +---@param path string? Path to run the git status command in, defaults to cwd. +---@return neotree.git.Status, string? git_status the neotree.Git.Status of the given root +M.status = function(base, skip_bubbling, path) + local git_root = git_utils.get_repository_root(path) + if not utils.truthy(git_root) then + return {} + end + + local status_cmd = { + "git", + "-C", + git_root, + "status", + "--porcelain=v2", + "--untracked-files=normal", + "--ignored=traditional", + "-z", + } + local status_result = vim.fn.system(status_cmd) + + local status_ok = vim.v.shell_error == 0 + if not status_ok then + return {} + end + + -- system() replaces \000 with \001 + local status_iter = vim.gsplit(status_result, "\001", { plain = true }) + local gs = parse_porcelain_output(git_root, status_iter, nil, skip_bubbling) local context = { git_root = git_root, @@ -228,41 +261,48 @@ M.status = function(base, exclude_directories, path) lines_parsed = 0, } - return context.git_status, git_root -end - -local function parse_lines_batch(context, job_complete_callback) - local i, batch_size = 0, context.batch_size - - if context.lines_total == nil then - -- first time through, get the total number of lines - context.lines_total = math.min(context.max_lines, #context.lines) - context.lines_parsed = 0 - if context.lines_total == 0 then - if type(job_complete_callback) == "function" then - job_complete_callback() - end - return - end - end - batch_size = math.min(context.batch_size, context.lines_total - context.lines_parsed) - - while i < batch_size do - i = i + 1 - parse_git_status_line(context, context.lines[context.lines_parsed + 1]) - end + vim.schedule(function() + events.fire_event(events.GIT_STATUS_CHANGED, { + git_root = context.git_root, + git_status = context.git_status, + }) + end) - if context.lines_parsed >= context.lines_total then - if type(job_complete_callback) == "function" then - job_complete_callback() - end - else - -- add small delay so other work can happen - vim.defer_fn(function() - parse_lines_batch(context, job_complete_callback) - end, context.batch_delay) - end + return context.git_status, git_root end +-- +-- local function parse_lines_batch(context, job_complete_callback) +-- local i, batch_size = 0, context.batch_size +-- +-- if context.lines_total == nil then +-- -- first time through, get the total number of lines +-- context.lines_total = math.min(context.max_lines, #context.lines) +-- context.lines_parsed = 0 +-- if context.lines_total == 0 then +-- if type(job_complete_callback) == "function" then +-- job_complete_callback() +-- end +-- return +-- end +-- end +-- batch_size = math.min(context.batch_size, context.lines_total - context.lines_parsed) +-- +-- while i < batch_size do +-- i = i + 1 +-- parse_git_status_line(context, context.lines[context.lines_parsed + 1]) +-- end +-- +-- if context.lines_parsed >= context.lines_total then +-- if type(job_complete_callback) == "function" then +-- job_complete_callback() +-- end +-- else +-- -- add small delay so other work can happen +-- vim.defer_fn(function() +-- parse_lines_batch(context, job_complete_callback) +-- end, context.batch_delay) +-- end +-- end ---@param path string path to run commands in ---@param base string git ref base @@ -288,19 +328,6 @@ M.status_async = function(path, base, opts) max_lines = opts.max_lines or 100000, } - local job_complete_callback = function() - vim.schedule(function() - events.fire_event(events.GIT_STATUS_CHANGED, { - git_root = context.git_root, - git_status = context.git_status, - }) - end) - end - - local parse_lines = vim.schedule_wrap(function() - parse_lines_batch(context, job_complete_callback) - end) - utils.debounce(event_id, function() -- ---@diagnostic disable-next-line: missing-fields -- local staged_job = Job:new({ @@ -419,7 +446,38 @@ M.status_async = function(path, base, opts) local stdout = log.assert(uv.new_pipe()) local stderr = log.assert(uv.new_pipe()) ---@diagnostic disable-next-line: missing-fields + + local output_chunks = {} + local on_exit = function() + local str = output_chunks[1] + if #output_chunks > 1 then + str = table.concat(output_chunks, "") + end + local status_iter = vim.gsplit(str, "\000", { plain = true }) + local parsing_task = co.create(parse_porcelain_output) + local _, git_status = + log.assert(co.resume(parsing_task, git_root, status_iter, context.batch_size)) + + local do_next_batch_later + do_next_batch_later = function() + if co.status(parsing_task) ~= "dead" then + _, git_status = log.assert(co.resume(parsing_task)) + vim.defer_fn(do_next_batch_later, 500) + return + end + context.git_status = git_status + vim.schedule(function() + events.fire_event(events.GIT_STATUS_CHANGED, { + git_root = context.git_root, + git_status = context.git_status, + }) + end) + end + do_next_batch_later() + end + ---@diagnostic disable-next-line: missing-fields local handle = uv.spawn("git", { + hide = true, args = { "-C", git_root, @@ -430,23 +488,34 @@ M.status_async = function(path, base, opts) "-z", }, stdio = { stdin, stdout, stderr }, - }, function(code, signal) end) + }, function(code, signal) + log.assert( + code == 0, + "git status async process exited abnormally, code: %s, signal: %s", + code, + signal + ) + on_exit() + end) stdout:read_start(function(err, data) log.assert(not err, err) - if data then - print("data\n") - print(data) - else - print("stdout end") + -- for some reason data can be a table here? + if type(data) == "string" then + table.insert(output_chunks, data) end end) - stderr:read_start(function(err, data) end) - - uv.shutdown(stdin, function() - uv.close(handle, function() end) + stderr:read_start(function(err, data) + if err then + local errfmt = (err or "") .. "%s" + log.at.error.format(errfmt, data) + end end) + + -- uv.shutdown(stdin, function() + -- uv.close(handle) + -- end) end, 1000, utils.debounce_strategy.CALL_FIRST_AND_LAST, utils.debounce_action.START_ASYNC_JOB) return true diff --git a/lua/neo-tree/git/utils.lua b/lua/neo-tree/git/utils.lua index ba6b96e5..18770ae6 100644 --- a/lua/neo-tree/git/utils.lua +++ b/lua/neo-tree/git/utils.lua @@ -5,6 +5,9 @@ local log = require("neo-tree.log") local M = {} +---@param path string? Defaults to cwd +---@param callback fun(git_root: string)? +---@return string? M.get_repository_root = function(path, callback) local args = { "rev-parse", "--show-toplevel" } if utils.truthy(path) then From f8a4895a8b2bd8d5e0f3f571dd61f9791dc9441c Mon Sep 17 00:00:00 2001 From: pynappo Date: Fri, 10 Oct 2025 04:49:48 -0700 Subject: [PATCH 07/13] fix types --- lua/neo-tree/git/status.lua | 59 ++++++++++++++++--------------------- 1 file changed, 26 insertions(+), 33 deletions(-) diff --git a/lua/neo-tree/git/status.lua b/lua/neo-tree/git/status.lua index d16d42d6..4eee8053 100644 --- a/lua/neo-tree/git/status.lua +++ b/lua/neo-tree/git/status.lua @@ -236,6 +236,7 @@ M.status = function(base, skip_bubbling, path) local status_cmd = { "git", + "--no-optional-locks", "-C", git_root, "status", @@ -309,13 +310,13 @@ end ---@param opts neotree.Config.GitStatusAsync M.status_async = function(path, base, opts) git_utils.get_repository_root(path, function(git_root) - if utils.truthy(git_root) then - log.trace("git.status.status_async called") - else + if not utils.truthy(git_root) then log.trace("status_async: not a git folder:", path) - return false + return end + log.trace("git.status.status_async called") + local event_id = "git_status_" .. git_root ---@type neotree.git.Context local context = { @@ -448,7 +449,27 @@ M.status_async = function(path, base, opts) ---@diagnostic disable-next-line: missing-fields local output_chunks = {} - local on_exit = function() + ---@diagnostic disable-next-line: missing-fields + local handle = uv.spawn("git", { + hide = true, + args = { + "--no-optional-locks", + "-C", + git_root, + "status", + "--porcelain=v2", + "--untracked-files=normal", + "--ignored=traditional", + "-z", + }, + stdio = { stdin, stdout, stderr }, + }, function(code, signal) + log.assert( + code == 0, + "git status async process exited abnormally, code: %s, signal: %s", + code, + signal + ) local str = output_chunks[1] if #output_chunks > 1 then str = table.concat(output_chunks, "") @@ -474,28 +495,6 @@ M.status_async = function(path, base, opts) end) end do_next_batch_later() - end - ---@diagnostic disable-next-line: missing-fields - local handle = uv.spawn("git", { - hide = true, - args = { - "-C", - git_root, - "status", - "--porcelain=v2", - "--untracked-files=normal", - "--ignored=traditional", - "-z", - }, - stdio = { stdin, stdout, stderr }, - }, function(code, signal) - log.assert( - code == 0, - "git status async process exited abnormally, code: %s, signal: %s", - code, - signal - ) - on_exit() end) stdout:read_start(function(err, data) @@ -512,13 +511,7 @@ M.status_async = function(path, base, opts) log.at.error.format(errfmt, data) end end) - - -- uv.shutdown(stdin, function() - -- uv.close(handle) - -- end) end, 1000, utils.debounce_strategy.CALL_FIRST_AND_LAST, utils.debounce_action.START_ASYNC_JOB) - - return true end) end From 333bd79d705993e6aa0e67ce25e3c8ee8adf6ab6 Mon Sep 17 00:00:00 2001 From: pynappo Date: Fri, 10 Oct 2025 17:35:53 -0700 Subject: [PATCH 08/13] a lot of cleanup --- lua/neo-tree/git/ignored.lua | 186 ------- lua/neo-tree/git/init.lua | 459 +++++++++++++++- lua/neo-tree/git/status.lua | 518 ------------------ lua/neo-tree/git/utils.lua | 69 --- lua/neo-tree/sources/common/commands.lua | 6 +- lua/neo-tree/sources/common/components.lua | 2 +- lua/neo-tree/sources/common/file-items.lua | 30 +- lua/neo-tree/sources/filesystem/commands.lua | 2 +- .../sources/filesystem/lib/fs_scan.lua | 83 +-- .../sources/filesystem/lib/fs_watch.lua | 15 + .../sources/filesystem/lib/ignored.lua | 5 +- lua/neo-tree/utils/init.lua | 12 +- 12 files changed, 490 insertions(+), 897 deletions(-) delete mode 100644 lua/neo-tree/git/ignored.lua delete mode 100644 lua/neo-tree/git/status.lua diff --git a/lua/neo-tree/git/ignored.lua b/lua/neo-tree/git/ignored.lua deleted file mode 100644 index 5c993b4b..00000000 --- a/lua/neo-tree/git/ignored.lua +++ /dev/null @@ -1,186 +0,0 @@ -local Job = require("plenary.job") -local uv = vim.uv or vim.loop - -local utils = require("neo-tree.utils") -local log = require("neo-tree.log") -local git_utils = require("neo-tree.git.utils") - -local M = {} -local sep = utils.path_separator - ----@param ignored string[] ----@param path string ----@param _type neotree.Filetype -M.is_ignored = function(ignored, path, _type) - if _type == "directory" and not utils.is_windows then - path = path .. sep - end - - return vim.tbl_contains(ignored, path) -end - -local git_root_cache = { - known_roots = {}, - dir_lookup = {}, -} -local get_root_for_item = function(item) - local dir = item.type == "directory" and item.path or item.parent_path - if type(git_root_cache.dir_lookup[dir]) ~= "nil" then - return git_root_cache.dir_lookup[dir] - end - --for _, root in ipairs(git_root_cache.known_roots) do - -- if vim.startswith(dir, root) then - -- git_root_cache.dir_lookup[dir] = root - -- return root - -- end - --end - local root = git_utils.get_repository_root(dir) - if root then - git_root_cache.dir_lookup[dir] = root - table.insert(git_root_cache.known_roots, root) - else - git_root_cache.dir_lookup[dir] = false - end - return root -end - ----@param state neotree.State ----@param items neotree.FileItem[] ----@param callback fun(results: string[]) ----@overload fun(state: neotree.State, items: neotree.FileItem[]):string[] -M.mark_ignored = function(state, items, callback) - local folders = {} - log.trace("================================================================================") - log.trace("IGNORED: mark_ignore BEGIN...") - - for _, item in ipairs(items) do - local folder = utils.split_path(item.path) - if folder then - folders[folder] = folders[folder] or {} - table.insert(folders[folder], item.path) - end - end - - ---@param results string[] - local function process_results(results) - if utils.is_windows then - --on Windows, git seems to return quotes and double backslash "path\\directory" - ---@param item string - results = vim.tbl_map(function(item) - item = item:gsub("\\\\", "\\") - return item - end, results) - else - --check-ignore does not indicate directories the same as 'status' so we need to - --add the trailing slash to the path manually if not on Windows. - log.trace("IGNORED: Checking types of", #results, "items to see which ones are directories") - for i, item in ipairs(results) do - local stat = uv.fs_stat(item) - if stat and stat.type == "directory" then - results[i] = item .. sep - end - end - end - ---@param item string - results = vim.tbl_map(function(item) - item = item:gsub("\\\\", "\\") - -- remove leading and trailing " from git output - item = item:gsub('^"', ""):gsub('"$', "") - -- convert octal encoded lines to utf-8 - item = git_utils.octal_to_utf8(item) - return item - end, results) - return results - end - - ---@param all_results string[] - local function finalize(all_results) - local ignored, not_ignored = 0, 0 - for _, item in ipairs(items) do - if M.is_ignored(all_results, item.path, item.type) then - item.filtered_by = item.filtered_by or {} - item.filtered_by.gitignored = true - ignored = ignored + 1 - else - not_ignored = not_ignored + 1 - end - end - log.trace("IGNORED: mark_ignored is complete, ignored:", ignored, ", not ignored:", not_ignored) - log.trace("================================================================================") - end - - ---@type string[] - local all_results = {} - if type(callback) == "function" then - local jobs = {} - local running_jobs = 0 - local job_count = 0 - local completed_jobs = 0 - - -- This is called when a job completes, and starts the next job if there are any left - -- or calls the callback if all jobs are complete. - -- It is also called once at the start to start the first 50 jobs. - -- - -- This is done to avoid running too many jobs at once, which can cause a crash from - -- having too many open files. - local run_more_jobs = function() - while #jobs > 0 and running_jobs < 50 and job_count > completed_jobs do - local next_job = table.remove(jobs, #jobs) - next_job:start() - running_jobs = running_jobs + 1 - end - - if completed_jobs == job_count then - finalize(all_results) - callback(all_results) - end - end - - for folder, folder_items in pairs(folders) do - local args = { "-C", folder, "check-ignore", "--stdin" } - ---@diagnostic disable-next-line: missing-fields - local job = Job:new({ - command = "git", - args = args, - enabled_recording = true, - writer = folder_items, - on_start = function() - log.trace("IGNORED: Running async git with args:", args) - end, - on_exit = function(self, code, _) - local results - if code ~= 0 then - log.debug("Failed to load ignored files for", folder, ":", self:stderr_result()) - results = {} - else - results = self:result() - end - vim.list_extend(all_results, process_results(results)) - - running_jobs = running_jobs - 1 - completed_jobs = completed_jobs + 1 - run_more_jobs() - end, - }) - table.insert(jobs, job) - job_count = job_count + 1 - end - - run_more_jobs() - else - for folder, folder_items in pairs(folders) do - local cmd = { "git", "-C", folder, "check-ignore", unpack(folder_items) } - log.trace("IGNORED: Running cmd:", cmd) - local result = vim.fn.systemlist(cmd) - if vim.v.shell_error == 128 then - log.debug("Failed to load ignored files for", state.path, ":", result) - result = {} - end - vim.list_extend(all_results, process_results(result)) - end - finalize(all_results) - return all_results - end -end - -return M diff --git a/lua/neo-tree/git/init.lua b/lua/neo-tree/git/init.lua index 863599be..4e5c485d 100644 --- a/lua/neo-tree/git/init.lua +++ b/lua/neo-tree/git/init.lua @@ -1,13 +1,450 @@ -local status = require("neo-tree.git.status") -local ignored = require("neo-tree.git.ignored") -local git_utils = require("neo-tree.git.utils") - -local M = { - get_repository_root = git_utils.get_repository_root, - is_ignored = ignored.is_ignored, - mark_ignored = ignored.mark_ignored, - status = status.status, - status_async = status.status_async, -} +local utils = require("neo-tree.utils") +local events = require("neo-tree.events") +local log = require("neo-tree.log") +local Job = require("plenary.job") +local uv = vim.uv or vim.loop +local co = coroutine +local M = {} + +---@type table +M.cache = setmetatable({}, { + __mode = "kv", + __newindex = function(_, root_dir, status) + require("neo-tree.sources.filesystem.lib.fs_watch").on_destroyed(root_dir, function() + rawset(M.cache, root_dir, nil) + end) + rawset(M.cache, root_dir, status) + end, +}) + +---@class (exact) neotree.git.Context : neotree.Config.GitStatusAsync +---@field git_status neotree.git.Status +---@field git_root string +---@field lines_parsed integer + +---@param git_root string +---@param status_iter fun():string? +---@param batch_size integer? This will use coroutine.yield if provided. +---@param skip_bubbling boolean? +---@return neotree.git.Status +local parse_porcelain_output = function(git_root, status_iter, batch_size, skip_bubbling) + local git_root_dir = utils.normalize_path(git_root) .. utils.path_separator + local prev_line = "" + local num_in_batch = 0 + local git_status = {} + if batch_size == 0 then + batch_size = nil + end + local yield_if_batch_completed = function() end + + if batch_size then + yield_if_batch_completed = function() + num_in_batch = num_in_batch + 1 + if num_in_batch > batch_size then + num_in_batch = 0 + coroutine.yield(git_status) + end + end + end + for line in status_iter do + -- Example status: + -- 1 D. N... 100644 000000 000000 ade2881afa1dcb156a3aa576024aa0fecf789191 0000000000000000000000000000000000000000 deleted_staged.txt + -- 1 .D N... 100644 100644 000000 9c13483e67ceff219800303ec7af39c4f0301a5b 9c13483e67ceff219800303ec7af39c4f0301a5b deleted_unstaged.txt + -- 1 MM N... 100644 100644 100644 4417f3aca512ffdf247662e2c611ee03ff9255cc 29c0e9846cd6410a44c4ca3fdaf5623818bd2838 modified_mixed.txt + -- 1 M. N... 100644 100644 100644 f784736eecdd43cd8eb665615163cfc6506fca5f 8d6fad5bd11ac45c7c9e62d4db1c427889ed515b modified_staged.txt + -- 1 .M N... 100644 100644 100644 c9e1e027aa9430cb4ffccccf45844286d10285c1 c9e1e027aa9430cb4ffccccf45844286d10285c1 modified_unstaged.txt + -- 1 A. N... 000000 100644 100644 0000000000000000000000000000000000000000 89cae60d74c222609086441e29985f959b6ec546 new_staged_file.txt + -- 2 R. N... 100644 100644 100644 3454a7dc6b93d1098e3c3f3ec369589412abdf99 3454a7dc6b93d1098e3c3f3ec369589412abdf99 R100 renamed_staged_new.txt + -- renamed_staged_old.txt + -- 1 .T N... 100644 100644 120000 192f10ed8c11efb70155e8eb4cae6ec677347623 192f10ed8c11efb70155e8eb4cae6ec677347623 type_change.txt + -- ? .gitignore + -- ? untracked.txt + + -- 1 + -- 2 + local t = line:sub(1, 1) + if t == "1" then + local XY = line:sub(3, 4) + -- local submodule_state = line:sub(6, 9) + -- local mH = line:sub(11, 16) + -- local mI = line:sub(18, 23) + -- local mW = line:sub(25, 30) + -- local hH = line:sub(32, 71) + -- local hI = line:sub(73, 112) + local path = line:sub(114) + git_status[git_root_dir .. path] = XY + elseif t == "2" then + -- local XY = line:sub(3, 4) + -- local submodule_state = line:sub(6, 9) + -- local mH = line:sub(11, 16) + -- local mI = line:sub(18, 23) + -- local mW = line:sub(25, 30) + -- local hH = line:sub(32, 71) + -- local hI = line:sub(73, 112) + local rest = line:sub(114) + local first_space = rest:find(" ", 1, true) + local Xscore = rest:sub(1, first_space - 1) + local path = rest:sub(first_space + 1) + git_status[git_root_dir .. path] = Xscore + -- ignore the original path + status_iter() + elseif t == "u" then + local XY = line:sub(3, 4) + -- local submodule_state = line:sub(6, 9) + -- local m1 = line:sub(11, 16) + -- local m2 = line:sub(18, 23) + -- local m3 = line:sub(25, 30) + -- local mW = line:sub(32, 37) + -- local h1 = line:sub(39, 78) + -- local h2 = line:sub(80, 119) + -- local h3 = line:sub(121, 160) + local path = line:sub(162) + git_status[git_root_dir .. path] = XY + else + prev_line = line + break + end + -- X Y Meaning + -- ------------------------------------------------- + -- [AMD] not updated + -- M [ MTD] updated in index + -- T [ MTD] type changed in index + -- A [ MTD] added to index + -- D deleted from index + -- R [ MTD] renamed in index + -- C [ MTD] copied in index + -- [MTARC] index and work tree matches + -- [ MTARC] M work tree changed since index + -- [ MTARC] T type changed in work tree since index + -- [ MTARC] D deleted in work tree + -- R renamed in work tree + -- C copied in work tree + -- ------------------------------------------------- + -- D D unmerged, both deleted + -- A U unmerged, added by us + -- U D unmerged, deleted by them + -- U A unmerged, added by them + -- D U unmerged, deleted by us + -- A A unmerged, both added + -- U U unmerged, both modified + yield_if_batch_completed() + end + + -- ------------------------------------------------- + -- ? ? untracked + -- ! ! ignored + -- ------------------------------------------------- + if prev_line:sub(1, 1) == "?" then + git_status[git_root_dir .. prev_line:sub(3)] = "?" + for line in status_iter do + if line:sub(1, 1) ~= "?" then + prev_line = line + break + end + git_status[git_root_dir .. line:sub(3)] = "?" + end + yield_if_batch_completed() + end + + if not skip_bubbling then + -- bubble up every status besides ignored + local status_prio = { "U", "?", "M", "A" } + + for dir, status in pairs(git_status) do + if status ~= "!" then + local s = status:sub(1, 1) + for parent in utils.path_parents(dir, true) do + if parent == git_root then + break + end + + local parent_status = git_status[parent] + if not parent_status then + git_status[parent] = status + else + -- Bubble up the most important status + local p = parent_status:sub(1, 1) + for _, c in ipairs(status_prio) do + if p == c then + break + end + if s == c then + git_status[parent] = c + end + end + end + end + end + yield_if_batch_completed() + end + end + if prev_line:sub(1, 1) == "!" then + git_status[git_root_dir .. prev_line:sub(3)] = "!" + for line in status_iter do + git_status[git_root_dir .. line:sub(3)] = "!" + end + yield_if_batch_completed() + end + + M.cache[git_root] = git_status + return git_status +end +---@alias neotree.git.Status table + +---Parse "git status" output for the current working directory. +---@param base string git ref base +---@param skip_bubbling boolean? Whether to skip bubling up status to directories +---@param path string? Path to run the git status command in, defaults to cwd. +---@return neotree.git.Status, string? git_status the neotree.Git.Status of the given root +M.status = function(base, skip_bubbling, path) + local git_root = M.get_repository_root(path) + if not utils.truthy(git_root) then + if git_root then + M.cache[git_root] = {} + end + return {} + end + ---@cast git_root -nil + + local status_cmd = { + "git", + "--no-optional-locks", + "-C", + git_root, + "status", + "--porcelain=v2", + "--untracked-files=normal", + "--ignored=traditional", + "-z", + } + local status_result = vim.fn.system(status_cmd) + + local status_ok = vim.v.shell_error == 0 + if not status_ok then + M.cache[git_root] = {} + return {} + end + + -- system() replaces \000 with \001 + local status_iter = vim.gsplit(status_result, "\001", { plain = true }) + local gs = parse_porcelain_output(git_root, status_iter, nil, skip_bubbling) + + local context = { + git_root = git_root, + git_status = gs, + lines_parsed = 0, + } + + vim.schedule(function() + events.fire_event(events.GIT_STATUS_CHANGED, { + git_root = context.git_root, + git_status = context.git_status, + }) + end) + + return context.git_status, git_root +end + +---@param path string path to run commands in +---@param base string git ref base +---@param opts neotree.Config.GitStatusAsync +M.status_async = function(path, base, opts) + M.get_repository_root(path, function(git_root) + if not utils.truthy(git_root) then + if git_root then + M.cache[git_root] = {} + end + log.trace("status_async: not a git folder:", path) + return + end + ---@cast git_root -false + + log.trace("git.status.status_async called") + + local event_id = "git_status_" .. git_root + ---@type neotree.git.Context + local context = { + git_root = git_root, + git_status = {}, + lines = {}, + lines_parsed = 0, + batch_size = opts.batch_size or 1000, + batch_delay = opts.batch_delay or 10, + max_lines = opts.max_lines or 100000, + } + + utils.debounce(event_id, function() + local stdin = log.assert(uv.new_pipe()) + local stdout = log.assert(uv.new_pipe()) + local stderr = log.assert(uv.new_pipe()) + + local output_chunks = {} + log.trace("spawning git") + ---@diagnostic disable-next-line: missing-fields + uv.spawn("git", { + hide = true, + args = { + "--no-optional-locks", + "-C", + git_root, + "status", + "--porcelain=v2", + "--untracked-files=normal", + "--ignored=traditional", + "-z", + }, + stdio = { stdin, stdout, stderr }, + }, function(code, signal) + log.assert( + code == 0, + "git status async process exited abnormally, code: %s, signal: %s", + code, + signal + ) + local str = output_chunks[1] + if #output_chunks > 1 then + str = table.concat(output_chunks, "") + end + assert(str) + local status_iter = vim.gsplit(str, "\000", { plain = true }) + local parsing_task = co.create(parse_porcelain_output) + local _, git_status = + log.assert(co.resume(parsing_task, git_root, status_iter, context.batch_size)) + + local do_next_batch_later + do_next_batch_later = function() + if co.status(parsing_task) ~= "dead" then + _, git_status = log.assert(co.resume(parsing_task)) + vim.defer_fn(do_next_batch_later, context.batch_delay) + return + end + context.git_status = git_status + M.cache[git_root] = git_status + vim.schedule(function() + events.fire_event(events.GIT_STATUS_CHANGED, { + git_root = context.git_root, + git_status = context.git_status, + }) + end) + end + do_next_batch_later() + end) + + stdout:read_start(function(err, data) + log.assert(not err, err) + -- for some reason data can be a table here? + if type(data) == "string" then + table.insert(output_chunks, data) + end + end) + + stderr:read_start(function(err, data) + if err then + local errfmt = (err or "") .. "%s" + log.at.error.format(errfmt, data) + end + end) + end, 1000, utils.debounce_strategy.CALL_FIRST_AND_LAST, utils.debounce_action.START_ASYNC_JOB) + end) +end + +M.is_ignored = function(path) + local git_root = M.get_repository_root(path) + if not git_root then + return false + end + local direct_lookup = M.cache[git_root][path] or M.cache[git_root][path .. utils.path_separator] + if direct_lookup then + vim.print(direct_lookup, path) + return direct_lookup == "!" + end +end + +---@type table +local git_rootdir_cache = setmetatable({}, { __mode = "kv" }) +---@param path string? Defaults to cwd +---@param callback fun(git_root: string|false?)? +---@return string? +M.get_repository_root = function(path, callback) + path = path or log.assert(vim.uv.cwd()) + + do -- direct lookup in cache + local cached_rootdir = git_rootdir_cache[path] + if cached_rootdir ~= nil then + log.trace("git.get_repository_root: cache hit for", path, "was", cached_rootdir) + if callback then + callback(cached_rootdir) + return + end + return cached_rootdir + end + end + + do -- check parents in cache + for parent in utils.path_parents(path, true) do + local cached_parent_entry = git_rootdir_cache[parent] + if cached_parent_entry ~= nil then + log.trace( + "git.get_repository_root: cache hit for parent of", + path, + ",", + parent, + "was", + cached_parent_entry + ) + git_rootdir_cache[path] = cached_parent_entry + return cached_parent_entry + end + end + end + + log.trace("git.get_repository_root: cache miss for", path) + local args = { "-C", path, "rev-parse", "--show-toplevel" } + + if type(callback) == "function" then + ---@diagnostic disable-next-line: missing-fields + Job:new({ + command = "git", + args = args, + enabled_recording = true, + on_exit = function(self, code, _) + if code ~= 0 then + log.trace("GIT ROOT ERROR", self:stderr_result()) + git_rootdir_cache[path] = false + callback(nil) + return + end + local git_root = self:result()[1] + + if utils.is_windows then + git_root = utils.windowize_path(git_root) + end + + log.trace("GIT ROOT for '", path, "' is '", git_root, "'") + git_rootdir_cache[path] = git_root + git_rootdir_cache[git_root] = git_root + callback(git_root) + end, + }):start() + return + end + + local ok, git_output = utils.execute_command({ "git", unpack(args) }) + if not ok then + log.trace("GIT ROOT ERROR", git_output) + git_rootdir_cache[path] = false + return nil + end + local git_root = git_output[1] + + if utils.is_windows then + git_root = utils.windowize_path(git_root) + end + + log.trace("GIT ROOT for '", path, "' is '", git_root, "'") + git_rootdir_cache[path] = path + git_rootdir_cache[git_root] = git_root + return git_root +end return M diff --git a/lua/neo-tree/git/status.lua b/lua/neo-tree/git/status.lua deleted file mode 100644 index 4eee8053..00000000 --- a/lua/neo-tree/git/status.lua +++ /dev/null @@ -1,518 +0,0 @@ -local utils = require("neo-tree.utils") -local events = require("neo-tree.events") -local log = require("neo-tree.log") -local git_utils = require("neo-tree.git.utils") -local uv = vim.uv or vim.loop -local co = coroutine - -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 - ----@class (exact) neotree.git.Context : neotree.Config.GitStatusAsync ----@field git_status neotree.git.Status ----@field git_root string ----@field lines_parsed integer - ----@param git_root string ----@param status_iter fun():string? ----@param batch_size integer? This will use coroutine.yield if provided. ----@param skip_bubbling boolean? ----@return neotree.git.Status -local parse_porcelain_output = function(git_root, status_iter, batch_size, skip_bubbling) - local git_root_dir = utils.normalize_path(git_root) .. utils.path_separator - local prev_line = "" - local num_in_batch = 0 - local git_status = {} - if batch_size == 0 then - batch_size = nil - end - local yield_if_batch_completed = function() end - - if batch_size then - yield_if_batch_completed = function() - num_in_batch = num_in_batch + 1 - if num_in_batch > batch_size then - num_in_batch = 0 - coroutine.yield(git_status) - end - end - end - for line in status_iter do - -- Example status: - -- 1 D. N... 100644 000000 000000 ade2881afa1dcb156a3aa576024aa0fecf789191 0000000000000000000000000000000000000000 deleted_staged.txt - -- 1 .D N... 100644 100644 000000 9c13483e67ceff219800303ec7af39c4f0301a5b 9c13483e67ceff219800303ec7af39c4f0301a5b deleted_unstaged.txt - -- 1 MM N... 100644 100644 100644 4417f3aca512ffdf247662e2c611ee03ff9255cc 29c0e9846cd6410a44c4ca3fdaf5623818bd2838 modified_mixed.txt - -- 1 M. N... 100644 100644 100644 f784736eecdd43cd8eb665615163cfc6506fca5f 8d6fad5bd11ac45c7c9e62d4db1c427889ed515b modified_staged.txt - -- 1 .M N... 100644 100644 100644 c9e1e027aa9430cb4ffccccf45844286d10285c1 c9e1e027aa9430cb4ffccccf45844286d10285c1 modified_unstaged.txt - -- 1 A. N... 000000 100644 100644 0000000000000000000000000000000000000000 89cae60d74c222609086441e29985f959b6ec546 new_staged_file.txt - -- 2 R. N... 100644 100644 100644 3454a7dc6b93d1098e3c3f3ec369589412abdf99 3454a7dc6b93d1098e3c3f3ec369589412abdf99 R100 renamed_staged_new.txt - -- renamed_staged_old.txt - -- 1 .T N... 100644 100644 120000 192f10ed8c11efb70155e8eb4cae6ec677347623 192f10ed8c11efb70155e8eb4cae6ec677347623 type_change.txt - -- ? .gitignore - -- ? untracked.txt - - -- 1 - -- 2 - local t = line:sub(1, 1) - if t == "1" then - local XY = line:sub(3, 4) - -- local submodule_state = line:sub(6, 9) - -- local mH = line:sub(11, 16) - -- local mI = line:sub(18, 23) - -- local mW = line:sub(25, 30) - -- local hH = line:sub(32, 71) - -- local hI = line:sub(73, 112) - local path = line:sub(114) - git_status[git_root_dir .. path] = XY - elseif t == "2" then - -- local XY = line:sub(3, 4) - -- local submodule_state = line:sub(6, 9) - -- local mH = line:sub(11, 16) - -- local mI = line:sub(18, 23) - -- local mW = line:sub(25, 30) - -- local hH = line:sub(32, 71) - -- local hI = line:sub(73, 112) - local rest = line:sub(114) - local first_space = rest:find(" ", 1, true) - local Xscore = rest:sub(1, first_space - 1) - local path = rest:sub(first_space + 1) - git_status[git_root_dir .. path] = Xscore - -- ignore the original path - status_iter() - elseif t == "u" then - local XY = line:sub(3, 4) - -- local submodule_state = line:sub(6, 9) - -- local m1 = line:sub(11, 16) - -- local m2 = line:sub(18, 23) - -- local m3 = line:sub(25, 30) - -- local mW = line:sub(32, 37) - -- local h1 = line:sub(39, 78) - -- local h2 = line:sub(80, 119) - -- local h3 = line:sub(121, 160) - local path = line:sub(162) - git_status[git_root_dir .. path] = XY - else - prev_line = line - break - end - -- X Y Meaning - -- ------------------------------------------------- - -- [AMD] not updated - -- M [ MTD] updated in index - -- T [ MTD] type changed in index - -- A [ MTD] added to index - -- D deleted from index - -- R [ MTD] renamed in index - -- C [ MTD] copied in index - -- [MTARC] index and work tree matches - -- [ MTARC] M work tree changed since index - -- [ MTARC] T type changed in work tree since index - -- [ MTARC] D deleted in work tree - -- R renamed in work tree - -- C copied in work tree - -- ------------------------------------------------- - -- D D unmerged, both deleted - -- A U unmerged, added by us - -- U D unmerged, deleted by them - -- U A unmerged, added by them - -- D U unmerged, deleted by us - -- A A unmerged, both added - -- U U unmerged, both modified - yield_if_batch_completed() - end - - -- ------------------------------------------------- - -- ? ? untracked - -- ! ! ignored - -- ------------------------------------------------- - if prev_line:sub(1, 1) == "?" then - git_status[git_root_dir .. prev_line:sub(3)] = "?" - for line in status_iter do - if line:sub(1, 1) ~= "?" then - prev_line = line - break - end - git_status[git_root_dir .. line:sub(3)] = "?" - end - yield_if_batch_completed() - end - - if not skip_bubbling then - -- bubble up every status besides ignored - local status_prio = { "U", "?", "M", "A" } - - for dir, status in pairs(git_status) do - if status ~= "!" then - local s = status:sub(1, 1) - for parent in utils.path_parents(dir, true) do - if parent == git_root then - break - end - - local parent_status = git_status[parent] - if not parent_status then - git_status[parent] = status - else - -- Bubble up the most important status - local p = parent_status:sub(1, 1) - for _, c in ipairs(status_prio) do - if p == c then - break - end - if s == c then - git_status[parent] = c - end - end - end - end - end - yield_if_batch_completed() - end - end - if prev_line:sub(1, 1) == "!" then - git_status[git_root_dir .. prev_line:sub(3)] = "!" - for line in status_iter do - git_status[git_root_dir .. line:sub(3)] = "!" - end - yield_if_batch_completed() - end - - return git_status -end ----@alias neotree.git.Status table - ----Parse "git status" output for the current working directory. ----@param base string git ref base ----@param skip_bubbling boolean? Whether to skip bubling up status to directories ----@param path string? Path to run the git status command in, defaults to cwd. ----@return neotree.git.Status, string? git_status the neotree.Git.Status of the given root -M.status = function(base, skip_bubbling, path) - local git_root = git_utils.get_repository_root(path) - if not utils.truthy(git_root) then - return {} - end - - local status_cmd = { - "git", - "--no-optional-locks", - "-C", - git_root, - "status", - "--porcelain=v2", - "--untracked-files=normal", - "--ignored=traditional", - "-z", - } - local status_result = vim.fn.system(status_cmd) - - local status_ok = vim.v.shell_error == 0 - if not status_ok then - return {} - end - - -- system() replaces \000 with \001 - local status_iter = vim.gsplit(status_result, "\001", { plain = true }) - local gs = parse_porcelain_output(git_root, status_iter, nil, skip_bubbling) - - local context = { - git_root = git_root, - git_status = gs, - lines_parsed = 0, - } - - vim.schedule(function() - events.fire_event(events.GIT_STATUS_CHANGED, { - git_root = context.git_root, - git_status = context.git_status, - }) - end) - - return context.git_status, git_root -end --- --- local function parse_lines_batch(context, job_complete_callback) --- local i, batch_size = 0, context.batch_size --- --- if context.lines_total == nil then --- -- first time through, get the total number of lines --- context.lines_total = math.min(context.max_lines, #context.lines) --- context.lines_parsed = 0 --- if context.lines_total == 0 then --- if type(job_complete_callback) == "function" then --- job_complete_callback() --- end --- return --- end --- end --- batch_size = math.min(context.batch_size, context.lines_total - context.lines_parsed) --- --- while i < batch_size do --- i = i + 1 --- parse_git_status_line(context, context.lines[context.lines_parsed + 1]) --- end --- --- if context.lines_parsed >= context.lines_total then --- if type(job_complete_callback) == "function" then --- job_complete_callback() --- end --- else --- -- add small delay so other work can happen --- vim.defer_fn(function() --- parse_lines_batch(context, job_complete_callback) --- end, context.batch_delay) --- end --- end - ----@param path string path to run commands in ----@param base string git ref base ----@param opts neotree.Config.GitStatusAsync -M.status_async = function(path, base, opts) - git_utils.get_repository_root(path, function(git_root) - if not utils.truthy(git_root) then - log.trace("status_async: not a git folder:", path) - return - end - - log.trace("git.status.status_async called") - - local event_id = "git_status_" .. git_root - ---@type neotree.git.Context - local context = { - git_root = git_root, - git_status = {}, - lines = {}, - lines_parsed = 0, - batch_size = opts.batch_size or 1000, - batch_delay = opts.batch_delay or 10, - max_lines = opts.max_lines or 100000, - } - - utils.debounce(event_id, function() - -- ---@diagnostic disable-next-line: missing-fields - -- local staged_job = Job:new({ - -- command = "git", - -- args = { "-C", git_root, "diff", "--staged", "--name-status", base, "--" }, - -- enable_recording = false, - -- maximium_results = context.max_lines, - -- on_stdout = function(err, line, job) - -- table.insert(context.lines, line) - -- end, - -- on_stderr = function(err, line) - -- if err and err > 0 then - -- log.error("status_async staged error: ", err, line) - -- end - -- end, - -- }) - -- - -- ---@diagnostic disable-next-line: missing-fields - -- local unstaged_job = Job:new({ - -- command = "git", - -- args = { "-C", git_root, "diff", "--name-status" }, - -- enable_recording = false, - -- maximium_results = context.max_lines, - -- on_stdout = function(err, line, job) - -- if should_process(err, line, job, "status_async unstaged error:") then - -- if line then - -- line = " " .. line - -- end - -- table.insert(context.lines, line) - -- end - -- end, - -- on_stderr = function(err, line) - -- if err and err > 0 then - -- log.error("status_async unstaged error: ", err, line) - -- end - -- end, - -- }) - -- - -- ---@diagnostic disable-next-line: missing-fields - -- local untracked_job = Job:new({ - -- command = "git", - -- args = { "-C", git_root, "ls-files", "--exclude-standard", "--others" }, - -- enable_recording = false, - -- maximium_results = context.max_lines, - -- on_stdout = function(err, line, job) - -- if should_process(err, line, job, "status_async untracked error:") then - -- if line then - -- line = "? " .. line - -- end - -- table.insert(context.lines, line) - -- end - -- end, - -- on_stderr = function(err, line) - -- if err and err > 0 then - -- log.error("status_async untracked error: ", err, line) - -- end - -- end, - -- }) - -- - -- ---@diagnostic disable-next-line: missing-fields - -- Job:new({ - -- command = "git", - -- args = { - -- "-C", - -- git_root, - -- "config", - -- "--get", - -- "status.showUntrackedFiles", - -- }, - -- enabled_recording = true, - -- on_exit = function(self, _, _) - -- local result = self:result() - -- log.debug("git status.showUntrackedFiles =", result[1]) - -- if result[1] == "no" then - -- unstaged_job:after(parse_lines) - -- Job.chain(staged_job, unstaged_job) - -- else - -- untracked_job:after(parse_lines) - -- Job.chain(staged_job, unstaged_job, untracked_job) - -- end - -- end, - -- }):start() - -- - -- Job:new({ - -- command = "git", - -- args = { - -- "-C", - -- git_root, - -- "status", - -- "--porcelain=v2", - -- "--untracked-files=normal", - -- "--ignored=traditional", - -- "-z", - -- }, - -- on_stdout = function(err, line, job) - -- if vim.v.dying > 0 or vim.v.exiting ~= vim.NIL then - -- job:shutdown() - -- return - -- end - -- if err and err > 0 then - -- log.error("status_async error:", err, line) - -- return - -- end - -- table.insert(context.lines, line) - -- end, - -- on_stderr = function(err, line) - -- if err and err > 0 then - -- log.error("status_async untracked error: ", err, line) - -- end - -- end, - -- on_exit = function(self) - -- local result = self:result() - -- end, - -- }):start() - local stdin = log.assert(uv.new_pipe()) - local stdout = log.assert(uv.new_pipe()) - local stderr = log.assert(uv.new_pipe()) - ---@diagnostic disable-next-line: missing-fields - - local output_chunks = {} - ---@diagnostic disable-next-line: missing-fields - local handle = uv.spawn("git", { - hide = true, - args = { - "--no-optional-locks", - "-C", - git_root, - "status", - "--porcelain=v2", - "--untracked-files=normal", - "--ignored=traditional", - "-z", - }, - stdio = { stdin, stdout, stderr }, - }, function(code, signal) - log.assert( - code == 0, - "git status async process exited abnormally, code: %s, signal: %s", - code, - signal - ) - local str = output_chunks[1] - if #output_chunks > 1 then - str = table.concat(output_chunks, "") - end - local status_iter = vim.gsplit(str, "\000", { plain = true }) - local parsing_task = co.create(parse_porcelain_output) - local _, git_status = - log.assert(co.resume(parsing_task, git_root, status_iter, context.batch_size)) - - local do_next_batch_later - do_next_batch_later = function() - if co.status(parsing_task) ~= "dead" then - _, git_status = log.assert(co.resume(parsing_task)) - vim.defer_fn(do_next_batch_later, 500) - return - end - context.git_status = git_status - vim.schedule(function() - events.fire_event(events.GIT_STATUS_CHANGED, { - git_root = context.git_root, - git_status = context.git_status, - }) - end) - end - do_next_batch_later() - end) - - stdout:read_start(function(err, data) - log.assert(not err, err) - -- for some reason data can be a table here? - if type(data) == "string" then - table.insert(output_chunks, data) - end - end) - - stderr:read_start(function(err, data) - if err then - local errfmt = (err or "") .. "%s" - log.at.error.format(errfmt, data) - end - end) - end, 1000, utils.debounce_strategy.CALL_FIRST_AND_LAST, utils.debounce_action.START_ASYNC_JOB) - end) -end - -return M diff --git a/lua/neo-tree/git/utils.lua b/lua/neo-tree/git/utils.lua index 18770ae6..e69de29b 100644 --- a/lua/neo-tree/git/utils.lua +++ b/lua/neo-tree/git/utils.lua @@ -1,69 +0,0 @@ -local Job = require("plenary.job") - -local utils = require("neo-tree.utils") -local log = require("neo-tree.log") - -local M = {} - ----@param path string? Defaults to cwd ----@param callback fun(git_root: string)? ----@return string? -M.get_repository_root = function(path, callback) - local args = { "rev-parse", "--show-toplevel" } - if utils.truthy(path) then - args = { "-C", path, "rev-parse", "--show-toplevel" } - end - if type(callback) == "function" then - ---@diagnostic disable-next-line: missing-fields - Job:new({ - command = "git", - args = args, - enabled_recording = true, - on_exit = function(self, code, _) - if code ~= 0 then - log.trace("GIT ROOT ERROR", self:stderr_result()) - callback(nil) - return - end - local git_root = self:result()[1] - - if utils.is_windows then - git_root = utils.windowize_path(git_root) - end - - log.trace("GIT ROOT for '", path, "' is '", git_root, "'") - callback(git_root) - end, - }):start() - else - local ok, git_output = utils.execute_command({ "git", unpack(args) }) - if not ok then - log.trace("GIT ROOT ERROR", git_output) - return nil - end - local git_root = git_output[1] - - if utils.is_windows then - git_root = utils.windowize_path(git_root) - end - - log.trace("GIT ROOT for '", path, "' is '", git_root, "'") - return git_root - end -end - -local convert_octal_char = function(octal) - return string.char(tonumber(octal, 8)) -end - -M.octal_to_utf8 = function(text) - -- git uses octal encoding for utf-8 filepaths, convert octal back to utf-8 - local success, converted = pcall(string.gsub, text, "\\([0-7][0-7][0-7])", convert_octal_char) - if success then - return converted - else - return text - end -end - -return M diff --git a/lua/neo-tree/sources/common/commands.lua b/lua/neo-tree/sources/common/commands.lua index 48c95120..f6e5b043 100644 --- a/lua/neo-tree/sources/common/commands.lua +++ b/lua/neo-tree/sources/common/commands.lua @@ -533,11 +533,7 @@ M.order_by_git_status = function(state) return git_status end - if node.filtered_by and node.filtered_by.gitignored then - return "!!" - else - return "" - end + return "" end require("neo-tree.sources.manager").refresh(state.name) diff --git a/lua/neo-tree/sources/common/components.lua b/lua/neo-tree/sources/common/components.lua index 335484bd..96909e00 100644 --- a/lua/neo-tree/sources/common/components.lua +++ b/lua/neo-tree/sources/common/components.lua @@ -353,7 +353,7 @@ M.filtered_by = function(_, node, state) text = "(hide by pattern)", highlight = highlights.HIDDEN_BY_NAME, } - elseif fby.gitignored then + elseif require("neo-tree.git").is_ignored(node.path) then return { text = "(gitignored)", highlight = highlights.GIT_IGNORED, diff --git a/lua/neo-tree/sources/common/file-items.lua b/lua/neo-tree/sources/common/file-items.lua index 7b7cce44..0ed469eb 100644 --- a/lua/neo-tree/sources/common/file-items.lua +++ b/lua/neo-tree/sources/common/file-items.lua @@ -234,41 +234,37 @@ local function create_item(context, path, _type, bufnr) local f = state.filtered_items local is_not_root = not utils.is_subpath(path, context.state.path) if f and is_not_root then + local fby = item.filtered_by or {} if f.never_show[name] then - item.filtered_by = item.filtered_by or {} - item.filtered_by.never_show = true + fby.never_show = true else if utils.is_filtered_by_pattern(f.never_show_by_pattern, path, name) then - item.filtered_by = item.filtered_by or {} - item.filtered_by.never_show = true + fby.never_show = true end end if f.always_show[name] then - item.filtered_by = item.filtered_by or {} - item.filtered_by.always_show = true + fby.always_show = true else if utils.is_filtered_by_pattern(f.always_show_by_pattern, path, name) then - item.filtered_by = item.filtered_by or {} - item.filtered_by.always_show = true + fby.always_show = true end end if f.hide_by_name[name] then - item.filtered_by = item.filtered_by or {} - item.filtered_by.name = true + fby.name = true end if utils.is_filtered_by_pattern(f.hide_by_pattern, path, name) then - item.filtered_by = item.filtered_by or {} - item.filtered_by.pattern = true + fby.pattern = true end if f.hide_dotfiles and string.sub(name, 1, 1) == "." then - item.filtered_by = item.filtered_by or {} - item.filtered_by.dotfiles = true + fby.dotfiles = true end if f.hide_hidden and utils.is_hidden(path) then - item.filtered_by = item.filtered_by or {} - item.filtered_by.hidden = true + fby.hidden = true + end + + if not vim.tbl_isempty(fby) then + item.filtered_by = fby end - -- NOTE: git_ignored logic moved to job_complete end set_parents(context, item) diff --git a/lua/neo-tree/sources/filesystem/commands.lua b/lua/neo-tree/sources/filesystem/commands.lua index 6a036bd7..33d794d9 100644 --- a/lua/neo-tree/sources/filesystem/commands.lua +++ b/lua/neo-tree/sources/filesystem/commands.lua @@ -154,7 +154,7 @@ local focus_next_git_modified = function(state, reverse) ---@cast g -nil local paths = { current_path } for path, status in pairs(g) do - if path ~= current_path and not vim.tbl_contains({ "!!", "?" }, status) then + if path ~= current_path and not vim.tbl_contains({ "!", "?" }, status) then --don't include files not in the current working directory if utils.is_subpath(state.path, path) then table.insert(paths, path) diff --git a/lua/neo-tree/sources/filesystem/lib/fs_scan.lua b/lua/neo-tree/sources/filesystem/lib/fs_scan.lua index ec96d434..0d3b83dc 100644 --- a/lua/neo-tree/sources/filesystem/lib/fs_scan.lua +++ b/lua/neo-tree/sources/filesystem/lib/fs_scan.lua @@ -114,90 +114,13 @@ local render_context = function(context) context = nil end ----@param context neotree.sources.filesystem.Context -local should_check_gitignore = function(context) - local state = context.state - if #context.all_items == 0 then - log.debug("No items, skipping git ignored/status lookups") - return false - end - if state.search_pattern and state.check_gitignore_in_search == false then - return false - end - if state.filtered_items.hide_gitignored then - return true - end - if state.enable_git_status == false then - return false - end - return true -end - ----@param context neotree.sources.filesystem.Context -local job_complete_async = function(context) - local state = context.state - local parent_id = context.parent_id - - file_nesting.nest_items(context) - - -- if state.search_pattern and #context.all_items > 50 then - -- -- don't do git ignored/status lookups when searching unless we are down to a reasonable number of items - -- return context - -- end - if should_check_gitignore(context) then - local mark_ignored_async = async.wrap(function(_state, _all_items, _callback) - ---lua-ls can't narrow this properly - ---@diagnostic disable-next-line: redundant-parameter - git.mark_ignored(_state, _all_items, _callback) - end, 3) - local all_items = mark_ignored_async(state, context.all_items) - - if parent_id then - vim.list_extend(state.git_ignored, all_items) - else - state.git_ignored = all_items - end - end - - ignored.mark_ignored(state, context.all_items) - return context -end - ---@param context neotree.sources.filesystem.Context local job_complete = function(context) local state = context.state - local parent_id = context.parent_id - file_nesting.nest_items(context) - ignored.mark_ignored(state, context.all_items) - if should_check_gitignore(context) then - if require("neo-tree").config.git_status_async then - ---lua-ls can't narrow this properly - ---@diagnostic disable-next-line: redundant-parameter - git.mark_ignored(state, context.all_items, function(all_items) - if parent_id then - vim.list_extend(state.git_ignored, all_items) - else - state.git_ignored = all_items - end - vim.schedule(function() - render_context(context) - end) - end) - return - else - local all_items = git.mark_ignored(state, context.all_items) - if parent_id then - vim.list_extend(state.git_ignored, all_items) - else - state.git_ignored = all_items - end - end - render_context(context) - else - render_context(context) - end + render_context(context) + return context end local function create_node(context, node) @@ -708,7 +631,7 @@ M.get_dir_items_async = function(state, parent_id, recursive) end async.util.join(scan_tasks) - job_complete_async(context) + job_complete(context) local finalize = async.wrap(function(_context, _callback) vim.schedule(function() diff --git a/lua/neo-tree/sources/filesystem/lib/fs_watch.lua b/lua/neo-tree/sources/filesystem/lib/fs_watch.lua index 2d9bbf90..2064e065 100644 --- a/lua/neo-tree/sources/filesystem/lib/fs_watch.lua +++ b/lua/neo-tree/sources/filesystem/lib/fs_watch.lua @@ -164,6 +164,21 @@ M.unwatch_git_index = function(path, async) end end +---@generic P : string +---@param path P +---@param callback fun(path: P) +M.on_destroyed = function(path, callback) + local ev = log.assert(uv.new_fs_event()) + ev:start(path, flags, function(err, path, events) + if events.change then + if not uv.fs_stat(path) then + callback(path) + ev:close() + end + end + end) +end + ---Stop watching all directories. This is the nuclear option and it affects all ---sources. M.unwatch_all = function() diff --git a/lua/neo-tree/sources/filesystem/lib/ignored.lua b/lua/neo-tree/sources/filesystem/lib/ignored.lua index e1bed0b5..3c26afed 100644 --- a/lua/neo-tree/sources/filesystem/lib/ignored.lua +++ b/lua/neo-tree/sources/filesystem/lib/ignored.lua @@ -43,9 +43,8 @@ end ---@param items neotree.FileItem[] ---@return string[] results M.mark_ignored = function(state, items) - local config = require("neo-tree").config - local ignore_files = config.filesystem.filtered_items.ignore_files - if not ignore_files or vim.tbl_isempty(config.filesystem.filtered_items.ignore_files) then + local ignore_files = state.filtered_items.ignore_files + if not ignore_files or vim.tbl_isempty(ignore_files) then return {} end ---@type table diff --git a/lua/neo-tree/utils/init.lua b/lua/neo-tree/utils/init.lua index 7026afca..c9f3bea9 100644 --- a/lua/neo-tree/utils/init.lua +++ b/lua/neo-tree/utils/init.lua @@ -899,7 +899,7 @@ M.normalize_path = function(path) return path end ----Check if a path is a subpath of another. +---Check if a path is a subpath of another. In other words, whether the path starts with the base path. ---@param base string The base path. ---@param path string The path to check is a subpath. ---@return boolean path_is_subpath True if it is a subpath, false otherwise. @@ -915,10 +915,10 @@ M.is_subpath = function(base, path) base = M.normalize_path(base) path = M.normalize_path(path) if path:sub(1, #base) == base then - local base_parts = M.split(base, M.path_separator) - local path_parts = M.split(path, M.path_separator) - for i, base_part in ipairs(base_parts) do - if path_parts[i] ~= base_part then + local base_parts = M.split(base:sub(#base), M.path_separator) + local path_parts = M.split(path:sub(#base), M.path_separator) + for i, remaining_parts in ipairs(base_parts) do + if path_parts[i] ~= remaining_parts then return false end end @@ -1611,7 +1611,7 @@ local slice = vim.fn.exists("*slice") == 1 and vim.fn.slice -- Function below provided by @akinsho, modified by @pynappo -- https://github.com/nvim-neo-tree/neo-tree.nvim/pull/427#discussion_r924947766 --- TODO: maybe use vim.stf_utf* functions instead of strchars, once neovim updates enough +-- TODO: maybe use vim.str_utf* functions instead of strchars, once neovim updates enough -- Truncate a string based on number of display columns/cells it occupies -- so that multibyte characters are not broken up mid-character From d45083d0041cf0620cd6d81b1a6adb8a2523401f Mon Sep 17 00:00:00 2001 From: pynappo Date: Fri, 10 Oct 2025 17:44:29 -0700 Subject: [PATCH 09/13] fix gitignore --- lua/neo-tree/git/init.lua | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lua/neo-tree/git/init.lua b/lua/neo-tree/git/init.lua index 4e5c485d..0f9d1096 100644 --- a/lua/neo-tree/git/init.lua +++ b/lua/neo-tree/git/init.lua @@ -156,6 +156,7 @@ local parse_porcelain_output = function(git_root, status_iter, batch_size, skip_ local s = status:sub(1, 1) for parent in utils.path_parents(dir, true) do if parent == git_root then + -- bubble only up to the children of the git root break end @@ -354,6 +355,9 @@ M.is_ignored = function(path) if not git_root then return false end + if not M.cache[git_root] then + M.status("HEAD", false, path) + end local direct_lookup = M.cache[git_root][path] or M.cache[git_root][path .. utils.path_separator] if direct_lookup then vim.print(direct_lookup, path) From 84d716398dd6232ebb122940d04db20c2b5337e9 Mon Sep 17 00:00:00 2001 From: pynappo Date: Sun, 12 Oct 2025 01:14:06 -0700 Subject: [PATCH 10/13] more gitignore refactoring --- lua/neo-tree/git/init.lua | 213 +++++++++++---------- lua/neo-tree/sources/common/components.lua | 2 +- 2 files changed, 110 insertions(+), 105 deletions(-) diff --git a/lua/neo-tree/git/init.lua b/lua/neo-tree/git/init.lua index 0f9d1096..e0d9f578 100644 --- a/lua/neo-tree/git/init.lua +++ b/lua/neo-tree/git/init.lua @@ -8,18 +8,18 @@ local co = coroutine local M = {} ---@type table -M.cache = setmetatable({}, { - __mode = "kv", +M.status_cache = setmetatable({}, { + __mode = "v", __newindex = function(_, root_dir, status) require("neo-tree.sources.filesystem.lib.fs_watch").on_destroyed(root_dir, function() - rawset(M.cache, root_dir, nil) + rawset(M.status_cache, root_dir, nil) end) - rawset(M.cache, root_dir, status) + rawset(M.status_cache, root_dir, status) end, }) ---@class (exact) neotree.git.Context : neotree.Config.GitStatusAsync ----@field git_status neotree.git.Status +---@field git_status neotree.git.Status? ---@field git_root string ---@field lines_parsed integer @@ -188,7 +188,7 @@ local parse_porcelain_output = function(git_root, status_iter, batch_size, skip_ yield_if_batch_completed() end - M.cache[git_root] = git_status + M.status_cache[git_root] = git_status return git_status end ---@alias neotree.git.Status table @@ -197,14 +197,11 @@ end ---@param base string git ref base ---@param skip_bubbling boolean? Whether to skip bubling up status to directories ---@param path string? Path to run the git status command in, defaults to cwd. ----@return neotree.git.Status, string? git_status the neotree.Git.Status of the given root +---@return neotree.git.Status?, string? git_status the neotree.Git.Status of the given root, if there's a valid git status there M.status = function(base, skip_bubbling, path) local git_root = M.get_repository_root(path) if not utils.truthy(git_root) then - if git_root then - M.cache[git_root] = {} - end - return {} + return nil end ---@cast git_root -nil @@ -222,27 +219,27 @@ M.status = function(base, skip_bubbling, path) local status_result = vim.fn.system(status_cmd) local status_ok = vim.v.shell_error == 0 - if not status_ok then - M.cache[git_root] = {} - return {} - end - - -- system() replaces \000 with \001 - local status_iter = vim.gsplit(status_result, "\001", { plain = true }) - local gs = parse_porcelain_output(git_root, status_iter, nil, skip_bubbling) - + ---@type neotree.git.Context local context = { git_root = git_root, - git_status = gs, + git_status = nil, lines_parsed = 0, } - vim.schedule(function() - events.fire_event(events.GIT_STATUS_CHANGED, { - git_root = context.git_root, - git_status = context.git_status, - }) - end) + if status_ok then + -- system() replaces \000 with \001 + local status_iter = vim.gsplit(status_result, "\001", { plain = true }) + local gs = parse_porcelain_output(git_root, status_iter, nil, skip_bubbling) + + context.git_status = gs + vim.schedule(function() + events.fire_event(events.GIT_STATUS_CHANGED, { + git_root = context.git_root, + git_status = context.git_status, + }) + end) + M.status_cache[git_root] = {} + end return context.git_status, git_root end @@ -252,10 +249,7 @@ end ---@param opts neotree.Config.GitStatusAsync M.status_async = function(path, base, opts) M.get_repository_root(path, function(git_root) - if not utils.truthy(git_root) then - if git_root then - M.cache[git_root] = {} - end + if not git_root then log.trace("status_async: not a git folder:", path) return end @@ -297,22 +291,27 @@ M.status_async = function(path, base, opts) }, stdio = { stdin, stdout, stderr }, }, function(code, signal) - log.assert( - code == 0, - "git status async process exited abnormally, code: %s, signal: %s", - code, - signal - ) + if code ~= 0 then + log.at.debug.format( + "git status async process exited abnormally, code: %s, signal: %s", + code, + signal + ) + return + end local str = output_chunks[1] if #output_chunks > 1 then str = table.concat(output_chunks, "") end - assert(str) local status_iter = vim.gsplit(str, "\000", { plain = true }) local parsing_task = co.create(parse_porcelain_output) local _, git_status = log.assert(co.resume(parsing_task, git_root, status_iter, context.batch_size)) + stdin:shutdown() + stdout:shutdown() + stderr:shutdown() + local do_next_batch_later do_next_batch_later = function() if co.status(parsing_task) ~= "dead" then @@ -321,7 +320,7 @@ M.status_async = function(path, base, opts) return end context.git_status = git_status - M.cache[git_root] = git_status + M.status_cache[git_root] = git_status vim.schedule(function() events.fire_event(events.GIT_STATUS_CHANGED, { git_root = context.git_root, @@ -350,30 +349,50 @@ M.status_async = function(path, base, opts) end) end -M.is_ignored = function(path) - local git_root = M.get_repository_root(path) - if not git_root then - return false - end - if not M.cache[git_root] then - M.status("HEAD", false, path) - end - local direct_lookup = M.cache[git_root][path] or M.cache[git_root][path .. utils.path_separator] - if direct_lookup then - vim.print(direct_lookup, path) - return direct_lookup == "!" +---@param state neotree.State +---@param items neotree.FileItem[] +M.mark_ignored = function(state, items) + for _, i in ipairs(items) do + repeat + local path = i.path + local git_root = M.get_repository_root(path) + if not git_root then + break + end + local status = M.status_cache[git_root] or M.status("HEAD", false, path) + if not status then + break + end + + local direct_lookup = M.status_cache[git_root][path] + or M.status_cache[git_root][path .. utils.path_separator] + if direct_lookup then + i.filtered_by = i.filtered_by or {} + i.filtered_by.gitignored = true + end + until true end end ---@type table -local git_rootdir_cache = setmetatable({}, { __mode = "kv" }) ----@param path string? Defaults to cwd ----@param callback fun(git_root: string|false?)? ----@return string? -M.get_repository_root = function(path, callback) - path = path or log.assert(vim.uv.cwd()) - - do -- direct lookup in cache +do + local git_rootdir_cache = setmetatable({}, { __mode = "kv" }) + local finalize = function(path, git_root) + if utils.is_windows then + git_root = utils.windowize_path(git_root) + end + + log.trace("GIT ROOT for '", path, "' is '", git_root, "'") + git_rootdir_cache[path] = git_root + git_rootdir_cache[git_root] = git_root + end + + ---@param path string? Defaults to cwd + ---@param callback fun(git_root: string?)? + ---@return string? + M.get_repository_root = function(path, callback) + path = path or log.assert(vim.uv.cwd()) + local cached_rootdir = git_rootdir_cache[path] if cached_rootdir ~= nil then log.trace("git.get_repository_root: cache hit for", path, "was", cached_rootdir) @@ -383,9 +402,7 @@ M.get_repository_root = function(path, callback) end return cached_rootdir end - end - do -- check parents in cache for parent in utils.path_parents(path, true) do local cached_parent_entry = git_rootdir_cache[parent] if cached_parent_entry ~= nil then @@ -401,54 +418,42 @@ M.get_repository_root = function(path, callback) return cached_parent_entry end end - end - - log.trace("git.get_repository_root: cache miss for", path) - local args = { "-C", path, "rev-parse", "--show-toplevel" } - if type(callback) == "function" then - ---@diagnostic disable-next-line: missing-fields - Job:new({ - command = "git", - args = args, - enabled_recording = true, - on_exit = function(self, code, _) - if code ~= 0 then - log.trace("GIT ROOT ERROR", self:stderr_result()) - git_rootdir_cache[path] = false - callback(nil) - return - end - local git_root = self:result()[1] + log.trace("git.get_repository_root: cache miss for", path) + local args = { "-C", path, "rev-parse", "--show-toplevel" } - if utils.is_windows then - git_root = utils.windowize_path(git_root) - end + if type(callback) == "function" then + ---@diagnostic disable-next-line: missing-fields + Job:new({ + command = "git", + args = args, + enabled_recording = true, + on_exit = function(self, code, _) + if code ~= 0 then + log.trace("GIT ROOT ERROR", self:stderr_result()) + git_rootdir_cache[path] = false + callback(nil) + return + end + local git_root = self:result()[1] - log.trace("GIT ROOT for '", path, "' is '", git_root, "'") - git_rootdir_cache[path] = git_root - git_rootdir_cache[git_root] = git_root - callback(git_root) - end, - }):start() - return - end + finalize(path, git_root) + callback(git_root) + end, + }):start() + return + end - local ok, git_output = utils.execute_command({ "git", unpack(args) }) - if not ok then - log.trace("GIT ROOT ERROR", git_output) - git_rootdir_cache[path] = false - return nil - end - local git_root = git_output[1] + local ok, git_output = utils.execute_command({ "git", unpack(args) }) + if not ok then + log.trace("GIT ROOT NOT FOUND", git_output) + git_rootdir_cache[path] = false + return nil + end + local git_root = git_output[1] - if utils.is_windows then - git_root = utils.windowize_path(git_root) + finalize(path, git_root) + return git_root end - - log.trace("GIT ROOT for '", path, "' is '", git_root, "'") - git_rootdir_cache[path] = path - git_rootdir_cache[git_root] = git_root - return git_root end return M diff --git a/lua/neo-tree/sources/common/components.lua b/lua/neo-tree/sources/common/components.lua index 96909e00..335484bd 100644 --- a/lua/neo-tree/sources/common/components.lua +++ b/lua/neo-tree/sources/common/components.lua @@ -353,7 +353,7 @@ M.filtered_by = function(_, node, state) text = "(hide by pattern)", highlight = highlights.HIDDEN_BY_NAME, } - elseif require("neo-tree.git").is_ignored(node.path) then + elseif fby.gitignored then return { text = "(gitignored)", highlight = highlights.GIT_IGNORED, From 6364fa1438d4b9ac0c59560f721052359fac909f Mon Sep 17 00:00:00 2001 From: pynappo Date: Wed, 22 Oct 2025 02:14:02 -0700 Subject: [PATCH 11/13] update --- lua/neo-tree/git/init.lua | 27 +++++++------------ .../sources/filesystem/lib/fs_scan.lua | 1 + lua/neo-tree/utils/init.lua | 2 +- 3 files changed, 11 insertions(+), 19 deletions(-) diff --git a/lua/neo-tree/git/init.lua b/lua/neo-tree/git/init.lua index e0d9f578..ca85c8f6 100644 --- a/lua/neo-tree/git/init.lua +++ b/lua/neo-tree/git/init.lua @@ -352,25 +352,16 @@ end ---@param state neotree.State ---@param items neotree.FileItem[] M.mark_ignored = function(state, items) + local gs = state.git_status_lookup + if not gs then + return + end for _, i in ipairs(items) do - repeat - local path = i.path - local git_root = M.get_repository_root(path) - if not git_root then - break - end - local status = M.status_cache[git_root] or M.status("HEAD", false, path) - if not status then - break - end - - local direct_lookup = M.status_cache[git_root][path] - or M.status_cache[git_root][path .. utils.path_separator] - if direct_lookup then - i.filtered_by = i.filtered_by or {} - i.filtered_by.gitignored = true - end - until true + local direct_lookup = gs[i.path] or gs[i.path .. utils.path_separator] + if direct_lookup == "!" then + i.filtered_by = i.filtered_by or {} + i.filtered_by.gitignored = true + end 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 0d3b83dc..058fcc54 100644 --- a/lua/neo-tree/sources/filesystem/lib/fs_scan.lua +++ b/lua/neo-tree/sources/filesystem/lib/fs_scan.lua @@ -119,6 +119,7 @@ local job_complete = function(context) local state = context.state file_nesting.nest_items(context) ignored.mark_ignored(state, context.all_items) + git.mark_ignored(state, context.all_items) render_context(context) return context end diff --git a/lua/neo-tree/utils/init.lua b/lua/neo-tree/utils/init.lua index c9f3bea9..df495bca 100644 --- a/lua/neo-tree/utils/init.lua +++ b/lua/neo-tree/utils/init.lua @@ -839,7 +839,7 @@ M.open_file = function(state, path, open_cmd, bufnr) result, err = M.force_new_split(state.current_position, escaped_path) end end - if result or err == "Vim(edit):E325: ATTENTION" then + if result or err:find("Vim(edit):E325: ATTENTION") then -- fixes #321 vim.bo[0].buflisted = true events.fire_event(events.FILE_OPENED, path) From 9988771d0da20f393c3230b13dcec6dac0f1f640 Mon Sep 17 00:00:00 2001 From: pynappo Date: Wed, 22 Oct 2025 18:13:53 -0700 Subject: [PATCH 12/13] fix git status on destroyed --- lua/neo-tree/git/init.lua | 1 + 1 file changed, 1 insertion(+) diff --git a/lua/neo-tree/git/init.lua b/lua/neo-tree/git/init.lua index ca85c8f6..38a33f62 100644 --- a/lua/neo-tree/git/init.lua +++ b/lua/neo-tree/git/init.lua @@ -13,6 +13,7 @@ M.status_cache = setmetatable({}, { __newindex = function(_, root_dir, status) require("neo-tree.sources.filesystem.lib.fs_watch").on_destroyed(root_dir, function() rawset(M.status_cache, root_dir, nil) + events.fire_event(events.GIT_STATUS_CHANGED, { git_root = root_dir, status = status }) end) rawset(M.status_cache, root_dir, status) end, From e51e4084d464c832682e17c80fb4351d79c3bb97 Mon Sep 17 00:00:00 2001 From: pynappo Date: Sat, 25 Oct 2025 13:15:26 -0700 Subject: [PATCH 13/13] don't open too many pipes --- lua/neo-tree/git/init.lua | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lua/neo-tree/git/init.lua b/lua/neo-tree/git/init.lua index 38a33f62..bafe9c9c 100644 --- a/lua/neo-tree/git/init.lua +++ b/lua/neo-tree/git/init.lua @@ -312,6 +312,9 @@ M.status_async = function(path, base, opts) stdin:shutdown() stdout:shutdown() stderr:shutdown() + stdin:close() + stdout:close() + stderr:close() local do_next_batch_later do_next_batch_later = function()