| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342 |
- 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 DESCRIPTION from "./patch.txt"
- const PatchParams = z.object({
- patchText: z.string().describe("The full patch text that describes all changes to be made"),
- })
- 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 PatchTool = Tool.define("patch", {
- description: DESCRIPTION,
- parameters: PatchParams,
- execute: async (params, ctx) => {
- // 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)
- }
- await FileTime.assert(ctx.sessionID, absPath)
- try {
- const stats = await fs.stat(absPath)
- if (stats.isDirectory()) {
- throw new Error(`path is a directory, not a file: ${absPath}`)
- }
- } 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
- FileTime.read(ctx.sessionID, absPath)
- }
- const result = `Patch applied successfully. ${changedFiles.length} files changed, ${totalAdditions} additions, ${totalRemovals} removals`
- const output = result
- return {
- title: `${filesToRead.length} files`,
- metadata: {
- changed: changedFiles,
- additions: totalAdditions,
- removals: totalRemovals,
- },
- output,
- }
- },
- })
|