Files
oh-my-opencode/src/tools/glob/cli.ts
T
ismeth 418cf8529f fix(glob): use cwd-relative search for ripgrep to fix directory prefix patterns
Ripgrep's --glob flag silently returns zero results when the search target
is an absolute path and the pattern contains directory prefixes (e.g.
'apps/backend/**/*.ts' with '/project'). This is a known ripgrep behavior
where glob matching fails against paths rooted at absolute arguments.

Fix by running ripgrep with cwd set to the search path and '.' as the
search target, matching how the find backend already operates. Ripgrep
then sees relative paths internally, so directory-prefixed globs match
correctly. Output paths are resolved back to absolute via resolve().
2026-02-28 13:26:30 +09:00

207 lines
5.5 KiB
TypeScript

import { resolve } from "node:path"
import { spawn } from "bun"
import {
resolveGrepCli,
type GrepBackend,
DEFAULT_TIMEOUT_MS,
DEFAULT_LIMIT,
DEFAULT_MAX_DEPTH,
DEFAULT_MAX_OUTPUT_BYTES,
RG_FILES_FLAGS,
DEFAULT_RG_THREADS,
} from "./constants"
import type { GlobOptions, GlobResult, FileMatch } from "./types"
import { stat } from "node:fs/promises"
import { rgSemaphore } from "../shared/semaphore"
export interface ResolvedCli {
path: string
backend: GrepBackend
}
function buildRgArgs(options: GlobOptions): string[] {
const args: string[] = [
...RG_FILES_FLAGS,
`--threads=${Math.min(options.threads ?? DEFAULT_RG_THREADS, DEFAULT_RG_THREADS)}`,
`--max-depth=${Math.min(options.maxDepth ?? DEFAULT_MAX_DEPTH, DEFAULT_MAX_DEPTH)}`,
]
if (options.hidden !== false) args.push("--hidden")
if (options.follow !== false) args.push("--follow")
if (options.noIgnore) args.push("--no-ignore")
args.push(`--glob=${options.pattern}`)
return args
}
function buildFindArgs(options: GlobOptions): string[] {
const args: string[] = []
if (options.follow !== false) {
args.push("-L")
}
args.push(".")
const maxDepth = Math.min(options.maxDepth ?? DEFAULT_MAX_DEPTH, DEFAULT_MAX_DEPTH)
args.push("-maxdepth", String(maxDepth))
args.push("-type", "f")
args.push("-name", options.pattern)
if (options.hidden === false) {
args.push("-not", "-path", "*/.*")
}
return args
}
function buildPowerShellCommand(options: GlobOptions): string[] {
const maxDepth = Math.min(options.maxDepth ?? DEFAULT_MAX_DEPTH, DEFAULT_MAX_DEPTH)
const paths = options.paths?.length ? options.paths : ["."]
const searchPath = paths[0] || "."
const escapedPath = searchPath.replace(/'/g, "''")
const escapedPattern = options.pattern.replace(/'/g, "''")
let psCommand = `Get-ChildItem -Path '${escapedPath}' -File -Recurse -Depth ${maxDepth - 1} -Filter '${escapedPattern}'`
if (options.hidden !== false) {
psCommand += " -Force"
}
// NOTE: Symlink following (-FollowSymlink) is NOT supported in PowerShell backend.
// -FollowSymlink was introduced in PowerShell Core 6.0+ and is unavailable in
// Windows PowerShell 5.1 (default on Windows). OpenCode auto-downloads ripgrep
// which handles symlinks via --follow. This fallback rarely triggers in practice.
psCommand += " -ErrorAction SilentlyContinue | Select-Object -ExpandProperty FullName"
return ["powershell", "-NoProfile", "-Command", psCommand]
}
async function getFileMtime(filePath: string): Promise<number> {
try {
const stats = await stat(filePath)
return stats.mtime.getTime()
} catch {
return 0
}
}
export { buildRgArgs, buildFindArgs, buildPowerShellCommand }
export async function runRgFiles(
options: GlobOptions,
resolvedCli?: ResolvedCli
): Promise<GlobResult> {
await rgSemaphore.acquire()
try {
return await runRgFilesInternal(options, resolvedCli)
} finally {
rgSemaphore.release()
}
}
async function runRgFilesInternal(
options: GlobOptions,
resolvedCli?: ResolvedCli
): Promise<GlobResult> {
const cli = resolvedCli ?? resolveGrepCli()
const timeout = Math.min(options.timeout ?? DEFAULT_TIMEOUT_MS, DEFAULT_TIMEOUT_MS)
const limit = Math.min(options.limit ?? DEFAULT_LIMIT, DEFAULT_LIMIT)
const isRg = cli.backend === "rg"
const isWindows = process.platform === "win32"
let command: string[]
let cwd: string | undefined
if (isRg) {
const args = buildRgArgs(options)
cwd = options.paths?.[0] || "."
args.push(".")
command = [cli.path, ...args]
} else if (isWindows) {
command = buildPowerShellCommand(options)
cwd = undefined
} else {
const args = buildFindArgs(options)
const paths = options.paths?.length ? options.paths : ["."]
cwd = paths[0] || "."
command = [cli.path, ...args]
}
const proc = spawn(command, {
stdout: "pipe",
stderr: "pipe",
cwd,
})
const timeoutPromise = new Promise<never>((_, reject) => {
const id = setTimeout(() => {
proc.kill()
reject(new Error(`Glob search timeout after ${timeout}ms`))
}, timeout)
proc.exited.then(() => clearTimeout(id))
})
try {
const stdout = await Promise.race([new Response(proc.stdout).text(), timeoutPromise])
const stderr = await new Response(proc.stderr).text()
const exitCode = await proc.exited
if (exitCode > 1 && stderr.trim()) {
return {
files: [],
totalFiles: 0,
truncated: false,
error: stderr.trim(),
}
}
const truncatedOutput = stdout.length >= DEFAULT_MAX_OUTPUT_BYTES
const outputToProcess = truncatedOutput ? stdout.substring(0, DEFAULT_MAX_OUTPUT_BYTES) : stdout
const lines = outputToProcess.trim().split("\n").filter(Boolean)
const files: FileMatch[] = []
let truncated = false
for (const line of lines) {
if (files.length >= limit) {
truncated = true
break
}
let filePath: string
if (isRg) {
filePath = cwd ? resolve(cwd, line) : line
} else if (isWindows) {
filePath = line.trim()
} else {
filePath = `${cwd}/${line}`
}
const mtime = await getFileMtime(filePath)
files.push({ path: filePath, mtime })
}
files.sort((a, b) => b.mtime - a.mtime)
return {
files,
totalFiles: files.length,
truncated: truncated || truncatedOutput,
}
} catch (e) {
return {
files: [],
totalFiles: 0,
truncated: false,
error: e instanceof Error ? e.message : String(e),
}
}
}