@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
242 lines • 10.3 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 { Errors } from 'isomorphic-git';
import { getOpcodes } from "../../../builtin/diff_format.js";
import { plural } from "./dates.js";
import { short } from "./format.js";
import { readBlobBytes } from "./repo.js";
import { compareCodePoints } from "../../../../utils/sort.js";
import { textWidth } from "../../../../utils/width.js";
import { decodeText, encodeText } from "../../../../shell/bytes.js";
const ROOT_COMMIT = '(root-commit) ';
// A diffstat is 80 columns wide when the output is not a terminal, and a file
// reads as binary when its first 8000 bytes hold a NUL.
const STAT_WIDTH = 80;
export const BINARY_SNIFF = 8000;
// The narrowest name column and graph a diffstat shrinks to, how a cut name
// starts, and what a binary row puts in the count column.
const NARROWEST_NAME = 10;
const NARROWEST_GRAPH = 6;
const ELIDED = '...';
const BINARY_COUNT = 'Bin';
// What a row spends besides the name, count and graph: the leading space,
// " | ", the space after the count, and the last column, left free.
const FRAME = 6;
const SLASH = 0x2f;
const DEC = new TextDecoder('utf-8', { fatal: false });
/**
* A blob's bytes, empty when there is nothing to read.
*
* A gitlink names a commit in another repository, so its id is legitimately
* absent from this store, or names one of its commits rather than a blob; it
* reads as empty rather than failing the whole table.
*/
async function blobData(repo, oid) {
if (oid === null)
return new Uint8Array(0);
try {
return await readBlobBytes(repo, oid);
}
catch (err) {
if (err instanceof Errors.NotFoundError || err instanceof Errors.ObjectTypeError)
return new Uint8Array(0);
throw err;
}
}
/** Line insertions and deletions between two text blobs. */
function countLines(oldData, newData) {
const split = (data) => {
const text = DEC.decode(data);
return text === '' ? [] : text.split(/(?<=\n)/);
};
let insertions = 0;
let deletions = 0;
for (const [tag, i1, i2, j1, j2] of getOpcodes(split(oldData), split(newData))) {
if (tag === 'equal')
continue;
deletions += i2 - i1;
insertions += j2 - j1;
}
return [insertions, deletions];
}
/**
* Per-path change counts between two trees, in path order.
*
* A binary file (NUL in the first 8000 bytes of either side, git's own sniff)
* counts zero lines; a mode-only change counts zero too but still occupies a
* row, which is how git prints `path | 0`.
*/
export async function diffstat(repo, before, after) {
const stats = [];
for (const path of [...new Set([...before.keys(), ...after.keys()])].sort(compareCodePoints)) {
const old = before.get(path) ?? null;
const now = after.get(path) ?? null;
if (old?.oid === now?.oid && old?.mode === now?.mode)
continue;
const oldData = await blobData(repo, old?.oid ?? null);
const newData = await blobData(repo, now?.oid ?? null);
const binary = oldData.subarray(0, BINARY_SNIFF).includes(0) || newData.subarray(0, BINARY_SNIFF).includes(0);
let insertions = 0;
let deletions = 0;
if (!binary && old?.oid !== now?.oid) {
;
[insertions, deletions] = countLines(oldData, newData);
}
stats.push({
path,
insertions,
deletions,
binary,
oldSize: oldData.byteLength,
newSize: newData.byteLength,
});
}
return stats;
}
/**
* How many graph columns a scaled count takes: one for anything nonzero and its
* share of the rest, rounded down (git 2.50.1 gives the largest change the
* whole graph and 1 of 1000 lines in a 21-wide graph one mark).
*
* @param count the lines counted
* @param graph the graph's width
* @param largest the largest change in the table
*/
function marks(count, graph, largest) {
return count ? 1 + Math.floor((count * (graph - 1)) / largest) : 0;
}
/**
* A path cut to the name column from the left: `...` and the tail from the
* next `/` on. The cut drops as many bytes as the path is columns too wide,
* which is why git 2.50.1 leaves a path of wide characters wider than the
* column.
*
* @param path the path as the table shows it
* @param column the name column's width
*/
function fittedName(path, column) {
const shown = textWidth(path);
if (shown <= column)
return path;
const over = shown - (column - ELIDED.length);
let tail = encodeText(path).subarray(Math.max(over, 0));
const slash = tail.indexOf(SLASH);
if (slash >= 0)
tail = tail.subarray(slash);
return ELIDED + decodeText(tail);
}
/**
* git's diffstat table: one row per path, then the summary line.
*
* A row is ` <name> | <count> <graph>`, at most `width - 1` columns, and never
* narrower than a 10-column name and a 6-column graph allow. The count column
* fits the largest change, and `Bin` when a binary row reads
* `Bin <old> -> <new> bytes`, whose text after `Bin` claims graph room too.
* When the names and the graph do not both fit, a graph needing no more than
* its share (three eighths of the width, less the count column and six) keeps
* what it needs and the names take the rest; a wider one gets that share, at
* least 6 columns, or more if the names are short, and is scaled into it: the
* total, and then the smaller side (deletions on a tie), take their marks and
* the larger side keeps the rest, with two marks at least when both are
* nonzero. Measured against git 2.50.1 and 2.47.3 over 400 tables at 24 widths
* each.
*
* @param stats per-path counts from `diffstat`
* @param width total line budget, git's `--stat-width`
*/
export function statTable(stats, width = STAT_WIDTH) {
if (stats.length === 0)
return [];
const largest = Math.max(0, ...stats.filter((s) => !s.binary).map((s) => s.insertions + s.deletions));
const sizes = stats
.filter((s) => s.binary)
.map((s) => `${String(s.oldSize)} -> ${String(s.newSize)} bytes`);
const countColumn = Math.max(String(largest).length, sizes.length > 0 ? BINARY_COUNT.length : 0);
const wanted = Math.max(largest, ...sizes.map((size) => size.length));
const widest = Math.max(...stats.map((s) => textWidth(s.path)));
const total = Math.max(width, NARROWEST_NAME + NARROWEST_GRAPH + countColumn + FRAME);
const room = total - FRAME - countColumn;
const share = Math.floor((total * 3) / 8) - FRAME - countColumn;
let graph;
let column;
if (widest + wanted <= room)
[graph, column] = [wanted, widest];
else if (wanted <= share)
[graph, column] = [wanted, room - wanted];
else {
const given = Math.max(NARROWEST_GRAPH, share, room - widest);
[graph, column] = [Math.min(wanted, given), Math.min(widest, room - given)];
}
const rows = stats.map((stat) => {
const name = fittedName(stat.path, column);
const cell = ` ${name}${' '.repeat(Math.max(0, column - textWidth(name)))} | `;
if (stat.binary)
return `${cell}${BINARY_COUNT.padStart(countColumn)} ${String(stat.oldSize)} -> ${String(stat.newSize)} bytes`;
let plus = stat.insertions;
let minus = stat.deletions;
if (largest > graph) {
const whole = Math.max(marks(plus + minus, graph, largest), plus && minus ? 2 : 0);
if (plus < minus) {
plus = marks(plus, graph, largest);
minus = whole - plus;
}
else {
minus = marks(minus, graph, largest);
plus = whole - minus;
}
}
const bar = '+'.repeat(plus) + '-'.repeat(minus);
const count = String(stat.insertions + stat.deletions).padStart(countColumn);
return `${cell}${count}${bar ? ` ${bar}` : ''}`;
});
const insertions = stats.reduce((sum, s) => sum + s.insertions, 0);
const deletions = stats.reduce((sum, s) => sum + s.deletions, 0);
return [...rows, statLine(stats.length, insertions, deletions)];
}
/**
* git's one-line diffstat.
*
* A clause that would read zero is dropped, unless both would, in which case
* both are kept: a commit that changed a file without changing a line still has
* to say something about the lines, and " 1 file changed" alone reads like a
* truncation. Pinned against git 2.47 by committing an empty file.
*/
function statLine(files, insertions, deletions) {
const parts = [` ${plural(files, 'file')} changed`];
if (insertions !== 0 || deletions === 0)
parts.push(`${plural(insertions, 'insertion')}(+)`);
if (deletions !== 0 || insertions === 0)
parts.push(`${plural(deletions, 'deletion')}(-)`);
return parts.join(', ');
}
/**
* What `git commit` prints once the commit exists.
*
* The title line, then the counts and `--summary` lines that `commitSummary`
* renders for the change the commit records.
*
* @param oid the commit just written
* @param message its message
* @param branch the branch it landed on, null when detached
* @param changes the rendered counts and summary lines
* @param width how many hex digits to abbreviate the id to
* @param root whether this is the repository's first commit
*/
export function report(oid, message, branch, changes, width, root) {
const title = message.split('\n')[0] ?? '';
const where = branch ?? 'detached HEAD';
const marker = root ? ROOT_COMMIT : '';
return `[${where} ${marker}${short(oid, width)}] ${title}\n${changes}`;
}
//# sourceMappingURL=summary.js.map