UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

198 lines • 8.73 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 { GitConfigManager } from 'isomorphic-git/managers'; import { FileSystem } from 'isomorphic-git/models'; import { FileType, PathSpec } from "../../../../types.js"; import { ensureDir, exists, readNames, removeFile, writeFile as writePath } from "./io.js"; import { entryName } from "../../../../utils/remnants.js"; import { posixNormpath } from "../../../../utils/path.js"; const ENC = new TextEncoder(); const DEC = new TextDecoder(); /** * The lines that hold a variable, each keyed the way git reads it. * * isomorphic-git keeps a quoted subsection's escapes, so `[branch "q\"x"]` * came back as `branch.q\"x.remote` and a lookup for the branch `q"x` found * nothing; git drops the backslash before any character. */ function variables(parsed) { return parsed .filter((line) => line.name !== null) .map((line) => { const { section, subsection, name } = line; if (typeof subsection !== 'string' || section === null || name === null) return line; const unescaped = subsection.replace(/\\(.)/g, '$1'); return { ...line, path: `${section.toLowerCase()}.${unescaped}.${name.toLowerCase()}` }; }); } /** * The variables of one config file's text, in file order, as isomorphic-git * parses them: `path` is the dotted key, section and name folded, and `value` * the raw string git prints. Read from text rather than a git directory * because `--global` names files that are not called `config`. * * @param text the file's contents */ export async function configLines(text) { const config = await GitConfigManager.get({ fs: { read: () => Promise.resolve(text) }, gitdir: '', }); return variables(config.parsedConfig); } /** * The stat shape isomorphic-git reads. It consults `type`, `mode`, `size` and * the mtime, and calls `isDirectory()`/`isFile()`/`isSymbolicLink()`; the * numeric device fields feed its index stat cache, which mirage zeroes on * purpose so nothing downstream trusts it (see IndexEntry in types.ts). */ class GitStat { type; mode; size; ino = 0; dev = 0; uid = 0; gid = 0; ctimeMs; mtimeMs; ctimeSeconds; mtimeSeconds; ctimeNanoseconds = 0; mtimeNanoseconds = 0; constructor(stat) { const dir = stat.type === FileType.DIRECTORY; this.type = dir ? 'dir' : 'file'; this.mode = stat.mode ?? (dir ? 0o040755 : 0o100644); this.size = stat.size ?? 0; const ms = stat.modified === null ? 0 : Date.parse(stat.modified) || 0; this.ctimeMs = ms; this.mtimeMs = ms; this.ctimeSeconds = Math.floor(ms / 1000); this.mtimeSeconds = Math.floor(ms / 1000); } isDirectory() { return this.type === 'dir'; } isFile() { return this.type === 'file'; } isSymbolicLink() { return false; } } /** * A `PromiseFsClient` whose bytes come from a mirage mount. * * isomorphic-git reaches a repository through nothing but this object, so a * repository on S3, in RAM or over SSH is the same repository to it. That is * the whole bridge, and it is why the TypeScript side needs no counterpart to * Python's `objects.py`/`lazyfile.py`: dulwich's object store is synchronous, so * Python has to hand-build a lazy store and marshal every read onto the * workspace loop, while isomorphic-git is async to begin with and reads * packfiles, loose objects and the index itself. * * Symlinks are refused rather than faked. mirage keeps links in the namespace * rather than in any backend, so a `readlink` here would have to consult a * different layer than every other call, and no verb mirrored so far writes or * follows one inside a `.git` directory. */ export function gitFs(source, location) { // isomorphic-git accepts one gitdir and does not follow commondir itself. // Route shared storage here so every library operation keeps the selected // checkout's HEAD/index while using the common objects, refs and config. const roots = location === undefined ? [] : [location.gitdir, location.commondir, location.worktree].sort((a, b) => b.virtual.length - a.virtual.length); const route = (path) => { const spec = PathSpec.fromStrPath(path); if (location === undefined) return spec; const virtual = posixNormpath(spec.virtual); for (const root of roots) { const prefix = `${root.virtual.replace(/\/$/, '')}/`; if (virtual !== root.virtual && !virtual.startsWith(prefix)) continue; const relative = virtual === root.virtual ? '' : virtual.slice(prefix.length); const shared = ['objects', 'refs', 'packed-refs', 'config', 'shallow'].some((name) => relative === name || relative.startsWith(`${name}/`)); const local = ['refs/bisect', 'refs/worktree', 'refs/rewritten'].some((name) => relative === name || relative.startsWith(`${name}/`)); const base = root === location.gitdir && shared && !local ? location.commondir : root; return relative ? base.join(relative) : base; } return spec; }; const readFile = async (path, options) => { const encoding = typeof options === 'string' ? options : options?.encoding; const bytes = await source.read(route(path)); return encoding === undefined ? bytes : DEC.decode(bytes); }; const writeFile = async (path, data) => { await writePath(source, route(path), typeof data === 'string' ? ENC.encode(data) : data); }; const stat = async (path) => new GitStat(await source.stat(route(path))); return { promises: { readFile, writeFile, unlink: async (path) => { await removeFile(source, route(path)); }, // isomorphic-git wants bare names; backends may report either those or // whole paths, with or without a trailing slash. readdir: async (path) => (await readNames(source, route(path))).map(entryName), mkdir: async (path) => { await ensureDir(source, route(path)); }, rmdir: async (path) => { await source.rmdir(route(path)); }, stat, // A mount has no links below it, so lstat is stat. lstat: stat, readlink: () => Promise.reject(new Error('mirage git: symlinks are not read here')), symlink: () => Promise.reject(new Error('mirage git: symlinks are not written here')), // git chmods a loose object to 0444; the mount decides its own modes and // writeOnce never rewrites one, so there is nothing to enforce. chmod: () => Promise.resolve(), exists: (path) => exists(source, route(path)), }, }; } /** * Every value a variable takes in the repository's config, as written. * * Read below `git.getConfig`, which casts `core.bare` and a few other keys * itself, and only when they are spelled in lowercase and hold a word: * `[Core] Bare = true` came back a string and `bare = 1` threw. A linked * worktree's config is its repository's. * * @param vfs the session's files * @param location the discovered repository * @param path the variable, e.g. `core.bare`; its section and name in any case */ export async function configValues(vfs, location, path) { const config = await GitConfigManager.get({ fs: new FileSystem(gitFs(vfs, location)), gitdir: location.commondir.virtual, }); // Section and name fold case; a subsection between them does not. const first = path.indexOf('.'); const last = path.lastIndexOf('.'); const key = path.slice(0, first).toLowerCase() + path.slice(first, last) + path.slice(last).toLowerCase(); return variables(config.parsedConfig) .filter((line) => line.path === key) .map((line) => line.value ?? ''); } //# sourceMappingURL=fs.js.map