patch.go

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