// `rg` inside a local box, run by git rather than by a JavaScript matcher. // // just-bash implements rg in JavaScript and reads every file body through the // plane, so one search of this repo is ~15,000 plane reads. MEASURED in the box // shell: 14,093ms and two lines of output, which are its abort message. It does // not return results at all, and searching is the most common thing an agent // does, so that is the box being unusable on a real project. // // WHY GIT GREP AND NOT RIPGREP. The first version of this shelled out to `rg`, // which turned CI red: the Linux runners have no ripgrep binary, and a box that // depends on whatever happens to be installed on the host is not a box. Git is // different in kind, not degree: `CheckoutFilePlane` already REFUSES to open a // project that is not a git repository and runs `git ls-files` before every // single command, so git is a hard, already-enforced requirement. Searching // with it adds no dependency and cannot be absent where the box already runs. // // It also makes the file set correct BY CONSTRUCTION rather than by agreement. // The box indexes `git ls-files -co --exclude-standard`, tracked AND // untracked-not-ignored, and `git grep --untracked` applies those same rules // from the same tool. RAN against a checkout built to expose the difference: // tracked, untracked-not-ignored, a dotfile and a dotdir file all match, and // gitignored trees do not. Ripgrep needed the plane's path list piped into it // to get there, because plain `rg .` silently misses dotfiles and `rg --hidden` // searches .git/**. // // MEASURED on this repo, 14,949 indexed files: 391ms p50 against ripgrep's // 260ms and just-bash's 14,093ms. Giving up 130ms to remove a host dependency // and an entire class of "works on my machine" is the right trade. // // It also removes the ARG_MAX chunking the ripgrep version needed. Git walks // its own index, so there is one process and one exit code rather than several // whose "no match here" had to be merged. import { spawn } from 'node:child_process' import { defineCommand, type CustomCommand } from '@rnx/box/shell' import { PROJECT_MOUNT } from '@rnx/box/shell' // generous next to a 414ms p95: this bounds a wedged child, it does not cut off // a large search. const SEARCH_DEADLINE_MS = 20_000 /** * THE DEADLINE SETTLES THIS PROMISE ITSELF rather than only killing the child. * A spawned child's answer has been observed going missing while the process * stayed alive, with no one able to say which event never arrived, so nothing * that waits on a child event can be trusted to settle. A timer in this process * cannot be missed, so it is the authority and the kill is a side effect. * See docs/testing.md. */ function runGitGrep(args: string[], cwd: string) { return new Promise<{ stdout: string; stderr: string; exitCode: number }>((resolve) => { const child = spawn('git', args, { cwd, env: process.env, stdio: ['ignore', 'pipe', 'pipe'], }) let stdout = '' let stderr = '' let settled = false const finish = (exitCode: number) => { if (settled) return settled = true clearTimeout(timer) resolve({ stdout, stderr, exitCode }) } const timer = setTimeout(() => { try { child.kill('SIGKILL') } catch {} stderr += `rg: search timed out after ${SEARCH_DEADLINE_MS}ms\n` finish(2) }, SEARCH_DEADLINE_MS) child.stdout.on('data', (chunk) => { stdout += String(chunk) }) child.stderr.on('data', (chunk) => { stderr += String(chunk) }) child.once('error', (error) => { stderr += `rg: ${error.message}\n` finish(2) }) child.once('close', (code) => finish(code ?? 0)) }) } // the rg flags worth honouring, mapped onto git grep's spelling. anything else // is REFUSED BY NAME rather than dropped, because a search that silently // ignores `-i` returns a confidently wrong answer. const FLAG_MAP: Record = { '-i': '-i', '--ignore-case': '-i', '-l': '-l', '--files-with-matches': '-l', '-w': '-w', '--word-regexp': '-w', '-c': '-c', '--count': '-c', '-v': '-v', '--invert-match': '-v', '-F': '-F', '--fixed-strings': '-F', } export function createSearchCommand(options: { root: string }): CustomCommand { return defineCommand('rg', async (args) => { const flags: string[] = [] const bare: string[] = [] for (const arg of args) { if (!arg.startsWith('-')) { bare.push(arg) continue } const mapped = FLAG_MAP[arg] if (!mapped) { return { stdout: '', stderr: `rg: this box does not support ${arg}\n`, exitCode: 2, } } flags.push(mapped) } const pattern = bare.shift() if (pattern === undefined) { return { stdout: '', stderr: 'rg: no pattern given\n', exitCode: 2 } } // paths are project-relative to git; strip the shell's mount prefix so // `rg x /project/src` and `rg x src` mean the same thing. const scopes = bare.map((p) => p.startsWith(`${PROJECT_MOUNT}/`) ? p.slice(PROJECT_MOUNT.length + 1) : p, ) // --untracked is what makes this the plane's set rather than git's tracked // set. -E for extended regex, the closest of git's grammars to ripgrep's. // -e so a pattern beginning with a dash is a pattern, not a flag. const result = await runGitGrep( [ 'grep', '--no-color', '-n', '-I', '--untracked', '-E', ...flags, '-e', pattern, ...(scopes.length > 0 ? ['--', ...scopes] : []), ], options.root, ) return result }) }