diff --git a/src/services/glob/__tests__/list-files.spec.ts b/src/services/glob/__tests__/list-files.spec.ts index 6c133a732a..d855388002 100644 --- a/src/services/glob/__tests__/list-files.spec.ts +++ b/src/services/glob/__tests__/list-files.spec.ts @@ -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") + }) +}) diff --git a/src/services/glob/constants.ts b/src/services/glob/constants.ts index 1ddcc37df9..380e4afaf3 100644 --- a/src/services/glob/constants.ts +++ b/src/services/glob/constants.ts @@ -20,5 +20,6 @@ export const DIRS_TO_IGNORE = [ "deps", "pkg", "Pods", + ".git", ".*", ] diff --git a/src/services/glob/list-files.ts b/src/services/glob/list-files.ts index 05fa8a1d7b..7347515784 100644 --- a/src/services/glob/list-files.ts +++ b/src/services/glob/list-files.ts @@ -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 +} + /** * 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 { + 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 { + // 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 { 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 + // 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) + } - // If recursive mode and not a ignored directory, scan subdirectories - if (recursive && !isDirectoryExplicitlyIgnored(dirName)) { - await scanDirectory(fullDirPath) + 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 + */ +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 shouldIncludeDirectory( - dirName: string, +function isIgnoredByGitignore( fullDirPath: string, basePath: string, ignoreInstance: ReturnType, ): boolean { - // Skip hidden directories if configured to ignore them - if (dirName.startsWith(".") && DIRS_TO_IGNORE.includes(".*")) { + const relativePath = path.relative(basePath, fullDirPath) + const normalizedPath = relativePath.replace(/\\/g, "/") + return 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 } - // Check against explicit ignore patterns - if (isDirectoryExplicitlyIgnored(dirName)) { + // 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 using the ignore library - // Calculate relative path from the base directory - const relativePath = path.relative(basePath, fullDirPath) - const normalizedPath = relativePath.replace(/\\/g, "/") + // Check against gitignore patterns + return !isIgnoredByGitignore(fullDirPath, context.basePath, context.ignoreInstance) +} - // Check if the directory is ignored by .gitignore - if (ignoreInstance.ignores(normalizedPath) || ignoreInstance.ignores(normalizedPath + "/")) { - return false +/** + * 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) } - return true + // 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 { 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