Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
9 changes: 4 additions & 5 deletions src/adapters/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
12 changes: 12 additions & 0 deletions src/config/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
}
Expand Down
18 changes: 18 additions & 0 deletions src/config/schema.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<IndexingConfig>;
search?: Partial<SearchConfig>;
debug?: Partial<DebugConfig>;
Expand Down Expand Up @@ -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<string, unknown>;
const embeddingProviderValue = getResolvedString(input.embeddingProvider, "$root.embeddingProvider");
Expand Down Expand Up @@ -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()
Expand Down
12 changes: 6 additions & 6 deletions src/indexer/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
7 changes: 3 additions & 4 deletions src/tools/workspace-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -106,8 +105,8 @@ function isReady(status: StatusResult, branchMismatch: boolean): boolean {
}

async function readWorkspaceIndexStatus(root: string, host: HostMode): Promise<StatusResult> {
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)
Expand Down
2 changes: 1 addition & 1 deletion src/utils/auto-index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
}

Expand Down
2 changes: 1 addition & 1 deletion src/utils/background-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/watcher/file-watcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
118 changes: 118 additions & 0 deletions tests/configured-index-path.test.ts
Original file line number Diff line number Diff line change
@@ -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();
}
});
});
Loading