handleMultipatch is the engine entry-point for transactional multi-file edits. Contract: 1. The --edits flag carries a JSON array of MultipatchEdit objects. 2. Phase 1 (validate): every edit's path passes validatePath, the file exists, and the search string is found in the content the edit would se
(args []string)
| 76 | // that, a process kill mid-write leaves a partial multipatch — the |
| 77 | // agent can use @coder rollback to recover from individual .bak files. |
| 78 | func (e *Engine) handleMultipatch(args []string) error { |
| 79 | fs := flag.NewFlagSet("multipatch", flag.ContinueOnError) |
| 80 | editsJSON := fs.String("edits", "", "JSON array of {file,search,replace,encoding?}") |
| 81 | if err := parseFlags(fs, args); err != nil { |
| 82 | return err |
| 83 | } |
| 84 | if strings.TrimSpace(*editsJSON) == "" { |
| 85 | return errors.New("--edits required") |
| 86 | } |
| 87 | |
| 88 | var edits []MultipatchEdit |
| 89 | if err := json.Unmarshal([]byte(*editsJSON), &edits); err != nil { |
| 90 | return fmt.Errorf("invalid edits JSON: %w", err) |
| 91 | } |
| 92 | if len(edits) == 0 { |
| 93 | return errors.New("edits array cannot be empty") |
| 94 | } |
| 95 | |
| 96 | // Phase 0: normalize and group by absolute path. Keep declaration |
| 97 | // order within a file because later edits may depend on earlier |
| 98 | // edits' replacements. |
| 99 | type stagedEdit struct { |
| 100 | decl int // 1-indexed declaration position for error reporting |
| 101 | edit MultipatchEdit |
| 102 | search string // decoded search text |
| 103 | replace string |
| 104 | } |
| 105 | groups := make(map[string][]stagedEdit) |
| 106 | groupKeys := make([]string, 0) |
| 107 | |
| 108 | for i, ed := range edits { |
| 109 | idx := i + 1 |
| 110 | if ed.File == "" { |
| 111 | return fmt.Errorf("edit #%d: file is required", idx) |
| 112 | } |
| 113 | if ed.Search == "" { |
| 114 | return fmt.Errorf("edit #%d: search is required (use --diff if you have a unified diff)", idx) |
| 115 | } |
| 116 | |
| 117 | enc := ed.Encoding |
| 118 | if enc == "" { |
| 119 | enc = "text" |
| 120 | } |
| 121 | sBytes, err := smartDecode(ed.Search, enc) |
| 122 | if err != nil { |
| 123 | return fmt.Errorf("edit #%d: search decode failed: %w", idx, err) |
| 124 | } |
| 125 | rBytes, err := smartDecode(ed.Replace, enc) |
| 126 | if err != nil { |
| 127 | return fmt.Errorf("edit #%d: replace decode failed: %w", idx, err) |
| 128 | } |
| 129 | |
| 130 | // ed.File arrives inside the --edits JSON, so it bypasses the |
| 131 | // path-flag expansion in parseFlags; expand it here too or a |
| 132 | // "~/x" / "$CHATCLI_AGENT_TMPDIR/x" edit target would resolve |
| 133 | // to a literal directory under the cwd. |
| 134 | abs, err := filepath.Abs(expandPath(ed.File)) |
| 135 | if err != nil { |
no test coverage detected