@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
414 lines • 18.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 { systemNow } from "../../../../utils/clock.js";
import { compareCodePoints } from "../../../../utils/sort.js";
import { FlagView } from "../../../spec/flag_view.js";
import { isBare } from "./discover.js";
import { FetchHeadReadOnlyError, GitError, MissingRepositoryError } from "./errors.js";
import { configLines, configValues } from "./fs.js";
import { resolvedRefs } from "./history.js";
import { globalSources } from "./inspect.js";
import { writeFile } from "./io.js";
import { append, entry, IDENTITY, ZERO } from "./reflog.js";
import { BRANCH_PREFIX, deleteRef, loadRefs, mapped, parseRefspec, readHead, REMOTE_PREFIX, SYMREF_PREFIX, TAG_PREFIX, validRefName, writeRef, } from "./refs.js";
import { objectType, openRepo, repoArgs, storePack } from "./repo.js";
import { refsNamed } from "./revparse.js";
import { opened } from "./session.js";
import { displayUrl, extraHeaders, openTransport, } from "./transport.js";
import { checkSwitches } from "./util.js";
const ENC = new TextEncoder();
const FETCH_HEAD = 'FETCH_HEAD';
const ALL_TAGS = 'refs/tags/*:refs/tags/*';
const TERM_COLUMNS = 80;
const REFCOL_MIN = 10;
// What a summary row spends beside its two ref names when the ref column
// is sized, counted off the row summaryLines writes: ` * ` (3), the
// summary column at the default abbrev of 7 (2 * 7 + 3) and the space
// after it make the 21 columns before the remote, and ` -> ` the 4 before
// the local. The sizing keeps that default whatever the repository's
// abbrev is: measured on git 2.47.3, a 23-character branch widens the
// column at core.abbrev=12 as well, and a 24-character one at neither.
const ROW_LEAD = ' * '.length + 2 * 7 + 3 + ' '.length;
const ROW_ARROW = ' -> '.length;
const UNREACHABLE = 'fatal: Could not read from remote repository.\n\n' +
'Please make sure you have the correct access rights\n' +
'and the repository exists.';
const NO_REMOTE = 'No remote repository specified. Please, specify either a URL or a\n' +
'remote name from which new revisions should be fetched.';
/** One remote ref a fetch takes, and where it lands. */
export function wanted(remote, oid, local, force = false, merge = false, listed = true) {
return { remote, oid, local, force, merge, listed };
}
/** One line of git's fetch summary. */
export function row(code, summary, remote, local, error = '', counted = false) {
return { code, summary, remote, local, error, counted };
}
/** A ref name the way git's fetch summary shortens it. */
export function prettify(ref) {
for (const prefix of [BRANCH_PREFIX, TAG_PREFIX, REMOTE_PREFIX])
if (ref.startsWith(prefix))
return ref.slice(prefix.length);
return ref;
}
/**
* The `From <url>` block git prints to stderr after a fetch.
*
* Pinned against git 2.47.3 and 2.50.1: the summary column is `2 * abbrev + 3`
* wide and the ref column at least ten, widened by each changed ref whose line
* still fits 80 columns.
*/
export function summaryLines(url, rows, abbrev) {
if (!rows.length)
return '';
const width = 2 * abbrev + 3;
let refcol = REFCOL_MIN;
for (const line of rows)
if (line.counted &&
ROW_LEAD + line.remote.length + ROW_ARROW + line.local.length < TERM_COLUMNS)
refcol = Math.max(refcol, line.remote.length);
const lines = [`From ${displayUrl(url)}\n`];
for (const line of rows) {
const note = line.error ? ` (${line.error})` : '';
lines.push(` ${line.code} ${line.summary.padEnd(width)} ${line.remote.padEnd(refcol)} -> ${line.local}${note}\n`);
}
return lines.join('');
}
/** Whether `old` is reachable from `fresh`, a fast-forward. */
async function isAncestor(repo, old, fresh) {
const seen = new Set();
const stack = [fresh];
for (let oid = stack.pop(); oid !== undefined; oid = stack.pop()) {
if (oid === old)
return true;
if (seen.has(oid))
continue;
seen.add(oid);
if ((await objectType(repo, oid)) === 'commit')
for (const item of (await git.readCommit({ ...repoArgs(repo), oid })).commit.parent)
stack.push(item);
}
return false;
}
/** Whether a repository holds an object. */
async function holds(repo, oid) {
return (await objectType(repo, oid)) !== null;
}
/** Fetch what `wants` reaches that is missing, then reopen. */
async function receive(repo, transport, wants) {
const refs = await resolvedRefs(repo);
const tips = new Set(refs.values());
const missing = [];
for (const want of new Set(wants))
if (!(await holds(repo, want)))
missing.push(want);
if (!missing.length)
return repo;
const pack = await transport.fetchPack(missing, [...tips].sort(compareCodePoints), tips.size ? (oid) => holds(repo, oid) : () => Promise.resolve(false));
await storePack(repo, pack);
return openRepo(repo.vfs, repo.location, repo.ambiguous);
}
/**
* Drop the refs whose local name git refuses to write.
*
* The remote names its refs, so one it advertises as `refs/tags/../../x` would
* land outside `.git`. git skips such a ref with an error and takes the rest
* (pinned against git 2.50.1). Returns the refs kept and the errors git prints
* for the others.
*/
export function ignoreFunny(wants) {
const kept = [];
let notes = '';
for (const want of wants) {
if (want.local === null || (want.local.startsWith('refs/') && validRefName(want.local)))
kept.push(want);
else
notes += `error: * Ignoring funny ref '${want.local}' locally\n`;
}
return [kept, notes];
}
/**
* The remote tags git follows: new here, pointing at what is here.
*
* A tag whose name git refuses is left out. git asks for it by name and dies
* on the answer; mirage takes the rest instead.
*/
async function followedTags(repo, adv, taken) {
const local = await resolvedRefs(repo);
const follow = [];
for (const [name, oid] of adv.refs) {
if (!name.startsWith(TAG_PREFIX) || taken.has(name) || local.has(name) || !validRefName(name))
continue;
if (await holds(repo, adv.peeled.get(name) ?? oid))
follow.push(wanted(name, oid, name));
}
return follow;
}
/**
* Bring in every wanted object, then the tags that follow them. Two rounds, as
* git does: the refs first, then any annotated tag whose target is now here but
* whose tag object is not.
*/
export async function fetchObjects(repo, transport, adv, wants, follow) {
let current = await receive(repo, transport, wants.map((want) => want.oid));
if (!follow)
return [current, [...wants]];
const tags = await followedTags(current, adv, new Set(wants.map((want) => want.remote)));
if (tags.length)
current = await receive(current, transport, tags.map((tag) => tag.oid));
return [current, [...wants, ...tags]];
}
/** The summary row for one ref update, and its reflog reason (null: not written). */
async function classify(repo, want, old) {
const local = want.local ?? '';
const remote = prettify(want.remote);
const shown = prettify(local);
if (old === want.oid)
return [row('=', '[up to date]', remote, shown), null];
if (old === undefined) {
if (local.startsWith(TAG_PREFIX))
return [row('*', '[new tag]', remote, shown, '', true), 'storing tag'];
const kind = want.remote.startsWith(BRANCH_PREFIX) ? '[new branch]' : '[new ref]';
return [row('*', kind, remote, shown, '', true), 'storing head'];
}
if (local.startsWith(TAG_PREFIX) && !want.force)
return [row('!', '[rejected]', remote, shown, 'would clobber existing tag', true), null];
const ends = [old.slice(0, repo.abbrev), want.oid.slice(0, repo.abbrev)];
if (await isAncestor(repo, old, want.oid))
return [row(' ', ends.join('..'), remote, shown, '', true), 'fast-forward'];
if (want.force)
return [row('+', ends.join('...'), remote, shown, 'forced update', true), 'forced-update'];
return [row('!', '[rejected]', remote, shown, 'non-fast-forward', true), null];
}
/** Move each local ref, returning the summary rows and any rejection. */
export async function updateRefs(repo, wants, reason, logged) {
const local = await resolvedRefs(repo);
const rows = [];
let rejected = false;
const now = Math.floor(systemNow());
for (const want of wants) {
if (want.local === null) {
const kind = want.remote.startsWith(TAG_PREFIX)
? 'tag'
: want.remote.startsWith(REMOTE_PREFIX)
? 'remote-tracking branch'
: 'branch';
rows.push(row('*', kind, prettify(want.remote), FETCH_HEAD));
continue;
}
const old = local.get(want.local);
const [line, why] = await classify(repo, want, old);
rows.push(line);
rejected ||= line.code === '!';
if (why === null)
continue;
await writeRef(repo.vfs, repo.location.commondir, want.local, want.oid);
if (logged && !want.local.startsWith(TAG_PREFIX))
await append(repo.vfs, repo.location.commondir, `logs/${want.local}`, entry(old ?? ZERO, want.oid, IDENTITY, now, `${reason}: ${why}`));
}
return [rows, rejected];
}
/** FETCH_HEAD: every listed ref, the ones marked for merge first. */
export function fetchHead(url, wants) {
const lines = [];
for (const merge of [true, false]) {
for (const want of wants) {
if (!want.listed || want.merge !== merge)
continue;
const short = prettify(want.remote);
const what = want.remote.startsWith(BRANCH_PREFIX)
? `branch '${short}' of `
: want.remote.startsWith(TAG_PREFIX)
? `tag '${short}' of `
: want.remote.startsWith(REMOTE_PREFIX)
? `remote-tracking branch '${short}' of `
: want.remote === 'HEAD'
? ''
: `'${want.remote}' of `;
lines.push(`${want.oid}\t${merge ? '' : 'not-for-merge'}\t${what}${displayUrl(url)}\n`);
}
}
return lines.join('');
}
/**
* A short refspec destination, spelled out as git does: `main:copy` lands in
* `refs/heads/copy` because `main` is a branch; a tag's lands under `refs/tags/`.
*/
function localName(dst, remote) {
if (dst === null || dst.startsWith('refs/') || dst === 'HEAD')
return dst;
return `${remote.startsWith(TAG_PREFIX) ? TAG_PREFIX : BRANCH_PREFIX}${dst}`;
}
/**
* The refs a fetch takes, in the order git lists them.
*
* Refspecs on the line are taken for FETCH_HEAD and marked for merge, and the
* configured ones then update their remote-tracking refs opportunistically;
* without any on the line the configured ones are the whole fetch, and without
* those it is the remote's HEAD.
*/
function plan(adv, typed, configured, merge, allTags) {
const names = [...adv.refs.keys()].filter((name) => name !== 'HEAD');
const out = [];
const specs = typed.length ? typed : configured;
for (const spec of specs) {
if (spec.src.includes('*')) {
for (const name of names) {
const dst = mapped(spec, name);
if (dst !== null)
out.push(wanted(name, adv.refs.get(name) ?? '', dst || null, spec.force, (!typed.length && name === merge) || typed.length > 0));
}
continue;
}
const [name] = refsNamed(adv.refs, spec.src);
if (name === undefined)
throw new GitError(`couldn't find remote ref ${spec.src}`);
out.push(wanted(name, adv.refs.get(name) ?? '', localName(spec.dst, name), spec.force, typed.length > 0 || name === merge));
}
const head = adv.refs.get('HEAD');
if (!specs.length && head !== undefined)
out.push(wanted('HEAD', head, null, false, true));
if (typed.length) {
const taken = new Set(out.map((want) => want.local));
for (const spec of configured) {
for (const want of [...out]) {
const dst = mapped(spec, want.remote);
if (dst && !taken.has(dst)) {
taken.add(dst);
out.push({ ...want, local: dst, force: spec.force, merge: false, listed: false });
}
}
}
}
if (allTags) {
const spec = parseRefspec(ALL_TAGS);
const taken = new Set(out.map((want) => want.remote));
for (const name of names)
if (mapped(spec, name) && !taken.has(name))
out.push(wanted(name, adv.refs.get(name) ?? '', name));
}
return out;
}
/** `http.extraHeader` from the user's config, then the repository's. */
export async function configuredHeaders(ctx, repo) {
const values = [];
if (ctx.env?.HOME || ctx.env?.GIT_CONFIG_GLOBAL !== undefined) {
for (const { data } of await globalSources(ctx, false))
for (const line of await configLines(new TextDecoder().decode(data)))
if (line.path === 'http.extraheader')
values.push(line.value ?? '');
}
if (repo !== null)
for (const item of await configValues(repo.vfs, repo.location, 'http.extraheader'))
values.push(item);
return extraHeaders(values);
}
/** The words after the verb, which git's reflog reason repeats. */
export function leafArgs(argv, verb) {
const at = argv.indexOf(verb);
return at < 0 ? [] : argv.slice(at + 1);
}
/** Delete the remote-tracking refs whose remote ref is gone. */
async function prune(repo, adv, specs) {
const rows = [];
const refs = await loadRefs(repo.vfs, repo.location.gitdir, repo.location.commondir);
const names = [...refs.entries()]
.filter(([, value]) => !value.startsWith(SYMREF_PREFIX))
.map(([name]) => name)
.sort(compareCodePoints);
for (const name of names) {
for (const spec of specs) {
if (!spec.dst?.includes('*'))
continue;
const source = mapped({ src: spec.dst, dst: spec.src, force: false }, name);
if (source && !adv.refs.has(source)) {
await deleteRef(repo.vfs, repo.location.commondir, name);
rows.push(row('-', '[deleted]', '(none)', prettify(name)));
break;
}
}
}
return rows;
}
/**
* Download objects and refs from another repository.
*
* Reaches a repository in the workspace through the dispatcher and an
* `https://` remote over smart HTTP. Updates the remote-tracking refs a
* remote's refspecs name, follows the tags that point into what arrived, writes
* FETCH_HEAD, and prints git's summary to stderr.
*/
export async function fetch(_accessor, paths, texts, ctx) {
const fl = new FlagView(ctx.flags);
checkSwitches(ctx, texts);
const repo = await opened(ctx, fl);
const { location } = repo;
const values = (path) => configValues(repo.vfs, location, path);
const head = await readHead(repo.vfs, location.gitdir);
let name = texts[0] ?? null;
if (name === null) {
name =
head.branch === null
? 'origin'
: ((await values(`branch.${head.branch}.remote`))[0] ?? 'origin');
if (!(await values(`remote.${name}.url`)).length && name === 'origin')
throw new GitError(NO_REMOTE);
}
const urls = await values(`remote.${name}.url`);
const url = urls.at(-1) ?? name;
const configured = urls.length ? (await values(`remote.${name}.fetch`)).map(parseRefspec) : [];
let merge = null;
if (head.branch !== null && urls.length) {
const remotes = await values(`branch.${head.branch}.remote`);
if (remotes.at(-1) === name)
merge = (await values(`branch.${head.branch}.merge`)).at(-1) ?? null;
}
const bare = await isBare(repo.vfs, location);
const start = bare ? location.gitdir : location.worktree;
let transport;
try {
transport = await openTransport(ctx, url, start, await configuredHeaders(ctx, repo));
}
catch (err) {
if (err instanceof MissingRepositoryError)
throw new GitError(`'${url}' does not appear to be a git repository\n${UNREACHABLE}`);
throw err;
}
const adv = await transport.advertise();
const typed = texts.slice(1).map(parseRefspec);
const [wants, notes] = ignoreFunny(plan(adv, typed, configured, merge, fl.asBool('tags')));
const checked = bare ? null : head.ref;
for (const want of wants)
if (want.local !== null && want.local === checked)
throw new GitError(`refusing to fetch into branch '${checked}' checked out at '${location.worktree.virtual}'`);
const tagOpt = await values(`remote.${name}.tagopt`);
const follow = !fl.asBool('no_tags') && tagOpt.at(-1) !== '--no-tags';
const [fetched, taken] = await fetchObjects(repo, transport, adv, wants, follow);
const pruned = fl.asBool('prune') && !typed.length ? await prune(fetched, adv, configured) : [];
const reason = ['fetch', ...leafArgs(ctx.argv ?? [], 'fetch')].join(' ');
const [rows, rejected] = await updateRefs(fetched, taken, reason, !bare);
await writeFile(repo.vfs, location.gitdir.join(FETCH_HEAD), ENC.encode(fetchHead(url, taken)));
const shown = [...pruned, ...rows.filter((line) => line.code !== '=' || fl.asBool('verbose'))];
const err = notes + (fl.asBool('quiet') ? '' : summaryLines(url, shown, fetched.abbrev));
return [null, new IOResult({ exitCode: rejected ? 1 : 0, stderr: ENC.encode(err) })];
}
/**
* fetch's refusal by a read-only mount, at FETCH_HEAD, named the way git names
* it from the top of the work tree.
*/
export const fetchReadOnly = (_texts, _ctx, location) => new FetchHeadReadOnlyError(location === null || location.gitdir.virtual === location.worktree.join('.git').virtual
? `.git/${FETCH_HEAD}`
: location.gitdir.join(FETCH_HEAD).virtual);
//# sourceMappingURL=fetch.js.map