@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
178 lines • 7.9 kB
JavaScript
// ========= 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 { posixNormpath } from "../../../../utils/path.js";
import { byteView } from "../../../../shell/bytes.js";
import { fnmatch } from "../../../../utils/fnmatch.js";
import { EmptyPathspecError, OutsideRepositoryError, UnsupportedPathspecError } from "./errors.js";
import { rstripSlash } from "../../../../utils/slash.js";
import { readerOf } from "../../../../workspace/files.js";
const MAGIC = ':';
const SLASH = '/';
/**
* The virtual path a path operand names.
*
* Resolved against the directory git was told to run in, not the session's,
* because `-C` moves before anything else happens and a pathspec is read from
* where git ended up. Resolving against the session cwd instead would make
* `git -C /repo add letters.txt` reach for a file beside the shell rather than
* inside the repository.
*
* @param start absolute virtual path git is running in
* @param operand the operand as the user spelled it
*/
export function absoluteOperand(start, operand) {
if (operand.startsWith('/'))
return posixNormpath(operand);
return posixNormpath(`${start}/${operand}`);
}
/**
* A path operand as a repository-relative path.
*
* Empty string for the working tree root itself, which is what `git add .` from
* the top resolves to and means "everything". Git uses the work tree as the
* base when invoked from outside it, as with --git-dir and --work-tree.
*
* @param location the discovered repository
* @param start absolute virtual path git is running in
* @param operand the operand as the user spelled it
*/
export function repoRelative(location, start, operand) {
const root = rstripSlash(location.worktree.virtual) || '/';
const prefix = root.endsWith('/') ? root : `${root}/`;
const base = start === root || start.startsWith(prefix) ? start : root;
const absolute = absoluteOperand(base, operand);
if (absolute === root)
return '';
if (!absolute.startsWith(prefix))
throw new OutsideRepositoryError(operand, root);
return absolute.slice(prefix.length);
}
/**
* Whether a repository-relative path sits inside a directory. An empty
* directory is the working tree root, which everything is under.
*
* @param path repository-relative path
* @param directory repository-relative directory
*/
export function under(path, directory) {
return directory === '' || path.startsWith(`${directory}/`);
}
/**
* Every path a single operand selects: itself and its whole subtree.
*
* Both, not one or the other. A pathspec matches a path that equals it
* and a path it is a leading directory of, and the two are not exclusive
* as soon as the candidates come from more than one tree: restoring
* `slot` where the index holds the file `slot` and the source holds
* `slot/child` has to select both, or the file is deleted and the
* directory never written. git 2.50.1 replaces one with the other in
* either direction.
*
* @param paths the candidate paths, repository-relative
* @param target the operand, repository-relative
*/
export function matched(paths, target) {
return new Set([...paths].filter((path) => path === target || under(path, target)));
}
/**
* Pathspec operands as the repository-relative patterns they name.
*
* A trailing slash survives, because `docs/` names only what lies under a
* directory where `docs` also names a file of that name. An empty operand is
* git's own refusal, and magic (`:(top)`, `:!`) is refused as unsupported
* rather than matched as a path.
*
* @param location the discovered repository
* @param start absolute virtual path git is running in
* @param operands the pathspecs as typed
*/
export function pathspecPatterns(location, start, operands) {
return operands.map((operand) => {
if (operand === '')
throw new EmptyPathspecError();
if (operand.startsWith(MAGIC))
throw new UnsupportedPathspecError(operand);
const pattern = repoRelative(location, start, operand);
return pattern !== '' && operand.endsWith(SLASH) ? pattern + SLASH : pattern;
});
}
/**
* Whether a repository-relative path is one a pathspec names.
*
* git's default pathspec: a path it spells, a directory the path lies under
* (`docs` and `docs/` both name `docs/a.md`, only `docs` names a file `docs`),
* or a wildcard pattern matching the whole path. A wildcard crosses `/`, so
* `*.c` finds `sub/x.c`, and matches bytes, so `??.txt` is what names `é.txt`
* (pinned against git 2.54). A tree a diff does not descend into is also named
* by a pathspec inside it, which is how `diff-tree A -- dir/x` prints `dir`.
*
* @param path repository-relative path, surrogate-escaped
* @param patterns patterns from `pathspecPatterns`
* @param directory whether the path is a tree left undescended
*/
export function pathspecSelects(path, patterns, directory = false) {
return patterns.some((pattern) => selects(path, pattern, directory));
}
/**
* Where a walk has to go to see every path some patterns name: each
* pattern's leading literal part, the path it spells or the directory above
* its first wildcard. Null when one of them names the whole tree (`.`,
* `*.c`), or when there are none. `literal` says the operands are paths, as
* `git add` and `git rm` take them, so a wildcard is part of a name. Mirrors
* Python's `pattern_roots`.
*/
export function patternRoots(patterns, literal = false) {
const roots = [];
for (const pattern of patterns) {
let stem = pattern.endsWith(SLASH) ? pattern.slice(0, -1) : pattern;
const wild = literal
? []
: ['*', '?', '[', '\\'].map((mark) => stem.indexOf(mark)).filter((at) => at >= 0);
if (wild.length > 0) {
const head = stem.slice(0, Math.min(...wild));
stem = head.slice(0, Math.max(head.lastIndexOf(SLASH), 0));
}
if (stem === '')
return null;
roots.push(stem);
}
return roots.length > 0 ? roots : null;
}
/** Whether one pattern names a path; see `pathspecSelects`. */
function selects(path, pattern, directory) {
const stem = pattern.endsWith(SLASH) ? pattern.slice(0, -1) : pattern;
if (under(path, stem) || path === pattern)
return true;
if (directory && (path === stem || under(stem, path)))
return true;
return fnmatch(byteView(path), byteView(pattern));
}
/**
* Whether a repository entry belongs to the session's visible tree.
*
* For what git does to the index, never what it prints: a verb that
* stages, removes or resets acts only on the entries the session sees, and
* a hidden tracked file (absent from a worktree scan) is not taken for a
* deletion. What git prints it hands over as rows, which the command
* boundary filters.
*/
export function visiblePath(location, relative) {
const vfs = location.vfs;
return (vfs === undefined || vfs === null || readerOf(vfs).visible(location.worktree.join(relative)));
}
/** A session view of Git entries; the persistent mapping stays intact. */
export function visibleEntries(location, entries) {
return new Map([...entries].filter(([path]) => visiblePath(location, path)));
}
//# sourceMappingURL=pathspec.js.map