mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-11 08:44:49 +00:00
fix(core): discover project config once under symlinked paths (#46841)
This commit is contained in:
parent
36095decd7
commit
473c292521
2 changed files with 59 additions and 12 deletions
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue