mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-11 22:51:26 +00:00
# fix: list_files recursive mode now works for dot directories (#5176)
* feat: add Issue Fixer Orchestrator mode * The `list_files` tool with `recursive: true` was returning empty results when targeting directories that start with a dot (e.g., `.roo-memory`). This happened because the recursive mode was applying a blanket exclusion pattern `!**/.*//**` that excluded all files inside any hidden directory, even when the user explicitly requested to list that directory. - `src/services/glob/list-files.ts` - **Modified `buildRecursiveArgs()`**: Removed the problematic `!**/.*//**` exclusion pattern for hidden directories in recursive mode - **Enhanced `listFilteredDirectories()`**: Added `isTargetDir` parameter to distinguish between explicitly targeted directories and discovered subdirectories - **Updated `shouldIncludeDirectory()`**: Always include explicitly targeted directories (even if hidden), while still applying ignore rules to subdirectories found during traversal - **Before**: `list_files` with `path: ".roo-memory"` and `recursive: true` → Empty results - **After**: `list_files` with `path: ".roo-memory"` and `recursive: true` → Returns directory contents - **Preserved**: Hidden subdirectories discovered during traversal are still filtered out This maintains consistency with `.gitignore` and `.rooignore` mechanisms while ensuring explicitly targeted directories are always processed. Fixes #2992 * Update src/services/glob/list-files.ts Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> * fix: list_files recursive mode now works correctly for hidden directories - Fixed ripgrep exclusion pattern from `!**/.*//**` to `!**/.*/**` - Hidden directories (like .git/) now appear in recursive listings but their contents are excluded - Contents of hidden directories are only shown when explicitly targeting that directory - Prevents context window overflow from massive hidden directories like .git/ - Maintains consistent behavior: show directory exists, avoid flooding with contents Fixes #2992 * fix(glob): show top-level hidden directories in list_files - Modified list-files.ts to show hidden directories at top level - Added test coverage for hidden directory visibility behavior - Maintains existing functionality while fixing the .roo directory issue Addresses PR #5176 feedback with simplified implementation * fix(glob): include top-level files when recursively listing ignored directories - Fix issue where files at root level of directories in DIRS_TO_IGNORE were excluded - Add explicit include patterns (* and **/*) when targeting ignored directories - Modify exclusion pattern to use !*/dir/** instead of !**/dir/** for target directory - Add comprehensive test case for .roo/temp scenario Fixes #5176 * fix(list-files): improve handling of explicitly targeted ignored directories - Fixed issue where recursive listing would skip files in explicitly targeted directories that are in DIRS_TO_IGNORE - When targeting a directory like 'temp' that's in the ignore list, we now skip adding exclusion patterns for it - This ensures all files in the target directory are listed while still preventing recursion into nested directories with the same ignored name - Also fixed path resolution to convert relative paths from ripgrep to absolute paths * fix(tests): add missing mocks for list-files tests * refactor: improve list-files implementation - Remove redundant path resolution in listFilesWithRipgrep - Convert CRITICAL_IGNORE_PATTERNS to Set for better performance - Standardize error message format across all console.warn calls * fix: update tests to handle cross-platform path resolution --------- Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> Co-authored-by: Daniel Riccio <ricciodaniel98@gmail.com>
This commit is contained in:
parent
4d0550c855
commit
f530a71371
3 changed files with 587 additions and 51 deletions
|
|
@ -3,6 +3,18 @@ import * as path from "path"
|
|||
import { listFiles } from "../list-files"
|
||||
import * as childProcess from "child_process"
|
||||
|
||||
vi.mock("child_process")
|
||||
vi.mock("fs")
|
||||
vi.mock("vscode", () => ({
|
||||
env: {
|
||||
appRoot: "/mock/vscode/app/root",
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("../../ripgrep", () => ({
|
||||
getBinPath: vi.fn().mockResolvedValue("/mock/path/to/rg"),
|
||||
}))
|
||||
|
||||
vi.mock("../list-files", async () => {
|
||||
const actual = await vi.importActual("../list-files")
|
||||
return {
|
||||
|
|
@ -83,8 +95,11 @@ describe("list-files symlink support", () => {
|
|||
|
||||
mockSpawn.mockReturnValue(mockProcess as any)
|
||||
|
||||
// Use a test directory path
|
||||
const testDir = "/test/dir"
|
||||
|
||||
// Call listFiles to trigger ripgrep execution
|
||||
await listFiles("/test/dir", false, 100)
|
||||
await listFiles(testDir, false, 100)
|
||||
|
||||
// Verify that spawn was called with --follow flag (the critical fix)
|
||||
const [rgPath, args] = mockSpawn.mock.calls[0]
|
||||
|
|
@ -93,9 +108,12 @@ describe("list-files symlink support", () => {
|
|||
expect(args).toContain("--hidden")
|
||||
expect(args).toContain("--follow") // This is the critical assertion - the fix should add this flag
|
||||
|
||||
// Platform-agnostic path check - verify the last argument is the resolved path
|
||||
const expectedPath = path.resolve("/test/dir")
|
||||
expect(args[args.length - 1]).toBe(expectedPath)
|
||||
// Platform-agnostic path check - verify the last argument ends with the expected path
|
||||
const lastArg = args[args.length - 1]
|
||||
// On Windows, the path might be resolved to something like D:\test\dir
|
||||
// On Unix, it would be /test/dir
|
||||
// So we just check that it ends with the expected segments
|
||||
expect(lastArg).toMatch(/[/\\]test[/\\]dir$/)
|
||||
})
|
||||
|
||||
it("should include --follow flag for recursive listings too", async () => {
|
||||
|
|
@ -124,8 +142,11 @@ describe("list-files symlink support", () => {
|
|||
|
||||
mockSpawn.mockReturnValue(mockProcess as any)
|
||||
|
||||
// Use a test directory path
|
||||
const testDir = "/test/dir"
|
||||
|
||||
// Call listFiles with recursive=true
|
||||
await listFiles("/test/dir", true, 100)
|
||||
await listFiles(testDir, true, 100)
|
||||
|
||||
// Verify that spawn was called with --follow flag (the critical fix)
|
||||
const [rgPath, args] = mockSpawn.mock.calls[0]
|
||||
|
|
@ -134,9 +155,12 @@ describe("list-files symlink support", () => {
|
|||
expect(args).toContain("--hidden")
|
||||
expect(args).toContain("--follow") // This should be present in recursive mode too
|
||||
|
||||
// Platform-agnostic path check - verify the last argument is the resolved path
|
||||
const expectedPath = path.resolve("/test/dir")
|
||||
expect(args[args.length - 1]).toBe(expectedPath)
|
||||
// Platform-agnostic path check - verify the last argument ends with the expected path
|
||||
const lastArg = args[args.length - 1]
|
||||
// On Windows, the path might be resolved to something like D:\test\dir
|
||||
// On Unix, it would be /test/dir
|
||||
// So we just check that it ends with the expected segments
|
||||
expect(lastArg).toMatch(/[/\\]test[/\\]dir$/)
|
||||
})
|
||||
|
||||
it("should ensure first-level directories are included when limit is reached", async () => {
|
||||
|
|
@ -159,18 +183,19 @@ describe("list-files symlink support", () => {
|
|||
on: vi.fn((event, callback) => {
|
||||
if (event === "data") {
|
||||
// Return many file paths to trigger the limit
|
||||
// Note: ripgrep returns relative paths
|
||||
const paths =
|
||||
[
|
||||
"/test/dir/a_dir/",
|
||||
"/test/dir/a_dir/subdir1/",
|
||||
"/test/dir/a_dir/subdir1/file1.txt",
|
||||
"/test/dir/a_dir/subdir1/file2.txt",
|
||||
"/test/dir/a_dir/subdir2/",
|
||||
"/test/dir/a_dir/subdir2/file3.txt",
|
||||
"/test/dir/a_dir/file4.txt",
|
||||
"/test/dir/a_dir/file5.txt",
|
||||
"/test/dir/file1.txt",
|
||||
"/test/dir/file2.txt",
|
||||
"a_dir/",
|
||||
"a_dir/subdir1/",
|
||||
"a_dir/subdir1/file1.txt",
|
||||
"a_dir/subdir1/file2.txt",
|
||||
"a_dir/subdir2/",
|
||||
"a_dir/subdir2/file3.txt",
|
||||
"a_dir/file4.txt",
|
||||
"a_dir/file5.txt",
|
||||
"file1.txt",
|
||||
"file2.txt",
|
||||
// Note: b_dir and c_dir are missing from ripgrep output
|
||||
].join("\n") + "\n"
|
||||
setTimeout(() => callback(paths), 10)
|
||||
|
|
@ -216,3 +241,321 @@ describe("list-files symlink support", () => {
|
|||
expect(hasCDir).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("hidden directory exclusion", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it("should exclude .git subdirectories from recursive directory listing", async () => {
|
||||
// Mock filesystem structure with .git subdirectories
|
||||
const mockReaddir = vi.fn()
|
||||
vi.mocked(fs.promises).readdir = mockReaddir
|
||||
|
||||
// Mock the directory structure:
|
||||
// /test/
|
||||
// .git/
|
||||
// hooks/
|
||||
// objects/
|
||||
// src/
|
||||
// components/
|
||||
mockReaddir
|
||||
.mockResolvedValueOnce([
|
||||
{ name: ".git", isDirectory: () => true, isSymbolicLink: () => false },
|
||||
{ name: "src", isDirectory: () => true, isSymbolicLink: () => false },
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
// src subdirectories (should be included)
|
||||
{ name: "components", isDirectory: () => true, isSymbolicLink: () => false },
|
||||
])
|
||||
.mockResolvedValueOnce([]) // components/ is empty
|
||||
|
||||
// Mock ripgrep to return no files
|
||||
const mockSpawn = vi.mocked(childProcess.spawn)
|
||||
const mockProcess = {
|
||||
stdout: {
|
||||
on: vi.fn((event, callback) => {
|
||||
if (event === "data") {
|
||||
// No files returned
|
||||
}
|
||||
}),
|
||||
},
|
||||
stderr: {
|
||||
on: vi.fn(),
|
||||
},
|
||||
on: vi.fn((event, callback) => {
|
||||
if (event === "close") {
|
||||
setTimeout(() => callback(0), 10)
|
||||
}
|
||||
}),
|
||||
kill: vi.fn(),
|
||||
}
|
||||
mockSpawn.mockReturnValue(mockProcess as any)
|
||||
|
||||
// Call listFiles with recursive=true
|
||||
const [result] = await listFiles("/test", true, 100)
|
||||
|
||||
// Verify that .git subdirectories are NOT included
|
||||
const directories = result.filter((item) => item.endsWith("/"))
|
||||
|
||||
// More specific checks - look for exact paths
|
||||
const hasSrcDir = directories.some((dir) => dir.endsWith("/test/src/") || dir.endsWith("src/"))
|
||||
const hasComponentsDir = directories.some(
|
||||
(dir) =>
|
||||
dir.endsWith("/test/src/components/") || dir.endsWith("src/components/") || dir.includes("components/"),
|
||||
)
|
||||
const hasGitDir = directories.some((dir) => dir.includes(".git/"))
|
||||
|
||||
// Should include src/ and src/components/ but NOT .git/ or its subdirectories
|
||||
expect(hasSrcDir).toBe(true)
|
||||
expect(hasComponentsDir).toBe(true)
|
||||
|
||||
// Should NOT include .git (hidden directories are excluded)
|
||||
expect(hasGitDir).toBe(false)
|
||||
})
|
||||
|
||||
it("should allow explicit targeting of hidden directories", async () => {
|
||||
// Mock filesystem structure for explicit .roo-memory targeting
|
||||
const mockReaddir = vi.fn()
|
||||
vi.mocked(fs.promises).readdir = mockReaddir
|
||||
|
||||
// Mock .roo-memory directory contents
|
||||
mockReaddir.mockResolvedValueOnce([
|
||||
{ name: "tasks", isDirectory: () => true, isSymbolicLink: () => false },
|
||||
{ name: "context", isDirectory: () => true, isSymbolicLink: () => false },
|
||||
])
|
||||
|
||||
// Mock ripgrep to return no files
|
||||
const mockSpawn = vi.mocked(childProcess.spawn)
|
||||
const mockProcess = {
|
||||
stdout: {
|
||||
on: vi.fn((event, callback) => {
|
||||
if (event === "data") {
|
||||
// No files returned
|
||||
}
|
||||
}),
|
||||
},
|
||||
stderr: {
|
||||
on: vi.fn(),
|
||||
},
|
||||
on: vi.fn((event, callback) => {
|
||||
if (event === "close") {
|
||||
setTimeout(() => callback(0), 10)
|
||||
}
|
||||
}),
|
||||
kill: vi.fn(),
|
||||
}
|
||||
mockSpawn.mockReturnValue(mockProcess as any)
|
||||
|
||||
// Call listFiles explicitly targeting .roo-memory directory
|
||||
const [result] = await listFiles("/test/.roo-memory", true, 100)
|
||||
|
||||
// When explicitly targeting a hidden directory, its subdirectories should be included
|
||||
const directories = result.filter((item) => item.endsWith("/"))
|
||||
|
||||
const hasTasksDir = directories.some((dir) => dir.includes(".roo-memory/tasks/") || dir.includes("tasks/"))
|
||||
const hasContextDir = directories.some(
|
||||
(dir) => dir.includes(".roo-memory/context/") || dir.includes("context/"),
|
||||
)
|
||||
|
||||
expect(hasTasksDir).toBe(true)
|
||||
expect(hasContextDir).toBe(true)
|
||||
})
|
||||
|
||||
it("should include top-level files when recursively listing a hidden directory that's also in DIRS_TO_IGNORE", async () => {
|
||||
// This test specifically addresses the bug where files at the root level of .roo/temp
|
||||
// were being excluded when using recursive listing
|
||||
const mockSpawn = vi.mocked(childProcess.spawn)
|
||||
const mockProcess = {
|
||||
stdout: {
|
||||
on: vi.fn((event, callback) => {
|
||||
if (event === "data") {
|
||||
// Simulate files that should be found in .roo/temp
|
||||
// Note: ripgrep returns relative paths
|
||||
setTimeout(() => {
|
||||
callback("teste1.md\n")
|
||||
callback("22/test2.md\n")
|
||||
}, 10)
|
||||
}
|
||||
}),
|
||||
},
|
||||
stderr: {
|
||||
on: vi.fn(),
|
||||
},
|
||||
on: vi.fn((event, callback) => {
|
||||
if (event === "close") {
|
||||
setTimeout(() => callback(0), 20)
|
||||
}
|
||||
}),
|
||||
kill: vi.fn(),
|
||||
}
|
||||
|
||||
mockSpawn.mockReturnValue(mockProcess as any)
|
||||
|
||||
// Mock directory listing for .roo/temp
|
||||
const mockReaddir = vi.fn()
|
||||
vi.mocked(fs.promises).readdir = mockReaddir
|
||||
mockReaddir.mockResolvedValueOnce([{ name: "22", isDirectory: () => true, isSymbolicLink: () => false }])
|
||||
|
||||
// Call listFiles targeting .roo/temp (which is both hidden and in DIRS_TO_IGNORE)
|
||||
const [files] = await listFiles("/test/.roo/temp", true, 100)
|
||||
|
||||
// Verify ripgrep was called with correct arguments
|
||||
const [rgPath, args] = mockSpawn.mock.calls[0]
|
||||
expect(args).toContain("--no-ignore-vcs")
|
||||
expect(args).toContain("--no-ignore")
|
||||
|
||||
// Check for the inclusion patterns that should be added
|
||||
expect(args).toContain("-g")
|
||||
const gIndex = args.indexOf("-g")
|
||||
expect(args[gIndex + 1]).toBe("*")
|
||||
|
||||
// Verify that both top-level and nested files are included
|
||||
const fileNames = files.map((f) => path.basename(f))
|
||||
expect(fileNames).toContain("teste1.md")
|
||||
expect(fileNames).toContain("test2.md")
|
||||
|
||||
// Ensure the top-level file is actually included
|
||||
const topLevelFile = files.find((f) => f.endsWith("teste1.md"))
|
||||
expect(topLevelFile).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe("buildRecursiveArgs edge cases", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it("should correctly detect hidden directories with trailing slashes", async () => {
|
||||
const mockSpawn = vi.mocked(childProcess.spawn)
|
||||
const mockProcess = {
|
||||
stdout: {
|
||||
on: vi.fn((event, callback) => {
|
||||
if (event === "data") {
|
||||
setTimeout(() => callback("file.txt\n"), 10)
|
||||
}
|
||||
}),
|
||||
},
|
||||
stderr: {
|
||||
on: vi.fn(),
|
||||
},
|
||||
on: vi.fn((event, callback) => {
|
||||
if (event === "close") {
|
||||
setTimeout(() => callback(0), 20)
|
||||
}
|
||||
}),
|
||||
kill: vi.fn(),
|
||||
}
|
||||
mockSpawn.mockReturnValue(mockProcess as any)
|
||||
|
||||
// Test with trailing slash on hidden directory
|
||||
await listFiles("/test/.hidden/", true, 100)
|
||||
|
||||
const [rgPath, args] = mockSpawn.mock.calls[0]
|
||||
// When targeting a hidden directory, these flags should be present
|
||||
expect(args).toContain("--no-ignore-vcs")
|
||||
expect(args).toContain("--no-ignore")
|
||||
expect(args).toContain("-g")
|
||||
const gIndex = args.indexOf("-g")
|
||||
expect(args[gIndex + 1]).toBe("*")
|
||||
})
|
||||
|
||||
it("should correctly detect hidden directories with redundant separators", async () => {
|
||||
const mockSpawn = vi.mocked(childProcess.spawn)
|
||||
const mockProcess = {
|
||||
stdout: {
|
||||
on: vi.fn((event, callback) => {
|
||||
if (event === "data") {
|
||||
setTimeout(() => callback("file.txt\n"), 10)
|
||||
}
|
||||
}),
|
||||
},
|
||||
stderr: {
|
||||
on: vi.fn(),
|
||||
},
|
||||
on: vi.fn((event, callback) => {
|
||||
if (event === "close") {
|
||||
setTimeout(() => callback(0), 20)
|
||||
}
|
||||
}),
|
||||
kill: vi.fn(),
|
||||
}
|
||||
mockSpawn.mockReturnValue(mockProcess as any)
|
||||
|
||||
// Test with redundant separators before hidden directory
|
||||
await listFiles("/test//.hidden", true, 100)
|
||||
|
||||
const [rgPath, args] = mockSpawn.mock.calls[0]
|
||||
// When targeting a hidden directory, these flags should be present
|
||||
expect(args).toContain("--no-ignore-vcs")
|
||||
expect(args).toContain("--no-ignore")
|
||||
expect(args).toContain("-g")
|
||||
const gIndex = args.indexOf("-g")
|
||||
expect(args[gIndex + 1]).toBe("*")
|
||||
})
|
||||
|
||||
it("should correctly detect nested hidden directories with mixed separators", async () => {
|
||||
const mockSpawn = vi.mocked(childProcess.spawn)
|
||||
const mockProcess = {
|
||||
stdout: {
|
||||
on: vi.fn((event, callback) => {
|
||||
if (event === "data") {
|
||||
setTimeout(() => callback("file.txt\n"), 10)
|
||||
}
|
||||
}),
|
||||
},
|
||||
stderr: {
|
||||
on: vi.fn(),
|
||||
},
|
||||
on: vi.fn((event, callback) => {
|
||||
if (event === "close") {
|
||||
setTimeout(() => callback(0), 20)
|
||||
}
|
||||
}),
|
||||
kill: vi.fn(),
|
||||
}
|
||||
mockSpawn.mockReturnValue(mockProcess as any)
|
||||
|
||||
// Test with complex path including hidden directory
|
||||
await listFiles("/test//normal/.hidden//subdir/", true, 100)
|
||||
|
||||
const [rgPath, args] = mockSpawn.mock.calls[0]
|
||||
// When targeting a path containing a hidden directory, these flags should be present
|
||||
expect(args).toContain("--no-ignore-vcs")
|
||||
expect(args).toContain("--no-ignore")
|
||||
expect(args).toContain("-g")
|
||||
const gIndex = args.indexOf("-g")
|
||||
expect(args[gIndex + 1]).toBe("*")
|
||||
})
|
||||
|
||||
it("should not detect hidden directories when path only has dots in filenames", async () => {
|
||||
const mockSpawn = vi.mocked(childProcess.spawn)
|
||||
const mockProcess = {
|
||||
stdout: {
|
||||
on: vi.fn((event, callback) => {
|
||||
if (event === "data") {
|
||||
setTimeout(() => callback("file.txt\n"), 10)
|
||||
}
|
||||
}),
|
||||
},
|
||||
stderr: {
|
||||
on: vi.fn(),
|
||||
},
|
||||
on: vi.fn((event, callback) => {
|
||||
if (event === "close") {
|
||||
setTimeout(() => callback(0), 20)
|
||||
}
|
||||
}),
|
||||
kill: vi.fn(),
|
||||
}
|
||||
mockSpawn.mockReturnValue(mockProcess as any)
|
||||
|
||||
// Test with a path that has dots but no hidden directories
|
||||
await listFiles("/test/file.with.dots/normal", true, 100)
|
||||
|
||||
const [rgPath, args] = mockSpawn.mock.calls[0]
|
||||
// Should NOT have the special flags for hidden directories
|
||||
expect(args).not.toContain("--no-ignore-vcs")
|
||||
expect(args).not.toContain("--no-ignore")
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -20,5 +20,6 @@ export const DIRS_TO_IGNORE = [
|
|||
"deps",
|
||||
"pkg",
|
||||
"Pods",
|
||||
".git",
|
||||
".*",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -8,6 +8,20 @@ import { arePathsEqual } from "../../utils/path"
|
|||
import { getBinPath } from "../../services/ripgrep"
|
||||
import { DIRS_TO_IGNORE } from "./constants"
|
||||
|
||||
/**
|
||||
* Context object for directory scanning operations
|
||||
*/
|
||||
interface ScanContext {
|
||||
/** Whether this is the explicitly targeted directory */
|
||||
isTargetDir: boolean
|
||||
/** Whether we're inside an explicitly targeted hidden directory */
|
||||
insideExplicitHiddenTarget: boolean
|
||||
/** The base path for the scan operation */
|
||||
basePath: string
|
||||
/** The ignore instance for gitignore handling */
|
||||
ignoreInstance: ReturnType<typeof ignore>
|
||||
}
|
||||
|
||||
/**
|
||||
* List files in a directory, with optional recursive traversal
|
||||
*
|
||||
|
|
@ -70,7 +84,13 @@ async function getFirstLevelDirectories(dirPath: string, ignoreInstance: ReturnT
|
|||
for (const entry of entries) {
|
||||
if (entry.isDirectory() && !entry.isSymbolicLink()) {
|
||||
const fullDirPath = path.join(absolutePath, entry.name)
|
||||
if (shouldIncludeDirectory(entry.name, fullDirPath, dirPath, ignoreInstance)) {
|
||||
const context: ScanContext = {
|
||||
isTargetDir: false,
|
||||
insideExplicitHiddenTarget: false,
|
||||
basePath: dirPath,
|
||||
ignoreInstance,
|
||||
}
|
||||
if (shouldIncludeDirectory(entry.name, fullDirPath, context)) {
|
||||
const formattedPath = fullDirPath.endsWith("/") ? fullDirPath : `${fullDirPath}/`
|
||||
directories.push(formattedPath)
|
||||
}
|
||||
|
|
@ -179,9 +199,14 @@ async function listFilesWithRipgrep(
|
|||
recursive: boolean,
|
||||
limit: number,
|
||||
): Promise<string[]> {
|
||||
const rgArgs = buildRipgrepArgs(dirPath, recursive)
|
||||
|
||||
const relativePaths = await execRipgrep(rgPath, rgArgs, limit)
|
||||
|
||||
// Convert relative paths from ripgrep to absolute paths
|
||||
// Resolve dirPath once here for the mapping operation
|
||||
const absolutePath = path.resolve(dirPath)
|
||||
const rgArgs = buildRipgrepArgs(absolutePath, recursive)
|
||||
return execRipgrep(rgPath, rgArgs, limit)
|
||||
return relativePaths.map((relativePath) => path.resolve(absolutePath, relativePath))
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -192,7 +217,7 @@ function buildRipgrepArgs(dirPath: string, recursive: boolean): string[] {
|
|||
const args = ["--files", "--hidden", "--follow"]
|
||||
|
||||
if (recursive) {
|
||||
return [...args, ...buildRecursiveArgs(), dirPath]
|
||||
return [...args, ...buildRecursiveArgs(dirPath), dirPath]
|
||||
} else {
|
||||
return [...args, ...buildNonRecursiveArgs(), dirPath]
|
||||
}
|
||||
|
|
@ -201,14 +226,62 @@ function buildRipgrepArgs(dirPath: string, recursive: boolean): string[] {
|
|||
/**
|
||||
* Build ripgrep arguments for recursive directory traversal
|
||||
*/
|
||||
function buildRecursiveArgs(): string[] {
|
||||
function buildRecursiveArgs(dirPath: string): string[] {
|
||||
const args: string[] = []
|
||||
|
||||
// In recursive mode, respect .gitignore by default
|
||||
// (ripgrep does this automatically)
|
||||
|
||||
// Check if we're explicitly targeting a hidden directory
|
||||
// Normalize the path first to handle edge cases
|
||||
const normalizedPath = path.normalize(dirPath)
|
||||
// Split by separator and filter out empty parts
|
||||
// This handles cases like trailing slashes, multiple separators, etc.
|
||||
const pathParts = normalizedPath.split(path.sep).filter((part) => part.length > 0)
|
||||
const isTargetingHiddenDir = pathParts.some((part) => part.startsWith("."))
|
||||
|
||||
// Get the target directory name to check if it's in the ignore list
|
||||
const targetDirName = path.basename(dirPath)
|
||||
const isTargetInIgnoreList = DIRS_TO_IGNORE.includes(targetDirName)
|
||||
|
||||
// If targeting a hidden directory or a directory in the ignore list,
|
||||
// use special handling to ensure all files are shown
|
||||
if (isTargetingHiddenDir || isTargetInIgnoreList) {
|
||||
args.push("--no-ignore-vcs")
|
||||
args.push("--no-ignore")
|
||||
|
||||
// When targeting an ignored directory, we need to be careful with glob patterns
|
||||
// Add a pattern to explicitly include files at the root level
|
||||
args.push("-g", "*")
|
||||
args.push("-g", "**/*")
|
||||
}
|
||||
|
||||
// Apply directory exclusions for recursive searches
|
||||
for (const dir of DIRS_TO_IGNORE) {
|
||||
// Special handling for hidden directories pattern
|
||||
if (dir === ".*") {
|
||||
// If we're explicitly targeting a hidden directory, don't exclude hidden files/dirs
|
||||
// This allows the target hidden directory and all its contents to be listed
|
||||
if (!isTargetingHiddenDir) {
|
||||
// Not targeting hidden dir: exclude all hidden directories
|
||||
args.push("-g", `!**/.*/**`)
|
||||
}
|
||||
// If targeting hidden dir: don't add any exclusion for hidden directories
|
||||
continue
|
||||
}
|
||||
|
||||
// When explicitly targeting a directory that's in the ignore list (e.g., "temp"),
|
||||
// we need special handling:
|
||||
// - Don't add any exclusion pattern for the target directory itself
|
||||
// - Only exclude nested subdirectories with the same name
|
||||
// This ensures all files in the target directory are listed, while still
|
||||
// preventing recursion into nested directories with the same ignored name
|
||||
if (dir === targetDirName && isTargetInIgnoreList) {
|
||||
// Skip adding any exclusion pattern - we want to see everything in the target directory
|
||||
continue
|
||||
}
|
||||
|
||||
// For all other cases, exclude the directory pattern globally
|
||||
args.push("-g", `!**/${dir}/**`)
|
||||
}
|
||||
|
||||
|
|
@ -231,8 +304,11 @@ function buildNonRecursiveArgs(): string[] {
|
|||
// Apply directory exclusions for non-recursive searches
|
||||
for (const dir of DIRS_TO_IGNORE) {
|
||||
if (dir === ".*") {
|
||||
// For hidden files/dirs in non-recursive mode
|
||||
args.push("-g", "!.*")
|
||||
// For hidden directories in non-recursive mode, we want to show the directories
|
||||
// themselves but not their contents. Since we're using --maxdepth 1, this
|
||||
// naturally happens - we just need to avoid excluding the directories entirely.
|
||||
// We'll let the directory scanning logic handle the visibility.
|
||||
continue
|
||||
} else {
|
||||
// Direct children only
|
||||
args.push("-g", `!${dir}`)
|
||||
|
|
@ -261,7 +337,7 @@ async function createIgnoreInstance(dirPath: string): Promise<ReturnType<typeof
|
|||
ignoreInstance.add(content)
|
||||
} catch (err) {
|
||||
// Continue if we can't read a .gitignore file
|
||||
console.warn(`Error reading .gitignore at ${gitignoreFile}: ${err}`)
|
||||
console.warn(`Could not read .gitignore at ${gitignoreFile}: ${err}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -312,7 +388,20 @@ async function listFilteredDirectories(
|
|||
const absolutePath = path.resolve(dirPath)
|
||||
const directories: string[] = []
|
||||
|
||||
async function scanDirectory(currentPath: string): Promise<void> {
|
||||
// For environment details generation, we don't want to treat the root as a "target"
|
||||
// if we're doing a general recursive scan, as this would include hidden directories
|
||||
// Only treat as target if we're explicitly scanning a single hidden directory
|
||||
const isExplicitHiddenTarget = path.basename(absolutePath).startsWith(".")
|
||||
|
||||
// Create initial context for the scan
|
||||
const initialContext: ScanContext = {
|
||||
isTargetDir: isExplicitHiddenTarget,
|
||||
insideExplicitHiddenTarget: isExplicitHiddenTarget,
|
||||
basePath: dirPath,
|
||||
ignoreInstance,
|
||||
}
|
||||
|
||||
async function scanDirectory(currentPath: string, context: ScanContext): Promise<void> {
|
||||
try {
|
||||
// List all entries in the current directory
|
||||
const entries = await fs.promises.readdir(currentPath, { withFileTypes: true })
|
||||
|
|
@ -323,61 +412,155 @@ async function listFilteredDirectories(
|
|||
const dirName = entry.name
|
||||
const fullDirPath = path.join(currentPath, dirName)
|
||||
|
||||
// Create context for subdirectory checks
|
||||
// Subdirectories found during scanning are never target directories themselves
|
||||
const subdirContext: ScanContext = {
|
||||
...context,
|
||||
isTargetDir: false,
|
||||
}
|
||||
|
||||
// Check if this directory should be included
|
||||
if (shouldIncludeDirectory(dirName, fullDirPath, dirPath, ignoreInstance)) {
|
||||
if (shouldIncludeDirectory(dirName, fullDirPath, subdirContext)) {
|
||||
// Add the directory to our results (with trailing slash)
|
||||
// fullDirPath is already absolute since it's built with path.join from absolutePath
|
||||
const formattedPath = fullDirPath.endsWith("/") ? fullDirPath : `${fullDirPath}/`
|
||||
directories.push(formattedPath)
|
||||
}
|
||||
|
||||
// If recursive mode and not a ignored directory, scan subdirectories
|
||||
if (recursive && !isDirectoryExplicitlyIgnored(dirName)) {
|
||||
await scanDirectory(fullDirPath)
|
||||
// If recursive mode and not a ignored directory, scan subdirectories
|
||||
// Don't recurse into hidden directories unless they are the explicit target
|
||||
// or we're already inside an explicitly targeted hidden directory
|
||||
const isHiddenDir = dirName.startsWith(".")
|
||||
|
||||
// Use the same logic as shouldIncludeDirectory for recursion decisions
|
||||
// When inside an explicitly targeted hidden directory, only block critical directories
|
||||
let shouldRecurseIntoDir = true
|
||||
if (context.insideExplicitHiddenTarget) {
|
||||
// Only apply the most critical ignore patterns when inside explicit hidden target
|
||||
shouldRecurseIntoDir = !CRITICAL_IGNORE_PATTERNS.has(dirName)
|
||||
} else {
|
||||
shouldRecurseIntoDir = !isDirectoryExplicitlyIgnored(dirName)
|
||||
}
|
||||
|
||||
const shouldRecurse =
|
||||
recursive &&
|
||||
shouldRecurseIntoDir &&
|
||||
!(
|
||||
isHiddenDir &&
|
||||
DIRS_TO_IGNORE.includes(".*") &&
|
||||
!context.isTargetDir &&
|
||||
!context.insideExplicitHiddenTarget
|
||||
)
|
||||
if (shouldRecurse) {
|
||||
// If we're entering a hidden directory that's the target, or we're already inside one,
|
||||
// mark that we're inside an explicitly targeted hidden directory
|
||||
const newInsideExplicitHiddenTarget =
|
||||
context.insideExplicitHiddenTarget || (isHiddenDir && context.isTargetDir)
|
||||
const newContext: ScanContext = {
|
||||
...context,
|
||||
isTargetDir: false,
|
||||
insideExplicitHiddenTarget: newInsideExplicitHiddenTarget,
|
||||
}
|
||||
await scanDirectory(fullDirPath, newContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Silently continue if we can't read a directory
|
||||
// Continue if we can't read a directory
|
||||
console.warn(`Could not read directory ${currentPath}: ${err}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Start scanning from the root directory
|
||||
await scanDirectory(absolutePath)
|
||||
await scanDirectory(absolutePath, initialContext)
|
||||
|
||||
return directories
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a directory should be included in results based on filters
|
||||
* Critical directories that should always be ignored, even inside explicitly targeted hidden directories
|
||||
*/
|
||||
function shouldIncludeDirectory(
|
||||
dirName: string,
|
||||
const CRITICAL_IGNORE_PATTERNS = new Set(["node_modules", ".git", "__pycache__", "venv", "env"])
|
||||
|
||||
/**
|
||||
* Check if a directory matches any of the given patterns
|
||||
*/
|
||||
function matchesIgnorePattern(dirName: string, patterns: string[]): boolean {
|
||||
for (const pattern of patterns) {
|
||||
if (pattern === dirName || (pattern.includes("/") && pattern.split("/")[0] === dirName)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a directory is ignored by gitignore
|
||||
*/
|
||||
function isIgnoredByGitignore(
|
||||
fullDirPath: string,
|
||||
basePath: string,
|
||||
ignoreInstance: ReturnType<typeof ignore>,
|
||||
): boolean {
|
||||
// Skip hidden directories if configured to ignore them
|
||||
if (dirName.startsWith(".") && DIRS_TO_IGNORE.includes(".*")) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check against explicit ignore patterns
|
||||
if (isDirectoryExplicitlyIgnored(dirName)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check against gitignore patterns using the ignore library
|
||||
// Calculate relative path from the base directory
|
||||
const relativePath = path.relative(basePath, fullDirPath)
|
||||
const normalizedPath = relativePath.replace(/\\/g, "/")
|
||||
return ignoreInstance.ignores(normalizedPath) || ignoreInstance.ignores(normalizedPath + "/")
|
||||
}
|
||||
|
||||
// Check if the directory is ignored by .gitignore
|
||||
if (ignoreInstance.ignores(normalizedPath) || ignoreInstance.ignores(normalizedPath + "/")) {
|
||||
/**
|
||||
* Check if a target directory should be included
|
||||
*/
|
||||
function shouldIncludeTargetDirectory(dirName: string): boolean {
|
||||
// Only apply non-hidden-directory ignore rules to target directories
|
||||
const nonHiddenIgnorePatterns = DIRS_TO_IGNORE.filter((pattern) => pattern !== ".*")
|
||||
return !matchesIgnorePattern(dirName, nonHiddenIgnorePatterns)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a directory inside an explicitly targeted hidden directory should be included
|
||||
*/
|
||||
function shouldIncludeInsideHiddenTarget(dirName: string, fullDirPath: string, context: ScanContext): boolean {
|
||||
// Only apply the most critical ignore patterns when inside explicit hidden target
|
||||
if (CRITICAL_IGNORE_PATTERNS.has(dirName)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
// Check against gitignore patterns
|
||||
return !isIgnoredByGitignore(fullDirPath, context.basePath, context.ignoreInstance)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a regular directory should be included
|
||||
*/
|
||||
function shouldIncludeRegularDirectory(dirName: string, fullDirPath: string, context: ScanContext): boolean {
|
||||
// Check against explicit ignore patterns (excluding the ".*" pattern)
|
||||
const nonHiddenIgnorePatterns = DIRS_TO_IGNORE.filter((pattern) => pattern !== ".*")
|
||||
if (matchesIgnorePattern(dirName, nonHiddenIgnorePatterns)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check against gitignore patterns
|
||||
return !isIgnoredByGitignore(fullDirPath, context.basePath, context.ignoreInstance)
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a directory should be included in results based on filters
|
||||
*/
|
||||
function shouldIncludeDirectory(dirName: string, fullDirPath: string, context: ScanContext): boolean {
|
||||
// If this is the explicitly targeted directory, allow it even if it's hidden
|
||||
// This preserves the ability to explicitly target hidden directories like .roo-memory
|
||||
if (context.isTargetDir) {
|
||||
return shouldIncludeTargetDirectory(dirName)
|
||||
}
|
||||
|
||||
// If we're inside an explicitly targeted hidden directory, allow subdirectories
|
||||
// even if they would normally be filtered out by the ".*" pattern or other ignore rules
|
||||
if (context.insideExplicitHiddenTarget) {
|
||||
return shouldIncludeInsideHiddenTarget(dirName, fullDirPath, context)
|
||||
}
|
||||
|
||||
// Regular directory inclusion logic
|
||||
return shouldIncludeRegularDirectory(dirName, fullDirPath, context)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -390,6 +573,11 @@ function isDirectoryExplicitlyIgnored(dirName: string): boolean {
|
|||
return true
|
||||
}
|
||||
|
||||
// Skip the ".*" pattern - it's handled specially to allow top-level visibility
|
||||
if (pattern === ".*") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Path patterns that contain /
|
||||
if (pattern.includes("/")) {
|
||||
const pathParts = pattern.split("/")
|
||||
|
|
@ -432,6 +620,9 @@ function formatAndCombineResults(files: string[], directories: string[], limit:
|
|||
*/
|
||||
async function execRipgrep(rgPath: string, args: string[], limit: number): Promise<string[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Extract the directory path from args (it's the last argument)
|
||||
const searchDir = args[args.length - 1]
|
||||
|
||||
const rgProcess = childProcess.spawn(rgPath, args)
|
||||
let output = ""
|
||||
let results: string[] = []
|
||||
|
|
@ -497,6 +688,7 @@ async function execRipgrep(rgPath: string, args: string[], limit: number): Promi
|
|||
// Process each complete line
|
||||
for (const line of lines) {
|
||||
if (line.trim() && results.length < limit) {
|
||||
// Keep the relative path as returned by ripgrep
|
||||
results.push(line)
|
||||
} else if (results.length >= limit) {
|
||||
break
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue