118 lines
3.5 KiB
TypeScript
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}$`)
|
|
}
|