@heyhuynhgiabuu/pi-diff 0.8.2 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,170 +2,534 @@
2
2
  * apply_patch — Multi-file patch engine.
3
3
  *
4
4
  * One call can add, update, delete, or move multiple files.
5
- * Uses replace.ts's conservative matcher for oldText newText matching.
5
+ * Updates use a conservative matcher and are committed only after every
6
+ * change has been prepared successfully.
6
7
  */
8
+ import { isUtf8 } from "node:buffer";
7
9
  import * as fs from "node:fs";
8
10
  import * as path from "node:path";
9
- import { replaceForPatch } from "./replace.js";
11
+ import { structuredPatch } from "diff";
12
+ import { findPatchReplacement } from "./replace.js";
13
+ import { detectLineEnding, normalizeForLineEnding, restoreLineEndings, stripBom } from "./text-encoding.js";
14
+ function isRecord(value) {
15
+ return typeof value === "object" && value !== null && !Array.isArray(value);
16
+ }
17
+ function requiredString(value, field, context) {
18
+ if (typeof value !== "string" || value.length === 0)
19
+ throw new Error(`${context}.${field} must be a non-empty string`);
20
+ return value;
21
+ }
22
+ function requiredText(value, field, context) {
23
+ if (typeof value !== "string")
24
+ throw new Error(`${context}.${field} must be a string`);
25
+ return value;
26
+ }
27
+ function rejectUnknownKeys(value, allowed, context) {
28
+ const allowedSet = new Set(allowed);
29
+ for (const key of Object.keys(value)) {
30
+ if (!allowedSet.has(key))
31
+ throw new Error(`${context}.${key} is not supported`);
32
+ }
33
+ }
34
+ function parseUpdateEdits(value, context) {
35
+ if (!Array.isArray(value) || value.length === 0)
36
+ throw new Error(`${context}.edits must contain at least one replacement`);
37
+ return value.map((entry, index) => {
38
+ const editContext = `${context}.edits[${index}]`;
39
+ if (!isRecord(entry))
40
+ throw new Error(`${editContext} must be an object`);
41
+ rejectUnknownKeys(entry, ["oldText", "newText"], editContext);
42
+ return {
43
+ oldText: requiredString(entry.oldText, "oldText", editContext),
44
+ newText: requiredText(entry.newText, "newText", editContext),
45
+ };
46
+ });
47
+ }
48
+ /** Decode the model-facing tool payload before it reaches the mutation core. */
49
+ export function parseApplyPatchInput(input) {
50
+ if (!isRecord(input))
51
+ throw new Error("apply_patch input must be an object");
52
+ rejectUnknownKeys(input, ["changes"], "apply_patch");
53
+ if (!Array.isArray(input.changes) || input.changes.length === 0) {
54
+ throw new Error("apply_patch.changes must contain at least one change");
55
+ }
56
+ return input.changes.map((entry, index) => {
57
+ const context = `apply_patch.changes[${index}]`;
58
+ if (!isRecord(entry))
59
+ throw new Error(`${context} must be an object`);
60
+ const pathValue = requiredString(entry.path, "path", context);
61
+ const action = requiredString(entry.action, "action", context);
62
+ switch (action) {
63
+ case "add":
64
+ rejectUnknownKeys(entry, ["path", "action", "content"], context);
65
+ return { path: pathValue, action, content: requiredText(entry.content, "content", context) };
66
+ case "update": {
67
+ rejectUnknownKeys(entry, ["path", "action", "oldText", "newText", "edits"], context);
68
+ if (entry.edits !== undefined) {
69
+ if (entry.oldText !== undefined || entry.newText !== undefined) {
70
+ throw new Error(`${context} cannot combine edits with oldText/newText`);
71
+ }
72
+ return { path: pathValue, action, edits: parseUpdateEdits(entry.edits, context) };
73
+ }
74
+ return {
75
+ path: pathValue,
76
+ action,
77
+ oldText: requiredString(entry.oldText, "oldText", context),
78
+ newText: entry.newText === undefined ? undefined : requiredText(entry.newText, "newText", context),
79
+ };
80
+ }
81
+ case "delete":
82
+ rejectUnknownKeys(entry, ["path", "action"], context);
83
+ return { path: pathValue, action };
84
+ case "move":
85
+ rejectUnknownKeys(entry, ["path", "action", "movePath"], context);
86
+ return { path: pathValue, action, movePath: requiredString(entry.movePath, "movePath", context) };
87
+ default:
88
+ throw new Error(`${context}.action must be add, update, delete, or move`);
89
+ }
90
+ });
91
+ }
92
+ const pathLocks = new Map();
10
93
  // ---------------------------------------------------------------------------
