Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions src/core/ignore/RooIgnoreController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ import * as vscode from "vscode"

export const LOCK_TEXT_SYMBOL = "\u{1F512}"

export type RooIgnoreControllerOptions = {
/**
* When true (default), watches `.rooignore` for changes and reloads patterns.
*
* Set to false for short-lived usages (e.g., one-off scans) to avoid
* creating long-lived VS Code file watchers.
*/
watch?: boolean
}

/**
* Controls LLM access to files by enforcing ignore patterns.
* Designed to be instantiated once in Cline.ts and passed to file manipulation services.
Expand All @@ -18,12 +28,14 @@ export class RooIgnoreController {
private disposables: vscode.Disposable[] = []
rooIgnoreContent: string | undefined

constructor(cwd: string) {
constructor(cwd: string, options?: RooIgnoreControllerOptions) {
this.cwd = cwd
this.ignoreInstance = ignore()
this.rooIgnoreContent = undefined
// Set up file watcher for .rooignore
this.setupFileWatcher()
// Set up file watcher for .rooignore (optional)
if (options?.watch !== false) {
this.setupFileWatcher()
}
}

/**
Expand Down
26 changes: 20 additions & 6 deletions src/services/code-index/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export class CodeIndexManager {
private _orchestrator: CodeIndexOrchestrator | undefined
private _searchService: CodeIndexSearchService | undefined
private _cacheManager: CacheManager | undefined
private _rooIgnoreController: RooIgnoreController | undefined

// Flag to prevent race conditions during error recovery
private _isRecoveringFromError = false
Expand Down Expand Up @@ -128,6 +129,8 @@ export class CodeIndexManager {
if (this._orchestrator) {
this._orchestrator.stopWatcher()
}
this._rooIgnoreController?.dispose()
this._rooIgnoreController = undefined
return { requiresRestart }
}

Expand Down Expand Up @@ -195,9 +198,6 @@ export class CodeIndexManager {
* Stops the file watcher and potentially cleans up resources.
*/
public stopWatcher(): void {
if (!this.isFeatureEnabled) {
return
}
if (this._orchestrator) {
this._orchestrator.stopWatcher()
}
Expand Down Expand Up @@ -231,6 +231,8 @@ export class CodeIndexManager {
// Log error but continue with recovery - clearing service instances is more important
console.error("Failed to clear error state during recovery:", error)
} finally {
this._rooIgnoreController?.dispose()
this._rooIgnoreController = undefined
// Force re-initialization by clearing service instances
// This ensures a clean slate even if state update failed
this._configManager = undefined
Expand All @@ -250,6 +252,8 @@ export class CodeIndexManager {
if (this._orchestrator) {
this.stopWatcher()
}
this._rooIgnoreController?.dispose()
this._rooIgnoreController = undefined
this._stateManager.dispose()
}

Expand Down Expand Up @@ -293,6 +297,8 @@ export class CodeIndexManager {
if (this._orchestrator) {
this.stopWatcher()
}
this._rooIgnoreController?.dispose()
this._rooIgnoreController = undefined
// Clear existing services to ensure clean state
this._orchestrator = undefined
this._searchService = undefined
Expand Down Expand Up @@ -328,16 +334,22 @@ export class CodeIndexManager {
})
}

// Create RooIgnoreController instance
// Create RooIgnoreController instance (long-lived while indexing is enabled)
const rooIgnoreController = new RooIgnoreController(workspacePath)
await rooIgnoreController.initialize()
try {
await rooIgnoreController.initialize()
this._rooIgnoreController = rooIgnoreController
} catch (error) {
rooIgnoreController.dispose()
throw error
}

// (Re)Create shared service instances
const { embedder, vectorStore, scanner, fileWatcher } = this._serviceFactory.createServices(
this.context,
this._cacheManager!,
ignoreInstance,
rooIgnoreController,
this._rooIgnoreController,
)

// Validate embedder configuration before proceeding
Expand Down Expand Up @@ -390,6 +402,8 @@ export class CodeIndexManager {
if (this._orchestrator) {
this._orchestrator.stopWatcher()
}
this._rooIgnoreController?.dispose()
this._rooIgnoreController = undefined
// Set state to indicate service is disabled
this._stateManager.setSystemState("Standby", "Code indexing is disabled")
return
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ vi.mock("../../cache-manager")
vi.mock("../../../core/ignore/RooIgnoreController", () => ({
RooIgnoreController: vi.fn().mockImplementation(() => ({
validateAccess: vi.fn().mockReturnValue(true),
dispose: vi.fn(),
})),
}))
vi.mock("ignore")
Expand Down Expand Up @@ -284,5 +285,33 @@ describe("FileWatcher", () => {

expect(mockWatcher.dispose).toHaveBeenCalled()
})

it("should dispose the internally-owned RooIgnoreController", async () => {
await fileWatcher.initialize()
const ignoreController = (fileWatcher as any).ignoreController as { dispose: () => void }
const disposeSpy = vi.spyOn(ignoreController, "dispose")
fileWatcher.dispose()

// The internally created controller should be disposed
expect(disposeSpy).toHaveBeenCalledTimes(1)
})

it("should not dispose a provided RooIgnoreController", async () => {
const providedIgnoreController = { validateAccess: vi.fn().mockReturnValue(true), dispose: vi.fn() }
const customWatcher = new FileWatcher(
"/mock/workspace",
mockContext,
mockCacheManager,
mockEmbedder,
mockVectorStore,
mockIgnoreInstance,
providedIgnoreController as any,
)

await customWatcher.initialize()
customWatcher.dispose()

expect(providedIgnoreController.dispose).not.toHaveBeenCalled()
})
})
})
22 changes: 22 additions & 0 deletions src/services/code-index/processors/__tests__/parser.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,28 @@ describe("CodeParser", () => {
vi.mocked(readFile).mockResolvedValue("// default test content")
})

