import { spawn } from "node:child_process"; import { mkdir, mkdtemp, readFile, realpath, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { withFileMutationQueue } from "@earendil-works/pi-coding-agent"; import { isPathInside } from "./path-utils"; export interface ApplyPatchResult { changedFiles: string[]; summary: string; } type HunkLine = { kind: "context" | "remove" | "add"; text: string; }; type Hunk = { header?: string; lines: HunkLine[]; endOfFile: boolean; }; type PatchOperation = | { kind: "add"; path: string; lines: string[] } | { kind: "delete"; path: string } | { kind: "update"; path: string; moveTo?: string; hunks: Hunk[] }; interface PlannedWrite { kind: "write"; changeKind: "A" | "M"; path: string; absolutePath: string; content: string; } interface PlannedDelete { kind: "delete"; changeKind: "D"; path: string; absolutePath: string; summarize: boolean; } type PlannedChange = PlannedWrite | PlannedDelete; type Match = { kind: "found"; index: number } | { kind: "missing" }; const BEGIN = "*** Begin Patch"; const END = "*** End Patch"; const ADD = "*** Add File: "; const DELETE = "*** Delete File: "; const UPDATE = "*** Update File: "; const MOVE = "*** Move to: "; const EOF_MARKER = "*** End of File"; type UnifiedChangeKind = "A" | "M" | "D"; type UnifiedChange = { path: string; changeKind: UnifiedChangeKind }; function normalizedPatchLines(input: string): string[] { const normalized = input.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim(); return normalized ? normalized.split("\n") : []; } function strictPatchBodyLines(lines: string[]): string[] { const first = lines[0]?.trim(); const last = lines.at(-1)?.trim(); if (first !== BEGIN) throw new Error("Invalid patch: The first line of the patch must be '*** Begin Patch'"); if (last !== END) throw new Error("Invalid patch: The last line of the patch must be '*** End Patch'"); return lines.slice(1, -1); } function patchBodyLines(input: string): string[] { const originalLines = normalizedPatchLines(input); try { return strictPatchBodyLines(originalLines); } catch (originalError) { const first = originalLines[0]; const last = originalLines.at(-1); const isHeredoc = first === "<= 4) { return strictPatchBodyLines(originalLines.slice(1, -1)); } throw originalError; } } function looksLikeBeginPatch(input: string): boolean { const first = normalizedPatchLines(input)[0]?.trim(); return first === BEGIN || first === "< 2) return pathText.slice(2); return pathText; } function unifiedHeaderPath(line: string, marker: "--- " | "+++ "): string { let pathText = line.slice(marker.length).trim(); if (pathText === "/dev/null") return ""; const tabIndex = pathText.indexOf("\t"); if (tabIndex !== -1) pathText = pathText.slice(0, tabIndex); return stripDiffPathPrefix(pathText); } function extractUnifiedChanges(input: string): UnifiedChange[] { const changes = new Map(); let oldPath = ""; for (const line of input.split(/\r?\n/)) { if (line.startsWith("--- ")) { oldPath = unifiedHeaderPath(line, "--- "); continue; } if (!line.startsWith("+++ ")) continue; const newPath = unifiedHeaderPath(line, "+++ "); const path = newPath || oldPath; if (!path) continue; const changeKind: UnifiedChangeKind = oldPath ? (newPath ? "M" : "D") : "A"; changes.set(path, { path, changeKind }); oldPath = ""; } return [...changes.values()]; } function validateUnifiedPatchPaths(cwd: string, changes: UnifiedChange[]): void { const workspace = resolve(cwd); for (const change of changes) { const absolutePath = resolvePatchPath(cwd, change.path); if (!isPathInside(workspace, absolutePath)) throw new Error(`Patch path escapes workspace: ${change.path}`); } } function readPath(line: string, marker: string, lineNumber: number): string { const path = line.slice(marker.length).trim(); if (!path) throw new Error(`Invalid patch line ${lineNumber}: missing path`); return path; } function isFileOp(line: string): boolean { const trimmed = line.trim(); return trimmed.startsWith(ADD) || trimmed.startsWith(DELETE) || trimmed.startsWith(UPDATE) || trimmed === END; } function parseUpdateChunk( lines: string[], startIndex: number, lineNumber: number, allowMissingContext: boolean, ): { hunk: Hunk; consumed: number } { if (startIndex >= lines.length) throw new Error(`Invalid update hunk line ${lineNumber}: update hunk does not contain any lines`); let header: string | undefined; let index = startIndex; if (lines[index] === "@@") { index++; } else if (lines[index]?.startsWith("@@ ")) { header = (lines[index] ?? "").slice(3); index++; } else if (!allowMissingContext) { throw new Error(`Invalid update hunk line ${lineNumber}: expected @@ header`); } if (index >= lines.length) { throw new Error(`Invalid update hunk line ${lineNumber + 1}: update hunk does not contain any lines`); } const hunkLines: HunkLine[] = []; let endOfFile = false; let parsedLines = 0; for (; index < lines.length; index++) { const current = lines[index] ?? ""; if (current === EOF_MARKER) { if (parsedLines === 0) { throw new Error(`Invalid update hunk line ${lineNumber + 1}: update hunk does not contain any lines`); } endOfFile = true; parsedLines++; index++; break; } const prefix = current[0]; if (prefix === undefined) { hunkLines.push({ kind: "context", text: "" }); } else if (prefix === " ") { hunkLines.push({ kind: "context", text: current.slice(1) }); } else if (prefix === "-") { hunkLines.push({ kind: "remove", text: current.slice(1) }); } else if (prefix === "+") { hunkLines.push({ kind: "add", text: current.slice(1) }); } else { if (parsedLines === 0) { throw new Error(`Invalid update hunk line ${lineNumber + 1}: expected space, -, or + prefix`); } break; } parsedLines++; } return { hunk: { header, lines: hunkLines, endOfFile }, consumed: index - startIndex }; } export function parseApplyPatch(input: string): PatchOperation[] { const lines = patchBodyLines(input); const operations: PatchOperation[] = []; let index = 0; while (index < lines.length) { const line = lines[index]?.trim() ?? ""; const lineNumber = index + 2; if (line.startsWith(ADD)) { const path = readPath(line, ADD, lineNumber); index++; const content: string[] = []; while (index < lines.length) { const contentLine = lines[index] ?? ""; if (!contentLine.startsWith("+")) break; content.push(contentLine.slice(1)); index++; } operations.push({ kind: "add", path, lines: content }); continue; } if (line.startsWith(DELETE)) { operations.push({ kind: "delete", path: readPath(line, DELETE, lineNumber) }); index++; continue; } if (line.startsWith(UPDATE)) { const path = readPath(line, UPDATE, lineNumber); index++; let moveTo: string | undefined; if ((lines[index] ?? "").trim().startsWith(MOVE)) { moveTo = readPath((lines[index] ?? "").trim(), MOVE, index + 2); index++; } const hunks: Hunk[] = []; while (index < lines.length) { const current = lines[index] ?? ""; if (current.trim() === "") { index++; continue; } if (isFileOp(current) || current.startsWith("*")) break; const parsed = parseUpdateChunk(lines, index, index + 2, hunks.length === 0); hunks.push(parsed.hunk); index += parsed.consumed; } if (hunks.length === 0) { throw new Error(`Invalid update for ${path}: update must include hunks`); } operations.push({ kind: "update", path, moveTo, hunks }); continue; } throw new Error(`Invalid patch line ${lineNumber}: expected file operation header`); } return operations; } function resolvePatchPath(cwd: string, patchPath: string): string { return resolve(cwd, patchPath); } function isEnoent(error: unknown): boolean { return !!error && typeof error === "object" && "code" in error && error.code === "ENOENT"; } function assertNotAborted(signal?: AbortSignal): void { if (signal?.aborted) throw new Error("Operation aborted"); } function splitContent(content: string): string[] { const lines = content.split("\n"); if (lines.at(-1) === "") lines.pop(); return lines; } function joinContent(lines: string[]): string { const next = [...lines]; if (next.at(-1) !== "") next.push(""); return next.join("\n"); } function hunkOldLines(hunk: Hunk): string[] { return hunk.lines.filter((line) => line.kind !== "add").map((line) => line.text); } function hunkNewLines(hunk: Hunk): string[] { return hunk.lines.filter((line) => line.kind !== "remove").map((line) => line.text); } function matchesAt(lines: string[], pattern: string[], index: number): boolean { if (index + pattern.length > lines.length) return false; for (let offset = 0; offset < pattern.length; offset++) { if (lines[index + offset] !== pattern[offset]) return false; } return true; } function findHeaderStart(path: string, lines: string[], header: string | undefined, startAt: number): number { if (!header) return startAt; const match = findMatch(lines, [header], startAt, false); if (match.kind === "missing") throw new Error(`Failed to find context '${header}' in ${path}`); return match.index + 1; } function normalizeUnicodePunctuation(value: string): string { return value .trim() .replace(/[\u2010\u2011\u2012\u2013\u2014\u2015\u2212]/g, "-") .replace(/[\u2018\u2019\u201A\u201B]/g, "'") .replace(/[\u201C\u201D\u201E\u201F]/g, '"') .replace(/[\u00A0\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u202F\u205F\u3000]/g, " "); } function findMatch(lines: string[], pattern: string[], startAt: number, endOfFile: boolean): Match { if (pattern.length === 0) return { kind: "found", index: startAt }; if (pattern.length > lines.length) return { kind: "missing" }; const searchStart = endOfFile && lines.length >= pattern.length ? lines.length - pattern.length : startAt; const normalizers = [(value: string) => value, (value: string) => value.trimEnd(), (value: string) => value.trim(), normalizeUnicodePunctuation]; for (const normalize of normalizers) { const normalizedLines = lines.map(normalize); const normalizedPattern = pattern.map(normalize); for (let index = searchStart; index <= lines.length - pattern.length; index++) { if (matchesAt(normalizedLines, normalizedPattern, index)) return { kind: "found", index }; } } return { kind: "missing" }; } function applyUpdateHunks(path: string, content: string, hunks: Hunk[]): string { const originalLines = splitContent(content); const replacements: Array<{ start: number; oldLength: number; newLines: string[] }> = []; let lineIndex = 0; for (const hunk of hunks) { lineIndex = findHeaderStart(path, originalLines, hunk.header, lineIndex); let oldLines = hunkOldLines(hunk); let newLines = hunkNewLines(hunk); if (oldLines.length === 0) { replacements.push({ start: originalLines.length, oldLength: 0, newLines }); continue; } let match = findMatch(originalLines, oldLines, lineIndex, hunk.endOfFile); if (match.kind === "missing" && oldLines.at(-1) === "") { oldLines = oldLines.slice(0, -1); if (newLines.at(-1) === "") newLines = newLines.slice(0, -1); match = findMatch(originalLines, oldLines, lineIndex, hunk.endOfFile); } if (match.kind === "missing") { throw new Error(`Failed to find expected lines in ${path}:\n${hunkOldLines(hunk).join("\n")}`); } replacements.push({ start: match.index, oldLength: oldLines.length, newLines }); lineIndex = match.index + oldLines.length; } const lines = [...originalLines]; replacements.sort((left, right) => left.start - right.start); for (const replacement of replacements.reverse()) { lines.splice(replacement.start, replacement.oldLength, ...replacement.newLines); } return joinContent(lines); } async function assertDeletableFile(absolutePath: string, patchPath: string): Promise { let metadata: Awaited>; try { metadata = await stat(absolutePath); } catch (error) { if (isEnoent(error)) throw new Error(`Cannot delete missing file: ${patchPath}`); throw error; } if (metadata.isDirectory()) throw new Error(`Cannot delete directory: ${patchPath}`); } async function readUpdateFile(absolutePath: string, patchPath: string): Promise { try { return await readFile(absolutePath, "utf8"); } catch (error) { if (isEnoent(error)) throw new Error(`Cannot update missing file: ${patchPath}`); throw error; } } async function realpathIfExists(path: string): Promise { try { return await realpath(path); } catch (error) { if (isEnoent(error)) return undefined; throw error; } } async function planChanges(cwd: string, operations: PatchOperation[], signal?: AbortSignal): Promise { if (operations.length === 0) throw new Error("No files were modified."); const changes: PlannedChange[] = []; const pendingContent = new Map(); for (const operation of operations) { assertNotAborted(signal); const absolutePath = resolvePatchPath(cwd, operation.path); if (operation.kind === "add") { const content = operation.lines.length === 0 ? "" : `${operation.lines.join("\n")}\n`; pendingContent.set(absolutePath, content); changes.push({ kind: "write", changeKind: "A", path: operation.path, absolutePath, content, }); continue; } if (operation.kind === "delete") { if (pendingContent.get(absolutePath) === null) throw new Error(`Cannot delete missing file: ${operation.path}`); if (!pendingContent.has(absolutePath)) await assertDeletableFile(absolutePath, operation.path); pendingContent.set(absolutePath, null); changes.push({ kind: "delete", changeKind: "D", path: operation.path, absolutePath, summarize: true }); continue; } const pending = pendingContent.get(absolutePath); if (pending === null) throw new Error(`Cannot update missing file: ${operation.path}`); const current = pending ?? (await readUpdateFile(absolutePath, operation.path)); assertNotAborted(signal); const targetPath = operation.moveTo ?? operation.path; const targetAbsolutePath = resolvePatchPath(cwd, targetPath); if (operation.moveTo && targetAbsolutePath === absolutePath) { throw new Error(`Cannot move file to itself: ${operation.path}`); } const content = applyUpdateHunks(operation.path, current, operation.hunks); pendingContent.set(targetAbsolutePath, content); changes.push({ kind: "write", changeKind: "M", path: targetPath, absolutePath: targetAbsolutePath, content, }); if (operation.moveTo) { if (!pendingContent.has(absolutePath)) await assertDeletableFile(absolutePath, operation.path); pendingContent.set(absolutePath, null); changes.push({ kind: "delete", changeKind: "D", path: operation.path, absolutePath, summarize: false }); } } return changes; } async function writeChange(change: PlannedWrite, signal?: AbortSignal): Promise { assertNotAborted(signal); await mkdir(dirname(change.absolutePath), { recursive: true }); await writeFile(change.absolutePath, change.content, { encoding: "utf8" }); } async function withMutationQueues(paths: string[], fn: () => Promise): Promise { const [first, ...rest] = [...new Set(paths)].sort(); if (!first) return fn(); return withFileMutationQueue(first, () => withMutationQueues(rest, fn)); } async function operationPaths(cwd: string, operations: PatchOperation[]): Promise { const paths: string[] = []; for (const operation of operations) { const absolutePath = resolvePatchPath(cwd, operation.path); const realPath = await realpathIfExists(absolutePath); paths.push(realPath ?? absolutePath); if (operation.kind === "update" && operation.moveTo) { const targetAbsolutePath = resolvePatchPath(cwd, operation.moveTo); const targetRealPath = await realpathIfExists(targetAbsolutePath); paths.push(targetRealPath ?? targetAbsolutePath); } } return paths; } function summarizedChanges(changes: PlannedChange[]): PlannedChange[] { return changes.filter((change) => change.kind === "write" || change.summarize); } function summaryForChanges(changes: PlannedChange[]): string { const lines = ["Success. Updated the following files:"]; const visible = summarizedChanges(changes); for (const changeKind of ["A", "M", "D"] as const) { for (const change of visible) { if (change.changeKind === changeKind) lines.push(`${change.changeKind} ${change.path}`); } } return `${lines.join("\n")}\n`; } function summaryForUnifiedChanges(changes: UnifiedChange[]): string { const lines = ["Success. Updated the following files:"]; for (const changeKind of ["A", "M", "D"] as const) { for (const change of changes) { if (change.changeKind === changeKind) lines.push(`${change.changeKind} ${change.path}`); } } return `${lines.join("\n")}\n`; } function runGitApply(cwd: string, patchFile: string, signal?: AbortSignal): Promise { return new Promise((resolvePromise, reject) => { const child = spawn("git", ["apply", "--no-index", "--whitespace=nowarn", "--recount", patchFile], { cwd, signal }); let stderr = ""; let settled = false; const fail = (error: Error) => { if (settled) return; settled = true; reject(error); }; const succeed = () => { if (settled) return; settled = true; resolvePromise(); }; child.stderr?.setEncoding("utf8"); child.stderr?.on("data", (chunk) => { stderr += String(chunk); }); child.on("error", (error) => { if (signal?.aborted) fail(new Error("Operation aborted")); else fail(error); }); child.on("close", (code, signalName) => { if (code === 0) { succeed(); return; } const suffix = signalName ? ` (signal ${signalName})` : code === null ? "" : ` (exit ${code})`; fail(new Error(stderr.trim() || `git apply failed${suffix}`)); }); }); } async function applyUnifiedPatch(cwd: string, input: string, signal?: AbortSignal): Promise { assertNotAborted(signal); const changes = extractUnifiedChanges(input); if (changes.length === 0) throw new Error("Invalid unified diff: no changed files found"); validateUnifiedPatchPaths(cwd, changes); const tempDir = await mkdtemp(join(tmpdir(), "pi-apply-patch-")); const patchFile = join(tempDir, "patch.diff"); try { await writeFile(patchFile, input, "utf8"); await runGitApply(cwd, patchFile, signal); } finally { await rm(tempDir, { recursive: true, force: true }).catch(() => undefined); } return { changedFiles: changes.map((change) => change.path), summary: summaryForUnifiedChanges(changes) }; } export async function applyPatch(cwd: string, input: string, signal?: AbortSignal): Promise { if (!looksLikeBeginPatch(input)) return applyUnifiedPatch(cwd, input, signal); const operations = parseApplyPatch(input); const paths = await operationPaths(cwd, operations); let changes: PlannedChange[] = []; await withMutationQueues(paths, async () => { changes = await planChanges(cwd, operations, signal); for (const change of changes) { assertNotAborted(signal); if (change.kind === "delete") await rm(change.absolutePath, { force: false }); else await writeChange(change, signal); } }); const changedFiles = [...new Set(summarizedChanges(changes).map((change) => change.path))]; return { changedFiles, summary: summaryForChanges(changes) }; }