opencode_offline/packages/opencode/src/tool/patch.ts

421 lines
12 KiB
TypeScript
Raw Normal View History

2025-06-01 02:41:00 +08:00
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"
2025-05-27 03:08:31 +08:00
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 /)
2025-06-01 02:41:00 +08:00
The tool will apply all changes in a single atomic operation.`
2025-05-27 03:08:31 +08:00
const PatchParams = z.object({
patchText: z
2025-05-27 03:08:31 +08:00
.string()
.describe("The full patch text that describes all changes to be made"),
2025-06-01 02:41:00 +08:00
})
2025-05-27 03:08:31 +08:00
interface PatchResponseMetadata {
2025-06-01 02:41:00 +08:00
changed: string[]
additions: number
removals: number
2025-05-27 03:08:31 +08:00
}
interface Change {
2025-06-01 02:41:00 +08:00
type: "add" | "update" | "delete"
old_content?: string
new_content?: string
2025-05-27 03:08:31 +08:00
}
interface Commit {
2025-06-01 02:41:00 +08:00
changes: Record<string, Change>
2025-05-27 03:08:31 +08:00
}
interface PatchOperation {
2025-06-01 02:41:00 +08:00
type: "update" | "add" | "delete"
filePath: string
hunks?: PatchHunk[]
content?: string
2025-05-27 03:08:31 +08:00
}
interface PatchHunk {
2025-06-01 02:41:00 +08:00
contextLine: string
changes: PatchChange[]
2025-05-27 03:08:31 +08:00
}
interface PatchChange {
2025-06-01 02:41:00 +08:00
type: "keep" | "remove" | "add"
content: string
2025-05-27 03:08:31 +08:00
}
function identifyFilesNeeded(patchText: string): string[] {
2025-06-01 02:41:00 +08:00
const files: string[] = []
const lines = patchText.split("\n")
2025-05-27 03:08:31 +08:00
for (const line of lines) {
if (
line.startsWith("*** Update File:") ||
line.startsWith("*** Delete File:")
) {
2025-06-01 02:41:00 +08:00
const filePath = line.split(":", 2)[1]?.trim()
if (filePath) files.push(filePath)
2025-05-27 03:08:31 +08:00
}
}
2025-06-01 02:41:00 +08:00
return files
2025-05-27 03:08:31 +08:00
}
function identifyFilesAdded(patchText: string): string[] {
2025-06-01 02:41:00 +08:00
const files: string[] = []
const lines = patchText.split("\n")
2025-05-27 03:08:31 +08:00
for (const line of lines) {
if (line.startsWith("*** Add File:")) {
2025-06-01 02:41:00 +08:00
const filePath = line.split(":", 2)[1]?.trim()
if (filePath) files.push(filePath)
2025-05-27 03:08:31 +08:00
}
}
2025-06-01 02:41:00 +08:00
return files
2025-05-27 03:08:31 +08:00
}
function textToPatch(
patchText: string,
_currentFiles: Record<string, string>,
): [PatchOperation[], number] {
2025-06-01 02:41:00 +08:00
const operations: PatchOperation[] = []
const lines = patchText.split("\n")
let i = 0
let fuzz = 0
2025-05-27 03:08:31 +08:00
while (i < lines.length) {
2025-06-01 02:41:00 +08:00
const line = lines[i]
2025-05-27 03:08:31 +08:00
if (line.startsWith("*** Update File:")) {
2025-06-01 02:41:00 +08:00
const filePath = line.split(":", 2)[1]?.trim()
2025-05-27 03:08:31 +08:00
if (!filePath) {
2025-06-01 02:41:00 +08:00
i++
continue
2025-05-27 03:08:31 +08:00
}
2025-06-01 02:41:00 +08:00
const hunks: PatchHunk[] = []
i++
2025-05-27 03:08:31 +08:00
while (i < lines.length && !lines[i].startsWith("***")) {
if (lines[i].startsWith("@@")) {
2025-06-01 02:41:00 +08:00
const contextLine = lines[i].substring(2).trim()
const changes: PatchChange[] = []
i++
2025-05-27 03:08:31 +08:00
while (
i < lines.length &&
!lines[i].startsWith("@@") &&
!lines[i].startsWith("***")
) {
2025-06-01 02:41:00 +08:00
const changeLine = lines[i]
2025-05-27 03:08:31 +08:00
if (changeLine.startsWith(" ")) {
2025-06-01 02:41:00 +08:00
changes.push({ type: "keep", content: changeLine.substring(1) })
2025-05-27 03:08:31 +08:00
} else if (changeLine.startsWith("-")) {
changes.push({
type: "remove",
content: changeLine.substring(1),
2025-06-01 02:41:00 +08:00
})
2025-05-27 03:08:31 +08:00
} else if (changeLine.startsWith("+")) {
2025-06-01 02:41:00 +08:00
changes.push({ type: "add", content: changeLine.substring(1) })
2025-05-27 03:08:31 +08:00
}
2025-06-01 02:41:00 +08:00
i++
2025-05-27 03:08:31 +08:00
}
2025-06-01 02:41:00 +08:00
hunks.push({ contextLine, changes })
2025-05-27 03:08:31 +08:00
} else {
2025-06-01 02:41:00 +08:00
i++
2025-05-27 03:08:31 +08:00
}
}
2025-06-01 02:41:00 +08:00
operations.push({ type: "update", filePath, hunks })
2025-05-27 03:08:31 +08:00
} else if (line.startsWith("*** Add File:")) {
2025-06-01 02:41:00 +08:00
const filePath = line.split(":", 2)[1]?.trim()
2025-05-27 03:08:31 +08:00
if (!filePath) {
2025-06-01 02:41:00 +08:00
i++
continue
2025-05-27 03:08:31 +08:00
}
2025-06-01 02:41:00 +08:00
let content = ""
i++
2025-05-27 03:08:31 +08:00
while (i < lines.length && !lines[i].startsWith("***")) {
if (lines[i].startsWith("+")) {
2025-06-01 02:41:00 +08:00
content += lines[i].substring(1) + "\n"
2025-05-27 03:08:31 +08:00
}
2025-06-01 02:41:00 +08:00
i++
2025-05-27 03:08:31 +08:00
}
2025-06-01 02:41:00 +08:00
operations.push({ type: "add", filePath, content: content.slice(0, -1) })
2025-05-27 03:08:31 +08:00
} else if (line.startsWith("*** Delete File:")) {
2025-06-01 02:41:00 +08:00
const filePath = line.split(":", 2)[1]?.trim()
2025-05-27 03:08:31 +08:00
if (filePath) {
2025-06-01 02:41:00 +08:00
operations.push({ type: "delete", filePath })
2025-05-27 03:08:31 +08:00
}
2025-06-01 02:41:00 +08:00
i++
2025-05-27 03:08:31 +08:00
} else {
2025-06-01 02:41:00 +08:00
i++
2025-05-27 03:08:31 +08:00
}
}
2025-06-01 02:41:00 +08:00
return [operations, fuzz]
2025-05-27 03:08:31 +08:00
}
function patchToCommit(
operations: PatchOperation[],
currentFiles: Record<string, string>,
): Commit {
2025-06-01 02:41:00 +08:00
const changes: Record<string, Change> = {}
2025-05-27 03:08:31 +08:00
for (const op of operations) {
if (op.type === "delete") {
changes[op.filePath] = {
type: "delete",
old_content: currentFiles[op.filePath] || "",
2025-06-01 02:41:00 +08:00
}
2025-05-27 03:08:31 +08:00
} else if (op.type === "add") {
changes[op.filePath] = {
type: "add",
new_content: op.content || "",
2025-06-01 02:41:00 +08:00
}
2025-05-27 03:08:31 +08:00
} else if (op.type === "update" && op.hunks) {
2025-06-01 02:41:00 +08:00
const originalContent = currentFiles[op.filePath] || ""
const lines = originalContent.split("\n")
2025-05-27 03:08:31 +08:00
for (const hunk of op.hunks) {
const contextIndex = lines.findIndex((line) =>
line.includes(hunk.contextLine),
2025-06-01 02:41:00 +08:00
)
2025-05-27 03:08:31 +08:00
if (contextIndex === -1) {
2025-06-01 02:41:00 +08:00
throw new Error(`Context line not found: ${hunk.contextLine}`)
2025-05-27 03:08:31 +08:00
}
2025-06-01 02:41:00 +08:00
let currentIndex = contextIndex
2025-05-27 03:08:31 +08:00
for (const change of hunk.changes) {
if (change.type === "keep") {
2025-06-01 02:41:00 +08:00
currentIndex++
2025-05-27 03:08:31 +08:00
} else if (change.type === "remove") {
2025-06-01 02:41:00 +08:00
lines.splice(currentIndex, 1)
2025-05-27 03:08:31 +08:00
} else if (change.type === "add") {
2025-06-01 02:41:00 +08:00
lines.splice(currentIndex, 0, change.content)
currentIndex++
2025-05-27 03:08:31 +08:00
}
}
}
changes[op.filePath] = {
type: "update",
old_content: originalContent,
new_content: lines.join("\n"),
2025-06-01 02:41:00 +08:00
}
2025-05-27 03:08:31 +08:00
}
}
2025-06-01 02:41:00 +08:00
return { changes }
2025-05-27 03:08:31 +08:00
}
function generateDiff(
oldContent: string,
newContent: string,
filePath: string,
): [string, number, number] {
// Mock implementation - would need actual diff generation
2025-06-01 02:41:00 +08:00
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]
2025-05-27 03:08:31 +08:00
}
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") {
2025-06-01 02:41:00 +08:00
await deleteFile(filePath)
2025-05-27 03:08:31 +08:00
} else if (change.new_content !== undefined) {
2025-06-01 02:41:00 +08:00
await writeFile(filePath, change.new_content)
2025-05-27 03:08:31 +08:00
}
}
}
2025-06-01 05:12:16 +08:00
export const PatchTool = Tool.define({
id: "opencode.patch",
2025-05-27 03:08:31 +08:00
description: DESCRIPTION,
parameters: PatchParams,
execute: async (params) => {
if (!params.patchText) {
2025-06-01 02:41:00 +08:00
throw new Error("patchText is required")
2025-05-27 03:08:31 +08:00
}
// Identify all files needed for the patch and verify they've been read
2025-06-01 02:41:00 +08:00
const filesToRead = identifyFilesNeeded(params.patchText)
2025-05-27 03:08:31 +08:00
for (const filePath of filesToRead) {
2025-06-01 02:41:00 +08:00
let absPath = filePath
2025-05-27 03:08:31 +08:00
if (!path.isAbsolute(absPath)) {
2025-06-01 02:41:00 +08:00
absPath = path.resolve(process.cwd(), absPath)
2025-05-27 03:08:31 +08:00
}
if (!FileTimes.get(absPath)) {
throw new Error(
`you must read the file ${filePath} before patching it. Use the FileRead tool first`,
2025-06-01 02:41:00 +08:00
)
2025-05-27 03:08:31 +08:00
}
try {
2025-06-01 02:41:00 +08:00
const stats = await fs.stat(absPath)
2025-05-27 03:08:31 +08:00
if (stats.isDirectory()) {
2025-06-01 02:41:00 +08:00
throw new Error(`path is a directory, not a file: ${absPath}`)
2025-05-27 03:08:31 +08:00
}
2025-06-01 02:41:00 +08:00
const lastRead = FileTimes.get(absPath)
2025-05-27 03:08:31 +08:00
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()})`,
2025-06-01 02:41:00 +08:00
)
2025-05-27 03:08:31 +08:00
}
} catch (error: any) {
if (error.code === "ENOENT") {
2025-06-01 02:41:00 +08:00
throw new Error(`file not found: ${absPath}`)
2025-05-27 03:08:31 +08:00
}
2025-06-01 02:41:00 +08:00
throw new Error(`failed to access file: ${error.message}`)
2025-05-27 03:08:31 +08:00
}
}
// Check for new files to ensure they don't already exist
2025-06-01 02:41:00 +08:00
const filesToAdd = identifyFilesAdded(params.patchText)
2025-05-27 03:08:31 +08:00
for (const filePath of filesToAdd) {
2025-06-01 02:41:00 +08:00
let absPath = filePath
2025-05-27 03:08:31 +08:00
if (!path.isAbsolute(absPath)) {
2025-06-01 02:41:00 +08:00
absPath = path.resolve(process.cwd(), absPath)
2025-05-27 03:08:31 +08:00
}
try {
2025-06-01 02:41:00 +08:00
await fs.stat(absPath)
throw new Error(`file already exists and cannot be added: ${absPath}`)
2025-05-27 03:08:31 +08:00
} catch (error: any) {
if (error.code !== "ENOENT") {
2025-06-01 02:41:00 +08:00
throw new Error(`failed to check file: ${error.message}`)
2025-05-27 03:08:31 +08:00
}
}
}
// Load all required files
2025-06-01 02:41:00 +08:00
const currentFiles: Record<string, string> = {}
2025-05-27 03:08:31 +08:00
for (const filePath of filesToRead) {
2025-06-01 02:41:00 +08:00
let absPath = filePath
2025-05-27 03:08:31 +08:00
if (!path.isAbsolute(absPath)) {
2025-06-01 02:41:00 +08:00
absPath = path.resolve(process.cwd(), absPath)
2025-05-27 03:08:31 +08:00
}
try {
2025-06-01 02:41:00 +08:00
const content = await fs.readFile(absPath, "utf-8")
currentFiles[filePath] = content
2025-05-27 03:08:31 +08:00
} catch (error: any) {
2025-06-01 02:41:00 +08:00
throw new Error(`failed to read file ${absPath}: ${error.message}`)
2025-05-27 03:08:31 +08:00
}
}
// Process the patch
2025-06-01 02:41:00 +08:00
const [patch, fuzz] = textToPatch(params.patchText, currentFiles)
2025-05-27 03:08:31 +08:00
if (fuzz > 3) {
throw new Error(
`patch contains fuzzy matches (fuzz level: ${fuzz}). Please make your context lines more precise`,
2025-06-01 02:41:00 +08:00
)
2025-05-27 03:08:31 +08:00
}
// Convert patch to commit
2025-06-01 02:41:00 +08:00
const commit = patchToCommit(patch, currentFiles)
2025-05-27 03:08:31 +08:00
// Apply the changes to the filesystem
await applyCommit(
commit,
async (filePath: string, content: string) => {
2025-06-01 02:41:00 +08:00
let absPath = filePath
2025-05-27 03:08:31 +08:00
if (!path.isAbsolute(absPath)) {
2025-06-01 02:41:00 +08:00
absPath = path.resolve(process.cwd(), absPath)
2025-05-27 03:08:31 +08:00
}
// Create parent directories if needed
2025-06-01 02:41:00 +08:00
const dir = path.dirname(absPath)
await fs.mkdir(dir, { recursive: true })
await fs.writeFile(absPath, content, "utf-8")
2025-05-27 03:08:31 +08:00
},
async (filePath: string) => {
2025-06-01 02:41:00 +08:00
let absPath = filePath
2025-05-27 03:08:31 +08:00
if (!path.isAbsolute(absPath)) {
2025-06-01 02:41:00 +08:00
absPath = path.resolve(process.cwd(), absPath)
2025-05-27 03:08:31 +08:00
}
2025-06-01 02:41:00 +08:00
await fs.unlink(absPath)
2025-05-27 03:08:31 +08:00
},
2025-06-01 02:41:00 +08:00
)
2025-05-27 03:08:31 +08:00
// Calculate statistics
2025-06-01 02:41:00 +08:00
const changedFiles: string[] = []
let totalAdditions = 0
let totalRemovals = 0
2025-05-27 03:08:31 +08:00
for (const [filePath, change] of Object.entries(commit.changes)) {
2025-06-01 02:41:00 +08:00
let absPath = filePath
2025-05-27 03:08:31 +08:00
if (!path.isAbsolute(absPath)) {
2025-06-01 02:41:00 +08:00
absPath = path.resolve(process.cwd(), absPath)
2025-05-27 03:08:31 +08:00
}
2025-06-01 02:41:00 +08:00
changedFiles.push(absPath)
2025-05-27 03:08:31 +08:00
2025-06-01 02:41:00 +08:00
const oldContent = change.old_content || ""
const newContent = change.new_content || ""
2025-05-27 03:08:31 +08:00
// Calculate diff statistics
const [, additions, removals] = generateDiff(
oldContent,
newContent,
filePath,
2025-06-01 02:41:00 +08:00
)
totalAdditions += additions
totalRemovals += removals
2025-05-27 03:08:31 +08:00
// Record file operations
2025-06-01 02:41:00 +08:00
FileTimes.write(absPath)
FileTimes.read(absPath)
2025-05-27 03:08:31 +08:00
}
2025-06-01 02:41:00 +08:00
const result = `Patch applied successfully. ${changedFiles.length} files changed, ${totalAdditions} additions, ${totalRemovals} removals`
const output = result
2025-05-27 03:08:31 +08:00
return {
metadata: {
changed: changedFiles,
additions: totalAdditions,
removals: totalRemovals,
} satisfies PatchResponseMetadata,
output,
2025-06-01 02:41:00 +08:00
}
2025-05-27 03:08:31 +08:00
},
2025-06-01 02:41:00 +08:00
})