UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

214 lines • 8.12 kB
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. ========= // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // ========= Copyright 2026 @ Strukto.AI All Rights Reserved. ========= import { readOptional } from "./io.js"; export const GITIGNORE = '.gitignore'; const INFO_EXCLUDE = 'info/exclude'; const DEC = new TextDecoder('utf-8', { fatal: false }); /** * Translate one gitignore pattern to a regular expression. * * The rules that matter, all of them gitignore's rather than fnmatch's: * `*` and `?` stop at a slash, `**` crosses them, a pattern holding a slash * anywhere but at its end is anchored to the file's own directory, and one that * does not is matched against any path component from the right. */ function translate(pattern, anchored) { let out = anchored ? '^' : '^(?:.*/)?'; let i = 0; while (i < pattern.length) { const char = pattern.charAt(i); if (char === '*') { // `/**/` collapses to "any number of directories", which is the one // spelling that may also match nothing at all. if (pattern.charAt(i + 1) === '*') { const before = i === 0 || pattern.charAt(i - 1) === '/'; const after = i + 2 >= pattern.length || pattern.charAt(i + 2) === '/'; if (before && after) { if (pattern.charAt(i + 2) === '/') { out += '(?:.*/)?'; i += 3; continue; } out += '.*'; i += 2; continue; } } out += '[^/]*'; i += 1; continue; } if (char === '?') { out += '[^/]'; i += 1; continue; } if (char === '[') { const close = pattern.indexOf(']', i + 1); if (close === -1) { out += '\\['; i += 1; continue; } let body = pattern.slice(i + 1, close); if (body.startsWith('!')) body = `^${body.slice(1)}`; out += `[${body}]`; i = close + 1; continue; } if (char === '\\' && i + 1 < pattern.length) { out += pattern.charAt(i + 1).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); i += 2; continue; } out += char.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); i += 1; } return out; } /** Parse one `.gitignore` file's contents into rules, in file order. */ function parseRules(text) { const rules = []; for (const raw of text.split('\n')) { // Trailing whitespace is not part of a pattern unless it was escaped, and a // blank or commented line is not a pattern at all. let line = raw.replace(/\r$/, '').replace(/(?<!\\)\s+$/, ''); if (line === '' || line.startsWith('#')) continue; const negated = line.startsWith('!'); if (negated) line = line.slice(1); const dirOnly = line.endsWith('/'); if (dirOnly) line = line.slice(0, -1); // A leading slash anchors without being part of the pattern; a slash // anywhere else anchors too, but stays. const leading = line.startsWith('/'); if (leading) line = line.slice(1); const anchored = leading || line.includes('/'); const body = translate(line, anchored); // Two patterns rather than one: a `build/` rule matches the directory // itself only when it IS a directory, but covers everything under it // whatever type that is, which is what makes it hide the whole subtree. rules.push({ self: new RegExp(`${body}$`), under: new RegExp(`${body}/`), negated, dirOnly, }); } return rules; } /** * One `.gitignore` file's verdict on a path: true, false, or null when no * pattern in it had anything to say. The last matching pattern wins, which is * how a `!` line un-ignores something the lines above it caught. */ function verdict(rules, path, isDir) { let answer = null; for (const rule of rules) { const own = rule.self.test(path) && (!rule.dirOnly || isDir); if (!own && !rule.under.test(path)) continue; answer = !rule.negated; } return answer; } /** * Spell a repository-relative path relative to a filter's directory. * * A `.gitignore` names paths from its own directory down, so the same path is * a different string to each filter on the stack. * * @param prefix the filter's directory, repository-relative, empty at the root * @param path repository-relative path being tested */ function relative(prefix, path) { return prefix === '' ? path : path.slice(prefix.length + 1); } /** * The `.gitignore` files governing one directory, innermost last. * * Immutable, and pushed rather than mutated, so a walk hands each subdirectory * its own stack and cannot forget to pop one on the way back up. * * Precedence runs the opposite way to the list: a deeper file wins over a * shallower one, so the search runs from the end. */ export class IgnoreStack { filters; constructor(filters = []) { this.filters = filters; } /** * A new stack with one more `.gitignore` on top. * * @param prefix the directory the file was found in, repository-relative * @param patterns the file's contents */ push(prefix, patterns) { return new IgnoreStack([...this.filters, [prefix, parseRules(DEC.decode(patterns))]]); } /** * Whether git would leave a path out of an untracked listing. * * @param path repository-relative path * @param isDir whether the path names a directory, which decides whether a * `build/` pattern can match it */ isIgnored(path, isDir = false) { for (let i = this.filters.length - 1; i >= 0; i--) { const held = this.filters[i]; if (held === undefined) continue; const [prefix, rules] = held; if (prefix !== '' && !path.startsWith(`${prefix}/`)) continue; const answer = verdict(rules, relative(prefix, path), isDir); if (answer !== null) return answer; } return false; } } /** * The root of the ignore stack: the repository's own two files. * * `.git/info/exclude` sits below the root `.gitignore` because it is the * repository's private list and a tracked `.gitignore` should be able to * override it. * * `core.excludesFile`, the per-user global list, is deliberately not read. It * names a path on whichever machine git ran on, and a mount has no way to reach * that machine's home directory; honoring it would mean reading the operator's * own file and applying it to somebody else's repository. * * @param vfs the session's files * @param commondir the shared git directory, whose `info` a linked worktree * reads too * @param worktree the working tree root */ export async function loadIgnores(vfs, commondir, worktree) { let stack = new IgnoreStack(); const private_ = await readOptional(vfs, commondir.join(INFO_EXCLUDE)); if (private_ !== null) stack = stack.push('', private_); const root = await readOptional(vfs, worktree.join(GITIGNORE)); if (root !== null) stack = stack.push('', root); return stack; } //# sourceMappingURL=ignore.js.map