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

371 lines
10 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"
2025-06-27 23:29:20 +08:00
import { FileTime } from "../file/time"
2025-06-05 01:12:13 +08:00
import DESCRIPTION from "./patch.txt"
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 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: "patch",
2025-05-27 03:08:31 +08:00
description: DESCRIPTION,
parameters: PatchParams,
2025-06-03 08:24:32 +08:00
execute: async (params, ctx) => {
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
}
2025-06-27 23:29:20 +08:00
await FileTime.assert(ctx.sessionID, absPath)
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
}
} 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
2025-06-27 23:29:20 +08:00
FileTime.read(ctx.sessionID, 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,
2025-06-12 00:58:06 +08:00
title: `${filesToRead.length} files`,
},
2025-05-27 03:08:31 +08:00
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
})