Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
ca4027b
feat: detect main repo roots for git worktrees
Helweg Apr 22, 2026
c6704f1
feat: reuse main repo project index in fresh worktrees
Helweg Apr 22, 2026
7066120
feat: apply worktree fallback to eval index paths
Helweg Apr 22, 2026
d527712
fix: keep worktree fallback config and index in sync
Helweg Apr 22, 2026
4bddb31
fix: preserve inherited config paths during kb updates
Helweg Apr 22, 2026
ccebc8b
fix: avoid clearing inherited indexes during eval reindex
Helweg Apr 22, 2026
97db6c2
fix: preserve relative inherited config paths on save
Helweg Apr 22, 2026
e43719a
fix: isolate eval reindex state in fallback worktrees
Helweg Apr 22, 2026
a94bddb
fix: rebase fallback eval knowledge base paths
Helweg Apr 25, 2026
fd582a3
test: cover fallback eval config path semantics
Helweg Apr 25, 2026
65145f8
fix: preserve additionalInclude globs in tool config rewrites
Helweg Apr 25, 2026
8c1140b
fix: align worktree config and index boundaries
Helweg Apr 25, 2026
01d4150
fix: guard inherited project indexes during force rebuilds
Helweg Apr 26, 2026
f4d9583
fix: preserve repo-local fallback knowledge bases in eval
Helweg Apr 26, 2026
caa9344
fix: localize worktree knowledge base edits before rebuilds
Helweg Apr 26, 2026
ed5327c
fix: preserve runtime config during MCP force rebuilds
Helweg Apr 26, 2026
01c3131
fix: respect explicit eval config roots during reindex
Helweg Apr 26, 2026
b219cdd
merge: resolve main branch updates into worktree fallback fixes
Helweg Apr 26, 2026
7288335
fix: honor explicit eval config on repeated reindex
Helweg Apr 26, 2026
731fcec
refactor: split project config layer loading
Helweg Apr 27, 2026
7f4767e
fix: localize tool worktree config writes
Helweg Apr 27, 2026
7417d42
fix: localize MCP worktree force rebuilds
Helweg Apr 27, 2026
53f900e
fix: rebase absolute fallback knowledge bases
Helweg Apr 27, 2026
7164966
fix: satisfy MCP server lint checks
Helweg Apr 27, 2026
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
150 changes: 123 additions & 27 deletions src/config/merger.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { existsSync, readFileSync } from "fs";
import * as path from "path";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
import * as os from "os";
import * as path from "path";

import { resolveProjectConfigPath } from "./paths.js";

function loadJsonFile(filePath: string): unknown {
try {
Expand All @@ -12,6 +14,97 @@ function loadJsonFile(filePath: string): unknown {
return null;
}

export function rebasePathEntries(
values: unknown,
fromDir: string,
toDir: string,
): string[] {
if (!Array.isArray(values)) {
return [];
}

return values
.filter((value): value is string => typeof value === "string")
.map((value) => {
const trimmed = value.trim();
if (!trimmed || path.isAbsolute(trimmed)) {
return trimmed;
}

return path.normalize(path.relative(toDir, path.resolve(fromDir, trimmed)));
})
.filter(Boolean);
}

function isWithinRoot(rootDir: string, targetPath: string): boolean {
const relativePath = path.relative(rootDir, targetPath);
return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath));
}

export function resolveInheritedKnowledgeBaseEntries(
values: unknown,
sourceRoot: string,
targetRoot: string,
): string[] {
if (!Array.isArray(values)) {
return [];
}

return values
.filter((value): value is string => typeof value === "string")
.map((value) => {
const trimmed = value.trim();
if (!trimmed) {
return trimmed;
}

if (path.isAbsolute(trimmed)) {
if (isWithinRoot(sourceRoot, trimmed)) {
return path.normalize(path.relative(sourceRoot, trimmed) || ".");
}

return path.normalize(trimmed);
}

const resolvedFromSource = path.resolve(sourceRoot, trimmed);
if (isWithinRoot(sourceRoot, resolvedFromSource)) {
return path.normalize(trimmed);
}

return path.normalize(path.relative(targetRoot, resolvedFromSource));
})
.filter(Boolean);
}

export function materializeLocalProjectConfig(projectRoot: string, config: unknown): string {
const localConfigPath = path.join(projectRoot, ".opencode", "codebase-index.json");
mkdirSync(path.dirname(localConfigPath), { recursive: true });
writeFileSync(localConfigPath, JSON.stringify(config, null, 2), "utf-8");
return localConfigPath;
}

