UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

383 lines • 15.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 { compareCodePoints } from "../../../../utils/sort.js"; import { HEAD } from "./constants.js"; import { BadRefNameUpdateError } from "./errors.js"; import { isDirectory, readFile, readNames, readOptional, removeFile, writeFile } from "./io.js"; import { entryName } from "../../../../utils/remnants.js"; export const HEAD_FILE = 'HEAD'; const PACKED_REFS = 'packed-refs'; const REFS_DIR = 'refs'; export const MAX_SYMREF_DEPTH = 5; const SAFE_ONE_LEVEL = /^[A-Z_]+$/; export const SYMREF_PREFIX = 'ref: '; export const BRANCH_PREFIX = 'refs/heads/'; export const REMOTE_PREFIX = 'refs/remotes/'; const ENC = new TextEncoder(); const DEC = new TextDecoder('utf-8', { fatal: false }); /** * Resolve `.git/HEAD` to a branch name or a detached commit. * * HEAD holds either a symbolic ref (`ref: refs/heads/main`) or a raw object id * when the checkout is detached. A ref outside `refs/heads` keeps its full name, * which is what git shows for a checked-out tag or remote-tracking ref. */ export async function readHead(vfs, gitdir) { const text = DEC.decode(await readFile(vfs, gitdir.join(HEAD_FILE))).trim(); if (!text.startsWith(SYMREF_PREFIX)) { return { branch: null, ref: null, commit: text === '' ? null : text }; } const ref = text.slice(SYMREF_PREFIX.length).trim(); const branch = ref.startsWith(BRANCH_PREFIX) ? ref.slice(BRANCH_PREFIX.length) : ref; return { branch, ref, commit: null }; } /** * Collect loose refs under one directory into the ref table. * * Ref names nest arbitrarily (`refs/heads/feat/git-cli`, * `refs/remotes/origin/main`), so the walk recurses rather than listing one * level. */ async function walkLooseRefs(vfs, root, prefix, refs) { for (const entry of await readNames(vfs, root)) { const name = entryName(entry); if (name === '') continue; const child = root.join(name); if (await isDirectory(vfs, child)) { await walkLooseRefs(vfs, child, `${prefix}/${name}`, refs); continue; } const data = await readOptional(vfs, child); if (data === null) continue; const value = DEC.decode(data).trim(); if (value !== '') refs.set(`${prefix}/${name}`, value); } } /** * Point one ref at an object id, as a loose ref file. * * Always written loose, never into `packed-refs`: git does the same for any ref * it updates, and a loose file takes precedence over the packed copy, so a * branch that was packed is correctly overridden rather than duplicated. * * Refs live in the common directory, so a branch made from a linked worktree is * visible to the repository it was cut from, which is what makes `git worktree` * share branches at all. * * A name whose path would leave the ref tree is refused here as git's ref * transaction refuses it, whatever the caller checked: the name is joined onto * the git directory, so `refs/heads/../../config` would land on the * repository's own configuration. Every verb checks its name before it gets * here, so this is the last guard, not the refusal a user sees. * * @throws BadRefNameUpdateError a name `safeRefName` refuses */ export async function writeRef(vfs, commondir, ref, sha) { if (!safeRefName(ref)) throw new BadRefNameUpdateError(ref); await writeFile(vfs, commondir.join(ref), ENC.encode(`${sha}\n`)); } /** * `packed-refs` with one ref's lines removed, null if it held none. * * A packed ref is two lines rather than one when it is an annotated tag: the tag * object's own id, then a `^` line holding the commit it peels to. The peeled * line belongs to the ref above it, so dropping a ref drops the `^` line that * follows it and nothing else. */ export function withoutPacked(text, ref) { const kept = []; let dropped = false; let found = false; for (const line of text.split('\n')) { if (line.startsWith('^')) { if (!dropped) kept.push(line); continue; } dropped = false; if (line !== '' && !line.startsWith('#')) { const space = line.indexOf(' '); if (space !== -1 && line.slice(space + 1).trim() === ref) { dropped = true; found = true; continue; } } kept.push(line); } return found ? kept.join('\n') : null; } /** * Remove a ref, loose copy and packed copy alike. * * Both are removed because either alone can be what holds the ref, and removing * only the loose one would report a deletion the next read undoes: after * `git pack-refs` a ref exists nowhere else, and a force-updated one exists in * both, where dropping the loose file would resurrect the older packed value. */ export async function deleteRef(vfs, commondir, ref) { await removeFile(vfs, commondir.join(ref)); const path = commondir.join(PACKED_REFS); const data = await readOptional(vfs, path); if (data === null) return; const rewritten = withoutPacked(DEC.decode(data), ref); if (rewritten !== null) await writeFile(vfs, path, ENC.encode(rewritten)); } /** Point HEAD at a branch, symbolically. */ export async function setHead(vfs, gitdir, ref) { await writeFile(vfs, gitdir.join(HEAD_FILE), ENC.encode(`${SYMREF_PREFIX}${ref}\n`)); } /** Point HEAD straight at a commit, detaching it from any branch. */ export async function detachHead(vfs, gitdir, sha) { await writeFile(vfs, gitdir.join(HEAD_FILE), ENC.encode(`${sha}\n`)); } /** * Read `packed-refs`, discarding the peeled lines. * * `packed-refs` records an annotated tag twice: the tag object's own id, then a * `^` line holding the commit it points at. The peeled id is a lookup shortcut, * not a separate ref, so it is read and discarded; resolving a tag loads the tag * object and follows it. A reader that treats a peeled line as a ref would * publish a second entry under the same name. */ function parsePackedRefs(data) { const refs = new Map(); for (const line of DEC.decode(data).split('\n')) { const text = line.trim(); if (text === '' || text.startsWith('#') || text.startsWith('^')) continue; const space = text.indexOf(' '); if (space === -1) continue; refs.set(text.slice(space + 1).trim(), text.slice(0, space)); } return refs; } /** * Read every ref a repository publishes, packed and loose. * * Both sources are needed and neither is optional: a freshly cloned repository * keeps `refs/remotes/origin/main` only in `packed-refs`, while a branch * committed to since the last pack exists only as a loose file. Loose wins on a * collision, which is git's own precedence. * * Refs come from two directories when the two differ. A linked worktree shares * its branches with the repository it was cut from and keeps only its own HEAD * and per-checkout refs (`refs/bisect`, `refs/worktree`), so the shared table is * read first and the worktree's own overrides it, then HEAD last of all. * * @param vfs the session's files * @param gitdir this checkout's git directory, which owns HEAD * @param commondir the shared git directory, which owns the branches; null means * it is the same directory, which is every ordinary checkout */ export async function loadRefs(vfs, gitdir, commondir = null) { const shared = commondir ?? gitdir; const refs = new Map(); const packed = await readOptional(vfs, shared.join(PACKED_REFS)); if (packed !== null) { for (const [name, sha] of parsePackedRefs(packed)) refs.set(name, sha); } await walkLooseRefs(vfs, shared.join(REFS_DIR), REFS_DIR, refs); if (gitdir.virtual !== shared.virtual) { await walkLooseRefs(vfs, gitdir.join(REFS_DIR), REFS_DIR, refs); } const head = await readHead(vfs, gitdir); if (head.ref !== null) refs.set(HEAD_FILE, `${SYMREF_PREFIX}${head.ref}`); else if (head.commit !== null) refs.set(HEAD_FILE, head.commit); return refs; } export const TAG_PREFIX = 'refs/tags/'; // Every character git forbids anywhere in a ref name, on top of the control // characters: the shell metacharacters that would make a name unusable as a // revision, and the backslash. const FORBIDDEN_IN_REF = new Set([' ', '~', '^', ':', '?', '*', '[', '\\']); const LOCK_SUFFIX = '.lock'; /** * The existing ref that stops a new one from being written. * * A ref is a path, so two of them cannot coexist when one spells a directory * the other spells a file: with `refs/tags/foo` already there, * `refs/tags/foo/bar` has no directory to live in, and with `refs/tags/foo/bar` * there, `refs/tags/foo` has a directory standing on its name. git refuses both * and names the ref already written; a repository can only ever hold one of the * two shapes, so the two searches cannot both answer. * * Nothing below git's own storage can be relied on to say so. A disk mount * raises whatever its host filesystem raises, which reaches the user as neither * git's wording nor git's exit code, and a prefix store takes both keys happily * and leaves a ref the loose-ref walk cannot find. * * @param known every ref the repository holds * @param ref the full ref name about to be written */ export function blockingRef(known, ref) { const parts = ref.split('/'); for (let depth = 1; depth < parts.length; depth += 1) { const above = parts.slice(0, depth).join('/'); if (known.has(above)) return above; } const below = `${ref}/`; const found = [...known].filter((name) => name.startsWith(below)).sort(compareCodePoints); return found[0] ?? null; } /** * Whether a name passes git's ref rules (`git check-ref-format`). * * The rules, in git's own order: no component may start with `.` or end with * `.lock`; `..` may not appear; no control character, space or shell * metacharacter; no leading, trailing or doubled `/`; no trailing `.`; and no * `@{`. Empty is refused too. A bare `@` is refused only as a whole ref, and a * name here always sits below `refs/`, so it passes. Pinned against git 2.50.1. * * @param name the name below `refs/heads/` or `refs/tags/` */ export function validRefName(name) { if (name === '' || name.startsWith('/') || name.endsWith('/')) return false; if (name.includes('//') || name.includes('..') || name.includes('@{') || name.endsWith('.')) { return false; } for (const ch of name) { const code = ch.codePointAt(0) ?? 0; if (code < 0x20 || code === 0x7f || FORBIDDEN_IN_REF.has(ch)) return false; } return name.split('/').every((part) => !part.startsWith('.') && !part.endsWith(LOCK_SUFFIX)); } /** * Whether git takes a name for a new branch: git's ref rules, and two more * that hold for a branch only: no leading `-`, which would read as an option, * and not `HEAD`, which `refs/heads/HEAD` would make ambiguous with HEAD * itself. Pinned against git 2.47.3. * * @param name the name below `refs/heads/` */ export function validBranchName(name) { return !name.startsWith('-') && name !== HEAD && validRefName(name); } /** * Whether git's ref rules take a whole ref name, one level allowed, as * `git check-ref-format --allow-onelevel` judges it: a bare `@` is the one name * they refuse whole that they take below `refs/`. * * @param name the full ref name */ export function wholeRefName(name) { return name !== '@' && validRefName(name); } /** * A ref's raw value, an object id or `ref: <target>`, null when there is none. * * Read from the ref table, or for a one-level name outside it (`ORIG_HEAD` and * its kin) from the checkout's git directory, which is where git keeps them. * * @param vfs the session's files * @param gitdir this checkout's git directory * @param table every ref, as loadRefs reads them * @param name the full ref name */ export async function rawRef(vfs, gitdir, table, name) { const known = table.get(name); if (known !== undefined) return known; if (name.startsWith(`${REFS_DIR}/`)) return null; const data = await readOptional(vfs, gitdir.join(name)); return data === null ? null : DEC.decode(data).trim(); } /** * Follow a symbolic ref the way git resolves one, without reading the object * it lands on. * * One hop when `recurse` is off, otherwise to the end of the chain, which may * name a ref that does not exist yet (an unborn branch). Null where git finds * no such ref: a name its rules refuse, or a chain more than five deep. 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 name the full ref name to start from * @param recurse follow every hop rather than the first */ export async function resolveSymbolic(vfs, gitdir, table, name, recurse) { if (!wholeRefName(name)) return null; let current = name; let symbolic = false; for (let depth = 0; depth < MAX_SYMREF_DEPTH; depth++) { const raw = await rawRef(vfs, gitdir, table, current); if (!raw?.startsWith(SYMREF_PREFIX)) return { name: current, symbolic }; symbolic = true; current = raw.slice(SYMREF_PREFIX.length).trim(); if (!recurse) return { name: current, symbolic }; if (!wholeRefName(current)) return null; } return null; } /** * Whether git lets a ref update write a name it has no object for: below * `refs/`, a path with no empty, `.` or `..` component; anywhere else, capitals * and underscores only, which is HEAD and its kin. Pinned against git 2.47.3. * * @param name the full ref name about to be written */ export function safeRefName(name) { if (name.startsWith(`${REFS_DIR}/`)) { return name .slice(REFS_DIR.length + 1) .split('/') .every((part) => part !== '' && part !== '.' && part !== '..'); } return SAFE_ONE_LEVEL.test(name); } /** Split a refspec into its source, destination and force flag. */ export function parseRefspec(text) { const force = text.startsWith('+'); const body = force ? text.slice(1) : text; const colon = body.indexOf(':'); const src = colon < 0 ? body : body.slice(0, colon); const dst = colon < 0 ? '' : body.slice(colon + 1); return { src, dst: dst || null, force }; } /** The local ref a refspec maps a remote ref to, null when unmatched. */ export function mapped(spec, name) { if (!spec.src.includes('*')) return name === spec.src ? (spec.dst ?? '') : null; const star = spec.src.indexOf('*'); const head = spec.src.slice(0, star); const tail = spec.src.slice(star + 1); if (name.length < head.length + tail.length || !name.startsWith(head) || !name.endsWith(tail)) return null; const middle = name.slice(head.length, name.length - tail.length); if (spec.dst === null) return ''; const at = spec.dst.indexOf('*'); return at < 0 ? spec.dst : `${spec.dst.slice(0, at)}${middle}${spec.dst.slice(at + 1)}`; } //# sourceMappingURL=refs.js.map