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
2 changes: 1 addition & 1 deletion packages/core/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ export const layer = (options?: Options) =>
const requestReload = PubSub.publish(reloads, undefined).pipe(Effect.asVoid)
const watched = yield* FiberMap.make<string>()
const reconcile = Effect.fn("Config.reconcileWatches")(function* (sources: ConfigDiscovery.Sources) {
const plan = ConfigWatch.plan(sources)
const plan = yield* ConfigWatch.plan(sources)
for (const key of Array.from(watched, ([key]) => key)) {
if (!plan.has(key)) yield* FiberMap.remove(watched, key)
}
Expand Down
23 changes: 20 additions & 3 deletions packages/core/src/config/watch.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,33 @@
export * as ConfigWatch from "./watch.js"

import path from "path"
import { Effect } from "effect"
import { FSUtil } from "@opencode/util/fs-util"
import type { Watcher } from "../filesystem/watcher.js"
import type { ConfigDiscovery } from "./discovery.js"
import { ConfigDiscovery } from "./discovery.js"

export function plan(sources: ConfigDiscovery.Sources) {
export const plan = Effect.fn("ConfigWatch.plan")(function* (sources: ConfigDiscovery.Sources) {
const fs = yield* FSUtil.Service
const directories = [
...(sources.global ? [sources.global] : []),
...sources.project.filter((root) => root.present).map((root) => root.path),
]
// Directory watches do not follow file symlinks outside their roots. Keep
// both spellings so target edits and changes to the link are observable.
const linked = yield* Effect.forEach(
[
...directories.flatMap((directory) => ConfigDiscovery.names.map((name) => path.join(directory, name))),
...sources.direct,
...(sources.explicit ? [sources.explicit] : []),
],
(file) =>
fs.readLink(file).pipe(
Effect.flatMap((link) => fs.resolve(path.resolve(path.dirname(file), link))),
Effect.orElseSucceed(() => undefined),
),
)
const files = [
...linked.filter((file) => file !== undefined),
...sources.direct,
...sources.project.map((root) => root.path),
...sources.claude,
Expand All @@ -36,4 +53,4 @@ export function plan(sources: ConfigDiscovery.Sources) {
})),
].map((target) => [JSON.stringify(target), target satisfies Watcher.WatchInput]),
)
}
})
46 changes: 46 additions & 0 deletions packages/core/test/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,52 @@ describe("Config", () => {
),
)

it.live("reloads successive edits to a symlinked global config target", () =>
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const global = path.join(tmp.path, "global")
const project = path.join(tmp.path, "project")
const target = path.join(tmp.path, "config.json")
yield* Effect.promise(async () => {
await fs.mkdir(global)
await fs.mkdir(project)
await fs.writeFile(target, JSON.stringify({ shell: "one" }))
await fs.symlink(target, path.join(global, "opencode.json"))
})
return yield* Effect.gen(function* () {
const config = yield* Config.Service
const bus = yield* Bus.Service
expect(Config.latest(yield* config.entries(), "shell")).toBe("one")
// A readiness rescan can catch the first edit; the second requires a target watch.
for (const shell of ["two", "three"]) {
const changed = yield* bus
.subscribe(Event.Updated)
.pipe(Stream.take(1), Stream.runDrain, Effect.forkScoped({ startImmediately: true }))
yield* Effect.promise(() => fs.writeFile(target, JSON.stringify({ shell })))
yield* Fiber.join(changed).pipe(Effect.timeout("3 seconds"))
expect(Config.latest(yield* config.entries(), "shell")).toBe(shell)
}
}).pipe(
Effect.provide(
AppNodeBuilder.build(LayerNode.group([Config.node, Bus.node]), [
Location.node.replace(
Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(project) })),
),
),
Global.node.replace(Global.layerWith({ config: global, home: path.join(global, "home") })),
Credential.node.replace(emptyCredentialNode),
WellKnown.node.replace(emptyWellknownNode),
]),
),
)
}),
),
),
)

