|
|
|
@@ -386,10 +386,11 @@ async function workspaceRg(args: any) {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function workspaceApplyPatch(args: any) {
|
|
|
|
|
const patch = text(args.patch) || (text(args.patchBase64) ? Buffer.from(text(args.patchBase64), "base64").toString("utf8") : "");
|
|
|
|
|
if (!patch) throw cliError("patch_required", "patch or patchBase64 is required");
|
|
|
|
|
const patch = text(args.patch) || text(args.patchContent) || (text(args.patchBase64) ? Buffer.from(text(args.patchBase64), "base64").toString("utf8") : "");
|
|
|
|
|
if (!patch) throw cliError("patch_required", "patch, patchContent, or patchBase64 is required", { acceptedArgs: ["patch", "patchContent", "patchBase64"], engine: APPLY_PATCH_ENGINE });
|
|
|
|
|
const cwd = workspaceRoot(args);
|
|
|
|
|
return { cwd, changes: await applyPatchEnvelope(cwd, patch) };
|
|
|
|
|
const result = await applyPatchEnvelope(cwd, patch);
|
|
|
|
|
return { cwd, ...result };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function workspaceWrite(args: any) {
|
|
|
|
@@ -514,78 +515,481 @@ function normalizeCommandRun(value: any, index: number) {
|
|
|
|
|
return { command, argv, commandLine: text(value.commandLine), timeoutMs: value.timeoutMs };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const APPLY_PATCH_ENGINE = "codex-apply-patch-v2-compatible";
|
|
|
|
|
const APPLY_PATCH_BEGIN_MARKER = "*** Begin Patch";
|
|
|
|
|
const APPLY_PATCH_END_MARKER = "*** End Patch";
|
|
|
|
|
const APPLY_PATCH_ENVIRONMENT_MARKER = "*** Environment ID: ";
|
|
|
|
|
const APPLY_PATCH_ADD_FILE_MARKER = "*** Add File: ";
|
|
|
|
|
const APPLY_PATCH_DELETE_FILE_MARKER = "*** Delete File: ";
|
|
|
|
|
const APPLY_PATCH_UPDATE_FILE_MARKER = "*** Update File: ";
|
|
|
|
|
const APPLY_PATCH_MOVE_TO_MARKER = "*** Move to: ";
|
|
|
|
|
const APPLY_PATCH_EOF_MARKER = "*** End of File";
|
|
|
|
|
|
|
|
|
|
type HwpodPatchHunk =
|
|
|
|
|
| { kind: "add"; path: string; content: string }
|
|
|
|
|
| { kind: "delete"; path: string }
|
|
|
|
|
| { kind: "update"; path: string; movePath: string | null; chunks: HwpodPatchChunk[] };
|
|
|
|
|
|
|
|
|
|
type HwpodPatchChunk = {
|
|
|
|
|
changeContext: string | null;
|
|
|
|
|
sourceStartLine: number | null;
|
|
|
|
|
oldLines: string[];
|
|
|
|
|
newLines: string[];
|
|
|
|
|
contextLinePairs: Array<{ oldIndex: number; newIndex: number }>;
|
|
|
|
|
contextLineCount: number;
|
|
|
|
|
addedLineCount: number;
|
|
|
|
|
deletedLineCount: number;
|
|
|
|
|
isEndOfFile: boolean;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type HwpodPatchReplacement = [start: number, oldLength: number, newLines: string[]];
|
|
|
|
|
|
|
|
|
|
async function applyPatchEnvelope(root: string, patch: string) {
|
|
|
|
|
const lines = patch.replace(/\r\n?/gu, "\n").split("\n");
|
|
|
|
|
while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
|
|
|
|
if (lines[0] !== "*** Begin Patch" || lines[lines.length - 1] !== "*** End Patch") {
|
|
|
|
|
throw cliError("invalid_apply_patch_envelope", "patch must start with *** Begin Patch and end with *** End Patch");
|
|
|
|
|
const parsed = parseHwpodApplyPatchV2(patch);
|
|
|
|
|
const changes: any[] = [];
|
|
|
|
|
const outcomes: any[] = [];
|
|
|
|
|
for (let index = 0; index < parsed.hunks.length; index += 1) {
|
|
|
|
|
const hunk = parsed.hunks[index];
|
|
|
|
|
try {
|
|
|
|
|
const change = await applyParsedPatchHunk(root, hunk);
|
|
|
|
|
changes.push(change);
|
|
|
|
|
outcomes.push({ hunk: index + 1, action: change.action, path: change.path, targetPath: change.targetPath ?? undefined, status: "applied", change });
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
const failed = {
|
|
|
|
|
hunk: index + 1,
|
|
|
|
|
action: hunk.kind,
|
|
|
|
|
path: hunk.path,
|
|
|
|
|
targetPath: hunk.kind === "update" ? hunk.movePath ?? undefined : undefined,
|
|
|
|
|
status: "failed",
|
|
|
|
|
error: { code: error?.code || "apply_patch_v2_failed", message: error?.message || String(error), details: error?.details }
|
|
|
|
|
};
|
|
|
|
|
outcomes.push(failed);
|
|
|
|
|
throw cliError(error?.code || "apply_patch_v2_failed", error?.message || String(error), { ...(error?.details ?? {}), engine: APPLY_PATCH_ENGINE, partialChanges: changes, outcomes, failed, cause: error?.details });
|
|
|
|
|
}
|
|
|
|
|
const changes = [];
|
|
|
|
|
for (let index = 1; index < lines.length - 1;) {
|
|
|
|
|
const header = lines[index];
|
|
|
|
|
if (header.startsWith("*** Add File: ")) {
|
|
|
|
|
const relativePath = header.slice("*** Add File: ".length).trim();
|
|
|
|
|
const filePath = resolvePatchFile(root, relativePath);
|
|
|
|
|
const addLines = [];
|
|
|
|
|
index += 1;
|
|
|
|
|
while (index < lines.length - 1 && !lines[index].startsWith("*** ")) {
|
|
|
|
|
if (!lines[index].startsWith("+")) throw cliError("invalid_add_file_hunk", `add file lines must start with + for ${relativePath}`);
|
|
|
|
|
addLines.push(lines[index].slice(1));
|
|
|
|
|
index += 1;
|
|
|
|
|
}
|
|
|
|
|
await mkdir(path.dirname(filePath), { recursive: true });
|
|
|
|
|
await writeFile(filePath, `${addLines.join("\n")}\n`, "utf8");
|
|
|
|
|
changes.push({ action: "add", path: relativePath, lines: addLines.length });
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if (header.startsWith("*** Delete File: ")) {
|
|
|
|
|
const relativePath = header.slice("*** Delete File: ".length).trim();
|
|
|
|
|
await rm(resolvePatchFile(root, relativePath), { force: false });
|
|
|
|
|
changes.push({ action: "delete", path: relativePath });
|
|
|
|
|
index += 1;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if (header.startsWith("*** Update File: ")) {
|
|
|
|
|
const relativePath = header.slice("*** Update File: ".length).trim();
|
|
|
|
|
const filePath = resolvePatchFile(root, relativePath);
|
|
|
|
|
const hunks = [];
|
|
|
|
|
index += 1;
|
|
|
|
|
while (index < lines.length - 1 && !lines[index].startsWith("*** ")) {
|
|
|
|
|
if (!lines[index].startsWith("@@")) throw cliError("invalid_update_hunk", `update hunk must start with @@ for ${relativePath}`);
|
|
|
|
|
index += 1;
|
|
|
|
|
const hunkLines = [];
|
|
|
|
|
while (index < lines.length - 1 && !lines[index].startsWith("@@") && !lines[index].startsWith("*** ")) {
|
|
|
|
|
const prefix = lines[index][0];
|
|
|
|
|
if (![" ", "-", "+"].includes(prefix)) throw cliError("invalid_update_hunk_line", `hunk lines must start with space, -, or + for ${relativePath}`);
|
|
|
|
|
hunkLines.push(lines[index]);
|
|
|
|
|
index += 1;
|
|
|
|
|
}
|
|
|
|
|
hunks.push(hunkLines);
|
|
|
|
|
}
|
|
|
|
|
const applied = await applyUpdateHunks(filePath, hunks, relativePath);
|
|
|
|
|
changes.push({ action: "update", path: relativePath, hunks: hunks.length, replacements: applied });
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
throw cliError("unsupported_apply_patch_operation", `unsupported patch operation: ${header}`);
|
|
|
|
|
}
|
|
|
|
|
return changes;
|
|
|
|
|
if (changes.length === 0) throw cliError("apply_patch_no_changes", "No files were modified.", { engine: APPLY_PATCH_ENGINE });
|
|
|
|
|
return { engine: APPLY_PATCH_ENGINE, changes, hints: parsed.hints, outcomes };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function applyUpdateHunks(filePath: string, hunks: string[][], relativePath: string) {
|
|
|
|
|
const before = await readTextFileState(filePath);
|
|
|
|
|
let fileLines = splitNormalizedLines(before.normalizedContent, before.hadFinalNewline);
|
|
|
|
|
let searchStart = 0;
|
|
|
|
|
for (const hunk of hunks) {
|
|
|
|
|
const oldLines = hunk.filter((line) => line[0] !== "+").map((line) => line.slice(1));
|
|
|
|
|
const newLines = hunk.filter((line) => line[0] !== "-").map((line) => line.slice(1));
|
|
|
|
|
const at = findLineSequence(fileLines, oldLines, searchStart);
|
|
|
|
|
if (at < 0) throw cliError("apply_patch_context_not_found", `patch context not found for ${relativePath}`, diagnosticDetails(before, oldLines.join("\n"), relativePath));
|
|
|
|
|
fileLines = [...fileLines.slice(0, at), ...newLines, ...fileLines.slice(at + oldLines.length)];
|
|
|
|
|
searchStart = at + newLines.length;
|
|
|
|
|
async function applyParsedPatchHunk(root: string, hunk: HwpodPatchHunk) {
|
|
|
|
|
if (hunk.kind === "add") {
|
|
|
|
|
const filePath = resolvePatchFile(root, hunk.path);
|
|
|
|
|
await mkdir(path.dirname(filePath), { recursive: true });
|
|
|
|
|
await writeFile(filePath, hunk.content, "utf8");
|
|
|
|
|
return { action: "add", path: hunk.path, lines: splitPatchContentLines(hunk.content).length };
|
|
|
|
|
}
|
|
|
|
|
const afterNormalized = `${fileLines.join("\n")}${before.hadFinalNewline ? "\n" : ""}`;
|
|
|
|
|
await writeFile(filePath, normalizeContentLineEndings(afterNormalized, before.lineEnding, false), "utf8");
|
|
|
|
|
return hunks.length;
|
|
|
|
|
if (hunk.kind === "delete") {
|
|
|
|
|
await rm(resolvePatchFile(root, hunk.path), { force: false });
|
|
|
|
|
return { action: "delete", path: hunk.path };
|
|
|
|
|
}
|
|
|
|
|
const filePath = resolvePatchFile(root, hunk.path);
|
|
|
|
|
const update = await derivePatchUpdate(filePath, hunk.chunks, hunk.path);
|
|
|
|
|
if (hunk.movePath !== null && hunk.movePath !== hunk.path) {
|
|
|
|
|
const targetPath = resolvePatchFile(root, hunk.movePath);
|
|
|
|
|
await mkdir(path.dirname(targetPath), { recursive: true });
|
|
|
|
|
await writeFile(targetPath, update.afterContent, "utf8");
|
|
|
|
|
await rm(filePath, { force: false });
|
|
|
|
|
return { action: "move", path: hunk.path, targetPath: hunk.movePath, hunks: hunk.chunks.length, replacements: update.replacements };
|
|
|
|
|
}
|
|
|
|
|
await writeFile(filePath, update.afterContent, "utf8");
|
|
|
|
|
return { action: "update", path: hunk.path, hunks: hunk.chunks.length, replacements: update.replacements };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseHwpodApplyPatchV2(patch: string): { hunks: HwpodPatchHunk[]; hints: string[] } {
|
|
|
|
|
const patchText = stripLenientPatchHeredoc(patch).trim();
|
|
|
|
|
const lines = patchText.length === 0 ? [] : patchText.split(/\r?\n/u);
|
|
|
|
|
const first = lines[0]?.trim();
|
|
|
|
|
const last = lines[lines.length - 1]?.trim();
|
|
|
|
|
if (first !== APPLY_PATCH_BEGIN_MARKER || last !== APPLY_PATCH_END_MARKER) throwInvalidApplyPatchEnvelope(patchText, first, last);
|
|
|
|
|
|
|
|
|
|
const hunks: HwpodPatchHunk[] = [];
|
|
|
|
|
const hints: string[] = [];
|
|
|
|
|
let index = 1;
|
|
|
|
|
if ((lines[index] ?? "").trimStart().startsWith(APPLY_PATCH_ENVIRONMENT_MARKER)) {
|
|
|
|
|
const environmentId = (lines[index] ?? "").slice(APPLY_PATCH_ENVIRONMENT_MARKER.length).trim();
|
|
|
|
|
if (!environmentId) throw cliError("invalid_apply_patch_environment", "apply_patch environment_id cannot be empty", { engine: APPLY_PATCH_ENGINE, line: index + 1 });
|
|
|
|
|
index += 1;
|
|
|
|
|
}
|
|
|
|
|
while (index < lines.length - 1) {
|
|
|
|
|
const line = (lines[index] ?? "").trim();
|
|
|
|
|
if (!line) {
|
|
|
|
|
index += 1;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if (line === APPLY_PATCH_BEGIN_MARKER || line === APPLY_PATCH_END_MARKER) {
|
|
|
|
|
pushUniquePatchHint(hints, `ignored nested ${line}`, `apply-patch hint: ignored nested ${line} marker on line ${index + 1}; keep one outer envelope around all hunks.`);
|
|
|
|
|
index += 1;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if (line.startsWith(APPLY_PATCH_ADD_FILE_MARKER)) {
|
|
|
|
|
const filePath = validateApplyPatchPath(line.slice(APPLY_PATCH_ADD_FILE_MARKER.length), index + 1);
|
|
|
|
|
index += 1;
|
|
|
|
|
const added: string[] = [];
|
|
|
|
|
while (index < lines.length - 1 && !isApplyPatchFileHeader(lines[index] ?? "")) {
|
|
|
|
|
const addLine = lines[index] ?? "";
|
|
|
|
|
if (addLine.startsWith("+")) {
|
|
|
|
|
added.push(addLine.length > 1 && addLine.slice(1).trim().length === 0 ? "" : addLine.slice(1));
|
|
|
|
|
} else if (addLine.trimStart().startsWith("@@")) {
|
|
|
|
|
hints.push(`apply-patch hint: accepted @@ inside Add File ${filePath} on line ${index + 1}; Add File does not need @@.`);
|
|
|
|
|
} else 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("");
|
|
|
|
|
} else {
|
|
|
|
|
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: joinPatchLinesWithFinalNewline(added) });
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if (line.startsWith(APPLY_PATCH_DELETE_FILE_MARKER)) {
|
|
|
|
|
const filePath = validateApplyPatchPath(line.slice(APPLY_PATCH_DELETE_FILE_MARKER.length), index + 1);
|
|
|
|
|
index += 1;
|
|
|
|
|
const extraLines: number[] = [];
|
|
|
|
|
while (index < lines.length - 1 && !isApplyPatchFileHeader(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 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(APPLY_PATCH_UPDATE_FILE_MARKER)) {
|
|
|
|
|
const filePath = validateApplyPatchPath(line.slice(APPLY_PATCH_UPDATE_FILE_MARKER.length), index + 1);
|
|
|
|
|
index += 1;
|
|
|
|
|
let movePath: string | null = null;
|
|
|
|
|
if ((lines[index] ?? "").startsWith(APPLY_PATCH_MOVE_TO_MARKER)) {
|
|
|
|
|
movePath = validateApplyPatchPath((lines[index] ?? "").slice(APPLY_PATCH_MOVE_TO_MARKER.length), index + 1);
|
|
|
|
|
index += 1;
|
|
|
|
|
}
|
|
|
|
|
const chunks: HwpodPatchChunk[] = [];
|
|
|
|
|
while (index < lines.length - 1 && !isApplyPatchFileHeader(lines[index] ?? "")) {
|
|
|
|
|
if ((lines[index] ?? "").trim().length === 0) {
|
|
|
|
|
index += 1;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
const parsed = parseApplyPatchUpdateChunk(lines, index, chunks.length === 0, filePath, hints);
|
|
|
|
|
chunks.push(parsed.chunk);
|
|
|
|
|
index = parsed.nextIndex;
|
|
|
|
|
}
|
|
|
|
|
if (chunks.length === 0) throw cliError("invalid_update_hunk", "update file hunk is empty", { engine: APPLY_PATCH_ENGINE, line: index + 1, path: filePath });
|
|
|
|
|
hunks.push({ kind: "update", path: filePath, movePath, chunks });
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
throw cliError("unsupported_apply_patch_operation", `unsupported patch operation: ${line}`, { engine: APPLY_PATCH_ENGINE, line: index + 1, text: line });
|
|
|
|
|
}
|
|
|
|
|
return { hunks, hints };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseApplyPatchUpdateChunk(lines: string[], startIndex: number, allowMissingContext: boolean, filePath: string, hints: string[]) {
|
|
|
|
|
let index = startIndex;
|
|
|
|
|
let changeContext: string | null = null;
|
|
|
|
|
let sourceStartLine: number | null = null;
|
|
|
|
|
const first = lines[index] ?? "";
|
|
|
|
|
if (first === "@@") {
|
|
|
|
|
index += 1;
|
|
|
|
|
} else {
|
|
|
|
|
const unifiedHeader = parseApplyPatchUnifiedHunkHeader(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("@@ ")) {
|
|
|
|
|
changeContext = first.slice("@@ ".length);
|
|
|
|
|
index += 1;
|
|
|
|
|
} else if (!allowMissingContext) {
|
|
|
|
|
throw cliError("invalid_update_hunk", "expected update chunk to start with @@ context marker", { engine: APPLY_PATCH_ENGINE, 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) {
|
|
|
|
|
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 (isApplyPatchFileHeader(line)) break;
|
|
|
|
|
if (parsed > 0 && isApplyPatchUpdateChunkHeader(line)) break;
|
|
|
|
|
if (line === APPLY_PATCH_EOF_MARKER) {
|
|
|
|
|
if (parsed === 0) throw cliError("invalid_update_hunk", "update chunk does not contain any lines", { engine: APPLY_PATCH_ENGINE, 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 {
|
|
|
|
|
pushUniquePatchHint(hints, `unprefixed context ${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 cliError("invalid_update_hunk", "update chunk does not contain any lines", { engine: APPLY_PATCH_ENGINE, line: startIndex + 1 });
|
|
|
|
|
return { chunk: { changeContext, sourceStartLine, oldLines, newLines, contextLinePairs, contextLineCount, addedLineCount, deletedLineCount, isEndOfFile }, nextIndex: index };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function derivePatchUpdate(filePath: string, chunks: HwpodPatchChunk[], relativePath: string) {
|
|
|
|
|
const before = await readTextFileState(filePath);
|
|
|
|
|
const originalLines = splitPatchContentLines(before.normalizedContent);
|
|
|
|
|
const replacements = computePatchReplacements(relativePath, before, originalLines, chunks);
|
|
|
|
|
const afterNormalized = joinPatchLinesWithFinalNewline(applyPatchReplacements(originalLines, replacements));
|
|
|
|
|
const afterContent = normalizeContentLineEndings(afterNormalized, before.lineEnding, false);
|
|
|
|
|
return { before, afterContent, replacements: replacements.length };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function computePatchReplacements(relativePath: string, before: any, originalLines: string[], chunks: HwpodPatchChunk[]): HwpodPatchReplacement[] {
|
|
|
|
|
const replacements: HwpodPatchReplacement[] = [];
|
|
|
|
|
let lineIndex = 0;
|
|
|
|
|
for (const [chunkIndex, chunk] of chunks.entries()) {
|
|
|
|
|
if (chunk.changeContext !== null) {
|
|
|
|
|
const foundContext = seekPatchSequence(originalLines, [chunk.changeContext], lineIndex, false);
|
|
|
|
|
if (foundContext === null) throw cliError("apply_patch_context_not_found", `patch context not found for ${relativePath}`, { ...diagnosticDetails(before, chunk.changeContext, relativePath), engine: APPLY_PATCH_ENGINE, chunk: chunkIndex + 1, context: chunk.changeContext });
|
|
|
|
|
lineIndex = foundContext + 1;
|
|
|
|
|
}
|
|
|
|
|
if (chunk.oldLines.length === 0) {
|
|
|
|
|
replacements.push([originalLines.length, 0, chunk.newLines]);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
let pattern = chunk.oldLines;
|
|
|
|
|
const preferredStart = chunk.sourceStartLine === null ? lineIndex : Math.max(lineIndex, chunk.sourceStartLine - 1);
|
|
|
|
|
let found = seekPatchSequenceWithFallback(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 = seekPatchSequenceWithFallback(originalLines, pattern, preferredStart, lineIndex, chunk.isEndOfFile);
|
|
|
|
|
}
|
|
|
|
|
if (found === null) {
|
|
|
|
|
const expected = chunk.oldLines.join("\n");
|
|
|
|
|
throw cliError("apply_patch_context_not_found", `patch context not found for ${relativePath}`, {
|
|
|
|
|
...diagnosticDetails(before, expected, relativePath),
|
|
|
|
|
engine: APPLY_PATCH_ENGINE,
|
|
|
|
|
chunk: chunkIndex + 1,
|
|
|
|
|
expected,
|
|
|
|
|
diagnostics: expectedPatchLineDiagnostics(originalLines, chunk, preferredStart)
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
replacements.push([found, pattern.length, preservePatchMatchedContextLines(originalLines, found, newLines, chunk.contextLinePairs, pattern.length)]);
|
|
|
|
|
lineIndex = found + pattern.length;
|
|
|
|
|
}
|
|
|
|
|
assertPatchReplacements(relativePath, replacements, originalLines.length);
|
|
|
|
|
return replacements;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function throwInvalidApplyPatchEnvelope(patchText: string, first?: string, last?: string): never {
|
|
|
|
|
const format = detectPatchTextFormat(patchText);
|
|
|
|
|
if (format === "unified-diff") {
|
|
|
|
|
throw cliError("unsupported_apply_patch_format", "hwpod workspace.apply-patch uses the Codex/UniDesk apply_patch v2 envelope; raw unified diff is not accepted", {
|
|
|
|
|
engine: APPLY_PATCH_ENGINE,
|
|
|
|
|
format,
|
|
|
|
|
expectedFirstLine: APPLY_PATCH_BEGIN_MARKER,
|
|
|
|
|
expectedLastLine: APPLY_PATCH_END_MARKER,
|
|
|
|
|
hint: "Wrap edits in *** Begin Patch / *** Update File: <relative-path> / @@ / *** End Patch, or use workspace.write for a whole-file rewrite."
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
throw cliError("invalid_apply_patch_envelope", `patch must start with ${APPLY_PATCH_BEGIN_MARKER} and end with ${APPLY_PATCH_END_MARKER}`, { engine: APPLY_PATCH_ENGINE, firstLine: first ?? null, lastLine: last ?? null });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function detectPatchTextFormat(patchText: string) {
|
|
|
|
|
const trimmed = patchText.trimStart();
|
|
|
|
|
if (trimmed.startsWith("diff --git") || trimmed.startsWith("--- ") || trimmed.startsWith("@@ ")) return "unified-diff";
|
|
|
|
|
return "unknown";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function stripLenientPatchHeredoc(textValue: string) {
|
|
|
|
|
const trimmed = textValue.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 textValue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function validateApplyPatchPath(value: string, line: number) {
|
|
|
|
|
const filePath = value.trim();
|
|
|
|
|
if (!filePath || path.isAbsolute(filePath)) throw cliError("invalid_patch_path", "patch file paths must be relative", { engine: APPLY_PATCH_ENGINE, line, path: filePath });
|
|
|
|
|
return filePath;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isApplyPatchFileHeader(line: string) {
|
|
|
|
|
const trimmed = line.trim();
|
|
|
|
|
return trimmed.startsWith(APPLY_PATCH_ADD_FILE_MARKER) || trimmed.startsWith(APPLY_PATCH_DELETE_FILE_MARKER) || trimmed.startsWith(APPLY_PATCH_UPDATE_FILE_MARKER) || trimmed === APPLY_PATCH_BEGIN_MARKER || trimmed === APPLY_PATCH_END_MARKER;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isApplyPatchUpdateChunkHeader(line: string) {
|
|
|
|
|
return line === "@@" || line.startsWith("@@ ") || parseApplyPatchUnifiedHunkHeader(line) !== null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseApplyPatchUnifiedHunkHeader(line: string) {
|
|
|
|
|
const match = /^@@\s+-(\d+)(?:,\d+)?\s+\+\d+(?:,\d+)?\s+@@(?:\s+.*)?$/u.exec(line);
|
|
|
|
|
if (match === null) return null;
|
|
|
|
|
const oldStart = Number(match[1] ?? "0");
|
|
|
|
|
return Number.isSafeInteger(oldStart) && oldStart >= 0 ? { oldStart } : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function splitPatchContentLines(content: string) {
|
|
|
|
|
const lines = content.split("\n");
|
|
|
|
|
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
|
|
|
|
return lines;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function joinPatchLinesWithFinalNewline(lines: string[]) {
|
|
|
|
|
return lines.length === 0 ? "" : `${lines.join("\n")}\n`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function applyPatchReplacements(lines: string[], replacements: HwpodPatchReplacement[]) {
|
|
|
|
|
const result = [...lines];
|
|
|
|
|
for (const [start, oldLength, newSegment] of [...replacements].reverse()) result.splice(start, oldLength, ...newSegment);
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function assertPatchReplacements(relativePath: string, replacements: HwpodPatchReplacement[], lineCount: number) {
|
|
|
|
|
const sorted = [...replacements].sort((left, right) => left[0] - right[0]);
|
|
|
|
|
let previousEnd = 0;
|
|
|
|
|
for (const [start, oldLength] of sorted) {
|
|
|
|
|
if (start < 0 || oldLength < 0 || start + oldLength > lineCount) throw cliError("apply_patch_replacement_out_of_bounds", "computed replacement is outside file bounds", { engine: APPLY_PATCH_ENGINE, path: relativePath, start, oldLength, lineCount });
|
|
|
|
|
if (start < previousEnd) throw cliError("apply_patch_replacement_overlap", "computed replacements overlap", { engine: APPLY_PATCH_ENGINE, path: relativePath, start, previousEnd });
|
|
|
|
|
previousEnd = Math.max(previousEnd, start + oldLength);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function seekPatchSequenceWithFallback(lines: string[], pattern: string[], preferredStart: number, fallbackStart: number, eof: boolean) {
|
|
|
|
|
const preferred = seekPatchSequence(lines, pattern, preferredStart, eof);
|
|
|
|
|
if (preferred !== null || preferredStart === fallbackStart) return preferred;
|
|
|
|
|
return seekPatchSequence(lines, pattern, fallbackStart, eof);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function seekPatchSequence(lines: string[], pattern: string[], start: number, eof: boolean) {
|
|
|
|
|
if (pattern.length === 0) return start;
|
|
|
|
|
if (pattern.length > lines.length) return null;
|
|
|
|
|
const searchStart = eof && lines.length >= pattern.length ? lines.length - pattern.length : Math.max(0, start);
|
|
|
|
|
const attempts: Array<(value: string) => string> = [
|
|
|
|
|
(value) => value,
|
|
|
|
|
(value) => value.trimEnd(),
|
|
|
|
|
(value) => value.trim(),
|
|
|
|
|
normalizePatchLine
|
|
|
|
|
];
|
|
|
|
|
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 preservePatchMatchedContextLines(originalLines: string[], found: number, newLines: string[], contextLinePairs: HwpodPatchChunk["contextLinePairs"], matchedOldLength: number) {
|
|
|
|
|
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 expectedPatchLineDiagnostics(originalLines: string[], chunk: HwpodPatchChunk, preferredStart: number) {
|
|
|
|
|
const firstExpectedLine = chunk.oldLines.find((line) => line.trim().length > 0) ?? "";
|
|
|
|
|
const firstExpectedLineCandidates = firstExpectedLine ? patchCandidateLineNumbers(originalLines, firstExpectedLine, 8) : [];
|
|
|
|
|
const prefix = bestPatchPrefixMatch(originalLines, chunk.oldLines, firstExpectedLine, preferredStart);
|
|
|
|
|
const missingAddedPrefixes = likelyPatchMissingAddedPrefixes(chunk, prefix.matchedLines);
|
|
|
|
|
return {
|
|
|
|
|
firstExpectedLine,
|
|
|
|
|
firstExpectedLineCandidates,
|
|
|
|
|
firstExpectedLineCandidatesTruncated: firstExpectedLine.length > 0 && patchCandidateLineNumbers(originalLines, firstExpectedLine, 9).length > firstExpectedLineCandidates.length,
|
|
|
|
|
bestPrefixMatchedLines: prefix.matchedLines,
|
|
|
|
|
bestPrefixStartLine: prefix.startLine,
|
|
|
|
|
likelyMissingAddedPrefixes: missingAddedPrefixes,
|
|
|
|
|
likelyStaleOrOversizedContext: !missingAddedPrefixes && likelyPatchStaleOrOversizedContext(chunk, prefix.matchedLines)
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function patchCandidateLineNumbers(lines: string[], expectedLine: string, limit: number) {
|
|
|
|
|
const result: number[] = [];
|
|
|
|
|
for (let index = 0; index < lines.length; index += 1) {
|
|
|
|
|
if (patchLineEquivalent(lines[index] ?? "", expectedLine)) {
|
|
|
|
|
result.push(index + 1);
|
|
|
|
|
if (result.length >= limit) break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function bestPatchPrefixMatch(lines: string[], expectedLines: string[], firstExpectedLine: string, preferredStart: 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 && !patchLineEquivalent(lines[index] ?? "", firstExpectedLine)) continue;
|
|
|
|
|
let matched = 0;
|
|
|
|
|
while (index + matched < lines.length && matched < expectedLines.length && patchLineEquivalent(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 && !patchLineEquivalent(lines[index] ?? "", firstExpectedLine)) continue;
|
|
|
|
|
let matched = 0;
|
|
|
|
|
while (index + matched < lines.length && matched < expectedLines.length && patchLineEquivalent(lines[index + matched] ?? "", expectedLines[matched] ?? "")) matched += 1;
|
|
|
|
|
if (matched > best.matchedLines) best = { startLine: index + 1, matchedLines: matched };
|
|
|
|
|
}
|
|
|
|
|
return best;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function likelyPatchMissingAddedPrefixes(chunk: HwpodPatchChunk, bestPrefixMatchedLines: number) {
|
|
|
|
|
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 likelyPatchStaleOrOversizedContext(chunk: HwpodPatchChunk, bestPrefixMatchedLines: number) {
|
|
|
|
|
if (chunk.oldLines.length < 4) return false;
|
|
|
|
|
if (bestPrefixMatchedLines < 2 || bestPrefixMatchedLines >= chunk.oldLines.length) return false;
|
|
|
|
|
return chunk.addedLineCount > 0 || chunk.deletedLineCount > 0 || chunk.contextLineCount >= 4;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function patchLineEquivalent(left: string, right: string) {
|
|
|
|
|
return left === right || left.trimEnd() === right.trimEnd() || left.trim() === right.trim() || normalizePatchLine(left) === normalizePatchLine(right);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizePatchLine(value: 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, " ");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function pushUniquePatchHint(hints: string[], prefix: string, hint: string) {
|
|
|
|
|
if (!hints.some((existing) => existing.includes(prefix))) hints.push(hint);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function readTextFileState(filePath: string) {
|
|
|
|
|