1package tools
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "os"
8 "path/filepath"
9 "time"
10
11 "github.com/opencode-ai/opencode/internal/config"
12 "github.com/opencode-ai/opencode/internal/diff"
13 "github.com/opencode-ai/opencode/internal/history"
14 "github.com/opencode-ai/opencode/internal/logging"
15 "github.com/opencode-ai/opencode/internal/lsp"
16 "github.com/opencode-ai/opencode/internal/permission"
17)
18
19type PatchParams struct {
20 PatchText string `json:"patch_text"`
21}
22
23type PatchResponseMetadata struct {
24 FilesChanged []string `json:"files_changed"`
25 Additions int `json:"additions"`
26 Removals int `json:"removals"`
27}
28
29type patchTool struct {
30 lspClients map[string]*lsp.Client
31 permissions permission.Service
32 files history.Service
33}
34
35const (
36 PatchToolName = "patch"
37 patchDescription = `Applies a patch to multiple files in one operation. This tool is useful for making coordinated changes across multiple files.
38
39The patch text must follow this format:
40*** Begin Patch
41*** Update File: /path/to/file
42@@ Context line (unique within the file)
43 Line to keep
44-Line to remove
45+Line to add
46 Line to keep
47*** Add File: /path/to/new/file
48+Content of the new file
49+More content
50*** Delete File: /path/to/file/to/delete
51*** End Patch
52
53Before using this tool:
541. Use the FileRead tool to understand the files' contents and context
552. Verify all file paths are correct (use the LS tool)
56
57CRITICAL REQUIREMENTS FOR USING THIS TOOL:
58
591. UNIQUENESS: Context lines MUST uniquely identify the specific sections you want to change
602. PRECISION: All whitespace, indentation, and surrounding code must match exactly
613. VALIDATION: Ensure edits result in idiomatic, correct code
624. PATHS: Always use absolute file paths (starting with /)
63
64The tool will apply all changes in a single atomic operation.`
65)
66
67func NewPatchTool(lspClients map[string]*lsp.Client, permissions permission.Service, files history.Service) BaseTool {
68 return &patchTool{
69 lspClients: lspClients,
70 permissions: permissions,
71 files: files,
72 }
73}
74
75func (p *patchTool) Info() ToolInfo {
76 return ToolInfo{
77 Name: PatchToolName,
78 Description: patchDescription,
79 Parameters: map[string]any{
80 "patch_text": map[string]any{
81 "type": "string",
82 "description": "The full patch text that describes all changes to be made",
83 },
84 },
85 Required: []string{"patch_text"},
86 }
87}
88
89func (p *patchTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
90 var params PatchParams
91 if err := json.Unmarshal([]byte(call.Input), ¶ms); err != nil {
92 return NewTextErrorResponse("invalid parameters"), nil
93 }
94
95 if params.PatchText == "" {
96 return NewTextErrorResponse("patch_text is required"), nil
97 }
98
99 // Identify all files needed for the patch and verify they've been read
100 filesToRead := diff.IdentifyFilesNeeded(params.PatchText)
101 for _, filePath := range filesToRead {
102 absPath := filePath
103 if !filepath.IsAbs(absPath) {
104 wd := config.WorkingDirectory()
105 absPath = filepath.Join(wd, absPath)
106 }
107
108 if getLastReadTime(absPath).IsZero() {
109 return NewTextErrorResponse(fmt.Sprintf("you must read the file %s before patching it. Use the FileRead tool first", filePath)), nil
110 }
111
112 fileInfo, err := os.Stat(absPath)
113 if err != nil {
114 if os.IsNotExist(err) {
115 return NewTextErrorResponse(fmt.Sprintf("file not found: %s", absPath)), nil
116 }
117 return ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
118 }
119
120 if fileInfo.IsDir() {
121 return NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", absPath)), nil
122 }
123
124 modTime := fileInfo.ModTime()
125 lastRead := getLastReadTime(absPath)
126 if modTime.After(lastRead) {
127 return NewTextErrorResponse(
128 fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
129 absPath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
130 )), nil
131 }
132 }
133
134 // Check for new files to ensure they don't already exist
135 filesToAdd := diff.IdentifyFilesAdded(params.PatchText)
136 for _, filePath := range filesToAdd {
137 absPath := filePath
138 if !filepath.IsAbs(absPath) {
139 wd := config.WorkingDirectory()
140 absPath = filepath.Join(wd, absPath)
141 }
142
143 _, err := os.Stat(absPath)
144 if err == nil {
145 return NewTextErrorResponse(fmt.Sprintf("file already exists and cannot be added: %s", absPath)), nil
146 } else if !os.IsNotExist(err) {
147 return ToolResponse{}, fmt.Errorf("failed to check file: %w", err)
148 }
149 }
150
151 // Load all required files
152 currentFiles := make(map[string]string)
153 for _, filePath := range filesToRead {
154 absPath := filePath
155 if !filepath.IsAbs(absPath) {
156 wd := config.WorkingDirectory()
157 absPath = filepath.Join(wd, absPath)
158 }
159
160 content, err := os.ReadFile(absPath)
161 if err != nil {
162 return ToolResponse{}, fmt.Errorf("failed to read file %s: %w", absPath, err)
163 }
164 currentFiles[filePath] = string(content)
165 }
166
167 // Process the patch
168 patch, fuzz, err := diff.TextToPatch(params.PatchText, currentFiles)
169 if err != nil {
170 return NewTextErrorResponse(fmt.Sprintf("failed to parse patch: %s", err)), nil
171 }
172
173 if fuzz > 3 {
174 return NewTextErrorResponse(fmt.Sprintf("patch contains fuzzy matches (fuzz level: %d). Please make your context lines more precise", fuzz)), nil
175 }
176
177 // Convert patch to commit
178 commit, err := diff.PatchToCommit(patch, currentFiles)
179 if err != nil {
180 return NewTextErrorResponse(fmt.Sprintf("failed to create commit from patch: %s", err)), nil
181 }
182
183 // Get session ID and message ID
184 sessionID, messageID := GetContextValues(ctx)
185 if sessionID == "" || messageID == "" {
186 return ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a patch")
187 }
188
189 // Request permission for all changes
190 for path, change := range commit.Changes {
191 switch change.Type {
192 case diff.ActionAdd:
193 dir := filepath.Dir(path)
194 patchDiff, _, _ := diff.GenerateDiff("", *change.NewContent, path)
195 p := p.permissions.Request(
196 permission.CreatePermissionRequest{
197 SessionID: sessionID,
198 Path: dir,
199 ToolName: PatchToolName,
200 Action: "create",
201 Description: fmt.Sprintf("Create file %s", path),
202 Params: EditPermissionsParams{
203 FilePath: path,
204 Diff: patchDiff,
205 },
206 },
207 )
208 if !p {
209 return ToolResponse{}, permission.ErrorPermissionDenied
210 }
211 case diff.ActionUpdate:
212 currentContent := ""
213 if change.OldContent != nil {
214 currentContent = *change.OldContent
215 }
216 newContent := ""
217 if change.NewContent != nil {
218 newContent = *change.NewContent
219 }
220 patchDiff, _, _ := diff.GenerateDiff(currentContent, newContent, path)
221 dir := filepath.Dir(path)
222 p := p.permissions.Request(
223 permission.CreatePermissionRequest{
224 SessionID: sessionID,
225 Path: dir,
226 ToolName: PatchToolName,
227 Action: "update",
228 Description: fmt.Sprintf("Update file %s", path),
229 Params: EditPermissionsParams{
230 FilePath: path,
231 Diff: patchDiff,
232 },
233 },
234 )
235 if !p {
236 return ToolResponse{}, permission.ErrorPermissionDenied
237 }
238 case diff.ActionDelete:
239 dir := filepath.Dir(path)
240 patchDiff, _, _ := diff.GenerateDiff(*change.OldContent, "", path)
241 p := p.permissions.Request(
242 permission.CreatePermissionRequest{
243 SessionID: sessionID,
244 Path: dir,
245 ToolName: PatchToolName,
246 Action: "delete",
247 Description: fmt.Sprintf("Delete file %s", path),
248 Params: EditPermissionsParams{
249 FilePath: path,
250 Diff: patchDiff,
251 },
252 },
253 )
254 if !p {
255 return ToolResponse{}, permission.ErrorPermissionDenied
256 }
257 }
258 }
259
260 // Apply the changes to the filesystem
261 err = diff.ApplyCommit(commit, func(path string, content string) error {
262 absPath := path
263 if !filepath.IsAbs(absPath) {
264 wd := config.WorkingDirectory()
265 absPath = filepath.Join(wd, absPath)
266 }
267
268 // Create parent directories if needed
269 dir := filepath.Dir(absPath)
270 if err := os.MkdirAll(dir, 0o755); err != nil {
271 return fmt.Errorf("failed to create parent directories for %s: %w", absPath, err)
272 }
273
274 return os.WriteFile(absPath, []byte(content), 0o644)
275 }, func(path string) error {
276 absPath := path
277 if !filepath.IsAbs(absPath) {
278 wd := config.WorkingDirectory()
279 absPath = filepath.Join(wd, absPath)
280 }
281 return os.Remove(absPath)
282 })
283 if err != nil {
284 return NewTextErrorResponse(fmt.Sprintf("failed to apply patch: %s", err)), nil
285 }
286
287 // Update file history for all modified files
288 changedFiles := []string{}
289 totalAdditions := 0
290 totalRemovals := 0
291
292 for path, change := range commit.Changes {
293 absPath := path
294 if !filepath.IsAbs(absPath) {
295 wd := config.WorkingDirectory()
296 absPath = filepath.Join(wd, absPath)
297 }
298 changedFiles = append(changedFiles, absPath)
299
300 oldContent := ""
301 if change.OldContent != nil {
302 oldContent = *change.OldContent
303 }
304
305 newContent := ""
306 if change.NewContent != nil {
307 newContent = *change.NewContent
308 }
309
310 // Calculate diff statistics
311 _, additions, removals := diff.GenerateDiff(oldContent, newContent, path)
312 totalAdditions += additions
313 totalRemovals += removals
314
315 // Update history
316 file, err := p.files.GetByPathAndSession(ctx, absPath, sessionID)
317 if err != nil && change.Type != diff.ActionAdd {
318 // If not adding a file, create history entry for existing file
319 _, err = p.files.Create(ctx, sessionID, absPath, oldContent)
320 if err != nil {
321 logging.Debug("Error creating file history", "error", err)
322 }
323 }
324
325 if err == nil && change.Type != diff.ActionAdd && file.Content != oldContent {
326 // User manually changed content, store intermediate version
327 _, err = p.files.CreateVersion(ctx, sessionID, absPath, oldContent)
328 if err != nil {
329 logging.Debug("Error creating file history version", "error", err)
330 }
331 }
332
333 // Store new version
334 if change.Type == diff.ActionDelete {
335 _, err = p.files.CreateVersion(ctx, sessionID, absPath, "")
336 } else {
337 _, err = p.files.CreateVersion(ctx, sessionID, absPath, newContent)
338 }
339 if err != nil {
340 logging.Debug("Error creating file history version", "error", err)
341 }
342
343 // Record file operations
344 recordFileWrite(absPath)
345 recordFileRead(absPath)
346 }
347
348 // Run LSP diagnostics on all changed files
349 for _, filePath := range changedFiles {
350 waitForLspDiagnostics(ctx, filePath, p.lspClients)
351 }
352
353 result := fmt.Sprintf("Patch applied successfully. %d files changed, %d additions, %d removals",
354 len(changedFiles), totalAdditions, totalRemovals)
355
356 diagnosticsText := ""
357 for _, filePath := range changedFiles {
358 diagnosticsText += getDiagnostics(filePath, p.lspClients)
359 }
360
361 if diagnosticsText != "" {
362 result += "\n\nDiagnostics:\n" + diagnosticsText
363 }
364
365 return WithResponseMetadata(
366 NewTextResponse(result),
367 PatchResponseMetadata{
368 FilesChanged: changedFiles,
369 Additions: totalAdditions,
370 Removals: totalRemovals,
371 }), nil
372}