mirror of
https://github.com/sst/opencode.git
synced 2025-08-06 14:28:09 +00:00
375 lines
11 KiB
Go
375 lines
11 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"github.com/sst/opencode/internal/config"
|
|
"github.com/sst/opencode/internal/diff"
|
|
"github.com/sst/opencode/internal/history"
|
|
"github.com/sst/opencode/internal/lsp"
|
|
"github.com/sst/opencode/internal/permission"
|
|
"log/slog"
|
|
)
|
|
|
|
type PatchParams struct {
|
|
PatchText string `json:"patch_text"`
|
|
}
|
|
|
|
type PatchResponseMetadata struct {
|
|
FilesChanged []string `json:"files_changed"`
|
|
Additions int `json:"additions"`
|
|
Removals int `json:"removals"`
|
|
}
|
|
|
|
type patchTool struct {
|
|
lspClients map[string]*lsp.Client
|
|
permissions permission.Service
|
|
files history.Service
|
|
}
|
|
|
|
const (
|
|
PatchToolName = "patch"
|
|
patchDescription = `Applies a patch to multiple files in one operation. This tool is useful for making coordinated changes across multiple files.
|
|
|
|
The patch text must follow this format:
|
|
*** Begin Patch
|
|
*** Update File: /path/to/file
|
|
@@ Context line (unique within the file)
|
|
Line to keep
|
|
-Line to remove
|
|
+Line to add
|
|
Line to keep
|
|
*** Add File: /path/to/new/file
|
|
+Content of the new file
|
|
+More content
|
|
*** Delete File: /path/to/file/to/delete
|
|
*** End Patch
|
|
|
|
Before using this tool:
|
|
1. Use the FileRead tool to understand the files' contents and context
|
|
2. Verify all file paths are correct (use the LS tool)
|
|
|
|
CRITICAL REQUIREMENTS FOR USING THIS TOOL:
|
|
|
|
1. UNIQUENESS: Context lines MUST uniquely identify the specific sections you want to change
|
|
2. PRECISION: All whitespace, indentation, and surrounding code must match exactly
|
|
3. VALIDATION: Ensure edits result in idiomatic, correct code
|
|
4. PATHS: Always use absolute file paths (starting with /)
|
|
|
|
The tool will apply all changes in a single atomic operation.`
|
|
)
|
|
|
|
func NewPatchTool(lspClients map[string]*lsp.Client, permissions permission.Service, files history.Service) BaseTool {
|
|
return &patchTool{
|
|
lspClients: lspClients,
|
|
permissions: permissions,
|
|
files: files,
|
|
}
|
|
}
|
|
|
|
func (p *patchTool) Info() ToolInfo {
|
|
return ToolInfo{
|
|
Name: PatchToolName,
|
|
Description: patchDescription,
|
|
Parameters: map[string]any{
|
|
"patch_text": map[string]any{
|
|
"type": "string",
|
|
"description": "The full patch text that describes all changes to be made",
|
|
},
|
|
},
|
|
Required: []string{"patch_text"},
|
|
}
|
|
}
|
|
|
|
func (p *patchTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
|
|
var params PatchParams
|
|
if err := json.Unmarshal([]byte(call.Input), ¶ms); err != nil {
|
|
return NewTextErrorResponse("invalid parameters"), nil
|
|
}
|
|
|
|
if params.PatchText == "" {
|
|
return NewTextErrorResponse("patch_text is required"), nil
|
|
}
|
|
|
|
// Identify all files needed for the patch and verify they've been read
|
|
filesToRead := diff.IdentifyFilesNeeded(params.PatchText)
|
|
for _, filePath := range filesToRead {
|
|
absPath := filePath
|
|
if !filepath.IsAbs(absPath) {
|
|
wd := config.WorkingDirectory()
|
|
absPath = filepath.Join(wd, absPath)
|
|
}
|
|
|
|
if getLastReadTime(absPath).IsZero() {
|
|
return NewTextErrorResponse(fmt.Sprintf("you must read the file %s before patching it. Use the FileRead tool first", filePath)), nil
|
|
}
|
|
|
|
fileInfo, err := os.Stat(absPath)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return NewTextErrorResponse(fmt.Sprintf("file not found: %s", absPath)), nil
|
|
}
|
|
return ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
|
|
}
|
|
|
|
if fileInfo.IsDir() {
|
|
return NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", absPath)), nil
|
|
}
|
|
|
|
modTime := fileInfo.ModTime()
|
|
lastRead := getLastReadTime(absPath)
|
|
if modTime.After(lastRead) {
|
|
return NewTextErrorResponse(
|
|
fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
|
|
absPath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
|
|
)), nil
|
|
}
|
|
}
|
|
|
|
// Check for new files to ensure they don't already exist
|
|
filesToAdd := diff.IdentifyFilesAdded(params.PatchText)
|
|
for _, filePath := range filesToAdd {
|
|
absPath := filePath
|
|
if !filepath.IsAbs(absPath) {
|
|
wd := config.WorkingDirectory()
|
|
absPath = filepath.Join(wd, absPath)
|
|
}
|
|
|
|
_, err := os.Stat(absPath)
|
|
if err == nil {
|
|
return NewTextErrorResponse(fmt.Sprintf("file already exists and cannot be added: %s", absPath)), nil
|
|
} else if !os.IsNotExist(err) {
|
|
return ToolResponse{}, fmt.Errorf("failed to check file: %w", err)
|
|
}
|
|
}
|
|
|
|
// Load all required files
|
|
currentFiles := make(map[string]string)
|
|
for _, filePath := range filesToRead {
|
|
absPath := filePath
|
|
if !filepath.IsAbs(absPath) {
|
|
wd := config.WorkingDirectory()
|
|
absPath = filepath.Join(wd, absPath)
|
|
}
|
|
|
|
content, err := os.ReadFile(absPath)
|
|
if err != nil {
|
|
return ToolResponse{}, fmt.Errorf("failed to read file %s: %w", absPath, err)
|
|
}
|
|
currentFiles[filePath] = string(content)
|
|
}
|
|
|
|
// Process the patch
|
|
patch, fuzz, err := diff.TextToPatch(params.PatchText, currentFiles)
|
|
if err != nil {
|
|
return NewTextErrorResponse(fmt.Sprintf("failed to parse patch: %s", err)), nil
|
|
}
|
|
|
|
if fuzz > 3 {
|
|
return NewTextErrorResponse(fmt.Sprintf("patch contains fuzzy matches (fuzz level: %d). Please make your context lines more precise", fuzz)), nil
|
|
}
|
|
|
|
// Convert patch to commit
|
|
commit, err := diff.PatchToCommit(patch, currentFiles)
|
|
if err != nil {
|
|
return NewTextErrorResponse(fmt.Sprintf("failed to create commit from patch: %s", err)), nil
|
|
}
|
|
|
|
// Get session ID and message ID
|
|
sessionID, messageID := GetContextValues(ctx)
|
|
if sessionID == "" || messageID == "" {
|
|
return ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a patch")
|
|
}
|
|
|
|
// Request permission for all changes
|
|
for path, change := range commit.Changes {
|
|
switch change.Type {
|
|
case diff.ActionAdd:
|
|
dir := filepath.Dir(path)
|
|
patchDiff, _, _ := diff.GenerateDiff("", *change.NewContent, path)
|
|
p := p.permissions.Request(
|
|
ctx,
|
|
permission.CreatePermissionRequest{
|
|
SessionID: sessionID,
|
|
Path: dir,
|
|
ToolName: PatchToolName,
|
|
Action: "create",
|
|
Description: fmt.Sprintf("Create file %s", path),
|
|
Params: EditPermissionsParams{
|
|
FilePath: path,
|
|
Diff: patchDiff,
|
|
},
|
|
},
|
|
)
|
|
if !p {
|
|
return ToolResponse{}, permission.ErrorPermissionDenied
|
|
}
|
|
case diff.ActionUpdate:
|
|
currentContent := ""
|
|
if change.OldContent != nil {
|
|
currentContent = *change.OldContent
|
|
}
|
|
newContent := ""
|
|
if change.NewContent != nil {
|
|
newContent = *change.NewContent
|
|
}
|
|
patchDiff, _, _ := diff.GenerateDiff(currentContent, newContent, path)
|
|
dir := filepath.Dir(path)
|
|
p := p.permissions.Request(
|
|
ctx,
|
|
permission.CreatePermissionRequest{
|
|
SessionID: sessionID,
|
|
Path: dir,
|
|
ToolName: PatchToolName,
|
|
Action: "update",
|
|
Description: fmt.Sprintf("Update file %s", path),
|
|
Params: EditPermissionsParams{
|
|
FilePath: path,
|
|
Diff: patchDiff,
|
|
},
|
|
},
|
|
)
|
|
if !p {
|
|
return ToolResponse{}, permission.ErrorPermissionDenied
|
|
}
|
|
case diff.ActionDelete:
|
|
dir := filepath.Dir(path)
|
|
patchDiff, _, _ := diff.GenerateDiff(*change.OldContent, "", path)
|
|
p := p.permissions.Request(
|
|
ctx,
|
|
permission.CreatePermissionRequest{
|
|
SessionID: sessionID,
|
|
Path: dir,
|
|
ToolName: PatchToolName,
|
|
Action: "delete",
|
|
Description: fmt.Sprintf("Delete file %s", path),
|
|
Params: EditPermissionsParams{
|
|
FilePath: path,
|
|
Diff: patchDiff,
|
|
},
|
|
},
|
|
)
|
|
if !p {
|
|
return ToolResponse{}, permission.ErrorPermissionDenied
|
|
}
|
|
}
|
|
}
|
|
|
|
// Apply the changes to the filesystem
|
|
err = diff.ApplyCommit(commit, func(path string, content string) error {
|
|
absPath := path
|
|
if !filepath.IsAbs(absPath) {
|
|
wd := config.WorkingDirectory()
|
|
absPath = filepath.Join(wd, absPath)
|
|
}
|
|
|
|
// Create parent directories if needed
|
|
dir := filepath.Dir(absPath)
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return fmt.Errorf("failed to create parent directories for %s: %w", absPath, err)
|
|
}
|
|
|
|
return os.WriteFile(absPath, []byte(content), 0o644)
|
|
}, func(path string) error {
|
|
absPath := path
|
|
if !filepath.IsAbs(absPath) {
|
|
wd := config.WorkingDirectory()
|
|
absPath = filepath.Join(wd, absPath)
|
|
}
|
|
return os.Remove(absPath)
|
|
})
|
|
if err != nil {
|
|
return NewTextErrorResponse(fmt.Sprintf("failed to apply patch: %s", err)), nil
|
|
}
|
|
|
|
// Update file history for all modified files
|
|
changedFiles := []string{}
|
|
totalAdditions := 0
|
|
totalRemovals := 0
|
|
|
|
for path, change := range commit.Changes {
|
|
absPath := path
|
|
if !filepath.IsAbs(absPath) {
|
|
wd := config.WorkingDirectory()
|
|
absPath = filepath.Join(wd, absPath)
|
|
}
|
|
changedFiles = append(changedFiles, absPath)
|
|
|
|
oldContent := ""
|
|
if change.OldContent != nil {
|
|
oldContent = *change.OldContent
|
|
}
|
|
|
|
newContent := ""
|
|
if change.NewContent != nil {
|
|
newContent = *change.NewContent
|
|
}
|
|
|
|
// Calculate diff statistics
|
|
_, additions, removals := diff.GenerateDiff(oldContent, newContent, path)
|
|
totalAdditions += additions
|
|
totalRemovals += removals
|
|
|
|
// Update history
|
|
file, err := p.files.GetLatestByPathAndSession(ctx, absPath, sessionID)
|
|
if err != nil && change.Type != diff.ActionAdd {
|
|
// If not adding a file, create history entry for existing file
|
|
_, err = p.files.Create(ctx, sessionID, absPath, oldContent)
|
|
if err != nil {
|
|
slog.Debug("Error creating file history", "error", err)
|
|
}
|
|
}
|
|
|
|
if err == nil && change.Type != diff.ActionAdd && file.Content != oldContent {
|
|
// User manually changed content, store intermediate version
|
|
_, err = p.files.CreateVersion(ctx, sessionID, absPath, oldContent)
|
|
if err != nil {
|
|
slog.Debug("Error creating file history version", "error", err)
|
|
}
|
|
}
|
|
|
|
// Store new version
|
|
if change.Type == diff.ActionDelete {
|
|
_, err = p.files.CreateVersion(ctx, sessionID, absPath, "")
|
|
} else {
|
|
_, err = p.files.CreateVersion(ctx, sessionID, absPath, newContent)
|
|
}
|
|
if err != nil {
|
|
slog.Debug("Error creating file history version", "error", err)
|
|
}
|
|
|
|
// Record file operations
|
|
recordFileWrite(absPath)
|
|
recordFileRead(absPath)
|
|
}
|
|
|
|
// Run LSP diagnostics on all changed files
|
|
for _, filePath := range changedFiles {
|
|
waitForLspDiagnostics(ctx, filePath, p.lspClients)
|
|
}
|
|
|
|
result := fmt.Sprintf("Patch applied successfully. %d files changed, %d additions, %d removals",
|
|
len(changedFiles), totalAdditions, totalRemovals)
|
|
|
|
diagnosticsText := ""
|
|
for _, filePath := range changedFiles {
|
|
diagnosticsText += getDiagnostics(filePath, p.lspClients)
|
|
}
|
|
|
|
if diagnosticsText != "" {
|
|
result += "\n\nDiagnostics:\n" + diagnosticsText
|
|
}
|
|
|
|
return WithResponseMetadata(
|
|
NewTextResponse(result),
|
|
PatchResponseMetadata{
|
|
FilesChanged: changedFiles,
|
|
Additions: totalAdditions,
|
|
Removals: totalRemovals,
|
|
}), nil
|
|
}
|