From 20674c96653e2dad8c84279b5564a20a45530084 Mon Sep 17 00:00:00 2001 From: Stephanie Gredell Date: Sun, 22 Feb 2026 09:59:15 -0800 Subject: exclude files/paths in .gitinore during completion --- README.md | 6 ++++- lua/99/extensions/files/init.lua | 56 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d875386..7838c37 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,10 @@ I make the assumption you are using Lazy -- max_files = 5000, -- cap on total discovered files -- exclude = { ".env", ".env.*", "node_modules", ".git", ... }, }, + --- File Discovery: + --- - In git repos: Uses `git ls-files` which automatically respects .gitignore + --- - Non-git repos: Falls back to filesystem scanning with manual excludes + --- - Both methods apply the configured `exclude` list on top of gitignore --- What autocomplete you use. source = "cmp" | "blink", @@ -139,7 +143,7 @@ I make the assumption you are using Lazy When prompting, you can reference rules and files to add context to your request. - `#` references rules — type `#` in the prompt to autocomplete rule files from your configured rule directories -- `@` references files — type `@` to fuzzy-search project files +- `@` references files — type `@` to fuzzy-search project files. This will exclude files that are in .gitignore. Referenced content is automatically resolved and injected into the AI context. Requires cmp (`source = "cmp"` in your completion config). diff --git a/lua/99/extensions/files/init.lua b/lua/99/extensions/files/init.lua index 9aa1780..2214db6 100644 --- a/lua/99/extensions/files/init.lua +++ b/lua/99/extensions/files/init.lua @@ -71,6 +71,47 @@ function M.set_project_root(root) cache.files = {} end +--- @param root string +--- @return boolean +local function is_git_repo(root) + local git_dir = vim.fs.joinpath(root, ".git") + local stat = vim.uv.fs_stat(git_dir) + return stat ~= nil and stat.type == "directory" +end + +--- @param root string +--- @return _99.Files.File[] +local function scan_with_git_sync(root) + local cmd = string.format("git -C %s ls-files --cached --others --exclude-standard", vim.fn.shellescape(root)) + local output = vim.fn.system(cmd) + + if vim.v.shell_error ~= 0 or output == "" then + return nil + end + + local files = {} + local count = 0 + + for line in output:gmatch("[^\n]+") do + if count >= config.max_files then + break + end + + line = vim.trim(line) + if line ~= "" and not matches_exclude_pattern(line) then + local name = line:match("([^/]+)$") or line + table.insert(files, { + path = line, + name = name, + absolute_path = vim.fs.joinpath(root, line), + }) + count = count + 1 + end + end + + return files +end + --- @return string function M.get_project_root() return cache.root @@ -83,6 +124,19 @@ function M.discover_files() return {} end + -- Try git-based discovery first if in a git repo + if is_git_repo(root) then + local git_files = scan_with_git_sync(root) + if git_files then + table.sort(git_files, function(a, b) + return a.path < b.path + end) + cache.files = git_files + return git_files + end + end + + -- Fallback to filesystem scanning local files = {} local count = 0 @@ -140,7 +194,7 @@ end --- @return _99.Files.File[] function M.get_files() - if #cache.files == 0 and cache.root ~= "" then + if #cache.files == 0 then return M.discover_files() end return cache.files -- cgit v1.3-3-g829e From e057505a1adbc0c27dd10006970157e738c0ea4b Mon Sep 17 00:00:00 2001 From: Stephanie Gredell Date: Sun, 22 Feb 2026 10:24:53 -0800 Subject: really need to fix stylua still... --- lua/99/extensions/files/init.lua | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lua/99/extensions/files/init.lua b/lua/99/extensions/files/init.lua index 2214db6..03afb03 100644 --- a/lua/99/extensions/files/init.lua +++ b/lua/99/extensions/files/init.lua @@ -82,7 +82,10 @@ end --- @param root string --- @return _99.Files.File[] local function scan_with_git_sync(root) - local cmd = string.format("git -C %s ls-files --cached --others --exclude-standard", vim.fn.shellescape(root)) + local cmd = string.format( + "git -C %s ls-files --cached --others --exclude-standard", + vim.fn.shellescape(root) + ) local output = vim.fn.system(cmd) if vim.v.shell_error ~= 0 or output == "" then -- cgit v1.3-3-g829e From 3e2bdb3e6663d77126bacc3019ab1e122fdea481 Mon Sep 17 00:00:00 2001 From: Stephanie Gredell Date: Sun, 22 Feb 2026 13:11:19 -0800 Subject: stuff --- lua/99/extensions/files/init.lua | 9 +- lua/99/test/files_spec.lua | 275 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 280 insertions(+), 4 deletions(-) diff --git a/lua/99/extensions/files/init.lua b/lua/99/extensions/files/init.lua index 03afb03..8805a12 100644 --- a/lua/99/extensions/files/init.lua +++ b/lua/99/extensions/files/init.lua @@ -76,7 +76,8 @@ end local function is_git_repo(root) local git_dir = vim.fs.joinpath(root, ".git") local stat = vim.uv.fs_stat(git_dir) - return stat ~= nil and stat.type == "directory" + -- Check if .git exists (can be directory OR file for worktrees/submodules) + return stat ~= nil end --- @param root string @@ -88,10 +89,14 @@ local function scan_with_git_sync(root) ) local output = vim.fn.system(cmd) - if vim.v.shell_error ~= 0 or output == "" then + if vim.v.shell_error ~= 0 then return nil end + if output == "" then + return {} + end + local files = {} local count = 0 diff --git a/lua/99/test/files_spec.lua b/lua/99/test/files_spec.lua index e033abe..b0063f2 100644 --- a/lua/99/test/files_spec.lua +++ b/lua/99/test/files_spec.lua @@ -33,13 +33,11 @@ describe("files", function() paths[f.path] = f end - -- known fixture files must be present assert.is_not_nil(paths["scratch/refresh.lua"]) assert.is_not_nil(paths["scratch/test.ts"]) eq("refresh.lua", paths["scratch/refresh.lua"].name) eq("test.ts", paths["scratch/test.ts"].name) - -- .git must be excluded for path, _ in pairs(paths) do assert.is_nil( path:match("^%.git/"), @@ -199,3 +197,276 @@ describe("files", function() ) end) end) + +describe("files git integration", function() + -- Mock storage + local _mocks = { + system_output = "", + system_exit = 0, + stat_type = nil, + stat_exists = false, + orig_system = nil, + orig_stat = nil, + orig_shell_error = nil, + system_calls = {}, + } + + before_each(function() + _mocks.orig_system = vim.fn.system + _mocks.orig_stat = vim.uv.fs_stat + + _mocks.system_output = "" + _mocks.system_exit = 0 + _mocks.stat_type = nil + _mocks.stat_exists = false + _mocks.system_calls = {} + + vim.fn.system = function(cmd) + table.insert(_mocks.system_calls, cmd) + + pcall(function() + rawset(vim.v, "shell_error", _mocks.system_exit) + end) + return _mocks.system_output + end + + vim.uv.fs_stat = function(path) + if _mocks.stat_exists then + return { type = _mocks.stat_type } + end + return nil + end + + Files.set_project_root("/test/repo") + end) + + after_each(function() + vim.fn.system = _mocks.orig_system + vim.uv.fs_stat = _mocks.orig_stat + + pcall(function() + rawset(vim.v, "shell_error", 0) + end) + Files.set_project_root("") + end) + + it( + "detects git repo with .git directory and uses git-based discovery", + function() + _mocks.stat_exists = true + _mocks.stat_type = "directory" + _mocks.system_output = "README.md\n" + _mocks.system_exit = 0 + + local files = Files.discover_files() + + assert.is_true( + #_mocks.system_calls > 0, + "git command should have been called" + ) + local git_called = false + for _, cmd in ipairs(_mocks.system_calls) do + if cmd:match("git.*ls%-files") then + git_called = true + break + end + end + assert.is_true(git_called, "git ls-files should have been executed") + + assert.is_true(#files > 0, "should return files from git") + eq("README.md", files[1].path) + end + ) + + it( + "detects git repo with .git file (worktree) and uses git-based discovery", + function() + _mocks.stat_exists = true + _mocks.stat_type = "file" + _mocks.system_output = "src/main.lua\ntest/file.lua\n" + _mocks.system_exit = 0 + + local files = Files.discover_files() + + assert.is_true( + #_mocks.system_calls > 0, + "git command should have been called for worktree" + ) + local git_called = false + for _, cmd in ipairs(_mocks.system_calls) do + if cmd:match("git.*ls%-files") then + git_called = true + break + end + end + assert.is_true(git_called, "should use git for worktree .git file") + + assert.are.equal(2, #files) + eq("src/main.lua", files[1].path) + eq("test/file.lua", files[2].path) + end + ) + + it("returns files when git command succeeds", function() + _mocks.stat_exists = true + _mocks.stat_type = "directory" + _mocks.system_output = "README.md\nsrc/init.lua\nsrc/utils.lua\n" + _mocks.system_exit = 0 + + local files = Files.discover_files() + + assert.are.equal(3, #files) + eq("README.md", files[1].path) + eq("src/init.lua", files[2].path) + eq("src/utils.lua", files[3].path) + + for _, f in ipairs(files) do + assert.is_not_nil(f.path, "file should have path") + assert.is_not_nil(f.name, "file should have name") + assert.is_not_nil(f.absolute_path, "file should have absolute_path") + end + end) + + it("returns empty table (not nil) for empty repo", function() + _mocks.stat_exists = true + _mocks.stat_type = "directory" + _mocks.system_output = "" + _mocks.system_exit = 0 + + local files = Files.discover_files() + + assert.is_not_nil(files, "should return table, not nil") + assert.are.equal(0, #files, "should return empty table for empty repo") + + for _, cmd in ipairs(_mocks.system_calls) do + assert.is_true( + cmd:match("git") ~= nil, + "should only call git, not fs commands" + ) + end + end) + + it("returns nil on git command failure", function() + _mocks.stat_exists = true + _mocks.stat_type = "directory" + _mocks.system_output = "fatal: not a git repository" + _mocks.system_exit = 128 -- Git failure + + local orig_scandir = vim.uv.fs_scandir + vim.uv.fs_scandir = function(dir) + return nil -- Empty directory + end + + local files = Files.discover_files() + + vim.uv.fs_scandir = orig_scandir + + local git_failed = false + for _, cmd in ipairs(_mocks.system_calls) do + if cmd:match("git") and vim.v.shell_error ~= 0 then + git_failed = true + break + end + end + assert.is_true(git_failed, "git should have been called and failed") + end) + + it("applies manual excludes on top of git output", function() + _mocks.stat_exists = true + _mocks.stat_type = "directory" + _mocks.system_output = + "README.md\n.env\n.env.local\nnode_modules/package.json\nsrc/main.lua\n" + _mocks.system_exit = 0 + + Files.setup({ + enabled = true, + exclude = { ".env", ".env.*", "node_modules" }, + }, {}) + + local files = Files.discover_files() + + assert.are.equal(2, #files) + eq("README.md", files[1].path) + eq("src/main.lua", files[2].path) + + for _, f in ipairs(files) do + assert.is_nil(f.path:match("%.env"), ".env files should be excluded") + assert.is_nil( + f.path:match("node_modules"), + "node_modules should be excluded" + ) + end + end) + + it("uses git-based discovery in git repo", function() + _mocks.stat_exists = true + _mocks.stat_type = "directory" + _mocks.system_output = "tracked.txt\n" + _mocks.system_exit = 0 + + Files.discover_files() + + local git_called = false + for _, cmd in ipairs(_mocks.system_calls) do + if cmd:match("git") then + git_called = true + break + end + end + assert.is_true(git_called, "should use git ls-files in git repo") + end) + + it("falls back to filesystem when not in git repo", function() + _mocks.stat_exists = false -- .git doesn't exist + + local orig_scandir = vim.uv.fs_scandir + local fs_called = false + vim.uv.fs_scandir = function(dir) + fs_called = true + return nil -- Empty + end + + Files.discover_files() + + vim.uv.fs_scandir = orig_scandir + + for _, cmd in ipairs(_mocks.system_calls) do + assert.is_nil( + cmd:match("git"), + "git should not be called in non-git repo" + ) + end + + assert.is_true(fs_called, "filesystem fallback should be used") + end) + + it("returns cached files on subsequent calls", function() + _mocks.stat_exists = true + _mocks.stat_type = "directory" + _mocks.system_output = "file.txt\n" + _mocks.system_exit = 0 + + local first = Files.get_files() + local first_call_count = #_mocks.system_calls + + local second = Files.get_files() + local second_call_count = #_mocks.system_calls + + eq(first, second) + + assert.are.equal( + first_call_count, + second_call_count, + "should not re-scan, use cache" + ) + end) + + it("handles empty root gracefully", function() + Files.set_project_root("") + + local files = Files.discover_files() + + assert.is_not_nil(files, "should return table") + assert.are.equal(0, #files, "should return empty for empty root") + end) +end) -- cgit v1.3-3-g829e From 603067fc728061b584299c0ced8a8eb2bf40a548 Mon Sep 17 00:00:00 2001 From: Stephanie Gredell Date: Sun, 22 Feb 2026 13:13:51 -0800 Subject: stylua --- lua/99/test/files_spec.lua | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lua/99/test/files_spec.lua b/lua/99/test/files_spec.lua index b0063f2..6c27304 100644 --- a/lua/99/test/files_spec.lua +++ b/lua/99/test/files_spec.lua @@ -230,7 +230,7 @@ describe("files git integration", function() return _mocks.system_output end - vim.uv.fs_stat = function(path) + vim.uv.fs_stat = function(_path) if _mocks.stat_exists then return { type = _mocks.stat_type } end @@ -353,7 +353,7 @@ describe("files git integration", function() _mocks.system_exit = 128 -- Git failure local orig_scandir = vim.uv.fs_scandir - vim.uv.fs_scandir = function(dir) + vim.uv.fs_scandir = function(_dir) return nil -- Empty directory end @@ -421,7 +421,7 @@ describe("files git integration", function() local orig_scandir = vim.uv.fs_scandir local fs_called = false - vim.uv.fs_scandir = function(dir) + vim.uv.fs_scandir = function(_dir) fs_called = true return nil -- Empty end -- cgit v1.3-3-g829e From 86a2e9c723945b3531f899deb2b69278457c4a2e Mon Sep 17 00:00:00 2001 From: Stephanie Gredell Date: Sun, 22 Feb 2026 13:32:44 -0800 Subject: stuff --- lua/99/extensions/files/init.lua | 20 +++++++++------- lua/99/test/files_spec.lua | 52 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 62 insertions(+), 10 deletions(-) diff --git a/lua/99/extensions/files/init.lua b/lua/99/extensions/files/init.lua index 8805a12..b3c7c78 100644 --- a/lua/99/extensions/files/init.lua +++ b/lua/99/extensions/files/init.lua @@ -84,7 +84,7 @@ end --- @return _99.Files.File[] local function scan_with_git_sync(root) local cmd = string.format( - "git -C %s ls-files --cached --others --exclude-standard", + "git -C %s ls-files --cached --others --exclude-standard --deduplicate", vim.fn.shellescape(root) ) local output = vim.fn.system(cmd) @@ -106,14 +106,18 @@ local function scan_with_git_sync(root) end line = vim.trim(line) - if line ~= "" and not matches_exclude_pattern(line) then + if line ~= "" then local name = line:match("([^/]+)$") or line - table.insert(files, { - path = line, - name = name, - absolute_path = vim.fs.joinpath(root, line), - }) - count = count + 1 + if + not matches_exclude_pattern(line) and not matches_exclude_pattern(name) + then + table.insert(files, { + path = line, + name = name, + absolute_path = vim.fs.joinpath(root, line), + }) + count = count + 1 + end end end diff --git a/lua/99/test/files_spec.lua b/lua/99/test/files_spec.lua index 6c27304..53d9458 100644 --- a/lua/99/test/files_spec.lua +++ b/lua/99/test/files_spec.lua @@ -398,6 +398,55 @@ describe("files git integration", function() end end) + it( + "excludes files by filename (not just path) consistent with fs scanner", + function() + _mocks.stat_exists = true + _mocks.stat_type = "directory" + _mocks.system_output = "README.md\nsrc/builder.js\nsrc/build/config.lua\n" + _mocks.system_exit = 0 + + Files.setup({ + enabled = true, + exclude = { "build" }, + }, {}) + + local files = Files.discover_files() + + assert.are.equal(1, #files) + eq("README.md", files[1].path) + + for _, f in ipairs(files) do + assert.is_nil( + f.name:match("^build"), + "files starting with 'build' should be excluded" + ) + end + end + ) + + it("uses --deduplicate flag to handle merge conflict duplicates", function() + _mocks.stat_exists = true + _mocks.stat_type = "directory" + + _mocks.system_output = "README.md\nREADME.md\nREADME.md\n" + _mocks.system_exit = 0 + + local files = Files.discover_files() + + local has_deduplicate = false + for _, cmd in ipairs(_mocks.system_calls) do + if cmd:match("%-%-deduplicate") then + has_deduplicate = true + break + end + end + assert.is_true( + has_deduplicate, + "git command should include --deduplicate flag" + ) + end) + it("uses git-based discovery in git repo", function() _mocks.stat_exists = true _mocks.stat_type = "directory" @@ -417,8 +466,7 @@ describe("files git integration", function() end) it("falls back to filesystem when not in git repo", function() - _mocks.stat_exists = false -- .git doesn't exist - + _mocks.stat_exists = false local orig_scandir = vim.uv.fs_scandir local fs_called = false vim.uv.fs_scandir = function(_dir) -- cgit v1.3-3-g829e