UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

414 lines • 18.9 kB
// ========= 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