patch.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418
  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 { FileTimes } from "./util/file-times"
  6. const DESCRIPTION = `Applies a patch to multiple files in one operation. This tool is useful for making coordinated changes across multiple files.
  7. The patch text must follow this format:
  8. *** Begin Patch
  9. *** Update File: /path/to/file
  10. @@ Context line (unique within the file)
  11. Line to keep
  12. -Line to remove
  13. +Line to add
  14. Line to keep
  15. *** Add File: /path/to/new/file
  16. +Content of the new file
  17. +More content
  18. *** Delete File: /path/to/file/to/delete
  19. *** End Patch
  20. Before using this tool:
  21. 1. Use the FileRead tool to understand the files' contents and context
  22. 2. Verify all file paths are correct (use the LS tool)
  23. CRITICAL REQUIREMENTS FOR USING THIS TOOL:
  24. 1. UNIQUENESS: Context lines MUST uniquely identify the specific sections you want to change
  25. 2. PRECISION: All whitespace, indentation, and surrounding code must match exactly
  26. 3. VALIDATION: Ensure edits result in idiomatic, correct code
  27. 4. PATHS: Always use absolute file paths (starting with /)
  28. The tool will apply all changes in a single atomic operation.`
  29. const PatchParams = z.object({
  30. patchText: z
  31. .string()
  32. .describe("The full patch text that describes all changes to be made"),
  33. })
  34. interface PatchResponseMetadata {
  35. changed: string[]
  36. additions: number
  37. removals: number
  38. }
  39. interface Change {
  40. type: "add" | "update" | "delete"
  41. old_content?: string
  42. new_content?: string
  43. }
  44. interface Commit {
  45. changes: Record<string, Change>
  46. }
  47. interface PatchOperation {
  48. type: "update" | "add" | "delete"
  49. filePath: string
  50. hunks?: PatchHunk[]
  51. content?: string
  52. }
  53. interface PatchHunk {
  54. contextLine: string
  55. changes: PatchChange[]
  56. }
  57. interface PatchChange {
  58. type: "keep" | "remove" | "add"
  59. content: string
  60. }
  61. function identifyFilesNeeded(patchText: string): string[] {
  62. const files: string[] = []
  63. const lines = patchText.split("\n")
  64. for (const line of lines) {
  65. if (
  66. line.startsWith("*** Update File:") ||
  67. line.startsWith("*** Delete File:")
  68. ) {
  69. const filePath = line.split(":", 2)[1]?.trim()
  70. if (filePath) files.push(filePath)
  71. }
  72. }
  73. return files
  74. }
  75. function identifyFilesAdded(patchText: string): string[] {
  76. const files: string[] = []
  77. const lines = patchText.split("\n")
  78. for (const line of lines) {
  79. if (line.startsWith("*** Add File:")) {
  80. const filePath = line.split(":", 2)[1]?.trim()
  81. if (filePath) files.push(filePath)
  82. }
  83. }
  84. return files
  85. }
  86. function textToPatch(
  87. patchText: string,
  88. _currentFiles: Record<string, string>,
  89. ): [PatchOperation[], number] {
  90. const operations: PatchOperation[] = []
  91. const lines = patchText.split("\n")
  92. let i = 0
  93. let fuzz = 0
  94. while (i < lines.length) {
  95. const line = lines[i]
  96. if (line.startsWith("*** Update File:")) {
  97. const filePath = line.split(":", 2)[1]?.trim()
  98. if (!filePath) {
  99. i++
  100. continue
  101. }
  102. const hunks: PatchHunk[] = []
  103. i++
  104. while (i < lines.length && !lines[i].startsWith("***")) {
  105. if (lines[i].startsWith("@@")) {
  106. const contextLine = lines[i].substring(2).trim()
  107. const changes: PatchChange[] = []
  108. i++
  109. while (
  110. i < lines.length &&
  111. !lines[i].startsWith("@@") &&
  112. !lines[i].startsWith("***")
  113. ) {
  114. const changeLine = lines[i]
  115. if (changeLine.startsWith(" ")) {
  116. changes.push({ type: "keep", content: changeLine.substring(1) })
  117. } else if (changeLine.startsWith("-")) {
  118. changes.push({
  119. type: "remove",
  120. content: changeLine.substring(1),
  121. })
  122. } else if (changeLine.startsWith("+")) {
  123. changes.push({ type: "add", content: changeLine.substring(1) })
  124. }
  125. i++
  126. }
  127. hunks.push({ contextLine, changes })
  128. } else {
  129. i++
  130. }
  131. }
  132. operations.push({ type: "update", filePath, hunks })
  133. } else if (line.startsWith("*** Add File:")) {
  134. const filePath = line.split(":", 2)[1]?.trim()
  135. if (!filePath) {
  136. i++
  137. continue
  138. }
  139. let content = ""
  140. i++
  141. while (i < lines.length && !lines[i].startsWith("***")) {
  142. if (lines[i].startsWith("+")) {
  143. content += lines[i].substring(1) + "\n"
  144. }
  145. i++
  146. }
  147. operations.push({ type: "add", filePath, content: content.slice(0, -1) })
  148. } else if (line.startsWith("*** Delete File:")) {
  149. const filePath = line.split(":", 2)[1]?.trim()
  150. if (filePath) {
  151. operations.push({ type: "delete", filePath })
  152. }
  153. i++
  154. } else {
  155. i++
  156. }
  157. }
  158. return [operations, fuzz]
  159. }
  160. function patchToCommit(
  161. operations: PatchOperation[],
  162. currentFiles: Record<string, string>,
  163. ): Commit {
  164. const changes: Record<string, Change> = {}
  165. for (const op of operations) {
  166. if (op.type === "delete") {
  167. changes[op.filePath] = {
  168. type: "delete",
  169. old_content: currentFiles[op.filePath] || "",
  170. }
  171. } else if (op.type === "add") {
  172. changes[op.filePath] = {
  173. type: "add",
  174. new_content: op.content || "",
  175. }
  176. } else if (op.type === "update" && op.hunks) {
  177. const originalContent = currentFiles[op.filePath] || ""
  178. const lines = originalContent.split("\n")
  179. for (const hunk of op.hunks) {
  180. const contextIndex = lines.findIndex((line) =>
  181. line.includes(hunk.contextLine),
  182. )
  183. if (contextIndex === -1) {
  184. throw new Error(`Context line not found: ${hunk.contextLine}`)
  185. }
  186. let currentIndex = contextIndex
  187. for (const change of hunk.changes) {
  188. if (change.type === "keep") {
  189. currentIndex++
  190. } else if (change.type === "remove") {
  191. lines.splice(currentIndex, 1)
  192. } else if (change.type === "add") {
  193. lines.splice(currentIndex, 0, change.content)
  194. currentIndex++
  195. }
  196. }
  197. }
  198. changes[op.filePath] = {
  199. type: "update",
  200. old_content: originalContent,
  201. new_content: lines.join("\n"),
  202. }
  203. }
  204. }
  205. return { changes }
  206. }
  207. function generateDiff(
  208. oldContent: string,
  209. newContent: string,
  210. filePath: string,
  211. ): [string, number, number] {
  212. // Mock implementation - would need actual diff generation
  213. const lines1 = oldContent.split("\n")
  214. const lines2 = newContent.split("\n")
  215. const additions = Math.max(0, lines2.length - lines1.length)
  216. const removals = Math.max(0, lines1.length - lines2.length)
  217. return [`--- ${filePath}\n+++ ${filePath}\n`, additions, removals]
  218. }
  219. async function applyCommit(
  220. commit: Commit,
  221. writeFile: (path: string, content: string) => Promise<void>,
  222. deleteFile: (path: string) => Promise<void>,
  223. ): Promise<void> {
  224. for (const [filePath, change] of Object.entries(commit.changes)) {
  225. if (change.type === "delete") {
  226. await deleteFile(filePath)
  227. } else if (change.new_content !== undefined) {
  228. await writeFile(filePath, change.new_content)
  229. }
  230. }
  231. }
  232. export const PatchTool = Tool.define({
  233. id: "opencode.patch",
  234. description: DESCRIPTION,
  235. parameters: PatchParams,
  236. execute: async (params, ctx) => {
  237. if (!params.patchText) {
  238. throw new Error("patchText is required")
  239. }
  240. // Identify all files needed for the patch and verify they've been read
  241. const filesToRead = identifyFilesNeeded(params.patchText)
  242. for (const filePath of filesToRead) {
  243. let absPath = filePath
  244. if (!path.isAbsolute(absPath)) {
  245. absPath = path.resolve(process.cwd(), absPath)
  246. }
  247. if (!FileTimes.get(ctx.sessionID, absPath)) {
  248. throw new Error(
  249. `you must read the file ${filePath} before patching it. Use the FileRead tool first`,
  250. )
  251. }
  252. try {
  253. const stats = await fs.stat(absPath)
  254. if (stats.isDirectory()) {
  255. throw new Error(`path is a directory, not a file: ${absPath}`)
  256. }
  257. const lastRead = FileTimes.get(ctx.sessionID, absPath)
  258. if (lastRead && stats.mtime > lastRead) {
  259. throw new Error(
  260. `file ${absPath} has been modified since it was last read (mod time: ${stats.mtime.toISOString()}, last read: ${lastRead.toISOString()})`,
  261. )
  262. }
  263. } catch (error: any) {
  264. if (error.code === "ENOENT") {
  265. throw new Error(`file not found: ${absPath}`)
  266. }
  267. throw new Error(`failed to access file: ${error.message}`)
  268. }
  269. }
  270. // Check for new files to ensure they don't already exist
  271. const filesToAdd = identifyFilesAdded(params.patchText)
  272. for (const filePath of filesToAdd) {
  273. let absPath = filePath
  274. if (!path.isAbsolute(absPath)) {
  275. absPath = path.resolve(process.cwd(), absPath)
  276. }
  277. try {
  278. await fs.stat(absPath)
  279. throw new Error(`file already exists and cannot be added: ${absPath}`)
  280. } catch (error: any) {
  281. if (error.code !== "ENOENT") {
  282. throw new Error(`failed to check file: ${error.message}`)
  283. }
  284. }
  285. }
  286. // Load all required files
  287. const currentFiles: Record<string, string> = {}
  288. for (const filePath of filesToRead) {
  289. let absPath = filePath
  290. if (!path.isAbsolute(absPath)) {
  291. absPath = path.resolve(process.cwd(), absPath)
  292. }
  293. try {
  294. const content = await fs.readFile(absPath, "utf-8")
  295. currentFiles[filePath] = content
  296. } catch (error: any) {
  297. throw new Error(`failed to read file ${absPath}: ${error.message}`)
  298. }
  299. }
  300. // Process the patch
  301. const [patch, fuzz] = textToPatch(params.patchText, currentFiles)
  302. if (fuzz > 3) {
  303. throw new Error(
  304. `patch contains fuzzy matches (fuzz level: ${fuzz}). Please make your context lines more precise`,
  305. )
  306. }
  307. // Convert patch to commit
  308. const commit = patchToCommit(patch, currentFiles)
  309. // Apply the changes to the filesystem
  310. await applyCommit(
  311. commit,
  312. async (filePath: string, content: string) => {
  313. let absPath = filePath
  314. if (!path.isAbsolute(absPath)) {
  315. absPath = path.resolve(process.cwd(), absPath)
  316. }
  317. // Create parent directories if needed
  318. const dir = path.dirname(absPath)
  319. await fs.mkdir(dir, { recursive: true })
  320. await fs.writeFile(absPath, content, "utf-8")
  321. },
  322. async (filePath: string) => {
  323. let absPath = filePath
  324. if (!path.isAbsolute(absPath)) {
  325. absPath = path.resolve(process.cwd(), absPath)
  326. }
  327. await fs.unlink(absPath)
  328. },
  329. )
  330. // Calculate statistics
  331. const changedFiles: string[] = []
  332. let totalAdditions = 0
  333. let totalRemovals = 0
  334. for (const [filePath, change] of Object.entries(commit.changes)) {
  335. let absPath = filePath
  336. if (!path.isAbsolute(absPath)) {
  337. absPath = path.resolve(process.cwd(), absPath)
  338. }
  339. changedFiles.push(absPath)
  340. const oldContent = change.old_content || ""
  341. const newContent = change.new_content || ""
  342. // Calculate diff statistics
  343. const [, additions, removals] = generateDiff(
  344. oldContent,
  345. newContent,
  346. filePath,
  347. )
  348. totalAdditions += additions
  349. totalRemovals += removals
  350. FileTimes.read(ctx.sessionID, absPath)
  351. }
  352. const result = `Patch applied successfully. ${changedFiles.length} files changed, ${totalAdditions} additions, ${totalRemovals} removals`
  353. const output = result
  354. return {
  355. metadata: {
  356. changed: changedFiles,
  357. additions: totalAdditions,
  358. removals: totalRemovals,
  359. } satisfies PatchResponseMetadata,
  360. output,
  361. }
  362. },
  363. })