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:
@@ -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("/"),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user