@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
236 lines • 10.9 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 git from 'isomorphic-git';
import { IOResult } from "../../../../io/types.js";
import { isEexist } from "../../../../errors/fs.js";
import { FlagView } from "../../../spec/flag_view.js";
import { headEntries, stagedEntries } from "./changes.js";
import { AllWithPathsError, BrokenHeadError, LockExistsError, MissingMessageError, PartialCommitError, UnmergedIndexError, } from "./errors.js";
import { stageTracked } from "./add.js";
import { commitSummary } from "./diff_output.js";
import { readIndex, updateIndex } from "./index_file.js";
import { takeLock } from "./io.js";
import { record } from "./reflog.js";
import { detachHead, readHead, safeRefName, writeRef } from "./refs.js";
import { configBool, repoArgs } from "./repo.js";
import { deltas, shownSides } from "./rows.js";
import { opened } from "./session.js";
import { reportRows } from "./status.js";
import { report } from "./summary.js";
import { TREE_MODE } from "./tree.js";
import { checkSwitches, startPoint, requireVfs } from "./util.js";
import { compareCodePoints } from "../../../../utils/sort.js";
import { encodeText } from "../../../../shell/bytes.js";
import { systemNow } from "../../../../utils/clock.js";
// git tags the first commit on a branch so the reflog reads
// "commit (initial): ..." rather than plain "commit: ...".
const ROOT_NOTE = ' (initial)';
const DEFAULT_NAME = 'mirage';
const DEFAULT_EMAIL = 'mirage@localhost';
// The environment git reads an identity from. There is no config file behind a
// mount, so these are the only real source.
const AUTHOR_NAME = 'GIT_AUTHOR_NAME';
const AUTHOR_EMAIL = 'GIT_AUTHOR_EMAIL';
const FALLBACK_EMAIL = 'EMAIL';
/**
* Who to record as author and committer.
*
* Three sources, in git's own order: `--author` outranks the environment, and
* the environment outranks the fallback. git reads `GIT_AUTHOR_NAME` and
* `GIT_AUTHOR_EMAIL`, with `EMAIL` as the fallback address, all pinned against
* git 2.50.
*
* Two deliberate divergences, both because a config file is not reachable from
* a mount. `user.name`/`user.email` are never consulted, so the environment is
* the only place a real identity can come from; and where git refuses to commit
* with no identity at all, mirage records a stated default rather than a guess
* at the operator's name. The committer is the author here, where git tracks
* `GIT_COMMITTER_*` separately.
*
* Read through the session view rather than the frozen `ctx.env`
* snapshot, so a hidden name reads as unset exactly as it does in the shell.
*/
export function identity(fl, session) {
const author = fl.asStr('author');
if (author === undefined || author === '') {
const name = session?.get(AUTHOR_NAME) ?? null;
if (name !== null && name !== '') {
const email = session?.get(AUTHOR_EMAIL) ?? session?.get(FALLBACK_EMAIL) ?? null;
const address = email !== null && email !== '' ? email : DEFAULT_EMAIL;
return { name, email: address, line: `${name} <${address}>` };
}
return {
name: DEFAULT_NAME,
email: DEFAULT_EMAIL,
line: `${DEFAULT_NAME} <${DEFAULT_EMAIL}>`,
};
}
const match = /^(.*?)\s*<([^>]*)>\s*$/.exec(author);
if (match === null)
return { name: author, email: '', line: `${author} <>` };
return { name: match[1] ?? '', email: match[2] ?? '', line: author };
}
/**
* Write the trees the index describes and the commit above them.
*
* The tree is built from the index rather than the working tree, which is the
* whole point of an index: what was staged is what is recorded, however the
* files have moved on since.
*/
async function buildCommit(repo, state, message, who, parents, when) {
// Nested trees are written bottom-up: a directory's entry names the id of the
// tree below it, so the deepest have to exist first.
const byDir = new Map();
const ensure = (dir) => {
const held = byDir.get(dir);
if (held !== undefined)
return held;
const fresh = [];
byDir.set(dir, fresh);
return fresh;
};
ensure('');
for (const [path, entry] of [...state.entries].sort(([a], [b]) => compareCodePoints(a, b))) {
const cut = path.lastIndexOf('/');
const dir = cut === -1 ? '' : path.slice(0, cut);
for (let at = dir; at !== '';) {
ensure(at);
const up = at.lastIndexOf('/');
at = up === -1 ? '' : at.slice(0, up);
}
ensure(dir).push({
mode: entry.mode.toString(8),
path: cut === -1 ? path : path.slice(cut + 1),
oid: entry.oid,
type: 'blob',
});
}
// Deepest first, and the root is depth 0 rather than 1: `''.split('/')` is
// one empty segment, so measuring by segment count alone ties the root with
// every top-level directory and can write it before its own children.
const depth = (dir) => (dir === '' ? 0 : dir.split('/').length);
const written = new Map();
for (const dir of [...byDir.keys()].sort((a, b) => depth(b) - depth(a))) {
const entries = ensure(dir);
for (const [child, oid] of written) {
const cut = child.lastIndexOf('/');
if ((cut === -1 ? '' : child.slice(0, cut)) !== dir)
continue;
entries.push({
mode: TREE_MODE,
path: cut === -1 ? child : child.slice(cut + 1),
oid,
type: 'tree',
});
}
entries.sort((a, b) => compareCodePoints(a.path, b.path));
written.set(dir, await git.writeTree({ ...repoArgs(repo), tree: entries }));
}
const tree = written.get('');
if (tree === undefined)
throw new Error('commit: no root tree was written');
const stamp = { name: who.name, email: who.email, timestamp: when, timezoneOffset: 0 };
return git.writeCommit({
...repoArgs(repo),
commit: {
tree,
parent: [...parents],
author: stamp,
committer: stamp,
message: `${message}\n`,
},
});
}
/**
* Record the index as a new commit on the current branch.
*
* The message must come from `-m`: git would otherwise open an editor, which a
* mount has no way to offer, and inventing a message would put an unreviewed one
* into history.
*
* `-a` restages every tracked path first, as `add -u` would, and the index keeps
* that staging only once the commit is written. git's `-a` also resolves
* conflicted paths and records a merge commit from `MERGE_HEAD`; this build
* writes no merge commits, so an unmerged index is refused with or without `-a`.
*/
export async function commit(_accessor, paths, texts, ctx) {
const fl = new FlagView(ctx.flags);
const vfs = requireVfs(ctx);
checkSwitches(ctx, texts);
const staging = fl.asBool('all');
const named = texts[0];
if (named !== undefined)
throw staging ? new AllWithPathsError(named) : new PartialCommitError(named);
const message = fl.asStr('message');
if (message === undefined || message === '')
throw new MissingMessageError();
const repo = await opened(ctx, fl, true);
// git takes the index's lock before it looks for anything to commit, so a
// read-only repository refuses an empty commit too.
const index = repo.location.gitdir.join('index');
try {
await takeLock(vfs, index);
}
catch (err) {
if (isEexist(err))
throw new LockExistsError(`${index.virtual}.lock`);
throw err;
}
const state = await readIndex(repo, vfs);
if (state.conflicts.size > 0)
throw new UnmergedIndexError();
const restaged = staging ? await stageTracked(repo, vfs, state) : null;
const head = await readHead(vfs, repo.location.gitdir);
const before = await headEntries(repo);
const after = stagedEntries(state);
const same = before !== null &&
before.size === after.size &&
[...after].every(([path, entry]) => {
const held = before.get(path);
return held?.oid === entry.oid && held.mode === entry.mode;
});
if (same && !fl.asBool('allow_empty')) {
// Printed on stdout, where the status report it stands in for would
// have gone, and exits 1.
const reportOf = await reportRows(repo, vfs, head, startPoint(fl).virtual);
return [null, new IOResult({ exitCode: 1, rows: reportOf })];
}
const parents = before === null ? [] : [await git.resolveRef({ ...repoArgs(repo), ref: 'HEAD' })];
const who = identity(fl, ctx.session);
const when = Math.floor(systemNow());
const oid = await buildCommit(repo, state, message, who, parents, when);
// The object is written and the ref is not, as git leaves it: the files
// backend refuses to lock a HEAD whose branch path leaves the ref tree,
// rather than let the commit land on .git/config.
if (head.ref !== null && !safeRefName(head.ref))
throw new BrokenHeadError(head.ref);
if (head.ref !== null)
await writeRef(vfs, repo.location.commondir, head.ref, oid);
else
await detachHead(vfs, repo.location.gitdir, oid);
if (restaged !== null)
await updateIndex(repo, restaged[0], restaged[1]);
await record(vfs, repo.location.gitdir, repo.location.commondir, head.ref, parents[0] ?? null, oid, who.line, when, `commit${before === null ? ROOT_NOTE : ''}: ${message.split('\n')[0] ?? ''}`);
const fully = await configBool(repo, 'core.quotepath', true);
if (fl.asBool('quiet'))
return [null, new IOResult()];
const render = async (shown) => {
const [oldSide, newSide] = shownSides(shown, 1)[0] ?? [new Map(), new Map()];
const body = report(oid, message, head.branch, await commitSummary(repo, oldSide, newSide, fully), repo.abbrev, before === null);
return encodeText(body);
};
const rows = deltas(repo.location, [[before ?? new Map(), after]]);
return [null, new IOResult({ rows: { items: rows, render } })];
}
//# sourceMappingURL=commit.js.map