patch.go

  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), &params); 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}