Files

167 lines
5.4 KiB
JavaScript
Raw Permalink Normal View History

import crypto from 'crypto';
// Parsing a unified diff into addressable hunks lives here and only here. The
// model anchors its narrative to hunk ids, the client resolves those ids back
// to rendered code, and staleness is "an id the current diff no longer has" —
// all three break the moment two implementations disagree about what an id is,
// so the client is never given the algorithm, only the results.
const FILE_HEADER = /^diff --git /;
const HUNK_HEADER = /^@@\s+-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s+@@(.*)$/;
const shortHash = (value) => crypto.createHash('sha1').update(value).digest('hex').slice(0, 8);
const parsePathsFromFileHeader = (line) => {
// `diff --git a/old b/new`, with either side quoted when it contains spaces.
const match = /^diff --git (?:"?a\/(.+?)"?) (?:"?b\/(.+?)"?)$/.exec(line);
if (!match) return null;
return { oldPath: match[1], newPath: match[2] };
};
const statusFromHeaderLines = (lines) => {
if (lines.some((line) => line.startsWith('new file mode'))) return 'added';
if (lines.some((line) => line.startsWith('deleted file mode'))) return 'deleted';
if (lines.some((line) => line.startsWith('rename from'))) return 'renamed';
return 'modified';
};
const isBinaryHeader = (lines) => lines.some((line) => line.startsWith('Binary files ') || line.startsWith('GIT binary patch'));
/**
* Split a unified diff covering any number of files into files and hunks.
*
* @param {string} patch raw `git diff` output
* @param {string} scope opaque namespace for the ids (e.g. 'staged', 'branch').
* Two scopes of the same repository can contain byte-identical hunks; the
* scope keeps their ids distinct so a walkthrough written against staged
* changes never silently resolves against unstaged ones.
* @returns {{files: Array<{path: string, oldPath: string|null, status: string, binary: boolean, hunks: Array<object>}>}}
*/
export function parseDiffFiles(patch, scope = 'diff') {
const text = typeof patch === 'string' ? patch : '';
if (!text.trim()) return { files: [] };
const lines = text.split(/\r?\n/);
const files = [];
let current = null;
let headerLines = [];
let hunk = null;
const closeHunk = () => {
if (!current || !hunk) return;
const body = hunk.lines.join('\n');
// The id covers the header and the body, so any edit to the hunk — even one
// that keeps its line numbers — produces a different id. That is what makes
// "this stop is stale" detectable without diffing narratives.
const digest = shortHash(`${hunk.header}\n${body}`);
const seen = current.hunkDigests.get(digest) ?? 0;
current.hunkDigests.set(digest, seen + 1);
// A file can legitimately contain byte-identical hunks (repeated boilerplate
// edits). Disambiguate by occurrence so ids stay unique without becoming
// positional for the common case.
const suffix = seen === 0 ? '' : `-${seen + 1}`;
current.hunks.push({
id: `${scope}:${current.path}:${digest}${suffix}`,
header: hunk.header,
oldStart: hunk.oldStart,
oldLines: hunk.oldLines,
newStart: hunk.newStart,
newLines: hunk.newLines,
added: hunk.added,
deleted: hunk.deleted,
patch: `${current.headerText}\n${hunk.header}\n${body}\n`,
body,
});
hunk = null;
};
const closeFile = () => {
closeHunk();
if (!current) return;
current.binary = current.binary || isBinaryHeader(headerLines);
delete current.hunkDigests;
files.push(current);
current = null;
};
for (const line of lines) {
if (FILE_HEADER.test(line)) {
closeFile();
headerLines = [line];
const paths = parsePathsFromFileHeader(line);
current = {
path: paths?.newPath || paths?.oldPath || '',
oldPath: paths && paths.oldPath !== paths.newPath ? paths.oldPath : null,
status: 'modified',
binary: false,
headerText: line,
hunks: [],
hunkDigests: new Map(),
};
continue;
}
if (!current) continue;
const hunkMatch = HUNK_HEADER.exec(line);
if (hunkMatch) {
closeHunk();
current.status = statusFromHeaderLines(headerLines);
current.headerText = headerLines.join('\n');
hunk = {
header: line,
oldStart: Number.parseInt(hunkMatch[1], 10),
oldLines: hunkMatch[2] === undefined ? 1 : Number.parseInt(hunkMatch[2], 10),
newStart: Number.parseInt(hunkMatch[3], 10),
newLines: hunkMatch[4] === undefined ? 1 : Number.parseInt(hunkMatch[4], 10),
added: 0,
deleted: 0,
lines: [],
};
continue;
}
if (!hunk) {
headerLines.push(line);
continue;
}
hunk.lines.push(line);
if (line.startsWith('+')) hunk.added += 1;
else if (line.startsWith('-')) hunk.deleted += 1;
}
closeFile();
return {
files: files.filter((file) => file.path),
};
}
/**
* Flatten parsed files into an id-keyed index for resolution and staleness
* checks.
*/
export function indexHunks(files) {
const index = new Map();
for (const file of files) {
for (const hunk of file.hunks) {
index.set(hunk.id, { ...hunk, path: file.path, status: file.status });
}
}
return index;
}
/**
* Every hunk id in the diff, in file-then-position order. Used to compute the
* "not covered by any stop" tail.
*/
export function listHunkIds(files) {
const ids = [];
for (const file of files) {
for (const hunk of file.hunks) ids.push(hunk.id);
}
return ids;
}