@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
220 lines • 9.99 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 { visibleEntries, matched, patternRoots, repoRelative } from "./pathspec.js";
import { FileType } from "../../../../types.js";
import { IOResult } from "../../../../io/types.js";
import { FlagView } from "../../../spec/flag_view.js";
import { DELETED, headEntries, MODIFIED, TYPE_CHANGED, workChanges } from "./changes.js";
import { NoPathspecRemoveError, NotRecursiveError, PathspecError, RemovalRefusedError, RemovePathError, } from "./errors.js";
import { readIndex, updateIndex } from "./index_file.js";
import { blockingAncestor, isLink, removeEmptyParents, removeFile, statOf } from "./io.js";
import { opened } from "./session.js";
import { checkSwitches, startPoint, requireVfs } from "./util.js";
import { scan, UNTRACKED_NO } from "./worktree.js";
import { compareCodePoints } from "../../../../utils/sort.js";
const ENC = new TextEncoder();
/** Read the raw rm flag kwargs into a frozen struct. */
function parseFlags(fl) {
return {
recursive: fl.asBool('r'),
cached: fl.asBool('cached'),
force: fl.asBool('force'),
quiet: fl.asBool('quiet'),
ignoreUnmatch: fl.asBool('ignore_unmatch'),
};
}
/**
* Which tracked paths the operands remove, in index order.
*
* Every operand is checked before anything is removed, which is git's order
* too: a line with one bad operand removes nothing. A directory is refused
* without `-r` rather than expanded, and an operand that names nothing tracked
* is a fatal unless `--ignore-unmatch` says otherwise. An untracked file is
* "nothing tracked": git has nothing to remove it from.
*/
export function select(location, start, operands, tracked, flags) {
const selected = new Set();
for (const operand of operands) {
const target = repoRelative(location, start, operand);
if (tracked.has(target)) {
selected.add(target);
continue;
}
const hits = matched(tracked, target);
if (hits.size === 0) {
if (flags.ignoreUnmatch)
continue;
throw new PathspecError(operand);
}
if (!flags.recursive)
throw new NotRecursiveError(operand);
for (const path of hits)
selected.add(path);
}
return [...selected].sort(compareCodePoints);
}
/**
* Which absent paths a link above them is only hiding.
*
* git lstats a tracked path to decide whether it still has local work to lose,
* and that lstat resolves every component above the file, so a symlink standing
* where a directory was does not hide the file: it points the check at whatever
* lies at the other end, which is some other file entirely and therefore a
* local change. The walk answers the opposite way on purpose, because git's own
* diff refuses to follow a leading link and reports the path deleted, which is
* why `git status` says `D` here and `git rm` still refuses. Nothing is
* compared: two files agreeing byte for byte are still two files, and git
* refuses that case too. Pinned against git 2.50.1.
*
* @param vfs the session's files
* @param worktree absolute virtual path of the working tree root
* @param paths the selected paths that hold an index entry
* @param missing what the walk said about each path
*/
export async function shadowed(vfs, worktree, paths, missing) {
const found = new Set();
for (const path of paths) {
if (missing.get(path) !== DELETED)
continue;
if ((await blockingAncestor(vfs, worktree, path)) === null)
continue;
if (await vfs.exists(worktree.join(path)))
found.add(path);
}
return found;
}
/**
* Refuse a removal that would throw away uncommitted work.
*
* git's own three-way test per path: whether the index differs from HEAD, and
* whether the working tree differs from the index. A path that differs both
* ways is refused outright; one that differs one way is refused unless
* `--cached` keeps the file. A file already gone from the working tree has no
* local change to lose, which is what makes `git rm <deleted>` the way to stage
* a deletion. Pinned against git 2.50.1.
*/
export async function refuseLostWork(repo, vfs, tree, entries, found, paths, cached) {
const chosen = new Map();
for (const path of paths) {
const entry = entries.get(path);
if (entry !== undefined)
chosen.set(path, entry);
}
const unstaged = await workChanges(repo, vfs, repo.location.worktree, chosen, found);
const hidden = await shadowed(vfs, repo.location.worktree, paths, unstaged);
const both = [];
const staged = [];
const local = [];
for (const [path, entry] of chosen) {
const recorded = tree.get(path);
const stagedChanges = recorded?.oid !== entry.oid || Number.parseInt(recorded.mode, 8) !== entry.mode;
const code = unstaged.get(path);
const localChanges = code === MODIFIED || code === TYPE_CHANGED || hidden.has(path);
if (localChanges && stagedChanges)
both.push(path);
else if (!cached) {
if (stagedChanges)
staged.push(path);
if (localChanges)
local.push(path);
}
}
if (both.length > 0 || staged.length > 0 || local.length > 0) {
throw new RemovalRefusedError(both, staged, local);
}
}
/**
* Delete every selected path from the working tree.
*
* A directory standing where a tracked file was is the one deletion a mount
* cannot make: `unlink` is not the call that empties a tree, and git reports
* the strerror rather than removing what it never tracked. It reports it in
* index order and only while nothing has been deleted yet; once one path is
* gone the rest of the loop tolerates a failure and the line still succeeds.
* That is git's own rule rather than a reading of it, and it is observable both
* ways: `git rm slot z.txt` is fatal with the index untouched, while
* `git rm a.txt slot` exits 0 with both entries unstaged and the directory
* still on disk. Pinned against git 2.50.1.
*
* A tracked symlink to a directory is not this case and must not be read as
* one: it is removed as the link it is, which is why the namespace is asked
* before the type.
*
* @param vfs the session's files
* @param worktree absolute virtual path of the working tree root
* @param selected the paths to delete, in index order
* @param lines the `rm` lines already printed, for a refusal to carry on stdout
* the way git prints them anyway
*/
export async function clearWorktree(vfs, worktree, selected, lines) {
let removed = false;
for (const path of selected) {
const absolute = worktree.join(path);
if (!(await isLink(vfs, absolute))) {
const info = await statOf(vfs, absolute);
if (info !== null && info.type === FileType.DIRECTORY) {
if (!removed)
throw new RemovePathError(path, lines);
continue;
}
}
await removeFile(vfs, absolute);
await removeEmptyParents(vfs, absolute, worktree);
removed = true;
}
}
/**
* Remove paths from the index, and from the working tree too.
*
* `--cached` leaves the file where it is and only stops tracking it. Without
* `-f` a path carrying uncommitted work is refused rather than deleted, which is
* the one check that makes the verb safe to hand an agent: there is no reflog
* here to recover a file from.
*/
export async function rm(_accessor, paths, texts, ctx) {
const fl = new FlagView(ctx.flags);
const vfs = requireVfs(ctx);
checkSwitches(ctx, texts);
const flags = parseFlags(fl);
if (texts.length === 0)
throw new NoPathspecRemoveError();
const repo = await opened(ctx, fl, !flags.cached);
const state = await readIndex(repo, vfs);
const tracked = new Set([
...visibleEntries(repo.location, state.entries).keys(),
...visibleEntries(repo.location, state.conflicts).keys(),
]);
const start = startPoint(fl).virtual;
const selected = select(repo.location, start, texts, tracked, flags);
if (!flags.force) {
const checkable = selected.filter((path) => state.entries.has(path));
const roots = patternRoots(texts.map((text) => repoRelative(repo.location, start, text)), true);
const found = await scan(vfs, repo.location, tracked, UNTRACKED_NO, false, roots);
const tree = (await headEntries(repo)) ?? new Map();
await refuseLostWork(repo, vfs, tree, state.entries, found, checkable, flags.cached);
}
const lines = flags.quiet ? '' : selected.map((path) => `rm '${path}'\n`).join('');
if (!flags.cached) {
await clearWorktree(vfs, repo.location.worktree, selected, lines);
}
// Last, because the deletions above can fail: git writes the index only
// once the working tree is done with, so a refused deletion leaves the
// entry staged exactly as it stood rather than staging a removal that
// never happened.
await updateIndex(repo, new Map(), selected);
if (flags.quiet)
return [null, new IOResult()];
return [ENC.encode(selected.map((path) => `rm '${path}'\n`).join('')), new IOResult()];
}
//# sourceMappingURL=rm.js.map