fix(glob): extract directory prefix from pattern for ripgrep compatibility

Ripgrep's --glob flag silently returns no results when the pattern
contains a directory prefix (e.g. 'apps/backend/**/*.ts'). Models
naturally write patterns this way when exploring unfamiliar codebases.

Extract the static directory segments from the pattern, append them to
the search path, and pass only the glob portion to ripgrep.
This commit is contained in:
ismeth
2026-02-28 01:12:22 +01:00
committed by YeonGyu-Kim
parent 2b70130c08
commit aa91c1fb19
4 changed files with 139 additions and 3 deletions
-1
View File
@@ -1161,7 +1161,6 @@ export class BackgroundManager {
properties: props as Record<string, unknown>,
findBySession: (id) => this.findBySession(id),
idleDeferralTimers: this.idleDeferralTimers,
recentlyCompactedSessions: this.recentlyCompactedSessions,
validateSessionHasOutput: (id) => this.validateSessionHasOutput(id),
checkSessionTodos: (id) => this.checkSessionTodos(id),
nudgeCouncilMemberIfNeeded: (task, sid) => this.nudgeCouncilMemberIfNeeded(task, sid),
@@ -0,0 +1,94 @@
import { describe, expect, test } from "bun:test"
import { extractDirectoryPrefix } from "./extract-directory-prefix"
describe("extractDirectoryPrefix", () => {
describe("#given a pattern with directory prefix before glob metacharacters", () => {
test("#then extracts the prefix and returns the remaining glob", () => {
expect(extractDirectoryPrefix("apps/backend/**/*.ts")).toEqual({
prefix: "apps/backend",
glob: "**/*.ts",
})
})
test("#then handles brace expansion after prefix", () => {
expect(extractDirectoryPrefix("apps/backend/**/*.{ts,js,json}")).toEqual({
prefix: "apps/backend",
glob: "**/*.{ts,js,json}",
})
})
test("#then handles single directory prefix", () => {
expect(extractDirectoryPrefix("src/**/*.tsx")).toEqual({
prefix: "src",
glob: "**/*.tsx",
})
})
test("#then handles deep prefix", () => {
expect(extractDirectoryPrefix("packages/core/src/**/*.ts")).toEqual({
prefix: "packages/core/src",
glob: "**/*.ts",
})
})
test("#then handles brace expansion in the first glob segment", () => {
expect(extractDirectoryPrefix("src/{hooks,components}/**/*.tsx")).toEqual({
prefix: "src",
glob: "{hooks,components}/**/*.tsx",
})
})
test("#then handles question mark metacharacter", () => {
expect(extractDirectoryPrefix("src/component?/**/*.ts")).toEqual({
prefix: "src",
glob: "component?/**/*.ts",
})
})
test("#then handles bracket metacharacter", () => {
expect(extractDirectoryPrefix("src/[a-z]omponents/**/*.ts")).toEqual({
prefix: "src",
glob: "[a-z]omponents/**/*.ts",
})
})
})
describe("#given a pattern without directory prefix", () => {
test("#then returns empty prefix for **/ patterns", () => {
expect(extractDirectoryPrefix("**/*.ts")).toEqual({
prefix: "",
glob: "**/*.ts",
})
})
test("#then returns empty prefix for simple wildcard", () => {
expect(extractDirectoryPrefix("*.ts")).toEqual({
prefix: "",
glob: "*.ts",
})
})
test("#then returns empty prefix for brace-only pattern", () => {
expect(extractDirectoryPrefix("*.{ts,js}")).toEqual({
prefix: "",
glob: "*.{ts,js}",
})
})
})
describe("#given a fully literal pattern (no metacharacters)", () => {
test("#then returns the pattern as-is with empty prefix", () => {
expect(extractDirectoryPrefix("src/components/Button.tsx")).toEqual({
prefix: "",
glob: "src/components/Button.tsx",
})
})
test("#then handles single filename", () => {
expect(extractDirectoryPrefix("package.json")).toEqual({
prefix: "",
glob: "package.json",
})
})
})
})
@@ -0,0 +1,40 @@
const GLOB_META = /[*?[\]{]/
/**
* Splits a glob pattern into a static directory prefix and a glob-only portion.
*
* Ripgrep's `--glob` flag doesn't match directory prefixes in patterns.
* For example, `rg --files --glob='apps/backend/**\/*.ts' /project` returns nothing
* even though files exist under `/project/apps/backend/`.
*
* This function extracts the leading literal path segments so the caller can
* append them to the search path and pass only the glob portion to ripgrep.
*
* @example
* extractDirectoryPrefix("apps/backend/**\/*.ts")
* // { prefix: "apps/backend", glob: "**\/*.ts" }
*
* extractDirectoryPrefix("**\/*.ts")
* // { prefix: "", glob: "**\/*.ts" }
*
* extractDirectoryPrefix("src/{hooks,components}/**\/*.tsx")
* // { prefix: "src", glob: "{hooks,components}/**\/*.tsx" }
*/
export function extractDirectoryPrefix(pattern: string): { prefix: string; glob: string } {
const segments = pattern.split("/")
let splitIndex = 0
for (const segment of segments) {
if (GLOB_META.test(segment)) break
splitIndex++
}
if (splitIndex === 0 || splitIndex === segments.length) {
return { prefix: "", glob: pattern }
}
return {
prefix: segments.slice(0, splitIndex).join("/"),
glob: segments.slice(splitIndex).join("/"),
}
}
+5 -2
View File
@@ -4,6 +4,7 @@ import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
import { runRgFiles } from "./cli"
import { resolveGrepCliWithAutoInstall } from "./constants"
import { formatGlobResult } from "./result-formatter"
import { extractDirectoryPrefix } from "./extract-directory-prefix"
export function createGlobTools(ctx: PluginInput): Record<string, ToolDefinition> {
const glob: ToolDefinition = tool({
@@ -29,11 +30,13 @@ export function createGlobTools(ctx: PluginInput): Record<string, ToolDefinition
const runtimeCtx = context as Record<string, unknown>
const dir = typeof runtimeCtx.directory === "string" ? runtimeCtx.directory : ctx.directory
const searchPath = args.path ? resolve(dir, args.path) : dir
const paths = [searchPath]
const { prefix, glob: effectivePattern } = extractDirectoryPrefix(args.pattern)
const effectivePath = prefix ? resolve(searchPath, prefix) : searchPath
const paths = [effectivePath]
const result = await runRgFiles(
{
pattern: args.pattern,
pattern: effectivePattern,
paths,
},
cli