UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

354 lines • 17.2 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 { 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