@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
134 lines • 5.67 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 { GitIndexManager } from 'isomorphic-git/managers';
import { FileSystem } from 'isomorphic-git/models';
import { ResolveIndexError } from "./errors.js";
import { exists } from "./io.js";
const MERGE_HEAD = 'MERGE_HEAD';
function stageOf(entry) {
return entry.stage ?? entry.flags?.stage ?? 0;
}
function toEntry(entry) {
return {
path: entry.path,
oid: entry.oid,
mode: entry.mode,
size: entry.size,
stage: stageOf(entry),
};
}
/**
* Run a closure against the live index, writing it back if it was changed.
*
* A fresh cache per call on purpose: the index is re-read every time rather than
* memoized, because a mirage mutation between two verbs happens outside
* isomorphic-git's knowledge and a shared cache would replay the old one.
*
* The index lives in `gitdir`, never `commondir`: a linked worktree stages its
* own content, and pointing this at the shared directory would show one
* checkout's staged changes in another.
*/
async function withIndex(repo, closure) {
// Wrapped rather than passed raw: the public API wraps every fs it is given
// before handing it to a manager, and a manager calls the wrapper's own
// methods (`lstat`, `readdir` returning null on a miss) rather than the plain
// ones the plugin declares.
return GitIndexManager.acquire({ fs: new FileSystem(repo.fs), gitdir: repo.location.gitdir.virtual, cache: {} }, closure);
}
/**
* Read `.git/index` through the dispatcher.
*
* The index is the third thing `status` compares, and the only one that is a
* single file: HEAD's tree is assembled from objects and the working tree is
* walked, but staged content is one binary blob.
*
* An absent index is not an error. `git init` writes none until the first
* `git add`, and every path is then untracked, which is what an empty table
* already says.
*/
export async function readIndex(repo, vfs) {
const merging = await exists(vfs, repo.location.gitdir.join(MERGE_HEAD));
const entries = new Map();
const conflicts = new Map();
const rows = await withIndex(repo, (index) => index.entriesFlat.map(toEntry));
for (const row of rows) {
if (row.stage === 0) {
entries.set(row.path, row);
continue;
}
// Conflicted paths are carried apart rather than dropped, because a dropped
// one reads as unmodified: the file would compare equal to nothing and
// vanish from the report while git is refusing to commit because of it.
const held = conflicts.get(row.path) ?? { ancestor: null, this: null, other: null };
conflicts.set(row.path, {
ancestor: row.stage === 1 ? row : held.ancestor,
this: row.stage === 2 ? row : held.this,
other: row.stage === 3 ? row : held.other,
});
}
return { entries, conflicts, merging };
}
/**
* Replace the staged entries, leaving conflicts alone.
*
* The stat cache is written zeroed, which git reads as "do not trust it": it
* then compares content rather than believing a size or an mtime mirage cannot
* promise is meaningful. A zeroed `size` therefore means "not stated" to every
* reader here, never "empty".
*
* @param repo the opened repository
* @param staged the paths to set, mapped to what should be recorded for them
* @param removed the paths to drop from the index entirely
*/
export async function updateIndex(repo, staged, removed = []) {
await withIndex(repo, (index) => {
for (const path of removed)
index.delete({ filepath: path });
for (const [path, entry] of staged) {
index.insert({
filepath: path,
oid: entry.oid,
stats: {
ctimeSeconds: 0,
ctimeNanoseconds: 0,
mtimeSeconds: 0,
mtimeNanoseconds: 0,
dev: 0,
ino: 0,
mode: entry.mode,
uid: 0,
gid: 0,
size: entry.size,
},
});
}
});
}
/**
* Refuse a branch move while the index still records conflict stages.
*
* Every collision check a checkout makes reads stage 0, so a path held only as
* stages 1-3 is invisible to all of them, and git refuses before it reads
* either tree. The refusal is not the moving side's alone: `git switch
* <current>` and `git checkout <current>` move nothing and still die on it, so
* a line that answers "Already on" out of a shortcut has to ask the index
* first. Pinned against git 2.50.1, where `git switch -c <new>` is the one line
* that survives an unresolved index, because it writes a ref and nothing else.
*/
export function refuseUnresolved(state) {
if (state.conflicts.size === 0)
return;
throw new ResolveIndexError([...state.conflicts.keys()]);
}
//# sourceMappingURL=index_file.js.map