patch.ts 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233
  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. const parentDir = path.dirname(filePath)
  50. if (agent.permission.external_directory === "ask") {
  51. await Permission.ask({
  52. type: "external_directory",
  53. pattern: [parentDir, path.join(parentDir, "*")],
  54. sessionID: ctx.sessionID,
  55. messageID: ctx.messageID,
  56. callID: ctx.callID,
  57. title: `Patch file outside working directory: ${filePath}`,
  58. metadata: {
  59. filepath: filePath,
  60. parentDir,
  61. },
  62. })
  63. } else if (agent.permission.external_directory === "deny") {
  64. throw new Permission.RejectedError(
  65. ctx.sessionID,
  66. "external_directory",
  67. ctx.callID,
  68. {
  69. filepath: filePath,
  70. parentDir,
  71. },
  72. `File ${filePath} is not in the current working directory`,
  73. )
  74. }
  75. }
  76. switch (hunk.type) {
  77. case "add":
  78. if (hunk.type === "add") {
  79. const oldContent = ""
  80. const newContent = hunk.contents
  81. const diff = createTwoFilesPatch(filePath, filePath, oldContent, newContent)
  82. fileChanges.push({
  83. filePath,
  84. oldContent,
  85. newContent,
  86. type: "add",
  87. })
  88. totalDiff += diff + "\n"
  89. }
  90. break
  91. case "update":
  92. // Check if file exists for update
  93. const stats = await fs.stat(filePath).catch(() => null)
  94. if (!stats || stats.isDirectory()) {
  95. throw new Error(`File not found or is directory: ${filePath}`)
  96. }
  97. // Read file and update time tracking (like edit tool does)
  98. await FileTime.assert(ctx.sessionID, filePath)
  99. const oldContent = await fs.readFile(filePath, "utf-8")
  100. let newContent = oldContent
  101. // Apply the update chunks to get new content
  102. try {
  103. const fileUpdate = Patch.deriveNewContentsFromChunks(filePath, hunk.chunks)
  104. newContent = fileUpdate.content
  105. } catch (error) {
  106. throw new Error(`Failed to apply update to ${filePath}: ${error}`)
  107. }
  108. const diff = createTwoFilesPatch(filePath, filePath, oldContent, newContent)
  109. fileChanges.push({
  110. filePath,
  111. oldContent,
  112. newContent,
  113. type: hunk.move_path ? "move" : "update",
  114. movePath: hunk.move_path ? path.resolve(Instance.directory, hunk.move_path) : undefined,
  115. })
  116. totalDiff += diff + "\n"
  117. break
  118. case "delete":
  119. // Check if file exists for deletion
  120. await FileTime.assert(ctx.sessionID, filePath)
  121. const contentToDelete = await fs.readFile(filePath, "utf-8")
  122. const deleteDiff = createTwoFilesPatch(filePath, filePath, contentToDelete, "")
  123. fileChanges.push({
  124. filePath,
  125. oldContent: contentToDelete,
  126. newContent: "",
  127. type: "delete",
  128. })
  129. totalDiff += deleteDiff + "\n"
  130. break
  131. }
  132. }
  133. // Check permissions if needed
  134. if (agent.permission.edit === "ask") {
  135. await Permission.ask({
  136. type: "edit",
  137. sessionID: ctx.sessionID,
  138. messageID: ctx.messageID,
  139. callID: ctx.callID,
  140. title: `Apply patch to ${fileChanges.length} files`,
  141. metadata: {
  142. diff: totalDiff,
  143. },
  144. })
  145. }
  146. // Apply the changes
  147. const changedFiles: string[] = []
  148. for (const change of fileChanges) {
  149. switch (change.type) {
  150. case "add":
  151. // Create parent directories
  152. const addDir = path.dirname(change.filePath)
  153. if (addDir !== "." && addDir !== "/") {
  154. await fs.mkdir(addDir, { recursive: true })
  155. }
  156. await fs.writeFile(change.filePath, change.newContent, "utf-8")
  157. changedFiles.push(change.filePath)
  158. break
  159. case "update":
  160. await fs.writeFile(change.filePath, change.newContent, "utf-8")
  161. changedFiles.push(change.filePath)
  162. break
  163. case "move":
  164. if (change.movePath) {
  165. // Create parent directories for destination
  166. const moveDir = path.dirname(change.movePath)
  167. if (moveDir !== "." && moveDir !== "/") {
  168. await fs.mkdir(moveDir, { recursive: true })
  169. }
  170. // Write to new location
  171. await fs.writeFile(change.movePath, change.newContent, "utf-8")
  172. // Remove original
  173. await fs.unlink(change.filePath)
  174. changedFiles.push(change.movePath)
  175. }
  176. break
  177. case "delete":
  178. await fs.unlink(change.filePath)
  179. changedFiles.push(change.filePath)
  180. break
  181. }
  182. // Update file time tracking
  183. FileTime.read(ctx.sessionID, change.filePath)
  184. if (change.movePath) {
  185. FileTime.read(ctx.sessionID, change.movePath)
  186. }
  187. }
  188. // Publish file change events
  189. for (const filePath of changedFiles) {
  190. await Bus.publish(FileWatcher.Event.Updated, { file: filePath, event: "change" })
  191. }
  192. // Generate output summary
  193. const relativePaths = changedFiles.map((filePath) => path.relative(Instance.worktree, filePath))
  194. const summary = `${fileChanges.length} files changed`
  195. return {
  196. title: summary,
  197. metadata: {
  198. diff: totalDiff,
  199. },
  200. output: `Patch applied successfully. ${summary}:\n${relativePaths.map((p) => ` ${p}`).join("\n")}`,
  201. }
  202. },
  203. })