ripgrep.ts
1 import { findActualExecutable } from 'spawn-rx' 2 import { memoize } from 'lodash-es' 3 import { fileURLToPath, resolve } from 'node:url' 4 import * as path from 'path' 5 import { logError } from './log.js' 6 import { execFileNoThrow } from './execFileNoThrow.js' 7 import { execFile } from 'child_process' 8 import debug from 'debug' 9 10 const __filename = fileURLToPath(import.meta.url) 11 const __dirname = resolve( 12 __filename, 13 process.env.NODE_ENV === 'test' ? '../..' : '.', 14 ) 15 16 const d = debug('claude:ripgrep') 17 18 const useBuiltinRipgrep = !!process.env.USE_BUILTIN_RIPGREP 19 if (useBuiltinRipgrep) { 20 d('Using builtin ripgrep because USE_BUILTIN_RIPGREP is set') 21 } 22 23 const ripgrepPath = memoize(() => { 24 const { cmd } = findActualExecutable('rg', []) 25 d(`ripgrep initially resolved as: ${cmd}`) 26 27 if (cmd !== 'rg' && !useBuiltinRipgrep) { 28 // NB: If we're able to find ripgrep in $PATH, cmd will be an absolute 29 // path rather than just returning 'rg' 30 return cmd 31 } else { 32 // Use the one we ship in-box 33 const rgRoot = path.resolve(__dirname, 'vendor', 'ripgrep') 34 if (process.platform === 'win32') { 35 // NB: Ripgrep doesn't ship an aarch64 binary for Windows, boooooo 36 return path.resolve(rgRoot, 'x64-win32', 'rg.exe') 37 } 38 39 const ret = path.resolve( 40 rgRoot, 41 `${process.arch}-${process.platform}`, 42 'rg', 43 ) 44 45 d('internal ripgrep resolved as: %s', ret) 46 return ret 47 } 48 }) 49 50 export async function ripGrep( 51 args: string[], 52 target: string, 53 abortSignal: AbortSignal, 54 ): Promise<string[]> { 55 await codesignRipgrepIfNecessary() 56 const rg = ripgrepPath() 57 d('ripgrep called: %s %o', rg, target, args) 58 59 // NB: When running interactively, ripgrep does not require a path as its last 60 // argument, but when run non-interactively, it will hang unless a path or file 61 // pattern is provided 62 return new Promise(resolve => { 63 execFile( 64 ripgrepPath(), 65 [...args, target], 66 { 67 maxBuffer: 1_000_000, 68 signal: abortSignal, 69 timeout: 10_000, 70 }, 71 (error, stdout) => { 72 if (error) { 73 // Exit code 1 from ripgrep means "no matches found" - this is normal 74 if (error.code !== 1) { 75 d('ripgrep error: %o', error) 76 logError(error) 77 } 78 resolve([]) 79 } else { 80 d('ripgrep succeeded with %s', stdout) 81 resolve(stdout.trim().split('\n').filter(Boolean)) 82 } 83 }, 84 ) 85 }) 86 } 87 88 // NB: We do something tricky here. We know that ripgrep processes common 89 // ignore files for us, so we just ripgrep for any character, which matches 90 // all non-empty files 91 export async function listAllContentFiles( 92 path: string, 93 abortSignal: AbortSignal, 94 limit: number, 95 ): Promise<string[]> { 96 try { 97 d('listAllContentFiles called: %s', path) 98 return (await ripGrep(['-l', '.', path], path, abortSignal)).slice(0, limit) 99 } catch (e) { 100 d('listAllContentFiles failed: %o', e) 101 102 logError(e) 103 return [] 104 } 105 } 106 107 let alreadyDoneSignCheck = false 108 async function codesignRipgrepIfNecessary() { 109 if (process.platform !== 'darwin' || alreadyDoneSignCheck) { 110 return 111 } 112 113 alreadyDoneSignCheck = true 114 115 // First, check to see if ripgrep is already signed 116 d('checking if ripgrep is already signed') 117 const lines = ( 118 await execFileNoThrow( 119 'codesign', 120 ['-vv', '-d', ripgrepPath()], 121 undefined, 122 undefined, 123 false, 124 ) 125 ).stdout.split('\n') 126 127 const needsSigned = lines.find(line => line.includes('linker-signed')) 128 if (!needsSigned) { 129 d('seems to be already signed') 130 return 131 } 132 133 try { 134 d('signing ripgrep') 135 const signResult = await execFileNoThrow('codesign', [ 136 '--sign', 137 '-', 138 '--force', 139 '--preserve-metadata=entitlements,requirements,flags,runtime', 140 ripgrepPath(), 141 ]) 142 143 if (signResult.code !== 0) { 144 d('failed to sign ripgrep: %o', signResult) 145 logError( 146 `Failed to sign ripgrep: ${signResult.stdout} ${signResult.stderr}`, 147 ) 148 } 149 150 d('removing quarantine') 151 const quarantineResult = await execFileNoThrow('xattr', [ 152 '-d', 153 'com.apple.quarantine', 154 ripgrepPath(), 155 ]) 156 157 if (quarantineResult.code !== 0) { 158 d('failed to remove quarantine: %o', quarantineResult) 159 logError( 160 `Failed to remove quarantine: ${quarantineResult.stdout} ${quarantineResult.stderr}`, 161 ) 162 } 163 } catch (e) { 164 d('failed during sign: %o', e) 165 logError(e) 166 } 167 }