mirror of
https://github.com/aljazceru/opencode.git
synced 2025-12-24 19:24:22 +01:00
sync
This commit is contained in:
420
js/src/tool/patch.ts
Normal file
420
js/src/tool/patch.ts
Normal file
@@ -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,
|
||||
};
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user