Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@

### Fixed

- Fixed cancelled multi-file `apply_patch` calls returning before byte-exact rollback completed, which could leave updates and deletions applied behind a generic abort result ([oh-my-openagent #8246](https://github.com/code-yeongyu/oh-my-openagent/issues/8246)).

- Fixed the goal monitor parking on the ask-user idle-timeout setting instead of the earliest pending question deadline, so a shorter request no longer waits for a longer one; typing in an answer now extends that park without adding continuation prompts ([#1645](https://github.com/code-yeongyu/senpi/issues/1645)).

- Fixed shared RPC hosts expiring an old idle window after a short readiness connection, which could remove the Windows named pipe before the client attached (part of #1290).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ gpt-apply-patch/
├── patch-diff.ts # Diff/hunk math on top of the npm `diff` package
├── patch-replace.ts # Replace algorithms (anchor matching, seek fallback)
├── seek-sequence.ts # Strict context-line seek with N-line tolerance
├── apply.ts # Apply parsed patch to workspace
├── apply.ts # Parse/orchestrate patch results and cancellation
├── apply-operation.ts # Apply one add/update/delete/move operation
├── transaction.ts # Multi-path byte snapshots and abort rollback
├── workspace.ts # File I/O + path normalization for patches
├── preview.ts # Preview before apply (used by permission-system parser)
├── preview-format.ts # Render preview as TUI nodes (opencode-style diff)
Expand All @@ -33,6 +35,7 @@ gpt-apply-patch/
|------|------|
| Fix a parse error from a real GPT output | `parser.ts` — add a regression test in `test/suite/gpt-apply-patch-extension.test.ts` |
| Improve strict-seek tolerance | `seek-sequence.ts` |
| Change cancellation or rollback | `transaction.ts` + `apply.ts` |
| Change render | `preview-format.ts` + `streaming-render.ts` |
| Add a new file op (e.g. `*** Rename File:`) | `types.ts` + `parser.ts` + `apply.ts` |
| Adjust which models opt in | `extension.ts` — `APPLY_PATCH_FREEFORM_APIS` + `gpt-` id prefix in `isOpenAIGptModel()` |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { mkdir, rename, rm, unlink, writeFile } from "node:fs/promises";
import path from "node:path";
import { withFileMutationQueues } from "../../../tools/file-mutation-queue.ts";
import { replaceChunks } from "./patch-replace.ts";
import { buildPatchPreviewFile, readPatchFileSnapshot } from "./preview.ts";
import type { AtomicWriteOperations, ParsedPatch } from "./types.ts";
import { resolvePatchPath } from "./workspace.ts";

const ATOMIC_WRITE_OPERATIONS: AtomicWriteOperations = { writeFile, rename, unlink };

function hasErrorCode(error: unknown, code: string): boolean {
return Boolean(error && typeof error === "object" && "code" in error && error.code === code);
}

async function writeFileAtomic(
absPath: string,
content: string,
operations: AtomicWriteOperations = ATOMIC_WRITE_OPERATIONS,
): Promise<void> {
const tempPath = `${absPath}.tmp.${process.pid}.${Math.random().toString(16).slice(2)}`;
await operations.writeFile(tempPath, content, "utf-8");
try {
await operations.rename(tempPath, absPath);
} catch (error) {
if (!hasErrorCode(error, "EEXIST")) throw error;
await operations.unlink(absPath);
await operations.rename(tempPath, absPath);
}
}

async function writeBinaryFileAtomic(absPath: string, content: Uint8Array): Promise<void> {
const tempPath = `${absPath}.tmp.${process.pid}.${Math.random().toString(16).slice(2)}`;
await writeFile(tempPath, content);
try {
await rename(tempPath, absPath);
} catch (error) {
if (!hasErrorCode(error, "EEXIST")) throw error;
await unlink(absPath);
await rename(tempPath, absPath);
}
}

export async function __testWriteFileAtomic(
absPath: string,
content: string,
operations: AtomicWriteOperations,
): Promise<void> {
await writeFileAtomic(absPath, content, operations);
}

export function patchMutationPaths(cwd: string, hunk: ParsedPatch): string[] {
return hunk.type === "update" && hunk.movePath
? [resolvePatchPath(cwd, hunk.filePath), resolvePatchPath(cwd, hunk.movePath)]
: [resolvePatchPath(cwd, hunk.filePath)];
}

async function applySingleHunkUnlocked(
cwd: string,
hunk: ParsedPatch,
): Promise<{
readonly summary: string;
readonly appliedFile: string;
readonly fuzz: number;
readonly preview: ReturnType<typeof buildPatchPreviewFile>;
}> {
const absolutePath = resolvePatchPath(cwd, hunk.filePath);
if (hunk.type === "add") {
const source = await readPatchFileSnapshot(absolutePath);
const preview = buildPatchPreviewFile({ hunk, source, newContent: hunk.content });
await mkdir(path.dirname(absolutePath), { recursive: true });
await writeFileAtomic(absolutePath, hunk.content);
return { summary: `add: ${hunk.filePath}`, appliedFile: hunk.filePath, fuzz: 0, preview };
}

if (hunk.type === "delete") {
const source = await readPatchFileSnapshot(absolutePath);
const preview = buildPatchPreviewFile({ hunk, source, newContent: "" });
await rm(absolutePath);
return { summary: `delete: ${hunk.filePath}`, appliedFile: hunk.filePath, fuzz: 0, preview };
}

const source = await readPatchFileSnapshot(absolutePath);
if (!source.exists) {
const error = new Error(`ENOENT: no such file or directory, open '${absolutePath}'`) as NodeJS.ErrnoException;
error.code = "ENOENT";
throw error;
}
const absoluteMovePath = hunk.movePath ? resolvePatchPath(cwd, hunk.movePath) : undefined;
const moveDestination =
absoluteMovePath && absoluteMovePath !== absolutePath ? await readPatchFileSnapshot(absoluteMovePath) : undefined;
if (source.binary) {
if (hunk.chunks.length > 0) {
throw new Error(`apply_patch cannot apply text hunks to binary file: ${hunk.filePath}`);
}
if (!hunk.movePath || !absoluteMovePath || !source.bytes) {
throw new Error(`apply_patch cannot update binary file without a move destination: ${hunk.filePath}`);
}
const preview = buildPatchPreviewFile({
hunk,
source,
newContent: "",
...(moveDestination ? { moveDestination } : {}),
});
await mkdir(path.dirname(absoluteMovePath), { recursive: true });
await writeBinaryFileAtomic(absoluteMovePath, source.bytes);
if (absoluteMovePath !== absolutePath) await rm(absolutePath);
return {
summary: `move: ${hunk.filePath} -> ${hunk.movePath}`,
appliedFile: hunk.movePath,
fuzz: 0,
preview,
};
}

const chunkResult =
hunk.chunks.length === 0
? { content: source.content, fuzz: 0 }
: replaceChunks(source.content, hunk.filePath, hunk.chunks);

if (hunk.movePath && absoluteMovePath) {
const preview = buildPatchPreviewFile({
hunk,
source,
newContent: chunkResult.content,
...(moveDestination ? { moveDestination } : {}),
});
await mkdir(path.dirname(absoluteMovePath), { recursive: true });
await writeFileAtomic(absoluteMovePath, chunkResult.content);
if (absoluteMovePath !== absolutePath) await rm(absolutePath);
return {
summary: `move: ${hunk.filePath} -> ${hunk.movePath}`,
appliedFile: hunk.movePath,
fuzz: chunkResult.fuzz,
preview,
};
}

const preview = buildPatchPreviewFile({ hunk, source, newContent: chunkResult.content });
await writeFileAtomic(absolutePath, chunkResult.content);
return { summary: `update: ${hunk.filePath}`, appliedFile: hunk.filePath, fuzz: chunkResult.fuzz, preview };
}

export async function applySingleHunk(cwd: string, hunk: ParsedPatch, queuesHeld = false) {
if (queuesHeld) return applySingleHunkUnlocked(cwd, hunk);
return withFileMutationQueues(patchMutationPaths(cwd, hunk), () => applySingleHunkUnlocked(cwd, hunk));
}
Loading