/ utils / glob.ts
glob.ts
  1  import { basename, dirname, isAbsolute, join, sep } from 'path'
  2  import type { ToolPermissionContext } from '../Tool.js'
  3  import { isEnvTruthy } from './envUtils.js'
  4  import {
  5    getFileReadIgnorePatterns,
  6    normalizePatternsToPath,
  7  } from './permissions/filesystem.js'
  8  import { getPlatform } from './platform.js'
  9  import { getGlobExclusionsForPluginCache } from './plugins/orphanedPluginFilter.js'
 10  import { ripGrep } from './ripgrep.js'
 11  
 12  /**
 13   * Extracts the static base directory from a glob pattern.
 14   * The base directory is everything before the first glob special character (* ? [ {).
 15   * Returns the directory portion and the remaining relative pattern.
 16   */
 17  export function extractGlobBaseDirectory(pattern: string): {
 18    baseDir: string
 19    relativePattern: string
 20  } {
 21    // Find the first glob special character: *, ?, [, {
 22    const globChars = /[*?[{]/
 23    const match = pattern.match(globChars)
 24  
 25    if (!match || match.index === undefined) {
 26      // No glob characters - this is a literal path
 27      // Return the directory portion and filename as pattern
 28      const dir = dirname(pattern)
 29      const file = basename(pattern)
 30      return { baseDir: dir, relativePattern: file }
 31    }
 32  
 33    // Get everything before the first glob character
 34    const staticPrefix = pattern.slice(0, match.index)
 35  
 36    // Find the last path separator in the static prefix
 37    const lastSepIndex = Math.max(
 38      staticPrefix.lastIndexOf('/'),
 39      staticPrefix.lastIndexOf(sep),
 40    )
 41  
 42    if (lastSepIndex === -1) {
 43      // No path separator before the glob - pattern is relative to cwd
 44      return { baseDir: '', relativePattern: pattern }
 45    }
 46  
 47    let baseDir = staticPrefix.slice(0, lastSepIndex)
 48    const relativePattern = pattern.slice(lastSepIndex + 1)
 49  
 50    // Handle root directory patterns (e.g., /*.txt on Unix or C:/*.txt on Windows)
 51    // When lastSepIndex is 0, baseDir is empty but we need to use '/' as the root
 52    if (baseDir === '' && lastSepIndex === 0) {
 53      baseDir = '/'
 54    }
 55  
 56    // Handle Windows drive root paths (e.g., C:/*.txt)
 57    // 'C:' means "current directory on drive C" (relative), not root
 58    // We need 'C:/' or 'C:\' for the actual drive root
 59    if (getPlatform() === 'windows' && /^[A-Za-z]:$/.test(baseDir)) {
 60      baseDir = baseDir + sep
 61    }
 62  
 63    return { baseDir, relativePattern }
 64  }
 65  
 66  export async function glob(
 67    filePattern: string,
 68    cwd: string,
 69    { limit, offset }: { limit: number; offset: number },
 70    abortSignal: AbortSignal,
 71    toolPermissionContext: ToolPermissionContext,
 72  ): Promise<{ files: string[]; truncated: boolean }> {
 73    let searchDir = cwd
 74    let searchPattern = filePattern
 75  
 76    // Handle absolute paths by extracting the base directory and converting to relative pattern
 77    // ripgrep's --glob flag only works with relative patterns
 78    if (isAbsolute(filePattern)) {
 79      const { baseDir, relativePattern } = extractGlobBaseDirectory(filePattern)
 80      if (baseDir) {
 81        searchDir = baseDir
 82        searchPattern = relativePattern
 83      }
 84    }
 85  
 86    const ignorePatterns = normalizePatternsToPath(
 87      getFileReadIgnorePatterns(toolPermissionContext),
 88      searchDir,
 89    )
 90  
 91    // Use ripgrep for better memory performance
 92    // --files: list files instead of searching content
 93    // --glob: filter by pattern
 94    // --sort=modified: sort by modification time (oldest first)
 95    // --no-ignore: don't respect .gitignore (default true, set CLAUDE_CODE_GLOB_NO_IGNORE=false to respect .gitignore)
 96    // --hidden: include hidden files (default true, set CLAUDE_CODE_GLOB_HIDDEN=false to exclude)
 97    // Note: use || instead of ?? to treat empty string as unset (defaulting to true)
 98    const noIgnore = isEnvTruthy(process.env.CLAUDE_CODE_GLOB_NO_IGNORE || 'true')
 99    const hidden = isEnvTruthy(process.env.CLAUDE_CODE_GLOB_HIDDEN || 'true')
100    const args = [
101      '--files',
102      '--glob',
103      searchPattern,
104      '--sort=modified',
105      ...(noIgnore ? ['--no-ignore'] : []),
106      ...(hidden ? ['--hidden'] : []),
107    ]
108  
109    // Add ignore patterns
110    for (const pattern of ignorePatterns) {
111      args.push('--glob', `!${pattern}`)
112    }
113  
114    // Exclude orphaned plugin version directories
115    for (const exclusion of await getGlobExclusionsForPluginCache(searchDir)) {
116      args.push('--glob', exclusion)
117    }
118  
119    const allPaths = await ripGrep(args, searchDir, abortSignal)
120  
121    // ripgrep returns relative paths, convert to absolute
122    const absolutePaths = allPaths.map(p =>
123      isAbsolute(p) ? p : join(searchDir, p),
124    )
125  
126    const truncated = absolutePaths.length > offset + limit
127    const files = absolutePaths.slice(offset, offset + limit)
128  
129    return { files, truncated }
130  }