1160 lines
48 KiB
TypeScript
1160 lines
48 KiB
TypeScript
import { createHash, randomBytes } from "node:crypto";
|
|
import type { Readable, Writable } from "node:stream";
|
|
|
|
export type PatchHunk =
|
|
| { kind: "add"; path: string; content: string }
|
|
| { kind: "delete"; path: string }
|
|
| { kind: "update"; path: string; movePath: string | null; chunks: UpdateChunk[] };
|
|
|
|
export interface UpdateChunk {
|
|
changeContext: string | null;
|
|
sourceStartLine: number | null;
|
|
oldLines: string[];
|
|
newLines: string[];
|
|
contextLinePairs: Array<{ oldIndex: number; newIndex: number }>;
|
|
contextLineCount: number;
|
|
addedLineCount: number;
|
|
deletedLineCount: number;
|
|
isEndOfFile: boolean;
|
|
}
|
|
|
|
export interface PatchParseResult {
|
|
patch: string;
|
|
hunks: PatchHunk[];
|
|
hints: string[];
|
|
}
|
|
|
|
export interface PatchUpdateResult {
|
|
oldContent: string;
|
|
newContent: string;
|
|
}
|
|
|
|
export interface ApplyPatchV2Outcome {
|
|
hunk: number;
|
|
action: PatchHunk["kind"] | "move";
|
|
status: "applied" | "failed";
|
|
path: string;
|
|
targetPath?: string;
|
|
change?: string;
|
|
partialChanges?: string[];
|
|
error?: {
|
|
name?: string;
|
|
message: string;
|
|
details?: unknown;
|
|
};
|
|
}
|
|
|
|
export interface ApplyPatchV2Options {
|
|
executor: ApplyPatchV2Executor;
|
|
stdin: Readable;
|
|
stdout: Writable;
|
|
stderr?: Writable;
|
|
argv?: string[];
|
|
}
|
|
|
|
export interface ApplyPatchV2Executor {
|
|
run?: (command: string[], input?: string) => Promise<ApplyPatchV2RemoteResult>;
|
|
fs?: ApplyPatchV2FileSystem;
|
|
}
|
|
|
|
export interface ApplyPatchV2FileSystem {
|
|
stat(path: string): Promise<ApplyPatchV2FileStat>;
|
|
readBlock(path: string, blockIndex: number, blockBytes: number): Promise<Buffer>;
|
|
writeFile(path: string, content: Buffer): Promise<void>;
|
|
deleteFile(path: string): Promise<void>;
|
|
}
|
|
|
|
export interface ApplyPatchV2FileStat {
|
|
bytes: number;
|
|
sha256: string;
|
|
}
|
|
|
|
export interface ApplyPatchV2RemoteResult {
|
|
exitCode: number;
|
|
stdout: string;
|
|
stderr: string;
|
|
}
|
|
|
|
export class ApplyPatchV2Error extends Error {
|
|
constructor(message: string, public readonly details: Record<string, unknown> = {}) {
|
|
super(message);
|
|
this.name = "ApplyPatchV2Error";
|
|
}
|
|
}
|
|
|
|
type PlannedFileState = { exists: true; content: string } | { exists: false; content: "" };
|
|
type PlannedOperation = { kind: "write"; path: string; content: string } | { kind: "delete"; path: string };
|
|
type Replacement = [start: number, oldLength: number, newLines: string[]];
|
|
|
|
interface ApplyPatchV2Plan {
|
|
changed: string[];
|
|
outcomes: ApplyPatchV2Outcome[];
|
|
}
|
|
|
|
const beginMarker = "*** Begin Patch";
|
|
const endMarker = "*** End Patch";
|
|
const environmentIdMarker = "*** Environment ID: ";
|
|
const addFileMarker = "*** Add File: ";
|
|
const deleteFileMarker = "*** Delete File: ";
|
|
const updateFileMarker = "*** Update File: ";
|
|
const moveToMarker = "*** Move to: ";
|
|
const emptyChangeContextMarker = "@@";
|
|
const changeContextMarker = "@@ ";
|
|
const eofMarker = "*** End of File";
|
|
|
|
export function isApplyPatchV2HelpArgs(args: string[] = []): boolean {
|
|
const first = args[0] ?? "";
|
|
return first === "help" || first === "--help" || first === "-h";
|
|
}
|
|
|
|
export function applyPatchV2HelpPayload() {
|
|
return {
|
|
ok: true,
|
|
command: "trans <route> apply-patch < patch.diff",
|
|
summary: "Apply a standard apply_patch patch to a remote route with the local v2 line-based engine.",
|
|
usage: [
|
|
"trans G14:/root/hwlab/.worktree/task apply-patch < patch.diff",
|
|
"trans D601:/tmp apply-patch < patch.diff"
|
|
],
|
|
input: {
|
|
required: true,
|
|
firstLine: beginMarker,
|
|
lastLine: endMarker
|
|
},
|
|
rules: [
|
|
"Add File has no @@ hunk marker: put content immediately after `*** Add File: <path>` and prefix every content line with +.",
|
|
"A blank line in Add File is a line containing only +.",
|
|
"Update File uses @@ or @@ context markers, followed by context lines starting with one extra space prefix and changed lines starting with - or +; for a column-0 source line `const x`, write ` const x`, and for a two-space-indented source line write three spaces total. Unified-diff line-range headers are accepted with hints for MiniMax compatibility.",
|
|
"Prefer `trans <route> apply-patch < /tmp/patch.diff` for long patches, Windows paths, or quoting-sensitive content.",
|
|
"MiniMax compatibility: stray @@ or unprefixed content inside Add File, unprefixed Update File context lines, and extra hunk/body lines after Delete File, are accepted with stderr hints."
|
|
],
|
|
examples: {
|
|
addFile: [
|
|
beginMarker,
|
|
`${addFileMarker}path/to/new.txt`,
|
|
"+first line",
|
|
"+",
|
|
"+third line after a blank line",
|
|
endMarker
|
|
].join("\n"),
|
|
updateFile: [
|
|
beginMarker,
|
|
`${updateFileMarker}path/to/existing.txt`,
|
|
emptyChangeContextMarker,
|
|
" context line",
|
|
"-old line",
|
|
"+new line",
|
|
" more context",
|
|
endMarker
|
|
].join("\n")
|
|
},
|
|
commonPitfalls: [
|
|
"Do not put @@ after `*** Add File:`; @@ is only for Update File.",
|
|
"Prefer canonical @@ or @@ context over unified diff headers such as `@@ -1,3 +1,4 @@`; v2 accepts those headers with a hint.",
|
|
"Do not use remote Python/Perl/sed heredocs for text patches when `trans <route> apply-patch` is available."
|
|
],
|
|
note: "apply-patch reads patch text from stdin and uses the v2 engine by default. Use `apply-patch-v1` only for the legacy helper."
|
|
};
|
|
}
|
|
|
|
export function parseApplyPatchV2(patchText: string): PatchParseResult {
|
|
const patch = stripLenientHeredoc(patchText).trim();
|
|
const lines = patch.length === 0 ? [] : patch.split(/\r?\n/u);
|
|
const first = lines[0]?.trim();
|
|
const last = lines[lines.length - 1]?.trim();
|
|
if (first !== beginMarker) throw new ApplyPatchV2Error(`invalid patch: first line must be '${beginMarker}'`);
|
|
if (last !== endMarker) throw new ApplyPatchV2Error(`invalid patch: last line must be '${endMarker}'`);
|
|
|
|
const hunks: PatchHunk[] = [];
|
|
const hints: string[] = [];
|
|
let index = 1;
|
|
const environmentLine = lines[index]?.trimStart() ?? "";
|
|
if (environmentLine.startsWith(environmentIdMarker)) {
|
|
const environmentId = environmentLine.slice(environmentIdMarker.length).trim();
|
|
if (environmentId.length === 0) throw new ApplyPatchV2Error("apply_patch environment_id cannot be empty", { line: index + 1 });
|
|
index += 1;
|
|
}
|
|
while (index < lines.length - 1) {
|
|
const line = lines[index]?.trim() ?? "";
|
|
if (line.length === 0) {
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (line === beginMarker) {
|
|
pushUniqueHint(
|
|
hints,
|
|
"apply-patch hint: ignored nested MiniMax-style Begin Patch marker",
|
|
`apply-patch hint: ignored nested MiniMax-style Begin Patch marker on line ${index + 1}; keep exactly one ${beginMarker}/${endMarker} envelope around all hunks.`,
|
|
);
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (line === endMarker) {
|
|
pushUniqueHint(
|
|
hints,
|
|
"apply-patch hint: ignored nested MiniMax-style End Patch marker",
|
|
`apply-patch hint: ignored nested MiniMax-style End Patch marker on line ${index + 1}; keep exactly one ${beginMarker}/${endMarker} envelope around all hunks.`,
|
|
);
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (line.startsWith(addFileMarker)) {
|
|
const filePath = validatePatchPath(line.slice(addFileMarker.length), index + 1);
|
|
index += 1;
|
|
const added: string[] = [];
|
|
while (index < lines.length - 1 && !isFileHeader(lines[index] ?? "")) {
|
|
const addLine = lines[index] ?? "";
|
|
if (addLine.startsWith("+")) {
|
|
if (addLine.length > 1 && addLine.slice(1).trim().length === 0) {
|
|
hints.push(`apply-patch hint: accepted MiniMax-style whitespace-only Add File line in ${filePath} on line ${index + 1}; prefer a line containing only + for blank lines.`);
|
|
added.push("");
|
|
index += 1;
|
|
continue;
|
|
}
|
|
added.push(addLine.slice(1));
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (addLine.trimStart().startsWith("@@")) {
|
|
hints.push(`apply-patch hint: accepted MiniMax-style @@ inside Add File ${filePath} on line ${index + 1}; Add File does not need @@.`);
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (addLine.trim().length === 0) {
|
|
hints.push(`apply-patch hint: accepted a bare blank line inside Add File ${filePath} on line ${index + 1}; prefer a line containing only +.`);
|
|
added.push("");
|
|
index += 1;
|
|
continue;
|
|
}
|
|
hints.push(`apply-patch hint: accepted unprefixed Add File content in ${filePath} on line ${index + 1}; prefix new-file content lines with +.`);
|
|
added.push(addLine);
|
|
index += 1;
|
|
}
|
|
hunks.push({ kind: "add", path: filePath, content: joinLinesWithFinalNewline(added) });
|
|
continue;
|
|
}
|
|
if (line.startsWith(deleteFileMarker)) {
|
|
const filePath = validatePatchPath(line.slice(deleteFileMarker.length), index + 1);
|
|
index += 1;
|
|
const extraLines: number[] = [];
|
|
while (index < lines.length - 1 && !isFileHeader(lines[index] ?? "")) {
|
|
if ((lines[index] ?? "").trim().length > 0) extraLines.push(index + 1);
|
|
index += 1;
|
|
}
|
|
if (extraLines.length > 0) {
|
|
hints.push(`apply-patch hint: ignored extra MiniMax-style hunk/body lines after Delete File ${filePath} on line ${extraLines[0]}; Delete File only needs the header.`);
|
|
}
|
|
hunks.push({ kind: "delete", path: filePath });
|
|
continue;
|
|
}
|
|
if (line.startsWith(updateFileMarker)) {
|
|
const filePath = validatePatchPath(line.slice(updateFileMarker.length), index + 1);
|
|
index += 1;
|
|
let movePath: string | null = null;
|
|
if ((lines[index] ?? "").startsWith(moveToMarker)) {
|
|
movePath = validatePatchPath((lines[index] ?? "").slice(moveToMarker.length), index + 1);
|
|
index += 1;
|
|
}
|
|
const chunks: UpdateChunk[] = [];
|
|
while (index < lines.length - 1 && !isFileHeader(lines[index] ?? "")) {
|
|
if ((lines[index] ?? "").trim().length === 0) {
|
|
index += 1;
|
|
continue;
|
|
}
|
|
const parsed = parseUpdateChunk(lines, index, chunks.length === 0, filePath, hints);
|
|
chunks.push(parsed.chunk);
|
|
index = parsed.nextIndex;
|
|
}
|
|
if (chunks.length === 0) throw new ApplyPatchV2Error("update file hunk is empty", { line: index + 1, path: filePath });
|
|
hunks.push({ kind: "update", path: filePath, movePath, chunks });
|
|
continue;
|
|
}
|
|
throw new ApplyPatchV2Error("invalid hunk header", { line: index + 1, text: line });
|
|
}
|
|
|
|
return { patch, hunks, hints };
|
|
}
|
|
|
|
export function deriveUpdatedContent(filePath: string, originalContent: string, chunks: UpdateChunk[]): PatchUpdateResult {
|
|
const originalLines = splitContentLines(originalContent);
|
|
const replacements = computeReplacements(filePath, originalLines, chunks);
|
|
const newLines = applyReplacements(originalLines, replacements);
|
|
const newContent = joinLinesWithFinalNewline(newLines);
|
|
return { oldContent: originalContent, newContent };
|
|
}
|
|
|
|
export async function runApplyPatchV2(options: ApplyPatchV2Options): Promise<number> {
|
|
if (isApplyPatchV2HelpArgs(options.argv)) {
|
|
options.stdout.write(`${JSON.stringify(applyPatchV2HelpPayload(), null, 2)}\n`);
|
|
return 0;
|
|
}
|
|
const stderr = options.stderr ?? process.stderr;
|
|
if ((options.argv?.length ?? 0) > 0) {
|
|
stderr.write("ssh apply-patch uses the v2 engine and accepts no helper flags. Use apply-patch-v1 for legacy helper options.\n");
|
|
return 2;
|
|
}
|
|
const patchText = await readStreamText(options.stdin);
|
|
if (!patchText.trim()) {
|
|
stderr.write("ssh apply-patch requires patch text on stdin.\n");
|
|
return 2;
|
|
}
|
|
try {
|
|
const parsed = parseApplyPatchV2(patchText);
|
|
const plan = await applyPatchV2Hunks(options.executor, parsed.hunks);
|
|
for (const hint of parsed.hints) stderr.write(`${hint}\n`);
|
|
options.stdout.write("Success. Updated the following files:\n");
|
|
for (const item of plan.changed) options.stdout.write(`${item}\n`);
|
|
return 0;
|
|
} catch (error) {
|
|
if (options.stderr === undefined) throw error;
|
|
options.stderr.write(formatApplyPatchFailure(error));
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
async function applyPatchV2Hunks(executor: ApplyPatchV2Executor, hunks: PatchHunk[]): Promise<ApplyPatchV2Plan> {
|
|
if (hunks.length === 0) throw new ApplyPatchV2Error("No files were modified.");
|
|
|
|
const states = new Map<string, PlannedFileState>();
|
|
const changed: string[] = [];
|
|
const outcomes: ApplyPatchV2Outcome[] = [];
|
|
|
|
async function readPlannedText(filePath: string): Promise<string> {
|
|
const state = states.get(filePath);
|
|
if (state !== undefined) {
|
|
if (!state.exists) throw new ApplyPatchV2Error("cannot update a file deleted earlier in this patch", { path: filePath });
|
|
return state.content;
|
|
}
|
|
const content = await readRemoteText(executor, filePath);
|
|
states.set(filePath, { exists: true, content });
|
|
return content;
|
|
}
|
|
|
|
async function applyWrite(filePath: string, content: string): Promise<void> {
|
|
await executePlannedOperation(executor, { kind: "write", path: filePath, content });
|
|
states.set(filePath, { exists: true, content });
|
|
}
|
|
|
|
async function applyDelete(filePath: string): Promise<void> {
|
|
const state = states.get(filePath);
|
|
if (state === undefined) {
|
|
await ensureRemoteFileExists(executor, filePath);
|
|
} else if (!state.exists) {
|
|
throw new ApplyPatchV2Error("cannot delete a file deleted earlier in this patch", { path: filePath });
|
|
}
|
|
await executePlannedOperation(executor, { kind: "delete", path: filePath });
|
|
states.set(filePath, { exists: false, content: "" });
|
|
}
|
|
|
|
for (let index = 0; index < hunks.length; index += 1) {
|
|
const hunk = hunks[index] as PatchHunk;
|
|
const changedBefore = changed.length;
|
|
try {
|
|
if (hunk.kind === "add") {
|
|
await applyWrite(hunk.path, hunk.content);
|
|
pushChanged(changed, `A ${hunk.path}`);
|
|
outcomes.push({ ...outcomeBase(hunk, index), status: "applied", change: `A ${hunk.path}` });
|
|
continue;
|
|
}
|
|
if (hunk.kind === "delete") {
|
|
await applyDelete(hunk.path);
|
|
pushChanged(changed, `D ${hunk.path}`);
|
|
outcomes.push({ ...outcomeBase(hunk, index), status: "applied", change: `D ${hunk.path}` });
|
|
continue;
|
|
}
|
|
const originalContent = await readPlannedText(hunk.path);
|
|
const update = deriveUpdatedContent(hunk.path, originalContent, hunk.chunks);
|
|
if (hunk.movePath !== null && hunk.movePath !== hunk.path) {
|
|
await applyWrite(hunk.movePath, update.newContent);
|
|
pushChanged(changed, `M ${hunk.movePath}`);
|
|
await applyDelete(hunk.path);
|
|
outcomes.push({ ...outcomeBase(hunk, index), action: "move", status: "applied", change: `M ${hunk.movePath}` });
|
|
continue;
|
|
}
|
|
await applyWrite(hunk.path, update.newContent);
|
|
pushChanged(changed, `M ${hunk.path}`);
|
|
outcomes.push({ ...outcomeBase(hunk, index), status: "applied", change: `M ${hunk.path}` });
|
|
} catch (error) {
|
|
const partialChanges = changed.slice(changedBefore);
|
|
outcomes.push({
|
|
...outcomeBase(hunk, index),
|
|
status: "failed",
|
|
...(partialChanges.length > 0 ? { partialChanges } : {}),
|
|
error: errorSummary(error),
|
|
});
|
|
throw new ApplyPatchV2Error(error instanceof Error ? error.message : String(error), {
|
|
partialChanges: changed,
|
|
outcomes,
|
|
failed: outcomes.find((item) => item.status === "failed") ?? null,
|
|
cause: error instanceof ApplyPatchV2Error ? error.details : undefined,
|
|
});
|
|
}
|
|
}
|
|
|
|
return { changed, outcomes };
|
|
}
|
|
|
|
function pushChanged(changed: string[], item: string): void {
|
|
if (!changed.includes(item)) changed.push(item);
|
|
}
|
|
|
|
function outcomeBase(hunk: PatchHunk, index: number): Omit<ApplyPatchV2Outcome, "status"> {
|
|
if (hunk.kind === "update" && hunk.movePath !== null && hunk.movePath !== hunk.path) {
|
|
return { hunk: index + 1, action: "move", path: hunk.path, targetPath: hunk.movePath };
|
|
}
|
|
return { hunk: index + 1, action: hunk.kind, path: hunk.path };
|
|
}
|
|
|
|
function errorSummary(error: unknown): ApplyPatchV2Outcome["error"] {
|
|
if (error instanceof ApplyPatchV2Error) {
|
|
return { name: error.name, message: error.message, details: error.details };
|
|
}
|
|
if (error instanceof Error) {
|
|
return { name: error.name, message: error.message };
|
|
}
|
|
return { message: String(error) };
|
|
}
|
|
|
|
function formatApplyPatchFailure(error: unknown): string {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
const details = error instanceof ApplyPatchV2Error ? error.details : {};
|
|
const outcomes = Array.isArray(details.outcomes) ? details.outcomes as ApplyPatchV2Outcome[] : [];
|
|
const lines = [`${message.trimEnd()}`];
|
|
appendExpectedLinesFailureHint(lines, details);
|
|
if (outcomes.length > 1 || outcomes.some((item) => item.status === "applied")) {
|
|
lines.push("Patch status:");
|
|
appendOutcomeSection(lines, "Applied before failure:", outcomes.filter((item) => item.status === "applied"));
|
|
appendOutcomeSection(lines, "Failed:", outcomes.filter((item) => item.status === "failed"));
|
|
}
|
|
return `${lines.join("\n")}\n`;
|
|
}
|
|
|
|
function appendExpectedLinesFailureHint(lines: string[], details: Record<string, unknown>): void {
|
|
const cause = recordValue(details.cause) ?? details;
|
|
const expected = typeof cause.expected === "string" ? cause.expected : "";
|
|
if (expected.length === 0) return;
|
|
const path = typeof cause.path === "string" ? cause.path : "target file";
|
|
const chunk = typeof cause.chunk === "number" ? ` hunk ${cause.chunk}` : "";
|
|
lines.push(`Expected lines for ${path}${chunk}:`);
|
|
appendQuotedBlock(lines, expected, 20, 1600);
|
|
appendExpectedLineDiagnostics(lines, cause);
|
|
lines.push("Hint: re-read the target file around this hunk. In Update File hunks, every context line needs a leading space prefix; for a column-0 source line like `]);`, write ` ]);`.");
|
|
}
|
|
|
|
function appendExpectedLineDiagnostics(lines: string[], cause: Record<string, unknown>): void {
|
|
const diagnostics = recordValue(cause.diagnostics);
|
|
if (diagnostics === null) return;
|
|
const candidates = Array.isArray(diagnostics.firstExpectedLineCandidates) ? diagnostics.firstExpectedLineCandidates.filter((item): item is number => typeof item === "number") : [];
|
|
const firstExpectedLine = typeof diagnostics.firstExpectedLine === "string" ? diagnostics.firstExpectedLine : "";
|
|
if (firstExpectedLine.length > 0 && candidates.length > 0) {
|
|
const suffix = Boolean(diagnostics.firstExpectedLineCandidatesTruncated) ? "+" : "";
|
|
lines.push(`First expected line appears near target line(s): ${candidates.join(", ")}${suffix}`);
|
|
}
|
|
if (typeof diagnostics.bestPrefixMatchedLines === "number" && diagnostics.bestPrefixMatchedLines > 0 && typeof diagnostics.bestPrefixStartLine === "number") {
|
|
lines.push(`Best partial context match: ${diagnostics.bestPrefixMatchedLines} expected line(s) matched starting near line ${diagnostics.bestPrefixStartLine}.`);
|
|
}
|
|
if (diagnostics.likelyMissingAddedPrefixes === true) {
|
|
lines.push("Hint: this hunk looks like a large insertion whose new lines were written as context. Prefix every inserted line with +, keep only a few real existing context lines before/after the insertion, and regenerate the patch instead of editing it with sed.");
|
|
}
|
|
}
|
|
|
|
function recordValue(value: unknown): Record<string, unknown> | null {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : null;
|
|
}
|
|
|
|
function appendQuotedBlock(lines: string[], text: string, maxLines: number, maxChars: number): void {
|
|
const sourceLines = text.split("\n");
|
|
let usedChars = 0;
|
|
let omitted = 0;
|
|
for (const [index, line] of sourceLines.entries()) {
|
|
const quoted = JSON.stringify(line);
|
|
if (index >= maxLines || usedChars + quoted.length > maxChars) {
|
|
omitted = sourceLines.length - index;
|
|
break;
|
|
}
|
|
lines.push(` ${quoted}`);
|
|
usedChars += quoted.length;
|
|
}
|
|
if (omitted > 0) lines.push(` ... ${omitted} more expected line(s) omitted`);
|
|
}
|
|
|
|
function appendOutcomeSection(lines: string[], title: string, outcomes: ApplyPatchV2Outcome[]): void {
|
|
if (outcomes.length === 0) return;
|
|
lines.push(title);
|
|
for (const outcome of outcomes) {
|
|
lines.push(` ${formatOutcome(outcome)}`);
|
|
}
|
|
}
|
|
|
|
function formatOutcome(outcome: ApplyPatchV2Outcome): string {
|
|
const target = outcome.targetPath === undefined ? outcome.path : `${outcome.path} -> ${outcome.targetPath}`;
|
|
const base = `hunk ${outcome.hunk} ${outcome.action} ${target}`;
|
|
if (outcome.status === "applied") return outcome.change === undefined ? base : `${outcome.change} (${base})`;
|
|
const error = outcome.error?.message ?? "failed";
|
|
const partial = outcome.partialChanges === undefined || outcome.partialChanges.length === 0
|
|
? ""
|
|
: `; partial changes: ${outcome.partialChanges.join(", ")}`;
|
|
return `${base}: ${error}${partial}`;
|
|
}
|
|
|
|
async function ensureRemoteFileExists(executor: ApplyPatchV2Executor, target: string): Promise<void> {
|
|
if (executor.fs) {
|
|
await executor.fs.stat(target);
|
|
return;
|
|
}
|
|
await checkedRemoteV2(executor, "stat", [target]);
|
|
}
|
|
|
|
async function executePlannedOperation(executor: ApplyPatchV2Executor, operation: PlannedOperation): Promise<void> {
|
|
if (operation.kind === "write") {
|
|
await writeRemoteText(executor, operation.path, operation.content);
|
|
return;
|
|
}
|
|
if (executor.fs) {
|
|
await executor.fs.deleteFile(operation.path);
|
|
return;
|
|
}
|
|
await checkedRemoteV2(executor, "delete", [operation.path]);
|
|
}
|
|
|
|
const readBlockBytes = 45_000;
|
|
const writeB64ArgvLimit = 48_000;
|
|
const writeB64ChunkChars = 12_000;
|
|
const remoteReadBlockMarker = "UNIDESK_APPLY_PATCH_V2_BLOCK";
|
|
|
|
async function readRemoteText(executor: ApplyPatchV2Executor, target: string): Promise<string> {
|
|
if (executor.fs) {
|
|
const stat = await executor.fs.stat(target);
|
|
if (!Number.isSafeInteger(stat.bytes) || stat.bytes < 0 || !/^[0-9a-f]{64}$/u.test(stat.sha256)) {
|
|
throw new ApplyPatchV2Error("remote apply-patch v2 fs stat returned invalid metadata", { path: target, stat });
|
|
}
|
|
const chunks: Buffer[] = [];
|
|
let actualBytes = 0;
|
|
for (let blockIndex = 0; actualBytes < stat.bytes; blockIndex += 1) {
|
|
const chunk = await executor.fs.readBlock(target, blockIndex, readBlockBytes);
|
|
if (chunk.length === 0) {
|
|
throw new ApplyPatchV2Error("remote apply-patch v2 fs read returned an empty block before EOF", {
|
|
path: target,
|
|
blockIndex,
|
|
expectedBytes: stat.bytes,
|
|
actualBytes,
|
|
});
|
|
}
|
|
chunks.push(chunk);
|
|
actualBytes += chunk.length;
|
|
}
|
|
const contentBuffer = Buffer.concat(chunks);
|
|
if (contentBuffer.length !== stat.bytes) {
|
|
throw new ApplyPatchV2Error("remote apply-patch v2 fs read byte count mismatch", {
|
|
path: target,
|
|
expectedBytes: stat.bytes,
|
|
actualBytes: contentBuffer.length,
|
|
});
|
|
}
|
|
const actualSha256 = sha256Hex(contentBuffer);
|
|
if (actualSha256 !== stat.sha256) {
|
|
throw new ApplyPatchV2Error("remote apply-patch v2 fs read sha256 mismatch", {
|
|
path: target,
|
|
expectedSha256: stat.sha256,
|
|
actualSha256,
|
|
});
|
|
}
|
|
return contentBuffer.toString("utf8");
|
|
}
|
|
|
|
const stat = await checkedRemoteV2(executor, "stat", [target]);
|
|
const [bytesText, expectedSha256] = stat.stdout.trim().split(/\s+/u);
|
|
const expectedBytes = Number(bytesText);
|
|
if (!Number.isSafeInteger(expectedBytes) || expectedBytes < 0 || !/^[0-9a-f]{64}$/u.test(expectedSha256 ?? "")) {
|
|
throw new ApplyPatchV2Error("remote apply-patch v2 stat returned invalid metadata", { path: target, stdout: stat.stdout.slice(0, 500), stderr: stat.stderr.slice(-500) });
|
|
}
|
|
|
|
const chunks: Buffer[] = [];
|
|
let actualBytes = 0;
|
|
for (let blockIndex = 0; actualBytes < expectedBytes; blockIndex += 1) {
|
|
const read = await checkedRemoteV2(executor, "read-b64-block", [target, String(blockIndex), String(readBlockBytes)]);
|
|
const expectedChunkBytes = Math.min(readBlockBytes, expectedBytes - actualBytes);
|
|
const chunk = decodeRemoteReadBlock(read.stdout, target, blockIndex, expectedChunkBytes);
|
|
if (chunk.length === 0) {
|
|
throw new ApplyPatchV2Error("remote apply-patch v2 read returned an empty block before EOF", {
|
|
path: target,
|
|
blockIndex,
|
|
expectedBytes,
|
|
actualBytes,
|
|
});
|
|
}
|
|
chunks.push(chunk);
|
|
actualBytes += chunk.length;
|
|
}
|
|
|
|
const contentBuffer = Buffer.concat(chunks);
|
|
if (contentBuffer.length !== expectedBytes) {
|
|
throw new ApplyPatchV2Error("remote apply-patch v2 read byte count mismatch", {
|
|
path: target,
|
|
expectedBytes,
|
|
actualBytes: contentBuffer.length,
|
|
});
|
|
}
|
|
const actualSha256 = sha256Hex(contentBuffer);
|
|
if (actualSha256 !== expectedSha256) {
|
|
throw new ApplyPatchV2Error("remote apply-patch v2 read sha256 mismatch", {
|
|
path: target,
|
|
expectedSha256,
|
|
actualSha256,
|
|
});
|
|
}
|
|
return contentBuffer.toString("utf8");
|
|
}
|
|
|
|
function decodeRemoteReadBlock(stdout: string, target: string, blockIndex: number, expectedChunkBytes: number): Buffer {
|
|
const lines = stdout.split(/\r?\n/u);
|
|
const markerIndex = lines.findIndex((line) => line.startsWith(`${remoteReadBlockMarker} `));
|
|
let encoded = "";
|
|
let declaredBytes: number | null = null;
|
|
let declaredSha256: string | null = null;
|
|
if (markerIndex >= 0) {
|
|
const [, bytesText, sha256] = lines[markerIndex].split(/\s+/u);
|
|
declaredBytes = Number(bytesText);
|
|
declaredSha256 = sha256 ?? null;
|
|
if (!Number.isSafeInteger(declaredBytes) || declaredBytes < 0 || !/^[0-9a-f]{64}$/u.test(declaredSha256 ?? "")) {
|
|
throw new ApplyPatchV2Error("remote apply-patch v2 read block returned invalid metadata", {
|
|
path: target,
|
|
blockIndex,
|
|
marker: lines[markerIndex].slice(0, 200),
|
|
});
|
|
}
|
|
encoded = (lines[markerIndex + 1] ?? "").replace(/\s+/gu, "");
|
|
} else {
|
|
encoded = stdout.replace(/\s+/gu, "");
|
|
}
|
|
const decoded = encoded.length === 0 ? Buffer.alloc(0) : Buffer.from(encoded, "base64");
|
|
if (declaredBytes !== null) {
|
|
if (decoded.length !== declaredBytes) {
|
|
throw new ApplyPatchV2Error("remote apply-patch v2 read block byte count mismatch", {
|
|
path: target,
|
|
blockIndex,
|
|
expectedBytes: declaredBytes,
|
|
actualBytes: decoded.length,
|
|
});
|
|
}
|
|
const actualSha256 = sha256Hex(decoded);
|
|
if (actualSha256 !== declaredSha256) {
|
|
throw new ApplyPatchV2Error("remote apply-patch v2 read block sha256 mismatch", {
|
|
path: target,
|
|
blockIndex,
|
|
expectedSha256: declaredSha256,
|
|
actualSha256,
|
|
});
|
|
}
|
|
}
|
|
return decoded.length > expectedChunkBytes ? decoded.subarray(0, expectedChunkBytes) : decoded;
|
|
}
|
|
|
|
async function writeRemoteText(executor: ApplyPatchV2Executor, target: string, content: string): Promise<void> {
|
|
const contentBuffer = Buffer.from(content, "utf8");
|
|
if (executor.fs) {
|
|
await executor.fs.writeFile(target, contentBuffer);
|
|
return;
|
|
}
|
|
const encoded = contentBuffer.toString("base64");
|
|
const expectedBytes = String(contentBuffer.length);
|
|
const expectedSha256 = sha256Hex(contentBuffer);
|
|
if (encoded.length <= writeB64ArgvLimit) {
|
|
await checkedRemoteV2(executor, "write-b64-argv", [target, expectedBytes, expectedSha256, ...chunkString(encoded, writeB64ChunkChars)]);
|
|
return;
|
|
}
|
|
try {
|
|
await checkedRemoteV2(executor, "write-b64-stdin", [target, expectedBytes, expectedSha256], encoded);
|
|
return;
|
|
} catch {
|
|
// Some SSH/websocket bridges cap stdin payloads without a stable public
|
|
// contract. The stdin path is still the fast path; fall back to small argv
|
|
// chunks only after the remote sha/byte guard proves no partial write moved.
|
|
}
|
|
const token = `${process.pid}-${Date.now()}-${randomBytes(4).toString("hex")}-${expectedSha256.slice(0, 12)}`;
|
|
await checkedRemoteV2(executor, "write-b64-begin", [target, token]);
|
|
for (const chunk of chunkString(encoded, writeB64ChunkChars)) {
|
|
await checkedRemoteV2(executor, "write-b64-append", [target, token, chunk]);
|
|
}
|
|
await checkedRemoteV2(executor, "write-b64-commit", [target, token, expectedBytes, expectedSha256]);
|
|
}
|
|
|
|
type RemoteV2Operation =
|
|
| "stat"
|
|
| "read-b64-block"
|
|
| "write-b64-argv"
|
|
| "write-b64-stdin"
|
|
| "write-b64-begin"
|
|
| "write-b64-append"
|
|
| "write-b64-commit"
|
|
| "delete";
|
|
|
|
async function checkedRemoteV2(executor: ApplyPatchV2Executor, operation: RemoteV2Operation, args: string[], input?: string): Promise<{ stdout: string }> {
|
|
if (!executor.run) {
|
|
throw new ApplyPatchV2Error("remote apply-patch v2 executor does not provide a command runner", { operation, args });
|
|
}
|
|
const result = await executor.run(remoteV2Script(operation, args), input);
|
|
if (result.exitCode === 0) return result;
|
|
throw new ApplyPatchV2Error("remote apply-patch v2 operation failed", {
|
|
operation,
|
|
args,
|
|
exitCode: result.exitCode,
|
|
stdout: result.stdout.slice(-2000),
|
|
stderr: result.stderr.slice(-4000),
|
|
});
|
|
}
|
|
|
|
function remoteV2Script(operation: RemoteV2Operation, args: string[]): string[] {
|
|
const script = [
|
|
"set -eu",
|
|
"sha256_file() {",
|
|
" if command -v sha256sum >/dev/null 2>&1; then sha256sum -- \"$1\" | awk '{print $1}'; return; fi",
|
|
" if command -v shasum >/dev/null 2>&1; then shasum -a 256 -- \"$1\" | awk '{print $1}'; return; fi",
|
|
" if command -v openssl >/dev/null 2>&1; then openssl dgst -sha256 -- \"$1\" | awk '{print $NF}'; return; fi",
|
|
" printf 'missing sha256 tool\\n' >&2",
|
|
" return 127",
|
|
"}",
|
|
"verify_tmp() {",
|
|
" target=$1",
|
|
" tmp=$2",
|
|
" expected_bytes=$3",
|
|
" expected_sha256=$4",
|
|
" actual_bytes=$(wc -c < \"$tmp\" | tr -d '[:space:]')",
|
|
" if [ \"$actual_bytes\" != \"$expected_bytes\" ]; then",
|
|
" rm -f -- \"$tmp\"",
|
|
" printf 'v2 decoded byte count mismatch for %s: expected=%s actual=%s\\n' \"$target\" \"$expected_bytes\" \"$actual_bytes\" >&2",
|
|
" exit 23",
|
|
" fi",
|
|
" actual_sha256=$(sha256_file \"$tmp\")",
|
|
" if [ \"$actual_sha256\" != \"$expected_sha256\" ]; then",
|
|
" rm -f -- \"$tmp\"",
|
|
" printf 'v2 decoded sha256 mismatch for %s: expected=%s actual=%s\\n' \"$target\" \"$expected_sha256\" \"$actual_sha256\" >&2",
|
|
" exit 24",
|
|
" fi",
|
|
"}",
|
|
"set_tmp_paths() {",
|
|
" target=$1",
|
|
" token=$2",
|
|
" case \"$token\" in ''|*[!a-zA-Z0-9_.-]*) printf 'invalid v2 temp token\\n' >&2; exit 2;; esac",
|
|
" base=${target##*/}",
|
|
" dir=.",
|
|
" case \"$target\" in */*) dir=${target%/*};; esac",
|
|
" tmp=\"$dir/.${base}.unidesk-v2-${token}.tmp\"",
|
|
" tmp_b64=\"$tmp.b64\"",
|
|
"}",
|
|
"op=$1",
|
|
"shift",
|
|
"case \"$op\" in",
|
|
" stat)",
|
|
" target=$1",
|
|
" if [ ! -e \"$target\" ]; then printf 'file not found: %s\\n' \"$target\" >&2; exit 1; fi",
|
|
" if [ -d \"$target\" ]; then printf 'not a file: %s\\n' \"$target\" >&2; exit 1; fi",
|
|
" bytes=$(wc -c < \"$target\" | tr -d '[:space:]')",
|
|
" digest=$(sha256_file \"$target\")",
|
|
" printf '%s %s\\n' \"$bytes\" \"$digest\"",
|
|
" ;;",
|
|
" read-b64-block)",
|
|
" target=$1",
|
|
" block_index=$2",
|
|
" block_size=$3",
|
|
" case \"$block_index:$block_size\" in *[!0-9:]*|:*) printf 'invalid read block args\\n' >&2; exit 2;; esac",
|
|
" tmp=$(mktemp)",
|
|
" dd if=\"$target\" bs=\"$block_size\" skip=\"$block_index\" count=1 2>/dev/null > \"$tmp\"",
|
|
" bytes=$(wc -c < \"$tmp\" | tr -d '[:space:]')",
|
|
" digest=$(sha256_file \"$tmp\")",
|
|
` printf '${remoteReadBlockMarker} %s %s\\n' "$bytes" "$digest"`,
|
|
" base64 < \"$tmp\" | tr -d '\\n'",
|
|
" printf '\\n'",
|
|
" rm -f -- \"$tmp\"",
|
|
" ;;",
|
|
" write-b64-argv)",
|
|
" target=$1",
|
|
" expected_bytes=$2",
|
|
" expected_sha256=$3",
|
|
" shift 3",
|
|
" case \"$target\" in */*) parent=${target%/*}; mkdir -p -- \"$parent\";; esac",
|
|
" base=${target##*/}",
|
|
" dir=.",
|
|
" case \"$target\" in */*) dir=${target%/*};; esac",
|
|
" tmp=\"$dir/.${base}.unidesk-v2-$$.tmp\"",
|
|
" : > \"$tmp.b64\"",
|
|
" for chunk in \"$@\"; do printf '%s' \"$chunk\" >> \"$tmp.b64\"; done",
|
|
" base64 -d < \"$tmp.b64\" > \"$tmp\"",
|
|
" rm -f -- \"$tmp.b64\"",
|
|
" verify_tmp \"$target\" \"$tmp\" \"$expected_bytes\" \"$expected_sha256\"",
|
|
" mv -f -- \"$tmp\" \"$target\"",
|
|
" actual_sha256=$(sha256_file \"$target\")",
|
|
" if [ \"$actual_sha256\" != \"$expected_sha256\" ]; then printf 'v2 final sha256 mismatch for %s\\n' \"$target\" >&2; exit 25; fi",
|
|
" ;;",
|
|
" write-b64-stdin)",
|
|
" target=$1",
|
|
" expected_bytes=$2",
|
|
" expected_sha256=$3",
|
|
" case \"$target\" in */*) parent=${target%/*}; mkdir -p -- \"$parent\";; esac",
|
|
" base=${target##*/}",
|
|
" dir=.",
|
|
" case \"$target\" in */*) dir=${target%/*};; esac",
|
|
" tmp=\"$dir/.${base}.unidesk-v2-$$.tmp\"",
|
|
" if ! base64 -d > \"$tmp\"; then rm -f -- \"$tmp\"; printf 'v2 base64 decode failed for %s\\n' \"$target\" >&2; exit 22; fi",
|
|
" verify_tmp \"$target\" \"$tmp\" \"$expected_bytes\" \"$expected_sha256\"",
|
|
" mv -f -- \"$tmp\" \"$target\"",
|
|
" actual_sha256=$(sha256_file \"$target\")",
|
|
" if [ \"$actual_sha256\" != \"$expected_sha256\" ]; then printf 'v2 final sha256 mismatch for %s\\n' \"$target\" >&2; exit 25; fi",
|
|
" ;;",
|
|
" write-b64-begin)",
|
|
" target=$1",
|
|
" case \"$target\" in */*) parent=${target%/*}; mkdir -p -- \"$parent\";; esac",
|
|
" set_tmp_paths \"$target\" \"$2\"",
|
|
" : > \"$tmp_b64\"",
|
|
" ;;",
|
|
" write-b64-append)",
|
|
" target=$1",
|
|
" set_tmp_paths \"$target\" \"$2\"",
|
|
" printf '%s' \"$3\" >> \"$tmp_b64\"",
|
|
" ;;",
|
|
" write-b64-commit)",
|
|
" target=$1",
|
|
" set_tmp_paths \"$target\" \"$2\"",
|
|
" expected_bytes=$3",
|
|
" expected_sha256=$4",
|
|
" if ! base64 -d < \"$tmp_b64\" > \"$tmp\"; then rm -f -- \"$tmp\" \"$tmp_b64\"; printf 'v2 base64 decode failed for %s\\n' \"$target\" >&2; exit 22; fi",
|
|
" rm -f -- \"$tmp_b64\"",
|
|
" verify_tmp \"$target\" \"$tmp\" \"$expected_bytes\" \"$expected_sha256\"",
|
|
" mv -f -- \"$tmp\" \"$target\"",
|
|
" actual_sha256=$(sha256_file \"$target\")",
|
|
" if [ \"$actual_sha256\" != \"$expected_sha256\" ]; then printf 'v2 final sha256 mismatch for %s\\n' \"$target\" >&2; exit 25; fi",
|
|
" ;;",
|
|
" delete)",
|
|
" target=$1",
|
|
" if [ ! -e \"$target\" ]; then printf 'file not found: %s\\n' \"$target\" >&2; exit 1; fi",
|
|
" rm -- \"$target\"",
|
|
" ;;",
|
|
" *)",
|
|
" printf 'unsupported op: %s\\n' \"$op\" >&2",
|
|
" exit 2",
|
|
" ;;",
|
|
"esac",
|
|
].join("\n");
|
|
return ["sh", "-c", script, "unidesk-apply-patch-v2", operation, ...args];
|
|
}
|
|
|
|
function sha256Hex(value: Buffer): string {
|
|
return createHash("sha256").update(value).digest("hex");
|
|
}
|
|
|
|
function chunkString(value: string, chunkSize: number): string[] {
|
|
const chunks: string[] = [];
|
|
for (let index = 0; index < value.length; index += chunkSize) {
|
|
chunks.push(value.slice(index, index + chunkSize));
|
|
}
|
|
return chunks.length > 0 ? chunks : [""];
|
|
}
|
|
|
|
function readStreamText(stream: Readable): Promise<string> {
|
|
return new Promise((resolve, reject) => {
|
|
const chunks: Buffer[] = [];
|
|
stream.on("data", (chunk: Buffer | string) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
|
|
stream.on("error", reject);
|
|
stream.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
stream.resume();
|
|
});
|
|
}
|
|
|
|
function stripLenientHeredoc(text: string): string {
|
|
const trimmed = text.trim();
|
|
const lines = trimmed.length === 0 ? [] : trimmed.split(/\r?\n/u);
|
|
const first = lines[0] ?? "";
|
|
const last = lines[lines.length - 1] ?? "";
|
|
if ((first === "<<EOF" || first === "<<'EOF'" || first === '<<"EOF"') && last.endsWith("EOF") && lines.length >= 4) {
|
|
return lines.slice(1, -1).join("\n");
|
|
}
|
|
return text;
|
|
}
|
|
|
|
function validatePatchPath(value: string, line: number): string {
|
|
const filePath = value.trim();
|
|
if (filePath.length === 0) throw new ApplyPatchV2Error("patch path cannot be empty", { line });
|
|
return filePath;
|
|
}
|
|
|
|
function isFileHeader(line: string): boolean {
|
|
const trimmed = line.trim();
|
|
return trimmed.startsWith(addFileMarker) || trimmed.startsWith(deleteFileMarker) || trimmed.startsWith(updateFileMarker) || trimmed === beginMarker || trimmed === endMarker;
|
|
}
|
|
|
|
function isUpdateChunkHeader(line: string): boolean {
|
|
return line === emptyChangeContextMarker || line.startsWith(changeContextMarker) || parseUnifiedDiffHunkHeader(line) !== null;
|
|
}
|
|
|
|
function pushUniqueHint(hints: string[], prefix: string, hint: string): void {
|
|
if (!hints.some((existing) => existing.startsWith(prefix))) hints.push(hint);
|
|
}
|
|
|
|
function parseUpdateChunk(lines: string[], startIndex: number, allowMissingContext: boolean, filePath: string, hints: string[]): { chunk: UpdateChunk; nextIndex: number } {
|
|
let index = startIndex;
|
|
let changeContext: string | null = null;
|
|
let sourceStartLine: number | null = null;
|
|
const first = lines[index] ?? "";
|
|
if (first === emptyChangeContextMarker) {
|
|
index += 1;
|
|
} else {
|
|
const unifiedHeader = parseUnifiedDiffHunkHeader(first);
|
|
if (unifiedHeader !== null) {
|
|
sourceStartLine = unifiedHeader.oldStart;
|
|
hints.push(`apply-patch hint: accepted unified-diff hunk header in ${filePath} on line ${startIndex + 1}; canonical apply_patch uses @@ or @@ context without line ranges.`);
|
|
index += 1;
|
|
} else if (first.startsWith(changeContextMarker)) {
|
|
changeContext = first.slice(changeContextMarker.length);
|
|
index += 1;
|
|
} else if (!allowMissingContext) {
|
|
throw new ApplyPatchV2Error("expected update chunk to start with @@ context marker", { line: startIndex + 1, text: first });
|
|
}
|
|
}
|
|
|
|
const oldLines: string[] = [];
|
|
const newLines: string[] = [];
|
|
const contextLinePairs: Array<{ oldIndex: number; newIndex: number }> = [];
|
|
let parsed = 0;
|
|
let isEndOfFile = false;
|
|
let contextLineCount = 0;
|
|
let addedLineCount = 0;
|
|
let deletedLineCount = 0;
|
|
function pushContextLine(value: string): void {
|
|
contextLinePairs.push({ oldIndex: oldLines.length, newIndex: newLines.length });
|
|
oldLines.push(value);
|
|
newLines.push(value);
|
|
contextLineCount += 1;
|
|
}
|
|
while (index < lines.length - 1) {
|
|
const line = lines[index] ?? "";
|
|
if (isFileHeader(line)) break;
|
|
if (parsed > 0 && isUpdateChunkHeader(line)) break;
|
|
if (line === eofMarker) {
|
|
if (parsed === 0) throw new ApplyPatchV2Error("update chunk does not contain any lines", { line: index + 1 });
|
|
isEndOfFile = true;
|
|
index += 1;
|
|
break;
|
|
}
|
|
const marker = line[0] ?? "";
|
|
if (marker === " ") {
|
|
pushContextLine(line.slice(1));
|
|
} else if (marker === "+") {
|
|
newLines.push(line.slice(1));
|
|
addedLineCount += 1;
|
|
} else if (marker === "-") {
|
|
oldLines.push(line.slice(1));
|
|
deletedLineCount += 1;
|
|
} else if (line.length === 0) {
|
|
pushContextLine("");
|
|
} else {
|
|
pushUniqueHint(
|
|
hints,
|
|
`apply-patch hint: accepted unprefixed Update File context line in ${filePath}`,
|
|
`apply-patch hint: accepted unprefixed Update File context line in ${filePath} on line ${index + 1}; prefix context lines with one extra space in addition to source indentation.`,
|
|
);
|
|
pushContextLine(line);
|
|
}
|
|
parsed += 1;
|
|
index += 1;
|
|
}
|
|
if (parsed === 0) throw new ApplyPatchV2Error("update chunk does not contain any lines", { line: startIndex + 1 });
|
|
return { chunk: { changeContext, sourceStartLine, oldLines, newLines, contextLinePairs, contextLineCount, addedLineCount, deletedLineCount, isEndOfFile }, nextIndex: index };
|
|
}
|
|
|
|
function parseUnifiedDiffHunkHeader(line: string): { oldStart: number } | null {
|
|
const match = /^@@\s+-(\d+)(?:,\d+)?\s+\+\d+(?:,\d+)?\s+@@(?:\s+.*)?$/u.exec(line);
|
|
if (match === null) return null;
|
|
const oldStart = Number(match[1] ?? "0");
|
|
if (!Number.isSafeInteger(oldStart) || oldStart < 0) return null;
|
|
return { oldStart };
|
|
}
|
|
|
|
function splitContentLines(content: string): string[] {
|
|
const lines = content.split("\n");
|
|
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
|
return lines;
|
|
}
|
|
|
|
function joinLinesWithFinalNewline(lines: string[]): string {
|
|
if (lines.length === 0) return "";
|
|
return `${lines.join("\n")}\n`;
|
|
}
|
|
|
|
function computeReplacements(filePath: string, originalLines: string[], chunks: UpdateChunk[]): Replacement[] {
|
|
const replacements: Replacement[] = [];
|
|
let lineIndex = 0;
|
|
for (const [chunkIndex, chunk] of chunks.entries()) {
|
|
if (chunk.changeContext !== null) {
|
|
const foundContext = seekSequence(originalLines, [chunk.changeContext], lineIndex, false);
|
|
if (foundContext === null) {
|
|
throw new ApplyPatchV2Error("failed to find update context", { path: filePath, chunk: chunkIndex + 1, context: chunk.changeContext });
|
|
}
|
|
lineIndex = foundContext + 1;
|
|
}
|
|
if (chunk.oldLines.length === 0) {
|
|
const insertAt = originalLines.length > 0 && originalLines[originalLines.length - 1] === "" ? originalLines.length - 1 : originalLines.length;
|
|
replacements.push([insertAt, 0, chunk.newLines]);
|
|
continue;
|
|
}
|
|
|
|
let pattern = chunk.oldLines;
|
|
const preferredStart = chunk.sourceStartLine === null ? lineIndex : Math.max(lineIndex, chunk.sourceStartLine - 1);
|
|
let found = seekSequenceWithFallback(originalLines, pattern, preferredStart, lineIndex, chunk.isEndOfFile);
|
|
let newLines = chunk.newLines;
|
|
if (found === null && pattern[pattern.length - 1] === "") {
|
|
pattern = pattern.slice(0, -1);
|
|
newLines = newLines[newLines.length - 1] === "" ? newLines.slice(0, -1) : newLines;
|
|
found = seekSequenceWithFallback(originalLines, pattern, preferredStart, lineIndex, chunk.isEndOfFile);
|
|
}
|
|
if (found === null) {
|
|
throw new ApplyPatchV2Error("failed to find expected lines", {
|
|
path: filePath,
|
|
chunk: chunkIndex + 1,
|
|
expected: chunk.oldLines.join("\n"),
|
|
diagnostics: expectedLineDiagnostics(originalLines, chunk, preferredStart),
|
|
});
|
|
}
|
|
replacements.push([found, pattern.length, preserveMatchedContextLines(originalLines, found, newLines, chunk.contextLinePairs, pattern.length)]);
|
|
lineIndex = found + pattern.length;
|
|
}
|
|
assertNonOverlappingReplacements(filePath, replacements, originalLines.length);
|
|
return replacements;
|
|
}
|
|
|
|
function expectedLineDiagnostics(originalLines: string[], chunk: UpdateChunk, preferredStart: number): Record<string, unknown> {
|
|
const firstExpectedLine = chunk.oldLines.find((line) => line.trim().length > 0) ?? "";
|
|
const firstExpectedLineCandidates = firstExpectedLine.length === 0 ? [] : candidateLineNumbers(originalLines, firstExpectedLine, 8);
|
|
const prefix = bestPrefixMatch(originalLines, chunk.oldLines, firstExpectedLine, preferredStart);
|
|
return {
|
|
firstExpectedLine,
|
|
firstExpectedLineCandidates,
|
|
firstExpectedLineCandidatesTruncated: firstExpectedLine.length > 0 && candidateLineNumbers(originalLines, firstExpectedLine, 9).length > firstExpectedLineCandidates.length,
|
|
bestPrefixMatchedLines: prefix.matchedLines,
|
|
bestPrefixStartLine: prefix.startLine,
|
|
likelyMissingAddedPrefixes: likelyMissingAddedPrefixes(chunk, prefix.matchedLines),
|
|
};
|
|
}
|
|
|
|
function candidateLineNumbers(lines: string[], expectedLine: string, limit: number): number[] {
|
|
const result: number[] = [];
|
|
for (let index = 0; index < lines.length; index += 1) {
|
|
if (lineEquivalent(lines[index] ?? "", expectedLine)) {
|
|
result.push(index + 1);
|
|
if (result.length >= limit) break;
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function bestPrefixMatch(lines: string[], expectedLines: string[], firstExpectedLine: string, preferredStart: number): { startLine: number | null; matchedLines: number } {
|
|
let best = { startLine: null as number | null, matchedLines: 0 };
|
|
if (expectedLines.length === 0) return best;
|
|
for (let index = Math.max(0, preferredStart); index < lines.length; index += 1) {
|
|
if (firstExpectedLine.length > 0 && !lineEquivalent(lines[index] ?? "", firstExpectedLine)) continue;
|
|
let matched = 0;
|
|
while (index + matched < lines.length && matched < expectedLines.length && lineEquivalent(lines[index + matched] ?? "", expectedLines[matched] ?? "")) matched += 1;
|
|
if (matched > best.matchedLines) best = { startLine: index + 1, matchedLines: matched };
|
|
if (matched === expectedLines.length) break;
|
|
}
|
|
if (best.matchedLines > 0 || preferredStart <= 0) return best;
|
|
for (let index = 0; index < Math.min(preferredStart, lines.length); index += 1) {
|
|
if (firstExpectedLine.length > 0 && !lineEquivalent(lines[index] ?? "", firstExpectedLine)) continue;
|
|
let matched = 0;
|
|
while (index + matched < lines.length && matched < expectedLines.length && lineEquivalent(lines[index + matched] ?? "", expectedLines[matched] ?? "")) matched += 1;
|
|
if (matched > best.matchedLines) best = { startLine: index + 1, matchedLines: matched };
|
|
}
|
|
return best;
|
|
}
|
|
|
|
function likelyMissingAddedPrefixes(chunk: UpdateChunk, bestPrefixMatchedLines: number): boolean {
|
|
if (chunk.deletedLineCount > 0) return false;
|
|
if (chunk.oldLines.length < 8) return false;
|
|
if (chunk.addedLineCount > 2) return false;
|
|
if (chunk.contextLineCount < 8) return false;
|
|
return bestPrefixMatchedLines > 0 && bestPrefixMatchedLines < chunk.oldLines.length;
|
|
}
|
|
|
|
function lineEquivalent(left: string, right: string): boolean {
|
|
return left === right || left.trimEnd() === right.trimEnd() || left.trim() === right.trim() || normalizeLine(left) === normalizeLine(right);
|
|
}
|
|
|
|
function preserveMatchedContextLines(originalLines: string[], found: number, newLines: string[], contextLinePairs: UpdateChunk["contextLinePairs"], matchedOldLength: number): string[] {
|
|
if (contextLinePairs.length === 0) return newLines;
|
|
const result = [...newLines];
|
|
for (const pair of contextLinePairs) {
|
|
if (pair.oldIndex >= matchedOldLength || pair.newIndex >= result.length) continue;
|
|
const originalLine = originalLines[found + pair.oldIndex];
|
|
if (originalLine !== undefined) result[pair.newIndex] = originalLine;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function seekSequenceWithFallback(lines: string[], pattern: string[], preferredStart: number, fallbackStart: number, eof: boolean): number | null {
|
|
const preferred = seekSequence(lines, pattern, preferredStart, eof);
|
|
if (preferred !== null || preferredStart === fallbackStart) return preferred;
|
|
return seekSequence(lines, pattern, fallbackStart, eof);
|
|
}
|
|
|
|
function applyReplacements(lines: string[], replacements: Replacement[]): string[] {
|
|
const result = [...lines];
|
|
for (const [start, oldLen, newSegment] of [...replacements].reverse()) {
|
|
result.splice(start, oldLen, ...newSegment);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function assertNonOverlappingReplacements(filePath: string, replacements: Replacement[], lineCount: number): void {
|
|
const sorted = [...replacements].sort((left, right) => left[0] - right[0]);
|
|
let previousEnd = 0;
|
|
for (const [start, oldLen] of sorted) {
|
|
if (start < 0 || oldLen < 0 || start + oldLen > lineCount) {
|
|
throw new ApplyPatchV2Error("computed replacement is outside file bounds", {
|
|
path: filePath,
|
|
start,
|
|
oldLen,
|
|
lineCount,
|
|
});
|
|
}
|
|
if (start < previousEnd) {
|
|
throw new ApplyPatchV2Error("computed replacements overlap", {
|
|
path: filePath,
|
|
start,
|
|
previousEnd,
|
|
});
|
|
}
|
|
previousEnd = Math.max(previousEnd, start + oldLen);
|
|
}
|
|
}
|
|
|
|
function seekSequence(lines: string[], pattern: string[], start: number, eof: boolean): number | null {
|
|
if (pattern.length === 0) return start;
|
|
if (pattern.length > lines.length) return null;
|
|
const searchStart = eof && lines.length >= pattern.length ? lines.length - pattern.length : start;
|
|
const attempts: Array<(value: string) => string> = [
|
|
(value) => value,
|
|
(value) => value.trimEnd(),
|
|
(value) => value.trim(),
|
|
normalizeLine,
|
|
];
|
|
for (const normalize of attempts) {
|
|
for (let index = searchStart; index <= lines.length - pattern.length; index += 1) {
|
|
let ok = true;
|
|
for (let offset = 0; offset < pattern.length; offset += 1) {
|
|
if (normalize(lines[index + offset] ?? "") !== normalize(pattern[offset] ?? "")) {
|
|
ok = false;
|
|
break;
|
|
}
|
|
}
|
|
if (ok) return index;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function normalizeLine(value: string): string {
|
|
return value.trim().replace(/[\u2010-\u2015\u2212]/gu, "-")
|
|
.replace(/[\u2018\u2019\u201A\u201B]/gu, "'")
|
|
.replace(/[\u201C\u201D\u201E\u201F]/gu, "\"")
|
|
.replace(/[\u00A0\u2002-\u200A\u202F\u205F\u3000]/gu, " ");
|
|
}
|