patch.ts 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208
  1. import z from "zod"
  2. import * as path from "path"
  3. import * as fs from "fs/promises"
  4. import { Tool } from "./tool"
  5. import { FileTime } from "../file/time"
  6. import { Permission } from "../permission"
  7. import { Bus } from "../bus"
  8. import { FileWatcher } from "../file/watcher"
  9. import { Instance } from "../project/instance"
  10. import { Agent } from "../agent/agent"
  11. import { Patch } from "../patch"
  12. import { Filesystem } from "../util/filesystem"
  13. import { createTwoFilesPatch } from "diff"
  14. const PatchParams = z.object({
  15. patchText: z.string().describe("The full patch text that describes all changes to be made"),
  16. })
  17. export const PatchTool = Tool.define("patch", {
  18. description:
  19. "Apply a patch to modify multiple files. Supports adding, updating, and deleting files with context-aware changes.",
  20. parameters: PatchParams,
  21. async execute(params, ctx) {
  22. if (!params.patchText) {
  23. throw new Error("patchText is required")
  24. }
  25. // Parse the patch to get hunks
  26. let hunks: Patch.Hunk[]
  27. try {
  28. const parseResult = Patch.parsePatch(params.patchText)
  29. hunks = parseResult.hunks
  30. } catch (error) {
  31. throw new Error(`Failed to parse patch: ${error}`)
  32. }
  33. if (hunks.length === 0) {
  34. throw new Error("No file changes found in patch")
  35. }
  36. // Validate file paths and check permissions
  37. const agent = await Agent.get(ctx.agent)
  38. const fileChanges: Array<{
  39. filePath: string
  40. oldContent: string
  41. newContent: string
  42. type: "add" | "update" | "delete" | "move"
  43. movePath?: string
  44. }> = []
  45. let totalDiff = ""
  46. for (const hunk of hunks) {
  47. const filePath = path.resolve(Instance.directory, hunk.path)
  48. if (!Filesystem.contains(Instance.directory, filePath)) {
  49. throw new Error(`File ${filePath} is not in the current working directory`)
  50. }
  51. switch (hunk.type) {
  52. case "add":
  53. if (hunk.type === "add") {
  54. const oldContent = ""
  55. const newContent = hunk.contents
  56. const diff = createTwoFilesPatch(filePath, filePath, oldContent, newContent)
  57. fileChanges.push({
  58. filePath,
  59. oldContent,
  60. newContent,
  61. type: "add",
  62. })
  63. totalDiff += diff + "\n"
  64. }
  65. break
  66. case "update":
  67. // Check if file exists for update
  68. const stats = await fs.stat(filePath).catch(() => null)
  69. if (!stats || stats.isDirectory()) {
  70. throw new Error(`File not found or is directory: ${filePath}`)
  71. }
  72. // Read file and update time tracking (like edit tool does)
  73. await FileTime.assert(ctx.sessionID, filePath)
  74. const oldContent = await fs.readFile(filePath, "utf-8")
  75. let newContent = oldContent
  76. // Apply the update chunks to get new content
  77. try {
  78. const fileUpdate = Patch.deriveNewContentsFromChunks(filePath, hunk.chunks)
  79. newContent = fileUpdate.content
  80. } catch (error) {
  81. throw new Error(`Failed to apply update to ${filePath}: ${error}`)
  82. }
  83. const diff = createTwoFilesPatch(filePath, filePath, oldContent, newContent)
  84. fileChanges.push({
  85. filePath,
  86. oldContent,
  87. newContent,
  88. type: hunk.move_path ? "move" : "update",
  89. movePath: hunk.move_path ? path.resolve(Instance.directory, hunk.move_path) : undefined,
  90. })
  91. totalDiff += diff + "\n"
  92. break
  93. case "delete":
  94. // Check if file exists for deletion
  95. await FileTime.assert(ctx.sessionID, filePath)
  96. const contentToDelete = await fs.readFile(filePath, "utf-8")
  97. const deleteDiff = createTwoFilesPatch(filePath, filePath, contentToDelete, "")
  98. fileChanges.push({
  99. filePath,
  100. oldContent: contentToDelete,
  101. newContent: "",
  102. type: "delete",
  103. })
  104. totalDiff += deleteDiff + "\n"
  105. break
  106. }
  107. }
  108. // Check permissions if needed
  109. if (agent.permission.edit === "ask") {
  110. await Permission.ask({
  111. type: "edit",
  112. sessionID: ctx.sessionID,
  113. messageID: ctx.messageID,
  114. callID: ctx.callID,
  115. title: `Apply patch to ${fileChanges.length} files`,
  116. metadata: {
  117. diff: totalDiff,
  118. },
  119. })
  120. }
  121. // Apply the changes
  122. const changedFiles: string[] = []
  123. for (const change of fileChanges) {
  124. switch (change.type) {
  125. case "add":
  126. // Create parent directories
  127. const addDir = path.dirname(change.filePath)
  128. if (addDir !== "." && addDir !== "/") {
  129. await fs.mkdir(addDir, { recursive: true })
  130. }
  131. await fs.writeFile(change.filePath, change.newContent, "utf-8")
  132. changedFiles.push(change.filePath)
  133. break
  134. case "update":
  135. await fs.writeFile(change.filePath, change.newContent, "utf-8")
  136. changedFiles.push(change.filePath)
  137. break
  138. case "move":
  139. if (change.movePath) {
  140. // Create parent directories for destination
  141. const moveDir = path.dirname(change.movePath)
  142. if (moveDir !== "." && moveDir !== "/") {
  143. await fs.mkdir(moveDir, { recursive: true })
  144. }
  145. // Write to new location
  146. await fs.writeFile(change.movePath, change.newContent, "utf-8")
  147. // Remove original
  148. await fs.unlink(change.filePath)
  149. changedFiles.push(change.movePath)
  150. }
  151. break
  152. case "delete":
  153. await fs.unlink(change.filePath)
  154. changedFiles.push(change.filePath)
  155. break
  156. }
  157. // Update file time tracking
  158. FileTime.read(ctx.sessionID, change.filePath)
  159. if (change.movePath) {
  160. FileTime.read(ctx.sessionID, change.movePath)
  161. }
  162. }
  163. // Publish file change events
  164. for (const filePath of changedFiles) {
  165. await Bus.publish(FileWatcher.Event.Updated, { file: filePath, event: "change" })
  166. }
  167. // Generate output summary
  168. const relativePaths = changedFiles.map((filePath) => path.relative(Instance.worktree, filePath))
  169. const summary = `${fileChanges.length} files changed`
  170. return {
  171. title: summary,
  172. metadata: {
  173. diff: totalDiff,
  174. },
  175. output: `Patch applied successfully. ${summary}:\n${relativePaths.map((p) => ` ${p}`).join("\n")}`,
  176. }
  177. },
  178. })