patch.ts 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342
  1. import z from "zod/v4"
  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 DESCRIPTION from "./patch.txt"
  7. const PatchParams = z.object({
  8. patchText: z.string().describe("The full patch text that describes all changes to be made"),
  9. })
  10. interface Change {
  11. type: "add" | "update" | "delete"
  12. old_content?: string
  13. new_content?: string
  14. }
  15. interface Commit {
  16. changes: Record<string, Change>
  17. }
  18. interface PatchOperation {
  19. type: "update" | "add" | "delete"
  20. filePath: string
  21. hunks?: PatchHunk[]
  22. content?: string
  23. }
  24. interface PatchHunk {
  25. contextLine: string
  26. changes: PatchChange[]
  27. }
  28. interface PatchChange {
  29. type: "keep" | "remove" | "add"
  30. content: string
  31. }
  32. function identifyFilesNeeded(patchText: string): string[] {
  33. const files: string[] = []
  34. const lines = patchText.split("\n")
  35. for (const line of lines) {
  36. if (line.startsWith("*** Update File:") || line.startsWith("*** Delete File:")) {
  37. const filePath = line.split(":", 2)[1]?.trim()
  38. if (filePath) files.push(filePath)
  39. }
  40. }
  41. return files
  42. }
  43. function identifyFilesAdded(patchText: string): string[] {
  44. const files: string[] = []
  45. const lines = patchText.split("\n")
  46. for (const line of lines) {
  47. if (line.startsWith("*** Add File:")) {
  48. const filePath = line.split(":", 2)[1]?.trim()
  49. if (filePath) files.push(filePath)
  50. }
  51. }
  52. return files
  53. }
  54. function textToPatch(patchText: string, _currentFiles: Record<string, string>): [PatchOperation[], number] {
  55. const operations: PatchOperation[] = []
  56. const lines = patchText.split("\n")
  57. let i = 0
  58. let fuzz = 0
  59. while (i < lines.length) {
  60. const line = lines[i]
  61. if (line.startsWith("*** Update File:")) {
  62. const filePath = line.split(":", 2)[1]?.trim()
  63. if (!filePath) {
  64. i++
  65. continue
  66. }
  67. const hunks: PatchHunk[] = []
  68. i++
  69. while (i < lines.length && !lines[i].startsWith("***")) {
  70. if (lines[i].startsWith("@@")) {
  71. const contextLine = lines[i].substring(2).trim()
  72. const changes: PatchChange[] = []
  73. i++
  74. while (i < lines.length && !lines[i].startsWith("@@") && !lines[i].startsWith("***")) {
  75. const changeLine = lines[i]
  76. if (changeLine.startsWith(" ")) {
  77. changes.push({ type: "keep", content: changeLine.substring(1) })
  78. } else if (changeLine.startsWith("-")) {
  79. changes.push({
  80. type: "remove",
  81. content: changeLine.substring(1),
  82. })
  83. } else if (changeLine.startsWith("+")) {
  84. changes.push({ type: "add", content: changeLine.substring(1) })
  85. }
  86. i++
  87. }
  88. hunks.push({ contextLine, changes })
  89. } else {
  90. i++
  91. }
  92. }
  93. operations.push({ type: "update", filePath, hunks })
  94. } else if (line.startsWith("*** Add File:")) {
  95. const filePath = line.split(":", 2)[1]?.trim()
  96. if (!filePath) {
  97. i++
  98. continue
  99. }
  100. let content = ""
  101. i++
  102. while (i < lines.length && !lines[i].startsWith("***")) {
  103. if (lines[i].startsWith("+")) {
  104. content += lines[i].substring(1) + "\n"
  105. }
  106. i++
  107. }
  108. operations.push({ type: "add", filePath, content: content.slice(0, -1) })
  109. } else if (line.startsWith("*** Delete File:")) {
  110. const filePath = line.split(":", 2)[1]?.trim()
  111. if (filePath) {
  112. operations.push({ type: "delete", filePath })
  113. }
  114. i++
  115. } else {
  116. i++
  117. }
  118. }
  119. return [operations, fuzz]
  120. }
  121. function patchToCommit(operations: PatchOperation[], currentFiles: Record<string, string>): Commit {
  122. const changes: Record<string, Change> = {}
  123. for (const op of operations) {
  124. if (op.type === "delete") {
  125. changes[op.filePath] = {
  126. type: "delete",
  127. old_content: currentFiles[op.filePath] || "",
  128. }
  129. } else if (op.type === "add") {
  130. changes[op.filePath] = {
  131. type: "add",
  132. new_content: op.content || "",
  133. }
  134. } else if (op.type === "update" && op.hunks) {
  135. const originalContent = currentFiles[op.filePath] || ""
  136. const lines = originalContent.split("\n")
  137. for (const hunk of op.hunks) {
  138. const contextIndex = lines.findIndex((line) => line.includes(hunk.contextLine))
  139. if (contextIndex === -1) {
  140. throw new Error(`Context line not found: ${hunk.contextLine}`)
  141. }
  142. let currentIndex = contextIndex
  143. for (const change of hunk.changes) {
  144. if (change.type === "keep") {
  145. currentIndex++
  146. } else if (change.type === "remove") {
  147. lines.splice(currentIndex, 1)
  148. } else if (change.type === "add") {
  149. lines.splice(currentIndex, 0, change.content)
  150. currentIndex++
  151. }
  152. }
  153. }
  154. changes[op.filePath] = {
  155. type: "update",
  156. old_content: originalContent,
  157. new_content: lines.join("\n"),
  158. }
  159. }
  160. }
  161. return { changes }
  162. }
  163. function generateDiff(oldContent: string, newContent: string, filePath: string): [string, number, number] {
  164. // Mock implementation - would need actual diff generation
  165. const lines1 = oldContent.split("\n")
  166. const lines2 = newContent.split("\n")
  167. const additions = Math.max(0, lines2.length - lines1.length)
  168. const removals = Math.max(0, lines1.length - lines2.length)
  169. return [`--- ${filePath}\n+++ ${filePath}\n`, additions, removals]
  170. }
  171. async function applyCommit(
  172. commit: Commit,
  173. writeFile: (path: string, content: string) => Promise<void>,
  174. deleteFile: (path: string) => Promise<void>,
  175. ): Promise<void> {
  176. for (const [filePath, change] of Object.entries(commit.changes)) {
  177. if (change.type === "delete") {
  178. await deleteFile(filePath)
  179. } else if (change.new_content !== undefined) {
  180. await writeFile(filePath, change.new_content)
  181. }
  182. }
  183. }
  184. export const PatchTool = Tool.define("patch", {
  185. description: DESCRIPTION,
  186. parameters: PatchParams,
  187. execute: async (params, ctx) => {
  188. // Identify all files needed for the patch and verify they've been read
  189. const filesToRead = identifyFilesNeeded(params.patchText)
  190. for (const filePath of filesToRead) {
  191. let absPath = filePath
  192. if (!path.isAbsolute(absPath)) {
  193. absPath = path.resolve(process.cwd(), absPath)
  194. }
  195. await FileTime.assert(ctx.sessionID, absPath)
  196. try {
  197. const stats = await fs.stat(absPath)
  198. if (stats.isDirectory()) {
  199. throw new Error(`path is a directory, not a file: ${absPath}`)
  200. }
  201. } catch (error: any) {
  202. if (error.code === "ENOENT") {
  203. throw new Error(`file not found: ${absPath}`)
  204. }
  205. throw new Error(`failed to access file: ${error.message}`)
  206. }
  207. }
  208. // Check for new files to ensure they don't already exist
  209. const filesToAdd = identifyFilesAdded(params.patchText)
  210. for (const filePath of filesToAdd) {
  211. let absPath = filePath
  212. if (!path.isAbsolute(absPath)) {
  213. absPath = path.resolve(process.cwd(), absPath)
  214. }
  215. try {
  216. await fs.stat(absPath)
  217. throw new Error(`file already exists and cannot be added: ${absPath}`)
  218. } catch (error: any) {
  219. if (error.code !== "ENOENT") {
  220. throw new Error(`failed to check file: ${error.message}`)
  221. }
  222. }
  223. }
  224. // Load all required files
  225. const currentFiles: Record<string, string> = {}
  226. for (const filePath of filesToRead) {
  227. let absPath = filePath
  228. if (!path.isAbsolute(absPath)) {
  229. absPath = path.resolve(process.cwd(), absPath)
  230. }
  231. try {
  232. const content = await fs.readFile(absPath, "utf-8")
  233. currentFiles[filePath] = content
  234. } catch (error: any) {
  235. throw new Error(`failed to read file ${absPath}: ${error.message}`)
  236. }
  237. }
  238. // Process the patch
  239. const [patch, fuzz] = textToPatch(params.patchText, currentFiles)
  240. if (fuzz > 3) {
  241. throw new Error(`patch contains fuzzy matches (fuzz level: ${fuzz}). Please make your context lines more precise`)
  242. }
  243. // Convert patch to commit
  244. const commit = patchToCommit(patch, currentFiles)
  245. // Apply the changes to the filesystem
  246. await applyCommit(
  247. commit,
  248. async (filePath: string, content: string) => {
  249. let absPath = filePath
  250. if (!path.isAbsolute(absPath)) {
  251. absPath = path.resolve(process.cwd(), absPath)
  252. }
  253. // Create parent directories if needed
  254. const dir = path.dirname(absPath)
  255. await fs.mkdir(dir, { recursive: true })
  256. await fs.writeFile(absPath, content, "utf-8")
  257. },
  258. async (filePath: string) => {
  259. let absPath = filePath
  260. if (!path.isAbsolute(absPath)) {
  261. absPath = path.resolve(process.cwd(), absPath)
  262. }
  263. await fs.unlink(absPath)
  264. },
  265. )
  266. // Calculate statistics
  267. const changedFiles: string[] = []
  268. let totalAdditions = 0
  269. let totalRemovals = 0
  270. for (const [filePath, change] of Object.entries(commit.changes)) {
  271. let absPath = filePath
  272. if (!path.isAbsolute(absPath)) {
  273. absPath = path.resolve(process.cwd(), absPath)
  274. }
  275. changedFiles.push(absPath)
  276. const oldContent = change.old_content || ""
  277. const newContent = change.new_content || ""
  278. // Calculate diff statistics
  279. const [, additions, removals] = generateDiff(oldContent, newContent, filePath)
  280. totalAdditions += additions
  281. totalRemovals += removals
  282. FileTime.read(ctx.sessionID, absPath)
  283. }
  284. const result = `Patch applied successfully. ${changedFiles.length} files changed, ${totalAdditions} additions, ${totalRemovals} removals`
  285. const output = result
  286. return {
  287. title: `${filesToRead.length} files`,
  288. metadata: {
  289. changed: changedFiles,
  290. additions: totalAdditions,
  291. removals: totalRemovals,
  292. },
  293. output,
  294. }
  295. },
  296. })