summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorKit Langton <[email protected]>2026-04-13 19:26:50 -0400
committerGitHub <[email protected]>2026-04-13 19:26:50 -0400
commita06f40297b06e3ce39c0618f4347db34074003f7 (patch)
treea12ed52f93b301b2e989787d33127bfae233ff06
parent59c0fc28ee53b9e63381ebd1190bf35bd74353e5 (diff)
downloadopencode-a06f40297b06e3ce39c0618f4347db34074003f7.tar.gz
opencode-a06f40297b06e3ce39c0618f4347db34074003f7.zip
fix grep exact file path searches (#22356)
-rw-r--r--packages/opencode/src/file/ripgrep.ts6
-rw-r--r--packages/opencode/src/tool/glob.ts4
-rw-r--r--packages/opencode/src/tool/grep.ts12
-rw-r--r--packages/opencode/test/file/ripgrep.test.ts19
-rw-r--r--packages/opencode/test/tool/glob.test.ts81
-rw-r--r--packages/opencode/test/tool/grep.test.ts21
6 files changed, 139 insertions, 4 deletions
diff --git a/packages/opencode/src/file/ripgrep.ts b/packages/opencode/src/file/ripgrep.ts
index 81cd2bf0d..c77fbe321 100644
--- a/packages/opencode/src/file/ripgrep.ts
+++ b/packages/opencode/src/file/ripgrep.ts
@@ -330,6 +330,7 @@ export namespace Ripgrep {
glob?: string[]
limit?: number
follow?: boolean
+ file?: string[]
}) => Effect.Effect<{ items: Item[]; partial: boolean }, PlatformError | Error>
}
@@ -351,6 +352,7 @@ export namespace Ripgrep {
maxDepth?: number
limit?: number
pattern?: string
+ file?: string[]
}) {
const out = [yield* bin(), input.mode === "search" ? "--json" : "--files", "--glob=!.git/*"]
if (input.follow) out.push("--follow")
@@ -363,7 +365,7 @@ export namespace Ripgrep {
}
if (input.limit) out.push(`--max-count=${input.limit}`)
if (input.mode === "search") out.push("--no-messages")
- if (input.pattern) out.push("--", input.pattern)
+ if (input.pattern) out.push("--", input.pattern, ...(input.file ?? []))
return out
})
@@ -405,6 +407,7 @@ export namespace Ripgrep {
glob?: string[]
limit?: number
follow?: boolean
+ file?: string[]
}) {
return yield* Effect.scoped(
Effect.gen(function* () {
@@ -414,6 +417,7 @@ export namespace Ripgrep {
follow: input.follow,
limit: input.limit,
pattern: input.pattern,
+ file: input.file,
})
const handle = yield* spawner.spawn(
diff --git a/packages/opencode/src/tool/glob.ts b/packages/opencode/src/tool/glob.ts
index a3ff5aef7..ea0fbf013 100644
--- a/packages/opencode/src/tool/glob.ts
+++ b/packages/opencode/src/tool/glob.ts
@@ -40,6 +40,10 @@ export const GlobTool = Tool.define(
let search = params.path ?? Instance.directory
search = path.isAbsolute(search) ? search : path.resolve(Instance.directory, search)
+ const info = yield* fs.stat(search).pipe(Effect.catch(() => Effect.succeed(undefined)))
+ if (info?.type === "File") {
+ throw new Error(`glob path must be a directory: ${search}`)
+ }
yield* assertExternalDirectoryEffect(ctx, search, { kind: "directory" })
const limit = 100
diff --git a/packages/opencode/src/tool/grep.ts b/packages/opencode/src/tool/grep.ts
index 9b5143cec..10a8de917 100644
--- a/packages/opencode/src/tool/grep.ts
+++ b/packages/opencode/src/tool/grep.ts
@@ -51,19 +51,25 @@ export const GrepTool = Tool.define(
? (params.path ?? Instance.directory)
: path.join(Instance.directory, params.path ?? "."),
)
- yield* assertExternalDirectoryEffect(ctx, searchPath, { kind: "directory" })
+ const info = yield* fs.stat(searchPath).pipe(Effect.catch(() => Effect.succeed(undefined)))
+ const cwd = info?.type === "Directory" ? searchPath : path.dirname(searchPath)
+ const file = info?.type === "Directory" ? undefined : [searchPath]
+ yield* assertExternalDirectoryEffect(ctx, searchPath, {
+ kind: info?.type === "Directory" ? "directory" : "file",
+ })
const result = yield* rg.search({
- cwd: searchPath,
+ cwd,
pattern: params.pattern,
glob: params.include ? [params.include] : undefined,
+ file,
})
if (result.items.length === 0) return empty
const rows = result.items.map((item) => ({
path: AppFileSystem.resolve(
- path.isAbsolute(item.path.text) ? item.path.text : path.join(searchPath, item.path.text),
+ path.isAbsolute(item.path.text) ? item.path.text : path.join(cwd, item.path.text),
),
line: item.line_number,
text: item.lines.text,
diff --git a/packages/opencode/test/file/ripgrep.test.ts b/packages/opencode/test/file/ripgrep.test.ts
index 11d212a08..cdc3493bd 100644
--- a/packages/opencode/test/file/ripgrep.test.ts
+++ b/packages/opencode/test/file/ripgrep.test.ts
@@ -76,6 +76,25 @@ describe("Ripgrep.Service", () => {
expect(result.items[0]?.lines.text).toContain("needle")
})
+ test("search supports explicit file targets", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(path.join(dir, "match.ts"), "const value = 'needle'\n")
+ await Bun.write(path.join(dir, "skip.ts"), "const value = 'needle'\n")
+ },
+ })
+
+ const file = path.join(tmp.path, "match.ts")
+ const result = await Effect.gen(function* () {
+ 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.items).toHaveLength(1)
+ expect(result.items[0]?.path.text).toBe(file)
+ })
+
test("files returns stream of filenames", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
diff --git a/packages/opencode/test/tool/glob.test.ts b/packages/opencode/test/tool/glob.test.ts
new file mode 100644
index 000000000..092885ed1
--- /dev/null
+++ b/packages/opencode/test/tool/glob.test.ts
@@ -0,0 +1,81 @@
+import { describe, expect } from "bun:test"
+import path from "path"
+import { Cause, Effect, Exit, Layer } from "effect"
+import { GlobTool } from "../../src/tool/glob"
+import { SessionID, MessageID } from "../../src/session/schema"
+import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
+import { Ripgrep } from "../../src/file/ripgrep"
+import { AppFileSystem } from "../../src/filesystem"
+import { Truncate } from "../../src/tool/truncate"
+import { Agent } from "../../src/agent/agent"
+import { provideTmpdirInstance } from "../fixture/fixture"
+import { testEffect } from "../lib/effect"
+
+const it = testEffect(
+ Layer.mergeAll(
+ CrossSpawnSpawner.defaultLayer,
+ AppFileSystem.defaultLayer,
+ Ripgrep.defaultLayer,
+ Truncate.defaultLayer,
+ Agent.defaultLayer,
+ ),
+)
+
+const ctx = {
+ sessionID: SessionID.make("ses_test"),
+ messageID: MessageID.make(""),
+ callID: "",
+ agent: "build",
+ abort: AbortSignal.any([]),
+ messages: [],
+ metadata: () => Effect.void,
+ ask: () => Effect.void,
+}
+
+describe("tool.glob", () => {
+ it.live("matches files from a directory path", () =>
+ provideTmpdirInstance((dir) =>
+ Effect.gen(function* () {
+ yield* Effect.promise(() => Bun.write(path.join(dir, "a.ts"), "export const a = 1\n"))
+ yield* Effect.promise(() => Bun.write(path.join(dir, "b.txt"), "hello\n"))
+ const info = yield* GlobTool
+ const glob = yield* info.init()
+ const result = yield* glob.execute(
+ {
+ pattern: "*.ts",
+ path: dir,
+ },
+ ctx,
+ )
+ expect(result.metadata.count).toBe(1)
+ expect(result.output).toContain(path.join(dir, "a.ts"))
+ expect(result.output).not.toContain(path.join(dir, "b.txt"))
+ }),
+ ),
+ )
+
+ it.live("rejects exact file paths", () =>
+ provideTmpdirInstance((dir) =>
+ Effect.gen(function* () {
+ const file = path.join(dir, "a.ts")
+ yield* Effect.promise(() => Bun.write(file, "export const a = 1\n"))
+ const info = yield* GlobTool
+ const glob = yield* info.init()
+ const exit = yield* glob
+ .execute(
+ {
+ pattern: "*.ts",
+ path: file,
+ },
+ ctx,
+ )
+ .pipe(Effect.exit)
+ expect(Exit.isFailure(exit)).toBe(true)
+ if (Exit.isFailure(exit)) {
+ const err = Cause.squash(exit.cause)
+ expect(err instanceof Error ? err.message : String(err)).toContain("glob path must be a directory")
+ }
+ }),
+ ),
+ )
+})
diff --git a/packages/opencode/test/tool/grep.test.ts b/packages/opencode/test/tool/grep.test.ts
index 07ac231df..678aeee3d 100644
--- a/packages/opencode/test/tool/grep.test.ts
+++ b/packages/opencode/test/tool/grep.test.ts
@@ -90,4 +90,25 @@ describe("tool.grep", () => {
}),
),
)
+
+ it.live("supports exact file paths", () =>
+ provideTmpdirInstance((dir) =>
+ Effect.gen(function* () {
+ const file = path.join(dir, "test.txt")
+ yield* Effect.promise(() => Bun.write(file, "line1\nline2\nline3"))
+ const info = yield* GrepTool
+ const grep = yield* info.init()
+ const result = yield* grep.execute(
+ {
+ pattern: "line2",
+ path: file,
+ },
+ ctx,
+ )
+ expect(result.metadata.matches).toBe(1)
+ expect(result.output).toContain(file)
+ expect(result.output).toContain("Line 2: line2")
+ }),
+ ),
+ )
})