mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-24 05:35:15 +00:00
277 lines
9.1 KiB
TypeScript
277 lines
9.1 KiB
TypeScript
import z from "zod"
|
|
import * as path from "path"
|
|
import * as fs from "fs/promises"
|
|
import { Tool } from "./tool"
|
|
import { FileTime } from "../file/time"
|
|
import { Bus } from "../bus"
|
|
import { FileWatcher } from "../file/watcher"
|
|
import { Instance } from "../project/instance"
|
|
import { Patch } from "../patch"
|
|
import { createTwoFilesPatch, diffLines } from "diff"
|
|
import { assertExternalDirectory } from "./external-directory"
|
|
import { trimDiff } from "./edit"
|
|
import { LSP } from "../lsp"
|
|
import { Filesystem } from "../util/filesystem"
|
|
|
|
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", {
|
|
description: "Use the `apply_patch` tool to edit files. This is a FREEFORM tool, so do not wrap the patch in JSON.",
|
|
parameters: PatchParams,
|
|
async execute(params, ctx) {
|
|
if (!params.patchText) {
|
|
throw 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) {
|
|
throw 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") {
|
|
throw new Error("patch rejected: empty patch")
|
|
}
|
|
throw 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)
|
|
await assertExternalDirectory(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 = await fs.stat(filePath).catch(() => null)
|
|
if (!stats || stats.isDirectory()) {
|
|
throw new Error(`apply_patch verification failed: Failed to read file to update: ${filePath}`)
|
|
}
|
|
|
|
// Read file and update time tracking (like edit tool does)
|
|
await FileTime.assert(ctx.sessionID, filePath)
|
|
const oldContent = await fs.readFile(filePath, "utf-8")
|
|
let newContent = oldContent
|
|
|
|
// Apply the update chunks to get new content
|
|
try {
|
|
const fileUpdate = Patch.deriveNewContentsFromChunks(filePath, hunk.chunks)
|
|
newContent = fileUpdate.content
|
|
} catch (error) {
|
|
throw 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
|
|
await assertExternalDirectory(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 = await fs.readFile(filePath, "utf-8").catch((error) => {
|
|
throw new Error(`apply_patch verification failed: ${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
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check permissions if needed
|
|
await ctx.ask({
|
|
permission: "edit",
|
|
patterns: fileChanges.map((c) => path.relative(Instance.worktree, c.filePath)),
|
|
always: ["*"],
|
|
metadata: {
|
|
diff: totalDiff,
|
|
},
|
|
})
|
|
|
|
// Apply the changes
|
|
const changedFiles: string[] = []
|
|
|
|
for (const change of fileChanges) {
|
|
switch (change.type) {
|
|
case "add":
|
|
// Create parent directories (recursive: true is safe on existing/root dirs)
|
|
await fs.mkdir(path.dirname(change.filePath), { recursive: true })
|
|
await fs.writeFile(change.filePath, change.newContent, "utf-8")
|
|
changedFiles.push(change.filePath)
|
|
break
|
|
|
|
case "update":
|
|
await fs.writeFile(change.filePath, change.newContent, "utf-8")
|
|
changedFiles.push(change.filePath)
|
|
break
|
|
|
|
case "move":
|
|
if (change.movePath) {
|
|
// Create parent directories (recursive: true is safe on existing/root dirs)
|
|
await fs.mkdir(path.dirname(change.movePath), { recursive: true })
|
|
await fs.writeFile(change.movePath, change.newContent, "utf-8")
|
|
await fs.unlink(change.filePath)
|
|
changedFiles.push(change.movePath)
|
|
}
|
|
break
|
|
|
|
case "delete":
|
|
await fs.unlink(change.filePath)
|
|
changedFiles.push(change.filePath)
|
|
break
|
|
}
|
|
|
|
// Update file time tracking
|
|
FileTime.read(ctx.sessionID, change.filePath)
|
|
if (change.movePath) {
|
|
FileTime.read(ctx.sessionID, change.movePath)
|
|
}
|
|
}
|
|
|
|
// Publish file change events
|
|
for (const filePath of changedFiles) {
|
|
await Bus.publish(FileWatcher.Event.Updated, { file: filePath, event: "change" })
|
|
}
|
|
|
|
// Notify LSP of file changes and collect diagnostics
|
|
for (const change of fileChanges) {
|
|
if (change.type === "delete") continue
|
|
const target = change.movePath ?? change.filePath
|
|
await LSP.touchFile(target, true)
|
|
}
|
|
const diagnostics = await LSP.diagnostics()
|
|
|
|
// Generate output summary
|
|
const summaryLines = fileChanges.map((change) => {
|
|
if (change.type === "add") {
|
|
return `A ${path.relative(Instance.worktree, change.filePath)}`
|
|
}
|
|
if (change.type === "delete") {
|
|
return `D ${path.relative(Instance.worktree, change.filePath)}`
|
|
}
|
|
const target = change.movePath ?? change.filePath
|
|
return `M ${path.relative(Instance.worktree, target)}`
|
|
})
|
|
let output = `Success. Updated the following files:\n${summaryLines.join("\n")}`
|
|
|
|
// Report LSP errors for changed files
|
|
const MAX_DIAGNOSTICS_PER_FILE = 20
|
|
for (const change of fileChanges) {
|
|
if (change.type === "delete") continue
|
|
const target = change.movePath ?? change.filePath
|
|
const normalized = Filesystem.normalizePath(target)
|
|
const issues = diagnostics[normalized] ?? []
|
|
const errors = issues.filter((item) => item.severity === 1)
|
|
if (errors.length > 0) {
|
|
const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE)
|
|
const suffix =
|
|
errors.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more` : ""
|
|
output += `\n\nLSP errors detected in ${path.relative(Instance.worktree, target)}, please fix:\n<diagnostics file="${target}">\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</diagnostics>`
|
|
}
|
|
}
|
|
|
|
// Build per-file metadata for UI rendering
|
|
const files = fileChanges.map((change) => ({
|
|
filePath: change.filePath,
|
|
relativePath: path.relative(Instance.worktree, change.movePath ?? change.filePath),
|
|
type: change.type,
|
|
diff: change.diff,
|
|
before: change.oldContent,
|
|
after: change.newContent,
|
|
additions: change.additions,
|
|
deletions: change.deletions,
|
|
movePath: change.movePath,
|
|
}))
|
|
|
|
return {
|
|
title: output,
|
|
metadata: {
|
|
diff: totalDiff,
|
|
files,
|
|
diagnostics,
|
|
},
|
|
output,
|
|
}
|
|
},
|
|
})
|