diff --git a/src/services/glob/__tests__/list-files-git-exclusion.spec.ts b/src/services/glob/__tests__/list-files-git-exclusion.spec.ts new file mode 100644 index 000000000000..e1c982a13903 --- /dev/null +++ b/src/services/glob/__tests__/list-files-git-exclusion.spec.ts @@ -0,0 +1,213 @@ +// npx vitest src/services/glob/__tests__/list-files-git-exclusion.spec.ts + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" +import * as childProcess from "child_process" +import * as path from "path" +import * as os from "os" +import * as fs from "fs" +import { listFiles } from "../list-files" + +// Mock child_process module +vi.mock("child_process", () => ({ + spawn: vi.fn(), +})) + +// Mock dependencies +vi.mock("../../ripgrep", () => ({ + getBinPath: vi.fn(async () => "/usr/bin/rg"), +})) + +vi.mock("vscode", () => ({ + env: { + appRoot: "/test/app/root", + }, + workspace: { + getConfiguration: vi.fn(() => ({ + get: vi.fn(() => undefined), + })), + }, +})) + +describe("list-files .git exclusion", () => { + let tempDir: string + let originalCwd: string + + beforeEach(async () => { + // Create a temporary directory for testing + tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "roo-git-exclusion-test-")) + originalCwd = process.cwd() + + // Mock fs.promises.access to simulate files exist + vi.spyOn(fs.promises, "access").mockResolvedValue(undefined) + vi.spyOn(fs.promises, "readdir").mockImplementation(async () => []) + }) + + afterEach(async () => { + // Clean up temporary directory + await fs.promises.rm(tempDir, { recursive: true, force: true }) + vi.restoreAllMocks() + }) + + it("should always exclude .git directories in recursive mode", async () => { + // Mock ripgrep spawn + const mockSpawn = vi.mocked(childProcess.spawn) + mockSpawn.mockImplementation((command: string, args: readonly string[]) => { + const mockProcess = { + stdout: { + on: (event: string, callback: (data: any) => void) => { + if (event === "data") { + // Simulate ripgrep output + setTimeout(() => callback(`${path.join(tempDir, "src", "index.ts")}\n`), 10) + } + }, + }, + stderr: { + on: vi.fn(), + }, + on: (event: string, callback: (code: number | null) => void) => { + if (event === "close") { + setTimeout(() => callback(0), 20) + } + }, + kill: vi.fn(), + } as any + return mockProcess + }) + + // Call listFiles in recursive mode + const [files, limitReached] = await listFiles(tempDir, true, 100) + + // Verify ripgrep was called with the .git exclusion pattern + expect(mockSpawn).toHaveBeenCalled() + const [rgPath, args] = mockSpawn.mock.calls[0] + + // Check that the arguments include the .git exclusion pattern + expect(args).toContain("-g") + expect(args).toContain("!**/.git/**") + }) + + it("should exclude .git directories even when explicitly targeting a hidden directory", async () => { + // Create a hidden directory path + const hiddenDirPath = path.join(tempDir, ".hidden-dir") + + // Mock ripgrep spawn + const mockSpawn = vi.mocked(childProcess.spawn) + mockSpawn.mockImplementation((command: string, args: readonly string[]) => { + const mockProcess = { + stdout: { + on: (event: string, callback: (data: any) => void) => { + if (event === "data") { + // Simulate ripgrep output + setTimeout(() => callback(`${path.join(hiddenDirPath, "file.ts")}\n`), 10) + } + }, + }, + stderr: { + on: vi.fn(), + }, + on: (event: string, callback: (code: number | null) => void) => { + if (event === "close") { + setTimeout(() => callback(0), 20) + } + }, + kill: vi.fn(), + } as any + return mockProcess + }) + + // Call listFiles targeting a hidden directory + const [files, limitReached] = await listFiles(hiddenDirPath, true, 100) + + // Verify ripgrep was called with the .git exclusion pattern + expect(mockSpawn).toHaveBeenCalled() + const [rgPath, args] = mockSpawn.mock.calls[0] + + // Even when targeting a hidden directory, .git should still be excluded + expect(args).toContain("-g") + expect(args).toContain("!**/.git/**") + + // But the command should have the special flags for hidden directories + expect(args).toContain("--no-ignore-vcs") + expect(args).toContain("--no-ignore") + }) + + it("should exclude .git directories in non-recursive mode", async () => { + // Mock ripgrep spawn + const mockSpawn = vi.mocked(childProcess.spawn) + mockSpawn.mockImplementation((command: string, args: readonly string[]) => { + const mockProcess = { + stdout: { + on: (event: string, callback: (data: any) => void) => { + if (event === "data") { + // Simulate ripgrep output for non-recursive + setTimeout(() => callback(`${path.join(tempDir, "file.ts")}\n`), 10) + } + }, + }, + stderr: { + on: vi.fn(), + }, + on: (event: string, callback: (code: number | null) => void) => { + if (event === "close") { + setTimeout(() => callback(0), 20) + } + }, + kill: vi.fn(), + } as any + return mockProcess + }) + + // Call listFiles in non-recursive mode + const [files, limitReached] = await listFiles(tempDir, false, 100) + + // Verify ripgrep was called with the .git exclusion patterns + expect(mockSpawn).toHaveBeenCalled() + const [rgPath, args] = mockSpawn.mock.calls[0] + + // Check that the arguments include the .git exclusion patterns for non-recursive mode + expect(args).toContain("-g") + expect(args).toContain("!.git") + expect(args).toContain("!.git/**") + }) + + it("should exclude .git even when it's the target directory", async () => { + // Create a .git directory path + const gitDirPath = path.join(tempDir, ".git") + + // Mock ripgrep spawn + const mockSpawn = vi.mocked(childProcess.spawn) + mockSpawn.mockImplementation((command: string, args: readonly string[]) => { + const mockProcess = { + stdout: { + on: (event: string, callback: (data: any) => void) => { + if (event === "data") { + // Simulate empty output (no files found) + setTimeout(() => callback(""), 10) + } + }, + }, + stderr: { + on: vi.fn(), + }, + on: (event: string, callback: (code: number | null) => void) => { + if (event === "close") { + setTimeout(() => callback(0), 20) + } + }, + kill: vi.fn(), + } as any + return mockProcess + }) + + // Call listFiles targeting .git directory + const [files, limitReached] = await listFiles(gitDirPath, true, 100) + + // Verify ripgrep was called with the .git exclusion pattern + expect(mockSpawn).toHaveBeenCalled() + const [rgPath, args] = mockSpawn.mock.calls[0] + + // .git should still be excluded even when it's the target + expect(args).toContain("-g") + expect(args).toContain("!**/.git/**") + }) +}) diff --git a/src/services/glob/list-files.ts b/src/services/glob/list-files.ts index 5366bbb84b46..ebe13e209574 100644 --- a/src/services/glob/list-files.ts +++ b/src/services/glob/list-files.ts @@ -260,14 +260,23 @@ function buildRecursiveArgs(dirPath: string): string[] { args.push("-g", "**/*") } + // CRITICAL: Always exclude .git directories for performance reasons + // This must come before other exclusions to ensure it's always applied + args.push("-g", "!**/.git/**") + // Apply directory exclusions for recursive searches for (const dir of DIRS_TO_IGNORE) { + // Skip .git since we already handled it above + if (dir === ".git") { + continue + } + // 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 + // Not targeting hidden dir: exclude all hidden directories (except .git which is already excluded) args.push("-g", `!**/.*/**`) } // If targeting hidden dir: don't add any exclusion for hidden directories @@ -305,8 +314,17 @@ function buildNonRecursiveArgs(): string[] { // Respect .gitignore in non-recursive mode too // (ripgrep respects .gitignore by default) + // CRITICAL: Always exclude .git directories for performance reasons + args.push("-g", "!.git") + args.push("-g", "!.git/**") + // Apply directory exclusions for non-recursive searches for (const dir of DIRS_TO_IGNORE) { + // Skip .git since we already handled it above + if (dir === ".git") { + continue + } + if (dir === ".*") { // 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