281 lines · 9.3 KB · TypeScript
RawDownload
import z from "zod"import * as path from "path"import * as fs from "fs/promises"import { Tool } from "./tool"import { Bus } from "../bus"import { FileWatcher } from "../file/watcher"import { Instance } from "../project/instance"import { Patch } from "../patch"import { createTwoFilesPatch, diffLines } from "diff"import { assertExternalDirectory } from "./external-directory"import { trimDiff } from "./edit"import { LSP } from "../lsp"import { Filesystem } from "../util/filesystem"import DESCRIPTION from "./apply_patch.txt"import { File } from "../file" const PatchParams = z.object({  patchText: z.string().describe("The full patch text that describes all changes to be made"),}) export const ApplyPatchTool = Tool.define("apply_patch", {  description: DESCRIPTION,  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(`apply_patch verification failed: ${error}`)    }     if (hunks.length === 0) {      const normalized = params.patchText.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim()      if (normalized === "*** Begin Patch\n*** End Patch") {        throw new Error("patch rejected: empty patch")      }      throw new Error("apply_patch verification failed: no hunks found")    }     // Validate file paths and check permissions    const fileChanges: Array<{      filePath: string      oldContent: string      newContent: string      type: "add" | "update" | "delete" | "move"      movePath?: string      diff: string      additions: number      deletions: number    }> = []     let totalDiff = ""     for (const hunk of hunks) {      const filePath = path.resolve(Instance.directory, hunk.path)      await assertExternalDirectory(ctx, filePath)       switch (hunk.type) {        case "add": {          const oldContent = ""          const newContent =            hunk.contents.length === 0 || hunk.contents.endsWith("\n") ? hunk.contents : `${hunk.contents}\n`          const diff = trimDiff(createTwoFilesPatch(filePath, filePath, oldContent, newContent))           let additions = 0          let deletions = 0          for (const change of diffLines(oldContent, newContent)) {            if (change.added) additions += change.count || 0            if (change.removed) deletions += change.count || 0          }           fileChanges.push({            filePath,            oldContent,            newContent,            type: "add",            diff,            additions,            deletions,          })           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(`apply_patch verification failed: Failed to read file to update: ${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(`apply_patch verification failed: ${error}`)          }           const diff = trimDiff(createTwoFilesPatch(filePath, filePath, oldContent, newContent))           let additions = 0          let deletions = 0          for (const change of diffLines(oldContent, newContent)) {            if (change.added) additions += change.count || 0            if (change.removed) deletions += change.count || 0          }           const movePath = hunk.move_path ? path.resolve(Instance.directory, hunk.move_path) : undefined          await assertExternalDirectory(ctx, movePath)           fileChanges.push({            filePath,            oldContent,            newContent,            type: hunk.move_path ? "move" : "update",            movePath,            diff,            additions,            deletions,          })           totalDiff += diff + "\n"          break        }         case "delete": {          const contentToDelete = await fs.readFile(filePath, "utf-8").catch((error) => {            throw new Error(`apply_patch verification failed: ${error}`)          })          const deleteDiff = trimDiff(createTwoFilesPatch(filePath, filePath, contentToDelete, ""))           const deletions = contentToDelete.split("\n").length           fileChanges.push({            filePath,            oldContent: contentToDelete,            newContent: "",            type: "delete",            diff: deleteDiff,            additions: 0,            deletions,          })           totalDiff += deleteDiff + "\n"          break        }      }    }     // Build per-file metadata for UI rendering (used for both permission and result)    const files = fileChanges.map((change) => ({      filePath: change.filePath,      relativePath: path.relative(Instance.worktree, change.movePath ?? change.filePath),      type: change.type,      diff: change.diff,      before: change.oldContent,      after: change.newContent,      additions: change.additions,      deletions: change.deletions,      movePath: change.movePath,    }))     // Check permissions if needed    const relativePaths = fileChanges.map((c) => path.relative(Instance.worktree, c.filePath))    await ctx.ask({      permission: "edit",      patterns: relativePaths,      always: ["*"],      metadata: {        filepath: relativePaths.join(", "),        diff: totalDiff,        files,      },    })     // Apply the changes    const updates: Array<{ file: string; event: "add" | "change" | "unlink" }> = []     for (const change of fileChanges) {      const edited = change.type === "delete" ? undefined : (change.movePath ?? change.filePath)      switch (change.type) {        case "add":          // Create parent directories (recursive: true is safe on existing/root dirs)          await fs.mkdir(path.dirname(change.filePath), { recursive: true })          await fs.writeFile(change.filePath, change.newContent, "utf-8")          updates.push({ file: change.filePath, event: "add" })          break         case "update":          await fs.writeFile(change.filePath, change.newContent, "utf-8")          updates.push({ file: change.filePath, event: "change" })          break         case "move":          if (change.movePath) {            // Create parent directories (recursive: true is safe on existing/root dirs)            await fs.mkdir(path.dirname(change.movePath), { recursive: true })            await fs.writeFile(change.movePath, change.newContent, "utf-8")            await fs.unlink(change.filePath)            updates.push({ file: change.filePath, event: "unlink" })            updates.push({ file: change.movePath, event: "add" })          }          break         case "delete":          await fs.unlink(change.filePath)          updates.push({ file: change.filePath, event: "unlink" })          break      }       if (edited) {        await Bus.publish(File.Event.Edited, {          file: edited,        })      }    }     // Publish file change events    for (const update of updates) {      await Bus.publish(FileWatcher.Event.Updated, update)    }     // Notify LSP of file changes and collect diagnostics    for (const change of fileChanges) {      if (change.type === "delete") continue      const target = change.movePath ?? change.filePath      await LSP.touchFile(target, true)    }    const diagnostics = await LSP.diagnostics()     // Generate output summary    const summaryLines = fileChanges.map((change) => {      if (change.type === "add") {        return `A ${path.relative(Instance.worktree, change.filePath)}`      }      if (change.type === "delete") {        return `D ${path.relative(Instance.worktree, change.filePath)}`      }      const target = change.movePath ?? change.filePath      return `M ${path.relative(Instance.worktree, target)}`    })    let output = `Success. Updated the following files:\n${summaryLines.join("\n")}`     // Report LSP errors for changed files    const MAX_DIAGNOSTICS_PER_FILE = 20    for (const change of fileChanges) {      if (change.type === "delete") continue      const target = change.movePath ?? change.filePath      const normalized = Filesystem.normalizePath(target)      const issues = diagnostics[normalized] ?? []      const errors = issues.filter((item) => item.severity === 1)      if (errors.length > 0) {        const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE)        const suffix =          errors.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more` : ""        output += `\n\nLSP errors detected in ${path.relative(Instance.worktree, target)}, please fix:\n<diagnostics file="${target}">\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</diagnostics>`      }    }     return {      title: output,      metadata: {        diff: totalDiff,        files,        diagnostics,      },      output,    }  },})