feat: add deterministic naming for Qdrant collections

- Add support for custom collection names via .roo/codebase-index.json
- Use git repository URL for deterministic collection naming across worktrees
- Fall back to workspace path hash when no git repo is available
- Normalize git URLs for consistent hashing
- Add comprehensive tests for new naming strategies

Fixes #7940
This commit is contained in:
Roo Code 2025-09-12 22:00:42 +00:00
parent c4c478023d
commit 351ca02394
2 changed files with 224 additions and 3 deletions

View file

@ -1,5 +1,6 @@
import { QdrantClient } from "@qdrant/js-client-rest"
import { createHash } from "crypto"
import * as fs from "fs"
import { QdrantVectorStore } from "../qdrant-client"
import { getWorkspacePath } from "../../../../utils/path"
@ -8,6 +9,7 @@ import { DEFAULT_MAX_SEARCH_RESULTS, DEFAULT_SEARCH_MIN_SCORE } from "../../cons
// Mocks
vitest.mock("@qdrant/js-client-rest")
vitest.mock("crypto")
vitest.mock("fs")
vitest.mock("../../../../utils/path")
vitest.mock("../../../../i18n", () => ({
t: (key: string, params?: any) => {
@ -68,6 +70,10 @@ describe("QdrantVectorStore", () => {
// Mock getWorkspacePath
;(getWorkspacePath as any).mockReturnValue(mockWorkspacePath)
// Mock fs functions - default to no git repo and no config file
;(fs.existsSync as any).mockReturnValue(false)
;(fs.readFileSync as any).mockReturnValue("")
vectorStore = new QdrantVectorStore(mockWorkspacePath, mockQdrantUrl, mockVectorSize, mockApiKey)
})
@ -82,6 +88,7 @@ describe("QdrantVectorStore", () => {
"User-Agent": "Roo-Code",
},
})
// When no git repo or custom config, should fall back to workspace-based hash
expect(createHash).toHaveBeenCalledWith("sha256")
expect(mockCreateHashInstance.update).toHaveBeenCalledWith(mockWorkspacePath)
expect(mockCreateHashInstance.digest).toHaveBeenCalledWith("hex")
@ -89,6 +96,93 @@ describe("QdrantVectorStore", () => {
expect((vectorStore as any).collectionName).toBe(expectedCollectionName)
expect((vectorStore as any).vectorSize).toBe(mockVectorSize)
})
it("should use custom collection name from .roo/codebase-index.json if available", () => {
// Mock the config file to exist and contain a custom collection name
;(fs.existsSync as any).mockImplementation((path: string) => {
return path.includes(".roo/codebase-index.json")
})
;(fs.readFileSync as any).mockReturnValue(
JSON.stringify({
collectionName: "my-custom-collection",
}),
)
const customVectorStore = new QdrantVectorStore(mockWorkspacePath, mockQdrantUrl, mockVectorSize, mockApiKey)
// Should use the sanitized custom collection name
expect((customVectorStore as any).collectionName).toBe("my-custom-collection")
})
it("should use git repository URL for deterministic naming when available", () => {
// Mock git config to exist
;(fs.existsSync as any).mockImplementation((path: string) => {
return path.includes(".git")
})
;(fs.readFileSync as any).mockImplementation((path: string) => {
if (path.includes(".git/config")) {
return `[remote "origin"]
url = git@github.com:user/repo.git`
}
return ""
})
// Mock createHash for the git URL
const gitUrlHash = "gitrepo1234567890abcdef"
mockCreateHashInstance.digest.mockReturnValueOnce(gitUrlHash)
const gitVectorStore = new QdrantVectorStore(mockWorkspacePath, mockQdrantUrl, mockVectorSize, mockApiKey)
// Should use repo- prefix with git URL hash
expect((gitVectorStore as any).collectionName).toBe(`repo-${gitUrlHash.substring(0, 16)}`)
// Verify it hashed the normalized git URL
expect(mockCreateHashInstance.update).toHaveBeenCalledWith("https://github.com/user/repo")
})
it("should normalize different git URL formats consistently", () => {
const testCases = [
{ input: "git@github.com:user/repo.git", expected: "https://github.com/user/repo" },
{ input: "https://github.com/user/repo.git", expected: "https://github.com/user/repo" },
{ input: "ssh://git@github.com/user/repo.git", expected: "https://github.com/user/repo" },
{ input: "https://user@github.com/user/repo.git", expected: "https://github.com/user/repo" },
]
testCases.forEach(({ input, expected }) => {
vitest.clearAllMocks()
;(fs.existsSync as any).mockImplementation((path: string) => path.includes(".git"))
;(fs.readFileSync as any).mockImplementation((path: string) => {
if (path.includes(".git/config")) {
return `[remote "origin"]\n\turl = ${input}`
}
return ""
})
const gitUrlHash = "normalized1234567890abcdef"
mockCreateHashInstance.digest.mockReturnValueOnce(gitUrlHash)
const store = new QdrantVectorStore(mockWorkspacePath, mockQdrantUrl, mockVectorSize, mockApiKey)
// Verify it hashed the normalized URL
expect(mockCreateHashInstance.update).toHaveBeenCalledWith(expected)
})
})
it("should sanitize custom collection names to be Qdrant-compatible", () => {
;(fs.existsSync as any).mockImplementation((path: string) => {
return path.includes(".roo/codebase-index.json")
})
;(fs.readFileSync as any).mockReturnValue(
JSON.stringify({
collectionName: "My Custom Collection!@#$%",
}),
)
const customVectorStore = new QdrantVectorStore(mockWorkspacePath, mockQdrantUrl, mockVectorSize, mockApiKey)
// Should sanitize the collection name
expect((customVectorStore as any).collectionName).toBe("my-custom-collection")
})
it("should handle constructor with default URL when none provided", () => {
const vectorStoreWithDefaults = new QdrantVectorStore(mockWorkspacePath, undefined as any, mockVectorSize)

View file

@ -1,11 +1,13 @@
import { QdrantClient, Schemas } from "@qdrant/js-client-rest"
import { createHash } from "crypto"
import * as path from "path"
import * as fs from "fs"
import { getWorkspacePath } from "../../../utils/path"
import { IVectorStore } from "../interfaces/vector-store"
import { Payload, VectorStoreSearchResult } from "../interfaces"
import { DEFAULT_MAX_SEARCH_RESULTS, DEFAULT_SEARCH_MIN_SCORE } from "../constants"
import { t } from "../../../i18n"
import { getGitRepositoryInfo } from "../../../utils/git"
/**
* Qdrant implementation of the vector store interface
@ -77,10 +79,135 @@ export class QdrantVectorStore implements IVectorStore {
})
}
// Generate collection name from workspace path
const hash = createHash("sha256").update(workspacePath).digest("hex")
// Generate deterministic collection name
this.vectorSize = vectorSize
this.collectionName = `ws-${hash.substring(0, 16)}`
this.collectionName = this.generateCollectionName(workspacePath)
}
/**
* Generates a deterministic collection name based on repository or workspace
* @param workspacePath Path to the workspace
* @returns Collection name
*/
private generateCollectionName(workspacePath: string): string {
// First, check for a custom collection name in .roo/codebase-index.json
const customName = this.loadCustomCollectionName(workspacePath)
if (customName) {
// Sanitize the custom name to ensure it's valid for Qdrant
return this.sanitizeCollectionName(customName)
}
// Try to get git repository information for deterministic naming
const gitInfo = this.getGitInfoSync(workspacePath)
if (gitInfo?.repositoryUrl) {
// Use repository URL to generate a deterministic name
// This ensures the same collection name across worktrees and developers
const hash = createHash("sha256").update(gitInfo.repositoryUrl).digest("hex")
return `repo-${hash.substring(0, 16)}`
}
// Fallback to workspace path hash (original behavior)
const hash = createHash("sha256").update(workspacePath).digest("hex")
return `ws-${hash.substring(0, 16)}`
}
/**
* Loads custom collection name from .roo/codebase-index.json if it exists
* @param workspacePath Path to the workspace
* @returns Custom collection name or undefined
*/
private loadCustomCollectionName(workspacePath: string): string | undefined {
try {
const configPath = path.join(workspacePath, ".roo", "codebase-index.json")
if (fs.existsSync(configPath)) {
const config = JSON.parse(fs.readFileSync(configPath, "utf8"))
if (config.collectionName && typeof config.collectionName === "string") {
return config.collectionName
}
}
} catch (error) {
// Ignore errors reading config file
console.warn(
`[QdrantVectorStore] Could not read custom collection name from .roo/codebase-index.json:`,
error,
)
}
return undefined
}
/**
* Synchronously gets git repository information
* @param workspacePath Path to the workspace
* @returns Git repository info or undefined
*/
private getGitInfoSync(workspacePath: string): { repositoryUrl?: string } | undefined {
try {
const gitDir = path.join(workspacePath, ".git")
// Check if .git directory exists
if (!fs.existsSync(gitDir)) {
return undefined
}
// Try to read git config file
const configPath = path.join(gitDir, "config")
if (fs.existsSync(configPath)) {
const configContent = fs.readFileSync(configPath, "utf8")
// Extract remote URL
const urlMatch = configContent.match(/url\s*=\s*(.+?)(?:\r?\n|$)/m)
if (urlMatch && urlMatch[1]) {
const url = urlMatch[1].trim()
// Normalize the URL to ensure consistency
const normalizedUrl = this.normalizeGitUrl(url)
return { repositoryUrl: normalizedUrl }
}
}
} catch (error) {
// Ignore errors and fall back to workspace-based naming
console.warn(`[QdrantVectorStore] Could not read git repository info:`, error)
}
return undefined
}
/**
* Normalizes a git URL for consistent hashing
* @param url Git URL to normalize
* @returns Normalized URL
*/
private normalizeGitUrl(url: string): string {
// Remove credentials
let normalized = url.replace(/^https?:\/\/[^@]+@/, "https://")
// Convert SSH to HTTPS format for consistency
if (normalized.startsWith("git@")) {
normalized = normalized.replace(/^git@([^:]+):/, "https://$1/")
} else if (normalized.startsWith("ssh://")) {
normalized = normalized.replace(/^ssh:\/\/(?:git@)?([^\/]+)\//, "https://$1/")
}
// Remove .git suffix
normalized = normalized.replace(/\.git$/, "")
// Convert to lowercase for consistency
normalized = normalized.toLowerCase()
return normalized
}
/**
* Sanitizes a collection name to ensure it's valid for Qdrant
* @param name Collection name to sanitize
* @returns Sanitized collection name
*/
private sanitizeCollectionName(name: string): string {
// Qdrant collection names must be alphanumeric with underscores or hyphens
// Max length is typically 255 characters
return name
.toLowerCase()
.replace(/[^a-z0-9_-]/g, "-")
.replace(/^-+|-+$/g, "") // Remove leading/trailing hyphens
.substring(0, 255)
}
/**