diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index c9d78cbc0f5..2857385fc4d 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -188,7 +188,15 @@ export const layer = (options?: Options) => const globalDirectory = AbsolutePath.make(global.config) const globalAgentsDirectory = AbsolutePath.make(path.join(global.home, ".agents")) const globalClaudeDirectory = AbsolutePath.make(path.join(global.home, ".claude")) - const locationIsGlobal = path.resolve(location.directory) === path.resolve(global.config) + // Global roots and the walk are compared by canonical path: the same + // directory reached under two spellings (a symlinked checkout, macOS + // /var vs /private/var, OPENCODE_CONFIG_DIR inside the project) must + // classify identically or it enters discovery twice. + const globalRoots = yield* Effect.forEach( + [globalDirectory, globalClaudeDirectory, globalAgentsDirectory], + (item) => fs.resolve(item), + ) + const locationIsGlobal = (yield* fs.resolve(location.directory)) === globalRoots[0] const discovered = locationIsGlobal || options?.project === false ? [] @@ -197,22 +205,30 @@ export const layer = (options?: Options) => targets: [".opencode", ".claude", ".agents", ...names.toReversed()], start: location.directory, }) - .pipe(Effect.orDie) + .pipe( + Effect.flatMap((items) => + Effect.forEach(items, (item) => + fs.resolve(item).pipe(Effect.map((resolved) => ({ item, resolved }))), + ), + ), + Effect.orDie, + ) const globalEnabled = options?.global !== false // A walked path that resolves into a global root is global config // however the walk reached it (home above the project, or a location // beneath the global config dir), so global: false excludes it - // uniformly — classified once here, not per consumer below. - const globalRoots = [globalDirectory, globalClaudeDirectory, globalAgentsDirectory].map((item) => - path.resolve(item), - ) - const visible = globalEnabled - ? discovered - : discovered.filter((item) => { - const resolved = path.resolve(item) - return !globalRoots.some((root) => resolved === root || resolved.startsWith(root + path.sep)) - }) + // uniformly — classified once here, not per consumer below. With + // global enabled, the roots themselves and the global config files are + // already loaded below, so the walk must not add them a second time. + const globalFiles = yield* Effect.forEach(names, (name) => fs.resolve(path.join(globalDirectory, name))) + const visible = discovered + .filter(({ resolved }) => + globalEnabled + ? !globalRoots.includes(resolved) && !globalFiles.includes(resolved) + : !globalRoots.some((root) => resolved === root || resolved.startsWith(root + path.sep)), + ) + .map(({ item }) => item) // We load certain files from a few other folders in the ecosystem const claude = [ ...new Set([ diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index 0edc12d8bc0..8ba8e2d07c0 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -149,6 +149,37 @@ describe("Config", () => { ), ) + it.live("discovers the global config directory once when the project walk reaches it", () => + Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe( + Effect.flatMap((tmp) => { + // The global config dir is the project's own .opencode (as isolated + // hosts pin OPENCODE_CONFIG_DIR), and the location is the project under + // a symlinked spelling, so the walk reaches the same directory under a + // different string than the global root. + const real = path.join(tmp.path, "real") + const link = path.join(tmp.path, "link") + const global = AbsolutePath.make(path.join(real, ".opencode")) + const once = Effect.gen(function* () { + const config = yield* Config.Service + const watcher = yield* Watcher.Test + const entries = yield* config.entries() + expect(entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : []))).toEqual([global]) + expect(entries.flatMap((entry) => (entry.type === "document" ? [entry.info.shell] : []))).toEqual(["global"]) + expect((yield* watcher.subscriptions()).map((subscription) => subscription.path)).toEqual([global]) + }) + return Effect.promise(async () => { + await fs.mkdir(global, { recursive: true }) + await fs.writeFile(path.join(global, "opencode.json"), JSON.stringify({ shell: "global" })) + await fs.symlink(real, link, process.platform === "win32" ? "junction" : undefined) + }).pipe( + Effect.andThen(once.pipe(Effect.provide(testLayer(link, global, real)))), + // Same spelling on both sides: still exactly once. + Effect.andThen(once.pipe(Effect.provide(testLayer(real, global, real)))), + ) + }), + ), + ) + it.live("loads explicit file and content overrides in priority order", () => Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe( Effect.flatMap((tmp) => {