patch.go 11 KB

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