import { spawnSync } from 'child_process' import { existsSync, readFileSync } from 'fs' import { homedir } from 'os' import { join } from 'path' const ALWAYS_EXCLUDE = [ 'node_modules', '.sandlot', '.DS_Store', '.git', '.toes', '*~', ] const DEFAULT_PATTERNS = [ ...ALWAYS_EXCLUDE, '*.log', 'dist', 'build', '.env.local', 'bun.lockb', ] export interface GitignoreChecker { shouldExclude: (path: string) => boolean } export function loadGitignore(appPath: string): GitignoreChecker { const gitignorePath = join(appPath, '.gitignore') const globalPatterns = loadGlobalPatterns() const patterns = existsSync(gitignorePath) ? [...ALWAYS_EXCLUDE, ...globalPatterns, ...parseGitignore(readFileSync(gitignorePath, 'utf-8'))] : [...DEFAULT_PATTERNS, ...globalPatterns] return { shouldExclude: (path: string) => matchesPattern(path, patterns), } } export const _resetGlobalCache = () => { cachedGlobalPatterns = undefined } let cachedGlobalPatterns: string[] | undefined const expandHome = (filepath: string) => filepath.startsWith('~/') ? join(homedir(), filepath.slice(2)) : filepath function getGlobalExcludesPath(): string | null { const result = spawnSync('git', ['config', '--global', 'core.excludesFile'], { encoding: 'utf-8', }) if (result.status === 0 && result.stdout.trim()) { return expandHome(result.stdout.trim()) } const xdgHome = process.env.XDG_CONFIG_HOME || join(homedir(), '.config') const defaultPath = join(xdgHome, 'git', 'ignore') if (existsSync(defaultPath)) return defaultPath return null } function loadGlobalPatterns(): string[] { if (cachedGlobalPatterns !== undefined) return cachedGlobalPatterns const path = getGlobalExcludesPath() cachedGlobalPatterns = path && existsSync(path) ? parseGitignore(readFileSync(path, 'utf-8')) : [] return cachedGlobalPatterns } function parseGitignore(content: string): string[] { return content .split('\n') .map(line => line.trim()) .filter(line => line && !line.startsWith('#')) } function matchesPattern(path: string, patterns: string[]): boolean { const parts = path.split('/') const filename = parts[parts.length - 1]! for (const pattern of patterns) { // Patterns with wildcards or globs if (pattern.includes('*') || pattern.includes('?')) { const regex = globToRegex(pattern) // Try matching against full path and just filename if (regex.test(path) || regex.test(filename)) return true continue } // Directory match: "node_modules" or "node_modules/" const dirPattern = pattern.endsWith('/') ? pattern.slice(0, -1) : pattern if (parts.includes(dirPattern)) return true // Prefix patterns: "dist/" or "build/" if (pattern.endsWith('/') && path.startsWith(pattern)) return true // Exact match if (path === pattern || filename === pattern) return true } return false } function globToRegex(glob: string): RegExp { // Handle ** specially - it can match zero or more path segments // Use placeholders to avoid conflicts with regex escaping let pattern = glob .replace(/\*\*\//g, '\x00') // **/ placeholder .replace(/\*\*/g, '\x01') // ** placeholder .replace(/\./g, '\\.') // Escape dots .replace(/\*/g, '[^/]*') // Single * matches within path segment .replace(/\?/g, '[^/]') // ? matches single char .replace(/\x00/g, '(.*/)?') // **/ matches zero or more dirs .replace(/\x01/g, '.*') // ** matches anything return new RegExp(`^${pattern}$`) }