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"
19type PatchParams struct {
20 PatchText string `json:"patch_text"`
23type PatchResponseMetadata struct {
24 FilesChanged []string `json:"files_changed"`
25 Additions int `json:"additions"`
26 Removals int `json:"removals"`
29type patchTool struct {
30 lspClients map[string]*lsp.Client
31 permissions permission.Service
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.
39The patch text must follow this format:
41*** Update File: /path/to/file
42@@ Context line (unique within the file)
47*** Add File: /path/to/new/file
48+Content of the new file
50*** Delete File: /path/to/file/to/delete
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)
57CRITICAL REQUIREMENTS FOR USING THIS TOOL:
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 /)
64The tool will apply all changes in a single atomic operation.`
67func NewPatchTool(lspClients map[string]*lsp.Client, permissions permission.Service, files history.Service) BaseTool {
69 lspClients: lspClients,
70 permissions: permissions,
75func (p *patchTool) Info() ToolInfo {
78 Description: patchDescription,
79 Parameters: map[string]any{
80 "patch_text": map[string]any{
82 "description": "The full patch text that describes all changes to be made",
85 Required: []string{"patch_text"},
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
95 if params.PatchText == "" {
96 return NewTextErrorResponse("patch_text is required"), nil
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 {
103 if !filepath.IsAbs(absPath) {
104 wd := config.WorkingDirectory()
105 absPath = filepath.Join(wd, absPath)
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
112 fileInfo, err := os.Stat(absPath)
114 if os.IsNotExist(err) {
115 return NewTextErrorResponse(fmt.Sprintf("file not found: %s", absPath)), nil
117 return ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
120 if fileInfo.IsDir() {
121 return NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", absPath)), nil
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),
134 // Check for new files to ensure they don't already exist
135 filesToAdd := diff.IdentifyFilesAdded(params.PatchText)
136 for _, filePath := range filesToAdd {
138 if !filepath.IsAbs(absPath) {
139 wd := config.WorkingDirectory()
140 absPath = filepath.Join(wd, absPath)
143 _, err := os.Stat(absPath)
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)
151 // Load all required files
152 currentFiles := make(map[string]string)
153 for _, filePath := range filesToRead {
155 if !filepath.IsAbs(absPath) {
156 wd := config.WorkingDirectory()
157 absPath = filepath.Join(wd, absPath)
160 content, err := os.ReadFile(absPath)
162 return ToolResponse{}, fmt.Errorf("failed to read file %s: %w", absPath, err)
164 currentFiles[filePath] = string(content)
168 patch, fuzz, err := diff.TextToPatch(params.PatchText, currentFiles)
170 return NewTextErrorResponse(fmt.Sprintf("failed to parse patch: %s", err)), nil
174 return NewTextErrorResponse(fmt.Sprintf("patch contains fuzzy matches (fuzz level: %d). Please make your context lines more precise", fuzz)), nil
177 // Convert patch to commit
178 commit, err := diff.PatchToCommit(patch, currentFiles)
180 return NewTextErrorResponse(fmt.Sprintf("failed to create commit from patch: %s", err)), nil
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")
189 // Request permission for all changes
190 for path, change := range commit.Changes {
193 dir := filepath.Dir(path)
194 patchDiff, _, _ := diff.GenerateDiff("", *change.NewContent, path)
195 p := p.permissions.Request(
196 permission.CreatePermissionRequest{
197 SessionID: sessionID,
199 ToolName: PatchToolName,
201 Description: fmt.Sprintf("Create file %s", path),
202 Params: EditPermissionsParams{
209 return ToolResponse{}, permission.ErrorPermissionDenied
211 case diff.ActionUpdate:
213 if change.OldContent != nil {
214 currentContent = *change.OldContent
217 if change.NewContent != nil {
218 newContent = *change.NewContent
220 patchDiff, _, _ := diff.GenerateDiff(currentContent, newContent, path)
221 dir := filepath.Dir(path)
222 p := p.permissions.Request(
223 permission.CreatePermissionRequest{
224 SessionID: sessionID,
226 ToolName: PatchToolName,
228 Description: fmt.Sprintf("Update file %s", path),
229 Params: EditPermissionsParams{
236 return ToolResponse{}, permission.ErrorPermissionDenied
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,
245 ToolName: PatchToolName,
247 Description: fmt.Sprintf("Delete file %s", path),
248 Params: EditPermissionsParams{
255 return ToolResponse{}, permission.ErrorPermissionDenied
260 // Apply the changes to the filesystem
261 err = diff.ApplyCommit(commit, func(path string, content string) error {
263 if !filepath.IsAbs(absPath) {
264 wd := config.WorkingDirectory()
265 absPath = filepath.Join(wd, absPath)
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)
274 return os.WriteFile(absPath, []byte(content), 0o644)
275 }, func(path string) error {
277 if !filepath.IsAbs(absPath) {
278 wd := config.WorkingDirectory()
279 absPath = filepath.Join(wd, absPath)
281 return os.Remove(absPath)
284 return NewTextErrorResponse(fmt.Sprintf("failed to apply patch: %s", err)), nil
287 // Update file history for all modified files
288 changedFiles := []string{}
292 for path, change := range commit.Changes {
294 if !filepath.IsAbs(absPath) {
295 wd := config.WorkingDirectory()
296 absPath = filepath.Join(wd, absPath)
298 changedFiles = append(changedFiles, absPath)
301 if change.OldContent != nil {
302 oldContent = *change.OldContent
306 if change.NewContent != nil {
307 newContent = *change.NewContent
310 // Calculate diff statistics
311 _, additions, removals := diff.GenerateDiff(oldContent, newContent, path)
312 totalAdditions += additions
313 totalRemovals += removals
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)
321 logging.Debug("Error creating file history", "error", err)
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)
329 logging.Debug("Error creating file history version", "error", err)
334 if change.Type == diff.ActionDelete {
335 _, err = p.files.CreateVersion(ctx, sessionID, absPath, "")
337 _, err = p.files.CreateVersion(ctx, sessionID, absPath, newContent)
340 logging.Debug("Error creating file history version", "error", err)
343 // Record file operations
344 recordFileWrite(absPath)
345 recordFileRead(absPath)
348 // Run LSP diagnostics on all changed files
349 for _, filePath := range changedFiles {
350 waitForLspDiagnostics(ctx, filePath, p.lspClients)
353 result := fmt.Sprintf("Patch applied successfully. %d files changed, %d additions, %d removals",
354 len(changedFiles), totalAdditions, totalRemovals)
356 diagnosticsText := ""
357 for _, filePath := range changedFiles {
358 diagnosticsText += getDiagnostics(filePath, p.lspClients)
361 if diagnosticsText != "" {
362 result += "\n\nDiagnostics:\n" + diagnosticsText
365 return WithResponseMetadata(
366 NewTextResponse(result),
367 PatchResponseMetadata{
368 FilesChanged: changedFiles,
369 Additions: totalAdditions,
370 Removals: totalRemovals,
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list