@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
198 lines • 8.73 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 { 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