UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

383 lines • 15.4 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 { TEMPLATE_KINDS } from "./constants.js"; import { showDate } from "./dates.js"; import { mappedIdentity } from "./mailmap.js"; import { DateKind, Decoration } from "./types.js"; import { byteChar } from "../../../../shell/bytes.js"; import { BadPrettyError, UnsupportedPrettyError } from "./errors.js"; const SHORT_SHA = 7; export const FULL_SHA = 40; const INDENT = ' '; export const DEFAULT_DATE = { kind: DateKind.NORMAL, local: false, strftime: '', now: 0, zone: null, }; const RAW_DATE = { ...DEFAULT_DATE, kind: DateKind.RAW }; // The ident date placeholders that name a style of their own, whatever --date // says: %ad alone follows it. const FIXED_DATE_KINDS = new Map([ ['D', DateKind.RFC2822], ['r', DateKind.RELATIVE], ['t', DateKind.UNIX], ['i', DateKind.ISO8601], ['I', DateKind.ISO8601_STRICT], ['h', DateKind.HUMAN], ['s', DateKind.SHORT], ]); // The presets this build renders, and the real git presets it refuses by // name rather than calling invalid. const PRESET_KINDS = ['oneline', 'short', 'medium', 'full', 'fuller', 'raw']; const UNSUPPORTED_PRESETS = ['email', 'mboxrd', 'reference']; const HEX_DIGITS = /[0-9a-fA-F]/; export const MEDIUM = { kind: 'medium', template: null }; /** * Read a --pretty/--format value the way git reads it. * * @throws UnsupportedPrettyError for a real git preset this build lacks * @throws BadPrettyError for a name git itself would refuse */ export function parsePretty(value) { if (value.startsWith('format:')) return { kind: 'format', template: value.slice(7) }; if (value.startsWith('tformat:')) return { kind: 'tformat', template: value.slice(8) }; // A bare % string is tformat; so is the empty string, which renders // every commit as nothing and therefore prints nothing at all. if (value.includes('%') || value === '') return { kind: 'tformat', template: value }; if (PRESET_KINDS.includes(value)) return { kind: value, template: null }; if (UNSUPPORTED_PRESETS.includes(value)) throw new UnsupportedPrettyError(value); throw new BadPrettyError(value); } /** * Whether rendering this format has to know the refs. * * Only `%d`/`%D` read them; git turns decorations off for piped preset * output, which is the only output mirage produces. */ export function needsDecorations(fmt) { if (fmt.template === null) return false; let cursor = 0; for (;;) { cursor = fmt.template.indexOf('%', cursor); if (cursor === -1 || cursor + 1 === fmt.template.length) return false; const marker = fmt.template[cursor + 1]; if (marker === 'd' || marker === 'D') return true; cursor += 2; } } /** * How many hex digits an abbreviated id needs in a repository. * * git widens the abbreviation as a repository grows, so `--oneline` on a large * repository prints nine characters where a fresh one prints seven, and a build * that always printed seven disagreed with real git on every line of every big * repository. * * Measured against git 2.50.1 rather than read off its source, and the boundary * is sharp: 16,383 packed objects abbreviate to 7 and 16,384 to 8, which is one * hex digit per two bits of object count, floored at git's seven. Confirmed * again at 20,102 (8), 70,102 (9) and 184,401 (9). * * Only packed objects count. The same 70,102 objects abbreviate to 7 while loose * and to 9 once packed, which is consistent with it being an estimate: a pack * index states its object count in its header, while counting loose objects * means walking 256 directories. * * @param packed how many objects the repository's packs hold */ export function abbrevLength(packed) { const bits = packed <= 0 ? 0 : Math.floor(Math.log2(packed)) + 1; return Math.max(SHORT_SHA, Math.ceil(bits / 2)); } /** * Abbreviate an object id the way `--oneline` prints it. * * @param sha hex object id * @param length how many hex digits to keep, from abbrevLength */ export function short(sha, length = SHORT_SHA) { return sha.slice(0, length); } /** The first paragraph of a commit message, folded onto one line. */ export function subject(commit) { return subjectFolded(commit.message); } /** * One `--oneline` row: abbreviated id then subject. * * @param commit the commit to render * @param length how many hex digits of the id to print */ export function oneline(commit, length = SHORT_SHA) { return `${short(commit.oid, length)} ${subject(commit)}`; } /** Remove Git's trailing line whitespace in one backward pass. */ function trimLine(line) { let end = line.length; while (end > 0) { const char = line[end - 1]; if (char !== ' ' && char !== '\t' && char !== '\r') break; end--; } return line.slice(0, end); } /** * A commit message indented the way log and show print it. * * Every line is indented by four spaces, blank lines included, so an empty line * inside a message renders as four spaces rather than as an empty one. Verified * against git 2.47.3; it is trailing whitespace on purpose. */ function messageBlock(commit) { const lines = commit.message.split('\n').map(trimLine); let start = 0; let end = lines.length; while (start < end && lines[start] === '') start++; while (end > start && lines[end - 1] === '') end--; return lines.slice(start, end).map((line) => `${INDENT}${line}`); } /** * The `Merge:` line every block preset prints for a merge, which git puts * between the id and the author for both `log` and `show`. * * @param parents the parents to print, the commit's own when null */ function mergeLine(commit, length, parents = null) { const shown = parents ?? commit.parents; if (shown.length <= 1) return []; return [`Merge: ${shown.map((p) => short(p, length)).join(' ')}`]; } /** * One commit as a block preset renders it (short/medium/full/fuller). * * Pinned against git 2.50: `medium` is the id, author, author date and * indented message; `short` is the id, author and indented subject; `full` * adds `Commit:` and drops both dates; `fuller` aligns four header lines to * the `AuthorDate:` column. * * @param parents the parents to print, the commit's own when null; a * path-limited log prints the ones it kept, though raw prints the stored ones * whatever the graph draws (git 2.47.3) */ export function presetBlock(commit, kind, length, date = DEFAULT_DATE, mailmap = [], parents = null) { if (kind === 'raw') return [ `commit ${commit.oid}`, `tree ${commit.tree}`, ...commit.parents.map((p) => `parent ${p}`), `author ${commit.authorName} <${commit.authorEmail}> ${showDate(commit.authorTime, commit.authorTimezoneMinutes, RAW_DATE)}`, `committer ${commit.committerName} <${commit.committerEmail}> ${showDate(commit.committerTime, commit.committerTimezoneMinutes, RAW_DATE)}`, '', ...messageBlock(commit), ]; const author = mappedIdentity(`${commit.authorName} <${commit.authorEmail}>`, mailmap); const committer = mappedIdentity(`${commit.committerName} <${commit.committerEmail}>`, mailmap); const lines = [`commit ${commit.oid}`, ...mergeLine(commit, length, parents)]; if (kind === 'medium') { lines.push(`Author: ${author}`, `Date: ${showDate(commit.authorTime, commit.authorTimezoneMinutes, date)}`, '', ...messageBlock(commit)); return lines; } if (kind === 'short') { lines.push(`Author: ${author}`, '', ...subjectLines(commit.message).map((line) => `${INDENT}${line}`)); return lines; } if (kind === 'full') { lines.push(`Author: ${author}`, `Commit: ${committer}`, '', ...messageBlock(commit)); return lines; } lines.push(`Author: ${author}`, `AuthorDate: ${showDate(commit.authorTime, commit.authorTimezoneMinutes, date)}`, `Commit: ${committer}`, `CommitDate: ${showDate(commit.committerTime, commit.committerTimezoneMinutes, date)}`, '', ...messageBlock(commit)); return lines; } /** The subject paragraph's lines, with Git's trailing whitespace removed. */ function subjectLines(message) { const head = message.replace(/^(?:[ \t\r]*\n)+/, '').split(/\n[ \t\r]*\n/, 1)[0] ?? ''; return head .split('\n') .map(trimLine) .filter((part) => part !== ''); } /** git's %s: the first paragraph folded onto one line. */ function subjectFolded(message) { return subjectLines(message).join(' '); } /** git's %b: the text after the subject and blank separator lines. */ function body(message) { const text = message.replace(/^(?:[ \t\r]*\n)+/, ''); const separator = /\n(?:[ \t\r]*\n)+(?:[ \t\r]+$)?/.exec(text); return separator === null ? '' : text.slice(separator.index + separator[0].length); } /** * Expand a format:/tformat: template for one commit. * * The scan matches git's behaviour pinned in docker: an unknown or * incomplete placeholder stays verbatim (`%q` prints `%q`), `%%` is a literal * percent, and `%xHH` names a raw output byte (`%x80` is the single byte * 0x80, carried by the shell's byte-escape convention until `encodeText` * writes it). Explicit cursor, one pass, like the stat -c engine. */ export function renderTemplate(template, commit, length, decor, date = DEFAULT_DATE, mailmap = [], parents = null) { const labels = decor?.get(commit.oid) ?? []; const out = []; let i = 0; while (i < template.length) { const char = template[i] ?? ''; if (char !== '%' || i + 1 === template.length) { out.push(char); i += 1; continue; } const marker = template[i + 1] ?? ''; const expanded = simplePlaceholder(marker, commit, length, labels, parents); if (expanded !== null) { out.push(expanded); i += 2; continue; } if ((marker === 'a' || marker === 'c') && i + 2 < template.length) { const pair = identPlaceholder(marker, template[i + 2] ?? '', commit, date, mailmap); if (pair !== null) { out.push(pair); i += 3; continue; } } if (marker === 'x' && i + 3 < template.length && HEX_DIGITS.test(template[i + 2] ?? '') && HEX_DIGITS.test(template[i + 3] ?? '')) { out.push(byteChar(parseInt(template.slice(i + 2, i + 4), 16))); i += 4; continue; } out.push(char + marker); i += 2; } return out.join(''); } /** One single-letter placeholder's value, null when it is not one. */ function simplePlaceholder(marker, commit, length, labels, parents = null) { const shown = parents ?? commit.parents; switch (marker) { case 'H': return commit.oid; case 'h': return short(commit.oid, length); case 'T': return commit.tree; case 't': return short(commit.tree, length); case 'P': return shown.join(' '); case 'p': return shown.map((p) => short(p, length)).join(' '); case 's': return subjectFolded(commit.message); case 'b': return body(commit.message); case 'B': return commit.message; case 'D': return labels.join(', '); case 'd': return labels.length > 0 ? ` (${labels.join(', ')})` : ''; case 'n': return '\n'; case '%': return '%'; default: return null; } } /** * An author/committer placeholder's value (%an, %cd, ...). * * Uppercase identity placeholders always apply the worktree mailmap. */ function identPlaceholder(who, field, commit, date, mailmap) { let name = who === 'a' ? commit.authorName : commit.committerName; let email = who === 'a' ? commit.authorEmail : commit.committerEmail; if (field === 'N' || field === 'E') { const identity = mappedIdentity(`${name} <${email}>`, mailmap); const at = identity.lastIndexOf(' <'); name = identity.slice(0, at); email = identity.slice(at + 2, -1); } const time = who === 'a' ? commit.authorTime : commit.committerTime; const zone = who === 'a' ? commit.authorTimezoneMinutes : commit.committerTimezoneMinutes; switch (field) { case 'n': case 'N': return name; case 'e': case 'E': return email; case 'd': return showDate(time, zone, date); default: { const kind = FIXED_DATE_KINDS.get(field); if (kind === undefined) return null; return showDate(time, zone, { ...date, kind, local: false, strftime: '' }); } } } /** * One commit's header, as `log` and `show` print it. * * `format:` is a separator, so a single commit prints with no trailing newline * at all; `tformat:` terminates the entry even when it renders empty, except * that an empty template prints nothing, which is how `--format=` stays * silent. The caller encodes through `encodeText` because `%xHH` names a raw * byte. Pinned against git 2.37 and 2.54. A decorated preset labels the commit * after its id. * * @param commit the commit to render * @param display the invocation's presentation flags * @param width abbreviated id width for this repository * @param decor ref labels when the format asked */ export function commitHeader(commit, display, width, decor, parents = null) { const fmt = display.pretty; const decorated = display.decorate !== Decoration.NONE; if (fmt.kind === 'oneline') { const length = display.abbrevCommit ? width : FULL_SHA; return `${decorated ? renderTemplate('%h%d %s', commit, length, decor) : oneline(commit, length)}\n`; } if (TEMPLATE_KINDS.includes(fmt.kind)) { const text = renderTemplate(fmt.template ?? '', commit, width, decor, display.date, display.mailmap, parents); if (fmt.kind === 'tformat') { return fmt.template === null || fmt.template === '' ? '' : `${text}\n`; } return text; } const block = presetBlock(commit, fmt.kind, width, display.date, display.useMailmap ? display.mailmap : [], parents); if (decorated && block[0]?.startsWith('commit ')) block[0] += renderTemplate('%d', commit, width, decor); return `${block.join('\n')}\n`; } //# sourceMappingURL=format.js.map