patch.ts 6.2 KB

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