diff --git a/src/config/merger.ts b/src/config/merger.ts index 6b9168ba..7e26f33e 100644 --- a/src/config/merger.ts +++ b/src/config/merger.ts @@ -1,6 +1,8 @@ -import { existsSync, readFileSync } from "fs"; -import * as path from "path"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; import * as os from "os"; +import * as path from "path"; + +import { resolveProjectConfigPath } from "./paths.js"; function loadJsonFile(filePath: string): unknown { try { @@ -12,6 +14,97 @@ function loadJsonFile(filePath: string): unknown { return null; } +export function rebasePathEntries( + values: unknown, + fromDir: string, + toDir: string, +): string[] { + if (!Array.isArray(values)) { + return []; + } + + return values + .filter((value): value is string => typeof value === "string") + .map((value) => { + const trimmed = value.trim(); + if (!trimmed || path.isAbsolute(trimmed)) { + return trimmed; + } + + return path.normalize(path.relative(toDir, path.resolve(fromDir, trimmed))); + }) + .filter(Boolean); +} + +function isWithinRoot(rootDir: string, targetPath: string): boolean { + const relativePath = path.relative(rootDir, targetPath); + return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath)); +} + +export function resolveInheritedKnowledgeBaseEntries( + values: unknown, + sourceRoot: string, + targetRoot: string, +): string[] { + if (!Array.isArray(values)) { + return []; + } + + return values + .filter((value): value is string => typeof value === "string") + .map((value) => { + const trimmed = value.trim(); + if (!trimmed) { + return trimmed; + } + + if (path.isAbsolute(trimmed)) { + if (isWithinRoot(sourceRoot, trimmed)) { + return path.normalize(path.relative(sourceRoot, trimmed) || "."); + } + + return path.normalize(trimmed); + } + + const resolvedFromSource = path.resolve(sourceRoot, trimmed); + if (isWithinRoot(sourceRoot, resolvedFromSource)) { + return path.normalize(trimmed); + } + + return path.normalize(path.relative(targetRoot, resolvedFromSource)); + }) + .filter(Boolean); +} + +export function materializeLocalProjectConfig(projectRoot: string, config: unknown): string { + const localConfigPath = path.join(projectRoot, ".opencode", "codebase-index.json"); + mkdirSync(path.dirname(localConfigPath), { recursive: true }); + writeFileSync(localConfigPath, JSON.stringify(config, null, 2), "utf-8"); + return localConfigPath; +} + +export function loadProjectConfigLayer(projectRoot: string): Record { + const projectConfigPath = resolveProjectConfigPath(projectRoot); + const projectConfig = loadJsonFile(projectConfigPath) as Record | null; + + if (!projectConfig) { + return {}; + } + + const normalizedConfig: Record = { ...projectConfig }; + const projectConfigBaseDir = path.dirname(path.dirname(projectConfigPath)); + + if (Array.isArray(normalizedConfig.knowledgeBases)) { + normalizedConfig.knowledgeBases = resolveInheritedKnowledgeBaseEntries( + normalizedConfig.knowledgeBases, + projectConfigBaseDir, + projectRoot, + ); + } + + return normalizedConfig; +} + /** * Loads and merges global and project configs. * @@ -23,10 +116,11 @@ function loadJsonFile(filePath: string): unknown { * - For include/exclude: project overrides global if set, otherwise load global */ export function loadMergedConfig(projectRoot: string): unknown { - const globalConfigPath = path.join(os.homedir(), ".config", "opencode", "codebase-index.json"); + const globalConfigPath = os.homedir() + "/.config/opencode/codebase-index.json"; const globalConfig = loadJsonFile(globalConfigPath) as Record | null; - const projectConfigPath = path.join(projectRoot, ".opencode", "codebase-index.json"); + const projectConfigPath = resolveProjectConfigPath(projectRoot); const projectConfig = loadJsonFile(projectConfigPath) as Record | null; + const normalizedProjectConfig = loadProjectConfigLayer(projectRoot); // If neither exists, return empty if (!globalConfig && !projectConfig) { @@ -40,78 +134,78 @@ export function loadMergedConfig(projectRoot: string): unknown { // If only project exists, return it if (!globalConfig && projectConfig) { - return projectConfig; + return normalizedProjectConfig; } // Both exist - start with global config as base const merged: Record = { ...globalConfig }; // For embeddingProvider: project overrides if set, otherwise use global - if (projectConfig && "embeddingProvider" in projectConfig) { - merged.embeddingProvider = projectConfig.embeddingProvider; + if (projectConfig && "embeddingProvider" in normalizedProjectConfig) { + merged.embeddingProvider = normalizedProjectConfig.embeddingProvider; } else if (globalConfig && globalConfig.embeddingProvider) { merged.embeddingProvider = globalConfig.embeddingProvider; } // For customProvider: project overrides if set, otherwise use global - if (projectConfig && "customProvider" in projectConfig) { - merged.customProvider = projectConfig.customProvider; + if (projectConfig && "customProvider" in normalizedProjectConfig) { + merged.customProvider = normalizedProjectConfig.customProvider; } else if (globalConfig && globalConfig.customProvider) { merged.customProvider = globalConfig.customProvider; } // For embeddingModel: project overrides if set, otherwise use global - if (projectConfig && "embeddingModel" in projectConfig) { - merged.embeddingModel = projectConfig.embeddingModel; + if (projectConfig && "embeddingModel" in normalizedProjectConfig) { + merged.embeddingModel = normalizedProjectConfig.embeddingModel; } else if (globalConfig && globalConfig.embeddingModel) { merged.embeddingModel = globalConfig.embeddingModel; } // For reranker: project overrides if set, otherwise use global - if (projectConfig && "reranker" in projectConfig) { - merged.reranker = projectConfig.reranker; + if (projectConfig && "reranker" in normalizedProjectConfig) { + merged.reranker = normalizedProjectConfig.reranker; } else if (globalConfig && globalConfig.reranker) { merged.reranker = globalConfig.reranker; } // For include: project overrides if set, otherwise use global - if (projectConfig && "include" in projectConfig) { - merged.include = projectConfig.include; + if (projectConfig && "include" in normalizedProjectConfig) { + merged.include = normalizedProjectConfig.include; } else if (globalConfig && globalConfig.include) { merged.include = globalConfig.include; } // For exclude: project overrides if set, otherwise use global - if (projectConfig && "exclude" in projectConfig) { - merged.exclude = projectConfig.exclude; + if (projectConfig && "exclude" in normalizedProjectConfig) { + merged.exclude = normalizedProjectConfig.exclude; } else if (globalConfig && globalConfig.exclude) { merged.exclude = globalConfig.exclude; } // For indexing: project overrides if set, otherwise use global - if (projectConfig && "indexing" in projectConfig) { - merged.indexing = projectConfig.indexing; + if (projectConfig && "indexing" in normalizedProjectConfig) { + merged.indexing = normalizedProjectConfig.indexing; } else if (globalConfig && globalConfig.indexing) { merged.indexing = globalConfig.indexing; } // For search: project overrides if set, otherwise use global - if (projectConfig && "search" in projectConfig) { - merged.search = projectConfig.search; + if (projectConfig && "search" in normalizedProjectConfig) { + merged.search = normalizedProjectConfig.search; } else if (globalConfig && globalConfig.search) { merged.search = globalConfig.search; } // For debug: project overrides if set, otherwise use global - if (projectConfig && "debug" in projectConfig) { - merged.debug = projectConfig.debug; + if (projectConfig && "debug" in normalizedProjectConfig) { + merged.debug = normalizedProjectConfig.debug; } else if (globalConfig && globalConfig.debug) { merged.debug = globalConfig.debug; } // For scope: project overrides if set, otherwise use global - if (projectConfig && "scope" in projectConfig) { - merged.scope = projectConfig.scope; + if (projectConfig && "scope" in normalizedProjectConfig) { + merged.scope = normalizedProjectConfig.scope; } else if (globalConfig && "scope" in globalConfig) { merged.scope = globalConfig.scope; } @@ -135,13 +229,15 @@ export function loadMergedConfig(projectRoot: string): unknown { ) { continue; // Already handled above } - merged[key] = projectConfig[key]; + merged[key] = normalizedProjectConfig[key]; } } // For knowledgeBases: merge arrays (union, deduplicated) const globalKbs = globalConfig && Array.isArray(globalConfig.knowledgeBases) ? globalConfig.knowledgeBases : []; - const projectKbs = projectConfig && Array.isArray(projectConfig.knowledgeBases) ? projectConfig.knowledgeBases : []; + const projectKbs = projectConfig + ? (Array.isArray(normalizedProjectConfig.knowledgeBases) ? normalizedProjectConfig.knowledgeBases as string[] : []) + : []; const allKbs = [...globalKbs, ...projectKbs]; const uniqueKbs = [...new Set(allKbs.map(p => String(p).trim()))]; merged.knowledgeBases = uniqueKbs; diff --git a/src/config/paths.ts b/src/config/paths.ts new file mode 100644 index 00000000..e214ea3c --- /dev/null +++ b/src/config/paths.ts @@ -0,0 +1,56 @@ +import { existsSync } from "fs"; +import * as os from "os"; +import * as path from "path"; + +import { resolveWorktreeMainRepoRoot } from "../git/index.js"; + +const PROJECT_CONFIG_RELATIVE_PATH = path.join(".opencode", "codebase-index.json"); +const PROJECT_INDEX_RELATIVE_PATH = path.join(".opencode", "index"); + +function resolveWorktreeFallbackPath(projectRoot: string, relativePath: string): string | null { + const mainRepoRoot = resolveWorktreeMainRepoRoot(projectRoot); + if (!mainRepoRoot) { + return null; + } + + const fallbackPath = path.join(mainRepoRoot, relativePath); + return existsSync(fallbackPath) ? fallbackPath : null; +} + +function hasProjectConfig(projectRoot: string): boolean { + return existsSync(path.join(projectRoot, PROJECT_CONFIG_RELATIVE_PATH)); +} + +export function getGlobalIndexPath(): string { + return path.join(os.homedir(), ".opencode", "global-index"); +} + +export function resolveProjectConfigPath(projectRoot: string): string { + const localConfigPath = path.join(projectRoot, PROJECT_CONFIG_RELATIVE_PATH); + if (existsSync(localConfigPath)) { + return localConfigPath; + } + + return resolveWorktreeFallbackPath(projectRoot, PROJECT_CONFIG_RELATIVE_PATH) ?? localConfigPath; +} + +export function resolveWritableProjectConfigPath(projectRoot: string): string { + return path.join(projectRoot, PROJECT_CONFIG_RELATIVE_PATH); +} + +export function resolveProjectIndexPath(projectRoot: string, scope: "project" | "global"): string { + if (scope === "global") { + return getGlobalIndexPath(); + } + + const localIndexPath = path.join(projectRoot, PROJECT_INDEX_RELATIVE_PATH); + if (existsSync(localIndexPath)) { + return localIndexPath; + } + + if (hasProjectConfig(projectRoot)) { + return localIndexPath; + } + + return resolveWorktreeFallbackPath(projectRoot, PROJECT_INDEX_RELATIVE_PATH) ?? localIndexPath; +} diff --git a/src/eval/runner.ts b/src/eval/runner.ts index 35030d42..488d5eae 100644 --- a/src/eval/runner.ts +++ b/src/eval/runner.ts @@ -1,13 +1,17 @@ import { existsSync } from "fs"; +import { mkdirSync } from "fs"; import { readFileSync } from "fs"; import { rmSync } from "fs"; +import { writeFileSync } from "fs"; import * as os from "os"; import * as path from "path"; import { performance } from "perf_hooks"; +import { rebasePathEntries, resolveInheritedKnowledgeBaseEntries } from "../config/merger.js"; import { parseConfig } from "../config/schema.js"; import type { SearchConfig as ConfigSearchConfig } from "../config/schema.js"; import { getDefaultModelForProvider } from "../config/index.js"; +import { getGlobalIndexPath, resolveProjectConfigPath, resolveProjectIndexPath } from "../config/paths.js"; import { Indexer } from "../indexer/index.js"; import { evaluateBudgetGate } from "./budget.js"; @@ -37,15 +41,56 @@ function toAbsolute(projectRoot: string, maybeRelative: string): string { return path.isAbsolute(maybeRelative) ? maybeRelative : path.join(projectRoot, maybeRelative); } +function isProjectScopedConfigPath(configPath: string): boolean { + return path.basename(configPath) === "codebase-index.json" + && path.basename(path.dirname(configPath)) === ".opencode"; +} + +function normalizeEvalConfigKnowledgeBases( + rawConfig: unknown, + projectRoot: string, + resolvedConfigPath: string, +): Record { + const config = rawConfig && typeof rawConfig === "object" + ? { ...(rawConfig as Record) } + : {}; + + if (!Array.isArray(config.knowledgeBases)) { + return config; + } + + config.knowledgeBases = isProjectScopedConfigPath(resolvedConfigPath) + ? resolveInheritedKnowledgeBaseEntries( + config.knowledgeBases, + path.dirname(path.dirname(resolvedConfigPath)), + projectRoot, + ) + : rebasePathEntries( + config.knowledgeBases, + path.dirname(resolvedConfigPath), + projectRoot, + ); + + return config; +} + function loadRawConfig(projectRoot: string, configPath?: string): unknown { const fromPath = configPath ? toAbsolute(projectRoot, configPath) : null; if (fromPath && existsSync(fromPath)) { - return JSON.parse(readFileSync(fromPath, "utf-8")); + return normalizeEvalConfigKnowledgeBases( + JSON.parse(readFileSync(fromPath, "utf-8")), + projectRoot, + fromPath, + ); } - const projectConfig = path.join(projectRoot, ".opencode", "codebase-index.json"); + const projectConfig = resolveProjectConfigPath(projectRoot); if (existsSync(projectConfig)) { - return JSON.parse(readFileSync(projectConfig, "utf-8")); + return normalizeEvalConfigKnowledgeBases( + JSON.parse(readFileSync(projectConfig, "utf-8")), + projectRoot, + projectConfig, + ); } const globalConfig = path.join(os.homedir(), ".config", "opencode", "codebase-index.json"); @@ -57,19 +102,53 @@ function loadRawConfig(projectRoot: string, configPath?: string): unknown { } function getIndexRootPath(projectRoot: string, scope: "project" | "global"): string { - if (scope === "global") { - return path.join(os.homedir(), ".opencode", "global-index"); - } + return scope === "global" + ? getGlobalIndexPath() + : resolveProjectIndexPath(projectRoot, scope); +} + +function getLocalProjectIndexRoot(projectRoot: string): string { return path.join(projectRoot, ".opencode", "index"); } +function getLocalProjectConfigPath(projectRoot: string): string { + return path.join(projectRoot, ".opencode", "codebase-index.json"); +} + function clearIndexRoot(projectRoot: string, scope: "project" | "global"): void { - const indexRoot = getIndexRootPath(projectRoot, scope); + const indexRoot = scope === "global" + ? getIndexRootPath(projectRoot, scope) + : getLocalProjectIndexRoot(projectRoot); if (existsSync(indexRoot)) { rmSync(indexRoot, { recursive: true, force: true }); } } +function ensureLocalEvalProjectConfig(projectRoot: string, configPath?: string): string | undefined { + const localConfigPath = getLocalProjectConfigPath(projectRoot); + const resolvedConfigPath = configPath + ? toAbsolute(projectRoot, configPath) + : resolveProjectConfigPath(projectRoot); + + if (!configPath && existsSync(localConfigPath)) { + return localConfigPath; + } + + if (!existsSync(resolvedConfigPath) || resolvedConfigPath === localConfigPath) { + return resolvedConfigPath; + } + + const sourceConfig = normalizeEvalConfigKnowledgeBases( + JSON.parse(readFileSync(resolvedConfigPath, "utf-8")), + projectRoot, + resolvedConfigPath, + ); + + mkdirSync(path.dirname(localConfigPath), { recursive: true }); + writeFileSync(localConfigPath, JSON.stringify(sourceConfig, null, 2), "utf-8"); + return localConfigPath; +} + function loadParsedConfig(projectRoot: string, configPath?: string) { const raw = loadRawConfig(projectRoot, configPath); return parseConfig(raw); @@ -116,7 +195,12 @@ export async function runEvaluation(options: EvalRunOptions): Promise { if (!initialized) { await indexer.initialize(); @@ -118,15 +143,23 @@ export function createMcpServer(projectRoot: string, config: ParsedCodebaseIndex verbose: z.boolean().optional().default(false).describe("Show detailed info about skipped files and parsing failures"), }, async (args) => { - await ensureInitialized(); - if (args.estimateOnly) { + await ensureInitialized(); const estimate = await indexer.estimateCost(); return { content: [{ type: "text", text: formatCostEstimate(estimate) }] }; } if (args.force) { + if (shouldForceLocalizeProjectIndex()) { + materializeLocalProjectConfig(projectRoot, loadProjectConfigLayer(projectRoot)); + refreshIndexerFromConfig(); + } + await ensureInitialized(); await indexer.clearIndex(); + refreshIndexerFromConfig(); + await ensureInitialized(); + } else { + await ensureInitialized(); } const stats = await indexer.index(); diff --git a/src/tools/index.ts b/src/tools/index.ts index c615bef9..89b1c669 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -18,7 +18,9 @@ import { } from "./utils.js"; import { existsSync, writeFileSync, mkdirSync, statSync } from "fs"; import * as path from "path"; -import { loadMergedConfig } from "../config/merger.js"; +import { loadMergedConfig, loadProjectConfigLayer, materializeLocalProjectConfig } from "../config/merger.js"; +import { resolveWritableProjectConfigPath } from "../config/paths.js"; +import { resolveWorktreeMainRepoRoot } from "../git/index.js"; const z = tool.schema; @@ -39,7 +41,23 @@ function refreshIndexerFromConfig(): void { throw new Error("Codebase index tools not initialized. Plugin may not be loaded correctly."); } - sharedIndexer = new Indexer(sharedProjectRoot, parseConfig(loadConfig())); + sharedIndexer = new Indexer(sharedProjectRoot, parseConfig(loadRuntimeConfig())); +} + +function shouldForceLocalizeProjectIndex(): boolean { + const currentConfig = parseConfig(loadRuntimeConfig()); + if (currentConfig.scope !== "project") { + return false; + } + + const localIndexPath = path.join(sharedProjectRoot, ".opencode", "index"); + const mainRepoRoot = resolveWorktreeMainRepoRoot(sharedProjectRoot); + if (!mainRepoRoot) { + return false; + } + + const inheritedIndexPath = path.join(mainRepoRoot, ".opencode", "index"); + return !existsSync(localIndexPath) && existsSync(inheritedIndexPath); } function getIndexer(): Indexer { @@ -50,43 +68,92 @@ function getIndexer(): Indexer { } function getConfigPath(): string { - return path.join(sharedProjectRoot, ".opencode", "codebase-index.json"); + return resolveWritableProjectConfigPath(sharedProjectRoot); +} + +function normalizeConfigPathValue(value: string, baseDir: string): string { + const trimmed = value.trim(); + if (!trimmed) { + return trimmed; + } + + const absolutePath = path.isAbsolute(trimmed) ? trimmed : path.resolve(baseDir, trimmed); + return path.normalize(absolutePath); +} + +function serializeConfigPathValue(value: string, baseDir: string): string { + const trimmed = value.trim(); + if (!trimmed) { + return trimmed; + } + + if (!path.isAbsolute(trimmed)) { + return path.normalize(trimmed); + } + + const relativePath = path.relative(baseDir, trimmed); + if (!relativePath || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath))) { + return path.normalize(relativePath || "."); + } + + return path.normalize(trimmed); +} + +function normalizeKnowledgeBasePaths(config: Record): Record { + const normalized = { ...config }; + + if (Array.isArray(normalized.knowledgeBases)) { + normalized.knowledgeBases = (normalized.knowledgeBases as string[]).map(kb => { + return normalizeConfigPathValue(kb, sharedProjectRoot); + }); + } + + return normalized; } -function loadConfig(): Record { +function loadRuntimeConfig(): Record { const rawConfig = loadMergedConfig(sharedProjectRoot); const config: Record = {}; - + if (rawConfig && typeof rawConfig === "object") { for (const key of Object.keys(rawConfig)) { config[key] = rawConfig[key as keyof typeof rawConfig]; } } - - if (Array.isArray(config.knowledgeBases)) { - config.knowledgeBases = (config.knowledgeBases as string[]).map(kb => { - const resolved = path.isAbsolute(kb) ? kb : path.resolve(sharedProjectRoot, kb); - return path.normalize(resolved); - }); - } - - if (Array.isArray(config.additionalInclude)) { - config.additionalInclude = (config.additionalInclude as string[]).map(pattern => { - const resolved = path.isAbsolute(pattern) ? pattern : path.resolve(sharedProjectRoot, pattern); - return path.normalize(resolved); - }); + + return normalizeKnowledgeBasePaths(config); +} + +function loadEditableConfig(): Record { + const rawConfig = loadProjectConfigLayer(sharedProjectRoot); + const config: Record = {}; + + if (rawConfig && typeof rawConfig === "object") { + for (const key of Object.keys(rawConfig)) { + config[key] = rawConfig[key as keyof typeof rawConfig]; + } } - - return config; + + return normalizeKnowledgeBasePaths(config); } function saveConfig(config: Record): void { - const configDir = path.join(sharedProjectRoot, ".opencode"); + const configPath = getConfigPath(); + const configDir = path.dirname(configPath); + const configBaseDir = path.dirname(configDir); if (!existsSync(configDir)) { mkdirSync(configDir, { recursive: true }); } - const configPath = getConfigPath(); - writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8"); + + const serializableConfig: Record = { ...config }; + + if (Array.isArray(serializableConfig.knowledgeBases)) { + serializableConfig.knowledgeBases = (serializableConfig.knowledgeBases as string[]).map(kb => + serializeConfigPathValue(kb, configBaseDir) + ); + } + + writeFileSync(configPath, JSON.stringify(serializableConfig, null, 2) + "\n", "utf-8"); } export const codebase_peek: ToolDefinition = tool({ @@ -121,7 +188,7 @@ export const index_codebase: ToolDefinition = tool({ verbose: z.boolean().optional().default(false).describe("Show detailed info about skipped files and parsing failures"), }, async execute(args, context) { - const indexer = getIndexer(); + let indexer = getIndexer(); if (args.estimateOnly) { const estimate = await indexer.estimateCost(); @@ -129,6 +196,11 @@ export const index_codebase: ToolDefinition = tool({ } if (args.force) { + if (shouldForceLocalizeProjectIndex()) { + materializeLocalProjectConfig(sharedProjectRoot, loadProjectConfigLayer(sharedProjectRoot)); + refreshIndexerFromConfig(); + indexer = getIndexer(); + } await indexer.clearIndex(); } @@ -365,7 +437,7 @@ export const add_knowledge_base: ToolDefinition = tool({ } // Load current config - const config = loadConfig(); + const config = loadEditableConfig(); const knowledgeBases: string[] = Array.isArray(config.knowledgeBases) ? config.knowledgeBases as string[] : []; @@ -400,7 +472,7 @@ export const list_knowledge_bases: ToolDefinition = tool({ "List all configured knowledge base folders that are indexed alongside the main project.", args: {}, async execute() { - const config = loadConfig(); + const config = loadRuntimeConfig(); const knowledgeBases: string[] = Array.isArray(config.knowledgeBases) ? config.knowledgeBases as string[] : []; @@ -443,7 +515,7 @@ export const remove_knowledge_base: ToolDefinition = tool({ const inputPath = args.path.trim(); // Load current config - const config = loadConfig(); + const config = loadEditableConfig(); const knowledgeBases: string[] = Array.isArray(config.knowledgeBases) ? config.knowledgeBases as string[] : []; diff --git a/tests/eval-runner.test.ts b/tests/eval-runner.test.ts index 7aac67d6..734b5342 100644 --- a/tests/eval-runner.test.ts +++ b/tests/eval-runner.test.ts @@ -130,6 +130,548 @@ describe("eval runner", () => { expect(readFileSync(path.join(result.outputDir, "per-query.json"), "utf-8")).toContain("\"queries\""); }); + it("does not delete an inherited main-repo project index when reindexing from a fresh worktree", async () => { + const mainRepoDir = path.join(tempDir, "main-repo"); + const worktreeDir = path.join(tempDir, "worktree-feature"); + const worktreeGitDir = path.join(mainRepoDir, ".git", "worktrees", "feature"); + + mkdirSync(path.join(mainRepoDir, ".git", "refs", "heads"), { recursive: true }); + mkdirSync(path.join(mainRepoDir, ".opencode", "index"), { recursive: true }); + mkdirSync(path.join(mainRepoDir, "src", "indexer"), { recursive: true }); + mkdirSync(path.join(mainRepoDir, "src", "tools"), { recursive: true }); + mkdirSync(path.join(mainRepoDir, "benchmarks", "golden"), { recursive: true }); + mkdirSync(path.join(mainRepoDir, "benchmarks", "budgets"), { recursive: true }); + mkdirSync(path.join(mainRepoDir, "benchmarks", "baselines"), { recursive: true }); + mkdirSync(worktreeGitDir, { recursive: true }); + mkdirSync(worktreeDir, { recursive: true }); + + writeFileSync(path.join(mainRepoDir, ".git", "HEAD"), "ref: refs/heads/main\n"); + writeFileSync(path.join(mainRepoDir, ".git", "refs", "heads", "main"), "1111111111111111111111111111111111111111\n"); + writeFileSync(path.join(worktreeDir, ".git"), `gitdir: ${worktreeGitDir}\n`); + writeFileSync(path.join(worktreeGitDir, "HEAD"), "ref: refs/heads/feature\n"); + writeFileSync(path.join(worktreeGitDir, "commondir"), "../..\n"); + + writeFileSync( + path.join(mainRepoDir, ".opencode", "codebase-index.json"), + JSON.stringify( + { + embeddingProvider: "custom", + customProvider: { + baseUrl: "http://localhost:11434/v1", + model: "mock-embedding-model", + dimensions: 8, + }, + indexing: { + watchFiles: false, + }, + search: { + maxResults: 10, + minScore: 0, + fusionStrategy: "rrf", + rrfK: 60, + rerankTopN: 20, + }, + }, + null, + 2 + ), + "utf-8" + ); + + writeFileSync(path.join(mainRepoDir, ".opencode", "index", "sentinel.txt"), "keep-me", "utf-8"); + writeFileSync( + path.join(mainRepoDir, "src", "indexer", "index.ts"), + "export function rankHybridResults(query: string) { return query.length; }\n", + "utf-8" + ); + writeFileSync( + path.join(mainRepoDir, "src", "tools", "index.ts"), + "export const codebase_search = () => 'ok';\n", + "utf-8" + ); + writeFileSync( + path.join(mainRepoDir, "benchmarks", "golden", "small.json"), + JSON.stringify( + { + version: "1.0.0", + name: "small", + queries: [ + { + id: "q1", + query: "where is rankHybridResults implementation", + queryType: "definition", + expected: { + filePath: "src/indexer/index.ts", + symbol: "rankHybridResults", + }, + }, + ], + }, + null, + 2 + ), + "utf-8" + ); + + await runEvaluation({ + projectRoot: worktreeDir, + datasetPath: path.relative(worktreeDir, path.join(mainRepoDir, "benchmarks", "golden", "small.json")), + outputRoot: "benchmarks/results", + ciMode: false, + reindex: true, + }); + + expect(readFileSync(path.join(mainRepoDir, ".opencode", "index", "sentinel.txt"), "utf-8")).toBe("keep-me"); + }); + + it("creates a local eval config boundary when reindexing from a fallback worktree", async () => { + const mainRepoDir = path.join(tempDir, "main-repo"); + const worktreeDir = path.join(tempDir, "worktree-feature"); + const worktreeGitDir = path.join(mainRepoDir, ".git", "worktrees", "feature"); + + mkdirSync(path.join(mainRepoDir, ".git", "refs", "heads"), { recursive: true }); + mkdirSync(path.join(mainRepoDir, ".opencode", "index"), { recursive: true }); + mkdirSync(path.join(mainRepoDir, "docs", "reference"), { recursive: true }); + mkdirSync(path.join(mainRepoDir, "src", "indexer"), { recursive: true }); + mkdirSync(path.join(mainRepoDir, "src", "tools"), { recursive: true }); + mkdirSync(path.join(mainRepoDir, "benchmarks", "golden"), { recursive: true }); + mkdirSync(worktreeGitDir, { recursive: true }); + mkdirSync(worktreeDir, { recursive: true }); + + writeFileSync(path.join(mainRepoDir, ".git", "HEAD"), "ref: refs/heads/main\n"); + writeFileSync(path.join(mainRepoDir, ".git", "refs", "heads", "main"), "1111111111111111111111111111111111111111\n"); + writeFileSync(path.join(worktreeDir, ".git"), `gitdir: ${worktreeGitDir}\n`); + writeFileSync(path.join(worktreeGitDir, "HEAD"), "ref: refs/heads/feature\n"); + writeFileSync(path.join(worktreeGitDir, "commondir"), "../..\n"); + + writeFileSync( + path.join(mainRepoDir, ".opencode", "codebase-index.json"), + JSON.stringify( + { + embeddingProvider: "custom", + customProvider: { + baseUrl: "http://localhost:11434/v1", + model: "mock-embedding-model", + dimensions: 8, + }, + indexing: { + watchFiles: false, + }, + additionalInclude: ["docs/**/*.md"], + knowledgeBases: ["docs/reference"], + search: { + maxResults: 10, + minScore: 0, + fusionStrategy: "rrf", + rrfK: 60, + rerankTopN: 20, + }, + }, + null, + 2 + ), + "utf-8" + ); + + writeFileSync( + path.join(mainRepoDir, "src", "indexer", "index.ts"), + "export function rankHybridResults(query: string) { return query.length; }\n", + "utf-8" + ); + writeFileSync( + path.join(mainRepoDir, "src", "tools", "index.ts"), + "export const codebase_search = () => 'ok';\n", + "utf-8" + ); + writeFileSync( + path.join(mainRepoDir, "benchmarks", "golden", "small.json"), + JSON.stringify( + { + version: "1.0.0", + name: "small", + queries: [ + { + id: "q1", + query: "where is rankHybridResults implementation", + queryType: "definition", + expected: { + filePath: "src/indexer/index.ts", + symbol: "rankHybridResults", + }, + }, + ], + }, + null, + 2 + ), + "utf-8" + ); + + await runEvaluation({ + projectRoot: worktreeDir, + datasetPath: path.relative(worktreeDir, path.join(mainRepoDir, "benchmarks", "golden", "small.json")), + outputRoot: "benchmarks/results", + ciMode: false, + reindex: true, + }); + + const localEvalConfig = JSON.parse( + readFileSync(path.join(worktreeDir, ".opencode", "codebase-index.json"), "utf-8") + ) as { + additionalInclude?: string[]; + knowledgeBases?: string[]; + customProvider?: { model?: string }; + }; + + expect(localEvalConfig.customProvider?.model).toBe("mock-embedding-model"); + expect(localEvalConfig.additionalInclude).toEqual(["docs/**/*.md"]); + expect(localEvalConfig.knowledgeBases).toEqual(["docs/reference"]); + }); + + it("creates a local eval config boundary when reindexing with an explicit config path", async () => { + const mainRepoDir = path.join(tempDir, "main-repo"); + const worktreeDir = path.join(tempDir, "worktree-feature"); + const worktreeGitDir = path.join(mainRepoDir, ".git", "worktrees", "feature"); + + mkdirSync(path.join(mainRepoDir, ".git", "refs", "heads"), { recursive: true }); + mkdirSync(path.join(mainRepoDir, ".opencode", "index"), { recursive: true }); + mkdirSync(path.join(mainRepoDir, "docs", "reference"), { recursive: true }); + mkdirSync(path.join(mainRepoDir, "src", "indexer"), { recursive: true }); + mkdirSync(path.join(mainRepoDir, "src", "tools"), { recursive: true }); + mkdirSync(path.join(mainRepoDir, "benchmarks", "golden"), { recursive: true }); + mkdirSync(path.join(worktreeDir, ".opencode", "index"), { recursive: true }); + mkdirSync(worktreeGitDir, { recursive: true }); + mkdirSync(worktreeDir, { recursive: true }); + + writeFileSync(path.join(mainRepoDir, ".git", "HEAD"), "ref: refs/heads/main\n"); + writeFileSync(path.join(mainRepoDir, ".git", "refs", "heads", "main"), "1111111111111111111111111111111111111111\n"); + writeFileSync(path.join(worktreeDir, ".git"), `gitdir: ${worktreeGitDir}\n`); + writeFileSync(path.join(worktreeGitDir, "HEAD"), "ref: refs/heads/feature\n"); + writeFileSync(path.join(worktreeGitDir, "commondir"), "../..\n"); + + const externalConfigPath = path.join(mainRepoDir, ".opencode", "codebase-index.json"); + writeFileSync( + externalConfigPath, + JSON.stringify( + { + embeddingProvider: "custom", + customProvider: { + baseUrl: "http://localhost:11434/v1", + model: "mock-embedding-model", + dimensions: 8, + }, + indexing: { + watchFiles: false, + }, + additionalInclude: ["docs/**/*.md"], + knowledgeBases: ["docs/reference"], + search: { + maxResults: 10, + minScore: 0, + fusionStrategy: "rrf", + rrfK: 60, + rerankTopN: 20, + }, + }, + null, + 2 + ), + "utf-8" + ); + + writeFileSync( + path.join(mainRepoDir, "src", "indexer", "index.ts"), + "export function rankHybridResults(query: string) { return query.length; }\n", + "utf-8" + ); + writeFileSync( + path.join(mainRepoDir, "src", "tools", "index.ts"), + "export const codebase_search = () => 'ok';\n", + "utf-8" + ); + writeFileSync( + path.join(mainRepoDir, "benchmarks", "golden", "small.json"), + JSON.stringify( + { + version: "1.0.0", + name: "small", + queries: [ + { + id: "q1", + query: "where is rankHybridResults implementation", + queryType: "definition", + expected: { + filePath: "src/indexer/index.ts", + symbol: "rankHybridResults", + }, + }, + ], + }, + null, + 2 + ), + "utf-8" + ); + + await runEvaluation({ + projectRoot: worktreeDir, + configPath: path.relative(worktreeDir, externalConfigPath), + datasetPath: path.relative(worktreeDir, path.join(mainRepoDir, "benchmarks", "golden", "small.json")), + outputRoot: "benchmarks/results", + ciMode: false, + reindex: true, + }); + + const localEvalConfig = JSON.parse( + readFileSync(path.join(worktreeDir, ".opencode", "codebase-index.json"), "utf-8") + ) as { + additionalInclude?: string[]; + knowledgeBases?: string[]; + customProvider?: { model?: string }; + }; + + expect(localEvalConfig.customProvider?.model).toBe("mock-embedding-model"); + expect(localEvalConfig.additionalInclude).toEqual(["docs/**/*.md"]); + expect(localEvalConfig.knowledgeBases).toEqual(["docs/reference"]); + }); + + it("resolves relative knowledge bases from an arbitrary explicit config path during eval reindex", async () => { + const mainRepoDir = path.join(tempDir, "main-repo"); + const worktreeDir = path.join(tempDir, "worktree-feature"); + const worktreeGitDir = path.join(mainRepoDir, ".git", "worktrees", "feature"); + const configDir = path.join(mainRepoDir, "config"); + const externalKbDir = path.join(mainRepoDir, "external-kb"); + const externalConfigPath = path.join(configDir, "eval-config.json"); + + mkdirSync(path.join(mainRepoDir, ".git", "refs", "heads"), { recursive: true }); + mkdirSync(path.join(mainRepoDir, "src", "indexer"), { recursive: true }); + mkdirSync(path.join(mainRepoDir, "src", "tools"), { recursive: true }); + mkdirSync(path.join(mainRepoDir, "benchmarks", "golden"), { recursive: true }); + mkdirSync(path.join(worktreeDir, ".opencode", "index"), { recursive: true }); + mkdirSync(worktreeGitDir, { recursive: true }); + mkdirSync(configDir, { recursive: true }); + mkdirSync(externalKbDir, { recursive: true }); + mkdirSync(worktreeDir, { recursive: true }); + + writeFileSync(path.join(mainRepoDir, ".git", "HEAD"), "ref: refs/heads/main\n"); + writeFileSync(path.join(mainRepoDir, ".git", "refs", "heads", "main"), "1111111111111111111111111111111111111111\n"); + writeFileSync(path.join(worktreeDir, ".git"), `gitdir: ${worktreeGitDir}\n`); + writeFileSync(path.join(worktreeGitDir, "HEAD"), "ref: refs/heads/feature\n"); + writeFileSync(path.join(worktreeGitDir, "commondir"), "../..\n"); + + writeFileSync( + externalConfigPath, + JSON.stringify( + { + embeddingProvider: "custom", + customProvider: { + baseUrl: "http://localhost:11434/v1", + model: "mock-embedding-model", + dimensions: 8, + }, + indexing: { + watchFiles: false, + }, + knowledgeBases: ["../external-kb"], + search: { + maxResults: 10, + minScore: 0, + fusionStrategy: "rrf", + rrfK: 60, + rerankTopN: 20, + }, + }, + null, + 2 + ), + "utf-8" + ); + + writeFileSync( + path.join(mainRepoDir, "src", "indexer", "index.ts"), + "export function rankHybridResults(query: string) { return query.length; }\n", + "utf-8" + ); + writeFileSync( + path.join(mainRepoDir, "src", "tools", "index.ts"), + "export const codebase_search = () => 'ok';\n", + "utf-8" + ); + writeFileSync( + path.join(externalKbDir, "guide.ts"), + "export function externalKbSymbol() { return 'kb'; }\n", + "utf-8" + ); + writeFileSync( + path.join(mainRepoDir, "benchmarks", "golden", "small.json"), + JSON.stringify( + { + version: "1.0.0", + name: "small", + queries: [ + { + id: "q1", + query: "where is externalKbSymbol implementation", + queryType: "definition", + expected: { + filePath: "external-kb/guide.ts", + symbol: "externalKbSymbol", + }, + }, + ], + }, + null, + 2 + ), + "utf-8" + ); + + const result = await runEvaluation({ + projectRoot: worktreeDir, + configPath: path.relative(worktreeDir, externalConfigPath), + datasetPath: path.relative(worktreeDir, path.join(mainRepoDir, "benchmarks", "golden", "small.json")), + outputRoot: "benchmarks/results", + ciMode: false, + reindex: true, + }); + + expect(result.perQuery).toHaveLength(1); + expect(result.perQuery[0]?.hitAt10).toBe(true); + expect(result.perQuery[0]?.failureBucket).toBeUndefined(); + + const localEvalConfig = JSON.parse( + readFileSync(path.join(worktreeDir, ".opencode", "codebase-index.json"), "utf-8") + ) as { + knowledgeBases?: string[]; + }; + + expect(localEvalConfig.knowledgeBases).toEqual([path.join("..", "main-repo", "external-kb")]); + }); + + it("rematerializes the local eval config when repeated reindex runs use different explicit config paths", async () => { + const mainRepoDir = path.join(tempDir, "main-repo"); + const worktreeDir = path.join(tempDir, "worktree-feature"); + const worktreeGitDir = path.join(mainRepoDir, ".git", "worktrees", "feature"); + const configDir = path.join(mainRepoDir, "config"); + const kbOneDir = path.join(mainRepoDir, "kb-one"); + const kbTwoDir = path.join(mainRepoDir, "kb-two"); + const configOnePath = path.join(configDir, "eval-config-one.json"); + const configTwoPath = path.join(configDir, "eval-config-two.json"); + + mkdirSync(path.join(mainRepoDir, ".git", "refs", "heads"), { recursive: true }); + mkdirSync(path.join(mainRepoDir, "src", "indexer"), { recursive: true }); + mkdirSync(path.join(mainRepoDir, "src", "tools"), { recursive: true }); + mkdirSync(path.join(mainRepoDir, "benchmarks", "golden"), { recursive: true }); + mkdirSync(path.join(worktreeDir, ".opencode", "index"), { recursive: true }); + mkdirSync(worktreeGitDir, { recursive: true }); + mkdirSync(configDir, { recursive: true }); + mkdirSync(kbOneDir, { recursive: true }); + mkdirSync(kbTwoDir, { recursive: true }); + mkdirSync(worktreeDir, { recursive: true }); + + writeFileSync(path.join(mainRepoDir, ".git", "HEAD"), "ref: refs/heads/main\n"); + writeFileSync(path.join(mainRepoDir, ".git", "refs", "heads", "main"), "1111111111111111111111111111111111111111\n"); + writeFileSync(path.join(worktreeDir, ".git"), `gitdir: ${worktreeGitDir}\n`); + writeFileSync(path.join(worktreeGitDir, "HEAD"), "ref: refs/heads/feature\n"); + writeFileSync(path.join(worktreeGitDir, "commondir"), "../..\n"); + + const baseConfig = { + embeddingProvider: "custom", + customProvider: { + baseUrl: "http://localhost:11434/v1", + model: "mock-embedding-model", + dimensions: 8, + }, + indexing: { + watchFiles: false, + }, + search: { + maxResults: 10, + minScore: 0, + fusionStrategy: "rrf", + rrfK: 60, + rerankTopN: 20, + }, + }; + + writeFileSync(configOnePath, JSON.stringify({ ...baseConfig, knowledgeBases: ["../kb-one"] }, null, 2), "utf-8"); + writeFileSync(configTwoPath, JSON.stringify({ ...baseConfig, knowledgeBases: ["../kb-two"] }, null, 2), "utf-8"); + + writeFileSync( + path.join(mainRepoDir, "src", "indexer", "index.ts"), + "export function rankHybridResults(query: string) { return query.length; }\n", + "utf-8" + ); + writeFileSync( + path.join(mainRepoDir, "src", "tools", "index.ts"), + "export const codebase_search = () => 'ok';\n", + "utf-8" + ); + writeFileSync( + path.join(kbOneDir, "guide.ts"), + "export function kbOneSymbol() { return 'one'; }\n", + "utf-8" + ); + writeFileSync( + path.join(kbTwoDir, "guide.ts"), + "export function kbTwoSymbol() { return 'two'; }\n", + "utf-8" + ); + writeFileSync( + path.join(mainRepoDir, "benchmarks", "golden", "small.json"), + JSON.stringify( + { + version: "1.0.0", + name: "small", + queries: [ + { + id: "q1", + query: "where is kbTwoSymbol implementation", + queryType: "definition", + expected: { + filePath: "kb-two/guide.ts", + symbol: "kbTwoSymbol", + }, + }, + ], + }, + null, + 2 + ), + "utf-8" + ); + + await runEvaluation({ + projectRoot: worktreeDir, + configPath: path.relative(worktreeDir, configOnePath), + datasetPath: path.relative(worktreeDir, path.join(mainRepoDir, "benchmarks", "golden", "small.json")), + outputRoot: "benchmarks/results", + ciMode: false, + reindex: true, + }); + + const secondRun = await runEvaluation({ + projectRoot: worktreeDir, + configPath: path.relative(worktreeDir, configTwoPath), + datasetPath: path.relative(worktreeDir, path.join(mainRepoDir, "benchmarks", "golden", "small.json")), + outputRoot: "benchmarks/results", + ciMode: false, + reindex: true, + }); + + expect(secondRun.perQuery).toHaveLength(1); + expect(secondRun.perQuery[0]?.hitAt10).toBe(true); + expect(secondRun.perQuery[0]?.results.some((result) => result.filePath.endsWith("kb-two/guide.ts"))).toBe(true); + + const localEvalConfig = JSON.parse( + readFileSync(path.join(worktreeDir, ".opencode", "codebase-index.json"), "utf-8") + ) as { + knowledgeBases?: string[]; + }; + + expect(localEvalConfig.knowledgeBases).toEqual([path.join("..", "main-repo", "kb-two")]); + }); + it("compares against baseline and writes compare artifact", async () => { const baselineRun = await runEvaluation({ projectRoot: tempDir, diff --git a/tests/git.test.ts b/tests/git.test.ts index f36553c3..1863ec03 100644 --- a/tests/git.test.ts +++ b/tests/git.test.ts @@ -11,6 +11,7 @@ import { getBranchOrDefault, getHeadPath, resolveGitDir, + resolveWorktreeMainRepoRoot, } from "../src/git/index.js"; describe("git utilities", () => { @@ -272,8 +273,19 @@ describe("git utilities", () => { expect(getHeadPath(worktreeDir)).toBe(path.join(worktreeGitDir, "HEAD")); }); + it("resolveWorktreeMainRepoRoot should return the main repo root", () => { + expect(resolveWorktreeMainRepoRoot(worktreeDir)).toBe(mainRepoDir); + }); + it("getBranchOrDefault should work in worktree", () => { expect(getBranchOrDefault(worktreeDir)).toBe("feature/x/y"); }); }); + + describe("resolveWorktreeMainRepoRoot", () => { + it("should return null for a normal repo", () => { + fs.mkdirSync(path.join(tempDir, ".git")); + expect(resolveWorktreeMainRepoRoot(tempDir)).toBe(null); + }); + }); }); diff --git a/tests/indexer-clear-index.test.ts b/tests/indexer-clear-index.test.ts index 03be1542..c44dfe2b 100644 --- a/tests/indexer-clear-index.test.ts +++ b/tests/indexer-clear-index.test.ts @@ -4,6 +4,7 @@ import * as path from "path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { loadMergedConfig } from "../src/config/merger.js"; import { parseConfig } from "../src/config/schema.js"; import { Indexer } from "../src/indexer/index.js"; import { Database } from "../src/native/index.js"; @@ -126,6 +127,52 @@ describe("indexer clearIndex force rebuild", () => { expect(floatCount).toBe(4); }); + it("rejects force clearing an inherited project index from a fresh worktree", async () => { + const mainRepoDir = path.join(tempDir, "main-repo"); + const worktreeDir = path.join(tempDir, "worktree-feature"); + const worktreeGitDir = path.join(mainRepoDir, ".git", "worktrees", "feature"); + const mainSourceFile = path.join(mainRepoDir, "src", "index.ts"); + + fs.mkdirSync(path.join(mainRepoDir, ".git", "refs", "heads"), { recursive: true }); + fs.mkdirSync(path.join(mainRepoDir, ".opencode", "index"), { recursive: true }); + fs.mkdirSync(path.dirname(mainSourceFile), { recursive: true }); + fs.mkdirSync(worktreeGitDir, { recursive: true }); + fs.mkdirSync(worktreeDir, { recursive: true }); + + fs.writeFileSync(path.join(mainRepoDir, ".git", "HEAD"), "ref: refs/heads/main\n"); + fs.writeFileSync(path.join(mainRepoDir, ".git", "refs", "heads", "main"), "1111111111111111111111111111111111111111\n"); + fs.writeFileSync(path.join(worktreeDir, ".git"), `gitdir: ${worktreeGitDir}\n`); + fs.writeFileSync(path.join(worktreeGitDir, "HEAD"), "ref: refs/heads/feature\n"); + fs.writeFileSync(path.join(worktreeGitDir, "commondir"), "../..\n"); + + fs.writeFileSync( + path.join(mainRepoDir, ".opencode", "codebase-index.json"), + JSON.stringify({ + embeddingProvider: "custom", + customProvider: { + baseUrl: "http://localhost:11434/v1", + model: "mock-8d", + dimensions: 8, + }, + indexing: { + watchFiles: false, + retries: 0, + retryDelayMs: 1, + }, + }, null, 2), + "utf-8" + ); + fs.writeFileSync(mainSourceFile, "export function alpha() { return 'a'; }\n", "utf-8"); + + embeddingDimensions = 8; + await createIndexer(mainRepoDir, 8).index(); + + const inheritedIndexer = new Indexer(worktreeDir, parseConfig(loadMergedConfig(worktreeDir))); + await expect(inheritedIndexer.clearIndex()).rejects.toThrow( + "Project-scoped force rebuild is unsafe while using an inherited worktree index" + ); + }); + it("clears only the current project from a shared global index when compatibility is unchanged", async () => { vi.stubEnv("HOME", tempHome); diff --git a/tests/mcp-server.test.ts b/tests/mcp-server.test.ts index 40149b82..a40c8228 100644 --- a/tests/mcp-server.test.ts +++ b/tests/mcp-server.test.ts @@ -4,6 +4,34 @@ import { parseConfig } from "../src/config/schema.js"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +vi.mock("fs", async () => { + const actual = await vi.importActual("fs"); + return { + ...actual, + existsSync: vi.fn((targetPath: string) => targetPath.includes("/main-repo/.opencode/index")), + }; +}); + +vi.mock("../src/git/index.js", () => ({ + resolveWorktreeMainRepoRoot: vi.fn(() => "/tmp/main-repo"), +})); + +const mergerMocks = vi.hoisted(() => ({ + loadProjectConfigLayer: vi.fn(() => ({})), + materializeLocalProjectConfig: vi.fn(), +})); + +const indexerMockState = vi.hoisted(() => ({ + constructorArgs: [] as Array<[string, unknown]>, + instances: [] as Array<{ + initialize: ReturnType; + getStatus: ReturnType; + clearIndex: ReturnType; + }>, +})); + +vi.mock("../src/config/merger.js", () => mergerMocks); + let mockIndexResult = { totalFiles: 10, totalChunks: 50, @@ -51,6 +79,15 @@ let mockHealthCheckResult = { vi.mock("../src/indexer/index.js", () => { class MockIndexer { + constructor(projectRoot: string, config: unknown) { + indexerMockState.constructorArgs.push([projectRoot, config]); + indexerMockState.instances.push({ + initialize: this.initialize, + getStatus: this.getStatus, + clearIndex: this.clearIndex, + }); + } + initialize = vi.fn().mockResolvedValue(undefined); search = vi.fn().mockResolvedValue([ { @@ -123,6 +160,11 @@ describe("MCP server tools and prompts", () => { let server: ReturnType; beforeEach(async () => { + indexerMockState.constructorArgs.length = 0; + indexerMockState.instances.length = 0; + mergerMocks.loadProjectConfigLayer.mockReset(); + mergerMocks.loadProjectConfigLayer.mockReturnValue({}); + mergerMocks.materializeLocalProjectConfig.mockReset(); mockIndexResult = { totalFiles: 10, totalChunks: 50, @@ -141,7 +183,7 @@ describe("MCP server tools and prompts", () => { vectorCount: 50, provider: "openai", model: "text-embedding-3-small", - indexPath: "/tmp/index", + indexPath: "/tmp/main-repo/.opencode/index", currentBranch: "main", baseBranch: "main", compatibility: { compatible: true }, @@ -309,6 +351,88 @@ describe("MCP server tools and prompts", () => { expect(content[0].text).toContain("Estimate"); }); + it("should preserve runtime config on force refresh after localizing inherited project state", async () => { + mergerMocks.loadProjectConfigLayer.mockReturnValue({ knowledgeBases: ["docs/reference"] }); + + const runtimeConfig = parseConfig({ + embeddingProvider: "custom", + customProvider: { + baseUrl: "https://runtime.example.com/v1", + model: "runtime-model", + dimensions: 1024, + apiKey: "runtime-key", + }, + scope: "project", + }); + server = createMcpServer("/tmp/test-project", runtimeConfig); + client = new Client({ name: "test-client", version: "1.0.0" }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([ + server.connect(serverTransport), + client.connect(clientTransport), + ]); + + const result = await client.callTool({ + name: "index_codebase", + arguments: { force: true }, + }); + + expect(result.content).toBeDefined(); + expect(mergerMocks.materializeLocalProjectConfig).toHaveBeenCalledWith( + "/tmp/test-project", + mergerMocks.loadProjectConfigLayer.mock.results.at(-1)?.value, + ); + + expect(indexerMockState.constructorArgs.length).toBeGreaterThanOrEqual(3); + expect(indexerMockState.constructorArgs.slice(-2)).toEqual([ + ["/tmp/test-project", runtimeConfig], + ["/tmp/test-project", runtimeConfig], + ]); + expect(indexerMockState.instances[0]?.initialize).not.toHaveBeenCalled(); + expect(indexerMockState.instances[0]?.getStatus).not.toHaveBeenCalled(); + }); + + it("should materialize only the project config layer during MCP force localization", async () => { + mergerMocks.loadProjectConfigLayer.mockReturnValue({ knowledgeBases: ["docs/reference"] }); + + const runtimeConfig = parseConfig({ + embeddingProvider: "custom", + customProvider: { + baseUrl: "https://runtime.example.com/v1", + model: "runtime-model", + dimensions: 1024, + apiKey: "runtime-key", + }, + scope: "project", + search: { + maxResults: 25, + }, + }); + server = createMcpServer("/tmp/test-project", runtimeConfig); + client = new Client({ name: "test-client", version: "1.0.0" }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([ + server.connect(serverTransport), + client.connect(clientTransport), + ]); + + await client.callTool({ + name: "index_codebase", + arguments: { force: true }, + }); + + expect(mergerMocks.materializeLocalProjectConfig).toHaveBeenCalledWith( + "/tmp/test-project", + { knowledgeBases: ["docs/reference"] }, + ); + expect(indexerMockState.constructorArgs.slice(-2)).toEqual([ + ["/tmp/test-project", runtimeConfig], + ["/tmp/test-project", runtimeConfig], + ]); + }); + it("should execute index_health_check tool", async () => { const result = await client.callTool({ name: "index_health_check", diff --git a/tests/tools-knowledge-bases.test.ts b/tests/tools-knowledge-bases.test.ts index 0796f108..43b652f1 100644 --- a/tests/tools-knowledge-bases.test.ts +++ b/tests/tools-knowledge-bases.test.ts @@ -5,16 +5,29 @@ import * as path from "path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const { indexerInstances, MockIndexer } = vi.hoisted(() => { - const indexerInstances: Array<{ projectRoot: string; config: Record }> = []; + const indexerInstances: Array<{ + projectRoot: string; + config: Record; + getStatus: ReturnType; + }> = []; class MockIndexer { public readonly projectRoot: string; public readonly config: Record; + public getStatus = vi.fn().mockResolvedValue({ + indexed: true, + vectorCount: 0, + provider: "ollama", + model: "nomic-embed-text", + indexPath: "/tmp/index", + currentBranch: "main", + baseBranch: "main", + }); public constructor(projectRoot: string, config: Record) { this.projectRoot = projectRoot; this.config = config; - indexerInstances.push({ projectRoot, config }); + indexerInstances.push({ projectRoot, config, getStatus: this.getStatus }); } public estimateCost = vi.fn().mockResolvedValue({ @@ -42,16 +55,6 @@ const { indexerInstances, MockIndexer } = vi.hoisted(() => { parseFailures: [], }); - public getStatus = vi.fn().mockResolvedValue({ - indexed: true, - vectorCount: 0, - provider: "ollama", - model: "nomic-embed-text", - indexPath: "/tmp/index", - currentBranch: "main", - baseBranch: "main", - }); - public healthCheck = vi.fn().mockResolvedValue({ removed: 0, gcOrphanEmbeddings: 0, @@ -79,7 +82,8 @@ vi.mock("../src/indexer/index.js", () => ({ })); import { parseConfig } from "../src/config/schema.js"; -import { add_knowledge_base, initializeTools, remove_knowledge_base } from "../src/tools/index.js"; +import { loadMergedConfig } from "../src/config/merger.js"; +import { add_knowledge_base, index_codebase, initializeTools, remove_knowledge_base } from "../src/tools/index.js"; describe("knowledge base tool config refresh", () => { let tempDir: string; @@ -113,4 +117,320 @@ describe("knowledge base tool config refresh", () => { expect(indexerInstances[2]?.projectRoot).toBe(tempDir); expect(indexerInstances[2]?.config.knowledgeBases).toEqual([]); }); + + it("materializes a local config boundary for fresh worktree knowledge base edits", async () => { + const mainRepoDir = path.join(tempDir, "main-repo"); + const worktreeDir = path.join(tempDir, "worktree-feature"); + const worktreeGitDir = path.join(mainRepoDir, ".git", "worktrees", "feature"); + + fs.mkdirSync(path.join(mainRepoDir, ".git", "refs", "heads"), { recursive: true }); + fs.mkdirSync(path.join(mainRepoDir, ".opencode"), { recursive: true }); + fs.mkdirSync(worktreeGitDir, { recursive: true }); + fs.mkdirSync(worktreeDir, { recursive: true }); + + fs.writeFileSync(path.join(mainRepoDir, ".git", "HEAD"), "ref: refs/heads/main\n"); + fs.writeFileSync(path.join(mainRepoDir, ".git", "refs", "heads", "main"), "1111111111111111111111111111111111111111\n"); + fs.writeFileSync(path.join(worktreeDir, ".git"), `gitdir: ${worktreeGitDir}\n`); + fs.writeFileSync(path.join(worktreeGitDir, "HEAD"), "ref: refs/heads/feature\n"); + fs.writeFileSync(path.join(worktreeGitDir, "commondir"), "../..\n"); + + const mainConfigPath = path.join(mainRepoDir, ".opencode", "codebase-index.json"); + fs.writeFileSync( + mainConfigPath, + JSON.stringify({ + embeddingProvider: "custom", + customProvider: { + baseUrl: "http://localhost:11434/v1", + model: "mock-model", + dimensions: 8, + }, + indexing: { watchFiles: false }, + knowledgeBases: [], + }, null, 2), + "utf-8" + ); + + indexerInstances.length = 0; + initializeTools(worktreeDir, parseConfig(loadMergedConfig(worktreeDir))); + + await add_knowledge_base.execute({ path: kbDir }); + + const localConfigPath = path.join(worktreeDir, ".opencode", "codebase-index.json"); + const savedMainConfig = JSON.parse(fs.readFileSync(mainConfigPath, "utf-8")) as { knowledgeBases?: string[] }; + const localConfig = JSON.parse(fs.readFileSync(localConfigPath, "utf-8")) as { knowledgeBases?: string[] }; + expect(savedMainConfig.knowledgeBases).toEqual([]); + expect(localConfig.knowledgeBases).toEqual([path.normalize(kbDir)]); + expect(indexerInstances.at(-1)?.projectRoot).toBe(worktreeDir); + expect(indexerInstances.at(-1)?.config.knowledgeBases).toEqual([path.normalize(kbDir)]); + }); + + it("keeps repo-local inherited knowledge bases relative when materializing a local boundary", async () => { + const mainRepoDir = path.join(tempDir, "main-repo"); + const worktreeDir = path.join(tempDir, "worktree-feature"); + const worktreeGitDir = path.join(mainRepoDir, ".git", "worktrees", "feature"); + + fs.mkdirSync(path.join(mainRepoDir, ".git", "refs", "heads"), { recursive: true }); + fs.mkdirSync(path.join(mainRepoDir, ".opencode"), { recursive: true }); + fs.mkdirSync(path.join(mainRepoDir, "docs", "reference"), { recursive: true }); + fs.mkdirSync(worktreeGitDir, { recursive: true }); + fs.mkdirSync(worktreeDir, { recursive: true }); + + fs.writeFileSync(path.join(mainRepoDir, ".git", "HEAD"), "ref: refs/heads/main\n"); + fs.writeFileSync(path.join(mainRepoDir, ".git", "refs", "heads", "main"), "1111111111111111111111111111111111111111\n"); + fs.writeFileSync(path.join(worktreeDir, ".git"), `gitdir: ${worktreeGitDir}\n`); + fs.writeFileSync(path.join(worktreeGitDir, "HEAD"), "ref: refs/heads/feature\n"); + fs.writeFileSync(path.join(worktreeGitDir, "commondir"), "../..\n"); + + const mainConfigPath = path.join(mainRepoDir, ".opencode", "codebase-index.json"); + fs.writeFileSync( + mainConfigPath, + JSON.stringify({ + embeddingProvider: "custom", + customProvider: { + baseUrl: "http://localhost:11434/v1", + model: "mock-model", + dimensions: 8, + }, + indexing: { watchFiles: false }, + knowledgeBases: ["docs/reference"], + }, null, 2), + "utf-8" + ); + + indexerInstances.length = 0; + initializeTools(worktreeDir, parseConfig(loadMergedConfig(worktreeDir))); + + await add_knowledge_base.execute({ path: kbDir }); + + const localConfigPath = path.join(worktreeDir, ".opencode", "codebase-index.json"); + const savedMainConfig = JSON.parse(fs.readFileSync(mainConfigPath, "utf-8")) as { knowledgeBases?: string[] }; + const localConfig = JSON.parse(fs.readFileSync(localConfigPath, "utf-8")) as { knowledgeBases?: string[] }; + expect(savedMainConfig.knowledgeBases).toEqual(["docs/reference"]); + expect(localConfig.knowledgeBases).toEqual(["docs/reference", path.normalize(kbDir)]); + }); + + it("preserves additionalInclude globs when tools rewrite local config", async () => { + const configPath = path.join(tempDir, ".opencode", "codebase-index.json"); + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.writeFileSync( + configPath, + JSON.stringify({ + embeddingProvider: "custom", + customProvider: { + baseUrl: "http://localhost:11434/v1", + model: "mock-model", + dimensions: 8, + }, + indexing: { watchFiles: false }, + additionalInclude: ["docs/**/*.md"], + knowledgeBases: [], + }, null, 2), + "utf-8" + ); + + indexerInstances.length = 0; + initializeTools(tempDir, parseConfig(loadMergedConfig(tempDir))); + + await add_knowledge_base.execute({ path: kbDir }); + + const savedConfig = JSON.parse(fs.readFileSync(configPath, "utf-8")) as { + additionalInclude?: string[]; + knowledgeBases?: string[]; + }; + expect(savedConfig.additionalInclude).toEqual(["docs/**/*.md"]); + expect(savedConfig.knowledgeBases).toEqual([path.normalize(kbDir)]); + expect(indexerInstances.at(-1)?.config.additionalInclude).toEqual(["docs/**/*.md"]); + }); + + it("preserves inherited additionalInclude globs when materializing a local config boundary", async () => { + const mainRepoDir = path.join(tempDir, "main-repo"); + const worktreeDir = path.join(tempDir, "worktree-feature"); + const worktreeGitDir = path.join(mainRepoDir, ".git", "worktrees", "feature"); + + fs.mkdirSync(path.join(mainRepoDir, ".git", "refs", "heads"), { recursive: true }); + fs.mkdirSync(path.join(mainRepoDir, ".opencode"), { recursive: true }); + fs.mkdirSync(worktreeGitDir, { recursive: true }); + fs.mkdirSync(worktreeDir, { recursive: true }); + + fs.writeFileSync(path.join(mainRepoDir, ".git", "HEAD"), "ref: refs/heads/main\n"); + fs.writeFileSync(path.join(mainRepoDir, ".git", "refs", "heads", "main"), "1111111111111111111111111111111111111111\n"); + fs.writeFileSync(path.join(worktreeDir, ".git"), `gitdir: ${worktreeGitDir}\n`); + fs.writeFileSync(path.join(worktreeGitDir, "HEAD"), "ref: refs/heads/feature\n"); + fs.writeFileSync(path.join(worktreeGitDir, "commondir"), "../..\n"); + + const mainConfigPath = path.join(mainRepoDir, ".opencode", "codebase-index.json"); + fs.writeFileSync( + mainConfigPath, + JSON.stringify({ + embeddingProvider: "custom", + customProvider: { + baseUrl: "http://localhost:11434/v1", + model: "mock-model", + dimensions: 8, + }, + indexing: { watchFiles: false }, + additionalInclude: ["docs/**/*.md"], + knowledgeBases: [], + }, null, 2), + "utf-8" + ); + + indexerInstances.length = 0; + initializeTools(worktreeDir, parseConfig(loadMergedConfig(worktreeDir))); + + await add_knowledge_base.execute({ path: kbDir }); + + const localConfigPath = path.join(worktreeDir, ".opencode", "codebase-index.json"); + const savedMainConfig = JSON.parse(fs.readFileSync(mainConfigPath, "utf-8")) as { + additionalInclude?: string[]; + knowledgeBases?: string[]; + }; + const localConfig = JSON.parse(fs.readFileSync(localConfigPath, "utf-8")) as { + additionalInclude?: string[]; + knowledgeBases?: string[]; + }; + expect(savedMainConfig.additionalInclude).toEqual(["docs/**/*.md"]); + expect(savedMainConfig.knowledgeBases).toEqual([]); + expect(localConfig.additionalInclude).toEqual(["docs/**/*.md"]); + expect(localConfig.knowledgeBases).toEqual([path.normalize(kbDir)]); + expect(indexerInstances.at(-1)?.config.additionalInclude).toEqual(["docs/**/*.md"]); + }); + + it("writes upgraded worktree knowledge base edits to a local config boundary when a local index already exists", async () => { + const mainRepoDir = path.join(tempDir, "main-repo"); + const worktreeDir = path.join(tempDir, "worktree-feature"); + const worktreeGitDir = path.join(mainRepoDir, ".git", "worktrees", "feature"); + + fs.mkdirSync(path.join(mainRepoDir, ".git", "refs", "heads"), { recursive: true }); + fs.mkdirSync(path.join(mainRepoDir, ".opencode"), { recursive: true }); + fs.mkdirSync(path.join(worktreeDir, ".opencode", "index"), { recursive: true }); + fs.mkdirSync(worktreeGitDir, { recursive: true }); + fs.mkdirSync(worktreeDir, { recursive: true }); + + fs.writeFileSync(path.join(mainRepoDir, ".git", "HEAD"), "ref: refs/heads/main\n"); + fs.writeFileSync(path.join(mainRepoDir, ".git", "refs", "heads", "main"), "1111111111111111111111111111111111111111\n"); + fs.writeFileSync(path.join(worktreeDir, ".git"), `gitdir: ${worktreeGitDir}\n`); + fs.writeFileSync(path.join(worktreeGitDir, "HEAD"), "ref: refs/heads/feature\n"); + fs.writeFileSync(path.join(worktreeGitDir, "commondir"), "../..\n"); + + const mainConfigPath = path.join(mainRepoDir, ".opencode", "codebase-index.json"); + fs.writeFileSync( + mainConfigPath, + JSON.stringify({ + embeddingProvider: "custom", + customProvider: { + baseUrl: "http://localhost:11434/v1", + model: "mock-model", + dimensions: 8, + }, + indexing: { watchFiles: false }, + knowledgeBases: [], + }, null, 2), + "utf-8" + ); + + indexerInstances.length = 0; + initializeTools(worktreeDir, parseConfig(loadMergedConfig(worktreeDir))); + + await add_knowledge_base.execute({ path: kbDir }); + + const localConfigPath = path.join(worktreeDir, ".opencode", "codebase-index.json"); + const localConfig = JSON.parse(fs.readFileSync(localConfigPath, "utf-8")) as { knowledgeBases?: string[] }; + const savedMainConfig = JSON.parse(fs.readFileSync(mainConfigPath, "utf-8")) as { knowledgeBases?: string[] }; + + expect(localConfig.knowledgeBases).toEqual([path.normalize(kbDir)]); + expect(savedMainConfig.knowledgeBases).toEqual([]); + expect(indexerInstances.at(-1)?.projectRoot).toBe(worktreeDir); + expect(indexerInstances.at(-1)?.config.knowledgeBases).toEqual([path.normalize(kbDir)]); + }); + + it("localizes force rebuilds before probing inherited project indexes", async () => { + const mainRepoDir = path.join(tempDir, "main-repo"); + const worktreeDir = path.join(tempDir, "worktree-feature"); + const worktreeGitDir = path.join(mainRepoDir, ".git", "worktrees", "feature"); + + fs.mkdirSync(path.join(mainRepoDir, ".git", "refs", "heads"), { recursive: true }); + fs.mkdirSync(path.join(mainRepoDir, ".opencode", "index"), { recursive: true }); + fs.mkdirSync(path.join(mainRepoDir, ".opencode"), { recursive: true }); + fs.mkdirSync(worktreeGitDir, { recursive: true }); + fs.mkdirSync(worktreeDir, { recursive: true }); + + fs.writeFileSync(path.join(mainRepoDir, ".git", "HEAD"), "ref: refs/heads/main\n"); + fs.writeFileSync(path.join(mainRepoDir, ".git", "refs", "heads", "main"), "1111111111111111111111111111111111111111\n"); + fs.writeFileSync(path.join(worktreeDir, ".git"), `gitdir: ${worktreeGitDir}\n`); + fs.writeFileSync(path.join(worktreeGitDir, "HEAD"), "ref: refs/heads/feature\n"); + fs.writeFileSync(path.join(worktreeGitDir, "commondir"), "../..\n"); + + fs.writeFileSync( + path.join(mainRepoDir, ".opencode", "codebase-index.json"), + JSON.stringify({ knowledgeBases: ["docs/reference"] }, null, 2), + "utf-8" + ); + + indexerInstances.length = 0; + initializeTools(worktreeDir, parseConfig(loadMergedConfig(worktreeDir))); + + await index_codebase.execute({ force: true, estimateOnly: false, verbose: false }, { + metadata: () => undefined, + }); + + expect(indexerInstances[0]?.getStatus).not.toHaveBeenCalled(); + expect(indexerInstances.length).toBeGreaterThanOrEqual(2); + const localConfig = JSON.parse(fs.readFileSync(path.join(worktreeDir, ".opencode", "codebase-index.json"), "utf-8")) as { + knowledgeBases?: string[]; + }; + expect(localConfig.knowledgeBases).toEqual(["docs/reference"]); + }); + + it("does not snapshot global-only settings when materializing a local config boundary", async () => { + const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "kb-tools-home-")); + const mainRepoDir = path.join(tempDir, "main-repo"); + const worktreeDir = path.join(tempDir, "worktree-feature"); + const worktreeGitDir = path.join(mainRepoDir, ".git", "worktrees", "feature"); + + try { + vi.stubEnv("HOME", homeDir); + fs.mkdirSync(path.join(homeDir, ".config", "opencode"), { recursive: true }); + fs.writeFileSync( + path.join(homeDir, ".config", "opencode", "codebase-index.json"), + JSON.stringify({ debug: { enabled: true } }, null, 2), + "utf-8" + ); + + fs.mkdirSync(path.join(mainRepoDir, ".git", "refs", "heads"), { recursive: true }); + fs.mkdirSync(path.join(mainRepoDir, ".opencode"), { recursive: true }); + fs.mkdirSync(worktreeGitDir, { recursive: true }); + fs.mkdirSync(worktreeDir, { recursive: true }); + + fs.writeFileSync(path.join(mainRepoDir, ".git", "HEAD"), "ref: refs/heads/main\n"); + fs.writeFileSync(path.join(mainRepoDir, ".git", "refs", "heads", "main"), "1111111111111111111111111111111111111111\n"); + fs.writeFileSync(path.join(worktreeDir, ".git"), `gitdir: ${worktreeGitDir}\n`); + fs.writeFileSync(path.join(worktreeGitDir, "HEAD"), "ref: refs/heads/feature\n"); + fs.writeFileSync(path.join(worktreeGitDir, "commondir"), "../..\n"); + + const mainConfigPath = path.join(mainRepoDir, ".opencode", "codebase-index.json"); + fs.writeFileSync( + mainConfigPath, + JSON.stringify({ knowledgeBases: [] }, null, 2), + "utf-8" + ); + + indexerInstances.length = 0; + initializeTools(worktreeDir, parseConfig(loadMergedConfig(worktreeDir))); + + await add_knowledge_base.execute({ path: kbDir }); + + const localConfigPath = path.join(worktreeDir, ".opencode", "codebase-index.json"); + const localConfig = JSON.parse(fs.readFileSync(localConfigPath, "utf-8")) as { + knowledgeBases?: string[]; + debug?: { enabled?: boolean }; + }; + + expect(localConfig.knowledgeBases).toEqual([path.normalize(kbDir)]); + expect(localConfig).not.toHaveProperty("debug"); + } finally { + vi.unstubAllEnvs(); + fs.rmSync(homeDir, { recursive: true, force: true }); + } + }); }); diff --git a/tests/worktree-fallback.test.ts b/tests/worktree-fallback.test.ts new file mode 100644 index 00000000..1336ce74 --- /dev/null +++ b/tests/worktree-fallback.test.ts @@ -0,0 +1,159 @@ +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { loadMergedConfig } from "../src/config/merger.js"; +import { parseConfig } from "../src/config/schema.js"; +import { resolveProjectConfigPath, resolveProjectIndexPath, resolveWritableProjectConfigPath } from "../src/config/paths.js"; +import { Indexer } from "../src/indexer/index.js"; + +describe("worktree fallback (issue #60)", () => { + let tempDir: string; + let mainRepoDir: string; + let worktreeDir: string; + let worktreeGitDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "worktree-fallback-")); + mainRepoDir = path.join(tempDir, "main-repo"); + worktreeDir = path.join(tempDir, "worktree-feature"); + worktreeGitDir = path.join(mainRepoDir, ".git", "worktrees", "feature"); + + fs.mkdirSync(path.join(mainRepoDir, ".git", "refs", "heads", "feature", "x"), { recursive: true }); + fs.mkdirSync(path.join(mainRepoDir, ".opencode", "index"), { recursive: true }); + fs.mkdirSync(worktreeGitDir, { recursive: true }); + fs.mkdirSync(worktreeDir, { recursive: true }); + + fs.writeFileSync(path.join(mainRepoDir, ".git", "HEAD"), "ref: refs/heads/main\n"); + fs.writeFileSync(path.join(mainRepoDir, ".git", "refs", "heads", "main"), "1111111111111111111111111111111111111111\n"); + fs.writeFileSync(path.join(mainRepoDir, ".git", "refs", "heads", "feature", "x", "y"), "2222222222222222222222222222222222222222\n"); + fs.writeFileSync(path.join(worktreeDir, ".git"), `gitdir: ${worktreeGitDir}\n`); + fs.writeFileSync(path.join(worktreeGitDir, "HEAD"), "ref: refs/heads/feature/x/y\n"); + fs.writeFileSync(path.join(worktreeGitDir, "commondir"), "../..\n"); + + fs.writeFileSync( + path.join(mainRepoDir, ".opencode", "codebase-index.json"), + JSON.stringify( + { + embeddingProvider: "custom", + customProvider: { + baseUrl: "http://localhost:11434/v1", + model: "mock-model", + dimensions: 8, + }, + scope: "project", + indexing: { + watchFiles: false, + }, + additionalInclude: ["docs/**/*.md"], + knowledgeBases: ["docs/reference"], + }, + null, + 2 + ), + "utf-8" + ); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it("loads project config from the main repo when the worktree has no local config", () => { + const configPath = resolveProjectConfigPath(worktreeDir); + const loaded = loadMergedConfig(worktreeDir) as Record; + + expect(configPath).toBe(path.join(mainRepoDir, ".opencode", "codebase-index.json")); + expect(loaded.scope).toBe("project"); + expect(loaded.additionalInclude).toEqual(["docs/**/*.md"]); + expect(loaded.knowledgeBases).toEqual(["docs/reference"]); + }); + + it("rebases inherited absolute repo-local knowledge bases onto the worktree", () => { + const absoluteRepoLocalKb = path.join(mainRepoDir, "docs", "reference"); + + fs.writeFileSync( + path.join(mainRepoDir, ".opencode", "codebase-index.json"), + JSON.stringify( + { + embeddingProvider: "custom", + customProvider: { + baseUrl: "http://localhost:11434/v1", + model: "mock-model", + dimensions: 8, + }, + scope: "project", + indexing: { + watchFiles: false, + }, + additionalInclude: ["docs/**/*.md"], + knowledgeBases: [absoluteRepoLocalKb], + }, + null, + 2 + ), + "utf-8" + ); + + const loaded = loadMergedConfig(worktreeDir) as Record; + + expect(loaded.knowledgeBases).toEqual(["docs/reference"]); + }); + + it("resolves the project index path to the main repo when the worktree has no local index", async () => { + const config = parseConfig(loadMergedConfig(worktreeDir)); + const indexer = new Indexer(worktreeDir, config); + const status = await indexer.getStatus(); + + expect(resolveProjectIndexPath(worktreeDir, "project")).toBe(path.join(mainRepoDir, ".opencode", "index")); + expect(status.indexPath).toBe(path.join(mainRepoDir, ".opencode", "index")); + expect(status.currentBranch).toBe("feature/x/y"); + }); + + it("keeps explicit worktree-local config and index when they exist", () => { + fs.mkdirSync(path.join(worktreeDir, ".opencode", "index"), { recursive: true }); + fs.writeFileSync( + path.join(worktreeDir, ".opencode", "codebase-index.json"), + JSON.stringify({ scope: "project", knowledgeBases: ["worktree-only"] }, null, 2), + "utf-8" + ); + + const configPath = resolveProjectConfigPath(worktreeDir); + const indexPath = resolveProjectIndexPath(worktreeDir, "project"); + const loaded = loadMergedConfig(worktreeDir) as Record; + + expect(configPath).toBe(path.join(worktreeDir, ".opencode", "codebase-index.json")); + expect(indexPath).toBe(path.join(worktreeDir, ".opencode", "index")); + expect(loaded.knowledgeBases).toEqual(["worktree-only"]); + }); + + it("keeps a worktree-local config on a local worktree index boundary", () => { + fs.mkdirSync(path.join(worktreeDir, ".opencode", "index"), { recursive: true }); + + expect(resolveWritableProjectConfigPath(worktreeDir)).toBe(path.join(worktreeDir, ".opencode", "codebase-index.json")); + expect(resolveProjectConfigPath(worktreeDir)).toBe(path.join(mainRepoDir, ".opencode", "codebase-index.json")); + expect(resolveProjectIndexPath(worktreeDir, "project")).toBe(path.join(worktreeDir, ".opencode", "index")); + }); + + it("keeps explicit worktree-local config and index when they exist", () => { + fs.mkdirSync(path.join(worktreeDir, ".opencode", "index"), { recursive: true }); + + fs.writeFileSync( + path.join(worktreeDir, ".opencode", "codebase-index.json"), + JSON.stringify({ + embeddingProvider: "custom", + customProvider: { + baseUrl: "http://localhost:11434/v1", + model: "worktree-model", + dimensions: 16, + }, + scope: "project", + }, null, 2), + "utf-8" + ); + + expect(resolveProjectIndexPath(worktreeDir, "project")).toBe(path.join(worktreeDir, ".opencode", "index")); + }); +});