UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

324 lines • 14.4 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 { visiblePath, repoRelative, under as inside } from "./pathspec.js"; import { FileType } from "../../../../types.js"; import { IOResult } from "../../../../io/types.js"; import { posixPhrase } from "../../../../errors/posix.js"; import { isEisdir, isEnotdir, isMissingPath } from "../../../../errors/fs.js"; import { FlagView } from "../../../spec/flag_view.js"; import { MoveOverlapError, MoveRefusedError, NotADirectoryDestinationError, RenameFailedError, UsageError, } from "./errors.js"; import { readIndex, updateIndex } from "./index_file.js"; import { lstat, removeFile, renamePath } from "./io.js"; import { entryName } from "../../../../utils/remnants.js"; import { opened } from "./session.js"; import { checkSwitches, startPoint, verbUsage, requireVfs } from "./util.js"; import { compareCodePoints } from "../../../../utils/sort.js"; const ENC = new TextEncoder(); // git's own wording for each way a source can be refused, in the shape // `fatal: <reason>, source=<src>, destination=<dst>`. const BAD_SOURCE = 'bad source'; const INTO_ITSELF = 'can not move directory into itself'; const DESTINATION_EXISTS = 'destination exists'; const DESTINATION_ALREADY_EXISTS = 'destination already exists'; const SOURCE_DIRECTORY_EMPTY = 'source directory is empty'; const NOT_UNDER_VERSION_CONTROL = 'not under version control'; const MULTIPLE_SOURCES = 'multiple sources for the same target'; const CONFLICTED = 'conflicted'; /** Read the raw mv flag kwargs into a frozen struct. */ function parseFlags(fl) { const dryRun = fl.asBool('dry_run'); return { force: fl.asBool('force'), skip: fl.asBool('k'), dryRun, verbose: fl.asBool('verbose') || dryRun, }; } /** Where one tracked path lands after a move. */ export function movedPath(move, path) { if (!move.directory) return move.destination; return `${move.destination}${path.slice(move.source.length)}`; } /** * The first path in a move that the index left unmerged. * * Named the way the collision is named, by the path itself rather than by the * operand that carried it, which is what git reports for a directory holding * one. */ export function conflicting(move, conflicted) { for (const path of move.paths) { if (conflicted.has(path)) return [path, movedPath(move, path)]; } return null; } /** * The first path in a move that lands where an earlier one already does. * * Landings are compared one tracked path at a time rather than one operand at a * time, because a directory operand moves every path under it and two * directories sharing a child name collide there and nowhere else. git reports * that collision by the colliding path too, not by the operand that carried it. */ export function clashing(move, claimed) { for (const path of move.paths) { const landing = movedPath(move, path); if (claimed.has(landing)) return [path, landing]; } return null; } /** * The first source that sits inside another source in the same line. * * git reads this off the whole line once every source has passed its own * checks, which is why a source with a fault of its own is still refused for * that fault first, and why `-k` skipping a source takes it out of this * comparison too. The pair reported is the first directory source in operand * order and the first source under it, named child first whatever order the * line put them in. */ export function overlapping(moves) { for (const move of moves) { if (!move.directory) continue; for (const other of moves) { if (inside(other.source, move.source)) return [other.source, move.source]; } } return null; } /** * Whether one source can move, in git's own order of refusals. * * The index is read before the destination is looked at, which is git's order * and observable: a conflicted source is refused as conflicted even when the * destination is occupied, and a directory holding an unmerged path is refused * for that rather than for a destination that already exists. */ export async function check(vfs, location, source, destination, tracked, conflicted, force) { const info = await lstat(vfs, location.worktree.join(source)); if (info === null) return { reason: BAD_SOURCE, paths: [], directory: false }; if (destination === source || destination.startsWith(`${source}/`)) { return { reason: INTO_ITSELF, paths: [], directory: false }; } const landing = location.worktree.join(destination); if (info.type === FileType.DIRECTORY) { const held = [...tracked].filter((path) => inside(path, source)).sort(compareCodePoints); // Renaming a directory moves every child; filtering the index alone // would leave hidden children tracked at paths that no longer exist. if (held.some((path) => !visiblePath(location, path) || !visiblePath(location, destination + path.slice(source.length)))) { return { reason: posixPhrase('EACCES'), paths: [], directory: true }; } if (held.some((path) => conflicted.has(path))) { return { reason: CONFLICTED, paths: held, directory: true }; } if ((await lstat(vfs, landing)) !== null) { return { reason: DESTINATION_ALREADY_EXISTS, paths: [], directory: true }; } if (held.length === 0) return { reason: SOURCE_DIRECTORY_EMPTY, paths: [], directory: true }; return { reason: null, paths: held, directory: true }; } if (!tracked.has(source) || !visiblePath(location, source)) return { reason: NOT_UNDER_VERSION_CONTROL, paths: [], directory: false }; if (conflicted.has(source)) return { reason: CONFLICTED, paths: [source], directory: false }; const target = await lstat(vfs, landing); if (target !== null && (!force || target.type === FileType.DIRECTORY)) { return { reason: DESTINATION_EXISTS, paths: [], directory: false }; } return { reason: null, paths: [source], directory: false }; } /** * Whether renaming a path would leave a mount behind. * * A mount nested in the repository is served by another VFS, and the * rename op reaches only the backend holding the parent path: that backend * cannot see the child's keys, so it moves everything except them and the index * is then re-keyed onto files that never moved. The mount root itself is the * same problem one level up, since the table still points at the old prefix. * Neither is something the verb can repair afterwards, so both are refused * before anything moves. * * The destination is the same fault read from the other end, and it catches an * ordinary file the first two questions pass: the op is bound to the backend * serving the source, so a landing another mount serves is written into the * source's backend at a path it does not own. The file is then hidden behind * the other mount while the index names the new path, which is the same broken * pair one level down. */ function spanning(vfs, path, landing) { if (vfs.isMount(path) || vfs.submounts(path).length > 0) return true; const there = vfs.mountOf(landing); return there !== null && vfs.mountOf(path) !== there; } /** * Decide every move before making any, which is git's order too. * * The last operand is the destination. With several sources it has to be a * directory that exists; with one, an existing directory takes the source * under its own name and anything else is the new name. */ export async function plan(vfs, location, start, operands, tracked, conflicted, flags) { const destination = repoRelative(location, start, operands[operands.length - 1] ?? ''); const target = await lstat(vfs, location.worktree.join(destination)); const into = destination === '' || target?.type === FileType.DIRECTORY; if (operands.length > 2 && !into) throw new NotADirectoryDestinationError(destination); const moves = []; const claimed = new Set(); for (const operand of operands.slice(0, -1)) { const source = repoRelative(location, start, operand); const landing = into ? destination === '' ? entryName(source) : `${destination}/${entryName(source)}` : destination; const verdict = await check(vfs, location, source, landing, tracked, conflicted, flags.force); const move = { source, destination: landing, paths: verdict.paths, directory: verdict.directory, }; let reason = verdict.reason; let named = [source, landing]; if (reason === CONFLICTED) { // git names the unmerged path, which for a directory is one of the paths // inside rather than the operand. const found = conflicting(move, conflicted); if (found !== null) named = found; } if (reason === null) { // Last of the per-source refusals, which is git's order: a source with a // fault of its own is refused for that fault even when it also collides // with an earlier one. const clash = clashing(move, claimed); if (clash !== null) { reason = MULTIPLE_SOURCES; named = clash; } } if (reason === null && spanning(vfs, location.worktree.join(source), location.worktree.join(landing))) { // Last, after every check git itself makes, so a source git would refuse // anyway is refused in git's own words. `-k` skips it like any other // rename this source cannot survive. git has no word for a mount, // mirage's own boundary, so the refusal borrows the kernel's EBUSY for // a rename it will not perform. if (flags.skip) continue; throw new RenameFailedError(source, 'EBUSY'); } if (reason !== null) { if (flags.skip) continue; throw new MoveRefusedError(reason, named[0], named[1]); } for (const path of move.paths) claimed.add(movedPath(move, path)); moves.push(move); } // After the per-source loop rather than inside it, which is git's order and // observable twice over: a source with a fault of its own outranks this, and // so does a same-target collision anywhere on the line. `-k` does not reach // it, since an overlap is not a rename this source could survive being // skipped for: moving the directory first is what makes the other source // disappear. const overlap = overlapping(moves); if (overlap !== null) throw new MoveOverlapError(overlap[0], overlap[1]); return moves; } /** * Make one move in the working tree. * * The mount's own rename, so a directory carries its untracked files along. * Under `-f` a file already at the destination goes first, since not every * mount renames over one. */ async function apply(vfs, location, move, force) { const source = location.worktree.join(move.source); const destination = location.worktree.join(move.destination); if (force && !move.directory) await removeFile(vfs, destination); try { await renamePath(vfs, source, destination); } catch (err) { if (isMissingPath(err) || isEnotdir(err) || isEisdir(err)) throw new RenameFailedError(move.source); throw err; } } /** * Move or rename a file or directory, and stage the move. * * The index entries are re-keyed with their blob ids and modes untouched, which * is what lets `status` read the result as a rename rather than a delete beside * an add. */ export async function mv(_accessor, paths, texts, ctx) { const fl = new FlagView(ctx.flags); const lines = []; const vfs = requireVfs(ctx); checkSwitches(ctx, texts); const flags = parseFlags(fl); if (texts.length < 2) throw new UsageError('', verbUsage(ctx)); const repo = await opened(ctx, fl, true); const state = await readIndex(repo, vfs); const conflicted = new Set(state.conflicts.keys()); // An unmerged path holds no ordinary entry, so a tracked set built from the // entries alone would call it untracked and let a directory holding one // move with its stages left behind. const tracked = new Set([...state.entries.keys(), ...conflicted]); const moves = await plan(vfs, repo.location, startPoint(fl).virtual, texts, tracked, conflicted, flags); if (flags.dryRun) { for (const move of moves) { lines.push(`Checking rename of '${move.source}' to '${move.destination}'`); } } if (flags.verbose) { for (const move of moves) lines.push(`Renaming ${move.source} to ${move.destination}`); } if (!flags.dryRun) { const staged = new Map(); const removed = []; for (const move of moves) { await apply(vfs, repo.location, move, flags.force); for (const path of move.paths) { const entry = state.entries.get(path); if (entry === undefined) continue; removed.push(path); staged.set(movedPath(move, path), { oid: entry.oid, mode: entry.mode, size: entry.size }); } } await updateIndex(repo, staged, removed); } if (lines.length === 0) return [null, new IOResult()]; return [ENC.encode(lines.map((line) => `${line}\n`).join('')), new IOResult()]; } //# sourceMappingURL=mv.js.map