| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294 |
- import z from "zod"
- import * as path from "path"
- import { Effect } from "effect"
- import * as Tool from "./tool"
- import { Bus } from "../bus"
- import { FileWatcher } from "../file/watcher"
- import { Instance } from "../project/instance"
- import { Patch } from "../patch"
- import { createTwoFilesPatch, diffLines } from "diff"
- import { assertExternalDirectoryEffect } from "./external-directory"
- import { trimDiff } from "./edit"
- import { LSP } from "../lsp"
- import { AppFileSystem } from "@opencode-ai/shared/filesystem"
- import DESCRIPTION from "./apply_patch.txt"
- import { File } from "../file"
- import { Format } from "../format"
- const PatchParams = z.object({
- patchText: z.string().describe("The full patch text that describes all changes to be made"),
- })
- export const ApplyPatchTool = Tool.define(
- "apply_patch",
- Effect.gen(function* () {
- const lsp = yield* LSP.Service
- const afs = yield* AppFileSystem.Service
- const format = yield* Format.Service
- const bus = yield* Bus.Service
- const run = Effect.fn("ApplyPatchTool.execute")(function* (params: z.infer<typeof PatchParams>, ctx: Tool.Context) {
- if (!params.patchText) {
- return yield* Effect.fail(new Error("patchText is required"))
- }
- // Parse the patch to get hunks
- let hunks: Patch.Hunk[]
- try {
- const parseResult = Patch.parsePatch(params.patchText)
- hunks = parseResult.hunks
- } catch (error) {
- return yield* Effect.fail(new Error(`apply_patch verification failed: ${error}`))
- }
- if (hunks.length === 0) {
- const normalized = params.patchText.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim()
- if (normalized === "*** Begin Patch\n*** End Patch") {
- return yield* Effect.fail(new Error("patch rejected: empty patch"))
- }
- return yield* Effect.fail(new Error("apply_patch verification failed: no hunks found"))
- }
- // Validate file paths and check permissions
- const fileChanges: Array<{
- filePath: string
- oldContent: string
- newContent: string
- type: "add" | "update" | "delete" | "move"
- movePath?: string
- diff: string
- additions: number
- deletions: number
- }> = []
- let totalDiff = ""
- for (const hunk of hunks) {
- const filePath = path.resolve(Instance.directory, hunk.path)
- yield* assertExternalDirectoryEffect(ctx, filePath)
- switch (hunk.type) {
- case "add": {
- const oldContent = ""
- const newContent =
- hunk.contents.length === 0 || hunk.contents.endsWith("\n") ? hunk.contents : `${hunk.contents}\n`
- const diff = trimDiff(createTwoFilesPatch(filePath, filePath, oldContent, newContent))
- let additions = 0
- let deletions = 0
- for (const change of diffLines(oldContent, newContent)) {
- if (change.added) additions += change.count || 0
- if (change.removed) deletions += change.count || 0
- }
- fileChanges.push({
- filePath,
- oldContent,
- newContent,
- type: "add",
- diff,
- additions,
- deletions,
- })
- totalDiff += diff + "\n"
- break
- }
- case "update": {
- // Check if file exists for update
- const stats = yield* afs.stat(filePath).pipe(Effect.catch(() => Effect.succeed(undefined)))
- if (!stats || stats.type === "Directory") {
- return yield* Effect.fail(
- new Error(`apply_patch verification failed: Failed to read file to update: ${filePath}`),
- )
- }
- const oldContent = yield* afs.readFileString(filePath)
- let newContent = oldContent
- // Apply the update chunks to get new content
- try {
- const fileUpdate = Patch.deriveNewContentsFromChunks(filePath, hunk.chunks)
- newContent = fileUpdate.content
- } catch (error) {
- return yield* Effect.fail(new Error(`apply_patch verification failed: ${error}`))
- }
- const diff = trimDiff(createTwoFilesPatch(filePath, filePath, oldContent, newContent))
- let additions = 0
- let deletions = 0
- for (const change of diffLines(oldContent, newContent)) {
- if (change.added) additions += change.count || 0
- if (change.removed) deletions += change.count || 0
- }
- const movePath = hunk.move_path ? path.resolve(Instance.directory, hunk.move_path) : undefined
- yield* assertExternalDirectoryEffect(ctx, movePath)
- fileChanges.push({
- filePath,
- oldContent,
- newContent,
- type: hunk.move_path ? "move" : "update",
- movePath,
- diff,
- additions,
- deletions,
- })
- totalDiff += diff + "\n"
- break
- }
- case "delete": {
- const contentToDelete = yield* afs
- .readFileString(filePath)
- .pipe(
- Effect.catch((error) =>
- Effect.fail(
- new Error(
- `apply_patch verification failed: ${error instanceof Error ? error.message : String(error)}`,
- ),
- ),
- ),
- )
- const deleteDiff = trimDiff(createTwoFilesPatch(filePath, filePath, contentToDelete, ""))
- const deletions = contentToDelete.split("\n").length
- fileChanges.push({
- filePath,
- oldContent: contentToDelete,
- newContent: "",
- type: "delete",
- diff: deleteDiff,
- additions: 0,
- deletions,
- })
- totalDiff += deleteDiff + "\n"
- break
- }
- }
- }
- // Build per-file metadata for UI rendering (used for both permission and result)
- const files = fileChanges.map((change) => ({
- filePath: change.filePath,
- relativePath: path.relative(Instance.worktree, change.movePath ?? change.filePath).replaceAll("\\", "/"),
- type: change.type,
- patch: change.diff,
- additions: change.additions,
- deletions: change.deletions,
- movePath: change.movePath,
- }))
- // Check permissions if needed
- const relativePaths = fileChanges.map((c) => path.relative(Instance.worktree, c.filePath).replaceAll("\\", "/"))
- yield* ctx.ask({
- permission: "edit",
- patterns: relativePaths,
- always: ["*"],
- metadata: {
- filepath: relativePaths.join(", "),
- diff: totalDiff,
- files,
- },
- })
- // Apply the changes
- const updates: Array<{ file: string; event: "add" | "change" | "unlink" }> = []
- for (const change of fileChanges) {
- const edited = change.type === "delete" ? undefined : (change.movePath ?? change.filePath)
- switch (change.type) {
- case "add":
- // Create parent directories (recursive: true is safe on existing/root dirs)
- yield* afs.writeWithDirs(change.filePath, change.newContent)
- updates.push({ file: change.filePath, event: "add" })
- break
- case "update":
- yield* afs.writeWithDirs(change.filePath, change.newContent)
- updates.push({ file: change.filePath, event: "change" })
- break
- case "move":
- if (change.movePath) {
- // Create parent directories (recursive: true is safe on existing/root dirs)
- yield* afs.writeWithDirs(change.movePath!, change.newContent)
- yield* afs.remove(change.filePath)
- updates.push({ file: change.filePath, event: "unlink" })
- updates.push({ file: change.movePath, event: "add" })
- }
- break
- case "delete":
- yield* afs.remove(change.filePath)
- updates.push({ file: change.filePath, event: "unlink" })
- break
- }
- if (edited) {
- yield* format.file(edited)
- yield* bus.publish(File.Event.Edited, { file: edited })
- }
- }
- // Publish file change events
- for (const update of updates) {
- yield* bus.publish(FileWatcher.Event.Updated, update)
- }
- // Notify LSP of file changes and collect diagnostics
- for (const change of fileChanges) {
- if (change.type === "delete") continue
- const target = change.movePath ?? change.filePath
- yield* lsp.touchFile(target, true)
- }
- const diagnostics = yield* lsp.diagnostics()
- // Generate output summary
- const summaryLines = fileChanges.map((change) => {
- if (change.type === "add") {
- return `A ${path.relative(Instance.worktree, change.filePath).replaceAll("\\", "/")}`
- }
- if (change.type === "delete") {
- return `D ${path.relative(Instance.worktree, change.filePath).replaceAll("\\", "/")}`
- }
- const target = change.movePath ?? change.filePath
- return `M ${path.relative(Instance.worktree, target).replaceAll("\\", "/")}`
- })
- let output = `Success. Updated the following files:\n${summaryLines.join("\n")}`
- for (const change of fileChanges) {
- if (change.type === "delete") continue
- const target = change.movePath ?? change.filePath
- const block = LSP.Diagnostic.report(target, diagnostics[AppFileSystem.normalizePath(target)] ?? [])
- if (!block) continue
- const rel = path.relative(Instance.worktree, target).replaceAll("\\", "/")
- output += `\n\nLSP errors detected in ${rel}, please fix:\n${block}`
- }
- return {
- title: output,
- metadata: {
- diff: totalDiff,
- files,
- diagnostics,
- },
- output,
- }
- })
- return {
- description: DESCRIPTION,
- parameters: PatchParams,
- execute: (params: z.infer<typeof PatchParams>, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie),
- }
- }),
- )
|