/**
* Ignore — .overlayignore file parser + default deny-list
*
* Gitignore-style pattern matching:
* - Lines starting with # are comments
* - Blank lines are skipped
* - Patterns ending with / match directories only
* - * matches anything except /
* - ** matches anything including /
* - ! negates a pattern (re-include)
* - Leading / anchors to the root
*
* Default deny-list is always active (security patterns).
* .overlayignore in a sync dir adds per-directory rules.
*/
import fs from '#fs'
import path from '#path'
// Always blocked — security-critical patterns
const DENY_LIST = [
'.env', '.env.*',
'.ssh', '.ssh/**',
'.gnupg', '.gnupg/**',
'*.key', '*.pem', '*.p12', '*.pfx',
'credentials*', '*secret*',
'.git', '.git/**',
'node_modules', 'node_modules/**',
'.DS_Store', 'Thumbs.db',
'*.overlay-partial',
'.overlayignore'
]
/**
* Load ignore patterns from a directory's .overlayignore file
* @param {string} dir - directory to check for .overlayignore
* @returns {string[]} patterns from the file (empty if no file)
*/
export function loadIgnoreFile (dir) {
const filePath = path.join(dir, '.overlayignore')
try {
const content = fs.readFileSync(filePath, 'utf-8')
return parseIgnoreFile(content)
} catch {
return []
}
}
/**
* Parse ignore file content into patterns
* @param {string} content
* @returns {string[]}
*/
export function parseIgnoreFile (content) {
return content
.split('\n')
.map(line => line.trim())
.filter(line => line && !line.startsWith('#'))
}
/**
* Create a matcher function from patterns
* @param {string[]} extraPatterns - user patterns from .overlayignore
* @param {object} [opts]
* @param {boolean} [opts.noDenyList] - if true, skip the security-deny list
* (publish-time use case: caller already handles node_modules/.git via
* rootSkip and may want to publish .env.example intentionally).
* @returns {(filePath: string, baseName: string) => boolean} returns true if file should be ignored
*/
export function createMatcher (extraPatterns = [], opts = {}) {
const patterns = opts.noDenyList
? [...extraPatterns]
: [...DENY_LIST, ...extraPatterns]
const negations = []
const positives = []
for (const p of patterns) {
if (p.startsWith('!')) {
negations.push(p.slice(1))
} else {
positives.push(p)
}
}
return function isIgnored (filePath, baseName) {
baseName = baseName || path.basename(filePath)
// Check negations first — if explicitly included, allow it
for (const pattern of negations) {
if (matchPattern(baseName, pattern) || matchPattern(filePath, pattern)) {
return false
}
}
// Check positive patterns
for (const pattern of positives) {
if (matchPattern(baseName, pattern) || matchPattern(filePath, pattern)) {
return true
}
}
return false
}
}
/**
* Simple glob pattern matching
* Supports: *, **, ?, literal characters
* @param {string} str - string to test
* @param {string} pattern - glob pattern
* @returns {boolean}
*/
function matchPattern (str, pattern) {
// Remove trailing / (directory marker — we treat all as paths)
pattern = pattern.replace(/\/$/, '')
// Exact match
if (str === pattern) return true
// Convert glob to regex
let regex = ''
let i = 0
while (i < pattern.length) {
const c = pattern[i]
if (c === '*') {
if (pattern[i + 1] === '*') {
regex += '.*' // ** matches everything
i += 2
if (pattern[i] === '/') i++ // skip separator after **
continue
}
regex += '[^/]*' // * matches non-separator
} else if (c === '?') {
regex += '[^/]'
} else if (c === '.') {
regex += '\\.'
} else {
regex += c
}
i++
}
try {
return new RegExp('^' + regex + '$').test(str)
} catch {
return false
}
}
/**
* Get the default deny list
* @returns {string[]}
*/
export function getDefaultDenyList () {
return [...DENY_LIST]
}