UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

534 lines • 23.6 kB
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