|
|
@@ -0,0 +1,450 @@
|
|
|
+import fs from "fs/promises"
|
|
|
+import path from "path"
|
|
|
+
|
|
|
+import { getReadablePath } from "../../utils/path"
|
|
|
+import { isPathOutsideWorkspace } from "../../utils/pathUtils"
|
|
|
+import { Task } from "../task/Task"
|
|
|
+import { formatResponse } from "../prompts/responses"
|
|
|
+import { ClineSayTool } from "../../shared/ExtensionMessage"
|
|
|
+import { RecordSource } from "../context-tracking/FileContextTrackerTypes"
|
|
|
+import { fileExistsAtPath } from "../../utils/fs"
|
|
|
+import { DEFAULT_WRITE_DELAY_MS } from "@roo-code/types"
|
|
|
+import { EXPERIMENT_IDS, experiments } from "../../shared/experiments"
|
|
|
+import { sanitizeUnifiedDiff, computeDiffStats } from "../diff/stats"
|
|
|
+import { BaseTool, ToolCallbacks } from "./BaseTool"
|
|
|
+import type { ToolUse } from "../../shared/tools"
|
|
|
+import { parsePatch, ParseError, processAllHunks } from "./apply-patch"
|
|
|
+import type { ApplyPatchFileChange } from "./apply-patch"
|
|
|
+
|
|
|
+interface ApplyPatchParams {
|
|
|
+ patch: string
|
|
|
+}
|
|
|
+
|
|
|
+export class ApplyPatchTool extends BaseTool<"apply_patch"> {
|
|
|
+ readonly name = "apply_patch" as const
|
|
|
+
|
|
|
+ parseLegacy(params: Partial<Record<string, string>>): ApplyPatchParams {
|
|
|
+ return {
|
|
|
+ patch: params.patch || "",
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ async execute(params: ApplyPatchParams, task: Task, callbacks: ToolCallbacks): Promise<void> {
|
|
|
+ const { patch } = params
|
|
|
+ const { askApproval, handleError, pushToolResult, toolProtocol } = callbacks
|
|
|
+
|
|
|
+ try {
|
|
|
+ // Validate required parameters
|
|
|
+ if (!patch) {
|
|
|
+ task.consecutiveMistakeCount++
|
|
|
+ task.recordToolError("apply_patch")
|
|
|
+ pushToolResult(await task.sayAndCreateMissingParamError("apply_patch", "patch"))
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ // Parse the patch
|
|
|
+ let parsedPatch
|
|
|
+ try {
|
|
|
+ parsedPatch = parsePatch(patch)
|
|
|
+ } catch (error) {
|
|
|
+ task.consecutiveMistakeCount++
|
|
|
+ task.recordToolError("apply_patch")
|
|
|
+ const errorMessage =
|
|
|
+ error instanceof ParseError
|
|
|
+ ? `Invalid patch format: ${error.message}`
|
|
|
+ : `Failed to parse patch: ${error instanceof Error ? error.message : String(error)}`
|
|
|
+ pushToolResult(formatResponse.toolError(errorMessage))
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ if (parsedPatch.hunks.length === 0) {
|
|
|
+ pushToolResult("No file operations found in patch.")
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ // Process each hunk
|
|
|
+ const readFile = async (filePath: string): Promise<string> => {
|
|
|
+ const absolutePath = path.resolve(task.cwd, filePath)
|
|
|
+ return await fs.readFile(absolutePath, "utf8")
|
|
|
+ }
|
|
|
+
|
|
|
+ let changes: ApplyPatchFileChange[]
|
|
|
+ try {
|
|
|
+ changes = await processAllHunks(parsedPatch.hunks, readFile)
|
|
|
+ } catch (error) {
|
|
|
+ task.consecutiveMistakeCount++
|
|
|
+ task.recordToolError("apply_patch")
|
|
|
+ const errorMessage = `Failed to process patch: ${error instanceof Error ? error.message : String(error)}`
|
|
|
+ pushToolResult(formatResponse.toolError(errorMessage))
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ // Process each file change
|
|
|
+ for (const change of changes) {
|
|
|
+ const relPath = change.path
|
|
|
+ const absolutePath = path.resolve(task.cwd, relPath)
|
|
|
+
|
|
|
+ // Check access permissions
|
|
|
+ const accessAllowed = task.rooIgnoreController?.validateAccess(relPath)
|
|
|
+ if (!accessAllowed) {
|
|
|
+ await task.say("rooignore_error", relPath)
|
|
|
+ pushToolResult(formatResponse.rooIgnoreError(relPath, toolProtocol))
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ // Check if file is write-protected
|
|
|
+ const isWriteProtected = task.rooProtectedController?.isWriteProtected(relPath) || false
|
|
|
+
|
|
|
+ if (change.type === "add") {
|
|
|
+ // Create new file
|
|
|
+ await this.handleAddFile(change, absolutePath, relPath, task, callbacks, isWriteProtected)
|
|
|
+ } else if (change.type === "delete") {
|
|
|
+ // Delete file
|
|
|
+ await this.handleDeleteFile(absolutePath, relPath, task, callbacks, isWriteProtected)
|
|
|
+ } else if (change.type === "update") {
|
|
|
+ // Update file
|
|
|
+ await this.handleUpdateFile(change, absolutePath, relPath, task, callbacks, isWriteProtected)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ task.consecutiveMistakeCount = 0
|
|
|
+ task.recordToolUsage("apply_patch")
|
|
|
+ } catch (error) {
|
|
|
+ await handleError("apply patch", error as Error)
|
|
|
+ await task.diffViewProvider.reset()
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private async handleAddFile(
|
|
|
+ change: ApplyPatchFileChange,
|
|
|
+ absolutePath: string,
|
|
|
+ relPath: string,
|
|
|
+ task: Task,
|
|
|
+ callbacks: ToolCallbacks,
|
|
|
+ isWriteProtected: boolean,
|
|
|
+ ): Promise<void> {
|
|
|
+ const { askApproval, pushToolResult } = callbacks
|
|
|
+
|
|
|
+ // Check if file already exists
|
|
|
+ const fileExists = await fileExistsAtPath(absolutePath)
|
|
|
+ if (fileExists) {
|
|
|
+ task.consecutiveMistakeCount++
|
|
|
+ task.recordToolError("apply_patch")
|
|
|
+ const errorMessage = `File already exists: ${relPath}. Use Update File instead.`
|
|
|
+ await task.say("error", errorMessage)
|
|
|
+ pushToolResult(formatResponse.toolError(errorMessage))
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ const newContent = change.newContent || ""
|
|
|
+ const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath)
|
|
|
+
|
|
|
+ // Initialize diff view for new file
|
|
|
+ task.diffViewProvider.editType = "create"
|
|
|
+ task.diffViewProvider.originalContent = undefined
|
|
|
+
|
|
|
+ const diff = formatResponse.createPrettyPatch(relPath, "", newContent)
|
|
|
+
|
|
|
+ // Check experiment settings
|
|
|
+ const provider = task.providerRef.deref()
|
|
|
+ const state = await provider?.getState()
|
|
|
+ const diagnosticsEnabled = state?.diagnosticsEnabled ?? true
|
|
|
+ const writeDelayMs = state?.writeDelayMs ?? DEFAULT_WRITE_DELAY_MS
|
|
|
+ const isPreventFocusDisruptionEnabled = experiments.isEnabled(
|
|
|
+ state?.experiments ?? {},
|
|
|
+ EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION,
|
|
|
+ )
|
|
|
+
|
|
|
+ const sanitizedDiff = sanitizeUnifiedDiff(diff || "")
|
|
|
+ const diffStats = computeDiffStats(sanitizedDiff) || undefined
|
|
|
+
|
|
|
+ const sharedMessageProps: ClineSayTool = {
|
|
|
+ tool: "appliedDiff",
|
|
|
+ path: getReadablePath(task.cwd, relPath),
|
|
|
+ diff: sanitizedDiff,
|
|
|
+ isOutsideWorkspace,
|
|
|
+ }
|
|
|
+
|
|
|
+ const completeMessage = JSON.stringify({
|
|
|
+ ...sharedMessageProps,
|
|
|
+ content: sanitizedDiff,
|
|
|
+ isProtected: isWriteProtected,
|
|
|
+ diffStats,
|
|
|
+ } satisfies ClineSayTool)
|
|
|
+
|
|
|
+ // Show diff view if focus disruption prevention is disabled
|
|
|
+ if (!isPreventFocusDisruptionEnabled) {
|
|
|
+ await task.diffViewProvider.open(relPath)
|
|
|
+ await task.diffViewProvider.update(newContent, true)
|
|
|
+ task.diffViewProvider.scrollToFirstDiff()
|
|
|
+ }
|
|
|
+
|
|
|
+ const didApprove = await askApproval("tool", completeMessage, undefined, isWriteProtected)
|
|
|
+
|
|
|
+ if (!didApprove) {
|
|
|
+ if (!isPreventFocusDisruptionEnabled) {
|
|
|
+ await task.diffViewProvider.revertChanges()
|
|
|
+ }
|
|
|
+ pushToolResult("Changes were rejected by the user.")
|
|
|
+ await task.diffViewProvider.reset()
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ // Save the changes
|
|
|
+ if (isPreventFocusDisruptionEnabled) {
|
|
|
+ await task.diffViewProvider.saveDirectly(relPath, newContent, true, diagnosticsEnabled, writeDelayMs)
|
|
|
+ } else {
|
|
|
+ await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs)
|
|
|
+ }
|
|
|
+
|
|
|
+ // Track file edit operation
|
|
|
+ await task.fileContextTracker.trackFileContext(relPath, "roo_edited" as RecordSource)
|
|
|
+ task.didEditFile = true
|
|
|
+
|
|
|
+ const message = await task.diffViewProvider.pushToolWriteResult(task, task.cwd, true)
|
|
|
+ pushToolResult(message)
|
|
|
+ await task.diffViewProvider.reset()
|
|
|
+ task.processQueuedMessages()
|
|
|
+ }
|
|
|
+
|
|
|
+ private async handleDeleteFile(
|
|
|
+ absolutePath: string,
|
|
|
+ relPath: string,
|
|
|
+ task: Task,
|
|
|
+ callbacks: ToolCallbacks,
|
|
|
+ isWriteProtected: boolean,
|
|
|
+ ): Promise<void> {
|
|
|
+ const { askApproval, pushToolResult } = callbacks
|
|
|
+
|
|
|
+ // Check if file exists
|
|
|
+ const fileExists = await fileExistsAtPath(absolutePath)
|
|
|
+ if (!fileExists) {
|
|
|
+ task.consecutiveMistakeCount++
|
|
|
+ task.recordToolError("apply_patch")
|
|
|
+ const errorMessage = `File not found: ${relPath}. Cannot delete a non-existent file.`
|
|
|
+ await task.say("error", errorMessage)
|
|
|
+ pushToolResult(formatResponse.toolError(errorMessage))
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath)
|
|
|
+
|
|
|
+ const sharedMessageProps: ClineSayTool = {
|
|
|
+ tool: "appliedDiff",
|
|
|
+ path: getReadablePath(task.cwd, relPath),
|
|
|
+ diff: `File will be deleted: ${relPath}`,
|
|
|
+ isOutsideWorkspace,
|
|
|
+ }
|
|
|
+
|
|
|
+ const completeMessage = JSON.stringify({
|
|
|
+ ...sharedMessageProps,
|
|
|
+ content: `Delete file: ${relPath}`,
|
|
|
+ isProtected: isWriteProtected,
|
|
|
+ } satisfies ClineSayTool)
|
|
|
+
|
|
|
+ const didApprove = await askApproval("tool", completeMessage, undefined, isWriteProtected)
|
|
|
+
|
|
|
+ if (!didApprove) {
|
|
|
+ pushToolResult("Delete operation was rejected by the user.")
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ // Delete the file
|
|
|
+ try {
|
|
|
+ await fs.unlink(absolutePath)
|
|
|
+ } catch (error) {
|
|
|
+ const errorMessage = `Failed to delete file '${relPath}': ${error instanceof Error ? error.message : String(error)}`
|
|
|
+ await task.say("error", errorMessage)
|
|
|
+ pushToolResult(formatResponse.toolError(errorMessage))
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ task.didEditFile = true
|
|
|
+ pushToolResult(`Successfully deleted ${relPath}`)
|
|
|
+ task.processQueuedMessages()
|
|
|
+ }
|
|
|
+
|
|
|
+ private async handleUpdateFile(
|
|
|
+ change: ApplyPatchFileChange,
|
|
|
+ absolutePath: string,
|
|
|
+ relPath: string,
|
|
|
+ task: Task,
|
|
|
+ callbacks: ToolCallbacks,
|
|
|
+ isWriteProtected: boolean,
|
|
|
+ ): Promise<void> {
|
|
|
+ const { askApproval, pushToolResult } = callbacks
|
|
|
+
|
|
|
+ // Check if file exists
|
|
|
+ const fileExists = await fileExistsAtPath(absolutePath)
|
|
|
+ if (!fileExists) {
|
|
|
+ task.consecutiveMistakeCount++
|
|
|
+ task.recordToolError("apply_patch")
|
|
|
+ const errorMessage = `File not found: ${relPath}. Cannot update a non-existent file.`
|
|
|
+ await task.say("error", errorMessage)
|
|
|
+ pushToolResult(formatResponse.toolError(errorMessage))
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ const originalContent = change.originalContent || ""
|
|
|
+ const newContent = change.newContent || ""
|
|
|
+ const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath)
|
|
|
+
|
|
|
+ // Initialize diff view
|
|
|
+ task.diffViewProvider.editType = "modify"
|
|
|
+ task.diffViewProvider.originalContent = originalContent
|
|
|
+
|
|
|
+ // Generate and validate diff
|
|
|
+ const diff = formatResponse.createPrettyPatch(relPath, originalContent, newContent)
|
|
|
+ if (!diff) {
|
|
|
+ pushToolResult(`No changes needed for '${relPath}'`)
|
|
|
+ await task.diffViewProvider.reset()
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ // Check experiment settings
|
|
|
+ const provider = task.providerRef.deref()
|
|
|
+ const state = await provider?.getState()
|
|
|
+ const diagnosticsEnabled = state?.diagnosticsEnabled ?? true
|
|
|
+ const writeDelayMs = state?.writeDelayMs ?? DEFAULT_WRITE_DELAY_MS
|
|
|
+ const isPreventFocusDisruptionEnabled = experiments.isEnabled(
|
|
|
+ state?.experiments ?? {},
|
|
|
+ EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION,
|
|
|
+ )
|
|
|
+
|
|
|
+ const sanitizedDiff = sanitizeUnifiedDiff(diff)
|
|
|
+ const diffStats = computeDiffStats(sanitizedDiff) || undefined
|
|
|
+
|
|
|
+ const sharedMessageProps: ClineSayTool = {
|
|
|
+ tool: "appliedDiff",
|
|
|
+ path: getReadablePath(task.cwd, relPath),
|
|
|
+ diff: sanitizedDiff,
|
|
|
+ isOutsideWorkspace,
|
|
|
+ }
|
|
|
+
|
|
|
+ const completeMessage = JSON.stringify({
|
|
|
+ ...sharedMessageProps,
|
|
|
+ content: sanitizedDiff,
|
|
|
+ isProtected: isWriteProtected,
|
|
|
+ diffStats,
|
|
|
+ } satisfies ClineSayTool)
|
|
|
+
|
|
|
+ // Show diff view if focus disruption prevention is disabled
|
|
|
+ if (!isPreventFocusDisruptionEnabled) {
|
|
|
+ await task.diffViewProvider.open(relPath)
|
|
|
+ await task.diffViewProvider.update(newContent, true)
|
|
|
+ task.diffViewProvider.scrollToFirstDiff()
|
|
|
+ }
|
|
|
+
|
|
|
+ const didApprove = await askApproval("tool", completeMessage, undefined, isWriteProtected)
|
|
|
+
|
|
|
+ if (!didApprove) {
|
|
|
+ if (!isPreventFocusDisruptionEnabled) {
|
|
|
+ await task.diffViewProvider.revertChanges()
|
|
|
+ }
|
|
|
+ pushToolResult("Changes were rejected by the user.")
|
|
|
+ await task.diffViewProvider.reset()
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ // Handle file move if specified
|
|
|
+ if (change.movePath) {
|
|
|
+ const moveAbsolutePath = path.resolve(task.cwd, change.movePath)
|
|
|
+
|
|
|
+ // Validate destination path access permissions
|
|
|
+ const moveAccessAllowed = task.rooIgnoreController?.validateAccess(change.movePath)
|
|
|
+ if (!moveAccessAllowed) {
|
|
|
+ await task.say("rooignore_error", change.movePath)
|
|
|
+ pushToolResult(formatResponse.rooIgnoreError(change.movePath))
|
|
|
+ await task.diffViewProvider.reset()
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ // Check if destination path is write-protected
|
|
|
+ const isMovePathWriteProtected = task.rooProtectedController?.isWriteProtected(change.movePath) || false
|
|
|
+ if (isMovePathWriteProtected) {
|
|
|
+ task.consecutiveMistakeCount++
|
|
|
+ task.recordToolError("apply_patch")
|
|
|
+ const errorMessage = `Cannot move file to write-protected path: ${change.movePath}`
|
|
|
+ await task.say("error", errorMessage)
|
|
|
+ pushToolResult(formatResponse.toolError(errorMessage))
|
|
|
+ await task.diffViewProvider.reset()
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ // Check if destination path is outside workspace
|
|
|
+ const isMoveOutsideWorkspace = isPathOutsideWorkspace(moveAbsolutePath)
|
|
|
+ if (isMoveOutsideWorkspace) {
|
|
|
+ task.consecutiveMistakeCount++
|
|
|
+ task.recordToolError("apply_patch")
|
|
|
+ const errorMessage = `Cannot move file to path outside workspace: ${change.movePath}`
|
|
|
+ await task.say("error", errorMessage)
|
|
|
+ pushToolResult(formatResponse.toolError(errorMessage))
|
|
|
+ await task.diffViewProvider.reset()
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ // Save new content to the new path
|
|
|
+ if (isPreventFocusDisruptionEnabled) {
|
|
|
+ await task.diffViewProvider.saveDirectly(
|
|
|
+ change.movePath,
|
|
|
+ newContent,
|
|
|
+ false,
|
|
|
+ diagnosticsEnabled,
|
|
|
+ writeDelayMs,
|
|
|
+ )
|
|
|
+ } else {
|
|
|
+ // Write to new path and delete old file
|
|
|
+ const parentDir = path.dirname(moveAbsolutePath)
|
|
|
+ await fs.mkdir(parentDir, { recursive: true })
|
|
|
+ await fs.writeFile(moveAbsolutePath, newContent, "utf8")
|
|
|
+ }
|
|
|
+
|
|
|
+ // Delete the original file
|
|
|
+ try {
|
|
|
+ await fs.unlink(absolutePath)
|
|
|
+ } catch (error) {
|
|
|
+ console.error(`Failed to delete original file after move: ${error}`)
|
|
|
+ }
|
|
|
+
|
|
|
+ await task.fileContextTracker.trackFileContext(change.movePath, "roo_edited" as RecordSource)
|
|
|
+ } else {
|
|
|
+ // Save changes to the same file
|
|
|
+ if (isPreventFocusDisruptionEnabled) {
|
|
|
+ await task.diffViewProvider.saveDirectly(relPath, newContent, false, diagnosticsEnabled, writeDelayMs)
|
|
|
+ } else {
|
|
|
+ await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs)
|
|
|
+ }
|
|
|
+
|
|
|
+ await task.fileContextTracker.trackFileContext(relPath, "roo_edited" as RecordSource)
|
|
|
+ }
|
|
|
+
|
|
|
+ task.didEditFile = true
|
|
|
+
|
|
|
+ const message = await task.diffViewProvider.pushToolWriteResult(task, task.cwd, false)
|
|
|
+ pushToolResult(message)
|
|
|
+ await task.diffViewProvider.reset()
|
|
|
+ task.processQueuedMessages()
|
|
|
+ }
|
|
|
+
|
|
|
+ override async handlePartial(task: Task, block: ToolUse<"apply_patch">): Promise<void> {
|
|
|
+ const patch: string | undefined = block.params.patch
|
|
|
+
|
|
|
+ let patchPreview: string | undefined
|
|
|
+ if (patch) {
|
|
|
+ // Show first few lines of the patch
|
|
|
+ const lines = patch.split("\n").slice(0, 5)
|
|
|
+ patchPreview = lines.join("\n") + (patch.split("\n").length > 5 ? "\n..." : "")
|
|
|
+ }
|
|
|
+
|
|
|
+ const sharedMessageProps: ClineSayTool = {
|
|
|
+ tool: "appliedDiff",
|
|
|
+ path: "",
|
|
|
+ diff: patchPreview || "Parsing patch...",
|
|
|
+ isOutsideWorkspace: false,
|
|
|
+ }
|
|
|
+
|
|
|
+ await task.ask("tool", JSON.stringify(sharedMessageProps), block.partial).catch(() => {})
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+export const applyPatchTool = new ApplyPatchTool()
|