toes/src/shared/gitignore.ts

118 lines
3.5 KiB
TypeScript

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}$`)
}