patch.go 10 KB

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