apply_patch.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294
  1. import z from "zod"
  2. import * as path from "path"
  3. import { Effect } from "effect"
  4. import * as Tool from "./tool"
  5. import { Bus } from "../bus"
  6. import { FileWatcher } from "../file/watcher"
  7. import { Instance } from "../project/instance"
  8. import { Patch } from "../patch"
  9. import { createTwoFilesPatch, diffLines } from "diff"
  10. import { assertExternalDirectoryEffect } from "./external-directory"
  11. import { trimDiff } from "./edit"
  12. import { LSP } from "../lsp"
  13. import { AppFileSystem } from "@opencode-ai/shared/filesystem"
  14. import DESCRIPTION from "./apply_patch.txt"
  15. import { File } from "../file"
  16. import { Format } from "../format"
  17. const PatchParams = z.object({
  18. patchText: z.string().describe("The full patch text that describes all changes to be made"),
  19. })
  20. export const ApplyPatchTool = Tool.define(
  21. "apply_patch",
  22. Effect.gen(function* () {
  23. const lsp = yield* LSP.Service
  24. const afs = yield* AppFileSystem.Service
  25. const format = yield* Format.Service
  26. const bus = yield* Bus.Service
  27. const run = Effect.fn("ApplyPatchTool.execute")(function* (params: z.infer<typeof PatchParams>, ctx: Tool.Context) {
  28. if (!params.patchText) {
  29. return yield* Effect.fail(new Error("patchText is required"))
  30. }
  31. // Parse the patch to get hunks
  32. let hunks: Patch.Hunk[]
  33. try {
  34. const parseResult = Patch.parsePatch(params.patchText)
  35. hunks = parseResult.hunks
  36. } catch (error) {
  37. return yield* Effect.fail(new Error(`apply_patch verification failed: ${error}`))
  38. }
  39. if (hunks.length === 0) {
  40. const normalized = params.patchText.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim()
  41. if (normalized === "*** Begin Patch\n*** End Patch") {
  42. return yield* Effect.fail(new Error("patch rejected: empty patch"))
  43. }
  44. return yield* Effect.fail(new Error("apply_patch verification failed: no hunks found"))
  45. }
  46. // Validate file paths and check permissions
  47. const fileChanges: Array<{
  48. filePath: string
  49. oldContent: string
  50. newContent: string
  51. type: "add" | "update" | "delete" | "move"
  52. movePath?: string
  53. diff: string
  54. additions: number
  55. deletions: number
  56. }> = []
  57. let totalDiff = ""
  58. for (const hunk of hunks) {
  59. const filePath = path.resolve(Instance.directory, hunk.path)
  60. yield* assertExternalDirectoryEffect(ctx, filePath)
  61. switch (hunk.type) {
  62. case "add": {
  63. const oldContent = ""
  64. const newContent =
  65. hunk.contents.length === 0 || hunk.contents.endsWith("\n") ? hunk.contents : `${hunk.contents}\n`
  66. const diff = trimDiff(createTwoFilesPatch(filePath, filePath, oldContent, newContent))
  67. let additions = 0
  68. let deletions = 0
  69. for (const change of diffLines(oldContent, newContent)) {
  70. if (change.added) additions += change.count || 0
  71. if (change.removed) deletions += change.count || 0
  72. }
  73. fileChanges.push({
  74. filePath,
  75. oldContent,
  76. newContent,
  77. type: "add",
  78. diff,
  79. additions,
  80. deletions,
  81. })
  82. totalDiff += diff + "\n"
  83. break
  84. }
  85. case "update": {
  86. // Check if file exists for update
  87. const stats = yield* afs.stat(filePath).pipe(Effect.catch(() => Effect.succeed(undefined)))
  88. if (!stats || stats.type === "Directory") {
  89. return yield* Effect.fail(
  90. new Error(`apply_patch verification failed: Failed to read file to update: ${filePath}`),
  91. )
  92. }
  93. const oldContent = yield* afs.readFileString(filePath)
  94. let newContent = oldContent
  95. // Apply the update chunks to get new content
  96. try {
  97. const fileUpdate = Patch.deriveNewContentsFromChunks(filePath, hunk.chunks)
  98. newContent = fileUpdate.content
  99. } catch (error) {
  100. return yield* Effect.fail(new Error(`apply_patch verification failed: ${error}`))
  101. }
  102. const diff = trimDiff(createTwoFilesPatch(filePath, filePath, oldContent, newContent))
  103. let additions = 0
  104. let deletions = 0
  105. for (const change of diffLines(oldContent, newContent)) {
  106. if (change.added) additions += change.count || 0
  107. if (change.removed) deletions += change.count || 0
  108. }
  109. const movePath = hunk.move_path ? path.resolve(Instance.directory, hunk.move_path) : undefined
  110. yield* assertExternalDirectoryEffect(ctx, movePath)
  111. fileChanges.push({
  112. filePath,
  113. oldContent,
  114. newContent,
  115. type: hunk.move_path ? "move" : "update",
  116. movePath,
  117. diff,
  118. additions,
  119. deletions,
  120. })
  121. totalDiff += diff + "\n"
  122. break
  123. }
  124. case "delete": {
  125. const contentToDelete = yield* afs
  126. .readFileString(filePath)
  127. .pipe(
  128. Effect.catch((error) =>
  129. Effect.fail(
  130. new Error(
  131. `apply_patch verification failed: ${error instanceof Error ? error.message : String(error)}`,
  132. ),
  133. ),
  134. ),
  135. )
  136. const deleteDiff = trimDiff(createTwoFilesPatch(filePath, filePath, contentToDelete, ""))
  137. const deletions = contentToDelete.split("\n").length
  138. fileChanges.push({
  139. filePath,
  140. oldContent: contentToDelete,
  141. newContent: "",
  142. type: "delete",
  143. diff: deleteDiff,
  144. additions: 0,
  145. deletions,
  146. })
  147. totalDiff += deleteDiff + "\n"
  148. break
  149. }
  150. }
  151. }
  152. // Build per-file metadata for UI rendering (used for both permission and result)
  153. const files = fileChanges.map((change) => ({
  154. filePath: change.filePath,
  155. relativePath: path.relative(Instance.worktree, change.movePath ?? change.filePath).replaceAll("\\", "/"),
  156. type: change.type,
  157. patch: change.diff,
  158. additions: change.additions,
  159. deletions: change.deletions,
  160. movePath: change.movePath,
  161. }))
  162. // Check permissions if needed
  163. const relativePaths = fileChanges.map((c) => path.relative(Instance.worktree, c.filePath).replaceAll("\\", "/"))
  164. yield* ctx.ask({
  165. permission: "edit",
  166. patterns: relativePaths,
  167. always: ["*"],
  168. metadata: {
  169. filepath: relativePaths.join(", "),
  170. diff: totalDiff,
  171. files,
  172. },
  173. })
  174. // Apply the changes
  175. const updates: Array<{ file: string; event: "add" | "change" | "unlink" }> = []
  176. for (const change of fileChanges) {
  177. const edited = change.type === "delete" ? undefined : (change.movePath ?? change.filePath)
  178. switch (change.type) {
  179. case "add":
  180. // Create parent directories (recursive: true is safe on existing/root dirs)
  181. yield* afs.writeWithDirs(change.filePath, change.newContent)
  182. updates.push({ file: change.filePath, event: "add" })
  183. break
  184. case "update":
  185. yield* afs.writeWithDirs(change.filePath, change.newContent)
  186. updates.push({ file: change.filePath, event: "change" })
  187. break
  188. case "move":
  189. if (change.movePath) {
  190. // Create parent directories (recursive: true is safe on existing/root dirs)
  191. yield* afs.writeWithDirs(change.movePath!, change.newContent)
  192. yield* afs.remove(change.filePath)
  193. updates.push({ file: change.filePath, event: "unlink" })
  194. updates.push({ file: change.movePath, event: "add" })
  195. }
  196. break
  197. case "delete":
  198. yield* afs.remove(change.filePath)
  199. updates.push({ file: change.filePath, event: "unlink" })
  200. break
  201. }
  202. if (edited) {
  203. yield* format.file(edited)
  204. yield* bus.publish(File.Event.Edited, { file: edited })
  205. }
  206. }
  207. // Publish file change events
  208. for (const update of updates) {
  209. yield* bus.publish(FileWatcher.Event.Updated, update)
  210. }
  211. // Notify LSP of file changes and collect diagnostics
  212. for (const change of fileChanges) {
  213. if (change.type === "delete") continue
  214. const target = change.movePath ?? change.filePath
  215. yield* lsp.touchFile(target, true)
  216. }
  217. const diagnostics = yield* lsp.diagnostics()
  218. // Generate output summary
  219. const summaryLines = fileChanges.map((change) => {
  220. if (change.type === "add") {
  221. return `A ${path.relative(Instance.worktree, change.filePath).replaceAll("\\", "/")}`
  222. }
  223. if (change.type === "delete") {
  224. return `D ${path.relative(Instance.worktree, change.filePath).replaceAll("\\", "/")}`
  225. }
  226. const target = change.movePath ?? change.filePath
  227. return `M ${path.relative(Instance.worktree, target).replaceAll("\\", "/")}`
  228. })
  229. let output = `Success. Updated the following files:\n${summaryLines.join("\n")}`
  230. for (const change of fileChanges) {
  231. if (change.type === "delete") continue
  232. const target = change.movePath ?? change.filePath
  233. const block = LSP.Diagnostic.report(target, diagnostics[AppFileSystem.normalizePath(target)] ?? [])
  234. if (!block) continue
  235. const rel = path.relative(Instance.worktree, target).replaceAll("\\", "/")
  236. output += `\n\nLSP errors detected in ${rel}, please fix:\n${block}`
  237. }
  238. return {
  239. title: output,
  240. metadata: {
  241. diff: totalDiff,
  242. files,
  243. diagnostics,
  244. },
  245. output,
  246. }
  247. })
  248. return {
  249. description: DESCRIPTION,
  250. parameters: PatchParams,
  251. execute: (params: z.infer<typeof PatchParams>, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie),
  252. }
  253. }),
  254. )