@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
171 lines • 8.07 kB
JavaScript
// ========= 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 { getOpcodes, groupOpcodes } from "../../../builtin/diff_format.js";
import { decodeText, encodeText } from "../../../../shell/bytes.js";
import { FUNCNAME_START, GIT_SPACE, GITLINK_MODE } from "./constants.js";
import { quotePath } from "./render.js";
import { readBlobBytes } from "./repo.js";
import { readable } from "./seen.js";
import { BINARY_SNIFF } from "./summary.js";
export const HUNK_CONTEXT = 3;
const FUNCNAME_BYTES = 80;
const HUNK_HEADER_BYTES = 128;
export const OID_HEX = 40;
export const DEV_NULL = '/dev/null';
/** An entry's bytes: a blob's contents, a submodule's commit line, empty for a missing side. */
export async function blobData(repo, entry) {
if (!entry)
return new Uint8Array();
if (entry.mode === GITLINK_MODE)
return encodeText(`Subproject commit ${entry.oid}\n`);
readable(repo, entry.oid);
return readBlobBytes(repo, entry.oid);
}
/** A blob's lines, each keeping its newline, as xdiff splits them. */
export function byteLines(data) {
const text = decodeText(data);
return text === '' ? [] : text.split(/(?<=\n)/);
}
/** An entry's object id cut to `width`, zeros for a missing side. */
export function shortOid(entry, width) {
return (entry?.oid ?? '0'.repeat(OID_HEX)).slice(0, width);
}
/**
* One path's patch, headers and hunks, as git diff prints it. A change between
* a file and a symlink is split into a deletion and a creation, the two patches
* git diff prints for a type change. A `---` or `+++` label holding a space
* ends in a tab, so a patch tool can tell where the name stops.
*/
export async function filePatch(repo, path, oldPath, before, after, score, width, fully = true, count = HUNK_CONTEXT, functionContext = false, forceText = false) {
if (before && after && before.mode.slice(0, 3) !== after.mode.slice(0, 3)) {
const rest = [score, width, fully, count, functionContext, forceText];
return ((await filePatch(repo, path, oldPath, before, null, ...rest)) +
(await filePatch(repo, path, oldPath, null, after, ...rest)));
}
const source = quotePath(`a/${oldPath}`, false, fully), target = quotePath(`b/${path}`, false, fully);
const head = [`diff --git ${source} ${target}`];
if (!before && after)
head.push(`new file mode ${after.mode}`);
else if (!after && before)
head.push(`deleted file mode ${before.mode}`);
else if (before && after && before.mode !== after.mode)
head.push(`old mode ${before.mode}`, `new mode ${after.mode}`);
if (score !== null)
head.push(`similarity index ${String(score)}%`, `rename from ${quotePath(oldPath, false, fully)}`, `rename to ${quotePath(path, false, fully)}`);
if (before && before.oid === after?.oid)
return text(head);
head.push(`index ${shortOid(before, width)}..${shortOid(after, width)}` +
(before && before.mode === after?.mode ? ` ${after.mode}` : ''));
const old = await blobData(repo, before), fresh = await blobData(repo, after);
const from = before ? source : DEV_NULL, to = after ? target : DEV_NULL;
if (!forceText && [old, fresh].some((data) => data.subarray(0, BINARY_SNIFF).includes(0)))
return text([...head, `Binary files ${from} and ${to} differ`]);
const body = hunks(byteLines(old), byteLines(fresh), count, functionContext);
return (text(body ? [...head, `--- ${from}${labelTab(from)}`, `+++ ${to}${labelTab(to)}`] : head) + body);
}
/** The tab git puts after a `---`/`+++` label holding a space. */
function labelTab(label) {
return label.includes(' ') ? '\t' : '';
}
function text(rows) {
return rows.map((row) => row + '\n').join('');
}
/**
* The `@@` hunks of a two-way patch, as git diff prints them. Each
* header carries the nearest earlier line of the old side that starts with a
* letter, `_` or `$` (git's default funcname), and keeps the previous hunk's
* when none lies between the two.
*/
function hunks(old, fresh, count, functionContext = false) {
const out = [];
let context = '';
let searched = -1;
const codes = getOpcodes(old, fresh);
const groups = groupOpcodes(codes, count);
for (const group of functionContext ? functionGroups(old, codes, groups) : groups) {
const first = group[0], last = group.at(-1);
if (first === undefined || last === undefined)
continue;
const start = first[1];
for (let k = start - 1; k > searched; k--) {
const line = old[k] ?? '';
if (FUNCNAME_START.test(line)) {
context = trimmed(encodeText(line).subarray(0, FUNCNAME_BYTES));
break;
}
}
searched = start - 1;
let header = `@@ -${span(start, last[2])} +${span(first[3], last[4])} @@`;
if (context) {
const room = HUNK_HEADER_BYTES - encodeText(header).length - 2;
header += ' ' + decodeText(encodeText(context).subarray(0, room));
}
out.push(header + '\n');
for (const [tag, i1, i2, j1, j2] of group) {
if (tag === 'equal') {
for (const line of old.slice(i1, i2))
out.push(hunkLine(' ', line));
continue;
}
for (const line of old.slice(i1, i2))
out.push(hunkLine('-', line));
for (const line of fresh.slice(j1, j2))
out.push(hunkLine('+', line));
}
}
return out.join('');
}
function trimmed(bytes) {
let end = bytes.length;
while (end > 0 && GIT_SPACE.has(bytes[end - 1] ?? 0))
end--;
return decodeText(bytes.subarray(0, end));
}
function span(start, stop) {
if (stop - start === 1)
return String(start + 1);
return `${String(stop > start ? start + 1 : start)},${String(stop - start)}`;
}
function hunkLine(marker, line) {
return line.endsWith('\n') ? marker + line : `${marker}${line}\n\\ No newline at end of file\n`;
}
/** Widen to Git's default function boundaries (Git 2.47.3 Debian / 2.50.1). */
function functionGroups(old, codes, groups) {
const boundaries = old.flatMap((line, i) => (FUNCNAME_START.test(line) ? [i] : []));
const ranges = [];
for (const group of groups) {
const changes = group.filter((code) => code[0] !== 'equal');
const first = changes[0], last = changes.at(-1);
if (!first || !last || !group[0] || !group.at(-1))
continue;
const start = Math.min(group[0][1], boundaries.filter((i) => i <= first[1]).at(-1) ?? 0);
let end = boundaries.find((i) => i >= Math.max(last[2], last[1] + 1)) ?? old.length;
while (end < old.length && end > last[2] && !(old[end - 1] ?? '').trim())
end--;
end = Math.max(end, group.at(-1)?.[2] ?? end);
const previous = ranges.at(-1);
if (previous && start <= previous[1])
previous[1] = Math.max(previous[1], end);
else
ranges.push([start, end]);
}
return ranges.map(([start, end]) => codes.flatMap(([tag, i1, i2, j1, j2]) => {
if (tag === 'equal') {
const lo = Math.max(start, i1), hi = Math.min(end, i2);
return lo < hi ? [[tag, lo, hi, j1 + lo - i1, j1 + hi - i1]] : [];
}
return i1 <= end && i2 >= start ? [[tag, i1, i2, j1, j2]] : [];
}));
}
//# sourceMappingURL=patch.js.map