@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
354 lines • 17.2 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, repoRelative, under as inside } from "./pathspec.js";
import git from 'isomorphic-git';
import { IOResult } from "../../../../io/types.js";
import { FileType } from "../../../../types.js";
import { FlagView } from "../../../spec/flag_view.js";
import { headEntries } from "./changes.js";
import { GITLINK_MODE, HEAD } from "./constants.js";
import { GitError, NoRestorePathsError, UnknownPathspecError, UnmergedPathError, UnreadableTreeError, UnresolvableSourceError, } from "./errors.js";
import { readIndex, updateIndex } from "./index_file.js";
import { blockingAncestor, dropGitlink, isLink, keepGitlink, readNames, readOptional, refuseReplacedMounts, removeEmptyParents, removeFile, removeTree, restoreEntry, statOf, } from "./io.js";
import { repoArgs } from "./repo.js";
import { BRANCH_PREFIX, loadRefs, SYMREF_PREFIX } from "./refs.js";
import { opened } from "./session.js";
import { restored } from "./reset.js";
import { COMMIT, TREE, resolveObject, unwrapped } from "./revparse.js";
import { checkRules, ruledUnder } from "./seen.js";
import { treeEntries } from "./tree.js";
import { checkSwitches, startPoint, requireVfs } from "./util.js";
import { entryName } from "../../../../utils/remnants.js";
import { rstripSlash } from "../../../../utils/slash.js";
import { compareCodePoints } from "../../../../utils/sort.js";
const ENC = new TextEncoder();
/** Read the raw restore flag kwargs into a frozen struct. */
function parseFlags(fl) {
const staged = fl.asBool('staged');
return { staged, worktree: fl.asBool('worktree') || !staged, source: fl.asStr('source') };
}
/** The index read as a tree: every path with its mode and blob id. */
export function indexTree(entries) {
const out = new Map();
for (const [path, entry] of entries)
out.set(path, { oid: entry.oid, mode: entry.mode.toString(8) });
return out;
}
/**
* Every path a `--source` names, commit-ish or tree-ish.
*
* git takes any tree-ish here, so a raw tree id
* (`--source=$(git rev-parse HEAD^{tree})`) is as good as a branch. A
* commit-ish is tried first because it is what the option is normally spelled
* with and it is the only form carrying ancestry suffixes; a revision neither
* reading resolves is unresolvable.
*/
export async function sourceTree(repo, revision) {
let found;
try {
found = await resolveObject(repo, revision);
}
catch (err) {
if (err instanceof GitError)
throw new UnresolvableSourceError(revision);
throw err;
}
// A local branch of that name wins over every other reading, as it does for
// checkout's own operand: `--source=main` reads the branch even where a tag
// `main` is what rev-parse would answer (pinned against git 2.50.1).
const refs = await loadRefs(repo.vfs, repo.location.gitdir, repo.location.commondir);
const branch = refs.get(`${BRANCH_PREFIX}${revision}`);
if (branch !== undefined && !branch.startsWith(SYMREF_PREFIX)) {
found = { oid: branch, type: COMMIT };
}
// A bare id names the object itself, so an annotated tag arrives as the tag
// rather than as what it points at. A tag is no tree-ish, and unwrapping it
// is what makes `--source=<tag-id>` read the same tree `--source=v1` reads.
found = await unwrapped(repo, found, revision);
// git's one implicit peel: a commit stands for its tree here.
const tree = found.type === COMMIT
? (await git.readCommit({ ...repoArgs(repo), oid: found.oid })).commit.tree
: found.oid;
if (found.type !== COMMIT && found.type !== TREE)
throw new UnreadableTreeError(found.oid);
return [tree, await treeEntries(repo, tree)];
}
/**
* Put paths back to what a source records.
*
* Two targets and one source, git's own model. `--staged` restores the index
* and `--worktree` the working tree; the default is the working tree alone. The
* source is the index for the working tree and HEAD for the index unless
* `--source` names a tree, in which case a selected path the source does not
* hold is removed from whichever target is being restored, since that is what
* "make it match the source" means for it. Pinned against git 2.50.1.
*/
export async function restore(_accessor, paths, texts, ctx) {
const fl = new FlagView(ctx.flags);
let notes = [];
checkSwitches(ctx, texts);
if (texts.length === 0)
throw new NoRestorePathsError();
const flags = parseFlags(fl);
const repo = await opened(ctx, fl, true);
let source;
if (flags.source !== undefined) {
;
[, source] = await sourceTree(repo, flags.source);
}
else if (flags.staged) {
// Before the first commit there is no HEAD to restore the index from, and
// reading that as an empty tree unstaged every selected path and reported
// success. git refuses the whole line instead, index untouched. The
// working tree restores from the index, so it is only the implicit HEAD
// source that has nothing to read: `--source` naming a tree still works
// in the same repository.
const found = await headEntries(repo);
if (found === null)
throw new UnresolvableSourceError(HEAD);
source = found;
}
else {
source = null;
}
;
[notes] = await restorePaths(ctx, repo, texts, startPoint(fl).virtual, source, flags.staged, flags.worktree, false);
const told = notes.join('');
return [null, told === '' ? new IOResult() : new IOResult({ stderr: ENC.encode(told) })];
}
/**
* Ask the running command's rules about everything the working-tree pass writes
* or removes, before anything is: each entry restored or dropped, a
* non-directory standing in a restored entry's way, and every name under a
* directory a restored file replaces.
*
* @param vfs the session's files
* @param location the discovered repository
* @param replacing the entries written
* @param dropped the entries removed
* @throws PolicyDenied a rule refuses one of them
*/
export async function refuseRuled(vfs, location, replacing, dropped) {
if (!ruledUnder(location))
return;
for (const name of [...replacing, ...dropped])
checkRules(location, name);
for (const name of replacing) {
const above = await blockingAncestor(vfs, location.worktree, name);
if (above !== null) {
checkRules(location, relative(location, above));
continue;
}
// Only a directory the file replaces is removed whole, and only one a rule
// could reach inside is walked.
const where = location.worktree.join(name);
if (!ruledUnder(location, name) || (await isLink(vfs, where)))
continue;
const info = await statOf(vfs, where);
if (info !== null && info.type === FileType.DIRECTORY)
await refuseRuledUnder(vfs, location, where);
}
}
/**
* Ask the rules about every name under a directory the restore removes whole,
* walked as `removeTree` walks it: a link is not descended into, a file lists
* as nothing below it, and a name no rule could reach at or under is passed
* over.
*
* @param vfs the session's files
* @param location the discovered repository
* @param path the directory, or whatever stands there
*/
async function refuseRuledUnder(vfs, location, path) {
for (const entry of await readNames(vfs, path)) {
const name = entryName(entry);
if (name === '')
continue;
const child = path.join(name);
const place = relative(location, child);
if (!ruledUnder(location, place))
continue;
checkRules(location, place);
if (!(await isLink(vfs, child)))
await refuseRuledUnder(vfs, location, child);
}
}
/**
* A working-tree path, repository-relative.
*
* @param location the discovered repository
* @param path an absolute virtual path inside the work tree
*/
function relative(location, path) {
return path.virtual.slice(rstripSlash(location.worktree.virtual).length + 1);
}
/**
* Put selected paths back to what a source records: the work `restore` and
* `checkout`'s path form share.
*
* Under `overlay`, which is checkout's reading, a pathspec has to match the
* source and a path the index holds that the source does not is left as it
* is; without it, restore's own reading, such a path is removed.
*
* @param ctx the line's context, whose files git writes
* @param repo the opened repository
* @param operands the pathspecs as typed
* @param start the directory the line runs in
* @param source the tree to restore from, null for the index
* @param staged put the index back
* @param worktree put the working tree back
* @param overlay checkout's reading of the pathspecs
* @returns the warnings git prints, and how many working-tree files changed
*/
export async function restorePaths(ctx, repo, operands, start, source, staged, worktree, overlay) {
const vfs = requireVfs(ctx);
const notes = [];
let updated = 0;
const state = await readIndex(repo, vfs);
const held = indexTree(state.entries);
const tree = source ?? held;
// The conflict stages name paths too. An unmerged path has no stage-0
// entry, so neither the index nor HEAD carries it and the pathspec would
// miss what git matches: to git it is an index entry like any other.
// Selecting it is what lets a source holding it put it back, stages and
// all, and what lets the refusal below name it when none does.
const names = overlay && source !== null
? new Set(visibleEntries(repo.location, tree).keys())
: new Set([
...visibleEntries(repo.location, held).keys(),
...visibleEntries(repo.location, tree).keys(),
...visibleEntries(repo.location, state.conflicts).keys(),
]);
const selected = new Set();
for (const operand of operands) {
const hits = matched(names, repoRelative(repo.location, start, operand));
if (hits.size === 0)
throw new UnknownPathspecError(operand);
for (const path of hits)
selected.add(path);
}
const present = [...selected].filter((name) => tree.has(name)).sort(compareCodePoints);
const absent = [...selected].filter((name) => !tree.has(name)).sort(compareCodePoints);
// A selected path the source does not hold and the index still holds
// stages for cannot be restored either way: there is no stage-0 content to
// write into the working tree and no entry to stage. git names every one of
// them and does none of the work, where an absent path with no stages is
// simply removed.
const unmerged = absent.filter((name) => state.conflicts.has(name));
if (unmerged.length > 0)
throw new UnmergedPathError(unmerged);
// Before the index is written, not at the entry that meets it: `-SW`
// stages first and restores after, so a refusal in the working-tree pass
// would leave the index moved and the tree exactly as it was, which is the
// one outcome this verb has no wording for.
// A gitlink is not written into the working tree at all, so it is neither
// read as a blob nor allowed to clear what stands at the name; keepGitlink
// is the whole of what the entry asks for, and the preflight has nothing
// to say about it either.
const replacing = present.filter((name) => tree.get(name)?.mode !== GITLINK_MODE);
// A gitlink's directory is not this verb's to empty either. The entry is a
// placeholder for a repository mirage cannot read, so git writes the
// directory and leaves every path under it alone: a child the source drops
// loses its index entry and keeps its working-tree copy, edits included.
// Removing it here is the one loss nothing can undo, since the content was
// never staged. Pinned against git 2.50.1.
const linked = present.filter((name) => tree.get(name)?.mode === GITLINK_MODE);
const dropped = absent.filter((name) => !linked.some((root) => inside(name, root)));
if (worktree) {
await refuseReplacedMounts(vfs, repo.location.worktree, replacing);
// A rule refusing anything the working-tree pass writes or removes is
// asked here too, so its refusal leaves the index as it was.
await refuseRuled(vfs, repo.location, replacing, dropped);
}
if (staged) {
const staged = new Map();
for (const name of present) {
const entry = tree.get(name);
if (entry !== undefined)
staged.set(name, restored(entry.oid, Number.parseInt(entry.mode, 8)));
}
await updateIndex(repo, staged, absent);
}
if (worktree) {
// Removals first, because the two sets can name the same place:
// restoring a directory over a file writes `slot/child` where the
// file `slot` still sits, and the other direction writes the file
// where the directory still sits. Nothing is read back from the
// working tree, so emptying it first is free.
for (const name of dropped) {
const path = repo.location.worktree.join(name);
// A component above the entry that is not a directory is not a way
// through to it: the unlink would resolve past it and delete a file
// inside whatever it points at, which no branch named. git checks the
// leading path and removes nothing when it finds one, so neither does
// this.
const blocked = await blockingAncestor(vfs, repo.location.worktree, name);
if (blocked !== null)
continue;
// What stands at a gitlink is a directory, so taking it away is an
// rmdir that may legitimately fail: unlink died on it with the index
// already written, which is the half-restore this verb has no wording
// for.
if (held.get(name)?.mode === GITLINK_MODE) {
const warned = await dropGitlink(vfs, path, name);
if (warned !== null)
notes.push(warned);
}
else {
await removeFile(vfs, path);
}
await removeEmptyParents(vfs, path, repo.location.worktree);
}
for (const name of present) {
const entry = tree.get(name);
if (entry === undefined)
continue;
const where = repo.location.worktree.join(name);
if (entry.mode === GITLINK_MODE) {
await keepGitlink(vfs, where);
continue;
}
const { blob } = await git.readBlob({ ...repoArgs(repo), oid: entry.oid });
// The write direction takes the same component the other way round:
// the entry needs a directory where it stands, so git replaces it with
// one rather than writing through it. A link's target tree is left
// exactly as it was, and an untracked file standing there is replaced
// in silence, which is what git does to any leading non-directory.
const above = await blockingAncestor(vfs, repo.location.worktree, name);
if (above !== null)
await removeFile(vfs, above);
// A directory can still stand here after the loop above: it removed
// the tracked children, but an untracked one keeps it alive and the
// write would fail on it with the index already updated. git replaces
// the whole directory, untracked children included. A link is left to
// restoreEntry, which retargets it; following one to a directory here
// would delete a tree no branch named.
let current = null;
if (!(await isLink(vfs, where))) {
const info = await statOf(vfs, where);
if (info !== null && info.type === FileType.DIRECTORY) {
await removeTree(vfs, where);
}
else if (info !== null && above === null) {
current = await readOptional(vfs, where);
}
}
if (current?.length !== blob.length || current.some((byte, at) => byte !== blob[at]))
updated += 1;
await restoreEntry(vfs, where, entry.mode, blob);
}
}
return [notes, updated];
}
//# sourceMappingURL=restore.js.map