export function loadProjectConfigLayer(projectRoot: string): Record<string, unknown> {
const projectConfigPath = resolveProjectConfigPath(projectRoot);
const projectConfig = loadJsonFile(projectConfigPath) as Record<string, unknown> | null;

if (!projectConfig) {
return {};
}

const normalizedConfig: Record<string, unknown> = { ...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.
*
Expand All @@ -23,10 +116,11 @@ function loadJsonFile(filePath: string): unknown {
* - For include/exclude: project overrides global if set, otherwise load global
*/
export function loadMergedConfig(projectRoot: string): unknown {
const globalConfigPath = path.join(os.homedir(), ".config", "opencode", "codebase-index.json");
const globalConfigPath = os.homedir() + "/.config/opencode/codebase-index.json";
const globalConfig = loadJsonFile(globalConfigPath) as Record<string, unknown> | null;
const projectConfigPath = path.join(projectRoot, ".opencode", "codebase-index.json");
const projectConfigPath = resolveProjectConfigPath(projectRoot);
const projectConfig = loadJsonFile(projectConfigPath) as Record<string, unknown> | null;
const normalizedProjectConfig = loadProjectConfigLayer(projectRoot);

// If neither exists, return empty
if (!globalConfig && !projectConfig) {
Expand All @@ -40,78 +134,78 @@ export function loadMergedConfig(projectRoot: string): unknown {

// If only project exists, return it
if (!globalConfig && projectConfig) {
return projectConfig;
return normalizedProjectConfig;
}

// Both exist - start with global config as base
const merged: Record<string, unknown> = { ...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;
}
Expand All @@ -135,13 +229,15 @@ export function loadMergedConfig(projectRoot: string): unknown {
) {
continue; // Already handled above
}
merged[key] = projectConfig[key];
merged[key] = normalizedProjectConfig[key];
}
}

// For knowledgeBases: merge arrays (union, deduplicated)
const globalKbs = globalConfig && Array.isArray(globalConfig.knowledgeBases) ? globalConfig.knowledgeBases : [];
const projectKbs = projectConfig && Array.isArray(projectConfig.knowledgeBases) ? projectConfig.knowledgeBases : [];
const projectKbs = projectConfig
? (Array.isArray(normalizedProjectConfig.knowledgeBases) ? normalizedProjectConfig.knowledgeBases as string[] : [])
: [];
const allKbs = [...globalKbs, ...projectKbs];
const uniqueKbs = [...new Set(allKbs.map(p => String(p).trim()))];
merged.knowledgeBases = uniqueKbs;
Expand Down
56 changes: 56 additions & 0 deletions src/config/paths.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { existsSync } from "fs";
import * as os from "os";
import * as path from "path";

import { resolveWorktreeMainRepoRoot } from "../git/index.js";

const PROJECT_CONFIG_RELATIVE_PATH = path.join(".opencode", "codebase-index.json");
const PROJECT_INDEX_RELATIVE_PATH = path.join(".opencode", "index");

function resolveWorktreeFallbackPath(projectRoot: string, relativePath: string): string | null {
const mainRepoRoot = resolveWorktreeMainRepoRoot(projectRoot);
if (!mainRepoRoot) {
return null;
}

const fallbackPath = path.join(mainRepoRoot, relativePath);
return existsSync(fallbackPath) ? fallbackPath : null;
}

function hasProjectConfig(projectRoot: string): boolean {
return existsSync(path.join(projectRoot, PROJECT_CONFIG_RELATIVE_PATH));
}

export function getGlobalIndexPath(): string {
return path.join(os.homedir(), ".opencode", "global-index");
}

export function resolveProjectConfigPath(projectRoot: string): string {
const localConfigPath = path.join(projectRoot, PROJECT_CONFIG_RELATIVE_PATH);
if (existsSync(localConfigPath)) {
return localConfigPath;
}

return resolveWorktreeFallbackPath(projectRoot, PROJECT_CONFIG_RELATIVE_PATH) ?? localConfigPath;
}

export function resolveWritableProjectConfigPath(projectRoot: string): string {
return path.join(projectRoot, PROJECT_CONFIG_RELATIVE_PATH);
}

export function resolveProjectIndexPath(projectRoot: string, scope: "project" | "global"): string {
if (scope === "global") {
return getGlobalIndexPath();
}

const localIndexPath = path.join(projectRoot, PROJECT_INDEX_RELATIVE_PATH);
if (existsSync(localIndexPath)) {
return localIndexPath;
}

if (hasProjectConfig(projectRoot)) {
return localIndexPath;
}

return resolveWorktreeFallbackPath(projectRoot, PROJECT_INDEX_RELATIVE_PATH) ?? localIndexPath;
}
Loading
Loading