fix: always exclude .git directories from codebase indexing

- Added explicit .git exclusion in ripgrep arguments for both recursive and non-recursive modes
- This prevents indexing of .git folder contents which improves performance
- Added comprehensive tests to verify .git exclusion in various scenarios

Fixes #9048
This commit is contained in:
Roo Code 2025-11-05 09:46:10 +00:00
parent 6965e5c791
commit a59ca7b3c0
2 changed files with 232 additions and 1 deletions

View file

@ -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/**")
})
})

View file

@ -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