@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
534 lines • 23.6 kB
JavaScript
import { PathSpec } from "../../../../types.js";
import { loadRefs, resolveSymbolic } from "./refs.js";
import { MINIMUM_ABBREV, shortenRef } from "./ref_fields.js";
import { FULL_SHA } from "./format.js";
import git from 'isomorphic-git';
import { VERSION } from "../../../../version.js";
import { RegexError } from "../../../../rs/errors.js";
import { posixRegex } from "../../../../rs/regex.js";
import { byteView, utf8Locale } from "../../../../shell/bytes.js";
import { compareCodePoints } from "../../../../utils/sort.js";
import { IOResult } from "../../../../io/types.js";
import { CommandSpec, Argument } from "../../../spec/types.js";
import { parseCommand, parseToKwargs } from "../../../spec/parser.js";
import { UsageStyle } from "../../../spec/types.js";
import { FlagView } from "../../../spec/flag_view.js";
import { AbbrevModeError, AmbiguousArgumentError, GitError, NotAWorkTreeError, SingleRevisionError, UnknownSubcommandError, UsageError, } from "./errors.js";
import { inPrintOrder, parseFlags, refCommits, walkEnds, walkOperands, walked } from "./history.js";
import { resolveRef, uniqueAbbreviations } from "./ref_list.js";
import { commitFacts, configBool, repoArgs } from "./repo.js";
import { opened } from "./session.js";
import { configLines } from "./fs.js";
import { readFile, readOptional } from "./io.js";
import { mergeBases, resolveCommit, refsNamed, resolveObject } from "./revparse.js";
import { checkOperands, checkSwitches, escaped, fatal, loneHelp, optionOperand, offending, startPoint, verbUsage, requireVfs, } from "./util.js";
import { gitOptionRefusal, gitUsage, leafRefusal } from "../../refusal.js";
import { GIT_LONG_OPTIONS, USAGE_EXIT } from "../../constants.js";
import { isBare } from "./discover.js";
const ENC = new TextEncoder();
const SHOW_TOPLEVEL = '--show-toplevel';
// git's own global spells `--git-dir` too, so rev-parse cannot declare it: it
// arrives as an operand, and is answered from there.
const GIT_DIR_OPTION = '--git-dir';
const GET_URL = new CommandSpec({
arguments: [
new Argument('--push', {
action: 'store_true',
help: 'query push URLs rather than fetch URLs',
}),
new Argument('--all', { action: 'store_true', help: 'return all URLs' }),
new Argument('--no-push', { action: 'store_true' }),
new Argument('--no-all', { action: 'store_true' }),
new Argument('name'),
new Argument('texts', { metavar: '', nargs: '*' }),
],
});
export async function remote(_accessor, paths, texts, ctx) {
const fl = new FlagView(ctx.flags);
const [word] = texts;
if (word === 'get-url')
return await remoteGetUrl(texts, ctx);
checkOperands(ctx, texts);
if (word !== undefined)
throw new UnknownSubcommandError(word, verbUsage(ctx));
const repo = await opened(ctx, fl);
const rows = (await git.listRemotes(repoArgs(repo))).sort((a, b) => compareCodePoints(a.remote, b.remote));
const cfg = await configLines(new TextDecoder().decode(await readFile(repo.vfs, repo.location.commondir.join('config'))));
const lines = [];
for (const row of rows) {
if (!fl.asBool('verbose'))
lines.push(row.remote);
else {
const [urls, push] = remoteUrls(cfg, row.remote);
if (urls[0])
lines.push(`${row.remote}\t${urls[0]} (fetch)`);
for (const url of push.length ? push : urls)
lines.push(`${row.remote}\t${url} (push)`);
}
}
return [ENC.encode(lines.length ? `${lines.join('\n')}\n` : ''), new IOResult()];
}
/** Apply the longest matching URL prefix once, as Git does. */
function rewriteUrl(url, rules) {
let matched;
for (const rule of rules) {
if (url.startsWith(rule[0]) && (matched === undefined || rule[0].length > matched[0].length))
matched = rule;
}
return matched === undefined ? url : matched[1] + url.slice(matched[0].length);
}
/** Fetch and push URLs, with Git's longest-prefix config rewrites. */
export function remoteUrls(cfg, name) {
let urls = cfg
.filter((line) => line.path === `remote.${name}.url`)
.map((line) => line.value ?? '');
let push = cfg
.filter((line) => line.path === `remote.${name}.pushurl`)
.map((line) => line.value ?? '');
if (!urls.length && push.length)
urls = [name];
const rules = [], pushRules = [];
for (const line of cfg) {
if (!line.path.startsWith('url.'))
continue;
const end = line.path.lastIndexOf('.');
const base = line.path.slice(4, end), key = line.path.slice(end + 1);
if (key === 'insteadof')
rules.push([line.value ?? '', base]);
if (key === 'pushinsteadof')
pushRules.push([line.value ?? '', base]);
}
const fetch = urls.map((url) => rewriteUrl(url, rules));
if (push.length)
push = push.map((url) => rewriteUrl(url, rules));
else {
push = urls
.filter((url) => pushRules.some(([prefix]) => url.startsWith(prefix)))
.map((url) => rewriteUrl(url, pushRules));
if (!push.length)
push = fetch;
}
return [fetch, push];
}
/** Read the get-url remainder through the shared option parser. */
export async function remoteGetUrl(texts, ctx) {
const parsed = parseCommand(GET_URL, [...texts.slice(1)], ctx.cwd.virtual, '', undefined, false, GIT_LONG_OPTIONS.get('remote get-url'));
if (parsed.optionErrorKinds.length > 0) {
const [stderr, exitCode, stdout] = leafRefusal(UsageStyle.GIT, new Uint8Array(), parsed, 'remote get-url', GET_URL);
return [stdout, new IOResult({ exitCode, stderr })];
}
const names = parsed.args.map(([word]) => word);
const bad = offending(names, escaped(ctx.argv ?? []), new Set());
if (bad !== null)
throw new UsageError(...gitOptionRefusal(bad, 'remote get-url', GET_URL));
const [name] = names;
if (names.length !== 1 || name === undefined)
throw new UsageError('', gitUsage('remote get-url', GET_URL));
const fl = new FlagView(parseToKwargs(parsed), GET_URL);
const repo = await opened(ctx, new FlagView(ctx.flags));
const cfg = await configLines(new TextDecoder().decode(await readFile(repo.vfs, repo.location.commondir.join('config'))));
const [urls, push] = remoteUrls(cfg, name);
if (!urls.length)
return [
null,
new IOResult({ exitCode: 2, stderr: ENC.encode(`error: No such remote '${name}'\n`) }),
];
let selected = fl.typedOrder('push', 'no_push').at(-1) === 'push' ? push : urls;
if (fl.typedOrder('all', 'no_all').at(-1) !== 'all')
selected = selected.slice(0, 1);
return [ENC.encode(selected.map((url) => `${url}\n`).join('')), new IOResult()];
}
/**
* The per-user config files `--global` reads, in git's order.
*
* `$GIT_CONFIG_GLOBAL` alone when set, else the XDG file then `~/.gitconfig`,
* each read through the dispatcher from the session's own `HOME` so the answer
* is the workspace's and never the host's. Only `--list` refuses when neither
* exists.
*/
export async function globalSources(ctx, listing) {
const vfs = requireVfs(ctx);
const home = ctx.env?.HOME ?? '';
const override = ctx.env?.GIT_CONFIG_GLOBAL;
if (override === undefined && !home)
throw new GitError('$HOME not set');
const target = override ?? `${home}/.gitconfig`;
const configured = ctx.env?.XDG_CONFIG_HOME ?? '';
const xdg = configured === '' ? `${home}/.config` : configured;
const sources = [];
for (const source of override === undefined ? [`${xdg}/git/config`, target] : [target]) {
const data = await readOptional(vfs, PathSpec.fromStrPath(source, undefined, '/'));
if (data !== null)
sources.push({ source, data });
}
if (!sources.length && listing)
throw new GitError(`unable to read config file '${target}': No such file or directory`);
return sources;
}
async function mergeBaseIds(repo, revisions) {
const commits = [];
for (const revision of revisions) {
try {
commits.push(await commitFacts(repo, await resolveCommit(repo, revision)));
}
catch (err) {
if (!(err instanceof AmbiguousArgumentError))
throw err;
throw new GitError(`Not a valid object name ${revision}`);
}
}
const [one, other, ...rest] = commits;
if (one === undefined || other === undefined)
throw new GitError('expected two commits');
return [one.oid, (await mergeBases(repo, one, other, rest)).map((c) => c.oid)];
}
export async function mergeBase(_accessor, paths, texts, ctx) {
const fl = new FlagView(ctx.flags);
checkSwitches(ctx, texts);
const repo = await opened(ctx, fl);
const ancestor = fl.asBool('is_ancestor');
const all = fl.asBool('all');
if (ancestor && all)
throw new GitError("options '--is-ancestor' and '--all' cannot be used together");
if (ancestor && texts.length !== 2)
throw new GitError('--is-ancestor takes exactly two commits');
if (texts.length < 2)
throw new UsageError('', verbUsage(ctx));
const [one, bases] = await mergeBaseIds(repo, texts);
if (ancestor)
return [
null,
new IOResult({
exitCode: bases.includes(one) ? 0 : 1,
}),
];
return [
ENC.encode((all ? bases : bases.slice(0, 1)).map((oid) => `${oid}\n`).join('')),
new IOResult({ exitCode: bases.length ? 0 : 1 }),
];
}
export async function config(_accessor, paths, texts, ctx) {
const fl = new FlagView(ctx.flags);
if (texts.length > 1)
return [
null,
new IOResult({
exitCode: 1,
stderr: ENC.encode(fl.asBool('get') || fl.asBool('get_regexp')
? 'error: git config value filters are not supported in Mirage\n'
: 'error: git config is read-only in Mirage; writes are not supported\n'),
}),
];
let sources;
if (fl.asBool('global'))
sources = await globalSources(ctx, fl.asBool('list'));
else {
const repo = await opened(ctx, fl);
const path = repo.location.commondir.join(`config`);
const ordinary = repo.location.commondir.virtual === repo.location.worktree.virtual + '/.git' &&
startPoint(fl).virtual === repo.location.worktree.virtual;
sources = [
{
source: ordinary ? '.git/config' : path.rawPath,
data: await readFile(repo.vfs, path),
},
];
}
const listing = fl.asBool('list'), regexp = fl.asBool('get_regexp'), origin = fl.asBool('show_origin');
if (!listing && !texts.length)
return [
null,
new IOResult({
exitCode: USAGE_EXIT,
stderr: ENC.encode('error: wrong number of arguments\n'),
}),
];
const key = texts[0] ?? '';
// git matches keys by the locale: bytes in C, characters in UTF-8.
const utf8 = utf8Locale(ctx.env);
let pattern;
try {
pattern = regexp ? posixRegex(byteView(configKey(key), utf8), true, 'posix', false, utf8) : null;
}
catch (err) {
if (!(err instanceof RegexError))
throw err;
return [
null,
new IOResult({ exitCode: 6, stderr: ENC.encode(`error: invalid key pattern: ${key}\n`) }),
];
}
const values = [];
for (const { source, data } of sources) {
for (const line of await configLines(new TextDecoder().decode(data))) {
if (listing ||
(pattern
? (await pattern.search(byteView(line.path, utf8), 0, ctx.signal)) !== null
: line.path === configKey(key)))
values.push([source, line.path, line.value ?? '']);
}
}
const chosen = listing || regexp ? values : values.slice(-1);
const out = chosen
.map(([source, name, value]) => (origin ? `file:${source}\t` : '') +
(listing || regexp ? name + (listing ? '=' : ' ') : '') +
value +
'\n')
.join('');
return [ENC.encode(out), new IOResult({ exitCode: chosen.length || listing ? 0 : 1 })];
}
export async function showRef(_accessor, paths, texts, ctx) {
const fl = new FlagView(ctx.flags);
checkSwitches(ctx, texts);
const repo = await opened(ctx, fl);
const refs = await loadRefs(repo.vfs, repo.location.gitdir, repo.location.commondir);
const lines = [];
for (const ref of [...refs.keys()].filter((r) => r.startsWith('refs/')).sort(compareCodePoints)) {
if (texts.length && !texts.some((pattern) => ref === pattern || ref.endsWith(`/${pattern}`)))
continue;
// A ref that resolves to nothing (a dangling symbolic ref) is skipped
// silently, as git's ref iteration skips it. Pinned against git 2.47.3.
const [oid] = resolveRef(refs, ref);
if (oid !== null)
lines.push(`${oid} ${ref}`);
}
return [
ENC.encode(lines.length ? `${lines.join('\n')}\n` : ''),
new IOResult({ exitCode: lines.length ? 0 : 1 }),
];
}
export async function revList(_accessor, paths, texts, ctx) {
const fl = new FlagView(ctx.flags);
if (optionOperand(ctx, texts, loneHelp(ctx, texts)) !== null) {
throw new UsageError('', verbUsage(ctx));
}
const repo = await opened(ctx, fl);
const parsed = parseFlags(fl, null, ctx.signal);
const [revisions, pathspecs, dashed] = await walkOperands(ctx, fl, repo, texts);
// A line naming only paths has no revision either (git 2.47.3).
if (!revisions.length && !parsed.allRefs)
throw new UsageError('', verbUsage(ctx));
const flags = { ...parsed, pathspecs };
const [shown, hidden] = await walkEnds(repo, revisions, dashed);
const starts = flags.allRefs ? [...(await refCommits(repo)), ...shown] : shown;
const walk = await walked(repo, starts, flags, hidden);
const commits = inPrintOrder(walk, flags);
// A `--boundary` commit's line starts with `-` (git 2.47.3).
const boundary = new Set(walk.boundary.map((commit) => commit.oid));
const out = fl.asBool('count')
? `${String(commits.length)}\n`
: commits.map((c) => `${boundary.has(c.oid) ? '-' : ''}${c.oid}\n`).join('');
return [ENC.encode(out), new IOResult()];
}
export function version(_accessor, _paths, _texts, _ctx) {
return [ENC.encode(`git version ${VERSION} (Mirage)\n`), new IOResult()];
}
function configKey(key) {
const parts = key.split('.');
return parts
.map((part, i) => (i === 0 || i === parts.length - 1 ? part.toLowerCase() : part))
.join('.');
}
function inGitDir(start, location) {
return start === location.gitdir.virtual || start.startsWith(`${location.gitdir.virtual}/`);
}
/**
* The answers rev-parse gives for the options that report where the line runs,
* keyed by spelling. Pinned against git 2.47: `--git-dir` is `.git` at the top
* of a work tree, `.` inside the git directory itself, and absolute elsewhere
* (a subdirectory, a linked worktree); inside the git directory there is no
* work tree, so `--show-prefix` is empty and `--show-toplevel` is refused.
*/
async function placeAnswers(repo, start) {
const location = repo.location;
const top = location.worktree.virtual === '/' ? '/' : `${location.worktree.virtual}/`;
const inWorkTree = !inGitDir(start, location) &&
!(await isBare(repo.vfs, location)) &&
(start === location.worktree.virtual || start.startsWith(top));
const prefix = inWorkTree && start !== location.worktree.virtual ? `${start.slice(top.length)}/` : '';
const gitDir = start === location.gitdir.virtual
? '.'
: start === location.worktree.virtual &&
location.gitdir.virtual === location.worktree.join('.git').virtual
? '.git'
: location.gitdir.virtual;
return new Map([
[SHOW_TOPLEVEL, `${location.worktree.virtual}\n`],
[GIT_DIR_OPTION, `${gitDir}\n`],
['--show-prefix', `${prefix}\n`],
['--is-inside-work-tree', `${String(inWorkTree)}\n`],
]);
}
/**
* How many hex digits `--short` keeps: the repository's own width bare, and
* otherwise the number given, read as strtoul reads it, between git's four and
* the whole id.
*/
function shortWidth(value, fallback) {
if (typeof value !== 'string')
return fallback;
const digits = /^\s*\d+/.exec(value);
const width = digits === null ? 0 : Number(digits[0]);
return Math.min(Math.max(width, MINIMUM_ABBREV), FULL_SHA);
}
/**
* `--abbrev-ref`: the ref a revision names, shortened as git shortens it, and
* the error git prints in its place.
*
* The name is found by git's rev-parse rules and followed through symbolic
* refs, so HEAD reads as its branch and `origin/HEAD` as what it points at;
* then the shortest unambiguous spelling is kept, by every other rule under
* `strict` and by the earlier ones otherwise. A name two refs answer to prints
* nothing and an error instead, while `core.warnAmbiguousRefs` is on; a
* revision that names no ref prints nothing. Pinned against git 2.47.3.
*
* @param vfs the session's files
* @param gitdir this checkout's git directory
* @param table every ref, as loadRefs reads them
* @param revision the revision as typed
* @param strict `=strict`, or `core.warnAmbiguousRefs` when no mode is given
* @param warn `core.warnAmbiguousRefs`
*/
async function abbreviated(vfs, gitdir, table, revision, strict, warn) {
const named = refsNamed(table, revision);
if (warn && named.length > 1)
return ['', `error: refname '${revision}' is ambiguous\n`];
const [name] = named;
if (name === undefined)
return ['', ''];
const found = await resolveSymbolic(vfs, gitdir, table, name, true);
if (found === null)
return ['', ''];
return [`${shortenRef(found.name, new Set(table.keys()), strict)}\n`, ''];
}
/**
* How strictly `--abbrev-ref` shortens: `strict` against every other rule,
* `loose` against the earlier ones, and with no mode as
* `core.warnAmbiguousRefs` says.
*
* @param mode the option's value, true when it has none
* @param warn `core.warnAmbiguousRefs`
* @throws AbbrevModeError any other mode
*/
function abbrevStrict(mode, warn) {
if (mode === 'strict')
return true;
if (mode === 'loose')
return false;
if (typeof mode === 'string')
throw new AbbrevModeError(mode);
return warn;
}
/**
* `git rev-parse`: each revision's object id, and the answers to the options
* that report where the line runs, in the order the line gives them. With
* `--verify` (or `--short`, which implies it) there must be exactly one
* revision, printed after everything else, and `-q` turns the refusal into a
* bare exit 1.
*/
export async function revParse(_accessor, paths, texts, ctx) {
const fl = new FlagView(ctx.flags);
const quiet = fl.asBool('quiet');
let repo;
try {
const marked = escaped(ctx.argv ?? []);
const revisions = texts.filter((text) => text !== GIT_DIR_OPTION || marked.has(text));
checkOperands(ctx, revisions, loneHelp(ctx, revisions));
const argv = ctx.argv ?? [];
const verb = argv.indexOf('rev-parse');
const words = argv.slice(verb + 1);
const end = words.indexOf('--');
const named = end === -1 ? words : words.slice(0, end);
const toplevel = named.includes(SHOW_TOPLEVEL);
const mode = fl.raw('abbrev_ref');
if (typeof mode === 'string')
abbrevStrict(mode, true);
repo = await opened(ctx, fl, toplevel);
const start = startPoint(fl).virtual;
if (toplevel && inGitDir(start, repo.location))
throw new NotAWorkTreeError();
const answers = await placeAnswers(repo, start);
if (fl.asBool('is_shallow_repository')) {
const shallow = await readOptional(repo.vfs, repo.location.commondir.join('shallow'));
answers.set('--is-shallow-repository', `${String(shallow !== null)}\n`);
}
const short = fl.raw('short');
const verify = fl.asBool('verify') || short !== undefined;
const width = shortWidth(short, repo.abbrev);
const refs = await loadRefs(repo.vfs, repo.location.gitdir, repo.location.commondir);
const warn = await configBool(repo, 'core.warnAmbiguousRefs', true);
const strict = mode === undefined ? warn : abbrevStrict(mode, warn);
const shown = [];
const errors = [];
// What stops the line: git prints what it answered before it, then the
// revision it could not read as one, then the refusal.
let failed = null;
for (const revision of revisions) {
let oid;
try {
oid = (await resolveObject(repo, revision)).oid;
}
catch (err) {
if (!(err instanceof GitError))
throw err;
if (verify)
throw new SingleRevisionError();
shown.push(`${revision}\n`);
failed = err;
break;
}
if (mode === undefined) {
const unique = short === undefined
? oid.length
: ((await uniqueAbbreviations(repo, new Map([[oid, width]]))).get(oid) ?? width);
shown.push(`${oid.slice(0, unique)}\n`);
continue;
}
const [line, error] = await abbreviated(repo.vfs, repo.location.gitdir, refs, revision, strict, warn);
shown.push(line);
// git prints each name's error right after its warning, so the error
// joins the warnings' list; -q keeps it while it drops them.
if (error === '')
continue;
if (quiet || repo.ambiguous === null)
errors.push(error);
else
repo.ambiguous.push(error);
}
if (verify && shown.length !== 1)
throw new SingleRevisionError();
const rows = [];
let next = 0;
for (const [at, word] of words.entries()) {
if (failed !== null && next === shown.length)
break;
const ended = end !== -1 && at > end;
const answer = ended ? undefined : answers.get(word);
if (answer !== undefined)
rows.push(answer);
else if (!verify && (ended || !word.startsWith('-')) && next < shown.length)
rows.push(shown[next++] ?? '');
}
for (const item of shown.slice(next))
rows.push(item);
if (failed !== null) {
const refused = fatal(failed);
if (refused !== null)
return [ENC.encode(rows.join('')), refused[1]];
}
const stderr = errors.length > 0 ? ENC.encode(errors.join('')) : null;
return [ENC.encode(rows.join('')), new IOResult({ stderr })];
}
catch (err) {
if (err instanceof SingleRevisionError && quiet)
return [null, new IOResult({ exitCode: 1 })];
if (err instanceof GitError)
return fatal(err);
throw err;
}
finally {
if (quiet)
repo?.ambiguous?.splice(0);
}
}
//# sourceMappingURL=inspect.js.map