it.effect("backs Config.Service and Config.Test with one shared test implementation", () =>
Effect.gen(function* () {
const config = yield* Config.Service
Expand Down
113 changes: 89 additions & 24 deletions packages/core/test/config/watch.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
import path from "path"
import { describe, expect, test } from "bun:test"
import fs from "fs/promises"
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { FSUtil } from "@opencode/util/fs-util"
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
import type { ConfigDiscovery } from "@opencode/core/config/discovery"
import { ConfigWatch } from "@opencode/core/config/watch"
import { AbsolutePath } from "@opencode/core/schema"
import { testEffect } from "../lib/effect"
import { tmpdir } from "../fixture/tmpdir"

const it = testEffect(AppNodeBuilder.build(FSUtil.node))

const project = path.resolve("watch-plan-project")
const root = AbsolutePath.make(path.join(project, ".opencode"))
Expand All @@ -14,29 +22,86 @@ const sources: ConfigDiscovery.Sources = {
}

describe("ConfigWatch.plan", () => {
test("groups missing candidates and keeps parent watches when roots appear", () => {
const missing = ConfigWatch.plan(sources)
expect(Array.from(missing.values())).toEqual([
{ path: project, type: "entries", names: [".agents", ".claude", ".opencode", "opencode.json", "opencode.jsonc"] },
])
const present = ConfigWatch.plan({ ...sources, project: [{ path: root, present: true }] })
expect(Array.from(present.values())).toEqual([
{ path: root, type: "directory", ignore: ["node_modules", ".git", "**/{node_modules,.git}/**"] },
...missing.values(),
])
})
for (const source of ["global", "direct", "project", "explicit"] as const) {
it.live(`watches the target of a symlinked ${source} config even when dangling`, () =>
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const directory = AbsolutePath.make(path.join(tmp.path, "config"))
const file = AbsolutePath.make(path.join(directory, "opencode.json"))
const target = path.join(tmp.path, "dotfiles", "target.json")
const input: ConfigDiscovery.Sources = {
direct: source === "direct" ? [file] : [],
project: source === "project" ? [{ path: directory, present: true }] : [],
claude: [],
agents: [],
global: source === "global" ? directory : undefined,
explicit: source === "explicit" ? file : undefined,
}
yield* Effect.promise(async () => {
await fs.mkdir(directory)
await fs.mkdir(path.dirname(target))
await fs.writeFile(target, "{}")
await fs.symlink(path.relative(directory, target), file)
})
const present = yield* ConfigWatch.plan(input)
expect(Array.from(present.values())).toContainEqual({
path: path.dirname(target),
type: "entries",
names: ["target.json"],
})
yield* Effect.promise(() => fs.rm(target))
expect(yield* ConfigWatch.plan(input)).toEqual(present)

test("adds exact watches for explicit files only when not already covered", () => {
expect(ConfigWatch.plan({ ...sources, explicit: sources.direct[0] })).toEqual(ConfigWatch.plan(sources))
const present = { ...sources, project: [{ path: root, present: true }] }
expect(ConfigWatch.plan({ ...present, explicit: AbsolutePath.make(path.join(root, "custom.json")) })).toEqual(
ConfigWatch.plan(present),
)
const directory = path.resolve("watch-plan-external")
expect(
Array.from(
ConfigWatch.plan({ ...sources, explicit: AbsolutePath.make(path.join(directory, "custom.json")) }).values(),
yield* Effect.promise(async () => {
await fs.unlink(file)
await fs.symlink("../dotfiles/replacement.json", file)
})
const retargeted = Array.from((yield* ConfigWatch.plan(input)).values())
expect(retargeted).toContainEqual({ path: path.dirname(target), type: "entries", names: ["replacement.json"] })
expect(retargeted).not.toContainEqual({ path: path.dirname(target), type: "entries", names: ["target.json"] })
}),
),
),
).toContainEqual({ path: directory, type: "entries", names: ["custom.json"] })
})
)
}

it.live("groups missing candidates and keeps parent watches when roots appear", () =>
Effect.gen(function* () {
const missing = yield* ConfigWatch.plan(sources)
expect(Array.from(missing.values())).toEqual([
{
path: project,
type: "entries",
names: [".agents", ".claude", ".opencode", "opencode.json", "opencode.jsonc"],
},
])
const present = yield* ConfigWatch.plan({ ...sources, project: [{ path: root, present: true }] })
expect(Array.from(present.values())).toEqual([
{ path: root, type: "directory", ignore: ["node_modules", ".git", "**/{node_modules,.git}/**"] },
...missing.values(),
])
}),
)

it.live("adds exact watches for explicit files only when not already covered", () =>
Effect.gen(function* () {
expect(yield* ConfigWatch.plan({ ...sources, explicit: sources.direct[0] })).toEqual(
yield* ConfigWatch.plan(sources),
)
const present = { ...sources, project: [{ path: root, present: true }] }
expect(
yield* ConfigWatch.plan({ ...present, explicit: AbsolutePath.make(path.join(root, "custom.json")) }),
).toEqual(yield* ConfigWatch.plan(present))
const directory = path.resolve("watch-plan-external")
expect(
Array.from(
(yield* ConfigWatch.plan({
...sources,
explicit: AbsolutePath.make(path.join(directory, "custom.json")),
})).values(),
),
).toContainEqual({ path: directory, type: "entries", names: ["custom.json"] })
}),
)
})
Loading