import * as path from "node:path"; import { Glob } from "bun"; import { getProjectDir } from "./dirs"; export interface GlobPathsOptions { /** Base directory for glob patterns. Defaults to getProjectDir(). */ cwd?: string; /** Glob exclusion patterns. */ exclude?: string[]; /** Abort signal to cancel the glob. */ signal?: AbortSignal; /** Timeout in milliseconds for the glob operation. */ timeoutMs?: number; /** Include dotfiles when true. */ dot?: boolean; /** Only return files (skip directories). Default: true. */ onlyFiles?: boolean; /** Respect .gitignore files when true. Walks up directory tree to find all applicable .gitignore files. */ gitignore?: boolean; } /** Patterns always excluded (.git is never useful in glob results). */ const ALWAYS_IGNORED = ["**/.git", "**/.git/**"]; /** node_modules exclusion patterns (skipped if pattern explicitly references node_modules). */ const NODE_MODULES_IGNORED = ["**/node_modules", "**/node_modules/**"]; /** * Parse a single .gitignore file and return glob-compatible exclude patterns. * @param content - Raw content of the .gitignore file * @param gitignoreDir - Absolute path to the directory containing the .gitignore * @param baseDir - Absolute path to the glob's cwd (for relativizing rooted patterns) */ function parseGitignorePatterns(content: string, gitignoreDir: string, baseDir: string): string[] { const patterns: string[] = []; for (const rawLine of content.split("\n")) { const line = rawLine.trim(); // Skip empty lines and comments if (!line || line.startsWith("#")) { continue; } // Skip negation patterns (unsupported for simple exclude) if (line.startsWith("!")) { continue; } let pattern = line; // Handle trailing slash (directory-only match) // For glob exclude, we treat it as matching the dir and its contents const isDirectoryOnly = pattern.endsWith("/"); if (isDirectoryOnly) { pattern = pattern.slice(0, -1); } // Handle rooted patterns (start with /) if (pattern.startsWith("/")) { // Rooted pattern: relative to the .gitignore location const absolutePattern = path.join(gitignoreDir, pattern.slice(1)); const relativeToBase = path.relative(baseDir, absolutePattern); if (relativeToBase.startsWith("..")) { // Pattern is outside the search directory, skip continue; } pattern = relativeToBase.replace(/\\/g, "/"); if (isDirectoryOnly) { patterns.push(pattern); patterns.push(`${pattern}/**`); } else { patterns.push(pattern); } } else if (pattern.includes("/") && !pattern.startsWith("**/")) { // Separator in the middle: git anchors these to the .gitignore's // directory, same as rooted patterns const absolutePattern = path.join(gitignoreDir, pattern); const relativeToBase = path.relative(baseDir, absolutePattern); if (relativeToBase.startsWith("..")) { // Pattern is outside the search directory, skip continue; } pattern = relativeToBase.replace(/\\/g, "/"); patterns.push(pattern); if (isDirectoryOnly) { patterns.push(`${pattern}/**`); } } else { // No middle separator: match file/dir name anywhere in the tree patterns.push(`**/${pattern}`); if (isDirectoryOnly) { patterns.push(`**/${pattern}/**`); } } } return patterns; } /** * Load .gitignore patterns from a directory and its parents. * Walks up the directory tree to find all applicable .gitignore files. * Returns glob-compatible exclude patterns. */ export async function loadGitignorePatterns(baseDir: string): Promise { const patterns: string[] = []; const absoluteBase = path.resolve(baseDir); let current = absoluteBase; const maxDepth = 50; // Prevent infinite loops for (let i = 0; i < maxDepth; i++) { const gitignorePath = path.join(current, ".gitignore"); try { const content = await Bun.file(gitignorePath).text(); const filePatterns = parseGitignorePatterns(content, current, absoluteBase); patterns.push(...filePatterns); } catch { // .gitignore doesn't exist or can't be read, continue } const parent = path.dirname(current); if (parent === current) { // Reached filesystem root break; } current = parent; } return patterns; } /** * Resolve filesystem paths matching glob patterns with optional exclude filters. * Returns paths relative to the provided cwd (or getProjectDir()). * Errors and abort/timeouts are surfaced to the caller. */ export async function globPaths(patterns: string | string[], options: GlobPathsOptions = {}): Promise { const { cwd, exclude, signal, timeoutMs, dot, onlyFiles = true, gitignore } = options; const timeoutSignal = timeoutMs ? AbortSignal.timeout(timeoutMs) : undefined; const combinedSignal = signal && timeoutSignal ? AbortSignal.any([signal, timeoutSignal]) : (signal ?? timeoutSignal); throwIfGlobAborted(combinedSignal); // Build exclude list: always exclude .git, exclude node_modules unless pattern references it const patternArray = Array.isArray(patterns) ? patterns : [patterns]; const mentionsNodeModules = patternArray.some(p => p.includes("node_modules")); const baseExclude = mentionsNodeModules ? [...ALWAYS_IGNORED] : [...ALWAYS_IGNORED, ...NODE_MODULES_IGNORED]; let effectiveExclude = exclude ? [...baseExclude, ...exclude] : baseExclude; if (gitignore) { const gitignorePatterns = await loadGitignorePatterns(cwd ?? getProjectDir()); throwIfGlobAborted(combinedSignal); effectiveExclude = [...effectiveExclude, ...gitignorePatterns]; } const excludeGlobs = effectiveExclude.map(pattern => new Glob(pattern)); const base = cwd ?? getProjectDir(); const allResults: string[] = []; // Overlapping patterns (e.g. `["**/*.ts", "src/*.ts"]`) can both match the same // file; dedupe so a path is returned at most once regardless of pattern overlap. const seen = new Set(); for (const pattern of patternArray) { const glob = new Glob(pattern); const scanOptions = { cwd: base, dot, onlyFiles, throwErrorOnBrokenSymlink: false, }; for await (const entry of glob.scan(scanOptions)) { throwIfGlobAborted(combinedSignal); // Check exclusion patterns const normalized = entry.replace(/\\/g, "/"); if (excludeGlobs.some(excludeGlob => excludeGlob.match(normalized))) { continue; } if (seen.has(normalized)) { continue; } seen.add(normalized); allResults.push(normalized); } throwIfGlobAborted(combinedSignal); } return allResults; } function throwIfGlobAborted(signal: AbortSignal | undefined): void { if (!signal?.aborted) return; if (signal.reason instanceof Error) throw signal.reason; throw new DOMException("Aborted", "AbortError"); }