it("should delete tree-sitter parse trees to avoid retaining WASM memory", async () => {
const deleteSpy = vi.fn()
mockLanguageParser.js.parser.parse = vi.fn((content: string) => ({
rootNode: {
text: content,
startPosition: { row: 0 },
endPosition: { row: content.split("\n").length - 1 },
children: [],
type: "program",
},
delete: deleteSpy,
}))
mockLanguageParser.js.query.captures.mockReturnValue([])

await parser.parseFile("test.js", {
content:
"/* This is a long test content string that exceeds 100 characters to trigger parsing. It spans enough content for fallback chunking. */",
})

expect(deleteSpy).toHaveBeenCalledTimes(1)
})

describe("parseFile", () => {
it("should return empty array for unsupported extensions", async () => {
const result = await parser.parseFile("test.unsupported")
Expand Down
22 changes: 21 additions & 1 deletion src/services/code-index/processors/__tests__/scanner.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,9 @@ vi.mock("vscode", () => ({
},
}))

vi.mock("../../../../core/ignore/RooIgnoreController")
vi.mock("../../../../core/ignore/RooIgnoreController", () => ({
RooIgnoreController: vi.fn(),
}))
vi.mock("ignore")

// Override the Jest-based mock with a vitest-compatible version
Expand Down Expand Up @@ -149,9 +151,27 @@ describe("DirectoryScanner", () => {
// Get and mock the listFiles function
const { listFiles } = await import("../../../glob/list-files")
vi.mocked(listFiles).mockResolvedValue([["test/file1.js", "test/file2.js"], false])

// Default: ensure short-lived RooIgnoreController is disposed
const { RooIgnoreController } = await import("../../../../core/ignore/RooIgnoreController")
const MockedRooIgnoreController = vi.mocked(RooIgnoreController as unknown as any)
MockedRooIgnoreController.mockImplementation(() => ({
initialize: vi.fn().mockResolvedValue(undefined),
filterPaths: vi.fn((paths: string[]) => paths),
dispose: vi.fn(),
}))
})

describe("scanDirectory", () => {
it("should dispose its short-lived RooIgnoreController", async () => {
const { RooIgnoreController } = await import("../../../../core/ignore/RooIgnoreController")
const MockedRooIgnoreController = vi.mocked(RooIgnoreController as unknown as any)

await scanner.scanDirectory("/test")
const instance = MockedRooIgnoreController.mock.results[0]?.value
expect(instance.dispose).toHaveBeenCalledTimes(1)
})

it("should skip files larger than MAX_FILE_SIZE_BYTES", async () => {
const { listFiles } = await import("../../../glob/list-files")
vi.mocked(listFiles).mockResolvedValue([["test/file1.js"], false])
Expand Down
7 changes: 6 additions & 1 deletion src/services/code-index/processors/file-watcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export class FileWatcher implements IFileWatcher {
private ignoreInstance?: Ignore
private fileWatcher?: vscode.FileSystemWatcher
private ignoreController: RooIgnoreController
private readonly ownsIgnoreController: boolean
private accumulatedEvents: Map<string, { uri: vscode.Uri; type: "create" | "change" | "delete" }> = new Map()
private batchProcessDebounceTimer?: NodeJS.Timeout
private readonly BATCH_DEBOUNCE_DELAY_MS = 500
Expand Down Expand Up @@ -82,6 +83,7 @@ export class FileWatcher implements IFileWatcher {
ignoreController?: RooIgnoreController,
batchSegmentThreshold?: number,
) {
this.ownsIgnoreController = ignoreController === undefined
this.ignoreController = ignoreController || new RooIgnoreController(workspacePath)
if (ignoreInstance) {
this.ignoreInstance = ignoreInstance
Expand Down Expand Up @@ -127,6 +129,9 @@ export class FileWatcher implements IFileWatcher {
if (this.batchProcessDebounceTimer) {
clearTimeout(this.batchProcessDebounceTimer)
}
if (this.ownsIgnoreController) {
this.ignoreController.dispose()
}
this._onDidStartBatchProcessing.dispose()
this._onBatchProgressUpdate.dispose()
this._onDidFinishBatchProcessing.dispose()
Expand Down Expand Up @@ -520,7 +525,7 @@ export class FileWatcher implements IFileWatcher {
// Check if file should be ignored
const relativeFilePath = generateRelativeFilePath(filePath, this.workspacePath)
if (
!this.ignoreController.validateAccess(filePath) ||
!this.ignoreController.validateAccess(relativeFilePath) ||
(this.ignoreInstance && this.ignoreInstance.ignores(relativeFilePath))
) {
return {
Expand Down
Loading
Loading