From ff5a3ccb245258298ef2321a89ecb30d2110e6b9 Mon Sep 17 00:00:00 2001 From: Bonny Date: Fri, 30 Oct 2026 13:47:55 +1100 Subject: [PATCH] feat(config): add indexPath to store the index outside the project A configured indexPath replaces the scope's default index directory in every place that resolves it (indexer, watcher, background worker, auto index, MCP diagnostics, index_status). The main checkout owns the configured index and linked worktrees inherit it, so the existing inherited-index safety checks still apply. index_status now reads the runtime config, so it honours --config like the rest of the server. --- docs/configuration.md | 10 +++ src/adapters/mcp/server.ts | 9 +-- src/config/paths.ts | 12 +++ src/config/schema.ts | 18 +++++ src/indexer/index.ts | 12 +-- src/tools/workspace-status.ts | 7 +- src/utils/auto-index.ts | 2 +- src/utils/background-worker.ts | 2 +- src/watcher/file-watcher.ts | 2 +- tests/configured-index-path.test.ts | 118 ++++++++++++++++++++++++++++ 10 files changed, 174 insertions(+), 18 deletions(-) create mode 100644 tests/configured-index-path.test.ts diff --git a/docs/configuration.md b/docs/configuration.md index 41c4f8c7..96f91764 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -282,6 +282,16 @@ Ollama server is stopped. The standard suite uses deterministic HTTP replicas wi - `project`: store the index with the repository. - `global`: use the host-specific global index path. +### Index location + +```json +{ + "indexPath": "~/.local/share/my-indexes/my-repo" +} +``` + +`indexPath` replaces the scope's default index directory, for example to keep a project index out of the repository. It must be absolute or start with `~/`, and supports `{env:NAME}` substitution. With `scope: "project"`, stored paths stay root-relative, so linked worktrees that use the same configuration (for example the same `--config` file) share the index exactly as they share `.claude/index/`. The main checkout owns the index; force rebuilds and recovery from a linked worktree are refused as for any inherited index. Give each project its own `indexPath`. + ## Indexing defaults | Option | Default | Purpose | diff --git a/src/adapters/mcp/server.ts b/src/adapters/mcp/server.ts index 3a00bda3..961a1d07 100644 --- a/src/adapters/mcp/server.ts +++ b/src/adapters/mcp/server.ts @@ -134,11 +134,10 @@ export function createMcpServer( }); initializeTools(projectRoot, config, host, { preserveManagedWorker: true }); - const diagnostics = new McpRuntimeDiagnostics(() => resolveProjectIndexPath( - projectRoot, - getRuntimeConfigForProject(projectRoot, host).scope, - host, - )); + const diagnostics = new McpRuntimeDiagnostics(() => { + const runtimeConfig = getRuntimeConfigForProject(projectRoot, host); + return resolveProjectIndexPath(projectRoot, runtimeConfig.scope, host, runtimeConfig.indexPath); + }); const backgroundWorker = configureMcpBackgroundWorker(projectRoot, config, host); if (backgroundWorker.managesWorker) { retainMcpBackgroundWorker(projectRoot, host); diff --git a/src/config/paths.ts b/src/config/paths.ts index e36c9256..19cd6e73 100644 --- a/src/config/paths.ts +++ b/src/config/paths.ts @@ -81,7 +81,14 @@ export function isProjectIndexPathOwnedByProject( projectRoot: string, indexPath: string, host: HostMode, + configuredIndexPath?: string, ): boolean { + if (configuredIndexPath) { + // A configured index belongs to the main checkout; linked worktrees inherit it. + return canonicalizePathForComparison(indexPath) === canonicalizePathForComparison(configuredIndexPath) + && resolveWorktreeMainRepoRoot(projectRoot) === null; + } + const projectRoots = [projectRoot]; const mainRepoRoot = resolveWorktreeMainRepoRoot(projectRoot); if (mainRepoRoot) { @@ -184,7 +191,12 @@ export function resolveProjectIndexPath( projectRoot: string, scope: "project" | "global", host: HostMode, + configuredIndexPath?: string, ): string { + if (configuredIndexPath) { + return configuredIndexPath; + } + if (scope === "global") { return resolveGlobalIndexPath(host); } diff --git a/src/config/schema.ts b/src/config/schema.ts index c84a7542..c2fc7b29 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -1,3 +1,6 @@ +import * as os from "os"; +import * as path from "path"; + // Config schema without zod dependency to avoid version conflicts with OpenCode SDK import { parseEmbeddingFallback } from "./embedding-fallback.js"; @@ -230,6 +233,8 @@ export interface CodebaseIndexConfig { * embedding endpoints is Ollama, primary or fallback replica; ignored otherwise. */ embedding?: EmbeddingConfig; scope: IndexScope; + /** Absolute directory for the index, replacing the scope's default location. */ + indexPath?: string; indexing?: Partial; search?: Partial; debug?: Partial; @@ -263,6 +268,18 @@ export type ParsedCodebaseIndexConfig = CodebaseIndexConfig & { embedding: EmbeddingConfig; }; +function parseIndexPath(value: unknown): { indexPath?: string } { + const raw = getResolvedString(value, "$root.indexPath")?.trim(); + if (!raw) { + return {}; + } + const expanded = raw === "~" || raw.startsWith("~/") ? path.join(os.homedir(), raw.slice(1)) : raw; + if (!path.isAbsolute(expanded)) { + throw new Error("indexPath must be an absolute path or start with ~/."); + } + return { indexPath: path.normalize(expanded) }; +} + export function parseConfig(raw: unknown): ParsedCodebaseIndexConfig { const input = (raw && typeof raw === "object" ? raw : {}) as Record; const embeddingProviderValue = getResolvedString(input.embeddingProvider, "$root.embeddingProvider"); @@ -551,6 +568,7 @@ export function parseConfig(raw: unknown): ParsedCodebaseIndexConfig { embeddingFallback: parseEmbeddingFallback(input.embeddingFallback, embeddingProvider, embeddingModel, customProvider), embedding, scope: isValidScope(scopeValue) ? scopeValue : "project", + ...parseIndexPath(input.indexPath), include: includeValue ?? DEFAULT_INCLUDE, exclude: excludeValue === undefined || hasDerivedDefaultExclude ? createDefaultExcludePatterns() diff --git a/src/indexer/index.ts b/src/indexer/index.ts index c924b48f..7fef814c 100644 --- a/src/indexer/index.ts +++ b/src/indexer/index.ts @@ -1390,7 +1390,7 @@ export class Indexer { } private getIndexPath(): string { - const resolvedPath = this.indexPathOverride ?? resolveProjectIndexPath(this.projectRoot, this.config.scope, this.host); + const resolvedPath = this.indexPathOverride ?? resolveProjectIndexPath(this.projectRoot, this.config.scope, this.host, this.config.indexPath); return this.config.indexing.mode === "structural" ? path.join(resolvedPath, "structural") : resolvedPath; @@ -1567,7 +1567,7 @@ export class Indexer { const ownershipPath = this.config.indexing.mode === "structural" ? path.dirname(this.indexPath) : this.indexPath; - return isProjectIndexPathOwnedByProject(this.projectRoot, ownershipPath, this.host); + return isProjectIndexPathOwnedByProject(this.projectRoot, ownershipPath, this.host, this.config.indexPath); } private resetLoadedIndexState(retireDatabase = false): void { @@ -4684,7 +4684,7 @@ export class Indexer { maxFilesPerDirectory: this.config.indexing.maxFilesPerDirectory, includeExcluded: this.config.indexing.includeExcluded, protectedPaths: [ - this.indexPathOverride ?? resolveProjectIndexPath(this.projectRoot, this.config.scope, this.host), + this.indexPathOverride ?? resolveProjectIndexPath(this.projectRoot, this.config.scope, this.host, this.config.indexPath), this.indexPath, ], signal: options.signal, @@ -4720,7 +4720,7 @@ export class Indexer { maxFilesPerDirectory: this.config.indexing.maxFilesPerDirectory, includeExcluded: this.config.indexing.includeExcluded, protectedPaths: [ - this.indexPathOverride ?? resolveProjectIndexPath(this.projectRoot, this.config.scope, this.host), + this.indexPathOverride ?? resolveProjectIndexPath(this.projectRoot, this.config.scope, this.host, this.config.indexPath), this.indexPath, ], signal: options.signal, @@ -5027,7 +5027,7 @@ export class Indexer { maxFilesPerDirectory: this.config.indexing.maxFilesPerDirectory, includeExcluded: this.config.indexing.includeExcluded, protectedPaths: [ - this.indexPathOverride ?? resolveProjectIndexPath(this.projectRoot, this.config.scope, this.host), + this.indexPathOverride ?? resolveProjectIndexPath(this.projectRoot, this.config.scope, this.host, this.config.indexPath), this.indexPath, ], signal, @@ -7007,7 +7007,7 @@ export class Indexer { maxFilesPerDirectory: this.config.indexing.maxFilesPerDirectory, includeExcluded: this.config.indexing.includeExcluded, protectedPaths: [ - this.indexPathOverride ?? resolveProjectIndexPath(this.projectRoot, this.config.scope, this.host), + this.indexPathOverride ?? resolveProjectIndexPath(this.projectRoot, this.config.scope, this.host, this.config.indexPath), this.indexPath, ], signal: options.signal, diff --git a/src/tools/workspace-status.ts b/src/tools/workspace-status.ts index 2c6d166c..f9cf22ed 100644 --- a/src/tools/workspace-status.ts +++ b/src/tools/workspace-status.ts @@ -5,10 +5,9 @@ import type { HostMode } from "../config/host.js"; import type { StatusResult } from "../indexer/index.js"; import { resolveProjectIndexPath } from "../config/paths.js"; -import { parseConfig } from "../config/schema.js"; import { getCurrentBranch, getCurrentCommit, isGitRepo } from "../git/index.js"; import { Indexer } from "../indexer/index.js"; -import { loadRuntimeConfig } from "./config-state.js"; +import { getRuntimeConfigForProject } from "./operation-runtime.js"; import { createWorkspaceDatabaseSnapshot } from "./workspace-status-snapshot.js"; export const MAX_WORKSPACE_REPOS = 20; @@ -106,8 +105,8 @@ function isReady(status: StatusResult, branchMismatch: boolean): boolean { } async function readWorkspaceIndexStatus(root: string, host: HostMode): Promise { - const config = parseConfig(loadRuntimeConfig(root, host)); - const baseIndexPath = resolveProjectIndexPath(root, config.scope, host); + const config = getRuntimeConfigForProject(root, host); + const baseIndexPath = resolveProjectIndexPath(root, config.scope, host, config.indexPath); const indexPath = config.indexing.mode === "structural" ? path.join(baseIndexPath, "structural") : baseIndexPath; const sourceDatabasePath = path.join(indexPath, "codebase.db"); const snapshot = existsSync(sourceDatabasePath) diff --git a/src/utils/auto-index.ts b/src/utils/auto-index.ts index 4156d407..9b609769 100644 --- a/src/utils/auto-index.ts +++ b/src/utils/auto-index.ts @@ -159,7 +159,7 @@ function coordinatorKey( host: HostMode, ): string { const canonicalProjectRoot = canonicalizePath(projectRoot); - const indexPath = resolveProjectIndexPath(projectRoot, config.scope, host); + const indexPath = resolveProjectIndexPath(projectRoot, config.scope, host, config.indexPath); return `${canonicalizePath(indexPath)}::${canonicalProjectRoot}`; } diff --git a/src/utils/background-worker.ts b/src/utils/background-worker.ts index a00c7445..9523c296 100644 --- a/src/utils/background-worker.ts +++ b/src/utils/background-worker.ts @@ -147,7 +147,7 @@ function resolveIdentity( host: HostMode, ): BackgroundWorkerIdentity { const canonicalProjectRoot = canonicalizePath(projectRoot); - const canonicalIndexPath = canonicalizePath(resolveProjectIndexPath(projectRoot, config.scope, host)); + const canonicalIndexPath = canonicalizePath(resolveProjectIndexPath(projectRoot, config.scope, host, config.indexPath)); return { canonicalIndexPath, canonicalProjectRoot, diff --git a/src/watcher/file-watcher.ts b/src/watcher/file-watcher.ts index 5cb22009..54fcffcb 100644 --- a/src/watcher/file-watcher.ts +++ b/src/watcher/file-watcher.ts @@ -85,7 +85,7 @@ export class FileWatcher { this.config = config; this.backend = options.backend ?? "auto"; this.configPath = options.configPath; - this.indexPath = path.resolve(options.indexPath ?? resolveProjectIndexPath(projectRoot, config.scope, host)); + this.indexPath = path.resolve(options.indexPath ?? resolveProjectIndexPath(projectRoot, config.scope, host, config.indexPath)); this.projectConfigPaths = options.configPath ? [options.configPath] : getProjectConfigCandidatePaths(projectRoot, host); diff --git a/tests/configured-index-path.test.ts b/tests/configured-index-path.test.ts new file mode 100644 index 00000000..c13730b0 --- /dev/null +++ b/tests/configured-index-path.test.ts @@ -0,0 +1,118 @@ +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { isProjectIndexPathOwnedByProject, resolveProjectIndexPath } from "../src/config/paths.js"; +import { parseConfig } from "../src/config/schema.js"; +import { Indexer } from "../src/indexer/index.js"; + +describe("indexPath config option", () => { + it("keeps an absolute path and expands ~/", () => { + const absolute = path.join(os.tmpdir(), "configured-index"); + expect(parseConfig({ indexPath: absolute }).indexPath).toBe(absolute); + expect(parseConfig({ indexPath: "~/indexes/repo" }).indexPath).toBe(path.join(os.homedir(), "indexes", "repo")); + expect(parseConfig({}).indexPath).toBeUndefined(); + expect(parseConfig({ indexPath: "" }).indexPath).toBeUndefined(); + }); + + it("rejects a relative path", () => { + expect(() => parseConfig({ indexPath: "indexes/repo" })).toThrow("indexPath must be an absolute path"); + }); +}); + +describe("configured index path", () => { + let tempDir: string; + let mainRepoDir: string; + let worktreeDir: string; + let indexPath: string; + + beforeEach(() => { + tempDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "configured-index-path-"))); + mainRepoDir = path.join(tempDir, "main-repo"); + worktreeDir = path.join(tempDir, "worktree-feature"); + indexPath = path.join(tempDir, "outside", "index"); + const worktreeGitDir = path.join(mainRepoDir, ".git", "worktrees", "feature"); + + fs.mkdirSync(path.join(mainRepoDir, ".git", "refs", "heads"), { 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"); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it("replaces the default location for the main checkout and its worktrees", () => { + for (const root of [mainRepoDir, worktreeDir]) { + expect(resolveProjectIndexPath(root, "project", "claude", indexPath)).toBe(indexPath); + expect(resolveProjectIndexPath(root, "global", "claude", indexPath)).toBe(indexPath); + } + }); + + it("is owned by the main checkout and inherited by worktrees", () => { + expect(isProjectIndexPathOwnedByProject(mainRepoDir, indexPath, "claude", indexPath)).toBe(true); + expect(isProjectIndexPathOwnedByProject(worktreeDir, indexPath, "claude", indexPath)).toBe(false); + }); + + it("indexes into the configured directory, writes nothing in the checkout, and shares it with worktrees", async () => { + const homeDir = path.join(tempDir, "home"); + fs.mkdirSync(homeDir, { recursive: true }); + vi.stubEnv("HOME", homeDir); + vi.stubEnv("USERPROFILE", homeDir); + + const source = "export function configuredIndexMarker() { return 'outside'; }\n"; + for (const root of [mainRepoDir, worktreeDir]) { + fs.mkdirSync(path.join(root, "src"), { recursive: true }); + fs.writeFileSync(path.join(root, "src", "marker.ts"), source, "utf-8"); + } + + const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async (_url, init) => { + const body = JSON.parse(String(init?.body ?? "{}")) as { input?: string[] }; + const texts = Array.isArray(body.input) ? body.input : []; + const data = texts.map((text) => { + const seed = Array.from(text).reduce((sum, character) => sum + character.charCodeAt(0), 0); + return { embedding: Array.from({ length: 8 }, (_, index) => ((seed + index * 17) % 997) / 997) }; + }); + return new Response(JSON.stringify({ data, usage: { total_tokens: Math.max(1, texts.length * 8) } }), { status: 200 }); + }); + + const config = parseConfig({ + embeddingProvider: "custom", + customProvider: { baseUrl: "http://localhost:11434/v1", model: "mock-model", dimensions: 8 }, + scope: "project", + indexPath, + indexing: { watchFiles: false, requireProjectMarker: false }, + }); + const mainIndexer = new Indexer(mainRepoDir, config, "claude"); + const worktreeIndexer = new Indexer(worktreeDir, config, "claude"); + + try { + const mainStats = await mainIndexer.index(); + const requestsAfterMain = fetchSpy.mock.calls.length; + const worktreeStats = await worktreeIndexer.index(); + + expect(mainStats.indexedChunks).toBeGreaterThan(0); + expect(fs.existsSync(path.join(indexPath, "codebase.db"))).toBe(true); + expect(fs.existsSync(path.join(mainRepoDir, ".claude"))).toBe(false); + expect(fs.existsSync(path.join(worktreeDir, ".claude"))).toBe(false); + expect(worktreeStats.tokensUsed).toBe(0); + expect(fetchSpy).toHaveBeenCalledTimes(requestsAfterMain); + expect((await worktreeIndexer.getStatus()).indexPath).toBe(indexPath); + + const results = await worktreeIndexer.search("configuredIndexMarker", 5, { metadataOnly: true }); + expect(results.find((result) => result.name === "configuredIndexMarker")?.filePath) + .toBe(path.join(worktreeDir, "src", "marker.ts")); + } finally { + await Promise.all([mainIndexer.close(), worktreeIndexer.close()]); + fetchSpy.mockRestore(); + } + }); +});