@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
133 lines • 5.92 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 } from "./pathspec.js";
import { IOResult } from "../../../../io/types.js";
import { FlagView } from "../../../spec/flag_view.js";
import { headEntries, workChanges } from "./changes.js";
import { isBare, requireWorkTree } from "./discover.js";
import { AmbiguousArgumentError, BareResetError, GitError, RevisionResetError } from "./errors.js";
import { readIndex, updateIndex } from "./index_file.js";
import { opened } from "./session.js";
import { resolveCommit } from "./revparse.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();
const UNSTAGED_HEADER = 'Unstaged changes after reset:';
/**
* Which fatal an operand that selected no path deserves.
*
* Two different mistakes reach here and git words them differently. A typo
* names neither a revision nor a path, and git calls that ambiguous. A revision
* names something real that this build cannot reset to, and answering "unknown
* revision" for a revision it can resolve perfectly well would send the caller
* looking for the wrong problem.
*/
async function unmatched(repo, operand) {
try {
await resolveCommit(repo, operand);
}
catch (err) {
if (err instanceof GitError)
return new AmbiguousArgumentError(operand);
throw err;
}
return new RevisionResetError(operand);
}
/**
* An index entry putting a path back to what HEAD records.
*
* The stat fields are zeroed for the same reason staging zeroes them: a mount
* serves none of them meaningfully, and git reads a zeroed entry as one whose
* cache it should not trust rather than as a corrupt one. The size is zero
* because the blob was never read; every reader here takes that as "not stated".
*/
export function restored(oid, mode) {
return { oid, mode, size: 0 };
}
/**
* Put the index back to what HEAD records, staging nothing.
*
* The working tree is never touched: this is `git reset` in its default mixed
* mode, which unstages. `--hard` is deliberately not offered, because it
* destroys uncommitted work and there is no reflog here to recover it from.
*
* A pathspec limits the reset to those paths, which is how a single file is
* unstaged.
*/
export async function reset(_accessor, paths, texts, ctx) {
const fl = new FlagView(ctx.flags);
const vfs = requireVfs(ctx);
checkSwitches(ctx, texts);
const repo = await opened(ctx, fl);
const named = fl.asPath('work_tree') !== undefined;
if (!named && (await isBare(vfs, repo.location)))
throw new BareResetError();
await requireWorkTree(vfs, repo.location, named);
const state = await readIndex(repo, vfs);
const tree = (await headEntries(repo)) ?? new Map();
const start = startPoint(fl).virtual;
const names = new Set([
...visibleEntries(repo.location, tree).keys(),
...visibleEntries(repo.location, state.entries).keys(),
]);
let selected;
if (texts.length > 0) {
selected = new Set();
for (const operand of texts) {
const hits = matched(names, repoRelative(repo.location, start, operand));
if (hits.size === 0)
throw await unmatched(repo, operand);
for (const path of hits)
selected.add(path);
}
}
else {
selected = names;
}
const staged = new Map();
const removed = [];
for (const name of selected) {
const recorded = tree.get(name);
if (recorded === undefined)
removed.push(name);
else
staged.set(name, restored(recorded.oid, Number.parseInt(recorded.mode, 8)));
}
if (texts.length === 0)
for (const item of visibleEntries(repo.location, state.conflicts).keys())
removed.push(item);
// The tree is walked before the index is written, so a work tree past its
// walk_limit leaves the index as it was.
const tracked = new Set(visibleEntries(repo.location, state.entries).keys());
for (const name of removed)
tracked.delete(name);
for (const name of staged.keys())
tracked.add(name);
const found = await scan(vfs, repo.location, tracked, UNTRACKED_NO);
await updateIndex(repo, staged, removed);
const after = await readIndex(repo, vfs);
const unstaged = await workChanges(repo, vfs, repo.location.worktree, visibleEntries(repo.location, after.entries), found);
if (unstaged.size === 0 || fl.asBool('quiet'))
return [null, new IOResult()];
const lines = [UNSTAGED_HEADER];
// Python is `sorted(unstaged.items())`, a tuple sort: path then letter.
// A bare .sort() here compared `${path},${letter}` instead, which orders
// `a+b` before `a` because ',' outranks '+'.
const entries = [...unstaged.entries()].sort((a, b) => compareCodePoints(a[0], b[0]) || compareCodePoints(a[1], b[1]));
for (const [path, letter] of entries)
lines.push(`${letter}\t${path}`);
return [ENC.encode(lines.map((line) => `${line}\n`).join('')), new IOResult()];
}
//# sourceMappingURL=reset.js.map