diff options
| -rw-r--r-- | README.md | 6 | ||||
| -rw-r--r-- | lua/99/extensions/files/init.lua | 68 | ||||
| -rw-r--r-- | lua/99/test/files_spec.lua | 323 |
3 files changed, 393 insertions, 4 deletions
@@ -87,6 +87,10 @@ through `search` and `work` -- 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 engine to use. Defaults to native (built-in) if not specified. source = "native", -- "native" (default), "cmp", or "blink" @@ -419,7 +423,7 @@ No description. 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. Native completions work by default. For nvim-cmp or blink.cmp, set `source = "cmp"` or `source = "blink"`. diff --git a/lua/99/extensions/files/init.lua b/lua/99/extensions/files/init.lua index 9aa1780..b3c7c78 100644 --- a/lua/99/extensions/files/init.lua +++ b/lua/99/extensions/files/init.lua @@ -71,6 +71,59 @@ 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) + -- Check if .git exists (can be directory OR file for worktrees/submodules) + return stat ~= nil +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 --deduplicate", + vim.fn.shellescape(root) + ) + local output = vim.fn.system(cmd) + + if vim.v.shell_error ~= 0 then + return nil + end + + if output == "" then + return {} + 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 ~= "" then + local name = line:match("([^/]+)$") or line + 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 + + return files +end + --- @return string function M.get_project_root() return cache.root @@ -83,6 +136,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 +206,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 diff --git a/lua/99/test/files_spec.lua b/lua/99/test/files_spec.lua index e033abe..53d9458 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,324 @@ 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( + "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" + _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 + 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) |
