Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.

Commit 929f9a7

Browse files
committed
fix: clean up stale vectors on directory deletion and add Qdrant collection alias
- Detect directory deletions in FileWatcher by checking cache for child paths, queuing all children for vector deletion - Add human-readable Qdrant collection alias based on workspace folder name - Add tests for both features Addresses #12115
1 parent 8b12f21 commit 929f9a7

4 files changed

Lines changed: 220 additions & 3 deletions

File tree

src/services/code-index/processors/__tests__/file-watcher.spec.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ describe("FileWatcher", () => {
108108
getHash: vi.fn(),
109109
updateHash: vi.fn(),
110110
deleteHash: vi.fn(),
111+
getAllHashes: vi.fn().mockReturnValue({}),
111112
}
112113

113114
mockEmbedder = {
@@ -277,6 +278,75 @@ describe("FileWatcher", () => {
277278
})
278279
})
279280

281+
describe("directory deletion handling", () => {
282+
it("should queue all cached child files for deletion when a directory is deleted", async () => {
283+
// Setup cache with files that are children of a directory
284+
const directoryPath = "/mock/workspace/src/components"
285+
mockCacheManager.getAllHashes.mockReturnValue({
286+
[`${directoryPath}/Button.tsx`]: "hash1",
287+
[`${directoryPath}/Modal.tsx`]: "hash2",
288+
[`${directoryPath}/utils/helpers.ts`]: "hash3",
289+
["/mock/workspace/src/index.ts"]: "hash4",
290+
})
291+
292+
await fileWatcher.initialize()
293+
294+
// Trigger directory deletion event
295+
await mockOnDidDelete({ fsPath: directoryPath })
296+
297+
// Wait for batch processing
298+
await new Promise((resolve) => setTimeout(resolve, 600))
299+
300+
// Verify that deletePointsByMultipleFilePaths was called with all child paths
301+
expect(mockVectorStore.deletePointsByMultipleFilePaths).toHaveBeenCalled()
302+
const deletedPaths = mockVectorStore.deletePointsByMultipleFilePaths.mock.calls[0][0]
303+
expect(deletedPaths).toContain(`${directoryPath}/Button.tsx`)
304+
expect(deletedPaths).toContain(`${directoryPath}/Modal.tsx`)
305+
expect(deletedPaths).toContain(`${directoryPath}/utils/helpers.ts`)
306+
// Should NOT include files outside the deleted directory
307+
expect(deletedPaths).not.toContain("/mock/workspace/src/index.ts")
308+
})
309+
310+
it("should handle single file deletion normally when no cached children exist", async () => {
311+
const filePath = "/mock/workspace/src/index.ts"
312+
mockCacheManager.getAllHashes.mockReturnValue({
313+
[filePath]: "hash1",
314+
["/mock/workspace/src/other.ts"]: "hash2",
315+
})
316+
317+
await fileWatcher.initialize()
318+
319+
// Trigger single file deletion
320+
await mockOnDidDelete({ fsPath: filePath })
321+
322+
// Wait for batch processing
323+
await new Promise((resolve) => setTimeout(resolve, 600))
324+
325+
// Should process deletion for just the one file
326+
expect(mockVectorStore.deletePointsByMultipleFilePaths).toHaveBeenCalled()
327+
const deletedPaths = mockVectorStore.deletePointsByMultipleFilePaths.mock.calls[0][0]
328+
expect(deletedPaths).toContain(filePath)
329+
expect(deletedPaths).not.toContain("/mock/workspace/src/other.ts")
330+
})
331+
332+
it("should handle deletion of path not in cache", async () => {
333+
mockCacheManager.getAllHashes.mockReturnValue({
334+
["/mock/workspace/src/other.ts"]: "hash1",
335+
})
336+
337+
await fileWatcher.initialize()
338+
339+
// Trigger deletion of a file not in cache
340+
await mockOnDidDelete({ fsPath: "/mock/workspace/src/nonexistent.ts" })
341+
342+
// Wait for batch processing
343+
await new Promise((resolve) => setTimeout(resolve, 600))
344+
345+
// Should still attempt deletion (the vector store will handle the no-op)
346+
expect(mockVectorStore.deletePointsByMultipleFilePaths).toHaveBeenCalled()
347+
})
348+
})
349+
280350
describe("dispose", () => {
281351
it("should dispose of the watcher when disposed", async () => {
282352
await fileWatcher.initialize()

src/services/code-index/processors/file-watcher.ts

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import * as path from "path"
12
import * as vscode from "vscode"
23
import {
34
QDRANT_CODE_BLOCK_NAMESPACE,
@@ -152,11 +153,35 @@ export class FileWatcher implements IFileWatcher {
152153
}
153154

154155
/**
155-
* Handles file deletion events
156-
* @param uri URI of the deleted file
156+
* Handles file deletion events.
157+
* When a directory is deleted, VSCode's FileSystemWatcher may not fire
158+
* individual delete events for each file inside it. This method detects
159+
* directory deletions by checking the cache for any files whose paths
160+
* start with the deleted path prefix, and queues them all for deletion.
161+
* @param uri URI of the deleted file or directory
157162
*/
158163
private async handleFileDeleted(uri: vscode.Uri): Promise<void> {
159-
this.accumulatedEvents.set(uri.fsPath, { uri, type: "delete" })
164+
const deletedPath = uri.fsPath
165+
166+
// Check if any cached files have this as a prefix (directory deletion)
167+
const allHashes = this.cacheManager.getAllHashes()
168+
const childPaths = Object.keys(allHashes).filter(
169+
(cachedPath) => cachedPath.startsWith(deletedPath + path.sep) || cachedPath === deletedPath,
170+
)
171+
172+
if (childPaths.length > 1) {
173+
// Directory was deleted - queue all child files for deletion
174+
for (const childPath of childPaths) {
175+
this.accumulatedEvents.set(childPath, {
176+
uri: vscode.Uri.file(childPath),
177+
type: "delete",
178+
})
179+
}
180+
} else {
181+
// Single file deletion (or a file matching exactly)
182+
this.accumulatedEvents.set(deletedPath, { uri, type: "delete" })
183+
}
184+
160185
this.scheduleBatchProcessing()
161186
}
162187

src/services/code-index/vector-store/__tests__/qdrant-client.spec.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ const mockQdrantClientInstance = {
3535
createCollection: vitest.fn(),
3636
deleteCollection: vitest.fn(),
3737
createPayloadIndex: vitest.fn(),
38+
updateCollectionAliases: vitest.fn(),
3839
upsert: vitest.fn(),
3940
query: vitest.fn(),
4041
delete: vitest.fn(),
@@ -980,6 +981,88 @@ describe("QdrantVectorStore", () => {
980981
})
981982
})
982983

984+
describe("workspace alias creation", () => {
985+
it("should create a workspace alias during initialization", async () => {
986+
mockQdrantClientInstance.getCollection.mockRejectedValue({
987+
response: { status: 404 },
988+
message: "Not found",
989+
})
990+
mockQdrantClientInstance.createCollection.mockResolvedValue(true as any)
991+
mockQdrantClientInstance.createPayloadIndex.mockResolvedValue({} as any)
992+
mockQdrantClientInstance.updateCollectionAliases.mockResolvedValue(true as any)
993+
vitest.spyOn(console, "log").mockImplementation(() => {})
994+
995+
await vectorStore.initialize()
996+
997+
expect(mockQdrantClientInstance.updateCollectionAliases).toHaveBeenCalledTimes(1)
998+
expect(mockQdrantClientInstance.updateCollectionAliases).toHaveBeenCalledWith({
999+
actions: [
1000+
{
1001+
create_alias: {
1002+
collection_name: expectedCollectionName,
1003+
alias_name: "workspace",
1004+
},
1005+
},
1006+
],
1007+
})
1008+
;(console.log as any).mockRestore()
1009+
})
1010+
1011+
it("should not fail initialization if alias creation fails", async () => {
1012+
mockQdrantClientInstance.getCollection.mockRejectedValue({
1013+
response: { status: 404 },
1014+
message: "Not found",
1015+
})
1016+
mockQdrantClientInstance.createCollection.mockResolvedValue(true as any)
1017+
mockQdrantClientInstance.createPayloadIndex.mockResolvedValue({} as any)
1018+
mockQdrantClientInstance.updateCollectionAliases.mockRejectedValue(new Error("Alias creation failed"))
1019+
vitest.spyOn(console, "warn").mockImplementation(() => {})
1020+
1021+
const result = await vectorStore.initialize()
1022+
1023+
// Should still succeed even if alias creation fails
1024+
expect(result).toBe(true)
1025+
expect(mockQdrantClientInstance.updateCollectionAliases).toHaveBeenCalledTimes(1)
1026+
expect(console.warn).toHaveBeenCalledWith(
1027+
expect.stringContaining("Could not create workspace alias"),
1028+
expect.any(String),
1029+
)
1030+
;(console.warn as any).mockRestore()
1031+
})
1032+
1033+
it("should sanitize workspace name for alias", async () => {
1034+
// Create a vector store with a workspace path that has special characters
1035+
const specialPathStore = new QdrantVectorStore(
1036+
"/test/My Project (v2)",
1037+
mockQdrantUrl,
1038+
mockVectorSize,
1039+
mockApiKey,
1040+
)
1041+
mockQdrantClientInstance.getCollection.mockRejectedValue({
1042+
response: { status: 404 },
1043+
message: "Not found",
1044+
})
1045+
mockQdrantClientInstance.createCollection.mockResolvedValue(true as any)
1046+
mockQdrantClientInstance.createPayloadIndex.mockResolvedValue({} as any)
1047+
mockQdrantClientInstance.updateCollectionAliases.mockResolvedValue(true as any)
1048+
vitest.spyOn(console, "log").mockImplementation(() => {})
1049+
1050+
await specialPathStore.initialize()
1051+
1052+
expect(mockQdrantClientInstance.updateCollectionAliases).toHaveBeenCalledWith({
1053+
actions: [
1054+
{
1055+
create_alias: {
1056+
collection_name: expect.any(String),
1057+
alias_name: "my-project--v2-",
1058+
},
1059+
},
1060+
],
1061+
})
1062+
;(console.log as any).mockRestore()
1063+
})
1064+
})
1065+
9831066
it("should return true when collection exists", async () => {
9841067
mockQdrantClientInstance.getCollection.mockResolvedValue({
9851068
config: {

src/services/code-index/vector-store/qdrant-client.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,10 @@ export class QdrantVectorStore implements IVectorStore {
194194

195195
// Create payload indexes
196196
await this._createPayloadIndexes()
197+
198+
// Create a human-readable alias for the collection using the workspace folder name
199+
await this._createWorkspaceAlias()
200+
197201
return created
198202
} catch (error: any) {
199203
const errorMessage = error?.message || error
@@ -331,6 +335,41 @@ export class QdrantVectorStore implements IVectorStore {
331335
}
332336
}
333337

338+
/**
339+
* Creates a human-readable Qdrant alias for the collection using the workspace folder name.
340+
* This allows external tools to discover and query the collection without reverse-engineering
341+
* the hashed naming scheme. Non-fatal: failures are logged but do not block initialization.
342+
*/
343+
private async _createWorkspaceAlias(): Promise<void> {
344+
try {
345+
const workspaceName = path.basename(this.workspacePath)
346+
if (!workspaceName) {
347+
return
348+
}
349+
350+
// Sanitize the alias name: only allow alphanumeric, hyphens, underscores
351+
const aliasName = workspaceName.replace(/[^a-zA-Z0-9_-]/g, "-").toLowerCase()
352+
if (!aliasName) {
353+
return
354+
}
355+
356+
await this.client.updateCollectionAliases({
357+
actions: [
358+
{
359+
create_alias: {
360+
collection_name: this.collectionName,
361+
alias_name: aliasName,
362+
},
363+
},
364+
],
365+
})
366+
console.log(`[QdrantVectorStore] Created alias "${aliasName}" for collection "${this.collectionName}"`)
367+
} catch (aliasError: any) {
368+
// Non-fatal - log warning but don't fail initialization
369+
console.warn(`[QdrantVectorStore] Could not create workspace alias:`, aliasError?.message || aliasError)
370+
}
371+
}
372+
334373
/**
335374
* Upserts points into the vector store
336375
* @param points Array of points to upsert

0 commit comments

Comments
 (0)