2026-06-14 01:39:49 +03:00
|
|
|
import {
|
|
|
|
|
parseDiffFromFile,
|
|
|
|
|
parsePatchFiles,
|
|
|
|
|
processFile,
|
|
|
|
|
trimPatchContext,
|
|
|
|
|
type FileDiffMetadata,
|
|
|
|
|
} from '@pierre/diffs';
|
2026-06-13 23:55:50 +03:00
|
|
|
|
|
|
|
|
const PATCH_DIFF_CACHE_LIMIT = 64;
|
2026-06-14 01:39:49 +03:00
|
|
|
const DEFAULT_PATCH_CONTEXT_LINES = 3;
|
2026-06-13 23:55:50 +03:00
|
|
|
const patchFileDiffCache = new Map<string, FileDiffMetadata>();
|
|
|
|
|
|
2026-09-09 14:58:42 +03:00
|
|
|
export const isBinaryPatch = (patch: string): boolean =>
|
|
|
|
|
/^Binary files .+ differ$/m.test(patch) || /^GIT binary patch$/m.test(patch);
|
|
|
|
|
|
2026-06-14 01:39:49 +03:00
|
|
|
export const fileDiffFromPatch = (
|
|
|
|
|
file: string,
|
|
|
|
|
patch: string,
|
|
|
|
|
contextLines = DEFAULT_PATCH_CONTEXT_LINES
|
|
|
|
|
): FileDiffMetadata => {
|
|
|
|
|
const key = `${file}\0${contextLines}\0${patch}`;
|
2026-06-13 23:55:50 +03:00
|
|
|
const cached = patchFileDiffCache.get(key);
|
|
|
|
|
if (cached) {
|
|
|
|
|
patchFileDiffCache.delete(key);
|
|
|
|
|
patchFileDiffCache.set(key, cached);
|
|
|
|
|
return cached;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const completeContents = completePatchContents(patch);
|
|
|
|
|
const value = completeContents
|
2026-06-14 01:39:49 +03:00
|
|
|
? (processFile(trimPatchContext(patch, contextLines), {
|
|
|
|
|
oldFile: { name: file, contents: completeContents.before },
|
|
|
|
|
newFile: { name: file, contents: completeContents.after },
|
|
|
|
|
}) ?? emptyFileDiff(file))
|
2026-06-13 23:55:50 +03:00
|
|
|
: (parsePatchFiles(withPatchHeader(file, patch))[0]?.files[0] ?? emptyFileDiff(file));
|
|
|
|
|
patchFileDiffCache.set(key, value);
|
|
|
|
|
|
|
|
|
|
while (patchFileDiffCache.size > PATCH_DIFF_CACHE_LIMIT) {
|
|
|
|
|
const firstKey = patchFileDiffCache.keys().next().value;
|
|
|
|
|
if (!firstKey) break;
|
|
|
|
|
patchFileDiffCache.delete(firstKey);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return value;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const withPatchHeader = (file: string, patch: string): string => {
|
|
|
|
|
if (!patch.trim()) {
|
|
|
|
|
return patch;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (patch.startsWith('diff --git ') || /^--- [^\n]*\r?\n\+\+\+ /m.test(patch)) {
|
|
|
|
|
return patch;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return `Index: ${file}\n===================================================================\n--- ${file}\t\n+++ ${file}\t\n${patch}`;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const completePatchContents = (patch: string): { before: string; after: string } | undefined => {
|
|
|
|
|
if (!patch.startsWith('diff --git ') && !/^--- [^\n]*\t?\r?\n\+\+\+ [^\n]*\t?(?:\r?\n|$)/m.test(patch)) {
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const hunkMatches = [...patch.matchAll(/^@@\s+-(\d+)(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@[^\n]*(?:\r?\n|$)/gm)];
|
|
|
|
|
if (hunkMatches.length !== 1) {
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const hunk = hunkMatches[0];
|
|
|
|
|
const oldStart = Number.parseInt(hunk[1] ?? '', 10);
|
|
|
|
|
const newStart = Number.parseInt(hunk[2] ?? '', 10);
|
|
|
|
|
if (oldStart > 1 || newStart > 1 || !Number.isFinite(oldStart) || !Number.isFinite(newStart)) {
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const hunkStart = (hunk.index ?? 0) + hunk[0].length;
|
|
|
|
|
const body = patch.slice(hunkStart);
|
|
|
|
|
const before: Array<{ text: string; newline: boolean }> = [];
|
|
|
|
|
const after: Array<{ text: string; newline: boolean }> = [];
|
|
|
|
|
let previous: '-' | '+' | ' ' | undefined;
|
|
|
|
|
|
|
|
|
|
for (const rawLine of body.split(/\r?\n/)) {
|
|
|
|
|
if (rawLine.startsWith('diff --git ') || rawLine.startsWith('@@ ')) {
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (rawLine.startsWith('\\')) {
|
|
|
|
|
if (previous === '-' || previous === ' ') {
|
|
|
|
|
const value = before.at(-1);
|
|
|
|
|
if (value) value.newline = false;
|
|
|
|
|
}
|
|
|
|
|
if (previous === '+' || previous === ' ') {
|
|
|
|
|
const value = after.at(-1);
|
|
|
|
|
if (value) value.newline = false;
|
|
|
|
|
}
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (rawLine.startsWith('-')) {
|
|
|
|
|
before.push({ text: rawLine.slice(1), newline: true });
|
|
|
|
|
previous = '-';
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (rawLine.startsWith('+')) {
|
|
|
|
|
after.push({ text: rawLine.slice(1), newline: true });
|
|
|
|
|
previous = '+';
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!rawLine.startsWith(' ')) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
before.push({ text: rawLine.slice(1), newline: true });
|
|
|
|
|
after.push({ text: rawLine.slice(1), newline: true });
|
|
|
|
|
previous = ' ';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
before: joinPatchLines(before),
|
|
|
|
|
after: joinPatchLines(after),
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const joinPatchLines = (lines: Array<{ text: string; newline: boolean }>): string =>
|
|
|
|
|
lines.map((line) => line.text + (line.newline ? '\n' : '')).join('');
|
|
|
|
|
|
|
|
|
|
const emptyFileDiff = (file: string): FileDiffMetadata =>
|
|
|
|
|
parseDiffFromFile({ name: file, contents: '' }, { name: file, contents: '' });
|
2026-06-14 10:58:23 +03:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Split a unified diff patch for a single file into standalone per-hunk patches.
|
|
|
|
|
*
|
|
|
|
|
* Each returned patch preserves the original file header (everything before the
|
|
|
|
|
* first `@@` hunk header) plus exactly one hunk, producing a patch that can be
|
|
|
|
|
* fed to `git apply` on its own.
|
|
|
|
|
*
|
|
|
|
|
* Returns an empty array when no hunk headers are present.
|
|
|
|
|
*/
|
|
|
|
|
export const splitPatchIntoHunks = (patch: string): string[] => {
|
|
|
|
|
if (!patch) return [];
|
|
|
|
|
|
|
|
|
|
const lines = patch.split(/\r?\n/);
|
|
|
|
|
const hunkHeaderRegex = /^@@\s/;
|
|
|
|
|
const headerLines: string[] = [];
|
|
|
|
|
let firstHunk = 0;
|
|
|
|
|
while (firstHunk < lines.length && !hunkHeaderRegex.test(lines[firstHunk] ?? '')) {
|
|
|
|
|
headerLines.push(lines[firstHunk]);
|
|
|
|
|
firstHunk += 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (firstHunk >= lines.length) {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const hunks: string[][] = [];
|
|
|
|
|
for (let index = firstHunk; index < lines.length; index += 1) {
|
|
|
|
|
const line = lines[index];
|
|
|
|
|
if (hunkHeaderRegex.test(line ?? '')) {
|
|
|
|
|
hunks.push([...headerLines, line]);
|
|
|
|
|
} else if (hunks.length > 0) {
|
|
|
|
|
hunks[hunks.length - 1].push(line ?? '');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return hunks.map((hunkLines) => hunkLines.join('\n'))
|
|
|
|
|
.filter((hunk) => hunk.trim().length > 0)
|
|
|
|
|
.map((hunk) => (hunk.endsWith('\n') ? hunk : `${hunk}\n`));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Extract a standalone patch for a single hunk by zero-based index.
|
|
|
|
|
*
|
|
|
|
|
* Returns `null` when the index is out of range or the patch has no hunks.
|
|
|
|
|
*/
|
|
|
|
|
export const extractHunkPatch = (patch: string, hunkIndex: number): string | null => {
|
|
|
|
|
if (!Number.isInteger(hunkIndex) || hunkIndex < 0) return null;
|
|
|
|
|
const hunks = splitPatchIntoHunks(patch);
|
|
|
|
|
return hunks[hunkIndex] ?? null;
|
|
|
|
|
};
|