feat: add branch isolation for codebase indexing

- Add codebaseIndexBranchIsolation configuration option
- Update QdrantVectorStore to use branch-specific collection names
- Add Git branch detection utilities (getCurrentBranch, sanitizeBranchName)
- Update UI with branch isolation toggle in Advanced Configuration
- Add i18n translations for new UI elements
- Add comprehensive tests for branch isolation functionality

When enabled, each Git branch will have its own separate index collection,
ensuring search results always reflect the current branch's code.

Addresses #8565
This commit is contained in:
Roo Code 2025-10-08 11:05:47 +00:00
parent eeaafef786
commit f34233be29
9 changed files with 481 additions and 5 deletions

View file

@ -20,6 +20,7 @@ export const CODEBASE_INDEX_DEFAULTS = {
export const codebaseIndexConfigSchema = z.object({
codebaseIndexEnabled: z.boolean().optional(),
codebaseIndexBranchIsolation: z.boolean().optional(),
codebaseIndexQdrantUrl: z.string().optional(),
codebaseIndexEmbedderProvider: z
.enum(["openai", "ollama", "openai-compatible", "gemini", "mistral", "vercel-ai-gateway"])

View file

@ -11,6 +11,7 @@ import { getDefaultModelId, getModelDimension, getModelScoreThreshold } from "..
*/
export class CodeIndexConfigManager {
private codebaseIndexEnabled: boolean = true
private codebaseIndexBranchIsolation: boolean = false
private embedderProvider: EmbedderProvider = "openai"
private modelId?: string
private modelDimension?: number
@ -45,6 +46,7 @@ export class CodeIndexConfigManager {
// Load configuration from storage
const codebaseIndexConfig = this.contextProxy?.getGlobalState("codebaseIndexConfig") ?? {
codebaseIndexEnabled: true,
codebaseIndexBranchIsolation: false,
codebaseIndexQdrantUrl: "http://localhost:6333",
codebaseIndexEmbedderProvider: "openai",
codebaseIndexEmbedderBaseUrl: "",
@ -55,6 +57,7 @@ export class CodeIndexConfigManager {
const {
codebaseIndexEnabled,
codebaseIndexBranchIsolation,
codebaseIndexQdrantUrl,
codebaseIndexEmbedderProvider,
codebaseIndexEmbedderBaseUrl,
@ -74,6 +77,7 @@ export class CodeIndexConfigManager {
// Update instance variables with configuration
this.codebaseIndexEnabled = codebaseIndexEnabled ?? true
this.codebaseIndexBranchIsolation = codebaseIndexBranchIsolation ?? false
this.qdrantUrl = codebaseIndexQdrantUrl
this.qdrantApiKey = qdrantApiKey ?? ""
this.searchMinScore = codebaseIndexSearchMinScore
@ -156,6 +160,7 @@ export class CodeIndexConfigManager {
// Capture the ACTUAL previous state before loading new configuration
const previousConfigSnapshot: PreviousConfigSnapshot = {
enabled: this.codebaseIndexEnabled,
branchIsolation: this.codebaseIndexBranchIsolation,
configured: this.isConfigured(),
embedderProvider: this.embedderProvider,
modelId: this.modelId,
@ -259,6 +264,7 @@ export class CodeIndexConfigManager {
// Handle null/undefined values safely
const prevEnabled = prev?.enabled ?? false
const prevBranchIsolation = prev?.branchIsolation ?? false
const prevConfigured = prev?.configured ?? false
const prevProvider = prev?.embedderProvider ?? "openai"
const prevOpenAiKey = prev?.openAiKey ?? ""
@ -282,6 +288,11 @@ export class CodeIndexConfigManager {
return true
}
// 2.5. Branch isolation setting changed
if (prevBranchIsolation !== this.codebaseIndexBranchIsolation) {
return true
}
// 3. If wasn't ready before and isn't ready now, no restart needed
if ((!prevEnabled || !prevConfigured) && (!this.codebaseIndexEnabled || !nowConfigured)) {
return false
@ -480,4 +491,12 @@ export class CodeIndexConfigManager {
public get currentSearchMaxResults(): number {
return this.searchMaxResults ?? DEFAULT_MAX_SEARCH_RESULTS
}
/**
* Gets whether branch isolation is enabled for codebase indexing.
* When enabled, each Git branch will have its own separate index.
*/
public get isBranchIsolationEnabled(): boolean {
return this.codebaseIndexBranchIsolation
}
}

View file

@ -26,6 +26,7 @@ export interface CodeIndexConfig {
*/
export type PreviousConfigSnapshot = {
enabled: boolean
branchIsolation: boolean
configured: boolean
embedderProvider: EmbedderProvider
modelId?: string

View file

@ -145,8 +145,15 @@ export class CodeIndexServiceFactory {
throw new Error(t("embeddings:serviceFactory.qdrantUrlMissing"))
}
// Assuming constructor is updated: new QdrantVectorStore(workspacePath, url, vectorSize, apiKey?)
return new QdrantVectorStore(this.workspacePath, config.qdrantUrl, vectorSize, config.qdrantApiKey)
// Pass branch isolation setting to QdrantVectorStore
const branchIsolationEnabled = this.configManager.isBranchIsolationEnabled
return new QdrantVectorStore(
this.workspacePath,
config.qdrantUrl,
vectorSize,
config.qdrantApiKey,
branchIsolationEnabled,
)
}
/**

View file

@ -0,0 +1,312 @@
import { describe, it, expect, beforeEach, vi } from "vitest"
import { QdrantClient } from "@qdrant/js-client-rest"
import { QdrantVectorStore } from "../qdrant-client"
import * as gitUtils from "../../../../utils/git"
// Mock dependencies
vi.mock("@qdrant/js-client-rest")
vi.mock("crypto", () => ({
createHash: vi.fn(() => ({
update: vi.fn(() => ({
digest: vi.fn(() => "mockedhash1234567890abcdef"),
})),
})),
}))
vi.mock("../../../../utils/git", () => ({
getCurrentBranch: vi.fn(),
sanitizeBranchName: vi.fn(),
}))
describe("QdrantVectorStore - Branch Isolation", () => {
let mockQdrantClientInstance: any
const mockWorkspacePath = "/test/workspace"
const mockQdrantUrl = "http://localhost:6333"
const mockVectorSize = 1536
const mockApiKey = "test-api-key"
beforeEach(() => {
vi.clearAllMocks()
mockQdrantClientInstance = {
getCollection: vi.fn(),
createCollection: vi.fn(),
deleteCollection: vi.fn(),
createPayloadIndex: vi.fn(),
upsert: vi.fn(),
query: vi.fn(),
delete: vi.fn(),
}
;(QdrantClient as any).mockImplementation(() => mockQdrantClientInstance)
})
describe("Branch Isolation Disabled", () => {
it("should use workspace-only collection name when branch isolation is disabled", async () => {
const vectorStore = new QdrantVectorStore(
mockWorkspacePath,
mockQdrantUrl,
mockVectorSize,
mockApiKey,
false, // branchIsolationEnabled = false
)
// Mock collection doesn't exist
mockQdrantClientInstance.getCollection.mockRejectedValue({
status: 404,
message: "Collection not found",
})
await vectorStore.initialize()
// Should create collection with workspace-only name
expect(mockQdrantClientInstance.createCollection).toHaveBeenCalledWith(
"ws-mockedhash123456",
expect.any(Object),
)
expect(gitUtils.getCurrentBranch).not.toHaveBeenCalled()
})
it("should return null for getCurrentBranch when branch isolation is disabled", () => {
const vectorStore = new QdrantVectorStore(
mockWorkspacePath,
mockQdrantUrl,
mockVectorSize,
mockApiKey,
false,
)
expect(vectorStore.getCurrentBranch()).toBeNull()
})
})
describe("Branch Isolation Enabled", () => {
it("should use branch-specific collection name when on a Git branch", async () => {
;(gitUtils.getCurrentBranch as any).mockResolvedValue("feature/test-branch")
;(gitUtils.sanitizeBranchName as any).mockReturnValue("feature-test-branch")
const vectorStore = new QdrantVectorStore(
mockWorkspacePath,
mockQdrantUrl,
mockVectorSize,
mockApiKey,
true, // branchIsolationEnabled = true
)
// Mock collection doesn't exist
mockQdrantClientInstance.getCollection.mockRejectedValue({
status: 404,
message: "Collection not found",
})
await vectorStore.initialize()
// Should detect current branch
expect(gitUtils.getCurrentBranch).toHaveBeenCalledWith(mockWorkspacePath)
expect(gitUtils.sanitizeBranchName).toHaveBeenCalledWith("feature/test-branch")
// Should create collection with branch-specific name
expect(mockQdrantClientInstance.createCollection).toHaveBeenCalledWith(
"ws-mockedhash123456-br-feature-test-branch",
expect.any(Object),
)
})
it("should use workspace-only collection name when in detached HEAD state", async () => {
;(gitUtils.getCurrentBranch as any).mockResolvedValue(null) // Detached HEAD
const vectorStore = new QdrantVectorStore(
mockWorkspacePath,
mockQdrantUrl,
mockVectorSize,
mockApiKey,
true,
)
// Mock collection doesn't exist
mockQdrantClientInstance.getCollection.mockRejectedValue({
status: 404,
message: "Collection not found",
})
await vectorStore.initialize()
// Should detect detached HEAD
expect(gitUtils.getCurrentBranch).toHaveBeenCalledWith(mockWorkspacePath)
// Should create collection with workspace-only name (no branch suffix)
expect(mockQdrantClientInstance.createCollection).toHaveBeenCalledWith(
"ws-mockedhash123456",
expect.any(Object),
)
})
it("should use workspace-only collection name when not in a Git repository", async () => {
;(gitUtils.getCurrentBranch as any).mockResolvedValue(null) // Not a Git repo
const vectorStore = new QdrantVectorStore(
mockWorkspacePath,
mockQdrantUrl,
mockVectorSize,
mockApiKey,
true,
)
// Mock collection doesn't exist
mockQdrantClientInstance.getCollection.mockRejectedValue({
status: 404,
message: "Collection not found",
})
await vectorStore.initialize()
// Should detect not a Git repo
expect(gitUtils.getCurrentBranch).toHaveBeenCalledWith(mockWorkspacePath)
// Should create collection with workspace-only name (no branch suffix)
expect(mockQdrantClientInstance.createCollection).toHaveBeenCalledWith(
"ws-mockedhash123456",
expect.any(Object),
)
})
it("should handle branch names with special characters", async () => {
;(gitUtils.getCurrentBranch as any).mockResolvedValue("feature/user-auth-2.0")
;(gitUtils.sanitizeBranchName as any).mockReturnValue("feature-user-auth-2-0")
const vectorStore = new QdrantVectorStore(
mockWorkspacePath,
mockQdrantUrl,
mockVectorSize,
mockApiKey,
true,
)
// Mock collection doesn't exist
mockQdrantClientInstance.getCollection.mockRejectedValue({
status: 404,
message: "Collection not found",
})
await vectorStore.initialize()
// Should sanitize branch name
expect(gitUtils.sanitizeBranchName).toHaveBeenCalledWith("feature/user-auth-2.0")
// Should create collection with sanitized branch name
expect(mockQdrantClientInstance.createCollection).toHaveBeenCalledWith(
"ws-mockedhash123456-br-feature-user-auth-2-0",
expect.any(Object),
)
})
it("should return the current branch name when branch isolation is enabled", async () => {
;(gitUtils.getCurrentBranch as any).mockResolvedValue("main")
;(gitUtils.sanitizeBranchName as any).mockReturnValue("main")
const vectorStore = new QdrantVectorStore(
mockWorkspacePath,
mockQdrantUrl,
mockVectorSize,
mockApiKey,
true,
)
// Mock collection doesn't exist
mockQdrantClientInstance.getCollection.mockRejectedValue({
status: 404,
message: "Collection not found",
})
await vectorStore.initialize()
expect(vectorStore.getCurrentBranch()).toBe("main")
})
it("should handle very long branch names", async () => {
const longBranchName = "feature/" + "a".repeat(100)
const sanitizedLongName = "feature-" + "a".repeat(42) // Truncated to 50 chars
;(gitUtils.getCurrentBranch as any).mockResolvedValue(longBranchName)
;(gitUtils.sanitizeBranchName as any).mockReturnValue(sanitizedLongName)
const vectorStore = new QdrantVectorStore(
mockWorkspacePath,
mockQdrantUrl,
mockVectorSize,
mockApiKey,
true,
)
// Mock collection doesn't exist
mockQdrantClientInstance.getCollection.mockRejectedValue({
status: 404,
message: "Collection not found",
})
await vectorStore.initialize()
// Should handle long branch name
expect(gitUtils.sanitizeBranchName).toHaveBeenCalledWith(longBranchName)
// Should create collection with truncated branch name
expect(mockQdrantClientInstance.createCollection).toHaveBeenCalledWith(
`ws-mockedhash123456-br-${sanitizedLongName}`,
expect.any(Object),
)
})
})
describe("Branch Switching", () => {
it("should use different collection names for different branches", async () => {
// First initialization on main branch
;(gitUtils.getCurrentBranch as any).mockResolvedValue("main")
;(gitUtils.sanitizeBranchName as any).mockReturnValue("main")
const vectorStore1 = new QdrantVectorStore(
mockWorkspacePath,
mockQdrantUrl,
mockVectorSize,
mockApiKey,
true,
)
mockQdrantClientInstance.getCollection.mockRejectedValue({
status: 404,
message: "Collection not found",
})
await vectorStore1.initialize()
expect(mockQdrantClientInstance.createCollection).toHaveBeenCalledWith(
"ws-mockedhash123456-br-main",
expect.any(Object),
)
// Clear mocks for second initialization
vi.clearAllMocks()
// Second initialization on feature branch
;(gitUtils.getCurrentBranch as any).mockResolvedValue("feature/new-feature")
;(gitUtils.sanitizeBranchName as any).mockReturnValue("feature-new-feature")
const vectorStore2 = new QdrantVectorStore(
mockWorkspacePath,
mockQdrantUrl,
mockVectorSize,
mockApiKey,
true,
)
mockQdrantClientInstance.getCollection.mockRejectedValue({
status: 404,
message: "Collection not found",
})
await vectorStore2.initialize()
expect(mockQdrantClientInstance.createCollection).toHaveBeenCalledWith(
"ws-mockedhash123456-br-feature-new-feature",
expect.any(Object),
)
})
})
})

View file

@ -6,6 +6,7 @@ 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 { getCurrentBranch, sanitizeBranchName } from "../../../utils/git"
/**
* Qdrant implementation of the vector store interface
@ -18,19 +19,31 @@ export class QdrantVectorStore implements IVectorStore {
private readonly collectionName: string
private readonly qdrantUrl: string = "http://localhost:6333"
private readonly workspacePath: string
private readonly branchIsolationEnabled: boolean
private currentBranch: string | null = null
/**
* Creates a new Qdrant vector store
* @param workspacePath Path to the workspace
* @param url Optional URL to the Qdrant server
* @param vectorSize Size of the vector embeddings
* @param apiKey Optional API key for Qdrant
* @param branchIsolationEnabled Whether to use branch-specific collections
*/
constructor(workspacePath: string, url: string, vectorSize: number, apiKey?: string) {
constructor(
workspacePath: string,
url: string,
vectorSize: number,
apiKey?: string,
branchIsolationEnabled: boolean = false,
) {
// Parse the URL to determine the appropriate QdrantClient configuration
const parsedUrl = this.parseQdrantUrl(url)
// Store the resolved URL for our property
this.qdrantUrl = parsedUrl
this.workspacePath = workspacePath
this.branchIsolationEnabled = branchIsolationEnabled
try {
const urlObj = new URL(parsedUrl)
@ -80,7 +93,18 @@ export class QdrantVectorStore implements IVectorStore {
// Generate collection name from workspace path
const hash = createHash("sha256").update(workspacePath).digest("hex")
this.vectorSize = vectorSize
this.collectionName = `ws-${hash.substring(0, 16)}`
// Base collection name
let collectionName = `ws-${hash.substring(0, 16)}`
// Add branch suffix if branch isolation is enabled
if (this.branchIsolationEnabled) {
// We'll set the actual collection name in initialize() after detecting the branch
// For now, just store the base name
collectionName = `ws-${hash.substring(0, 16)}`
}
this.collectionName = collectionName
}
/**
@ -147,6 +171,11 @@ export class QdrantVectorStore implements IVectorStore {
* @returns Promise resolving to boolean indicating if a new collection was created
*/
async initialize(): Promise<boolean> {
// Update collection name based on current branch if branch isolation is enabled
if (this.branchIsolationEnabled) {
await this.updateCollectionNameForBranch()
}
let created = false
try {
const collectionInfo = await this.getCollectionInfo()
@ -548,4 +577,37 @@ export class QdrantVectorStore implements IVectorStore {
const collectionInfo = await this.getCollectionInfo()
return collectionInfo !== null
}
/**
* Updates the collection name based on the current Git branch
* Only called when branch isolation is enabled
*/
private async updateCollectionNameForBranch(): Promise<void> {
const branch = await getCurrentBranch(this.workspacePath)
// Generate base collection name
const hash = createHash("sha256").update(this.workspacePath).digest("hex")
let collectionName = `ws-${hash.substring(0, 16)}`
if (branch) {
// Sanitize branch name for use in collection name
const sanitizedBranch = sanitizeBranchName(branch)
collectionName = `${collectionName}-br-${sanitizedBranch}`
this.currentBranch = branch
} else {
// Detached HEAD or not a git repo - use workspace-only collection
this.currentBranch = null
}
// Update the collection name
;(this as any).collectionName = collectionName
}
/**
* Gets the current branch being used for the collection
* @returns The current branch name or null if not using branch isolation
*/
public getCurrentBranch(): string | null {
return this.branchIsolationEnabled ? this.currentBranch : null
}
}

View file

@ -355,3 +355,67 @@ export async function getWorkingState(cwd: string): Promise<string> {
return `Failed to get working state: ${error instanceof Error ? error.message : String(error)}`
}
}
/**
* Gets the current Git branch name for a given directory
* @param cwd The directory to check (defaults to current working directory)
* @returns The current branch name, or null if not in a git repo or in detached HEAD state
*/
export async function getCurrentBranch(cwd: string): Promise<string | null> {
try {
const isInstalled = await checkGitInstalled()
if (!isInstalled) {
return null
}
const isRepo = await checkGitRepo(cwd)
if (!isRepo) {
return null
}
// Try to get the current branch name
const { stdout } = await execAsync("git rev-parse --abbrev-ref HEAD", { cwd })
const branch = stdout.trim()
// Check if we're in detached HEAD state
if (branch === "HEAD") {
return null
}
return branch
} catch (error) {
console.error("Error getting current branch:", error)
return null
}
}
/**
* Sanitizes a branch name for use in collection names
* @param branchName The original branch name
* @returns Sanitized branch name suitable for use in identifiers
*/
export function sanitizeBranchName(branchName: string): string {
// Convert to lowercase
let sanitized = branchName.toLowerCase()
// Replace non-alphanumeric characters with hyphens
sanitized = sanitized.replace(/[^a-z0-9]+/g, "-")
// Remove leading/trailing hyphens
sanitized = sanitized.replace(/^-+|-+$/g, "")
// Collapse multiple consecutive hyphens
sanitized = sanitized.replace(/-+/g, "-")
// Limit length to 50 characters (reasonable limit for branch segment)
if (sanitized.length > 50) {
sanitized = sanitized.substring(0, 50).replace(/-+$/, "")
}
// If empty after sanitization, use a default
if (!sanitized) {
sanitized = "default"
}
return sanitized
}

View file

@ -57,6 +57,7 @@ interface CodeIndexPopoverProps {
interface LocalCodeIndexSettings {
// Global state settings
codebaseIndexEnabled: boolean
codebaseIndexBranchIsolation: boolean
codebaseIndexQdrantUrl: string
codebaseIndexEmbedderProvider: EmbedderProvider
codebaseIndexEmbedderBaseUrl?: string
@ -180,6 +181,7 @@ export const CodeIndexPopover: React.FC<CodeIndexPopoverProps> = ({
// Default settings template
const getDefaultSettings = (): LocalCodeIndexSettings => ({
codebaseIndexEnabled: true,
codebaseIndexBranchIsolation: false,
codebaseIndexQdrantUrl: "",
codebaseIndexEmbedderProvider: "openai",
codebaseIndexEmbedderBaseUrl: "",
@ -212,6 +214,7 @@ export const CodeIndexPopover: React.FC<CodeIndexPopoverProps> = ({
if (codebaseIndexConfig) {
const settings = {
codebaseIndexEnabled: codebaseIndexConfig.codebaseIndexEnabled ?? true,
codebaseIndexBranchIsolation: codebaseIndexConfig.codebaseIndexBranchIsolation ?? false,
codebaseIndexQdrantUrl: codebaseIndexConfig.codebaseIndexQdrantUrl || "",
codebaseIndexEmbedderProvider: codebaseIndexConfig.codebaseIndexEmbedderProvider || "openai",
codebaseIndexEmbedderBaseUrl: codebaseIndexConfig.codebaseIndexEmbedderBaseUrl || "",
@ -508,8 +511,9 @@ export const CodeIndexPopover: React.FC<CodeIndexPopoverProps> = ({
settingsToSave[key] = value
}
// Always include codebaseIndexEnabled to ensure it's persisted
// Always include codebaseIndexEnabled and codebaseIndexBranchIsolation to ensure they're persisted
settingsToSave.codebaseIndexEnabled = currentSettings.codebaseIndexEnabled
settingsToSave.codebaseIndexBranchIsolation = currentSettings.codebaseIndexBranchIsolation
// Save settings to backend
vscode.postMessage({

View file

@ -91,6 +91,12 @@
"qdrantApiKeyPlaceholder": "Enter your Qdrant API key (optional)",
"setupConfigLabel": "Setup",
"advancedConfigLabel": "Advanced Configuration",
"advancedConfiguration": "Advanced Configuration",
"branchIsolation": {
"enableLabel": "Enable Branch Isolation",
"enableDescription": "When enabled, each Git branch will have its own separate index. This ensures search results always reflect the current branch's code.",
"storageWarning": "Warning: Enabling branch isolation will increase storage usage as each branch maintains its own index."
},
"searchMinScoreLabel": "Search Score Threshold",
"searchMinScoreDescription": "Minimum similarity score (0.0-1.0) required for search results. Lower values return more results but may be less relevant. Higher values return fewer but more relevant results.",
"searchMinScoreResetTooltip": "Reset to default value (0.4)",