mirror of
https://gitea.toothfairyai.com/ToothFairyAI/tf_code.git
synced 2026-03-31 14:22:27 +00:00
The patch tool now works seamlessly alongside other file editing tools with improved error handling and a more intuitive permission system. Users will experience: - More reliable patch application with better error messages - Consistent permission prompts that match other editing tools - Smoother integration when applying complex multi-file changes - Better feedback on what changes are being made before applying patches This refactoring leverages the robust patch parsing engine while making the tool feel native to the opencode workflow, reducing friction when making bulk changes to your codebase.
207 lines
6.5 KiB
TypeScript
207 lines
6.5 KiB
TypeScript
import z from "zod/v4"
|
|
import * as path from "path"
|
|
import * as fs from "fs/promises"
|
|
import { Tool } from "./tool"
|
|
import { FileTime } from "../file/time"
|
|
import { Permission } from "../permission"
|
|
import { Bus } from "../bus"
|
|
import { FileWatcher } from "../file/watcher"
|
|
import { Instance } from "../project/instance"
|
|
import { Agent } from "../agent/agent"
|
|
import { Patch } from "../patch"
|
|
import { Filesystem } from "../util/filesystem"
|
|
import { createTwoFilesPatch } from "diff"
|
|
|
|
const PatchParams = z.object({
|
|
patchText: z.string().describe("The full patch text that describes all changes to be made"),
|
|
})
|
|
|
|
export const PatchTool = Tool.define("patch", {
|
|
description: "Apply a patch to modify multiple files. Supports adding, updating, and deleting files with context-aware changes.",
|
|
parameters: PatchParams,
|
|
async execute(params, ctx) {
|
|
if (!params.patchText) {
|
|
throw new Error("patchText is required")
|
|
}
|
|
|
|
// Parse the patch to get hunks
|
|
let hunks: Patch.Hunk[]
|
|
try {
|
|
const parseResult = Patch.parsePatch(params.patchText)
|
|
hunks = parseResult.hunks
|
|
} catch (error) {
|
|
throw new Error(`Failed to parse patch: ${error}`)
|
|
}
|
|
|
|
if (hunks.length === 0) {
|
|
throw new Error("No file changes found in patch")
|
|
}
|
|
|
|
// Validate file paths and check permissions
|
|
const agent = await Agent.get(ctx.agent)
|
|
const fileChanges: Array<{
|
|
filePath: string
|
|
oldContent: string
|
|
newContent: string
|
|
type: "add" | "update" | "delete" | "move"
|
|
movePath?: string
|
|
}> = []
|
|
|
|
let totalDiff = ""
|
|
|
|
for (const hunk of hunks) {
|
|
const filePath = path.resolve(Instance.directory, hunk.path)
|
|
|
|
if (!Filesystem.contains(Instance.directory, filePath)) {
|
|
throw new Error(`File ${filePath} is not in the current working directory`)
|
|
}
|
|
|
|
switch (hunk.type) {
|
|
case "add":
|
|
if (hunk.type === "add") {
|
|
const oldContent = ""
|
|
const newContent = hunk.contents
|
|
const diff = createTwoFilesPatch(filePath, filePath, oldContent, newContent)
|
|
|
|
fileChanges.push({
|
|
filePath,
|
|
oldContent,
|
|
newContent,
|
|
type: "add",
|
|
})
|
|
|
|
totalDiff += diff + "\n"
|
|
}
|
|
break
|
|
|
|
case "update":
|
|
// Check if file exists for update
|
|
const stats = await fs.stat(filePath).catch(() => null)
|
|
if (!stats || stats.isDirectory()) {
|
|
throw new Error(`File not found or is directory: ${filePath}`)
|
|
}
|
|
|
|
// Read file and update time tracking (like edit tool does)
|
|
await FileTime.assert(ctx.sessionID, filePath)
|
|
const oldContent = await fs.readFile(filePath, "utf-8")
|
|
let newContent = oldContent
|
|
|
|
// Apply the update chunks to get new content
|
|
try {
|
|
const fileUpdate = Patch.deriveNewContentsFromChunks(filePath, hunk.chunks)
|
|
newContent = fileUpdate.content
|
|
} catch (error) {
|
|
throw new Error(`Failed to apply update to ${filePath}: ${error}`)
|
|
}
|
|
|
|
const diff = createTwoFilesPatch(filePath, filePath, oldContent, newContent)
|
|
|
|
fileChanges.push({
|
|
filePath,
|
|
oldContent,
|
|
newContent,
|
|
type: hunk.move_path ? "move" : "update",
|
|
movePath: hunk.move_path ? path.resolve(Instance.directory, hunk.move_path) : undefined,
|
|
})
|
|
|
|
totalDiff += diff + "\n"
|
|
break
|
|
|
|
case "delete":
|
|
// Check if file exists for deletion
|
|
await FileTime.assert(ctx.sessionID, filePath)
|
|
const contentToDelete = await fs.readFile(filePath, "utf-8")
|
|
const deleteDiff = createTwoFilesPatch(filePath, filePath, contentToDelete, "")
|
|
|
|
fileChanges.push({
|
|
filePath,
|
|
oldContent: contentToDelete,
|
|
newContent: "",
|
|
type: "delete",
|
|
})
|
|
|
|
totalDiff += deleteDiff + "\n"
|
|
break
|
|
}
|
|
}
|
|
|
|
// Check permissions if needed
|
|
if (agent.permission.edit === "ask") {
|
|
await Permission.ask({
|
|
type: "edit",
|
|
sessionID: ctx.sessionID,
|
|
messageID: ctx.messageID,
|
|
callID: ctx.callID,
|
|
title: `Apply patch to ${fileChanges.length} files`,
|
|
metadata: {
|
|
diff: totalDiff,
|
|
},
|
|
})
|
|
}
|
|
|
|
// Apply the changes
|
|
const changedFiles: string[] = []
|
|
|
|
for (const change of fileChanges) {
|
|
switch (change.type) {
|
|
case "add":
|
|
// Create parent directories
|
|
const addDir = path.dirname(change.filePath)
|
|
if (addDir !== "." && addDir !== "/") {
|
|
await fs.mkdir(addDir, { recursive: true })
|
|
}
|
|
await fs.writeFile(change.filePath, change.newContent, "utf-8")
|
|
changedFiles.push(change.filePath)
|
|
break
|
|
|
|
case "update":
|
|
await fs.writeFile(change.filePath, change.newContent, "utf-8")
|
|
changedFiles.push(change.filePath)
|
|
break
|
|
|
|
case "move":
|
|
if (change.movePath) {
|
|
// Create parent directories for destination
|
|
const moveDir = path.dirname(change.movePath)
|
|
if (moveDir !== "." && moveDir !== "/") {
|
|
await fs.mkdir(moveDir, { recursive: true })
|
|
}
|
|
// Write to new location
|
|
await fs.writeFile(change.movePath, change.newContent, "utf-8")
|
|
// Remove original
|
|
await fs.unlink(change.filePath)
|
|
changedFiles.push(change.movePath)
|
|
}
|
|
break
|
|
|
|
case "delete":
|
|
await fs.unlink(change.filePath)
|
|
changedFiles.push(change.filePath)
|
|
break
|
|
}
|
|
|
|
// Update file time tracking
|
|
FileTime.read(ctx.sessionID, change.filePath)
|
|
if (change.movePath) {
|
|
FileTime.read(ctx.sessionID, change.movePath)
|
|
}
|
|
}
|
|
|
|
// Publish file change events
|
|
for (const filePath of changedFiles) {
|
|
await Bus.publish(FileWatcher.Event.Updated, { file: filePath, event: "change" })
|
|
}
|
|
|
|
// Generate output summary
|
|
const relativePaths = changedFiles.map(filePath => path.relative(Instance.worktree, filePath))
|
|
const summary = `${fileChanges.length} files changed`
|
|
|
|
return {
|
|
title: summary,
|
|
metadata: {
|
|
diff: totalDiff,
|
|
},
|
|
output: `Patch applied successfully. ${summary}:\n${relativePaths.map(p => ` ${p}`).join("\n")}`,
|
|
}
|
|
},
|
|
}) |