fix(core): discover project config once under symlinked paths (#46841)

This commit is contained in:
Kit Langton 2026-09-02 13:04:43 -04:00 committed by GitHub
parent 36095decd7
commit 473c292521
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 59 additions and 12 deletions

View file

@ -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([

View file

@ -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) => {