11
- // Atomic file write
94
+ // Path and filesystem safety
12
95
  // ---------------------------------------------------------------------------
13
- async function atomicWriteFile(filePath, content, mode) {
14
- const dir = path.dirname(filePath);
15
- const tmp = path.join(dir, `.${path.basename(filePath)}.pi-apply-patch.${process.pid}.tmp`);
16
- await fs.promises.writeFile(tmp, content, { encoding: "utf8", mode });
17
- await fs.promises.rename(tmp, filePath);
96
+ function isMissingPathError(error) {
97
+ return (typeof error === "object" &&
98
+ error !== null &&
99
+ "code" in error &&
100
+ (error.code === "ENOENT" || error.code === "ENOTDIR"));
18
101
  }
19
- async function readRegularFile(filePath, label) {
20
- let stats;
102
+ function formatFilesystemError(error) {
103
+ if (error instanceof Error) {
104
+ const code = "code" in error && typeof error.code === "string" ? `${error.code}: ` : "";
105
+ return `${code}${error.message}`;
106
+ }
107
+ return String(error);
108
+ }
109
+ function isWithin(root, candidate) {
110
+ const relative = path.relative(root, candidate);
111
+ return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
112
+ }
113
+ async function assertSafeAncestors(filePath, root, label) {
114
+ const relative = path.relative(root, filePath);
115
+ if (!isWithin(root, filePath))
116
+ throw new Error(`${label} must stay within workspace root: ${filePath}`);
117
+ if (relative === "")
118
+ return;
119
+ let current = root;
120
+ const segments = relative.split(path.sep).filter(Boolean);
121
+ for (let index = 0; index < segments.length; index++) {
122
+ current = path.join(current, segments[index]);
123
+ let stats;
124
+ try {
125
+ stats = await fs.promises.lstat(current);
126
+ }
127
+ catch (error) {
128
+ if (isMissingPathError(error))
129
+ return;
130
+ throw new Error(`${label} could not be inspected: ${formatFilesystemError(error)}`);
131
+ }
132
+ if (stats.isSymbolicLink())
133
+ throw new Error(`${label} must not traverse a symbolic link: ${filePath}`);
134
+ if (index < segments.length - 1 && !stats.isDirectory()) {
135
+ throw new Error(`${label} parent must be a directory: ${filePath}`);
136
+ }
137
+ }
138
+ }
139
+ async function createPathPolicy(options) {
140
+ const root = path.resolve(options.root ?? options.cwd ?? process.cwd());
141
+ const cwd = path.resolve(options.cwd ?? root);
21
142
  try {
22
- stats = await fs.promises.lstat(filePath);
143
+ const rootStats = await fs.promises.stat(root);
144
+ const cwdStats = await fs.promises.stat(cwd);
145
+ if (!rootStats.isDirectory())
146
+ throw new Error(`${root} is not a directory`);
147
+ if (!cwdStats.isDirectory())
148
+ throw new Error(`${cwd} is not a directory`);
23
149
  }
24
- catch {
25
- throw new Error(`${label} not found: ${filePath}`);
150
+ catch (error) {
151
+ throw new Error(`workspace could not be resolved: ${formatFilesystemError(error)}`);
152
+ }
153
+ if (!isWithin(root, cwd))
154
+ throw new Error(`cwd must stay within workspace root: ${cwd}`);
155
+ return {
156
+ async resolve(filePath, label) {
157
+ if (typeof filePath !== "string" || filePath.length === 0)
158
+ throw new Error(`${label} path is required`);
159
+ const resolved = path.resolve(cwd, filePath);
160
+ if (!isWithin(root, resolved))
161
+ throw new Error(`${label} must stay within workspace root: ${filePath}`);
162
+ await assertSafeAncestors(resolved, root, label);
163
+ return resolved;
164
+ },
165
+ };
166
+ }
167
+ async function lstatIfExists(filePath) {
168
+ try {
169
+ return await fs.promises.lstat(filePath);
26
170
  }
27
- if (stats.isSymbolicLink())
171
+ catch (error) {
172
+ if (isMissingPathError(error))
173
+ return undefined;
174
+ throw error;
175
+ }
176
+ }
177
+ async function readRegularFile(filePath, label) {
178
+ const initialStats = await lstatIfExists(filePath);
179
+ if (!initialStats)
180
+ throw new Error(`${label} not found: ${filePath}`);
181
+ if (initialStats.isSymbolicLink())
28
182
  throw new Error(`${label} must not be a symbolic link: ${filePath}`);
29
- if (!stats.isFile())
183
+ if (!initialStats.isFile())
30
184
  throw new Error(`${label} must be a regular file: ${filePath}`);
31
- return { content: await fs.promises.readFile(filePath, "utf8"), mode: stats.mode };
185
+ const noFollow = fs.constants.O_NOFOLLOW ?? 0;
186
+ let handle;
187
+ try {
188
+ handle = await fs.promises.open(filePath, fs.constants.O_RDONLY | noFollow);
189
+ const stats = await handle.stat();
190
+ if (!stats.isFile())
191
+ throw new Error(`${label} must be a regular file: ${filePath}`);
192
+ const bytes = await handle.readFile();
193
+ if (!isUtf8(bytes))
194
+ throw new Error(`${label} must contain valid UTF-8 text: ${filePath}`);
195
+ const rawContent = bytes.toString("utf8");
196
+ return {
197
+ bytes,
198
+ rawContent,
199
+ mode: stats.mode & 0o7777,
200
+ dev: stats.dev,
201
+ ino: stats.ino,
202
+ mtimeMs: stats.mtimeMs,
203
+ size: stats.size,
204
+ };
205
+ }
206
+ catch (error) {
207
+ if (error instanceof Error && error.message.includes("valid UTF-8"))
208
+ throw error;
209
+ throw new Error(`${label} could not be read: ${formatFilesystemError(error)}`);
210
+ }
211
+ finally {
212
+ await handle?.close();
213
+ }
214
+ }
215
+ async function assertUnchanged(filePath, original, label) {
216
+ const current = await readRegularFile(filePath, label);
217
+ if (current.dev !== original.dev ||
218
+ current.ino !== original.ino ||
219
+ current.size !== original.size ||
220
+ current.mtimeMs !== original.mtimeMs ||
221
+ !current.bytes.equals(original.bytes)) {
222
+ throw new Error(`${label} changed while patch was being prepared: ${filePath}`);
223
+ }
224
+ }
225
+ async function createParentDirectories(directory) {
226
+ const missing = [];
227
+ let current = directory;
228
+ while (true) {
229
+ const stats = await lstatIfExists(current);
230
+ if (stats) {
231
+ if (!stats.isDirectory())
232
+ throw new Error(`parent path is not a directory: ${current}`);
233
+ break;
234
+ }
235
+ missing.push(current);
236
+ const parent = path.dirname(current);
237
+ if (parent === current)
238
+ throw new Error(`could not find a directory for: ${directory}`);
239
+ current = parent;
240
+ }
241
+ try {
242
+ await fs.promises.mkdir(directory, { recursive: true });
243
+ }
244
+ catch (error) {
245
+ await removeCreatedDirectories(missing).catch(() => undefined);
246
+ throw error;
247
+ }
248
+ return missing;
32
249
  }
33
- async function pathExists(filePath) {
250
+ async function removeCreatedDirectories(directories) {
251
+ for (const directory of directories) {
252
+ try {
253
+ await fs.promises.rmdir(directory);
254
+ }
255
+ catch (error) {
256
+ if (isMissingPathError(error) ||
257
+ (typeof error === "object" && error !== null && "code" in error && error.code === "ENOTEMPTY")) {
258
+ continue;
259
+ }
260
+ throw error;
261
+ }
262
+ }
263
+ }
264
+ async function atomicWriteFile(filePath, content, mode, replace = true) {
265
+ const directory = path.dirname(filePath);
266
+ const temporaryDirectory = await fs.promises.mkdtemp(path.join(directory, `.pi-apply-patch-${process.pid}-`));
267
+ const temporaryPath = path.join(temporaryDirectory, "content");
268
+ const desiredMode = mode ?? 0o666 & ~process.umask();
269
+ let handle;
34
270
  try {
35
- await fs.promises.lstat(filePath);
36
- return true;
271
+ handle = await fs.promises.open(temporaryPath, "wx", 0o600);
272
+ await handle.writeFile(content);
273
+ await handle.chmod(desiredMode);
274
+ await handle.sync();
275
+ await handle.close();
276
+ handle = undefined;
277
+ if (replace) {
278
+ await fs.promises.rename(temporaryPath, filePath);
279
+ }
280
+ else {
281
+ await fs.promises.link(temporaryPath, filePath);
282
+ await fs.promises.unlink(temporaryPath);
283
+ }
37
284
  }
38
- catch {
39
- return false;
285
+ finally {
286
+ await handle?.close().catch(() => undefined);
287
+ await fs.promises.rm(temporaryDirectory, { recursive: true, force: true });
40
288
  }
41
289
  }
42
- async function prepareAdd(change) {
43
- if (await pathExists(change.path))
290
+ async function withPathLocks(keys, operation) {
291
+ const locks = [];
292
+ for (const key of [...new Set(keys)].sort()) {
293
+ const previous = pathLocks.get(key) ?? Promise.resolve();
294
+ let release;
295
+ const gate = new Promise((resolve) => {
296
+ release = resolve;
297
+ });
298
+ const queued = previous.then(() => gate);
299
+ pathLocks.set(key, queued);
300
+ locks.push({ key, previous, queued, release });
301
+ }
302
+ await Promise.all(locks.map((lock) => lock.previous));
303
+ try {
304
+ return await operation();
305
+ }
306
+ finally {
307
+ for (const lock of locks)
308
+ lock.release();
309
+ for (const lock of locks) {
310
+ if (pathLocks.get(lock.key) === lock.queued)
311
+ pathLocks.delete(lock.key);
312
+ }
313
+ }
314
+ }
315
+ // ---------------------------------------------------------------------------
316
+ // Preparation
317
+ // ---------------------------------------------------------------------------
318
+ async function prepareAdd(resolved) {
319
+ const { change, path: targetPath } = resolved;
320
+ if (await lstatIfExists(targetPath))
44
321
  throw new Error(`add target already exists: ${change.path}`);
45
322
  const content = change.content ?? "";
46
323
  const final = content.endsWith("\n") ? content : `${content}\n`;
324
+ let createdDirectories = [];
47
325
  return {
48
326
  change,
49
327
  applied: { path: change.path, action: "add", bytes: Buffer.byteLength(final, "utf8"), newContent: final },
50
328
  async commit() {
51
- await fs.promises.mkdir(path.dirname(change.path), { recursive: true });
52
- await atomicWriteFile(change.path, final);
329
+ createdDirectories = await createParentDirectories(path.dirname(targetPath));
330
+ try {
331
+ if (await lstatIfExists(targetPath))
332
+ throw new Error(`add target appeared during patch: ${change.path}`);
333
+ await atomicWriteFile(targetPath, final, undefined, false);
334
+ }
335
+ catch (error) {
336
+ await removeCreatedDirectories(createdDirectories).catch(() => undefined);
337
+ throw error;
338
+ }
53
339
  },
54
340
  async rollback() {
55
- await fs.promises.unlink(change.path);
341
+ await fs.promises.unlink(targetPath);
342
+ await removeCreatedDirectories(createdDirectories);
56
343
  },
57
344
  };
58
345
  }
59
- async function prepareUpdate(change) {
60
- if (!change.oldText)
346
+ function getUpdateEdits(change) {
347
+ if (change.edits !== undefined) {
348
+ if (change.oldText !== undefined || change.newText !== undefined) {
349
+ throw new Error("update cannot combine edits with oldText/newText");
350
+ }
351
+ if (!Array.isArray(change.edits) || change.edits.length === 0)
352
+ throw new Error("update requires edits");
353
+ return change.edits;
354
+ }
355
+ if (typeof change.oldText !== "string" || change.oldText.length === 0)
61
356
  throw new Error("update requires oldText");
62
- if (change.oldText === change.newText)
63
- throw new Error("oldText and newText are identical — no change");
64
- const original = await readRegularFile(change.path, "update target");
65
- const result = replaceForPatch(original.content, change.oldText, change.newText ?? "");
66
- if (!result.changed)
67
- throw new Error(`oldText not found in ${change.path}`);
68
- const newContent = result.content;
357
+ return [{ oldText: change.oldText, newText: change.newText ?? "" }];
358
+ }
359
+ function applyUpdateEdits(content, edits, filePath) {
360
+ const replacements = edits.map((edit, index) => {
361
+ if (!edit || typeof edit.oldText !== "string" || typeof edit.newText !== "string") {
362
+ throw new Error(`edit ${index + 1} in ${filePath} is invalid`);
363
+ }
364
+ if (edit.oldText.length === 0)
365
+ throw new Error(`edit ${index + 1} in ${filePath} requires oldText`);
366
+ if (edit.oldText === edit.newText)
367
+ throw new Error("oldText and newText are identical — no change");
368
+ const match = findPatchReplacement(content, edit.oldText, edit.newText);
369
+ if (!match)
370
+ throw new Error(`oldText not found in ${filePath}`);
371
+ return match;
372
+ });
373
+ const ordered = [...replacements].sort((a, b) => a.start - b.start);
374
+ for (let index = 1; index < ordered.length; index++) {
375
+ if (ordered[index - 1].end > ordered[index].start)
376
+ throw new Error(`update edits overlap in ${filePath}`);
377
+ }
378
+ let result = content;
379
+ for (const replacement of [...ordered].reverse()) {
380
+ result = result.slice(0, replacement.start) + replacement.replacement + result.slice(replacement.end);
381
+ }
382
+ return result;
383
+ }
384
+ async function prepareUpdate(resolved) {
385
+ const { change, path: targetPath } = resolved;
386
+ const original = await readRegularFile(targetPath, "update target");
387
+ const { bom, text } = stripBom(original.rawContent);
388
+ const ending = detectLineEnding(text);
389
+ const normalizedContent = normalizeForLineEnding(text, ending);
390
+ const normalizedEdits = getUpdateEdits(change).map((edit) => ({
391
+ oldText: normalizeForLineEnding(edit.oldText, ending),
392
+ newText: normalizeForLineEnding(edit.newText, ending),
393
+ }));
394
+ const normalizedNewContent = applyUpdateEdits(normalizedContent, normalizedEdits, change.path);
395
+ const newContent = bom + restoreLineEndings(normalizedNewContent, ending);
69
396
  return {
70
397
  change,
71
398
  applied: {
72
399
  path: change.path,
73
400
  action: "update",
74
- diff: generateDiff(change.path, original.content, newContent),
401
+ diff: generateDiff(change.path, original.rawContent, newContent),
75
402
  bytes: Buffer.byteLength(newContent, "utf8"),
76
- oldContent: original.content,
403
+ oldContent: original.rawContent,
77
404
  newContent,
78
405
  },
79
406
  async commit() {
80
- await atomicWriteFile(change.path, newContent, original.mode);
407
+ await assertUnchanged(targetPath, original, "update target");
408
+ await atomicWriteFile(targetPath, Buffer.from(newContent, "utf8"), original.mode);
81
409
  },
82
410
  async rollback() {
83
- await atomicWriteFile(change.path, original.content, original.mode);
411
+ await atomicWriteFile(targetPath, original.bytes, original.mode);
84
412
  },
85
413
  };
86
414
  }
87
- async function prepareDelete(change) {
88
- const original = await readRegularFile(change.path, "delete target");
415
+ async function prepareDelete(resolved) {
416
+ const { change, path: targetPath } = resolved;
417
+ const original = await readRegularFile(targetPath, "delete target");
89
418
  return {
90
419
  change,
91
- applied: { path: change.path, action: "delete", oldContent: original.content },
420
+ applied: { path: change.path, action: "delete", oldContent: original.rawContent },
92
421
  async commit() {
93
- await fs.promises.unlink(change.path);
422
+ await assertUnchanged(targetPath, original, "delete target");
423
+ await fs.promises.unlink(targetPath);
94
424
  },
95
425
  async rollback() {
96
- await atomicWriteFile(change.path, original.content, original.mode);
426
+ await atomicWriteFile(targetPath, original.bytes, original.mode, false);
97
427
  },
98
428
  };
99
429
  }
100
- async function prepareMove(change) {
101
- const movePath = change.movePath;
102
- if (!movePath)
430
+ async function prepareMove(resolved) {
431
+ const { change, path: sourcePath, movePath: destinationPath } = resolved;
432
+ if (!destinationPath)
103
433
  throw new Error("move requires movePath");
104
- await readRegularFile(change.path, "move source");
105
- if (await pathExists(movePath))
106
- throw new Error(`move destination already exists: ${movePath}`);
434
+ const original = await readRegularFile(sourcePath, "move source");
435
+ if (await lstatIfExists(destinationPath))
436
+ throw new Error(`move destination already exists: ${change.movePath}`);
437
+ let createdDirectories = [];
107
438
  return {
108
439
  change,
109
- applied: { path: change.path, action: "move", movePath },
440
+ applied: { path: change.path, action: "move", movePath: change.movePath },
110
441
  async commit() {
111
- await fs.promises.mkdir(path.dirname(movePath), { recursive: true });
112
- await fs.promises.rename(change.path, movePath);
442
+ await assertUnchanged(sourcePath, original, "move source");
443
+ if (await lstatIfExists(destinationPath))
444
+ throw new Error(`move destination appeared during patch: ${change.movePath}`);
445
+ createdDirectories = await createParentDirectories(path.dirname(destinationPath));
446
+ try {
447
+ await fs.promises.rename(sourcePath, destinationPath);
448
+ }
449
+ catch (error) {
450
+ await removeCreatedDirectories(createdDirectories).catch(() => undefined);
451
+ throw error;
452
+ }
113
453
  },
114
454
  async rollback() {
115
- await fs.promises.rename(movePath, change.path);
455
+ await fs.promises.rename(destinationPath, sourcePath);
456
+ await removeCreatedDirectories(createdDirectories);
116
457
  },
117
458
  };
118
459
  }
119
- async function prepareChange(change) {
120
- switch (change.action) {
460
+ async function prepareChange(resolved) {
461
+ switch (resolved.change.action) {
121
462
  case "add":
122
- return prepareAdd(change);
463
+ return prepareAdd(resolved);
123
464
  case "update":
124
- return prepareUpdate(change);
465
+ return prepareUpdate(resolved);
125
466
  case "delete":
126
- return prepareDelete(change);
467
+ return prepareDelete(resolved);
127
468
  case "move":
128
- return prepareMove(change);
469
+ return prepareMove(resolved);
129
470
  default:
130
- throw new Error(`unknown action: ${change.action}`);
471
+ throw new Error(`unknown action: ${resolved.change.action}`);
131
472
  }
132
473
  }
133
474
  // ---------------------------------------------------------------------------
134
- // Diff generation (simple, for UI feedback)
475
+ // Diff generation (for result/UI feedback)
135
476
  // ---------------------------------------------------------------------------
136
- function generateDiff(_filePath, oldContent, newContent) {
477
+ function generateDiff(filePath, oldContent, newContent) {
137
478
  if (oldContent === newContent)
138
479
  return undefined;
139
- const oldLines = oldContent.split("\n");
140
- const newLines = newContent.split("\n");
141
- const maxLen = Math.max(oldLines.length, newLines.length);
142
- let diff = "";
143
- let hasChanges = false;
144
- for (let i = 0; i < maxLen; i++) {
145
- const oldLine = oldLines[i] ?? "";
146
- const newLine = newLines[i] ?? "";
147
- if (oldLine !== newLine) {
148
- if (oldLine)
149
- diff += `- ${oldLine}\n`;
150
- if (newLine)
151
- diff += `+ ${newLine}\n`;
152
- hasChanges = true;
153
- }
154
- else if (oldLine) {
155
- diff += ` ${oldLine}\n`;
156
- }
157
- }
158
- return hasChanges ? diff : undefined;
480
+ const patch = structuredPatch(filePath, filePath, oldContent, newContent, "", "", { context: 3 });
481
+ const lines = [];
482
+ for (const hunk of patch.hunks) {
483
+ const oldRange = `${hunk.oldStart},${hunk.oldLines}`;
484
+ const newRange = `${hunk.newStart},${hunk.newLines}`;
485
+ lines.push(`@@ -${oldRange} +${newRange} @@`);
486
+ lines.push(...hunk.lines);
487
+ }
488
+ return lines.length > 0 ? `${lines.join("\n")}\n` : undefined;
159
489
  }
160
490
  // ---------------------------------------------------------------------------
161
491
  // Main executor
162
492
  // ---------------------------------------------------------------------------
163
- export async function executeApplyPatch(changes) {
164
- const prepared = [];
493
+ export async function executeApplyPatch(changes, options = {}) {
494
+ if (!Array.isArray(changes) || changes.length === 0) {
495
+ return {
496
+ ok: false,
497
+ applied: [],
498
+ errors: [{ path: "", action: "patch", error: "patch must contain at least one change" }],
499
+ };
500
+ }
501
+ let policy;
502
+ try {
503
+ policy = await createPathPolicy(options);
504
+ }
505
+ catch (error) {
506
+ return {
507
+ ok: false,
508
+ applied: [],
509
+ errors: [{ path: "", action: "patch", error: formatFilesystemError(error) }],
510
+ };
511
+ }
512
+ const resolved = [];
165
513
  const errors = [];
166
514
  const claimedPaths = new Set();
167
515
  for (const change of changes) {
168
- const paths = [change.path, ...(change.action === "move" && change.movePath ? [change.movePath] : [])].map((filePath) => path.resolve(filePath));
516
+ let sourcePath;
517
+ let destinationPath;
518
+ try {
519
+ sourcePath = await policy.resolve(change?.path, `${change?.action ?? "change"} target`);
520
+ if (change?.action === "move") {
521
+ destinationPath = await policy.resolve(change.movePath, "move destination");
522
+ }
523
+ }
524
+ catch (error) {
525
+ errors.push({
526
+ path: typeof change?.path === "string" ? change.path : "",
527
+ action: typeof change?.action === "string" ? change.action : "change",
528
+ error: formatFilesystemError(error),
529
+ });
530
+ continue;
531
+ }
532
+ const paths = [sourcePath, ...(destinationPath ? [destinationPath] : [])];
169
533
  if (paths.some((filePath) => claimedPaths.has(filePath))) {
170
534
  errors.push({
171
535
  path: change.path,
@@ -176,47 +540,59 @@ export async function executeApplyPatch(changes) {
176
540
  }
177
541
  for (const filePath of paths)
178
542
  claimedPaths.add(filePath);
179
- try {
180
- prepared.push(await prepareChange(change));
181
- }
182
- catch (err) {
183
- errors.push({
184
- path: change.path,
185
- action: change.action,
186
- error: err instanceof Error ? err.message : String(err),
187
- });
188
- }
543
+ resolved.push({ change, path: sourcePath, movePath: destinationPath });
189
544
  }
190
545
  if (errors.length > 0)
191
546
  return { ok: false, applied: [], errors };
192
- const committed = [];
193
- try {
194
- for (const change of prepared) {
195
- await change.commit();
196
- committed.push(change);
197
- }
198
- }
199
- catch (err) {
200
- for (const change of committed.reverse()) {
547
+ const lockPaths = resolved.flatMap(({ path: sourcePath, movePath: destinationPath }) => {
548
+ const paths = [sourcePath, ...(destinationPath ? [destinationPath] : [])];
549
+ return [...paths, ...paths.map((filePath) => path.dirname(filePath))];
550
+ });
551
+ return withPathLocks(lockPaths, async () => {
552
+ const prepared = [];
553
+ for (const change of resolved) {
201
554
  try {
202
- await change.rollback();
555
+ prepared.push(await prepareChange(change));
203
556
  }
204
- catch (rollbackError) {
557
+ catch (error) {
205
558
  errors.push({
206
559
  path: change.change.path,
207
560
  action: change.change.action,
208
- error: `rollback failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`,
561
+ error: formatFilesystemError(error),
209
562
  });
210
563
  }
211
564
  }
212
- errors.unshift({
213
- path: prepared[committed.length]?.change.path ?? "",
214
- action: prepared[committed.length]?.change.action ?? "commit",
215
- error: err instanceof Error ? err.message : String(err),
216
- });
217
- return { ok: false, applied: [], errors };
218
- }
219
- return { ok: true, applied: prepared.map((change) => change.applied), errors: [] };
565
+ if (errors.length > 0)
566
+ return { ok: false, applied: [], errors };
567
+ const committed = [];
568
+ try {
569
+ for (const change of prepared) {
570
+ await change.commit();
571
+ committed.push(change);
572
+ }
573
+ }
574
+ catch (error) {
575
+ for (const change of committed.reverse()) {
576
+ try {
577
+ await change.rollback();
578
+ }
579
+ catch (rollbackError) {
580
+ errors.push({
581
+ path: change.change.path,
582
+ action: change.change.action,
583
+ error: `rollback failed: ${formatFilesystemError(rollbackError)}`,
584
+ });
585
+ }
586
+ }
587
+ errors.unshift({
588
+ path: prepared[committed.length]?.change.path ?? "",
589
+ action: prepared[committed.length]?.change.action ?? "commit",
590
+ error: formatFilesystemError(error),
591
+ });
592
+ return { ok: false, applied: [], errors };
593
+ }
594
+ return { ok: true, applied: prepared.map((change) => change.applied), errors: [] };
595
+ });
220
596
  }
221
597
  // ---------------------------------------------------------------------------
222
598
  // Format result for tool output