UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

270 lines • 11.3 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 git, { Errors } from 'isomorphic-git'; import { abbrevLength } from "./format.js"; import { decodeText } from "../../../../shell/bytes.js"; import { configValues, gitFs } from "./fs.js"; import { exists, readNames, readRange, writeFile } from "./io.js"; import { entryName } from "../../../../utils/remnants.js"; import { toHex } from "../../../../utils/hex.js"; import { compareCodePoints } from "../../../../utils/sort.js"; import { gitBool } from "./util.js"; import { GitError } from "./errors.js"; const PACK_DIR = 'objects/pack'; const IDX_SUFFIX = '.idx'; // A v2 pack index is a 8-byte header then 256 fanout entries; the last one is // the object count, so the total is four bytes at a fixed offset. const FANOUT_END = 8 + 256 * 4; const FANOUT_SIZE = 256 * 4; // A v2 index opens with this magic; a v1 one has none and starts with the // fanout, its entries an offset and a name each rather than names alone. const IDX_MAGIC = [0xff, 0x74, 0x4f, 0x63]; const SHA_BYTES = 20; const V1_OFFSET_BYTES = 4; const OBJECTS_DIR = 'objects'; const LOOSE_NAME_LENGTH = 38; /** The argument bag every isomorphic-git call in this package shares. */ export function repoArgs(repo) { return { fs: repo.fs, dir: repo.location.worktree.virtual, gitdir: repo.location.gitdir.virtual, cache: repo.cache, }; } /** * How many objects the repository's packs hold, for the id abbreviation. * * Read off each pack index, which states its own count in its fanout table, so * this costs one small ranged read per pack. Loose objects are deliberately not * counted: git's own estimate ignores them, and matching that is what makes an * abbreviated id agree with real git. */ async function packedCount(vfs, commondir) { const root = commondir.join(PACK_DIR); let total = 0; for (const entry of await readNames(vfs, root)) { const name = entryName(entry); if (!name.endsWith(IDX_SUFFIX)) continue; const head = await readRange(vfs, root.join(name), FANOUT_END - 4, 4); if (head.byteLength < 4) continue; total += new DataView(head.buffer, head.byteOffset, 4).getUint32(0, false); } return total; } /** * The ids one pack index holds whose first byte is `byte`: its fanout table * says where that bucket of the sorted names starts and ends, so only the * bucket is read rather than every name. */ async function packedUnder(vfs, path, byte) { const head = await readRange(vfs, path, 0, IDX_MAGIC.length); const v2 = IDX_MAGIC.every((value, i) => head[i] === value); const fanoutAt = v2 ? FANOUT_END - FANOUT_SIZE : 0; const fanout = await readRange(vfs, path, fanoutAt, FANOUT_SIZE); if (fanout.byteLength < FANOUT_SIZE) return []; const table = new DataView(fanout.buffer, fanout.byteOffset, FANOUT_SIZE); const start = byte === 0 ? 0 : table.getUint32((byte - 1) * 4, false); const end = table.getUint32(byte * 4, false); if (end <= start) return []; const stride = v2 ? SHA_BYTES : SHA_BYTES + V1_OFFSET_BYTES; const skip = v2 ? 0 : V1_OFFSET_BYTES; const names = await readRange(vfs, path, fanoutAt + FANOUT_SIZE + start * stride, (end - start) * stride); const ids = []; for (let at = skip; at + SHA_BYTES <= names.byteLength; at += stride) ids.push(toHex(names.subarray(at, at + SHA_BYTES))); return ids; } /** * Every id, loose or packed, starting with a two-digit fanout prefix, sorted. * Nothing is read but each pack index's bucket for that byte and the one loose * directory, which is what widening abbreviated ids needs. * * @param fanout two lowercase hex digits */ export async function idsUnder(repo, fanout) { const found = new Set(); const byte = parseInt(fanout, 16); const root = repo.location.commondir.join(PACK_DIR); for (const entry of await readNames(repo.vfs, root)) { const name = entryName(entry); if (!name.endsWith(IDX_SUFFIX)) continue; for (const oid of await packedUnder(repo.vfs, root.join(name), byte)) found.add(oid); } const loose = repo.location.commondir.join(`${OBJECTS_DIR}/${fanout}`); for (const entry of await readNames(repo.vfs, loose)) { const name = entryName(entry); if (name.length === LOOSE_NAME_LENGTH) found.add(`${fanout}${name}`); } return [...found].sort(compareCodePoints); } /** An object's type and its bytes as stored, whichever kind of object it is. */ export async function rawObject(repo, oid) { // Deprecated upstream for being general, but the general answer is what the // callers need: which kind this id names and its stored content, without // reading it as each kind in turn until one does not throw. // eslint-disable-next-line @typescript-eslint/no-deprecated const read = await git.readObject({ ...repoArgs(repo), oid, format: 'content' }); return { oid, type: read.type, raw: read.object }; } /** Which of commit/tag/tree/blob an id names, null when the repository lacks it. */ export async function objectType(repo, oid) { try { return (await rawObject(repo, oid)).type; } catch (err) { if (err instanceof Errors.NotFoundError) return null; throw err; } } /** * Keep a fetched pack whole, beside the index git reads it through. * * Named by the pack's own checksum, as git names one it receives, and indexed * after the pack is written, so a reader that lists `.idx` files never finds * one whose pack is not there yet. An empty pack stores nothing. */ export async function storePack(repo, data) { if (!data.length) return; const checksum = toHex(data.subarray(data.length - 20)); const dir = repo.location.commondir.join(PACK_DIR); const name = `pack-${checksum}.pack`; if (await exists(repo.vfs, dir.join(name.replace(/\.pack$/, IDX_SUFFIX)))) return; await writeFile(repo.vfs, dir.join(name), data); await git.indexPack({ ...repoArgs(repo), dir: dir.virtual, filepath: name }); } /** * Open a repository living in a mount. * * @param vfs the session's files * @param location the discovered repository */ export async function openRepo(vfs, location, ambiguous = null) { return { fs: gitFs(vfs, location), vfs, location, cache: {}, abbrev: abbrevLength(await packedCount(vfs, location.commondir)), held: new Map(), ambiguous, places: new Map(), guarded: null, named: new Map(), sealed: new Map(), free: new Set(), seen: new Map(), sightings: null, }; } /** * A blob's bytes, a held one first: what `git diff` hashes from the working * tree is rendered like any blob, but git writes none of it. Mirrors * Python's VfsObjectStore.hold. */ export async function readBlobBytes(repo, oid) { return repo.held.get(oid) ?? (await git.readBlob({ ...repoArgs(repo), oid })).blob; } /** * One commit's metadata and message from a single object read. The message * retains its stored newlines; identity offsets are minutes east of UTC. */ export async function commitFacts(repo, oid) { let object = await rawObject(repo, oid); while (object.type === 'tag') { const target = /^object ([0-9a-f]{40})\n/.exec(new TextDecoder().decode(object.raw))?.[1]; if (target === undefined) throw new GitError(`invalid tag ${object.oid}`); object = await rawObject(repo, target); } if (object.type !== 'commit') { throw new Errors.ObjectTypeError(oid, object.type, 'commit'); } const raw = object.raw; const text = new TextDecoder('utf-8', { ignoreBOM: true }).decode(raw); const split = text.indexOf('\n\n'); const message = split < 0 ? '' : text.slice(split + 2); const headers = (split < 0 ? text : text.slice(0, split)).split('\n'); const value = (key) => headers.find((line) => line.startsWith(`${key} `))?.slice(key.length + 1) ?? ''; const identity = (role) => { const found = /^(.*) <(.*)> (-?[0-9]+) ([+-])([0-9]{2})([0-9]{2})$/.exec(value(role)); if (found === null) throw new GitError(`invalid ${role} in commit ${oid}`); const [, name = '', email = '', timestamp = '', sign, hours = '', minutes = ''] = found; const offset = Number(hours) * 60 + Number(minutes); return { name, email, timestamp: Number(timestamp), offset: sign === '-' ? -offset : offset, }; }; const author = identity('author'); const committer = identity('committer'); const decoded = [message, author.name, author.email, committer.name, committer.email]; // A byte that is no part of a character decoded as U+FFFD: the object's own // bytes are decoded again, as surrogate-escaped text, for the filters that // match them. let bytes; if (decoded.some((text) => text.includes('\uFFFD'))) { const text = decodeText(raw); const split = text.indexOf('\n\n'); const head = (split < 0 ? text : text.slice(0, split)).split('\n'); const ident = (key) => (head.find((line) => line.startsWith(`${key} `)) ?? '') .slice(key.length + 1) .replace(/ -?\d+ [+-]\d{4}$/, ''); bytes = { message: split < 0 ? '' : text.slice(split + 2), author: ident('author'), committer: ident('committer'), }; } return { ...(bytes === undefined ? {} : { bytes }), oid, tree: value('tree'), message, authorName: author.name, authorEmail: author.email, authorTime: author.timestamp, authorTimezoneMinutes: author.offset, committerName: committer.name, committerEmail: committer.email, committerTime: committer.timestamp, committerTimezoneMinutes: committer.offset, parents: headers.filter((line) => line.startsWith('parent ')).map((line) => line.slice(7)), }; } /** * A boolean from the repository's config, read the way git reads one. * * @param repo the opened repository * @param path the variable, e.g. `core.quotepath` * @param fallback the answer when the variable is unset */ export async function configBool(repo, path, fallback) { const values = await configValues(repo.vfs, repo.location, path); return gitBool(values, path.toLowerCase(), fallback); } //# sourceMappingURL=repo.js.map