UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

299 lines • 12.8 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 { FileType } from "../../../../types.js"; import { dispatchedReadTree } from "../../../builtin/generic_bind/files.js"; import { linkLocation, linkView, mountView } from "../../../builtin/utils/links.js"; import { treeWalk } from "../../../builtin/utils/tree.js"; import { isEisdir, isEnotdir, isMissingPath } from "../../../../errors/fs.js"; import { GIT_DIR } from "./constants.js"; import { WorkTreeLimitError } from "./errors.js"; import { under } from "./pathspec.js"; import { GITIGNORE, loadIgnores } from "./ignore.js"; import { readNames, readOptional } from "./io.js"; import { entryName } from "../../../../utils/remnants.js"; import { compareCodePoints } from "../../../../utils/sort.js"; import { callerSignal } from "../../../../context/session_context.js"; // git's three untracked modes. "normal" names an untracked directory once // instead of everything inside it, "all" names every file, and "no" leaves them // out and is the reason the mode is threaded into the walk rather than filtered // afterwards: not reporting them means not looking for them, which is the whole // saving. export const UNTRACKED_NO = 'no'; export const UNTRACKED_NORMAL = 'normal'; export const UNTRACKED_ALL = 'all'; /** * Every directory that holds a tracked path, at any depth. * * Answering "does this directory contain anything tracked" per directory would * rescan the index once per directory walked; the same question asked of a * prepared set is a lookup. */ function trackedDirectories(tracked) { const directories = new Set(); for (const path of tracked) { const parts = path.split('/').slice(0, -1); for (let depth = 0; depth < parts.length; depth++) { directories.add(parts.slice(0, depth + 1).join('/')); } } return directories; } /** One walk of the working tree, carrying what the walk needs. */ class Scanner { vfs; worktree; tracked; mode; showIgnored; roots; found = { files: new Map(), untracked: [], ignored: [], }; directories; mounts; counted = new Map(); readTree; treeReaddir; treeLstat; constructor(vfs, worktree, tracked, mode, showIgnored = false, roots = null) { this.vfs = vfs; this.worktree = worktree; this.tracked = tracked; this.mode = mode; this.showIgnored = showIgnored; this.roots = roots; this.directories = trackedDirectories(tracked); this.mounts = mountView(vfs, true); const links = linkView(vfs); this.readTree = dispatchedReadTree(vfs.dispatch, mountView(vfs), links === undefined ? null : (virtual) => linkLocation(links, virtual), true); [this.treeReaddir, this.treeLstat] = treeWalk((path, _limit, level) => this.subtree(path, level === true), (path) => vfs.readdir(path), (path) => vfs.stat(path, undefined, { nofollow: true }), true, callerSignal()); } /** * Whether the walk goes to a path: one a root names or holds, or a * directory above a root. */ reaches(relative) { return (this.roots === null || this.roots.some((root) => relative === root || under(relative, root) || under(root, relative))); } /** * Count one file against the mount serving it; past that mount's * `walkLimit` the scan fails with `WorkTreeLimitError`. A file a root * names costs nothing, as a file handed to grep does. */ count(relative) { if (this.roots?.includes(relative) === true) return; if (this.mounts?.walkLimit === undefined) return; const virtual = this.absolute(relative).virtual; const limit = this.mounts.walkLimit(virtual); const root = this.mounts.rootOf(virtual); const seen = this.counted.get(root) ?? 0; if (limit >= 0 && seen >= limit) throw new WorkTreeLimitError(limit); this.counted.set(root, seen + 1); } /** The virtual path a repository-relative path names. */ absolute(relative) { return relative === '' ? this.worktree : this.worktree.join(relative); } /** * A directory's whole subtree in one read, given up once it holds more * entries than files the walk may still count on its mount; with `level`, * its one level. */ subtree(path, level = false) { if (level) return this.readTree(path, undefined, true); if (this.mounts?.walkLimit === undefined) return this.readTree(path); const limit = this.mounts.walkLimit(path.virtual); if (limit < 0) return this.readTree(path); const spent = this.counted.get(this.mounts.rootOf(path.virtual)) ?? 0; return this.readTree(path, Math.max(limit - spent, 0) + 1); } /** * Whether the walk reads a directory's whole subtree, which a mount's * `readTree` may then answer in one call: any below the work tree's root, * whose own subtree holds the git directory the walk skips, at or under a * root. */ whole(relative) { return (relative !== '' && (this.roots === null || this.roots.some((root) => relative === root || under(relative, root)))); } /** A directory's entries, empty when it does not exist. */ async names(relative) { const path = this.absolute(relative); if (!this.whole(relative)) return readNames(this.vfs, path); try { return await this.treeReaddir(path); } catch (err) { if (isMissingPath(err) || isEnotdir(err) || isEisdir(err)) return []; throw err; } } /** * What the walk sees at one path, without following a link. */ async entryStat(relative) { try { return await this.treeLstat(this.absolute(relative)); } catch (err) { if (isMissingPath(err) || isEnotdir(err) || isEisdir(err)) return null; throw err; } } /** * Whether a directory holds anything git would call untracked. * * git lists a directory only when something inside it would be reported, so a * directory holding nothing but ignored files, or nothing at all, is not * mentioned. Stops at the first find, so it lists one directory at a time * rather than reading a subtree whole. */ async holdsAFile(relative, ignores, includeIgnored = false) { const rules = await this.descend(relative, ignores); for (const entry of await readNames(this.vfs, this.absolute(relative))) { const name = entryName(entry); if (name === '') continue; const child = relative === '' ? name : `${relative}/${name}`; const info = await this.entryStat(child); if (info === null) continue; const directory = info.type === FileType.DIRECTORY; if (!directory) this.count(child); if (!includeIgnored && rules.isIgnored(child, directory)) continue; if (!directory) return true; if (await this.holdsAFile(child, rules, includeIgnored)) return true; } return false; } /** The ignore rules inside a directory, given the ones outside. */ async descend(relative, ignores) { if (relative === '') return ignores; const local = await readOptional(this.vfs, this.absolute(relative).join(GITIGNORE)); return local === null ? ignores : ignores.push(relative, local); } /** * Decide what a subdirectory contributes, then walk it or not. * * An ignored directory is still walked when the index holds something inside * it. Ignore rules govern untracked files only, so a tracked file under an * ignored directory is still compared, and skipping the directory outright * would report it deleted. */ async visitDirectory(relative, ignored, ignores) { const holdsTracked = this.directories.has(relative); if (ignored) { if (holdsTracked || (this.showIgnored && this.mode === UNTRACKED_ALL)) await this.walk(relative, true, ignores); else if (this.showIgnored && this.mode !== UNTRACKED_NO && (await this.holdsAFile(relative, ignores, true))) this.found.ignored.push(`${relative}/`); return; } if (holdsTracked || this.mode === UNTRACKED_ALL) { await this.walk(relative, false, ignores); return; } if (this.showIgnored && this.mode === UNTRACKED_NORMAL) { const first = this.found.untracked.length, ignoredFirst = this.found.ignored.length; await this.walk(relative, false, ignores); if (this.found.untracked.length > first) this.found.untracked.splice(first, Infinity, `${relative}/`); else if (this.found.ignored.length > ignoredFirst) this.found.ignored.splice(ignoredFirst, Infinity, `${relative}/`); return; } if (this.mode === UNTRACKED_NORMAL && (await this.holdsAFile(relative, ignores))) { this.found.untracked.push(`${relative}/`); } } /** * Walk one directory, recording files and untracked entries. * * @param relative repository-relative path, empty at the root * @param ignored whether this directory is itself ignored, in which case * nothing inside it is reported untracked * @param ignores the rules governing its parent */ async walk(relative, ignored, ignores) { const rules = await this.descend(relative, ignores); const entries = (await this.names(relative)).sort(compareCodePoints); for (const entry of entries) { const name = entryName(entry); if (name === '' || (relative === '' && name === GIT_DIR)) continue; const child = relative === '' ? name : `${relative}/${name}`; if (!this.reaches(child)) continue; const info = await this.entryStat(child); if (info === null) continue; if (info.type === FileType.DIRECTORY) { await this.visitDirectory(child, ignored || rules.isIgnored(child, true), rules); continue; } this.count(child); this.found.files.set(child, info); if (this.tracked.has(child) || this.mode === UNTRACKED_NO) continue; if (!ignored && !rules.isIgnored(child, false)) this.found.untracked.push(child); else if (this.showIgnored) this.found.ignored.push(child); } } } /** * Walk a working tree once, for both halves of a status report. * * One walk answers two questions, which is why they are not asked separately: * which tracked files still exist and how big they are, and which untracked ones * git would mention. Statting each index path instead would miss every untracked * file, and listing untracked files alone would leave the deletions unfound. * * @param vfs the session's files * @param location the discovered repository * @param tracked repository-relative paths the index holds * @param mode which untracked files to report * @param showIgnored whether to report ignored files too * @param roots narrow the walk to these repository-relative paths and what * lies under them, for a verb whose pathspecs say what it reads; null walks * it all */ export async function scan(vfs, location, tracked, mode, showIgnored = false, roots = null) { const ignores = await loadIgnores(vfs, location.commondir, location.worktree); const scanner = new Scanner(vfs, location.worktree, tracked, mode, showIgnored, roots); await scanner.walk('', false, ignores); return scanner.found; } //# sourceMappingURL=worktree.js.map