From ca4027b273de48cd37b3f86ba968a22f89f7582a Mon Sep 17 00:00:00 2001 From: Helweg Date: Wed, 22 Apr 2026 12:50:41 +0200 Subject: [PATCH 01/23] feat: detect main repo roots for git worktrees --- src/git/index.ts | 19 +++++++++++++++++++ tests/git.test.ts | 12 ++++++++++++ 2 files changed, 31 insertions(+) diff --git a/src/git/index.ts b/src/git/index.ts index 34fb40d2..51d5c189 100644 --- a/src/git/index.ts +++ b/src/git/index.ts @@ -40,6 +40,25 @@ function resolveCommonGitDir(gitDir: string): string { return gitDir; } +export function resolveWorktreeMainRepoRoot(repoRoot: string): string | null { + const gitDir = resolveGitDir(repoRoot); + if (!gitDir) { + return null; + } + + const commonGitDir = resolveCommonGitDir(gitDir); + if (commonGitDir === gitDir || path.basename(commonGitDir) !== ".git") { + return null; + } + + const mainRepoRoot = path.dirname(commonGitDir); + if (!existsSync(mainRepoRoot)) { + return null; + } + + return path.resolve(mainRepoRoot) === path.resolve(repoRoot) ? null : mainRepoRoot; +} + function tryResolveRefCommit(gitDir: string, refPath: string): string | null { const looseRefPath = path.join(gitDir, refPath); if (existsSync(looseRefPath)) { 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); + }); + }); }); From c6704f128e25ced7d937d28f0ba443b00cdb76b4 Mon Sep 17 00:00:00 2001 From: Helweg Date: Wed, 22 Apr 2026 12:50:54 +0200 Subject: [PATCH 02/23] feat: reuse main repo project index in fresh worktrees --- src/config/merger.ts | 7 ++- src/config/paths.ts | 44 +++++++++++++++ src/indexer/index.ts | 7 +-- tests/worktree-fallback.test.ts | 98 +++++++++++++++++++++++++++++++++ 4 files changed, 148 insertions(+), 8 deletions(-) create mode 100644 src/config/paths.ts create mode 100644 tests/worktree-fallback.test.ts diff --git a/src/config/merger.ts b/src/config/merger.ts index 6b9168ba..99bc7b56 100644 --- a/src/config/merger.ts +++ b/src/config/merger.ts @@ -1,7 +1,8 @@ import { existsSync, readFileSync } from "fs"; -import * as path from "path"; import * as os from "os"; +import { resolveProjectConfigPath } from "./paths.js"; + function loadJsonFile(filePath: string): unknown { try { if (existsSync(filePath)) { @@ -23,9 +24,9 @@ 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; // If neither exists, return empty diff --git a/src/config/paths.ts b/src/config/paths.ts new file mode 100644 index 00000000..32945405 --- /dev/null +++ b/src/config/paths.ts @@ -0,0 +1,44 @@ +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; +} + +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 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; + } + + return resolveWorktreeFallbackPath(projectRoot, PROJECT_INDEX_RELATIVE_PATH) ?? localIndexPath; +} diff --git a/src/indexer/index.ts b/src/indexer/index.ts index 46b107ed..e90cf0d5 100644 --- a/src/indexer/index.ts +++ b/src/indexer/index.ts @@ -33,6 +33,7 @@ import { } from "../native/index.js"; import type { SymbolData, CallEdgeData } from "../native/index.js"; import { getBranchOrDefault, getBaseBranch, isGitRepo } from "../git/index.js"; +import { resolveProjectIndexPath } from "../config/paths.js"; const CALL_GRAPH_LANGUAGES = new Set(["typescript", "tsx", "javascript", "jsx", "python", "go", "rust", "php"]); const CALL_GRAPH_SYMBOL_CHUNK_TYPES = new Set([ @@ -1456,11 +1457,7 @@ export class Indexer { } private getIndexPath(): string { - if (this.config.scope === "global") { - const homeDir = process.env.HOME || process.env.USERPROFILE || ""; - return path.join(homeDir, ".opencode", "global-index"); - } - return path.join(this.projectRoot, ".opencode", "index"); + return resolveProjectIndexPath(this.projectRoot, this.config.scope); } private loadFileHashCache(): void { diff --git a/tests/worktree-fallback.test.ts b/tests/worktree-fallback.test.ts new file mode 100644 index 00000000..4349e16f --- /dev/null +++ b/tests/worktree-fallback.test.ts @@ -0,0 +1,98 @@ +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 } 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, + }, + 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.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"]); + }); +}); From 706612073ec76efb484468c979b3f517b5562c22 Mon Sep 17 00:00:00 2001 From: Helweg Date: Wed, 22 Apr 2026 12:51:15 +0200 Subject: [PATCH 03/23] feat: apply worktree fallback to eval index paths --- src/eval/runner.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/eval/runner.ts b/src/eval/runner.ts index 35030d42..7a0b18ff 100644 --- a/src/eval/runner.ts +++ b/src/eval/runner.ts @@ -8,6 +8,7 @@ import { performance } from "perf_hooks"; 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"; @@ -43,7 +44,7 @@ function loadRawConfig(projectRoot: string, configPath?: string): unknown { return JSON.parse(readFileSync(fromPath, "utf-8")); } - const projectConfig = path.join(projectRoot, ".opencode", "codebase-index.json"); + const projectConfig = resolveProjectConfigPath(projectRoot); if (existsSync(projectConfig)) { return JSON.parse(readFileSync(projectConfig, "utf-8")); } @@ -57,10 +58,9 @@ 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 path.join(projectRoot, ".opencode", "index"); + return scope === "global" + ? getGlobalIndexPath() + : resolveProjectIndexPath(projectRoot, scope); } function clearIndexRoot(projectRoot: string, scope: "project" | "global"): void { From d52771250a967f39eed4d455e95adeccd2fb37b1 Mon Sep 17 00:00:00 2001 From: Helweg Date: Wed, 22 Apr 2026 15:10:19 +0200 Subject: [PATCH 04/23] fix: keep worktree fallback config and index in sync --- src/config/merger.ts | 31 ++++++++++++++++++++++++++++++- src/config/paths.ts | 8 ++++++++ tests/worktree-fallback.test.ts | 22 +++++++++++++++++++++- 3 files changed, 59 insertions(+), 2 deletions(-) diff --git a/src/config/merger.ts b/src/config/merger.ts index 99bc7b56..918da1bc 100644 --- a/src/config/merger.ts +++ b/src/config/merger.ts @@ -1,5 +1,6 @@ import { existsSync, readFileSync } from "fs"; import * as os from "os"; +import * as path from "path"; import { resolveProjectConfigPath } from "./paths.js"; @@ -13,6 +14,28 @@ function loadJsonFile(filePath: string): unknown { return null; } +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); +} + /** * Loads and merges global and project configs. * @@ -28,6 +51,7 @@ export function loadMergedConfig(projectRoot: string): unknown { const globalConfig = loadJsonFile(globalConfigPath) as Record | null; const projectConfigPath = resolveProjectConfigPath(projectRoot); const projectConfig = loadJsonFile(projectConfigPath) as Record | null; + const projectConfigBaseDir = path.dirname(path.dirname(projectConfigPath)); // If neither exists, return empty if (!globalConfig && !projectConfig) { @@ -41,6 +65,9 @@ export function loadMergedConfig(projectRoot: string): unknown { // If only project exists, return it if (!globalConfig && projectConfig) { + if (Array.isArray(projectConfig.knowledgeBases)) { + projectConfig.knowledgeBases = rebasePathEntries(projectConfig.knowledgeBases, projectConfigBaseDir, projectRoot); + } return projectConfig; } @@ -142,7 +169,9 @@ export function loadMergedConfig(projectRoot: string): unknown { // 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 + ? rebasePathEntries(projectConfig.knowledgeBases, projectConfigBaseDir, projectRoot) + : []; 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 index 32945405..60eaff45 100644 --- a/src/config/paths.ts +++ b/src/config/paths.ts @@ -17,6 +17,10 @@ function resolveWorktreeFallbackPath(projectRoot: string, relativePath: string): 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"); } @@ -40,5 +44,9 @@ export function resolveProjectIndexPath(projectRoot: string, scope: "project" | return localIndexPath; } + if (hasProjectConfig(projectRoot)) { + return localIndexPath; + } + return resolveWorktreeFallbackPath(projectRoot, PROJECT_INDEX_RELATIVE_PATH) ?? localIndexPath; } diff --git a/tests/worktree-fallback.test.ts b/tests/worktree-fallback.test.ts index 4349e16f..149c7a89 100644 --- a/tests/worktree-fallback.test.ts +++ b/tests/worktree-fallback.test.ts @@ -66,7 +66,7 @@ describe("worktree fallback (issue #60)", () => { expect(configPath).toBe(path.join(mainRepoDir, ".opencode", "codebase-index.json")); expect(loaded.scope).toBe("project"); - expect(loaded.knowledgeBases).toEqual(["docs/reference"]); + expect(loaded.knowledgeBases).toEqual([path.join("..", "main-repo", "docs", "reference")]); }); it("resolves the project index path to the main repo when the worktree has no local index", async () => { @@ -95,4 +95,24 @@ describe("worktree fallback (issue #60)", () => { 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"), { 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")); + }); }); From 4bddb31c25db69fe0bdb000f989b56d454da44c3 Mon Sep 17 00:00:00 2001 From: Helweg Date: Wed, 22 Apr 2026 15:53:13 +0200 Subject: [PATCH 05/23] fix: preserve inherited config paths during kb updates --- src/tools/index.ts | 7 +++-- tests/tools-knowledge-bases.test.ts | 45 +++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/src/tools/index.ts b/src/tools/index.ts index c615bef9..ca16ccc1 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -19,6 +19,7 @@ import { import { existsSync, writeFileSync, mkdirSync, statSync } from "fs"; import * as path from "path"; import { loadMergedConfig } from "../config/merger.js"; +import { resolveProjectConfigPath } from "../config/paths.js"; const z = tool.schema; @@ -50,7 +51,7 @@ function getIndexer(): Indexer { } function getConfigPath(): string { - return path.join(sharedProjectRoot, ".opencode", "codebase-index.json"); + return resolveProjectConfigPath(sharedProjectRoot); } function loadConfig(): Record { @@ -81,11 +82,11 @@ function loadConfig(): Record { } function saveConfig(config: Record): void { - const configDir = path.join(sharedProjectRoot, ".opencode"); + const configPath = getConfigPath(); + const configDir = path.dirname(configPath); if (!existsSync(configDir)) { mkdirSync(configDir, { recursive: true }); } - const configPath = getConfigPath(); writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8"); } diff --git a/tests/tools-knowledge-bases.test.ts b/tests/tools-knowledge-bases.test.ts index 0796f108..bd78c6e7 100644 --- a/tests/tools-knowledge-bases.test.ts +++ b/tests/tools-knowledge-bases.test.ts @@ -79,6 +79,7 @@ vi.mock("../src/indexer/index.js", () => ({ })); import { parseConfig } from "../src/config/schema.js"; +import { loadMergedConfig } from "../src/config/merger.js"; import { add_knowledge_base, initializeTools, remove_knowledge_base } from "../src/tools/index.js"; describe("knowledge base tool config refresh", () => { @@ -113,4 +114,48 @@ describe("knowledge base tool config refresh", () => { expect(indexerInstances[2]?.projectRoot).toBe(tempDir); expect(indexerInstances[2]?.config.knowledgeBases).toEqual([]); }); + + it("writes inherited worktree config updates back to the resolved source config", 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 savedMainConfig = JSON.parse(fs.readFileSync(mainConfigPath, "utf-8")) as { knowledgeBases?: string[] }; + expect(savedMainConfig.knowledgeBases).toEqual([path.normalize(kbDir)]); + expect(fs.existsSync(path.join(worktreeDir, ".opencode", "codebase-index.json"))).toBe(false); + expect(indexerInstances.at(-1)?.projectRoot).toBe(worktreeDir); + expect(indexerInstances.at(-1)?.config.knowledgeBases).toEqual([path.normalize(kbDir)]); + }); }); From ccebc8b0c6420ef532d6566e12dd7df6892ae8c1 Mon Sep 17 00:00:00 2001 From: Helweg Date: Wed, 22 Apr 2026 16:17:55 +0200 Subject: [PATCH 06/23] fix: avoid clearing inherited indexes during eval reindex --- src/eval/runner.ts | 8 +++- tests/eval-runner.test.ts | 94 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+), 1 deletion(-) diff --git a/src/eval/runner.ts b/src/eval/runner.ts index 7a0b18ff..55de797e 100644 --- a/src/eval/runner.ts +++ b/src/eval/runner.ts @@ -63,8 +63,14 @@ function getIndexRootPath(projectRoot: string, scope: "project" | "global"): str : resolveProjectIndexPath(projectRoot, scope); } +function getLocalProjectIndexRoot(projectRoot: string): string { + return path.join(projectRoot, ".opencode", "index"); +} + 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 }); } diff --git a/tests/eval-runner.test.ts b/tests/eval-runner.test.ts index 7aac67d6..5fecc1e3 100644 --- a/tests/eval-runner.test.ts +++ b/tests/eval-runner.test.ts @@ -130,6 +130,100 @@ 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("compares against baseline and writes compare artifact", async () => { const baselineRun = await runEvaluation({ projectRoot: tempDir, From 97db6c2e8bf958c8b7b31d866e6b176b5c608a45 Mon Sep 17 00:00:00 2001 From: Helweg Date: Wed, 22 Apr 2026 16:48:05 +0200 Subject: [PATCH 07/23] fix: preserve relative inherited config paths on save --- src/tools/index.ts | 53 ++++++++++++++++++++++++++--- tests/tools-knowledge-bases.test.ts | 42 +++++++++++++++++++++++ 2 files changed, 90 insertions(+), 5 deletions(-) diff --git a/src/tools/index.ts b/src/tools/index.ts index ca16ccc1..13ccc874 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -54,9 +54,38 @@ function getConfigPath(): string { return resolveProjectConfigPath(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 loadConfig(): Record { const rawConfig = loadMergedConfig(sharedProjectRoot); const config: Record = {}; + const configBaseDir = path.dirname(path.dirname(getConfigPath())); if (rawConfig && typeof rawConfig === "object") { for (const key of Object.keys(rawConfig)) { @@ -66,15 +95,13 @@ function loadConfig(): Record { 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); + return normalizeConfigPathValue(kb, configBaseDir); }); } 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 normalizeConfigPathValue(pattern, configBaseDir); }); } @@ -84,10 +111,26 @@ function loadConfig(): Record { function saveConfig(config: Record): void { const configPath = getConfigPath(); const configDir = path.dirname(configPath); + const configBaseDir = path.dirname(configDir); if (!existsSync(configDir)) { mkdirSync(configDir, { recursive: true }); } - 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) + ); + } + + if (Array.isArray(serializableConfig.additionalInclude)) { + serializableConfig.additionalInclude = (serializableConfig.additionalInclude as string[]).map(pattern => + serializeConfigPathValue(pattern, configBaseDir) + ); + } + + writeFileSync(configPath, JSON.stringify(serializableConfig, null, 2) + "\n", "utf-8"); } export const codebase_peek: ToolDefinition = tool({ diff --git a/tests/tools-knowledge-bases.test.ts b/tests/tools-knowledge-bases.test.ts index bd78c6e7..03601a71 100644 --- a/tests/tools-knowledge-bases.test.ts +++ b/tests/tools-knowledge-bases.test.ts @@ -158,4 +158,46 @@ describe("knowledge base tool config refresh", () => { expect(indexerInstances.at(-1)?.projectRoot).toBe(worktreeDir); expect(indexerInstances.at(-1)?.config.knowledgeBases).toEqual([path.normalize(kbDir)]); }); + + it("preserves inherited relative config paths when saving back to the source config", 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 savedMainConfig = JSON.parse(fs.readFileSync(mainConfigPath, "utf-8")) as { knowledgeBases?: string[] }; + expect(savedMainConfig.knowledgeBases).toEqual(["docs/reference", path.normalize(kbDir)]); + }); }); From e43719a3b112abb7ed8f11842230b9e97b582cd0 Mon Sep 17 00:00:00 2001 From: Helweg Date: Wed, 22 Apr 2026 16:48:05 +0200 Subject: [PATCH 08/23] fix: isolate eval reindex state in fallback worktrees --- src/eval/runner.ts | 30 +++++++++++++ tests/eval-runner.test.ts | 91 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+) diff --git a/src/eval/runner.ts b/src/eval/runner.ts index 55de797e..1ed9a8c0 100644 --- a/src/eval/runner.ts +++ b/src/eval/runner.ts @@ -1,6 +1,8 @@ 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"; @@ -67,6 +69,10 @@ 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 = scope === "global" ? getIndexRootPath(projectRoot, scope) @@ -76,6 +82,25 @@ function clearIndexRoot(projectRoot: string, scope: "project" | "global"): void } } +function ensureLocalEvalProjectConfig(projectRoot: string, configPath?: string): void { + if (configPath) { + return; + } + + const localConfigPath = getLocalProjectConfigPath(projectRoot); + if (existsSync(localConfigPath)) { + return; + } + + const resolvedConfigPath = resolveProjectConfigPath(projectRoot); + if (!existsSync(resolvedConfigPath) || resolvedConfigPath === localConfigPath) { + return; + } + + mkdirSync(path.dirname(localConfigPath), { recursive: true }); + writeFileSync(localConfigPath, readFileSync(resolvedConfigPath, "utf-8"), "utf-8"); +} + function loadParsedConfig(projectRoot: string, configPath?: string) { const raw = loadRawConfig(projectRoot, configPath); return parseConfig(raw); @@ -122,6 +147,11 @@ export async function runEvaluation(options: EvalRunOptions): Promise { 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, "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, + }, + 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, + }); + + expect(readFileSync(path.join(worktreeDir, ".opencode", "codebase-index.json"), "utf-8")).toContain("mock-embedding-model"); + }); + it("compares against baseline and writes compare artifact", async () => { const baselineRun = await runEvaluation({ projectRoot: tempDir, From a94bddb9cfd0b4c4282d1425f2884fc0f6dd3b2c Mon Sep 17 00:00:00 2001 From: Helweg Date: Sat, 25 Apr 2026 23:15:22 +0200 Subject: [PATCH 09/23] fix: rebase fallback eval knowledge base paths --- src/config/merger.ts | 2 +- src/eval/runner.ts | 14 +++++++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/config/merger.ts b/src/config/merger.ts index 918da1bc..252f9adc 100644 --- a/src/config/merger.ts +++ b/src/config/merger.ts @@ -14,7 +14,7 @@ function loadJsonFile(filePath: string): unknown { return null; } -function rebasePathEntries( +export function rebasePathEntries( values: unknown, fromDir: string, toDir: string, diff --git a/src/eval/runner.ts b/src/eval/runner.ts index 1ed9a8c0..80ae4350 100644 --- a/src/eval/runner.ts +++ b/src/eval/runner.ts @@ -7,6 +7,7 @@ import * as os from "os"; import * as path from "path"; import { performance } from "perf_hooks"; +import { rebasePathEntries } 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"; @@ -97,8 +98,19 @@ function ensureLocalEvalProjectConfig(projectRoot: string, configPath?: string): return; } + const sourceConfig = JSON.parse(readFileSync(resolvedConfigPath, "utf-8")) as Record; + const sourceConfigBaseDir = path.dirname(path.dirname(resolvedConfigPath)); + + if (Array.isArray(sourceConfig.knowledgeBases)) { + sourceConfig.knowledgeBases = rebasePathEntries( + sourceConfig.knowledgeBases, + sourceConfigBaseDir, + projectRoot, + ); + } + mkdirSync(path.dirname(localConfigPath), { recursive: true }); - writeFileSync(localConfigPath, readFileSync(resolvedConfigPath, "utf-8"), "utf-8"); + writeFileSync(localConfigPath, JSON.stringify(sourceConfig, null, 2), "utf-8"); } function loadParsedConfig(projectRoot: string, configPath?: string) { From fd582a3ae6e7e46a8d93c6f554c0465c4053174e Mon Sep 17 00:00:00 2001 From: Helweg Date: Sat, 25 Apr 2026 23:15:27 +0200 Subject: [PATCH 10/23] test: cover fallback eval config path semantics --- tests/eval-runner.test.ts | 15 ++++++++++++++- tests/worktree-fallback.test.ts | 2 ++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/tests/eval-runner.test.ts b/tests/eval-runner.test.ts index 4cc92d82..2eede615 100644 --- a/tests/eval-runner.test.ts +++ b/tests/eval-runner.test.ts @@ -231,6 +231,7 @@ describe("eval runner", () => { 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 }); @@ -256,6 +257,8 @@ describe("eval runner", () => { indexing: { watchFiles: false, }, + additionalInclude: ["docs/**/*.md"], + knowledgeBases: ["docs/reference"], search: { maxResults: 10, minScore: 0, @@ -312,7 +315,17 @@ describe("eval runner", () => { reindex: true, }); - expect(readFileSync(path.join(worktreeDir, ".opencode", "codebase-index.json"), "utf-8")).toContain("mock-embedding-model"); + 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([path.join("..", "main-repo", "docs", "reference")]); }); it("compares against baseline and writes compare artifact", async () => { diff --git a/tests/worktree-fallback.test.ts b/tests/worktree-fallback.test.ts index 149c7a89..f1ad518c 100644 --- a/tests/worktree-fallback.test.ts +++ b/tests/worktree-fallback.test.ts @@ -47,6 +47,7 @@ describe("worktree fallback (issue #60)", () => { indexing: { watchFiles: false, }, + additionalInclude: ["docs/**/*.md"], knowledgeBases: ["docs/reference"], }, null, @@ -66,6 +67,7 @@ describe("worktree fallback (issue #60)", () => { 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([path.join("..", "main-repo", "docs", "reference")]); }); From 65145f8f1cb4ece92ef810f0512fb3db14231514 Mon Sep 17 00:00:00 2001 From: Helweg Date: Sat, 25 Apr 2026 23:25:50 +0200 Subject: [PATCH 11/23] fix: preserve additionalInclude globs in tool config rewrites --- src/tools/index.ts | 12 ----- tests/tools-knowledge-bases.test.ts | 80 +++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 12 deletions(-) diff --git a/src/tools/index.ts b/src/tools/index.ts index 13ccc874..f10de692 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -99,12 +99,6 @@ function loadConfig(): Record { }); } - if (Array.isArray(config.additionalInclude)) { - config.additionalInclude = (config.additionalInclude as string[]).map(pattern => { - return normalizeConfigPathValue(pattern, configBaseDir); - }); - } - return config; } @@ -124,12 +118,6 @@ function saveConfig(config: Record): void { ); } - if (Array.isArray(serializableConfig.additionalInclude)) { - serializableConfig.additionalInclude = (serializableConfig.additionalInclude as string[]).map(pattern => - serializeConfigPathValue(pattern, configBaseDir) - ); - } - writeFileSync(configPath, JSON.stringify(serializableConfig, null, 2) + "\n", "utf-8"); } diff --git a/tests/tools-knowledge-bases.test.ts b/tests/tools-knowledge-bases.test.ts index 03601a71..46719bd3 100644 --- a/tests/tools-knowledge-bases.test.ts +++ b/tests/tools-knowledge-bases.test.ts @@ -200,4 +200,84 @@ describe("knowledge base tool config refresh", () => { const savedMainConfig = JSON.parse(fs.readFileSync(mainConfigPath, "utf-8")) as { knowledgeBases?: string[] }; expect(savedMainConfig.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 saving back to the source config", 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 savedMainConfig = JSON.parse(fs.readFileSync(mainConfigPath, "utf-8")) as { + additionalInclude?: string[]; + knowledgeBases?: string[]; + }; + expect(savedMainConfig.additionalInclude).toEqual(["docs/**/*.md"]); + expect(savedMainConfig.knowledgeBases).toEqual([path.normalize(kbDir)]); + expect(indexerInstances.at(-1)?.config.additionalInclude).toEqual(["docs/**/*.md"]); + }); }); From 8c1140b66119111a47bae01b5f523cbf5b985a1c Mon Sep 17 00:00:00 2001 From: Helweg Date: Sat, 25 Apr 2026 23:52:33 +0200 Subject: [PATCH 12/23] fix: align worktree config and index boundaries --- src/config/paths.ts | 13 ++++ src/eval/runner.ts | 8 +-- src/tools/index.ts | 8 ++- tests/eval-runner.test.ts | 107 ++++++++++++++++++++++++++++ tests/tools-knowledge-bases.test.ts | 48 +++++++++++++ tests/worktree-fallback.test.ts | 12 +++- 6 files changed, 187 insertions(+), 9 deletions(-) diff --git a/src/config/paths.ts b/src/config/paths.ts index 60eaff45..032ce1b4 100644 --- a/src/config/paths.ts +++ b/src/config/paths.ts @@ -21,6 +21,10 @@ function hasProjectConfig(projectRoot: string): boolean { return existsSync(path.join(projectRoot, PROJECT_CONFIG_RELATIVE_PATH)); } +function hasProjectIndex(projectRoot: string): boolean { + return existsSync(path.join(projectRoot, PROJECT_INDEX_RELATIVE_PATH)); +} + export function getGlobalIndexPath(): string { return path.join(os.homedir(), ".opencode", "global-index"); } @@ -34,6 +38,15 @@ export function resolveProjectConfigPath(projectRoot: string): string { return resolveWorktreeFallbackPath(projectRoot, PROJECT_CONFIG_RELATIVE_PATH) ?? localConfigPath; } +export function resolveWritableProjectConfigPath(projectRoot: string): string { + const localConfigPath = path.join(projectRoot, PROJECT_CONFIG_RELATIVE_PATH); + if (existsSync(localConfigPath) || hasProjectIndex(projectRoot)) { + return localConfigPath; + } + + return resolveProjectConfigPath(projectRoot); +} + export function resolveProjectIndexPath(projectRoot: string, scope: "project" | "global"): string { if (scope === "global") { return getGlobalIndexPath(); diff --git a/src/eval/runner.ts b/src/eval/runner.ts index 80ae4350..1b024951 100644 --- a/src/eval/runner.ts +++ b/src/eval/runner.ts @@ -84,16 +84,14 @@ function clearIndexRoot(projectRoot: string, scope: "project" | "global"): void } function ensureLocalEvalProjectConfig(projectRoot: string, configPath?: string): void { - if (configPath) { - return; - } - const localConfigPath = getLocalProjectConfigPath(projectRoot); if (existsSync(localConfigPath)) { return; } - const resolvedConfigPath = resolveProjectConfigPath(projectRoot); + const resolvedConfigPath = configPath + ? toAbsolute(projectRoot, configPath) + : resolveProjectConfigPath(projectRoot); if (!existsSync(resolvedConfigPath) || resolvedConfigPath === localConfigPath) { return; } diff --git a/src/tools/index.ts b/src/tools/index.ts index f10de692..056fb5c3 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -19,7 +19,7 @@ import { import { existsSync, writeFileSync, mkdirSync, statSync } from "fs"; import * as path from "path"; import { loadMergedConfig } from "../config/merger.js"; -import { resolveProjectConfigPath } from "../config/paths.js"; +import { resolveProjectConfigPath, resolveWritableProjectConfigPath } from "../config/paths.js"; const z = tool.schema; @@ -51,6 +51,10 @@ function getIndexer(): Indexer { } function getConfigPath(): string { + return resolveWritableProjectConfigPath(sharedProjectRoot); +} + +function getResolvedConfigPath(): string { return resolveProjectConfigPath(sharedProjectRoot); } @@ -85,7 +89,7 @@ function serializeConfigPathValue(value: string, baseDir: string): string { function loadConfig(): Record { const rawConfig = loadMergedConfig(sharedProjectRoot); const config: Record = {}; - const configBaseDir = path.dirname(path.dirname(getConfigPath())); + const configBaseDir = path.dirname(path.dirname(getResolvedConfigPath())); if (rawConfig && typeof rawConfig === "object") { for (const key of Object.keys(rawConfig)) { diff --git a/tests/eval-runner.test.ts b/tests/eval-runner.test.ts index 2eede615..93697370 100644 --- a/tests/eval-runner.test.ts +++ b/tests/eval-runner.test.ts @@ -328,6 +328,113 @@ describe("eval runner", () => { expect(localEvalConfig.knowledgeBases).toEqual([path.join("..", "main-repo", "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([path.join("..", "main-repo", "docs", "reference")]); + }); + it("compares against baseline and writes compare artifact", async () => { const baselineRun = await runEvaluation({ projectRoot: tempDir, diff --git a/tests/tools-knowledge-bases.test.ts b/tests/tools-knowledge-bases.test.ts index 46719bd3..43250215 100644 --- a/tests/tools-knowledge-bases.test.ts +++ b/tests/tools-knowledge-bases.test.ts @@ -280,4 +280,52 @@ describe("knowledge base tool config refresh", () => { expect(savedMainConfig.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)]); + }); }); diff --git a/tests/worktree-fallback.test.ts b/tests/worktree-fallback.test.ts index f1ad518c..e18590f4 100644 --- a/tests/worktree-fallback.test.ts +++ b/tests/worktree-fallback.test.ts @@ -6,7 +6,7 @@ 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 } from "../src/config/paths.js"; +import { resolveProjectConfigPath, resolveProjectIndexPath, resolveWritableProjectConfigPath } from "../src/config/paths.js"; import { Indexer } from "../src/indexer/index.js"; describe("worktree fallback (issue #60)", () => { @@ -99,7 +99,15 @@ describe("worktree fallback (issue #60)", () => { }); it("keeps a worktree-local config on a local worktree index boundary", () => { - fs.mkdirSync(path.join(worktreeDir, ".opencode"), { recursive: true }); + 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"), From 01d415098dc911d3154a477ad7dd32ab284baf96 Mon Sep 17 00:00:00 2001 From: Helweg Date: Sun, 26 Apr 2026 18:29:41 +0200 Subject: [PATCH 13/23] fix: guard inherited project indexes during force rebuilds --- src/config/paths.ts | 11 +------- src/indexer/index.ts | 8 ++++++ tests/indexer-clear-index.test.ts | 47 +++++++++++++++++++++++++++++++ tests/worktree-fallback.test.ts | 2 +- 4 files changed, 57 insertions(+), 11 deletions(-) diff --git a/src/config/paths.ts b/src/config/paths.ts index 032ce1b4..e214ea3c 100644 --- a/src/config/paths.ts +++ b/src/config/paths.ts @@ -21,10 +21,6 @@ function hasProjectConfig(projectRoot: string): boolean { return existsSync(path.join(projectRoot, PROJECT_CONFIG_RELATIVE_PATH)); } -function hasProjectIndex(projectRoot: string): boolean { - return existsSync(path.join(projectRoot, PROJECT_INDEX_RELATIVE_PATH)); -} - export function getGlobalIndexPath(): string { return path.join(os.homedir(), ".opencode", "global-index"); } @@ -39,12 +35,7 @@ export function resolveProjectConfigPath(projectRoot: string): string { } export function resolveWritableProjectConfigPath(projectRoot: string): string { - const localConfigPath = path.join(projectRoot, PROJECT_CONFIG_RELATIVE_PATH); - if (existsSync(localConfigPath) || hasProjectIndex(projectRoot)) { - return localConfigPath; - } - - return resolveProjectConfigPath(projectRoot); + return path.join(projectRoot, PROJECT_CONFIG_RELATIVE_PATH); } export function resolveProjectIndexPath(projectRoot: string, scope: "project" | "global"): string { diff --git a/src/indexer/index.ts b/src/indexer/index.ts index e90cf0d5..96847490 100644 --- a/src/indexer/index.ts +++ b/src/indexer/index.ts @@ -3290,6 +3290,14 @@ export class Indexer { return; } + const localProjectIndexPath = path.join(this.projectRoot, ".opencode", "index"); + if (path.resolve(this.indexPath) !== path.resolve(localProjectIndexPath)) { + throw new Error( + "Project-scoped force rebuild is unsafe while using an inherited worktree index. " + + "Create a local project config boundary before clearing the index." + ); + } + store.clear(); store.save(); invertedIndex.clear(); diff --git a/tests/indexer-clear-index.test.ts b/tests/indexer-clear-index.test.ts index 88825983..125c1558 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/worktree-fallback.test.ts b/tests/worktree-fallback.test.ts index e18590f4..7090953c 100644 --- a/tests/worktree-fallback.test.ts +++ b/tests/worktree-fallback.test.ts @@ -68,7 +68,7 @@ describe("worktree fallback (issue #60)", () => { 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([path.join("..", "main-repo", "docs", "reference")]); + expect(loaded.knowledgeBases).toEqual(["docs/reference"]); }); it("resolves the project index path to the main repo when the worktree has no local index", async () => { From f4d95836281a052088a32cf62b0bf4cabcc525ed Mon Sep 17 00:00:00 2001 From: Helweg Date: Sun, 26 Apr 2026 18:29:47 +0200 Subject: [PATCH 14/23] fix: preserve repo-local fallback knowledge bases in eval --- src/config/merger.ts | 45 ++++++++++++++++++++++++++++++++++++--- src/eval/runner.ts | 4 ++-- tests/eval-runner.test.ts | 4 ++-- 3 files changed, 46 insertions(+), 7 deletions(-) diff --git a/src/config/merger.ts b/src/config/merger.ts index 252f9adc..db23bde0 100644 --- a/src/config/merger.ts +++ b/src/config/merger.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync } from "fs"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; import * as os from "os"; import * as path from "path"; @@ -36,6 +36,45 @@ export function rebasePathEntries( .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 || path.isAbsolute(trimmed)) { + return 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; +} + /** * Loads and merges global and project configs. * @@ -66,7 +105,7 @@ export function loadMergedConfig(projectRoot: string): unknown { // If only project exists, return it if (!globalConfig && projectConfig) { if (Array.isArray(projectConfig.knowledgeBases)) { - projectConfig.knowledgeBases = rebasePathEntries(projectConfig.knowledgeBases, projectConfigBaseDir, projectRoot); + projectConfig.knowledgeBases = resolveInheritedKnowledgeBaseEntries(projectConfig.knowledgeBases, projectConfigBaseDir, projectRoot); } return projectConfig; } @@ -170,7 +209,7 @@ export function loadMergedConfig(projectRoot: string): unknown { // For knowledgeBases: merge arrays (union, deduplicated) const globalKbs = globalConfig && Array.isArray(globalConfig.knowledgeBases) ? globalConfig.knowledgeBases : []; const projectKbs = projectConfig - ? rebasePathEntries(projectConfig.knowledgeBases, projectConfigBaseDir, projectRoot) + ? resolveInheritedKnowledgeBaseEntries(projectConfig.knowledgeBases, projectConfigBaseDir, projectRoot) : []; const allKbs = [...globalKbs, ...projectKbs]; const uniqueKbs = [...new Set(allKbs.map(p => String(p).trim()))]; diff --git a/src/eval/runner.ts b/src/eval/runner.ts index 1b024951..c28ebcaa 100644 --- a/src/eval/runner.ts +++ b/src/eval/runner.ts @@ -7,7 +7,7 @@ import * as os from "os"; import * as path from "path"; import { performance } from "perf_hooks"; -import { rebasePathEntries } from "../config/merger.js"; +import { 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"; @@ -100,7 +100,7 @@ function ensureLocalEvalProjectConfig(projectRoot: string, configPath?: string): const sourceConfigBaseDir = path.dirname(path.dirname(resolvedConfigPath)); if (Array.isArray(sourceConfig.knowledgeBases)) { - sourceConfig.knowledgeBases = rebasePathEntries( + sourceConfig.knowledgeBases = resolveInheritedKnowledgeBaseEntries( sourceConfig.knowledgeBases, sourceConfigBaseDir, projectRoot, diff --git a/tests/eval-runner.test.ts b/tests/eval-runner.test.ts index 93697370..1a899a56 100644 --- a/tests/eval-runner.test.ts +++ b/tests/eval-runner.test.ts @@ -325,7 +325,7 @@ describe("eval runner", () => { expect(localEvalConfig.customProvider?.model).toBe("mock-embedding-model"); expect(localEvalConfig.additionalInclude).toEqual(["docs/**/*.md"]); - expect(localEvalConfig.knowledgeBases).toEqual([path.join("..", "main-repo", "docs", "reference")]); + expect(localEvalConfig.knowledgeBases).toEqual(["docs/reference"]); }); it("creates a local eval config boundary when reindexing with an explicit config path", async () => { @@ -432,7 +432,7 @@ describe("eval runner", () => { expect(localEvalConfig.customProvider?.model).toBe("mock-embedding-model"); expect(localEvalConfig.additionalInclude).toEqual(["docs/**/*.md"]); - expect(localEvalConfig.knowledgeBases).toEqual([path.join("..", "main-repo", "docs", "reference")]); + expect(localEvalConfig.knowledgeBases).toEqual(["docs/reference"]); }); it("compares against baseline and writes compare artifact", async () => { From caa93441db6dbf42dbc2dc6d533516a4e84c3499 Mon Sep 17 00:00:00 2001 From: Helweg Date: Sun, 26 Apr 2026 18:29:51 +0200 Subject: [PATCH 15/23] fix: localize worktree knowledge base edits before rebuilds --- src/tools/index.ts | 25 ++++++++++++++----------- tests/tools-knowledge-bases.test.ts | 26 +++++++++++++++++++------- 2 files changed, 33 insertions(+), 18 deletions(-) diff --git a/src/tools/index.ts b/src/tools/index.ts index 056fb5c3..ed13301c 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -18,8 +18,8 @@ import { } from "./utils.js"; import { existsSync, writeFileSync, mkdirSync, statSync } from "fs"; import * as path from "path"; -import { loadMergedConfig } from "../config/merger.js"; -import { resolveProjectConfigPath, resolveWritableProjectConfigPath } from "../config/paths.js"; +import { loadMergedConfig, materializeLocalProjectConfig } from "../config/merger.js"; +import { resolveWritableProjectConfigPath } from "../config/paths.js"; const z = tool.schema; @@ -54,10 +54,6 @@ function getConfigPath(): string { return resolveWritableProjectConfigPath(sharedProjectRoot); } -function getResolvedConfigPath(): string { - return resolveProjectConfigPath(sharedProjectRoot); -} - function normalizeConfigPathValue(value: string, baseDir: string): string { const trimmed = value.trim(); if (!trimmed) { @@ -89,17 +85,16 @@ function serializeConfigPathValue(value: string, baseDir: string): string { function loadConfig(): Record { const rawConfig = loadMergedConfig(sharedProjectRoot); const config: Record = {}; - const configBaseDir = path.dirname(path.dirname(getResolvedConfigPath())); - + 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 => { - return normalizeConfigPathValue(kb, configBaseDir); + return normalizeConfigPathValue(kb, sharedProjectRoot); }); } @@ -157,7 +152,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(); @@ -165,6 +160,14 @@ export const index_codebase: ToolDefinition = tool({ } if (args.force) { + const status = await indexer.getStatus(); + const localIndexPath = path.join(sharedProjectRoot, ".opencode", "index"); + const currentConfig = parseConfig(loadConfig()); + if (currentConfig.scope === "project" && path.resolve(status.indexPath) !== path.resolve(localIndexPath)) { + materializeLocalProjectConfig(sharedProjectRoot, loadMergedConfig(sharedProjectRoot)); + refreshIndexerFromConfig(); + indexer = getIndexer(); + } await indexer.clearIndex(); } diff --git a/tests/tools-knowledge-bases.test.ts b/tests/tools-knowledge-bases.test.ts index 43250215..dd22e8f8 100644 --- a/tests/tools-knowledge-bases.test.ts +++ b/tests/tools-knowledge-bases.test.ts @@ -115,7 +115,7 @@ describe("knowledge base tool config refresh", () => { expect(indexerInstances[2]?.config.knowledgeBases).toEqual([]); }); - it("writes inherited worktree config updates back to the resolved source config", async () => { + 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"); @@ -152,14 +152,16 @@ describe("knowledge base tool config refresh", () => { 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[] }; - expect(savedMainConfig.knowledgeBases).toEqual([path.normalize(kbDir)]); - expect(fs.existsSync(path.join(worktreeDir, ".opencode", "codebase-index.json"))).toBe(false); + 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("preserves inherited relative config paths when saving back to the source config", async () => { + 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"); @@ -197,8 +199,11 @@ describe("knowledge base tool config refresh", () => { 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[] }; - expect(savedMainConfig.knowledgeBases).toEqual(["docs/reference", path.normalize(kbDir)]); + 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 () => { @@ -234,7 +239,7 @@ describe("knowledge base tool config refresh", () => { expect(indexerInstances.at(-1)?.config.additionalInclude).toEqual(["docs/**/*.md"]); }); - it("preserves inherited additionalInclude globs when saving back to the source config", async () => { + 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"); @@ -272,12 +277,19 @@ describe("knowledge base tool config refresh", () => { 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([path.normalize(kbDir)]); + 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"]); }); From ed5327c4f68cb2c9415d32bf9056732c1d7a3a68 Mon Sep 17 00:00:00 2001 From: Helweg Date: Sun, 26 Apr 2026 18:29:58 +0200 Subject: [PATCH 16/23] fix: preserve runtime config during MCP force rebuilds --- src/mcp-server.ts | 17 +++++++++- tests/mcp-server.test.ts | 71 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 1 deletion(-) diff --git a/src/mcp-server.ts b/src/mcp-server.ts index 1e48e664..179d18df 100644 --- a/src/mcp-server.ts +++ b/src/mcp-server.ts @@ -1,8 +1,10 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; +import * as path from "path"; import { Indexer } from "./indexer/index.js"; import type { ParsedCodebaseIndexConfig, LogLevel } from "./config/schema.js"; +import { loadMergedConfig, materializeLocalProjectConfig } from "./config/merger.js"; import { formatDefinitionLookup, formatIndexStats, formatStatus } from "./tools/utils.js"; import { formatCostEstimate } from "./utils/cost.js"; import type { LogEntry } from "./utils/logger.js"; @@ -29,9 +31,15 @@ export function createMcpServer(projectRoot: string, config: ParsedCodebaseIndex version: "0.5.1", }); - const indexer = new Indexer(projectRoot, config); + let runtimeConfig = config; + let indexer = new Indexer(projectRoot, runtimeConfig); let initialized = false; + function refreshIndexerFromConfig(): void { + indexer = new Indexer(projectRoot, runtimeConfig); + initialized = false; + } + async function ensureInitialized(): Promise { if (!initialized) { await indexer.initialize(); @@ -126,6 +134,13 @@ export function createMcpServer(projectRoot: string, config: ParsedCodebaseIndex } if (args.force) { + const status = await indexer.getStatus(); + const localIndexPath = path.join(projectRoot, ".opencode", "index"); + if (runtimeConfig.scope === "project" && path.resolve(status.indexPath) !== path.resolve(localIndexPath)) { + materializeLocalProjectConfig(projectRoot, loadMergedConfig(projectRoot)); + refreshIndexerFromConfig(); + await ensureInitialized(); + } await indexer.clearIndex(); } diff --git a/tests/mcp-server.test.ts b/tests/mcp-server.test.ts index fdb31b01..20598488 100644 --- a/tests/mcp-server.test.ts +++ b/tests/mcp-server.test.ts @@ -4,6 +4,17 @@ import { parseConfig } from "../src/config/schema.js"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +const mergerMocks = vi.hoisted(() => ({ + loadMergedConfig: vi.fn(() => ({})), + materializeLocalProjectConfig: vi.fn(), +})); + +const indexerMockState = vi.hoisted(() => ({ + constructorArgs: [] as Array<[string, unknown]>, +})); + +vi.mock("../src/config/merger.js", () => mergerMocks); + let mockIndexResult = { totalFiles: 10, totalChunks: 50, @@ -33,6 +44,10 @@ let mockStatusResult = { vi.mock("../src/indexer/index.js", () => { class MockIndexer { + constructor(projectRoot: string, config: unknown) { + indexerMockState.constructorArgs.push([projectRoot, config]); + } + initialize = vi.fn().mockResolvedValue(undefined); search = vi.fn().mockResolvedValue([ { @@ -112,6 +127,10 @@ describe("MCP server tools and prompts", () => { let server: ReturnType; beforeEach(async () => { + indexerMockState.constructorArgs.length = 0; + mergerMocks.loadMergedConfig.mockReset(); + mergerMocks.loadMergedConfig.mockReturnValue({}); + mergerMocks.materializeLocalProjectConfig.mockReset(); mockIndexResult = { totalFiles: 10, totalChunks: 50, @@ -290,6 +309,58 @@ 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 () => { + mockStatusResult = { + ...mockStatusResult, + indexPath: "/tmp/shared-index", + }; + mergerMocks.loadMergedConfig.mockReturnValue({ + embeddingProvider: "openai", + customProvider: { + baseUrl: "https://disk.example.com/v1", + model: "disk-model", + dimensions: 1536, + apiKey: "disk-key", + }, + }); + + 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.loadMergedConfig.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], + ]); + }); + it("should execute index_health_check tool", async () => { const result = await client.callTool({ name: "index_health_check", From 01c31317a2bf0b8d9b8bdb3503328fff4bbe5c2e Mon Sep 17 00:00:00 2001 From: Helweg Date: Sun, 26 Apr 2026 18:47:35 +0200 Subject: [PATCH 17/23] fix: respect explicit eval config roots during reindex --- src/eval/runner.ts | 77 +++++++++++++++++++------- tests/eval-runner.test.ts | 113 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 170 insertions(+), 20 deletions(-) diff --git a/src/eval/runner.ts b/src/eval/runner.ts index c28ebcaa..bae10883 100644 --- a/src/eval/runner.ts +++ b/src/eval/runner.ts @@ -7,7 +7,7 @@ import * as os from "os"; import * as path from "path"; import { performance } from "perf_hooks"; -import { resolveInheritedKnowledgeBaseEntries } from "../config/merger.js"; +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"; @@ -41,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 = 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"); @@ -83,32 +124,28 @@ function clearIndexRoot(projectRoot: string, scope: "project" | "global"): void } } -function ensureLocalEvalProjectConfig(projectRoot: string, configPath?: string): void { +function ensureLocalEvalProjectConfig(projectRoot: string, configPath?: string): string | undefined { const localConfigPath = getLocalProjectConfigPath(projectRoot); if (existsSync(localConfigPath)) { - return; + return localConfigPath; } const resolvedConfigPath = configPath ? toAbsolute(projectRoot, configPath) : resolveProjectConfigPath(projectRoot); if (!existsSync(resolvedConfigPath) || resolvedConfigPath === localConfigPath) { - return; + return resolvedConfigPath; } - const sourceConfig = JSON.parse(readFileSync(resolvedConfigPath, "utf-8")) as Record; - const sourceConfigBaseDir = path.dirname(path.dirname(resolvedConfigPath)); - - if (Array.isArray(sourceConfig.knowledgeBases)) { - sourceConfig.knowledgeBases = resolveInheritedKnowledgeBaseEntries( - sourceConfig.knowledgeBases, - sourceConfigBaseDir, - projectRoot, - ); - } + 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) { @@ -158,11 +195,11 @@ export async function runEvaluation(options: EvalRunOptions): Promise { 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("compares against baseline and writes compare artifact", async () => { const baselineRun = await runEvaluation({ projectRoot: tempDir, From 7288335e73ec906c6ee870cf485067c2d59a2892 Mon Sep 17 00:00:00 2001 From: Helweg Date: Sun, 26 Apr 2026 19:03:12 +0200 Subject: [PATCH 18/23] fix: honor explicit eval config on repeated reindex --- src/eval/runner.ts | 9 +-- tests/eval-runner.test.ts | 124 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 4 deletions(-) diff --git a/src/eval/runner.ts b/src/eval/runner.ts index bae10883..488d5eae 100644 --- a/src/eval/runner.ts +++ b/src/eval/runner.ts @@ -126,13 +126,14 @@ function clearIndexRoot(projectRoot: string, scope: "project" | "global"): void function ensureLocalEvalProjectConfig(projectRoot: string, configPath?: string): string | undefined { const localConfigPath = getLocalProjectConfigPath(projectRoot); - if (existsSync(localConfigPath)) { - return localConfigPath; - } - const resolvedConfigPath = configPath ? toAbsolute(projectRoot, configPath) : resolveProjectConfigPath(projectRoot); + + if (!configPath && existsSync(localConfigPath)) { + return localConfigPath; + } + if (!existsSync(resolvedConfigPath) || resolvedConfigPath === localConfigPath) { return resolvedConfigPath; } diff --git a/tests/eval-runner.test.ts b/tests/eval-runner.test.ts index 01753de7..734b5342 100644 --- a/tests/eval-runner.test.ts +++ b/tests/eval-runner.test.ts @@ -548,6 +548,130 @@ describe("eval runner", () => { 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, From 731fcece1d7936f3c47ccc62df3f7c9ab0ed724e Mon Sep 17 00:00:00 2001 From: Helweg Date: Mon, 27 Apr 2026 13:18:14 +0200 Subject: [PATCH 19/23] refactor: split project config layer loading --- src/config/merger.ts | 73 ++++++++++++++++++++++++++++---------------- 1 file changed, 46 insertions(+), 27 deletions(-) diff --git a/src/config/merger.ts b/src/config/merger.ts index db23bde0..08e75b29 100644 --- a/src/config/merger.ts +++ b/src/config/merger.ts @@ -75,6 +75,28 @@ export function materializeLocalProjectConfig(projectRoot: string, config: unkno 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. * @@ -90,7 +112,7 @@ export function loadMergedConfig(projectRoot: string): unknown { const globalConfig = loadJsonFile(globalConfigPath) as Record | null; const projectConfigPath = resolveProjectConfigPath(projectRoot); const projectConfig = loadJsonFile(projectConfigPath) as Record | null; - const projectConfigBaseDir = path.dirname(path.dirname(projectConfigPath)); + const normalizedProjectConfig = loadProjectConfigLayer(projectRoot); // If neither exists, return empty if (!globalConfig && !projectConfig) { @@ -104,81 +126,78 @@ export function loadMergedConfig(projectRoot: string): unknown { // If only project exists, return it if (!globalConfig && projectConfig) { - if (Array.isArray(projectConfig.knowledgeBases)) { - projectConfig.knowledgeBases = resolveInheritedKnowledgeBaseEntries(projectConfig.knowledgeBases, projectConfigBaseDir, projectRoot); - } - 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; } @@ -202,14 +221,14 @@ 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 - ? resolveInheritedKnowledgeBaseEntries(projectConfig.knowledgeBases, projectConfigBaseDir, projectRoot) + ? (Array.isArray(normalizedProjectConfig.knowledgeBases) ? normalizedProjectConfig.knowledgeBases as string[] : []) : []; const allKbs = [...globalKbs, ...projectKbs]; const uniqueKbs = [...new Set(allKbs.map(p => String(p).trim()))]; From 7f4767ee95dc8a2657df4f25e7eec7cbb2d5c641 Mon Sep 17 00:00:00 2001 From: Helweg Date: Mon, 27 Apr 2026 13:18:27 +0200 Subject: [PATCH 20/23] fix: localize tool worktree config writes --- src/tools/index.ts | 67 ++++++++++++---- tests/tools-knowledge-bases.test.ts | 119 +++++++++++++++++++++++++--- 2 files changed, 156 insertions(+), 30 deletions(-) diff --git a/src/tools/index.ts b/src/tools/index.ts index ed13301c..89b1c669 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -18,8 +18,9 @@ import { } from "./utils.js"; import { existsSync, writeFileSync, mkdirSync, statSync } from "fs"; import * as path from "path"; -import { loadMergedConfig, materializeLocalProjectConfig } 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; @@ -40,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 { @@ -82,7 +99,19 @@ function serializeConfigPathValue(value: string, baseDir: string): string { return path.normalize(trimmed); } -function loadConfig(): Record { +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 loadRuntimeConfig(): Record { const rawConfig = loadMergedConfig(sharedProjectRoot); const config: Record = {}; @@ -92,13 +121,20 @@ function loadConfig(): Record { } } - if (Array.isArray(config.knowledgeBases)) { - config.knowledgeBases = (config.knowledgeBases as string[]).map(kb => { - return normalizeConfigPathValue(kb, sharedProjectRoot); - }); + 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 { @@ -160,11 +196,8 @@ export const index_codebase: ToolDefinition = tool({ } if (args.force) { - const status = await indexer.getStatus(); - const localIndexPath = path.join(sharedProjectRoot, ".opencode", "index"); - const currentConfig = parseConfig(loadConfig()); - if (currentConfig.scope === "project" && path.resolve(status.indexPath) !== path.resolve(localIndexPath)) { - materializeLocalProjectConfig(sharedProjectRoot, loadMergedConfig(sharedProjectRoot)); + if (shouldForceLocalizeProjectIndex()) { + materializeLocalProjectConfig(sharedProjectRoot, loadProjectConfigLayer(sharedProjectRoot)); refreshIndexerFromConfig(); indexer = getIndexer(); } @@ -404,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[] : []; @@ -439,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[] : []; @@ -482,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/tools-knowledge-bases.test.ts b/tests/tools-knowledge-bases.test.ts index dd22e8f8..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, @@ -80,7 +83,7 @@ vi.mock("../src/indexer/index.js", () => ({ import { parseConfig } from "../src/config/schema.js"; import { loadMergedConfig } from "../src/config/merger.js"; -import { add_knowledge_base, initializeTools, remove_knowledge_base } from "../src/tools/index.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; @@ -340,4 +343,94 @@ describe("knowledge base tool config refresh", () => { 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 }); + } + }); }); From 7417d4295c07b25c643d305b5be830eae9c3399a Mon Sep 17 00:00:00 2001 From: Helweg Date: Mon, 27 Apr 2026 13:18:34 +0200 Subject: [PATCH 21/23] fix: localize MCP worktree force rebuilds --- src/mcp-server.ts | 34 +++++++++++---- tests/mcp-server.test.ts | 89 ++++++++++++++++++++++++++++++++-------- 2 files changed, 97 insertions(+), 26 deletions(-) diff --git a/src/mcp-server.ts b/src/mcp-server.ts index 3e4aa61a..30aac0e3 100644 --- a/src/mcp-server.ts +++ b/src/mcp-server.ts @@ -1,13 +1,15 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import * as path from "path"; +import { existsSync } from "fs"; import { Indexer } from "./indexer/index.js"; import type { ParsedCodebaseIndexConfig, LogLevel } from "./config/schema.js"; -import { loadMergedConfig, materializeLocalProjectConfig } from "./config/merger.js"; +import { loadProjectConfigLayer, materializeLocalProjectConfig } from "./config/merger.js"; import { formatDefinitionLookup, formatHealthCheck, formatIndexStats, formatStatus } from "./tools/utils.js"; import { formatCostEstimate } from "./utils/cost.js"; import type { LogEntry } from "./utils/logger.js"; +import { resolveWorktreeMainRepoRoot } from "./git/index.js"; const MAX_CONTENT_LINES = 30; @@ -40,6 +42,21 @@ export function createMcpServer(projectRoot: string, config: ParsedCodebaseIndex initialized = false; } + function shouldForceLocalizeProjectIndex(): boolean { + if (runtimeConfig.scope !== "project") { + return false; + } + + const localIndexPath = path.join(projectRoot, ".opencode", "index"); + const mainRepoRoot = resolveWorktreeMainRepoRoot(projectRoot); + if (!mainRepoRoot) { + return false; + } + + const inheritedIndexPath = path.join(mainRepoRoot, ".opencode", "index"); + return !existsSync(localIndexPath) && existsSync(inheritedIndexPath); + } + async function ensureInitialized(): Promise { if (!initialized) { await indexer.initialize(); @@ -126,22 +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) { - const status = await indexer.getStatus(); - const localIndexPath = path.join(projectRoot, ".opencode", "index"); - if (runtimeConfig.scope === "project" && path.resolve(status.indexPath) !== path.resolve(localIndexPath)) { - materializeLocalProjectConfig(projectRoot, loadMergedConfig(projectRoot)); + if (shouldForceLocalizeProjectIndex()) { + materializeLocalProjectConfig(projectRoot, loadProjectConfigLayer(projectRoot)); refreshIndexerFromConfig(); - await ensureInitialized(); } + await ensureInitialized(); await indexer.clearIndex(); + refreshIndexerFromConfig(); + await ensureInitialized(); + } else { + await ensureInitialized(); } const stats = await indexer.index(); diff --git a/tests/mcp-server.test.ts b/tests/mcp-server.test.ts index f8c6d09e..a40c8228 100644 --- a/tests/mcp-server.test.ts +++ b/tests/mcp-server.test.ts @@ -4,13 +4,30 @@ 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(() => ({ - loadMergedConfig: vi.fn(() => ({})), + 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); @@ -64,6 +81,11 @@ 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); @@ -139,8 +161,9 @@ describe("MCP server tools and prompts", () => { beforeEach(async () => { indexerMockState.constructorArgs.length = 0; - mergerMocks.loadMergedConfig.mockReset(); - mergerMocks.loadMergedConfig.mockReturnValue({}); + indexerMockState.instances.length = 0; + mergerMocks.loadProjectConfigLayer.mockReset(); + mergerMocks.loadProjectConfigLayer.mockReturnValue({}); mergerMocks.materializeLocalProjectConfig.mockReset(); mockIndexResult = { totalFiles: 10, @@ -160,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 }, @@ -329,19 +352,7 @@ describe("MCP server tools and prompts", () => { }); it("should preserve runtime config on force refresh after localizing inherited project state", async () => { - mockStatusResult = { - ...mockStatusResult, - indexPath: "/tmp/shared-index", - }; - mergerMocks.loadMergedConfig.mockReturnValue({ - embeddingProvider: "openai", - customProvider: { - baseUrl: "https://disk.example.com/v1", - model: "disk-model", - dimensions: 1536, - apiKey: "disk-key", - }, - }); + mergerMocks.loadProjectConfigLayer.mockReturnValue({ knowledgeBases: ["docs/reference"] }); const runtimeConfig = parseConfig({ embeddingProvider: "custom", @@ -370,7 +381,7 @@ describe("MCP server tools and prompts", () => { expect(result.content).toBeDefined(); expect(mergerMocks.materializeLocalProjectConfig).toHaveBeenCalledWith( "/tmp/test-project", - mergerMocks.loadMergedConfig.mock.results.at(-1)?.value, + mergerMocks.loadProjectConfigLayer.mock.results.at(-1)?.value, ); expect(indexerMockState.constructorArgs.length).toBeGreaterThanOrEqual(3); @@ -378,6 +389,48 @@ describe("MCP server tools and prompts", () => { ["/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 () => { From 53f900ed05b667cf10d3f8e5ed82fed4de38b2f8 Mon Sep 17 00:00:00 2001 From: Helweg Date: Mon, 27 Apr 2026 15:12:34 +0200 Subject: [PATCH 22/23] fix: rebase absolute fallback knowledge bases --- src/config/merger.ts | 10 +++++++++- tests/worktree-fallback.test.ts | 31 +++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/config/merger.ts b/src/config/merger.ts index 08e75b29..7e26f33e 100644 --- a/src/config/merger.ts +++ b/src/config/merger.ts @@ -54,10 +54,18 @@ export function resolveInheritedKnowledgeBaseEntries( .filter((value): value is string => typeof value === "string") .map((value) => { const trimmed = value.trim(); - if (!trimmed || path.isAbsolute(trimmed)) { + 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); diff --git a/tests/worktree-fallback.test.ts b/tests/worktree-fallback.test.ts index 7090953c..1336ce74 100644 --- a/tests/worktree-fallback.test.ts +++ b/tests/worktree-fallback.test.ts @@ -71,6 +71,37 @@ describe("worktree fallback (issue #60)", () => { 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); From 71649669b31f440fed9e67ad3314c0738a32572b Mon Sep 17 00:00:00 2001 From: Helweg Date: Mon, 27 Apr 2026 15:40:16 +0200 Subject: [PATCH 23/23] fix: satisfy MCP server lint checks --- src/mcp-server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mcp-server.ts b/src/mcp-server.ts index 30aac0e3..e744f1bf 100644 --- a/src/mcp-server.ts +++ b/src/mcp-server.ts @@ -33,7 +33,7 @@ export function createMcpServer(projectRoot: string, config: ParsedCodebaseIndex version: "0.5.1", }); - let runtimeConfig = config; + const runtimeConfig = config; let indexer = new Indexer(projectRoot, runtimeConfig); let initialized = false;