Sfoglia il codice sorgente

# 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 <[email protected]>
Murilo Pires 5 mesi fa
parent
commit
f530a71371

+ 361 - 18
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")
+	})
+})

+ 1 - 0
src/services/glob/constants.ts

@@ -20,5 +20,6 @@ export const DIRS_TO_IGNORE = [
 	"deps",
 	"pkg",
 	"Pods",
+	".git",
 	".*",
 ]

+ 222 - 30
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<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
+					// 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<typeof ignore>,
 ): 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<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