summaryrefslogtreecommitdiffhomepage
path: root/js/src/tool/patch.ts
diff options
context:
space:
mode:
authorDax Raad <[email protected]>2025-05-30 20:47:56 -0400
committerDax Raad <[email protected]>2025-05-30 20:48:36 -0400
commitf3da73553c45f17e04b1e77cb13eb0fca714d1bd (patch)
treea24317a19e1ab2a89da50db669dc6894f15d00d1 /js/src/tool/patch.ts
parent9a26b3058ffc1023e5c7e54b6d571c903d15888e (diff)
downloadopencode-f3da73553c45f17e04b1e77cb13eb0fca714d1bd.tar.gz
opencode-f3da73553c45f17e04b1e77cb13eb0fca714d1bd.zip
sync
Diffstat (limited to 'js/src/tool/patch.ts')
-rw-r--r--js/src/tool/patch.ts420
1 files changed, 0 insertions, 420 deletions
diff --git a/js/src/tool/patch.ts b/js/src/tool/patch.ts
deleted file mode 100644
index 9f9192fda..000000000
--- a/js/src/tool/patch.ts
+++ /dev/null
@@ -1,420 +0,0 @@
-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({
- patchText: 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: "opencode.patch",
- description: DESCRIPTION,
- parameters: PatchParams,
- execute: async (params) => {
- if (!params.patchText) {
- throw new Error("patchText is required");
- }
-
- // Identify all files needed for the patch and verify they've been read
- const filesToRead = identifyFilesNeeded(params.patchText);
- 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(params.patchText);
- 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(params.patchText, 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,
- };
- },
-});