UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

579 lines • 24.3 kB
import git from 'isomorphic-git'; import { pairRenames, kindOf } from "./changes.js"; import { combinedLines } from "./combined.js"; import { TEMPLATE_KINDS, TERMINATED_KINDS } from "./constants.js"; import { GitError } from "./errors.js"; import { pathspecSelects } from "./pathspec.js"; import { commitHeader } from "./format.js"; import { blobData, DEV_NULL, filePatch, HUNK_CONTEXT, OID_HEX, shortOid } from "./patch.js"; import { quotePath } from "./render.js"; import { deltas, shownSides } from "./rows.js"; import { commitFacts, repoArgs } from "./repo.js"; import { readable, shownEntries } from "./seen.js"; import { BINARY_SNIFF, diffstat, statTable } from "./summary.js"; import { matches } from "./pickaxe.js"; import { TREE_MODE, treeEntries, treeItems } from "./tree.js"; import { compareCodePoints } from "../../../../utils/sort.js"; const RENAME_SCORE = 50; // What a combined diff's mode line starts with; `log --graph` prints that line // with no graph in front of it. export const COMBINED_MODE = 'mode '; export function parseDiffFlags(fl, defaultPatch = true, defaultMerge = 'off', porcelain = true, defaultRenames = true, quotePathFully = true) { const nameOnly = fl.asBool('name_only'), nameStatus = fl.asBool('name_status'), stat = fl.asBool('stat'), numstat = fl.asBool('numstat'), shortstat = fl.asBool('shortstat'), summary = fl.asBool('summary'), raw = fl.asBool('raw'); const modes = nameOnly || nameStatus || stat || numstat || shortstat || summary || raw; const rename = fl.raw('find_renames'); let threshold = porcelain && defaultRenames ? RENAME_SCORE : null; if (rename !== undefined && rename !== false) { threshold = RENAME_SCORE; if (typeof rename === 'string' && rename !== '') { threshold = rename.endsWith('%') ? Number(rename.slice(0, -1)) : Math.floor(Number(`0.${rename}`) * 100); if (!Number.isFinite(threshold)) throw new GitError(`invalid similarity index ${rename}`); } } if (fl.asBool('no_renames')) threshold = null; let merge = fl.asStr('diff_merges', defaultMerge); if (fl.asBool('m')) merge = 'separate'; if (fl.asBool('c')) merge = 'combined'; if (fl.asBool('cc')) merge = 'dense-combined'; if (porcelain && fl.asBool('first_parent')) merge = 'first-parent'; const aliases = { '0': 'off', '1': 'separate', on: 'separate', m: 'separate', c: 'combined', cc: 'dense-combined', }; merge = aliases[merge] ?? merge; if (!['off', 'separate', 'combined', 'dense-combined', 'first-parent'].includes(merge)) throw new GitError(`invalid value for '--diff-merges': '${merge}'`); const context = fl.asInt('unified') ?? HUNK_CONTEXT; if (context < 0) throw new GitError('negative context length'); const patch = fl.asBool('patch') || fl.asBool('function_context') || fl.asInt('unified') != null || ((defaultPatch || fl.asBool('cc') || (porcelain && fl.asBool('c'))) && !modes); return { context, functionContext: fl.asBool('function_context'), forceText: fl.asBool('text'), nameOnly, nameStatus, stat, numstat, shortstat, summary, patch, noPatch: fl.asBool('no_patch'), renames: threshold, merge, raw: raw || (!porcelain && !modes && !patch), abbrev: porcelain, quotePathFully, pathspecs: [], }; } export async function compare(repo, before, after, threshold) { const codes = new Map(), oids = new Map(), kinds = new Map(); for (const path of [...new Set([...before.keys(), ...after.keys()])].sort(compareCodePoints)) { const a = before.get(path), b = after.get(path); if (a?.oid === b?.oid && a?.mode === b?.mode) continue; codes.set(path, !a ? 'A' : !b ? 'D' : a.mode.slice(0, 3) !== b.mode.slice(0, 3) ? 'T' : 'M'); const entry = b ?? a; if (entry) { oids.set(path, entry.oid); kinds.set(path, kindOf(entry.mode)); } } const pairs = threshold === null ? new Map([...codes].map(([p, c]) => [p, [c, null, 0]])) : await pairRenames(repo, codes, oids, kinds, threshold); const rows = []; for (const [path, [status, origin, score]] of [...pairs].sort((a, b) => compareCodePoints(a[0], b[0]))) { const oldPath = origin ?? path, old = before.get(oldPath) ?? null, now = after.get(path) ?? null; rows.push({ path, oldPath, old, new: now, status, score }); } return rows; } /** * How a rename names its paths in a diffstat, as git prints it: when either * side needs quoting the two quoted paths stand whole, otherwise the shared * leading and trailing directories fold into `pre/{old => new}/post`. */ function renameName(old, fresh, fully = true) { const quotedOld = quotePath(old, false, fully), quotedFresh = quotePath(fresh, false, fully); if (quotedOld !== old || quotedFresh !== fresh) return `${quotedOld} => ${quotedFresh}`; const a = old.split('/'), b = fresh.split('/'), prefix = [], suffix = []; while (a.length > 1 && b.length > 1 && a[0] === b[0]) { prefix.push(a.shift() ?? ''); b.shift(); } while (a.length > 1 && b.length > 1 && a.at(-1) === b.at(-1)) { suffix.unshift(a.pop() ?? ''); b.pop(); } const middle = `${a.join('/')} => ${b.join('/')}`; return prefix.length || suffix.length ? [...prefix, `{${middle}}`, ...suffix].join('/') : middle; } function textLines(data) { const text = new TextDecoder().decode(data); return text === '' ? [] : text.split(/(?<=\n)/); } /** * Every requested format for one two-tree comparison, in git's order: * `--name-only` and `--name-status` stand alone; otherwise raw rows come first, * then numstat, stat and summary, then a blank line and the patch. */ export async function renderChanges(repo, rows, flags) { if (flags.noPatch) return ''; const width = flags.abbrev ? repo.abbrev : OID_HEX; const fully = flags.quotePathFully; let output = []; const numbers = [], summaries = []; const stats = [], patches = []; for (const row of rows) { const shown = quotePath(row.path, false, fully); const status = row.status === 'R' ? `R${String(row.score).padStart(3, '0')}` : row.status; const paths = row.status === 'R' ? `${quotePath(row.oldPath, false, fully)}\t${shown}` : shown; if (flags.nameOnly) { output.push(shown); continue; } if (flags.nameStatus) { output.push(`${status}\t${paths}`); continue; } if (flags.raw) output.push(`:${row.old?.mode ?? '000000'} ${row.new?.mode ?? '000000'} ${shortOid(row.old, width)} ${shortOid(row.new, width)} ${status}\t${paths}`); const display = row.status === 'R' ? renameName(row.oldPath, row.path, fully) : shown; if (flags.stat || flags.numstat || flags.shortstat) stats.push(await rowStat(repo, row, display)); const stat = stats.at(-1); if (flags.numstat && stat) numbers.push(`${stat.binary ? '-\t-' : `${String(stat.insertions)}\t${String(stat.deletions)}`}\t${display}`); if (flags.summary) for (const item of summaryLines(row, display, shown)) summaries.push(item); if (flags.patch) patches.push(await filePatch(repo, row.path, row.oldPath, row.old, row.new, row.status === 'R' ? row.score : null, repo.abbrev, fully, flags.context, flags.functionContext, flags.forceText)); } if (!(flags.nameOnly || flags.nameStatus)) { const table = statTable(stats); output = [ ...output, ...numbers, ...(flags.stat ? table : flags.shortstat ? table.slice(-1) : []), ...summaries, ]; } const head = output.map((l) => l + '\n').join(''), body = patches.join(''); return head + (head && body ? '\n' : '') + body; } /** The diffstat row for one change, named the way git prints it. */ async function rowStat(repo, row, display) { const before = new Map(row.old ? [[row.path, row.old]] : []), after = new Map(row.new ? [[row.path, row.new]] : []); // Counting lines reads both sides, as a patch does. for (const side of [row.old, row.new]) if (side !== null) readable(repo, side.oid); const counted = (await diffstat(repo, before, after))[0]; if (counted) return { ...counted, path: display }; const fresh = (await diffstat(repo, new Map(), after))[0]; if (!fresh) throw new Error('Missing renamed entry'); return { ...fresh, path: display, insertions: 0, deletions: 0, oldSize: fresh.newSize }; } /** The `--summary` lines for one change, as git prints them. */ function summaryLines(row, display, shown) { const old = row.old, fresh = row.new; if (row.status === 'R') { const lines = [` rename ${display} (${String(row.score)}%)`]; if (old && fresh && old.mode !== fresh.mode) lines.push(` mode change ${old.mode} => ${fresh.mode}`); return lines; } if (!old && fresh) return [` create mode ${fresh.mode} ${shown}`]; if (!fresh && old) return [` delete mode ${old.mode} ${shown}`]; if (old && fresh && old.mode !== fresh.mode) return [` mode change ${old.mode} => ${fresh.mode} ${shown}`]; return []; } /** * A tree's entries a diff compares, limited to a pathspec. * * @param repo repository to read * @param tree the tree id, null for the empty tree * @param recursive whether to descend into subtrees * @param pathspecs repository-relative patterns, all paths when empty */ export async function entries(repo, tree, recursive, pathspecs = []) { if (tree === null) return new Map(); const found = recursive ? await treeEntries(repo, tree) : new Map((await treeItems(repo, tree)).map((e) => [e.path, { mode: e.mode, oid: e.oid }])); // A commit's tree stands at the root; what the session cannot see there is // no side of any change, so no patch, count or match. return limited(shownEntries(repo, found), pathspecs); } /** * The entries a pathspec names, all of them for none. * * Applied before pairing, so a rename is found only where both paths match * (pinned against git 2.54). * * @param found path to entry * @param pathspecs repository-relative patterns */ export function limited(found, pathspecs) { if (!pathspecs.length) return found; return new Map([...found].filter(([path, entry]) => pathspecSelects(path, pathspecs, entry.mode === TREE_MODE))); } /** * The counts and summary lines `git commit` prints under its title. * * `--shortstat --summary` of the change, with renames found at git's default * score whatever `diff.renames` says, which is how git's commit summary reads * (pinned against git 2.50). * * @param repo repository to read blobs from * @param before the parent tree * @param after the new tree * @param fully `core.quotePath` */ export async function commitSummary(repo, before, after, fully = true) { return renderChanges(repo, await compare(repo, before, after, RENAME_SCORE), { context: 3, functionContext: false, forceText: false, nameOnly: false, nameStatus: false, stat: false, numstat: false, shortstat: true, summary: true, patch: false, noPatch: false, renames: RENAME_SCORE, merge: 'off', raw: false, abbrev: true, quotePathFully: fully, pathspecs: [], }); } /** * The pairs of one comparison a pickaxe keeps: the ones it matches, or every * pair under `--pickaxe-all` once one matches, and none when none does. * * The search runs on the pairs as renames left them, so a rename that keeps the * count matches nothing however many times the string occurs (git 2.47.3). * * @param repo repository holding the blobs * @param rows one comparison's pairs * @param pickaxe the search, null for none */ export async function picked(repo, rows, pickaxe) { if (!pickaxe) return rows; const kept = []; for (const row of rows) if (await matches(repo, pickaxe, row.old, row.new)) kept.push(row); return kept.length && pickaxe.every ? rows : kept; } /** The pairs one comparison's diff block shows, the pickaxe applied. */ async function rowsOf(repo, before, after, flags) { return picked(repo, await compare(repo, before, after, flags.renames), flags.pickaxe); } /** * One comparison's diff block, null when its sides do not differ or the pickaxe * keeps none of its pairs. */ async function block(repo, before, after, flags) { const rows = await rowsOf(repo, before, after, flags); return rows.length ? renderChanges(repo, rows, flags) : null; } /** * The comparisons a commit's diff makes, one per parent the merge mode * compares, each limited to the pathspec. * * A root commit compares with the empty tree when `root` says so, and a merge * under `--diff-merges=off` makes none. Mirrors Python's `commit_sides`. * * @param repo repository to read * @param commit the commit to diff * @param flags the diff flags * @param recursive whether to descend into subtrees * @param root whether a root commit diffs against the empty tree */ export async function commitSides(repo, commit, flags, recursive = true, root = true) { let trees = []; for (const p of commit.parents) trees.push((await commitFacts(repo, p)).tree); if (!trees.length && !root) return []; if (trees.length === 1 || flags.merge === 'first-parent') trees = trees.slice(0, 1); else if (trees.length > 1 && flags.merge === 'off') return []; const after = await entries(repo, commit.tree, recursive, flags.pathspecs); if (!trees.length) return [[new Map(), after]]; const sides = []; for (const tree of trees) sides.push([await entries(repo, tree, recursive, flags.pathspecs), after]); return sides; } /** * Whether a log the pickaxe filters prints a commit. * * A commit with one parent or none is printed when the pickaxe keeps a pair of * its diff. A merge follows the merge diff: never by default, when its first * parent's diff keeps one under first-parent, when any parent's does under * `-m`, and always under `-c` and `--cc`, whatever the pickaxe says (git * 2.47.3). * * @param repo repository to read * @param commit the commit * @param flags the diff flags, carrying the pickaxe */ export async function shows(repo, commit, flags) { if (!flags.pickaxe || isCombined(commit, flags)) return true; for (const [before, after] of await commitSides(repo, commit, flags)) if ((await rowsOf(repo, before, after, flags)).length) return true; return false; } /** * Every commit's comparisons as rows, and which comparisons are whose. * * Made one commit at a time, so only the changed paths of a long log are * held. Mirrors Python's `commit_deltas`. * * @param repo repository to read * @param commits the commits whose diffs print * @param flags the diff flags * @param recursive whether to descend into subtrees * @param root whether a root commit diffs against the empty tree */ export async function commitDeltas(repo, commits, flags, recursive = true, root = true) { const rows = []; const spans = new Map(); let count = 0; for (const commit of commits) { if (spans.has(commit.oid)) continue; const sides = await commitSides(repo, commit, flags, recursive, root); for (const row of deltas(repo.location, sides, count)) rows.push(row); spans.set(commit.oid, [count, count + sides.length]); count += sides.length; } return [rows, spans]; } /** * Each commit's comparisons, holding only the rows the session sees. Mirrors * Python's `commit_shown`. * * @param shown the rows the command boundary kept * @param spans which comparisons are whose, as `[start, stop)` */ export function commitShown(shown, spans) { let count = 0; for (const [, stop] of spans.values()) count = Math.max(count, stop); const sides = shownSides(shown, count); return new Map([...spans].map(([commit, [start, stop]]) => [commit, sides.slice(start, stop)])); } /** * A commit's diff blocks, one per comparison `commitSides` made. * * A comparison that does not differ is null, so its header is not printed * either (pinned against git 2.54). * * @param repo repository to read * @param commit the commit to diff * @param flags the diff flags * @param sides its comparisons, as the session sees them */ export async function commitOutput(repo, commit, flags, sides) { if (!sides.length || !isCombined(commit, flags)) { const out = []; for (const [before, after] of sides) out.push(await block(repo, before, after, flags)); return out; } const comparisons = []; for (const [before, after] of sides) comparisons.push(await rowsOf(repo, before, after, flags)); const maps = comparisons.map((rows) => new Map(rows.map((row) => [row.path, row]))); const common = [...(maps[0]?.keys() ?? [])] .filter((path) => maps.every((m) => m.has(path))) .sort(compareCodePoints); if (flags.noPatch) return []; const width = flags.abbrev ? repo.abbrev : OID_HEX; const names = flags.nameOnly || flags.nameStatus; const stat = !names && (flags.stat || flags.numstat || flags.shortstat || flags.summary) ? await renderChanges(repo, comparisons[0] ?? [], { ...flags, patch: false, raw: false }) : ''; const listing = (names || flags.raw ? common : []) .map((path) => { const rows = maps.map((m) => changeAt(m, path)), status = rows.map((r) => r.status).join(''), shown = quotePath(path, false, flags.quotePathFully); if (flags.nameOnly) return shown + '\n'; if (flags.nameStatus) return `${status}\t${shown}\n`; const ends = [...rows.map((r) => r.old), rows[0]?.new ?? null]; return (':'.repeat(maps.length) + ends.map((e) => e?.mode ?? '000000').join(' ') + ' ' + ends.map((e) => shortOid(e, width)).join(' ') + ` ${status}\t${shown}\n`); }) .join(''); const head = stat + listing; const body = flags.patch && !names ? await combinedPatch(repo, maps, common, flags.merge === 'dense-combined', flags.quotePathFully, flags.context) : ''; return [head + (head && body ? '\n' : '') + body]; } /** Binary combined patches stay binary under `--text`, as in Git 2.50.1. */ async function combinedPatch(repo, maps, paths, dense, fully = true, context = 3) { const output = []; for (const path of paths) { const rows = maps.map((m) => changeAt(m, path)), fresh = rows[0]?.new ?? null, old = rows.map((r) => r.old); const data = await blobData(repo, fresh), parents = []; for (const entry of old) parents.push(await blobData(repo, entry)); const binary = [...parents, data].some((d) => d.subarray(0, BINARY_SNIFF).includes(0)); const body = binary ? [] : combinedLines(parents.map(textLines), textLines(data), dense, context); const mode = fresh?.mode ?? '000000'; const moved = old.some((e) => (e?.mode ?? '000000') !== mode); if (!binary && !body.length && !moved) continue; const deleted = fresh === null, created = !deleted && rows.every((r) => r.status === 'A'); output.push(`diff --${dense ? 'cc' : 'combined'} ${quotePath(path, false, fully)}\n`); output.push('index ' + old.map((e) => shortOid(e, repo.abbrev)).join(',') + '..' + shortOid(fresh, repo.abbrev) + '\n'); if (moved && created) output.push(`new file mode ${mode}\n`); else if (moved) output.push((deleted ? 'deleted file mode ' : COMBINED_MODE) + old.map((e) => e?.mode ?? '000000').join(',') + (deleted ? '' : `..${mode}`) + '\n'); if (binary) { output.push('Binary files differ\n'); continue; } output.push(`--- ${created ? DEV_NULL : quotePath(`a/${path}`, false, fully)}\n`, `+++ ${deleted ? DEV_NULL : quotePath(`b/${path}`, false, fully)}\n`); for (const line of body) output.push(line); } return output.join(''); } function changeAt(rows, path) { const row = rows.get(path); if (!row) throw new Error(`Missing combined-diff entry: ${path}`); return row; } export async function renamesEnabled(repo) { const value = (await git.getConfig({ ...repoArgs(repo), path: 'diff.renames' })); return (value === undefined || !['false', 'no', 'off', '0', ''].includes(String(value).toLowerCase())); } /** * Whether a commit's diff is a combined one (`-c`, `--cc`), which only a merge * gets. * * @param commit the commit whose diff follows * @param flags the diff flags */ export function isCombined(commit, flags) { return (commit.parents.length > 1 && (flags.merge === 'combined' || flags.merge === 'dense-combined')); } /** * The line git prints between a commit's text and its diff, null for none. * * `---` when both a diffstat and a patch follow, otherwise an empty line, * and none under oneline. A combined diff (`-c`, `--cc`) always gets the * empty line, oneline included. Pinned against git 2.50.1. * * @param commit the commit whose diff follows * @param kind the pretty format's kind * @param flags the diff flags */ export function separatorLine(commit, kind, flags) { if (isCombined(commit, flags)) return ''; if (kind === 'oneline') return null; return flags.stat && flags.patch ? '---' : ''; } export function joinOutput(commit, head, bodies, kind, width, flags, emptySummary = false) { if (!bodies.length) return head; const line = separatorLine(commit, kind, flags); const blocks = []; bodies.forEach((body, index) => { if (body === null) return; let text = head; if (bodies.length > 1 && !TEMPLATE_KINDS.includes(kind)) { const length = kind === 'oneline' ? width : OID_HEX; const id = commit.oid.slice(0, length), parent = (commit.parents[index] ?? '').slice(0, length); text = text.replace(id, `${id} (from ${parent})`); } let gap = text && body && line !== null ? `${line}\n` : ''; if (!body && text && (emptySummary || isCombined(commit, flags))) gap = '\n'; blocks.push(text + gap + body); }); const separator = TEMPLATE_KINDS.includes(kind) || TERMINATED_KINDS.includes(kind) ? '' : '\n'; return blocks.join(separator) || head; } /** * A commit's log entry and its diff against its parents, as `log` and `show` * print it. A commit that changes nothing the pathspec names prints nothing at * all. * * @param repo repository to read * @param commit the commit to show * @param display the invocation's presentation flags * @param diff the diff options * @param sides its comparisons, as the session sees them * @param decor ref labels when the format asked * @param emptySummary put a blank line under a header whose diff is empty, as * `show` does for a summary or a combined diff * @param parents the parents the header prints, the commit's own when null */ export async function commitEntry(repo, commit, display, diff, sides, decor, emptySummary = false, parents = null) { const head = commitHeader(commit, display, repo.abbrev, decor, parents); const bodies = await commitOutput(repo, commit, diff, sides); return joinOutput(commit, head, bodies, display.pretty.kind, repo.abbrev, diff, emptySummary); } //# sourceMappingURL=diff_output.js.map