@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
490 lines • 21.3 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 { FileType } from "../../../../types.js";
import git from 'isomorphic-git';
import { fsStrerror, isEacces, isEisdir, isEnotdir, isMissingPath } from "../../../../errors/fs.js";
import { AmbiguousArgumentError, UnhashableFileError } from "./errors.js";
import { entryMode } from "./add.js";
import { EXECUTABLE_MODE, GITLINK_MODE, REGULAR_MODE, SYMLINK, SYMLINK_MODE } from "./constants.js";
import { readIndex } from "./index_file.js";
import { entryBytes } from "./io.js";
import { visibleEntries } from "./pathspec.js";
import { readBlobBytes } from "./repo.js";
import { resolveCommit } from "./revparse.js";
import { similarityScore } from "./similarity.js";
import { commitEntries } from "./tree.js";
import { scan, UNTRACKED_NO } from "./worktree.js";
import { compareCodePoints } from "../../../../utils/sort.js";
const UNCHANGED = ' ';
export const MODIFIED = 'M';
export const ADDED = 'A';
export const DELETED = 'D';
export const TYPE_CHANGED = 'T';
const RENAMED = 'R';
const UNTRACKED = '?';
const IGNORED = '!';
// git's own two rename knobs: a pair counts as a rename at 60% shared content,
// and the search is abandoned entirely once the add-by-delete matrix would
// exceed 200 by 200. Matching both is what makes mirage give up exactly where
// git gives up rather than answer differently.
const RENAME_THRESHOLD = 60;
const MAX_RENAME_FILES = 200;
// A regular file, as the mode's type bits spell it. Only these are scored for
// similarity: a symlink is paired only with another symlink holding the same
// bytes, since scoring one against a file would pair two unrelated things by
// the bytes of a path.
// What a mode says a path is, for pairing within one kind. Only the executable
// bit separates the two regular modes, and git renames across it.
const SYMLINK_KIND = 'symlink';
const REGULAR_KIND = 'regular';
// git spells an unmerged path by which of the three index stages it kept, keyed
// here as (ancestor, ours, theirs). The pair is the porcelain XY, and the long
// format's label follows from it.
const CONFLICT_CODES = {
'true,true,true': 'UU',
'true,true,false': 'UD',
'true,false,true': 'DU',
'true,false,false': 'DD',
'false,true,true': 'AA',
'false,true,false': 'AU',
'false,false,true': 'UA',
};
function isRegular(mode) {
return mode === REGULAR_MODE || mode === EXECUTABLE_MODE;
}
/** What a mode makes a path, for a rename pair that must not cross kinds. */
export function kindOf(mode) {
if (isRegular(mode))
return REGULAR_KIND;
return mode === SYMLINK_MODE ? SYMLINK_KIND : mode;
}
/**
* Every path HEAD's tree holds, with its mode and blob id.
*
* Null rather than an empty map when HEAD resolves to nothing: a repository
* before its first commit is a different thing from one whose commit is empty,
* and git says so ("No commits yet").
*
* Resolved through the revision parser so a HEAD detached onto a tag peels to
* the commit it names, rather than being read as a tree it does not have.
*/
export async function headEntries(repo) {
let head;
try {
head = await resolveCommit(repo, 'HEAD');
}
catch (err) {
if (err instanceof AmbiguousArgumentError)
return null;
throw err;
}
return commitEntries(repo, head);
}
/**
* Pair an add with a delete holding byte-identical content.
*
* Costs a map rather than a read, so it runs first and takes every pair it can
* before anything is fetched. Keyed by kind as well as content, because a
* symlink and a regular file that happen to share bytes are not a rename of
* each other, while a moved symlink is exactly one.
*/
function exactRenames(adds, deletes, oids, kinds) {
const key = (path) => {
const oid = oids.get(path);
return oid === undefined ? undefined : `${kinds.get(path) ?? ''}:${oid}`;
};
const sources = new Map();
for (const path of deletes) {
const held = key(path);
if (held !== undefined && !sources.has(held))
sources.set(held, path);
}
const taken = new Set();
const pairs = [];
for (const path of adds) {
const held = key(path);
const origin = held === undefined ? undefined : sources.get(held);
if (origin !== undefined && !taken.has(origin)) {
taken.add(origin);
pairs.push([path, origin]);
}
}
return pairs;
}
/**
* Pair the rest by how much content they still have in common.
*
* This is what makes a move that also edited the file read as one rename instead
* of an add beside a delete. It costs a read of both sides of every candidate
* pair, which is why git bounds the matrix and stops trying rather than slow
* down on a large rewrite; the same bound is kept here so the answer agrees with
* git's on the trees where git gives up.
*/
async function contentRenames(repo, adds, deletes, oids, threshold = RENAME_THRESHOLD) {
if (adds.length === 0 ||
deletes.length === 0 ||
adds.length * deletes.length > MAX_RENAME_FILES ** 2) {
return [];
}
const blobs = new Map();
const load = async (oid) => {
const held = blobs.get(oid);
if (held !== undefined)
return held;
const blob = await readBlobBytes(repo, oid);
blobs.set(oid, blob);
return blob;
};
const candidates = [];
for (const old of deletes) {
const oldOid = oids.get(old);
if (oldOid === undefined)
continue;
const source = await load(oldOid);
for (const fresh of adds) {
const newOid = oids.get(fresh);
if (newOid === undefined)
continue;
const score = similarityScore(source, await load(newOid));
// Negative score so the strongest pair sorts first while paths still
// tie-break in ascending order, which is what makes two equally similar
// candidates resolve the same way on every run.
if (score >= threshold)
candidates.push([-score, fresh, old]);
}
}
// Python is `candidates.sort()`, a code-point tuple sort, so the paths
// tie-break by code point rather than ICU collation. The comment above
// promises the same resolution on every run; that only holds if both
// trees break the tie the same way.
candidates.sort((a, b) => a[0] - b[0] || compareCodePoints(a[1], b[1]) || compareCodePoints(a[2], b[2]));
const takenNew = new Set();
const takenOld = new Set();
const pairs = [];
for (const [score, fresh, old] of candidates) {
if (takenNew.has(fresh) || takenOld.has(old))
continue;
takenNew.add(fresh);
takenOld.add(old);
pairs.push([fresh, old, -score]);
}
return pairs;
}
/**
* Fold an add and a delete of the same file into one rename.
*
* Two passes, git's own order: identical content first, then what is merely
* similar enough. Both pair within one kind, and only the second is limited to
* regular files: a moved symlink is a rename git reports as one. Each rename
* carries its similarity, 100 for identical content.
*/
export async function pairRenames(repo, staged, oids, kinds, threshold = RENAME_THRESHOLD) {
const pick = (letter) => [...staged.entries()]
.filter(([path, held]) => held === letter && kinds.has(path))
.map(([path]) => path)
.sort(compareCodePoints);
const adds = pick(ADDED);
const deletes = pick(DELETED);
const pairs = exactRenames(adds, deletes, oids, kinds).map(([fresh, old]) => [fresh, old, 100]);
const matchedNew = new Set(pairs.map(([fresh]) => fresh));
const matchedOld = new Set(pairs.map(([, old]) => old));
const scored = (side) => side.filter((p) => kinds.get(p) === REGULAR_KIND);
pairs.push(...(await contentRenames(repo, scored(adds.filter((p) => !matchedNew.has(p))), scored(deletes.filter((p) => !matchedOld.has(p))), oids, threshold)));
const paired = new Map(pairs.map(([fresh, old, score]) => [fresh, [RENAMED, old, score]]));
const consumed = new Set(pairs.map(([, old]) => old));
const out = new Map();
for (const [path, letter] of staged) {
if (consumed.has(path))
continue;
out.set(path, paired.get(path) ?? [letter, null, 0]);
}
return out;
}
/**
* Compare HEAD's tree with the index: what a commit would record, before an
* add and a delete pair into a rename.
*
* Conflicted paths are excluded rather than compared. An unmerged path holds no
* ordinary index entry, so comparing it against HEAD's tree would find the path
* on one side only and call it deleted, which is the opposite of what is
* happening to it. Returns each changed path's letter, then the blob id and the
* kind of each added and deleted path, which is what `pairRenames` takes.
* Mirrors Python's `staged_letters`.
*/
function stagedLetters(head, entries, conflicts) {
const tree = head ?? new Map();
const staged = new Map();
const oids = new Map();
const kinds = new Map();
for (const [path, entry] of entries) {
const recorded = tree.get(path);
if (recorded === undefined) {
staged.set(path, ADDED);
oids.set(path, entry.oid);
kinds.set(path, kindOf(entry.mode.toString(8)));
}
else if (recorded.oid !== entry.oid || Number.parseInt(recorded.mode, 8) !== entry.mode) {
staged.set(path, MODIFIED);
}
}
for (const [path, recorded] of tree) {
if (entries.has(path) || conflicts.has(path))
continue;
staged.set(path, DELETED);
oids.set(path, recorded.oid);
kinds.set(path, kindOf(recorded.mode));
}
return [staged, oids, kinds];
}
/** The two-letter code for each unmerged path. */
function conflictCodes(conflicts) {
const codes = new Map();
for (const [path, entry] of conflicts) {
const key = [entry.ancestor !== null, entry.this !== null, entry.other !== null].join(',');
codes.set(path, CONFLICT_CODES[key] ?? 'UU');
}
return codes;
}
/**
* Whether the executable bit moved since the path was staged.
*
* git tracks exactly one permission bit and only the owner's copy of it:
* `chmod 744` is a modification and `chmod 645` is not, pinned against git 2.47.
* Nothing is claimed when the mount reports no mode at all, which is most of
* them; a backend that has no permissions to report would otherwise make every
* executable file look changed.
*/
function modeDiffers(entry, info) {
if (info.mode === null || !isRegular(entry.mode.toString(8)))
return false;
return Boolean(entry.mode & 0o100) !== Boolean(info.mode & 0o100);
}
/**
* Whether a working-tree file differs from what the index staged.
*
* A size the mount already reported settles most of it for free, since an edit
* that keeps the byte count is the exception. When the sizes agree the file is
* read and hashed, because that is the only thing that actually answers the
* question. git normally skips even that by trusting the stat data it cached
* (device, inode, mtime to the nanosecond); a mount serves none of those
* meaningfully, so the cheap answer is not available here and a wrong one is
* worse than a slow one.
*
* A recorded size of zero is read as "not stated" rather than "empty", because
* that is what mirage writes for an entry it restored from a tree without
* reading the blob. Trusting it would report every tracked file as modified the
* moment anything was unstaged. Nothing is lost: a genuinely empty file falls
* through to the hash and compares equal there.
*/
async function differs(repo, vfs, worktree, path, entry, info) {
if (modeDiffers(entry, info))
return true;
if (info.size !== null && entry.size !== 0 && info.size !== entry.size)
return true;
let data;
try {
data = await entryBytes(vfs, worktree.join(path), info);
}
catch (err) {
if (isMissingPath(err) || isEnotdir(err) || isEisdir(err))
return true;
// git counts a file it cannot read as changed; a command that needs its
// bytes then refuses it (UnhashableFileError).
if (isEacces(err))
return true;
throw err;
}
const oid = await git.hashBlob({ object: data });
return oid.oid !== entry.oid;
}
/** Compare the index with the working tree: what is not staged yet. */
export async function workChanges(repo, vfs, worktree, entries, found) {
const changes = new Map();
for (const [path, entry] of entries) {
// A 160000 entry records another repository's HEAD, and what stands at the
// name is a directory, so the walk never finds a file there and every
// submodule read as deleted. git compares the submodule's own HEAD, which
// is unreadable from here, and says nothing at all when there is none;
// saying nothing is both the closest this can get and what keeps a branch
// switch away from a submodule from being refused over a file that was
// never missing.
if (entry.mode === Number.parseInt(GITLINK_MODE, 8))
continue;
const info = found.files.get(path);
if (info === undefined)
changes.set(path, DELETED);
// A symlink and a file holding its target text hash alike.
else if ((info.type === FileType.SYMLINK) !== ((entry.mode & 0o170000) === SYMLINK)) {
changes.set(path, TYPE_CHANGED);
}
else if (await differs(repo, vfs, worktree, path, entry, info)) {
changes.set(path, MODIFIED);
}
}
return changes;
}
/** The index as entries, path to mode and id, conflict stages left out. */
export function stagedEntries(state) {
return new Map([...state.entries].map(([path, entry]) => [
path,
{ oid: entry.oid, mode: entry.mode.toString(8).padStart(6, '0') },
]));
}
/**
* The working tree as entries, for the side `git diff` compares.
*
* The index stands for every file the walk found unchanged; a modified file is
* hashed and its blob held on the repo for this invocation only, as git writes
* nothing on a diff. Untracked files are not part of it. A path the index holds
* only as conflict stages is the file standing there, if any: what a revision
* is compared with, while the index side leaves it out (git shows a combined
* diff there, which is not offered). `roots` (`patternRoots`) narrows the
* walk to what the pathspecs reach; an entry outside them reads as gone, for
* the caller to drop. Mirrors Python's work_entries.
*/
export async function workEntries(repo, vfs, state, roots = null) {
const tracked = new Set([...state.entries.keys(), ...state.conflicts.keys()]);
const found = await scan(vfs, repo.location, tracked, UNTRACKED_NO, false, roots);
const changes = await workChanges(repo, vfs, repo.location.worktree, state.entries, found);
const entries = stagedEntries(state);
for (const path of state.conflicts.keys()) {
if (found.files.has(path))
changes.set(path, MODIFIED);
}
for (const [path, code] of changes) {
const info = found.files.get(path);
if (code === DELETED || info === undefined) {
entries.delete(path);
continue;
}
let data;
try {
data = await entryBytes(vfs, repo.location.worktree.join(path), info);
}
catch (err) {
if (!isEacces(err))
throw err;
throw new UnhashableFileError(path, fsStrerror(err) ?? 'Permission denied');
}
const { oid } = await git.hashBlob({ object: data });
repo.held.set(oid, data);
entries.set(path, { oid, mode: entryMode(info).toString(8).padStart(6, '0') });
}
return entries;
}
/**
* Assemble one row per path from the three comparisons.
*
* A path can appear in both the staged and unstaged mappings, and that is the
* point of carrying two columns: it is one row reading `MM`, not two rows.
*
* Sorting is per group, not overall, which is git's own order: everything
* tracked sorts together (an unmerged path among the rest, verified against git
* 2.47), and untracked paths follow as their own sorted block however they
* collate against the tracked ones.
*/
function merge(staged, unstaged, conflicts, untracked) {
const rows = [];
const paths = [...new Set([...staged.keys(), ...unstaged.keys(), ...conflicts.keys()])].sort(compareCodePoints);
for (const path of paths) {
const code = conflicts.get(path);
if (code !== undefined) {
rows.push({
path,
indexStatus: code.charAt(0),
treeStatus: code.charAt(1),
original: null,
});
continue;
}
const [letter, origin] = staged.get(path) ?? [UNCHANGED, null];
rows.push({
path,
indexStatus: letter,
treeStatus: unstaged.get(path) ?? UNCHANGED,
original: origin,
});
}
for (const path of [...untracked].sort(compareCodePoints)) {
rows.push({ path, indexStatus: UNTRACKED, treeStatus: UNTRACKED, original: null });
}
return rows;
}
/**
* The status entries the rows a session sees make, renames paired. Mirrors
* Python's `assembled`.
*/
export async function assembled(repo, shown) {
const staged = new Map();
const oids = new Map();
const kinds = new Map();
const unstaged = new Map();
const conflicts = new Map();
const untracked = [];
const ignored = [];
for (const row of shown) {
if (row.indexStatus === UNTRACKED)
untracked.push(row.name);
else if (row.indexStatus === IGNORED)
ignored.push(row.name);
else if (row.unmerged)
conflicts.set(row.name, row.indexStatus + row.treeStatus);
else {
if (row.indexStatus !== UNCHANGED)
staged.set(row.name, row.indexStatus);
if (row.treeStatus !== UNCHANGED)
unstaged.set(row.name, row.treeStatus);
if (row.end !== null) {
kinds.set(row.name, row.end[0]);
oids.set(row.name, row.end[1]);
}
}
}
const rows = merge(await pairRenames(repo, staged, oids, kinds), unstaged, conflicts, untracked);
for (const path of ignored.sort(compareCodePoints))
rows.push({ path, indexStatus: IGNORED, treeStatus: IGNORED, original: null });
return rows;
}
/** Everything `status` reports, in one pass over the three sources. */
export async function collect(repo, vfs, mode, showIgnored = false) {
const state = await readIndex(repo, vfs);
const head = await headEntries(repo);
const [staged, oids, kinds] = stagedLetters(head, state.entries, new Set(state.conflicts.keys()));
// The walk collapses a directory holding nothing tracked into `dir/`, and
// which directories those are is the session's view of the index: one whose
// only tracked file is hidden reads as untracked, as it would in a checkout
// of what the session sees.
const tracked = new Set([
...visibleEntries(repo.location, state.entries).keys(),
...visibleEntries(repo.location, state.conflicts).keys(),
]);
const found = await scan(vfs, repo.location, tracked, mode, showIgnored);
const unstaged = await workChanges(repo, vfs, repo.location.worktree, state.entries, found);
const at = (name) => repo.location.worktree.join(name.replace(/\/+$/, ''));
const row = (name, indexStatus, treeStatus, end = null, unmerged = false) => ({ path: at(name), name, indexStatus, treeStatus, end, unmerged });
const rows = [];
for (const [name, code] of conflictCodes(state.conflicts))
rows.push(row(name, code.charAt(0), code.charAt(1), null, true));
for (const name of [...new Set([...staged.keys(), ...unstaged.keys()])].sort(compareCodePoints)) {
const oid = oids.get(name);
const kind = kinds.get(name);
rows.push(row(name, staged.get(name) ?? UNCHANGED, unstaged.get(name) ?? UNCHANGED, oid !== undefined && kind !== undefined ? [kind, oid] : null));
}
for (const name of found.untracked)
rows.push(row(name, UNTRACKED, UNTRACKED));
for (const name of found.ignored)
rows.push(row(name, IGNORED, IGNORED));
return [rows, state, head === null];
}
//# sourceMappingURL=changes.js.map