feat: implement unified .gitignore/.rooignore handling for code indexing

- Enhanced RooIgnoreController to support .gitignore fallback when .rooignore is missing/empty
- Added priority system: .rooignore → .gitignore → default patterns
- Removed dual filtering that caused conflicts between ignore systems
- Updated DirectoryScanner and FileWatcher to use unified ignore controller
- Fixed service factory to remove redundant ignore instance parameter
- Updated tests to reflect new constructor signatures

Fixes #5655
This commit is contained in:
Roo Code 2025-07-13 15:06:29 +00:00
parent e84dd0a2cf
commit df040a1d0e
6 changed files with 148 additions and 93 deletions

View file

@ -16,13 +16,15 @@ export class RooIgnoreController {
private ignoreInstance: Ignore
private disposables: vscode.Disposable[] = []
rooIgnoreContent: string | undefined
private gitIgnoreContent: string | undefined
private currentIgnoreSource: "rooignore" | "gitignore" | "default" | "none" = "none"
constructor(cwd: string) {
this.cwd = cwd
this.ignoreInstance = ignore()
this.rooIgnoreContent = undefined
// Set up file watcher for .rooignore
this.setupFileWatcher()
// Set up file watchers for both .rooignore and .gitignore
this.setupFileWatchers()
}
/**
@ -30,53 +32,131 @@ export class RooIgnoreController {
* Must be called after construction and before using the controller
*/
async initialize(): Promise<void> {
await this.loadRooIgnore()
await this.loadIgnorePatterns()
}
/**
* Set up the file watcher for .rooignore changes
* Set up file watchers for both .rooignore and .gitignore changes
*/
private setupFileWatcher(): void {
private setupFileWatchers(): void {
// Watch .rooignore
const rooignorePattern = new vscode.RelativePattern(this.cwd, ".rooignore")
const fileWatcher = vscode.workspace.createFileSystemWatcher(rooignorePattern)
const rooignoreWatcher = vscode.workspace.createFileSystemWatcher(rooignorePattern)
// Watch for changes and updates
// Watch .gitignore
const gitignorePattern = new vscode.RelativePattern(this.cwd, ".gitignore")
const gitignoreWatcher = vscode.workspace.createFileSystemWatcher(gitignorePattern)
// Watch for changes and updates to both files
this.disposables.push(
fileWatcher.onDidChange(() => {
this.loadRooIgnore()
rooignoreWatcher.onDidChange(() => {
this.loadIgnorePatterns()
}),
fileWatcher.onDidCreate(() => {
this.loadRooIgnore()
rooignoreWatcher.onDidCreate(() => {
this.loadIgnorePatterns()
}),
fileWatcher.onDidDelete(() => {
this.loadRooIgnore()
rooignoreWatcher.onDidDelete(() => {
this.loadIgnorePatterns()
}),
gitignoreWatcher.onDidChange(() => {
this.loadIgnorePatterns()
}),
gitignoreWatcher.onDidCreate(() => {
this.loadIgnorePatterns()
}),
gitignoreWatcher.onDidDelete(() => {
this.loadIgnorePatterns()
}),
)
// Add fileWatcher itself to disposables
this.disposables.push(fileWatcher)
// Add fileWatchers themselves to disposables
this.disposables.push(rooignoreWatcher, gitignoreWatcher)
}
/**
* Load ignore patterns with .gitignore fallback support
*/
private async loadIgnorePatterns(): Promise<void> {
try {
// Reset ignore instance to prevent duplicate patterns
this.ignoreInstance = ignore()
// Try to load .rooignore first
const rooIgnorePath = path.join(this.cwd, ".rooignore")
const rooIgnoreExists = await fileExistsAtPath(rooIgnorePath)
if (rooIgnoreExists) {
const content = await fs.readFile(rooIgnorePath, "utf8")
const trimmedContent = content.trim()
if (trimmedContent) {
// .rooignore exists and has content - use it exclusively
this.rooIgnoreContent = content
this.gitIgnoreContent = undefined
this.currentIgnoreSource = "rooignore"
this.ignoreInstance.add(content)
this.ignoreInstance.add(".rooignore")
return
} else {
// .rooignore exists but is empty - fall back to .gitignore
this.rooIgnoreContent = content
}
} else {
this.rooIgnoreContent = undefined
}
// Try to load .gitignore as fallback
const gitIgnorePath = path.join(this.cwd, ".gitignore")
const gitIgnoreExists = await fileExistsAtPath(gitIgnorePath)
if (gitIgnoreExists) {
const content = await fs.readFile(gitIgnorePath, "utf8")
this.gitIgnoreContent = content
this.currentIgnoreSource = "gitignore"
this.ignoreInstance.add(content)
this.ignoreInstance.add(".gitignore")
} else {
// Neither file exists - use default patterns for common directories
this.gitIgnoreContent = undefined
this.currentIgnoreSource = "default"
this.addDefaultIgnorePatterns()
}
} catch (error) {
console.error("Unexpected error loading ignore patterns:", error)
// Fallback to default patterns on error
this.currentIgnoreSource = "default"
this.addDefaultIgnorePatterns()
}
}
/**
* Add default ignore patterns for common directories that should typically be excluded
*/
private addDefaultIgnorePatterns(): void {
const defaultPatterns = [
"node_modules/",
"vendor/",
".git/",
".svn/",
".hg/",
"dist/",
"build/",
"out/",
"target/",
"*.log",
".DS_Store",
"Thumbs.db",
]
this.ignoreInstance.add(defaultPatterns)
}
/**
* Load custom patterns from .rooignore if it exists
* @deprecated Use loadIgnorePatterns() instead
*/
private async loadRooIgnore(): Promise<void> {
try {
// Reset ignore instance to prevent duplicate patterns
this.ignoreInstance = ignore()
const ignorePath = path.join(this.cwd, ".rooignore")
if (await fileExistsAtPath(ignorePath)) {
const content = await fs.readFile(ignorePath, "utf8")
this.rooIgnoreContent = content
this.ignoreInstance.add(content)
this.ignoreInstance.add(".rooignore")
} else {
this.rooIgnoreContent = undefined
}
} catch (error) {
// Should never happen: reading file failed even though it exists
console.error("Unexpected error loading .rooignore:", error)
}
await this.loadIgnorePatterns()
}
/**
@ -85,16 +165,12 @@ export class RooIgnoreController {
* @returns true if file is accessible, false if ignored
*/
validateAccess(filePath: string): boolean {
// Always allow access if .rooignore does not exist
if (!this.rooIgnoreContent) {
return true
}
try {
// Normalize path to be relative to cwd and use forward slashes
const absolutePath = path.resolve(this.cwd, filePath)
const relativePath = path.relative(this.cwd, absolutePath).toPosix()
// Ignore expects paths to be path.relative()'d
// Use the unified ignore instance which now handles .rooignore, .gitignore, or default patterns
return !this.ignoreInstance.ignores(relativePath)
} catch (error) {
// console.error(`Error validating access for ${filePath}:`, error)
@ -109,10 +185,7 @@ export class RooIgnoreController {
* @returns path of file that is being accessed if it is being accessed, undefined if command is allowed
*/
validateCommand(command: string): string | undefined {
// Always allow if no .rooignore exists
if (!this.rooIgnoreContent) {
return undefined
}
// Use unified ignore patterns (rooignore, gitignore, or defaults)
// Split command into parts and get the base command
const parts = command.trim().split(/\s+/)
@ -188,14 +261,30 @@ export class RooIgnoreController {
}
/**
* Get formatted instructions about the .rooignore file for the LLM
* @returns Formatted instructions or undefined if .rooignore doesn't exist
* Get the current ignore source being used
* @returns The source of ignore patterns currently in use
*/
getIgnoreSource(): "rooignore" | "gitignore" | "default" | "none" {
return this.currentIgnoreSource
}
/**
* Get formatted instructions about the ignore patterns for the LLM
* @returns Formatted instructions or undefined if no patterns are active
*/
getInstructions(): string | undefined {
if (!this.rooIgnoreContent) {
return undefined
}
switch (this.currentIgnoreSource) {
case "rooignore":
return `# .rooignore\n\n(The following is provided by a root-level .rooignore file where the user has specified files and directories that should not be accessed. When using list_files, you'll notice a ${LOCK_TEXT_SYMBOL} next to files that are blocked. Attempting to access the file's contents e.g. through read_file will result in an error.)\n\n${this.rooIgnoreContent}\n.rooignore`
return `# .rooignore\n\n(The following is provided by a root-level .rooignore file where the user has specified files and directories that should not be accessed. When using list_files, you'll notice a ${LOCK_TEXT_SYMBOL} next to files that are blocked. Attempting to access the file's contents e.g. through read_file will result in an error.)\n\n${this.rooIgnoreContent}\n.rooignore`
case "gitignore":
return `# .gitignore (fallback)\n\n(The following is provided by a root-level .gitignore file being used as fallback since no .rooignore file was found or it was empty. When using list_files, you'll notice a ${LOCK_TEXT_SYMBOL} next to files that are blocked. Attempting to access the file's contents e.g. through read_file will result in an error.)\n\n${this.gitIgnoreContent}\n.gitignore`
case "default":
return `# Default ignore patterns\n\n(The following default ignore patterns are being used since neither .rooignore nor .gitignore files were found. These patterns exclude common directories that are typically not needed for code analysis. When using list_files, you'll notice a ${LOCK_TEXT_SYMBOL} next to files that are blocked.)\n\nnode_modules/\nvendor/\n.git/\n.svn/\n.hg/\ndist/\nbuild/\nout/\ntarget/\n*.log\n.DS_Store\nThumbs.db`
default:
return undefined
}
}
}

View file

@ -236,7 +236,6 @@ export class CodeIndexManager {
this._cacheManager!,
)
const ignoreInstance = ignore()
const workspacePath = getWorkspacePath()
if (!workspacePath) {
@ -244,26 +243,10 @@ export class CodeIndexManager {
return
}
const ignorePath = path.join(workspacePath, ".gitignore")
try {
const content = await fs.readFile(ignorePath, "utf8")
ignoreInstance.add(content)
ignoreInstance.add(".gitignore")
} catch (error) {
// Should never happen: reading file failed even though it exists
console.error("Unexpected error loading .gitignore:", error)
TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
location: "_recreateServices",
})
}
// (Re)Create shared service instances
// (Re)Create shared service instances - ignore patterns are now handled by RooIgnoreController
const { embedder, vectorStore, scanner, fileWatcher } = this._serviceFactory.createServices(
this.context,
this._cacheManager!,
ignoreInstance,
)
// Validate embedder configuration before proceeding

View file

@ -104,13 +104,7 @@ describe("DirectoryScanner", () => {
ignores: vi.fn().mockReturnValue(false),
}
scanner = new DirectoryScanner(
mockEmbedder,
mockVectorStore,
mockCodeParser,
mockCacheManager,
mockIgnoreInstance,
)
scanner = new DirectoryScanner(mockEmbedder, mockVectorStore, mockCodeParser, mockCacheManager)
// Mock default implementations - create proper Stats object
mockStats = {

View file

@ -31,7 +31,6 @@ import { sanitizeErrorMessage } from "../shared/validation-helpers"
* Implementation of the file watcher interface
*/
export class FileWatcher implements IFileWatcher {
private ignoreInstance?: Ignore
private fileWatcher?: vscode.FileSystemWatcher
private ignoreController: RooIgnoreController
private accumulatedEvents: Map<string, { uri: vscode.Uri; type: "create" | "change" | "delete" }> = new Map()
@ -76,19 +75,18 @@ export class FileWatcher implements IFileWatcher {
private readonly cacheManager: CacheManager,
private embedder?: IEmbedder,
private vectorStore?: IVectorStore,
ignoreInstance?: Ignore,
ignoreController?: RooIgnoreController,
) {
this.ignoreController = ignoreController || new RooIgnoreController(workspacePath)
if (ignoreInstance) {
this.ignoreInstance = ignoreInstance
}
}
/**
* Initializes the file watcher
*/
async initialize(): Promise<void> {
// Initialize the ignore controller
await this.ignoreController.initialize()
// Create file watcher
const filePattern = new vscode.RelativePattern(
this.workspacePath,
@ -495,12 +493,8 @@ export class FileWatcher implements IFileWatcher {
}
}
// Check if file should be ignored
const relativeFilePath = generateRelativeFilePath(filePath, this.workspacePath)
if (
!this.ignoreController.validateAccess(filePath) ||
(this.ignoreInstance && this.ignoreInstance.ignores(relativeFilePath))
) {
// Check if file should be ignored using unified ignore controller
if (!this.ignoreController.validateAccess(filePath)) {
return {
path: filePath,
status: "skipped" as const,

View file

@ -35,7 +35,6 @@ export class DirectoryScanner implements IDirectoryScanner {
private readonly qdrantClient: IVectorStore,
private readonly codeParser: ICodeParser,
private readonly cacheManager: CacheManager,
private readonly ignoreInstance: Ignore,
) {}
/**
@ -70,17 +69,16 @@ export class DirectoryScanner implements IDirectoryScanner {
// Filter paths using .rooignore
const allowedPaths = ignoreController.filterPaths(filePaths)
// Filter by supported extensions, ignore patterns, and excluded directories
// Filter by supported extensions and excluded directories
const supportedPaths = allowedPaths.filter((filePath) => {
const ext = path.extname(filePath).toLowerCase()
const relativeFilePath = generateRelativeFilePath(filePath, scanWorkspace)
// Check if file is in an ignored directory using the shared helper
if (isPathInIgnoredDirectory(filePath)) {
return false
}
return scannerExtensions.includes(ext) && !this.ignoreInstance.ignores(relativeFilePath)
return scannerExtensions.includes(ext)
})
// Initialize tracking variables

View file

@ -144,9 +144,8 @@ export class CodeIndexServiceFactory {
embedder: IEmbedder,
vectorStore: IVectorStore,
parser: ICodeParser,
ignoreInstance: Ignore,
): DirectoryScanner {
return new DirectoryScanner(embedder, vectorStore, parser, this.cacheManager, ignoreInstance)
return new DirectoryScanner(embedder, vectorStore, parser, this.cacheManager)
}
/**
@ -157,9 +156,8 @@ export class CodeIndexServiceFactory {
embedder: IEmbedder,
vectorStore: IVectorStore,
cacheManager: CacheManager,
ignoreInstance: Ignore,
): IFileWatcher {
return new FileWatcher(this.workspacePath, context, cacheManager, embedder, vectorStore, ignoreInstance)
return new FileWatcher(this.workspacePath, context, cacheManager, embedder, vectorStore)
}
/**
@ -169,7 +167,6 @@ export class CodeIndexServiceFactory {
public createServices(
context: vscode.ExtensionContext,
cacheManager: CacheManager,
ignoreInstance: Ignore,
): {
embedder: IEmbedder
vectorStore: IVectorStore
@ -184,8 +181,8 @@ export class CodeIndexServiceFactory {
const embedder = this.createEmbedder()
const vectorStore = this.createVectorStore()
const parser = codeParser
const scanner = this.createDirectoryScanner(embedder, vectorStore, parser, ignoreInstance)
const fileWatcher = this.createFileWatcher(context, embedder, vectorStore, cacheManager, ignoreInstance)
const scanner = this.createDirectoryScanner(embedder, vectorStore, parser)
const fileWatcher = this.createFileWatcher(context, embedder, vectorStore, cacheManager)
return {
embedder,