summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorDax Raad <[email protected]>2025-05-26 15:08:31 -0400
committerDax Raad <[email protected]>2025-05-26 17:15:47 -0400
commit0ad87389331c4b18bcecec74476853a2e16a8d10 (patch)
tree025a67eb7e59e01a5383748e156344dda6d194f7
parentdb5744bbc4bd0915447c28e26621ad252b1f8986 (diff)
downloadopencode-0ad87389331c4b18bcecec74476853a2e16a8d10.tar.gz
opencode-0ad87389331c4b18bcecec74476853a2e16a8d10.zip
sync
-rw-r--r--js/src/tool/patch.ts420
1 files changed, 420 insertions, 0 deletions
diff --git a/js/src/tool/patch.ts b/js/src/tool/patch.ts
new file mode 100644
index 000000000..9a02757d7
--- /dev/null
+++ b/js/src/tool/patch.ts
@@ -0,0 +1,420 @@
+import { z } from "zod";
+import * as path from "path";
+import * as fs from "fs/promises";
+import { Tool } from "./tool";
+import { FileTimes } from "./util/file-times";
+
+const DESCRIPTION = `Applies a patch to multiple files in one operation. This tool is useful for making coordinated changes across multiple files.
+
+The patch text must follow this format:
+*** Begin Patch
+*** Update File: /path/to/file
+@@ Context line (unique within the file)
+ Line to keep
+-Line to remove
++Line to add
+ Line to keep
+*** Add File: /path/to/new/file
++Content of the new file
++More content
+*** Delete File: /path/to/file/to/delete
+*** End Patch
+
+Before using this tool:
+1. Use the FileRead tool to understand the files' contents and context
+2. Verify all file paths are correct (use the LS tool)
+
+CRITICAL REQUIREMENTS FOR USING THIS TOOL:
+
+1. UNIQUENESS: Context lines MUST uniquely identify the specific sections you want to change
+2. PRECISION: All whitespace, indentation, and surrounding code must match exactly
+3. VALIDATION: Ensure edits result in idiomatic, correct code
+4. PATHS: Always use absolute file paths (starting with /)
+
+The tool will apply all changes in a single atomic operation.`;
+
+const PatchParams = z.object({
+ patch_text: z
+ .string()
+ .describe("The full patch text that describes all changes to be made"),
+});
+
+interface PatchResponseMetadata {
+ changed: string[];
+ additions: number;
+ removals: number;
+}
+
+interface Change {
+ type: "add" | "update" | "delete";
+ old_content?: string;
+ new_content?: string;
+}
+
+interface Commit {
+ changes: Record<string, Change>;
+}
+
+interface PatchOperation {
+ type: "update" | "add" | "delete";
+ filePath: string;
+ hunks?: PatchHunk[];
+ content?: string;
+}
+
+interface PatchHunk {
+ contextLine: string;
+ changes: PatchChange[];
+}
+
+interface PatchChange {
+ type: "keep" | "remove" | "add";
+ content: string;
+}
+
+function identifyFilesNeeded(patchText: string): string[] {
+ const files: string[] = [];
+ const lines = patchText.split("\n");
+ for (const line of lines) {
+ if (
+ line.startsWith("*** Update File:") ||
+ line.startsWith("*** Delete File:")
+ ) {
+ const filePath = line.split(":", 2)[1]?.trim();
+ if (filePath) files.push(filePath);
+ }
+ }
+ return files;
+}
+
+function identifyFilesAdded(patchText: string): string[] {
+ const files: string[] = [];
+ const lines = patchText.split("\n");
+ for (const line of lines) {
+ if (line.startsWith("*** Add File:")) {
+ const filePath = line.split(":", 2)[1]?.trim();
+ if (filePath) files.push(filePath);
+ }
+ }
+ return files;
+}
+
+function textToPatch(
+ patchText: string,
+ _currentFiles: Record<string, string>,
+): [PatchOperation[], number] {
+ const operations: PatchOperation[] = [];
+ const lines = patchText.split("\n");
+ let i = 0;
+ let fuzz = 0;
+
+ while (i < lines.length) {
+ const line = lines[i];
+
+ if (line.startsWith("*** Update File:")) {
+ const filePath = line.split(":", 2)[1]?.trim();
+ if (!filePath) {
+ i++;
+ continue;
+ }
+
+ const hunks: PatchHunk[] = [];
+ i++;
+
+ while (i < lines.length && !lines[i].startsWith("***")) {
+ if (lines[i].startsWith("@@")) {
+ const contextLine = lines[i].substring(2).trim();
+ const changes: PatchChange[] = [];
+ i++;
+
+ while (
+ i < lines.length &&
+ !lines[i].startsWith("@@") &&
+ !lines[i].startsWith("***")
+ ) {
+ const changeLine = lines[i];
+ if (changeLine.startsWith(" ")) {
+ changes.push({ type: "keep", content: changeLine.substring(1) });
+ } else if (changeLine.startsWith("-")) {
+ changes.push({
+ type: "remove",
+ content: changeLine.substring(1),
+ });
+ } else if (changeLine.startsWith("+")) {
+ changes.push({ type: "add", content: changeLine.substring(1) });
+ }
+ i++;
+ }
+
+ hunks.push({ contextLine, changes });
+ } else {
+ i++;
+ }
+ }
+
+ operations.push({ type: "update", filePath, hunks });
+ } else if (line.startsWith("*** Add File:")) {
+ const filePath = line.split(":", 2)[1]?.trim();
+ if (!filePath) {
+ i++;
+ continue;
+ }
+
+ let content = "";
+ i++;
+
+ while (i < lines.length && !lines[i].startsWith("***")) {
+ if (lines[i].startsWith("+")) {
+ content += lines[i].substring(1) + "\n";
+ }
+ i++;
+ }
+
+ operations.push({ type: "add", filePath, content: content.slice(0, -1) });
+ } else if (line.startsWith("*** Delete File:")) {
+ const filePath = line.split(":", 2)[1]?.trim();
+ if (filePath) {
+ operations.push({ type: "delete", filePath });
+ }
+ i++;
+ } else {
+ i++;
+ }
+ }
+
+ return [operations, fuzz];
+}
+
+function patchToCommit(
+ operations: PatchOperation[],
+ currentFiles: Record<string, string>,
+): Commit {
+ const changes: Record<string, Change> = {};
+
+ for (const op of operations) {
+ if (op.type === "delete") {
+ changes[op.filePath] = {
+ type: "delete",
+ old_content: currentFiles[op.filePath] || "",
+ };
+ } else if (op.type === "add") {
+ changes[op.filePath] = {
+ type: "add",
+ new_content: op.content || "",
+ };
+ } else if (op.type === "update" && op.hunks) {
+ const originalContent = currentFiles[op.filePath] || "";
+ const lines = originalContent.split("\n");
+
+ for (const hunk of op.hunks) {
+ const contextIndex = lines.findIndex((line) =>
+ line.includes(hunk.contextLine),
+ );
+ if (contextIndex === -1) {
+ throw new Error(`Context line not found: ${hunk.contextLine}`);
+ }
+
+ let currentIndex = contextIndex;
+ for (const change of hunk.changes) {
+ if (change.type === "keep") {
+ currentIndex++;
+ } else if (change.type === "remove") {
+ lines.splice(currentIndex, 1);
+ } else if (change.type === "add") {
+ lines.splice(currentIndex, 0, change.content);
+ currentIndex++;
+ }
+ }
+ }
+
+ changes[op.filePath] = {
+ type: "update",
+ old_content: originalContent,
+ new_content: lines.join("\n"),
+ };
+ }
+ }
+
+ return { changes };
+}
+
+function generateDiff(
+ oldContent: string,
+ newContent: string,
+ filePath: string,
+): [string, number, number] {
+ // Mock implementation - would need actual diff generation
+ const lines1 = oldContent.split("\n");
+ const lines2 = newContent.split("\n");
+ const additions = Math.max(0, lines2.length - lines1.length);
+ const removals = Math.max(0, lines1.length - lines2.length);
+ return [`--- ${filePath}\n+++ ${filePath}\n`, additions, removals];
+}
+
+async function applyCommit(
+ commit: Commit,
+ writeFile: (path: string, content: string) => Promise<void>,
+ deleteFile: (path: string) => Promise<void>,
+): Promise<void> {
+ for (const [filePath, change] of Object.entries(commit.changes)) {
+ if (change.type === "delete") {
+ await deleteFile(filePath);
+ } else if (change.new_content !== undefined) {
+ await writeFile(filePath, change.new_content);
+ }
+ }
+}
+
+export const patch = Tool.define({
+ name: "patch",
+ description: DESCRIPTION,
+ parameters: PatchParams,
+ execute: async ({ patch_text }) => {
+ if (!patch_text) {
+ throw new Error("patch_text is required");
+ }
+
+ // Identify all files needed for the patch and verify they've been read
+ const filesToRead = identifyFilesNeeded(patch_text);
+ for (const filePath of filesToRead) {
+ let absPath = filePath;
+ if (!path.isAbsolute(absPath)) {
+ absPath = path.resolve(process.cwd(), absPath);
+ }
+
+ if (!FileTimes.get(absPath)) {
+ throw new Error(
+ `you must read the file ${filePath} before patching it. Use the FileRead tool first`,
+ );
+ }
+
+ try {
+ const stats = await fs.stat(absPath);
+ if (stats.isDirectory()) {
+ throw new Error(`path is a directory, not a file: ${absPath}`);
+ }
+
+ const lastRead = FileTimes.get(absPath);
+ if (lastRead && stats.mtime > lastRead) {
+ throw new Error(
+ `file ${absPath} has been modified since it was last read (mod time: ${stats.mtime.toISOString()}, last read: ${lastRead.toISOString()})`,
+ );
+ }
+ } catch (error: any) {
+ if (error.code === "ENOENT") {
+ throw new Error(`file not found: ${absPath}`);
+ }
+ throw new Error(`failed to access file: ${error.message}`);
+ }
+ }
+
+ // Check for new files to ensure they don't already exist
+ const filesToAdd = identifyFilesAdded(patch_text);
+ for (const filePath of filesToAdd) {
+ let absPath = filePath;
+ if (!path.isAbsolute(absPath)) {
+ absPath = path.resolve(process.cwd(), absPath);
+ }
+
+ try {
+ await fs.stat(absPath);
+ throw new Error(`file already exists and cannot be added: ${absPath}`);
+ } catch (error: any) {
+ if (error.code !== "ENOENT") {
+ throw new Error(`failed to check file: ${error.message}`);
+ }
+ }
+ }
+
+ // Load all required files
+ const currentFiles: Record<string, string> = {};
+ for (const filePath of filesToRead) {
+ let absPath = filePath;
+ if (!path.isAbsolute(absPath)) {
+ absPath = path.resolve(process.cwd(), absPath);
+ }
+
+ try {
+ const content = await fs.readFile(absPath, "utf-8");
+ currentFiles[filePath] = content;
+ } catch (error: any) {
+ throw new Error(`failed to read file ${absPath}: ${error.message}`);
+ }
+ }
+
+ // Process the patch
+ const [patch, fuzz] = textToPatch(patch_text, currentFiles);
+ if (fuzz > 3) {
+ throw new Error(
+ `patch contains fuzzy matches (fuzz level: ${fuzz}). Please make your context lines more precise`,
+ );
+ }
+
+ // Convert patch to commit
+ const commit = patchToCommit(patch, currentFiles);
+
+ // Apply the changes to the filesystem
+ await applyCommit(
+ commit,
+ async (filePath: string, content: string) => {
+ let absPath = filePath;
+ if (!path.isAbsolute(absPath)) {
+ absPath = path.resolve(process.cwd(), absPath);
+ }
+
+ // Create parent directories if needed
+ const dir = path.dirname(absPath);
+ await fs.mkdir(dir, { recursive: true });
+ await fs.writeFile(absPath, content, "utf-8");
+ },
+ async (filePath: string) => {
+ let absPath = filePath;
+ if (!path.isAbsolute(absPath)) {
+ absPath = path.resolve(process.cwd(), absPath);
+ }
+ await fs.unlink(absPath);
+ },
+ );
+
+ // Calculate statistics
+ const changedFiles: string[] = [];
+ let totalAdditions = 0;
+ let totalRemovals = 0;
+
+ for (const [filePath, change] of Object.entries(commit.changes)) {
+ let absPath = filePath;
+ if (!path.isAbsolute(absPath)) {
+ absPath = path.resolve(process.cwd(), absPath);
+ }
+ changedFiles.push(absPath);
+
+ const oldContent = change.old_content || "";
+ const newContent = change.new_content || "";
+
+ // Calculate diff statistics
+ const [, additions, removals] = generateDiff(
+ oldContent,
+ newContent,
+ filePath,
+ );
+ totalAdditions += additions;
+ totalRemovals += removals;
+
+ // Record file operations
+ FileTimes.write(absPath);
+ FileTimes.read(absPath);
+ }
+
+ const result = `Patch applied successfully. ${changedFiles.length} files changed, ${totalAdditions} additions, ${totalRemovals} removals`;
+ const output = result;
+
+ return {
+ metadata: {
+ changed: changedFiles,
+ additions: totalAdditions,
+ removals: totalRemovals,
+ } satisfies PatchResponseMetadata,
+ output,
+ };
+ },
+});