fix: align apply-patch v2 with codex semantics
This commit is contained in:
@@ -1,4 +1,3 @@
|
||||
import path from "node:path";
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import type { Readable, Writable } from "node:stream";
|
||||
|
||||
@@ -66,12 +65,12 @@ type PlannedOperation = { kind: "write"; path: string; content: string } | { kin
|
||||
type Replacement = [start: number, oldLength: number, newLines: string[]];
|
||||
|
||||
interface ApplyPatchV2Plan {
|
||||
operations: PlannedOperation[];
|
||||
changed: string[];
|
||||
}
|
||||
|
||||
const beginMarker = "*** Begin Patch";
|
||||
const endMarker = "*** End Patch";
|
||||
const environmentIdMarker = "*** Environment ID: ";
|
||||
const addFileMarker = "*** Add File: ";
|
||||
const deleteFileMarker = "*** Delete File: ";
|
||||
const updateFileMarker = "*** Update File: ";
|
||||
@@ -113,6 +112,12 @@ export function parseApplyPatchV2(patchText: string): PatchParseResult {
|
||||
|
||||
const hunks: PatchHunk[] = [];
|
||||
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) {
|
||||
@@ -205,18 +210,16 @@ export async function runApplyPatchV2(options: ApplyPatchV2Options): Promise<num
|
||||
return 2;
|
||||
}
|
||||
const parsed = parseApplyPatchV2(patchText);
|
||||
const plan = await planApplyPatchV2(options.executor, parsed.hunks);
|
||||
for (const operation of plan.operations) {
|
||||
await executePlannedOperation(options.executor, operation);
|
||||
}
|
||||
const plan = await applyPatchV2Hunks(options.executor, parsed.hunks);
|
||||
options.stdout.write("Success. Updated the following files:\n");
|
||||
for (const item of plan.changed) options.stdout.write(`${item}\n`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function planApplyPatchV2(executor: ApplyPatchV2Executor, hunks: PatchHunk[]): Promise<ApplyPatchV2Plan> {
|
||||
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 operations: PlannedOperation[] = [];
|
||||
const changed: string[] = [];
|
||||
|
||||
async function readPlannedText(filePath: string): Promise<string> {
|
||||
@@ -230,40 +233,62 @@ async function planApplyPatchV2(executor: ApplyPatchV2Executor, hunks: PatchHunk
|
||||
return content;
|
||||
}
|
||||
|
||||
function planWrite(filePath: string, content: string): void {
|
||||
async function applyWrite(filePath: string, content: string): Promise<void> {
|
||||
await executePlannedOperation(executor, { kind: "write", path: filePath, content });
|
||||
states.set(filePath, { exists: true, content });
|
||||
operations.push({ kind: "write", path: filePath, content });
|
||||
}
|
||||
|
||||
function planDelete(filePath: string): void {
|
||||
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: "" });
|
||||
operations.push({ kind: "delete", path: filePath });
|
||||
}
|
||||
|
||||
for (const hunk of hunks) {
|
||||
if (hunk.kind === "add") {
|
||||
planWrite(hunk.path, hunk.content);
|
||||
changed.push(hunk.path);
|
||||
continue;
|
||||
try {
|
||||
for (const hunk of hunks) {
|
||||
if (hunk.kind === "add") {
|
||||
await applyWrite(hunk.path, hunk.content);
|
||||
changed.push(`A ${hunk.path}`);
|
||||
continue;
|
||||
}
|
||||
if (hunk.kind === "delete") {
|
||||
await applyDelete(hunk.path);
|
||||
changed.push(`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);
|
||||
changed.push(`M ${hunk.movePath}`);
|
||||
await applyDelete(hunk.path);
|
||||
continue;
|
||||
}
|
||||
await applyWrite(hunk.path, update.newContent);
|
||||
changed.push(`M ${hunk.path}`);
|
||||
}
|
||||
if (hunk.kind === "delete") {
|
||||
planDelete(hunk.path);
|
||||
changed.push(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) {
|
||||
planWrite(hunk.movePath, update.newContent);
|
||||
planDelete(hunk.path);
|
||||
changed.push(`${hunk.path} -> ${hunk.movePath}`);
|
||||
continue;
|
||||
}
|
||||
planWrite(hunk.path, update.newContent);
|
||||
changed.push(hunk.path);
|
||||
} catch (error) {
|
||||
if (changed.length === 0) throw error;
|
||||
throw new ApplyPatchV2Error(error instanceof Error ? error.message : String(error), {
|
||||
partialChanges: changed,
|
||||
cause: error instanceof ApplyPatchV2Error ? error.details : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return { operations, changed };
|
||||
return { changed };
|
||||
}
|
||||
|
||||
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> {
|
||||
@@ -448,8 +473,7 @@ type RemoteV2Operation =
|
||||
| "write-b64-begin"
|
||||
| "write-b64-append"
|
||||
| "write-b64-commit"
|
||||
| "delete"
|
||||
| "move";
|
||||
| "delete";
|
||||
|
||||
async function checkedRemoteV2(executor: ApplyPatchV2Executor, operation: RemoteV2Operation, args: string[], input?: string): Promise<{ stdout: string }> {
|
||||
if (!executor.run) {
|
||||
@@ -585,12 +609,9 @@ function remoteV2Script(operation: RemoteV2Operation, args: string[]): string[]
|
||||
" if [ \"$actual_sha256\" != \"$expected_sha256\" ]; then printf 'v2 final sha256 mismatch for %s\\n' \"$target\" >&2; exit 25; fi",
|
||||
" ;;",
|
||||
" delete)",
|
||||
" rm -f -- \"$1\"",
|
||||
" ;;",
|
||||
" move)",
|
||||
" target=$2",
|
||||
" case \"$target\" in */*) parent=${target%/*}; mkdir -p -- \"$parent\";; esac",
|
||||
" mv -f -- \"$1\" \"$2\"",
|
||||
" 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",
|
||||
@@ -637,8 +658,6 @@ function stripLenientHeredoc(text: string): string {
|
||||
function validatePatchPath(value: string, line: number): string {
|
||||
const filePath = value.trim();
|
||||
if (filePath.length === 0) throw new ApplyPatchV2Error("patch path cannot be empty", { line });
|
||||
if (path.isAbsolute(filePath)) throw new ApplyPatchV2Error("patch paths must be relative", { line, path: filePath });
|
||||
if (filePath.split(/[\\/]+/u).includes("..")) throw new ApplyPatchV2Error("patch paths cannot contain ..", { line, path: filePath });
|
||||
return filePath;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user