UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

706 lines • 29 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, { Errors } from 'isomorphic-git'; import { DWIM_RULES, GITLINK_MODE, HEAD } from "./constants.js"; import { AmbiguousArgumentError, BadRevisionError, InvalidRevisionNameError, PathNotAtStageError, PathNotInIndexError, PathNotInRevisionError, } from "./errors.js"; import { readIndex } from "./index_file.js"; import { exists, readOptional } from "./io.js"; import { visiblePath } from "./pathspec.js"; import { isEnotdir } from "../../../../errors/fs.js"; import { MINIMUM_ABBREV } from "./ref_fields.js"; import { loadRefs, TAG_PREFIX } from "./refs.js"; import { commitFacts, repoArgs } from "./repo.js"; import { guarded, located, placeOf, placed, rooted } from "./seen.js"; const ANCESTOR = '~'; const PARENT = '^'; const SUFFIXES = [ANCESTOR, PARENT]; // `<rev>^{<type>}` peels to a type; `<rev>:<path>` reads a tree. const PEEL_OPEN = '^{'; const PEEL_CLOSE = '}'; const PATH_MARK = ':'; export const COMMIT = 'commit'; export const TREE = 'tree'; export const TAG = 'tag'; // Not a type any object reports: `^{object}` asks only that the name resolve to // something, and hands back whatever that is. export const OBJECT = 'object'; const STAGED = /^[0-3]:/; // `A..B` hides A and walks B; a third dot walks both and hides only what they // share. A leading caret hides one revision on its own. const RANGE = '..'; const SYMMETRIC_DOT = '.'; const NEGATION = '^'; /** * Split a revision into its base and the operators applied to it. * * git reads a revision left to right: every `~n`, `^n` and `^{<type>}` applies * to whatever the one before it produced, so `HEAD^{commit}~1` is the parent of * HEAD and `HEAD~1^{tree}` is that parent's tree. Reading the peel as a trailing * thing instead refused every chain that did not end in one, and reading `^{` as * an ancestry step is worse than refusing: `^` with no digits means "first * parent", so `HEAD^{tree}` would answer with HEAD's parent without a word. * Splitting on the first `~` or `^` is safe because git forbids both in a ref * name, so neither can belong to the base. Pinned against git 2.50.1. * * Only `~` and `^` open an operator, and reading anything else as one is silent * rather than loud: every other character counted as another first-parent hop, * so `HEAD^x` resolved to `HEAD^^` and the caller was handed a commit it never * named. git refuses the whole expression instead, and refuses `main~٣` with it, * since the digits it counts are ASCII. * * @param revision revision as the user spelled it * @throws AmbiguousArgumentError when the rest holds anything but operators */ function splitOperators(revision) { let index = revision.length; for (let i = 0; i < revision.length; i++) { if (SUFFIXES.includes(revision.charAt(i))) { index = i; break; } } const base = revision.slice(0, index); const rest = revision.slice(index); const ops = []; let position = 0; while (position < rest.length) { const kind = rest.charAt(position); if (!SUFFIXES.includes(kind)) throw new AmbiguousArgumentError(revision); if (rest.startsWith(PEEL_OPEN, position)) { const close = rest.indexOf(PEEL_CLOSE, position); if (close < 0) throw new AmbiguousArgumentError(revision); ops.push({ want: rest.slice(position + PEEL_OPEN.length, close) }); position = close + 1; continue; } position += 1; let digits = ''; while (position < rest.length && /[0-9]/.test(rest.charAt(position))) { digits += rest.charAt(position); position += 1; } ops.push({ firstParent: kind === ANCESTOR, count: digits === '' ? 1 : Number.parseInt(digits, 10), }); } return [base === '' ? HEAD : base, ops]; } /** Load one commit's parents by object id, or report the revision as unknown. */ async function parentsOf(repo, oid, revision) { try { const { commit } = await git.readCommit({ ...repoArgs(repo), oid }); return [...commit.parent]; } catch (err) { if (err instanceof Errors.NotFoundError || err instanceof Errors.ObjectTypeError) throw new AmbiguousArgumentError(revision); throw err; } } /** * Apply one ancestry suffix to a commit. * * `~n` walks n generations along first parents; `^n` takes the n-th parent of * this commit, and `^0` is the commit itself (git's way of spelling "the commit * a tag points at"). */ async function applyStep(repo, oid, step, revision) { let current = oid; if (step.firstParent) { for (let i = 0; i < step.count; i++) { const parents = await parentsOf(repo, current, revision); const first = parents[0]; if (first === undefined) throw new AmbiguousArgumentError(revision); current = first; } return current; } if (step.count === 0) return current; const parents = await parentsOf(repo, current, revision); const picked = parents[step.count - 1]; if (picked === undefined) throw new AmbiguousArgumentError(revision); return picked; } /** * Resolve a revision to a commit id, ancestry suffixes included. * * isomorphic-git resolves refs, full ids and unambiguous short ids, and peels a * tag; it knows nothing about `~` and `^`, which are applied here on top of * whatever its own parser returns. * * A `^{}` or `^{commit}` peel asks for exactly what this returns and is * honoured; a peel naming any other type is a revision this caller cannot use, * so it is refused rather than quietly stripped. * * @param repo repository to resolve against * @param revision revision as the user spelled it */ export async function resolveCommit(repo, revision) { const [base, ops] = splitOperators(revision); const named = await namedObject(repo, base, revision); // A tag names a tag object, not the commit under it; peel until it is one. const start = await unwrapped(repo, { oid: named, type: await typeOf(repo, named, revision) }, revision); if (start.type !== COMMIT) throw new AmbiguousArgumentError(revision); let oid = start.oid; for (const op of ops) { if ('want' in op) { // A peel this caller can use is one that lands back on a commit: `^{}` // and `^{commit}` do, `^{tree}` does not, and refusing the object rather // than the spelling is what lets a peel sit in the middle of a chain. const found = await peeled(repo, { oid, type: COMMIT }, op.want, revision); if (found.type !== COMMIT) throw new AmbiguousArgumentError(revision); oid = found.oid; continue; } oid = await applyStep(repo, oid, op, revision); } return oid; } /** * Every ref git's rev-parse rules find for a name, in rule order: more than one * is a name git calls ambiguous, and the first is the one it reads. * * @param table every ref, as loadRefs reads them * @param name the name as typed */ export function refsNamed(table, name) { return [...new Set(DWIM_RULES.map((rule) => rule.replace('{}', name)))].filter((ref) => table.has(ref)); } /** * The object id a revision's base names, a ref read without peeling it or an * id, full or abbreviated; an abbreviation shorter than four hex digits names * nothing, however few objects it would match (git 2.47.3). * * A name two refs answer to reads as the first, and git warns that it is * ambiguous each time it reads one, which is where the repository's list of * warnings gets the line (pinned against git 2.47.3). * * @param repo repository to resolve against * @param base the name or id, operators already split off * @param revision the whole revision, for error attribution */ async function namedObject(repo, base, revision) { let oid; try { oid = await git.resolveRef({ ...repoArgs(repo), ref: base }); } catch (err) { if (!(err instanceof Errors.NotFoundError)) throw err; if (base.length < MINIMUM_ABBREV) throw new AmbiguousArgumentError(revision); try { return await git.expandOid({ ...repoArgs(repo), oid: base }); } catch (missed) { if (missed instanceof Errors.NotFoundError || missed instanceof Errors.AmbiguousError) throw new AmbiguousArgumentError(revision); throw missed; } } await noteAmbiguity(repo, base); return oid; } /** * Put git's `refname is ambiguous` warning on the repository's list when two * refs answer to a name, as git does each time it reads one. * * @param repo the opened repository * @param name the name as typed */ export async function noteAmbiguity(repo, name) { if (repo.ambiguous === null) return; const table = await loadRefs(repo.vfs, repo.location.gitdir, repo.location.commondir); if (refsNamed(table, name).length > 1) { repo.ambiguous.push(`warning: refname '${name}' is ambiguous.\n`); } } /** The type isomorphic-git records for one object id. */ async function typeOf(repo, oid, revision) { try { // Deprecated upstream for being general, but the general answer is what // peeling needs: which of commit/tag/tree/blob this id names, without // reading it as each in turn until one does not throw. // eslint-disable-next-line @typescript-eslint/no-deprecated return (await git.readObject({ ...repoArgs(repo), oid })).type; } catch (err) { if (err instanceof Errors.NotFoundError) throw new AmbiguousArgumentError(revision); throw err; } } /** * What an object stands for once every tag wrapper is off. * * An annotated tag can point at another one, so this is a walk rather than a * single hop. Anything that is no tag is already what it stands for and comes * back untouched. * * Every reading that wants a particular kind of object goes through this: a * bare id names the tag itself, so a caller asking for a tree-ish or for the * tree behind `<rev>:<path>` has one to take off, and git takes it off in both * places. * * @param repo the opened repository * @param found the object to unwrap * @param revision the whole revision, for error attribution */ export async function unwrapped(repo, found, revision) { let { oid, type } = found; const tagged = type === TAG; while (type === TAG) { oid = (await git.readTag({ ...repoArgs(repo), oid })).tag.object; type = await typeOf(repo, oid, revision); } if (tagged && (await located(repo, { oid, type })) === null) throw new AmbiguousArgumentError(revision); return { oid, type }; } /** * Follow a `^{<type>}` peel from the object the stem named. * * A tag is unwrapped until the type asked for is reached, which for `^{}` and * for every non-tag type means unwrapping it entirely. `^{tag}` is the one * spelling that stops before the first hop, so `v1^{tag}` is the tag object * itself rather than a refusal saying the commit behind it is no tag. * `^{tree}` then takes a commit's tree, git's one implicit step; every other * spelling has to already name the type it asks for, so `HEAD^{blob}` is * refused rather than answered with something else. * * A lightweight tag is still refused by `^{tag}`: the name resolves straight to * a commit, so there is no tag object to stop at and the type check below is * what says so. */ async function peeled(repo, found, want, revision) { // An existence check, not a type. Every object reports a concrete type name, // so comparing one against `object` refuses every expression that spells it; // gitrevisions(7) has it return the named object and nothing else, which for // an annotated tag is the tag rather than the commit behind it. if (want === OBJECT) return found; let { oid, type } = want === TAG ? found : await unwrapped(repo, found, revision); if (want === '') return { oid, type }; if (want === TREE && type === COMMIT) { oid = (await git.readCommit({ ...repoArgs(repo), oid })).commit.tree; rooted(repo, oid, revision); type = TREE; } if (type !== want) throw new AmbiguousArgumentError(revision); return { oid, type }; } /** * The object a `<rev>:<path>` names inside a tree. * * @param repo the opened repository * @param rev the revision before the colon, HEAD when empty * @param path the path after it, repository-relative * @param revision the whole revision, for error attribution */ async function atPath(repo, rev, path, revision) { let named; try { named = await resolveObject(repo, rev); } catch (err) { if (err instanceof AmbiguousArgumentError) throw new InvalidRevisionNameError(rev); throw err; } // A tag is no tree and holds no path, so it comes off first: the rev half is // a tree-ish, and `<tag-id>:a.txt` reads the blob through it exactly as // `v1:a.txt` does. let holder = await unwrapped(repo, named, revision); if (holder.type === COMMIT) { const tree = (await git.readCommit({ ...repoArgs(repo), oid: holder.oid })).commit.tree; rooted(repo, tree); holder = { oid: tree, type: TREE }; } let found; try { // eslint-disable-next-line @typescript-eslint/no-deprecated const read = await git.readObject({ ...repoArgs(repo), oid: holder.oid, filepath: path }); found = { oid: read.oid, type: read.type }; } catch (err) { if (err instanceof Errors.NotFoundError || err instanceof Errors.ObjectTypeError) throw new PathNotInRevisionError(path, rev, await onDisk(repo, path)); throw err; } // The path is the tree's own, so it stands where the tree does: a commit's // at the root, a subtree named by id where it was found. A hidden place is // one the history never had, in the same words. const base = await placeOf(repo, holder.oid); const place = base ? `${base}/${path}` : path; if (guarded(repo) && !visiblePath(repo.location, place)) throw new PathNotInRevisionError(path, rev, false); return placed(repo, found, place, revision); } /** * Whether a repository-relative path is there in the working tree; a path * through a file is not. */ async function onDisk(repo, path) { if (path === '') return false; try { return await exists(repo.vfs, repo.location.worktree.join(path)); } catch (err) { if (isEnotdir(err)) return false; throw err; } } /** * The object `:<path>` or `:<n>:<path>` names in the index: the staged entry * at stage 0, or at the merge stage given. A path the index holds at another * stage, or not at all, is refused in git's words (pinned against git * 2.50.1). * * @param repo the opened repository * @param spec what follows the leading colon */ async function inIndex(repo, spec) { const staged = STAGED.exec(spec); const stage = staged === null ? 0 : Number(spec.charAt(0)); const path = staged === null ? spec : spec.slice(staged[0].length); // A hidden place is one the index never held, in the same words. if (guarded(repo) && !visiblePath(repo.location, path)) throw new PathNotInIndexError(path, false); const state = await readIndex(repo, repo.vfs); const conflicted = state.conflicts.get(path); const stages = [ state.entries.get(path), conflicted?.ancestor, conflicted?.this, conflicted?.other, ]; const found = stages[stage]; if (found !== undefined && found !== null) { const type = found.mode.toString(8) === GITLINK_MODE ? COMMIT : 'blob'; return placed(repo, { oid: found.oid, type }, path, PATH_MARK + spec); } const held = stages.findIndex((entry) => entry !== undefined && entry !== null); if (held !== -1) throw new PathNotAtStageError(path, stage, held); throw new PathNotInIndexError(path, await onDisk(repo, path)); } /** * The tag object a name or id denotes, null when it names no tag. * * Read before the stem is resolved, and only for `^{tag}`: every other peel type * sits at or below the commit, so unwrapping an annotated tag on the way is * git's own rule and costs nothing, while `^{tag}` is the one spelling that has * to stop above it. Resolving the stem the ordinary way cannot serve it, because * the commit-ish reading peels the tag before anything else sees it. * * Both spellings a tag answers to are tried, the ref and a raw id, and anything * that is not a tag object reads as no tag: a lightweight tag names a commit * directly, which is why git refuses `^{tag}` on one. */ export async function tagObject(repo, stem) { let oid; try { oid = await git.resolveRef({ ...repoArgs(repo), ref: `${TAG_PREFIX}${stem}` }); } catch (err) { if (!(err instanceof Errors.NotFoundError)) throw err; if (stem.length < MINIMUM_ABBREV) return null; try { oid = await git.expandOid({ ...repoArgs(repo), oid: stem }); } catch (missed) { if (missed instanceof Errors.NotFoundError || missed instanceof Errors.AmbiguousError) return null; throw missed; } } let type; try { type = await typeOf(repo, oid, stem); } catch (err) { if (err instanceof AmbiguousArgumentError) return null; throw err; } return type === TAG ? { oid, type } : null; } /** * The two ends of `A..B` or `A...B`, or null for one revision. * * Read the way git reads a range: the first `..` splits the operand, * a dot right after it makes the range symmetric, and an empty end is HEAD, so * `..side` is `HEAD..side`. */ function rangeEnds(revision) { const at = revision.indexOf(RANGE); if (at < 0) return null; let right = revision.slice(at + RANGE.length); const symmetric = right.startsWith(SYMMETRIC_DOT); if (symmetric) right = right.slice(SYMMETRIC_DOT.length); return [revision.slice(0, at) || HEAD, right || HEAD, symmetric]; } /** * Both ends of a range operand, or null when it names one revision. * * A lone `..` is a path to git, and mirage limits nothing by path, so it is no * range here and fails as the revision it is not. An end that does not resolve * fails naming the whole operand, as git's message does. * * @param repo repository to resolve against * @param revision the operand as the user spelled it * @returns the left end, the right end and whether the range is symmetric */ export async function rangeCommits(repo, revision) { const ends = revision === RANGE ? null : rangeEnds(revision); if (ends === null) return null; let left, right; try { left = await resolveCommit(repo, ends[0]); right = await resolveCommit(repo, ends[1]); } catch (err) { if (err instanceof AmbiguousArgumentError) throw new AmbiguousArgumentError(revision); throw err; } return [await commitFacts(repo, left), await commitFacts(repo, right), ends[2]]; } /** Put `commit` in `queue`: newest first, and in arrival order among equal dates. */ function enqueueByDate(queue, commit) { let at = queue.length; while (at > 0 && (queue[at - 1]?.committerTime ?? 0) < commit.committerTime) at -= 1; queue.splice(at, 0, commit); } /** * The common ancestors of two commits that nothing shared descends from. * * git-merge-base(1)'s best common ancestors: a common ancestor is better than * any of its own ancestors, and the bases are the common ancestors nothing is * better than. The other side is a hypothetical merge of `other` and `others`. * A newest-first climb spreads each side's reach down from its tips and stops * where both sides meet: everything beneath a meeting is common and worse, so a * second walk marks it for the climb to drop. A separate ancestry pass removes * redundant candidates without relying on commit timestamps. History shared by * every candidate cannot remove any of them. Bases come out newest first, equal * dates in the order the climb met them (pinned against `git merge-base --all` * 2.50.1). * * @param repo repository holding the commits * @param one one side * @param other the other side * @param others further tips of the hypothetical merged side */ export async function mergeBases(repo, one, other, others = []) { const data = await readOptional(repo.vfs, repo.location.commondir.join('shallow')); const shallow = new Set(new TextDecoder() .decode(data ?? new Uint8Array()) .trim() .split(/\s+/)); const tips = [one, other, ...others]; const known = new Map(tips.map((commit) => [commit.oid, commit])); const load = async (oid) => { let commit = known.get(oid); if (commit === undefined) { commit = await commitFacts(repo, oid); known.set(oid, commit); } return commit; }; const parentsOf = async (oid) => shallow.has(oid) ? [] : (await load(oid)).parents; // What `one` reaches, what the merged side reaches, and what lies beneath a // commit both reach. const fromOne = new Set([one.oid]); const fromRest = new Set([other, ...others].map((commit) => commit.oid)); const beneath = new Set(); const climbing = []; const sinking = []; const sinkBelow = async (oid) => { for (const parentId of await parentsOf(oid)) { if (beneath.has(parentId)) continue; beneath.add(parentId); enqueueByDate(sinking, await load(parentId)); } }; for (const tip of known.values()) enqueueByDate(climbing, tip); const meetings = new Map(); for (let commit = climbing.shift(); commit !== undefined; commit = climbing.shift()) { // Mark what lies beneath the meetings down to the climb's date, so the // climb drops a commit beneath one before it spreads further. for (let low = sinking[0]; low !== undefined && low.committerTime >= commit.committerTime; low = sinking[0]) { sinking.shift(); await sinkBelow(low.oid); } if (beneath.has(commit.oid)) continue; const onOne = fromOne.has(commit.oid); if (onOne && fromRest.has(commit.oid)) { if (!meetings.has(commit.oid)) { meetings.set(commit.oid, commit); await sinkBelow(commit.oid); } continue; } const reach = onOne ? fromOne : fromRest; for (const parentId of await parentsOf(commit.oid)) { if (reach.has(parentId)) continue; reach.add(parentId); enqueueByDate(climbing, await load(parentId)); } } const candidates = [...meetings.values()] .filter((commit) => !beneath.has(commit.oid)) .sort((a, b) => b.committerTime - a.committerTime); if (candidates.length <= 1) return candidates; const origins = new Map(candidates.map((commit, i) => [commit.oid, 1n << BigInt(i)])); const reached = new Map(origins); const pending = [...origins.keys()]; const redundant = new Set(); const allOrigins = (1n << BigInt(candidates.length)) - 1n; while (pending.length > 0) { const oid = pending.shift(); if (oid === undefined) break; const flags = reached.get(oid) ?? 0n; if (origins.has(oid) && flags !== origins.get(oid)) redundant.add(oid); if (flags === allOrigins || shallow.has(oid)) continue; for (const parent of (await commitFacts(repo, oid)).parents) { const had = reached.get(parent) ?? 0n; if ((had | flags) !== had) { reached.set(parent, had | flags); pending.push(parent); } } } return candidates.filter((commit) => !redundant.has(commit.oid)); } /** * The commits a walk starts from and the commits whose history it hides. * * `A..B` walks B and hides A, `A...B` walks both and hides their merge bases, * and `^A` hides A. An end that does not resolve fails naming the whole range, * and a negation that does not resolve is git's "bad revision", as is a negated * range (pinned against git 2.50). * * @param repo repository to resolve against * @param revisions the revision operands as spelled * @returns the commits to walk from and the commits to hide */ export async function splitRevisions(repo, revisions) { const shown = []; const hidden = []; for (const revision of revisions) { if (revision.startsWith(NEGATION)) { const name = revision.slice(NEGATION.length); if (!name || name.includes(RANGE)) throw new BadRevisionError(revision); let oid; try { oid = await resolveCommit(repo, name); } catch (err) { if (err instanceof AmbiguousArgumentError) throw new BadRevisionError(revision); throw err; } hidden.push(await commitFacts(repo, oid)); continue; } const ends = await rangeCommits(repo, revision); if (ends === null) { shown.push(await commitFacts(repo, await resolveCommit(repo, revision))); continue; } const [left, right, symmetric] = ends; if (symmetric) { shown.push(left, right); for (const item of await mergeBases(repo, left, right)) hidden.push(item); } else { shown.push(right); hidden.push(left); } } return [shown, hidden]; } /** * The object a revision names, whatever type it turns out to be. * * The whole grammar a caller that wants an object rather than a commit has to * read: `HEAD:a.txt` is the blob at a path, `HEAD^{tree}` is a commit's tree, * `v1^{}` is what a tag points at, and a bare id of any type is itself. A * commit-ish is tried first because that is what the operand is normally spelled * with, and it is the only reading that understands ancestry. * * @param repo repository to resolve against * @param revision revision as the user spelled it */ export async function resolveObject(repo, revision) { const mark = revision.indexOf(PATH_MARK); if (mark === 0) return inIndex(repo, revision.slice(1)); if (mark > 0) return atPath(repo, revision.slice(0, mark), revision.slice(mark + 1), revision); const [base, ops] = splitOperators(revision); // A name or id stands for exactly the object it names, so an annotated tag // is the tag rather than the commit behind it: git does not peel one until a // caller asks for a commit-ish, and `git rev-parse v1` prints the tag's id. // Every operator but `^{tag}` and `^{object}` unwraps a tag, which is git's // own rule and costs nothing here. A base that names nothing fails naming the // whole revision, as git's does. const named = await namedObject(repo, ops.length === 0 ? revision : base, revision); let obj = { oid: named, type: await typeOf(repo, named, revision) }; // An id, or a ref pointing past every commit, names an object with no place: // it is shown where the session sees it, else names nothing. if ((await located(repo, obj)) === null) throw new AmbiguousArgumentError(revision); for (const op of ops) { if ('want' in op) { obj = await peeled(repo, obj, op.want, revision); continue; } const commit = await unwrapped(repo, obj, revision); if (commit.type !== COMMIT) throw new AmbiguousArgumentError(revision); obj = { oid: await applyStep(repo, commit.oid, op, revision), type: COMMIT }; } return obj; } //# sourceMappingURL=revparse.js.map