refactor: remove makeRuntime facades from File and Ripgrep (#22513)

This commit is contained in:
Kit Langton
2026-04-15 21:22:18 -04:00
committed by GitHub
parent 02f2cf439e
commit 0fb0135e51
5 changed files with 130 additions and 213 deletions
+9 -24
View File
@@ -1,10 +1,9 @@
import { EOL } from "os" import { EOL } from "os"
import { Effect } from "effect"
import { AppRuntime } from "@/effect/app-runtime" import { AppRuntime } from "@/effect/app-runtime"
import { File } from "../../../file" import { File } from "../../../file"
import { Ripgrep } from "@/file/ripgrep"
import { bootstrap } from "../../bootstrap" import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd" import { cmd } from "../cmd"
import { Ripgrep } from "@/file/ripgrep"
const FileSearchCommand = cmd({ const FileSearchCommand = cmd({
command: "search <query>", command: "search <query>",
@@ -17,11 +16,7 @@ const FileSearchCommand = cmd({
}), }),
async handler(args) { async handler(args) {
await bootstrap(process.cwd(), async () => { await bootstrap(process.cwd(), async () => {
const results = await AppRuntime.runPromise( const results = await AppRuntime.runPromise(File.Service.use((svc) => svc.search({ query: args.query })))
Effect.gen(function* () {
return yield* File.Service.use((svc) => svc.search({ query: args.query }))
}),
)
process.stdout.write(results.join(EOL) + EOL) process.stdout.write(results.join(EOL) + EOL)
}) })
}, },
@@ -38,11 +33,7 @@ const FileReadCommand = cmd({
}), }),
async handler(args) { async handler(args) {
await bootstrap(process.cwd(), async () => { await bootstrap(process.cwd(), async () => {
const content = await AppRuntime.runPromise( const content = await AppRuntime.runPromise(File.Service.use((svc) => svc.read(args.path)))
Effect.gen(function* () {
return yield* File.Service.use((svc) => svc.read(args.path))
}),
)
process.stdout.write(JSON.stringify(content, null, 2) + EOL) process.stdout.write(JSON.stringify(content, null, 2) + EOL)
}) })
}, },
@@ -54,11 +45,7 @@ const FileStatusCommand = cmd({
builder: (yargs) => yargs, builder: (yargs) => yargs,
async handler() { async handler() {
await bootstrap(process.cwd(), async () => { await bootstrap(process.cwd(), async () => {
const status = await AppRuntime.runPromise( const status = await AppRuntime.runPromise(File.Service.use((svc) => svc.status()))
Effect.gen(function* () {
return yield* File.Service.use((svc) => svc.status())
}),
)
process.stdout.write(JSON.stringify(status, null, 2) + EOL) process.stdout.write(JSON.stringify(status, null, 2) + EOL)
}) })
}, },
@@ -75,11 +62,7 @@ const FileListCommand = cmd({
}), }),
async handler(args) { async handler(args) {
await bootstrap(process.cwd(), async () => { await bootstrap(process.cwd(), async () => {
const files = await AppRuntime.runPromise( const files = await AppRuntime.runPromise(File.Service.use((svc) => svc.list(args.path)))
Effect.gen(function* () {
return yield* File.Service.use((svc) => svc.list(args.path))
}),
)
process.stdout.write(JSON.stringify(files, null, 2) + EOL) process.stdout.write(JSON.stringify(files, null, 2) + EOL)
}) })
}, },
@@ -95,8 +78,10 @@ const FileTreeCommand = cmd({
default: process.cwd(), default: process.cwd(),
}), }),
async handler(args) { async handler(args) {
const files = await Ripgrep.tree({ cwd: args.dir, limit: 200 }) await bootstrap(process.cwd(), async () => {
console.log(JSON.stringify(files, null, 2)) const tree = await AppRuntime.runPromise(Ripgrep.Service.use((svc) => svc.tree({ cwd: args.dir, limit: 200 })))
console.log(JSON.stringify(tree, null, 2))
})
}, },
}) })
+18 -7
View File
@@ -1,4 +1,5 @@
import { EOL } from "os" import { EOL } from "os"
import { Effect, Stream } from "effect"
import { AppRuntime } from "../../../effect/app-runtime" import { AppRuntime } from "../../../effect/app-runtime"
import { Ripgrep } from "../../../file/ripgrep" import { Ripgrep } from "../../../file/ripgrep"
import { Instance } from "../../../project/instance" import { Instance } from "../../../project/instance"
@@ -21,7 +22,10 @@ const TreeCommand = cmd({
}), }),
async handler(args) { async handler(args) {
await bootstrap(process.cwd(), async () => { await bootstrap(process.cwd(), async () => {
process.stdout.write((await Ripgrep.tree({ cwd: Instance.directory, limit: args.limit })) + EOL) const tree = await AppRuntime.runPromise(
Ripgrep.Service.use((svc) => svc.tree({ cwd: Instance.directory, limit: args.limit })),
)
process.stdout.write(tree + EOL)
}) })
}, },
}) })
@@ -45,14 +49,21 @@ const FilesCommand = cmd({
}), }),
async handler(args) { async handler(args) {
await bootstrap(process.cwd(), async () => { await bootstrap(process.cwd(), async () => {
const files: string[] = [] const files = await AppRuntime.runPromise(
for await (const file of await Ripgrep.files({ Effect.gen(function* () {
const rg = yield* Ripgrep.Service
return yield* rg
.files({
cwd: Instance.directory, cwd: Instance.directory,
glob: args.glob ? [args.glob] : undefined, glob: args.glob ? [args.glob] : undefined,
})) { })
files.push(file) .pipe(
if (args.limit && files.length >= args.limit) break Stream.take(args.limit ?? Infinity),
} Stream.runCollect,
Effect.map((c) => [...c]),
)
}),
)
process.stdout.write(files.join(EOL) + EOL) process.stdout.write(files.join(EOL) + EOL)
}) })
}, },
+1 -23
View File
@@ -1,6 +1,6 @@
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { Git } from "@/git" import { Git } from "@/git"
import { Effect, Layer, Context } from "effect" import { Effect, Layer, Context } from "effect"
@@ -653,26 +653,4 @@ export namespace File {
Layer.provide(AppFileSystem.defaultLayer), Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Git.defaultLayer), Layer.provide(Git.defaultLayer),
) )
const { runPromise } = makeRuntime(Service, defaultLayer)
export function init() {
return runPromise((svc) => svc.init())
}
export async function status() {
return runPromise((svc) => svc.status())
}
export async function read(file: string): Promise<Content> {
return runPromise((svc) => svc.read(file))
}
export async function list(dir?: string) {
return runPromise((svc) => svc.list(dir))
}
export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) {
return runPromise((svc) => svc.search(input))
}
} }
+1 -15
View File
@@ -4,7 +4,7 @@ import { fileURLToPath } from "url"
import z from "zod" import z from "zod"
import { Cause, Context, Effect, Layer, Queue, Stream } from "effect" import { Cause, Context, Effect, Layer, Queue, Stream } from "effect"
import { ripgrep } from "ripgrep" import { ripgrep } from "ripgrep"
import { makeRuntime } from "@/effect/run-service"
import { Filesystem } from "@/util/filesystem" import { Filesystem } from "@/util/filesystem"
import { Log } from "@/util/log" import { Log } from "@/util/log"
@@ -572,18 +572,4 @@ export namespace Ripgrep {
) )
export const defaultLayer = layer export const defaultLayer = layer
const { runPromise } = makeRuntime(Service, defaultLayer)
export function files(input: FilesInput) {
return runPromise((svc) => Stream.toAsyncIterableEffect(svc.files(input)))
}
export function tree(input: TreeInput) {
return runPromise((svc) => svc.tree(input))
}
export function search(input: SearchInput) {
return runPromise((svc) => svc.search(input))
}
} }
+95 -138
View File
@@ -6,20 +6,8 @@ import path from "path"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
import { Ripgrep } from "../../src/file/ripgrep" import { Ripgrep } from "../../src/file/ripgrep"
async function seed(dir: string, count: number, size = 16) { const run = <A>(effect: Effect.Effect<A, unknown, Ripgrep.Service>) =>
const txt = "a".repeat(size) effect.pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromise)
await Promise.all(Array.from({ length: count }, (_, i) => Bun.write(path.join(dir, `file-${i}.txt`), `${txt}${i}\n`)))
}
function env(name: string, value: string | undefined) {
const prev = process.env[name]
if (value === undefined) delete process.env[name]
else process.env[name] = value
return () => {
if (prev === undefined) delete process.env[name]
else process.env[name] = prev
}
}
describe("file.ripgrep", () => { describe("file.ripgrep", () => {
test("defaults to include hidden", async () => { test("defaults to include hidden", async () => {
@@ -31,7 +19,14 @@ describe("file.ripgrep", () => {
}, },
}) })
const files = await Array.fromAsync(await Ripgrep.files({ cwd: tmp.path })) const files = await run(
Ripgrep.Service.use((rg) =>
rg.files({ cwd: tmp.path }).pipe(
Stream.runCollect,
Effect.map((c) => [...c]),
),
),
)
expect(files.includes("visible.txt")).toBe(true) expect(files.includes("visible.txt")).toBe(true)
expect(files.includes(path.join(".opencode", "thing.json"))).toBe(true) expect(files.includes(path.join(".opencode", "thing.json"))).toBe(true)
}) })
@@ -45,7 +40,14 @@ describe("file.ripgrep", () => {
}, },
}) })
const files = await Array.fromAsync(await Ripgrep.files({ cwd: tmp.path, hidden: false })) const files = await run(
Ripgrep.Service.use((rg) =>
rg.files({ cwd: tmp.path, hidden: false }).pipe(
Stream.runCollect,
Effect.map((c) => [...c]),
),
),
)
expect(files.includes("visible.txt")).toBe(true) expect(files.includes("visible.txt")).toBe(true)
expect(files.includes(path.join(".opencode", "thing.json"))).toBe(false) expect(files.includes(path.join(".opencode", "thing.json"))).toBe(false)
}) })
@@ -57,7 +59,7 @@ describe("file.ripgrep", () => {
}, },
}) })
const result = await Ripgrep.search({ cwd: tmp.path, pattern: "needle" }) const result = await run(Ripgrep.Service.use((rg) => rg.search({ cwd: tmp.path, pattern: "needle" })))
expect(result.partial).toBe(false) expect(result.partial).toBe(false)
expect(result.items).toEqual([]) expect(result.items).toEqual([])
}) })
@@ -70,7 +72,7 @@ describe("file.ripgrep", () => {
}, },
}) })
const result = await Ripgrep.search({ cwd: tmp.path, pattern: "needle" }) const result = await run(Ripgrep.Service.use((rg) => rg.search({ cwd: tmp.path, pattern: "needle" })))
expect(result.partial).toBe(false) expect(result.partial).toBe(false)
expect(result.items).toHaveLength(1) expect(result.items).toHaveLength(1)
expect(result.items[0]?.path.text).toBe(path.join("src", "match.ts")) expect(result.items[0]?.path.text).toBe(path.join("src", "match.ts"))
@@ -78,99 +80,7 @@ describe("file.ripgrep", () => {
expect(result.items[0]?.lines.text).toContain("needle") expect(result.items[0]?.lines.text).toContain("needle")
}) })
test("files returns empty when glob matches no files in worker mode", async () => { test("search returns matched rows with glob filter", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await fs.mkdir(path.join(dir, "packages", "console"), { recursive: true })
await Bun.write(path.join(dir, "packages", "console", "package.json"), "{}")
},
})
const ctl = new AbortController()
const files = await Array.fromAsync(
await Ripgrep.files({
cwd: tmp.path,
glob: ["packages/*"],
signal: ctl.signal,
}),
)
expect(files).toEqual([])
})
test("ignores RIPGREP_CONFIG_PATH in direct mode", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "match.ts"), "const needle = 1\n")
},
})
const restore = env("RIPGREP_CONFIG_PATH", path.join(tmp.path, "missing-ripgreprc"))
try {
const result = await Ripgrep.search({ cwd: tmp.path, pattern: "needle" })
expect(result.items).toHaveLength(1)
} finally {
restore()
}
})
test("ignores RIPGREP_CONFIG_PATH in worker mode", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "match.ts"), "const needle = 1\n")
},
})
const restore = env("RIPGREP_CONFIG_PATH", path.join(tmp.path, "missing-ripgreprc"))
try {
const ctl = new AbortController()
const result = await Ripgrep.search({
cwd: tmp.path,
pattern: "needle",
signal: ctl.signal,
})
expect(result.items).toHaveLength(1)
} finally {
restore()
}
})
test("aborts files scan in worker mode", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await seed(dir, 4000)
},
})
const ctl = new AbortController()
const iter = await Ripgrep.files({ cwd: tmp.path, signal: ctl.signal })
const pending = Array.fromAsync(iter)
setTimeout(() => ctl.abort(), 0)
const err = await pending.catch((err) => err)
expect(err).toBeInstanceOf(Error)
expect(err.name).toBe("AbortError")
}, 15_000)
test("aborts search in worker mode", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await seed(dir, 512, 64 * 1024)
},
})
const ctl = new AbortController()
const pending = Ripgrep.search({ cwd: tmp.path, pattern: "needle", signal: ctl.signal })
setTimeout(() => ctl.abort(), 0)
const err = await pending.catch((err) => err)
expect(err).toBeInstanceOf(Error)
expect(err.name).toBe("AbortError")
}, 15_000)
})
describe("Ripgrep.Service", () => {
test("search returns matched rows", async () => {
await using tmp = await tmpdir({ await using tmp = await tmpdir({
init: async (dir) => { init: async (dir) => {
await Bun.write(path.join(dir, "match.ts"), "const value = 'needle'\n") await Bun.write(path.join(dir, "match.ts"), "const value = 'needle'\n")
@@ -178,11 +88,9 @@ describe("Ripgrep.Service", () => {
}, },
}) })
const result = await Effect.gen(function* () { const result = await run(
const rg = yield* Ripgrep.Service Ripgrep.Service.use((rg) => rg.search({ cwd: tmp.path, pattern: "needle", glob: ["*.ts"] })),
return yield* rg.search({ cwd: tmp.path, pattern: "needle", glob: ["*.ts"] }) )
}).pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromise)
expect(result.partial).toBe(false) expect(result.partial).toBe(false)
expect(result.items).toHaveLength(1) expect(result.items).toHaveLength(1)
expect(result.items[0]?.path.text).toContain("match.ts") expect(result.items[0]?.path.text).toContain("match.ts")
@@ -198,16 +106,31 @@ describe("Ripgrep.Service", () => {
}) })
const file = path.join(tmp.path, "match.ts") const file = path.join(tmp.path, "match.ts")
const result = await Effect.gen(function* () { const result = await run(Ripgrep.Service.use((rg) => rg.search({ cwd: tmp.path, pattern: "needle", file: [file] })))
const rg = yield* Ripgrep.Service
return yield* rg.search({ cwd: tmp.path, pattern: "needle", file: [file] })
}).pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromise)
expect(result.partial).toBe(false) expect(result.partial).toBe(false)
expect(result.items).toHaveLength(1) expect(result.items).toHaveLength(1)
expect(result.items[0]?.path.text).toBe(file) expect(result.items[0]?.path.text).toBe(file)
}) })
test("files returns empty when glob matches no files", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await fs.mkdir(path.join(dir, "packages", "console"), { recursive: true })
await Bun.write(path.join(dir, "packages", "console", "package.json"), "{}")
},
})
const files = await run(
Ripgrep.Service.use((rg) =>
rg.files({ cwd: tmp.path, glob: ["packages/*"] }).pipe(
Stream.runCollect,
Effect.map((c) => [...c]),
),
),
)
expect(files).toEqual([])
})
test("files returns stream of filenames", async () => { test("files returns stream of filenames", async () => {
await using tmp = await tmpdir({ await using tmp = await tmpdir({
init: async (dir) => { init: async (dir) => {
@@ -216,14 +139,14 @@ describe("Ripgrep.Service", () => {
}, },
}) })
const files = await Effect.gen(function* () { const files = await run(
const rg = yield* Ripgrep.Service Ripgrep.Service.use((rg) =>
return yield* rg.files({ cwd: tmp.path }).pipe( rg.files({ cwd: tmp.path }).pipe(
Stream.runCollect, Stream.runCollect,
Effect.map((chunk) => [...chunk].sort()), Effect.map((c) => [...c].sort()),
),
),
) )
}).pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromise)
expect(files).toEqual(["a.txt", "b.txt"]) expect(files).toEqual(["a.txt", "b.txt"])
}) })
@@ -235,23 +158,57 @@ describe("Ripgrep.Service", () => {
}, },
}) })
const files = await Effect.gen(function* () { const files = await run(
const rg = yield* Ripgrep.Service Ripgrep.Service.use((rg) =>
return yield* rg.files({ cwd: tmp.path, glob: ["*.ts"] }).pipe( rg.files({ cwd: tmp.path, glob: ["*.ts"] }).pipe(
Stream.runCollect, Stream.runCollect,
Effect.map((chunk) => [...chunk]), Effect.map((c) => [...c]),
),
),
) )
}).pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromise)
expect(files).toEqual(["keep.ts"]) expect(files).toEqual(["keep.ts"])
}) })
test("files dies on nonexistent directory", async () => { test("files dies on nonexistent directory", async () => {
const exit = await Effect.gen(function* () { const exit = await Ripgrep.Service.use((rg) =>
const rg = yield* Ripgrep.Service rg.files({ cwd: "/tmp/nonexistent-dir-12345" }).pipe(Stream.runCollect),
return yield* rg.files({ cwd: "/tmp/nonexistent-dir-12345" }).pipe(Stream.runCollect) ).pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromiseExit)
}).pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromiseExit)
expect(exit._tag).toBe("Failure") expect(exit._tag).toBe("Failure")
}) })
test("ignores RIPGREP_CONFIG_PATH in direct mode", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "match.ts"), "const needle = 1\n")
},
})
const prev = process.env["RIPGREP_CONFIG_PATH"]
process.env["RIPGREP_CONFIG_PATH"] = path.join(tmp.path, "missing-ripgreprc")
try {
const result = await run(Ripgrep.Service.use((rg) => rg.search({ cwd: tmp.path, pattern: "needle" })))
expect(result.items).toHaveLength(1)
} finally {
if (prev === undefined) delete process.env["RIPGREP_CONFIG_PATH"]
else process.env["RIPGREP_CONFIG_PATH"] = prev
}
})
test("ignores RIPGREP_CONFIG_PATH in worker mode", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "match.ts"), "const needle = 1\n")
},
})
const prev = process.env["RIPGREP_CONFIG_PATH"]
process.env["RIPGREP_CONFIG_PATH"] = path.join(tmp.path, "missing-ripgreprc")
try {
const result = await run(Ripgrep.Service.use((rg) => rg.search({ cwd: tmp.path, pattern: "needle" })))
expect(result.items).toHaveLength(1)
} finally {
if (prev === undefined) delete process.env["RIPGREP_CONFIG_PATH"]
else process.env["RIPGREP_CONFIG_PATH"] = prev
}
})
}) })