@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.
- package/README.md +6 -0
- package/dist/core/apply-patch.d.ts +18 -3
- package/dist/core/apply-patch.d.ts.map +1 -1
- package/dist/core/apply-patch.js +492 -116
- package/dist/core/apply-patch.js.map +1 -1
- package/dist/core/replace.d.ts +8 -0
- package/dist/core/replace.d.ts.map +1 -1
- package/dist/core/replace.js +38 -19
- package/dist/core/replace.js.map +1 -1
- package/dist/core/text-encoding.d.ts +4 -2
- package/dist/core/text-encoding.d.ts.map +1 -1
- package/dist/core/text-encoding.js +13 -6
- package/dist/core/text-encoding.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +92 -26
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/core/apply-patch.js
CHANGED
|
@@ -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
|
-
*
|
|
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 {
|
|
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
|
-
//
|
|
94
|
+
// Path and filesystem safety
|
|
12
95
|
// ---------------------------------------------------------------------------
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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 (!
|
|
183
|
+
if (!initialStats.isFile())
|
|
30
184
|
throw new Error(`${label} must be a regular file: ${filePath}`);
|
|
31
|
-
|
|
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
|
|
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.
|
|
36
|
-
|
|
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
|
-
|
|
39
|
-
|
|
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
|
|
43
|
-
|
|
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
|
|
52
|
-
|
|
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(
|
|
341
|
+
await fs.promises.unlink(targetPath);
|
|
342
|
+
await removeCreatedDirectories(createdDirectories);
|
|
56
343
|
},
|
|
57
344
|
};
|
|
58
345
|
}
|
|
59
|
-
|
|
60
|
-
if (
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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.
|
|
401
|
+
diff: generateDiff(change.path, original.rawContent, newContent),
|
|
75
402
|
bytes: Buffer.byteLength(newContent, "utf8"),
|
|
76
|
-
oldContent: original.
|
|
403
|
+
oldContent: original.rawContent,
|
|
77
404
|
newContent,
|
|
78
405
|
},
|
|
79
406
|
async commit() {
|
|
80
|
-
await
|
|
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(
|
|
411
|
+
await atomicWriteFile(targetPath, original.bytes, original.mode);
|
|
84
412
|
},
|
|
85
413
|
};
|
|
86
414
|
}
|
|
87
|
-
async function prepareDelete(
|
|
88
|
-
const
|
|
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.
|
|
420
|
+
applied: { path: change.path, action: "delete", oldContent: original.rawContent },
|
|
92
421
|
async commit() {
|
|
93
|
-
await
|
|
422
|
+
await assertUnchanged(targetPath, original, "delete target");
|
|
423
|
+
await fs.promises.unlink(targetPath);
|
|
94
424
|
},
|
|
95
425
|
async rollback() {
|
|
96
|
-
await atomicWriteFile(
|
|
426
|
+
await atomicWriteFile(targetPath, original.bytes, original.mode, false);
|
|
97
427
|
},
|
|
98
428
|
};
|
|
99
429
|
}
|
|
100
|
-
async function prepareMove(
|
|
101
|
-
const movePath =
|
|
102
|
-
if (!
|
|
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(
|
|
105
|
-
if (await
|
|
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
|
|
112
|
-
await
|
|
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(
|
|
455
|
+
await fs.promises.rename(destinationPath, sourcePath);
|
|
456
|
+
await removeCreatedDirectories(createdDirectories);
|
|
116
457
|
},
|
|
117
458
|
};
|
|
118
459
|
}
|
|
119
|
-
async function prepareChange(
|
|
120
|
-
switch (change.action) {
|
|
460
|
+
async function prepareChange(resolved) {
|
|
461
|
+
switch (resolved.change.action) {
|
|
121
462
|
case "add":
|
|
122
|
-
return prepareAdd(
|
|
463
|
+
return prepareAdd(resolved);
|
|
123
464
|
case "update":
|
|
124
|
-
return prepareUpdate(
|
|
465
|
+
return prepareUpdate(resolved);
|
|
125
466
|
case "delete":
|
|
126
|
-
return prepareDelete(
|
|
467
|
+
return prepareDelete(resolved);
|
|
127
468
|
case "move":
|
|
128
|
-
return prepareMove(
|
|
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 (
|
|
475
|
+
// Diff generation (for result/UI feedback)
|
|
135
476
|
// ---------------------------------------------------------------------------
|
|
136
|
-
function generateDiff(
|
|
477
|
+
function generateDiff(filePath, oldContent, newContent) {
|
|
137
478
|
if (oldContent === newContent)
|
|
138
479
|
return undefined;
|
|
139
|
-
const
|
|
140
|
-
const
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
|
555
|
+
prepared.push(await prepareChange(change));
|
|
203
556
|
}
|
|
204
|
-
catch (
|
|
557
|
+
catch (error) {
|
|
205
558
|
errors.push({
|
|
206
559
|
path: change.change.path,
|
|
207
560
|
action: change.change.action,
|
|
208
|
-
error:
|
|
561
|
+
error: formatFilesystemError(error),
|
|
209
562
|
});
|
|
210
563
|
}
|
|
211
564
|
}
|
|
212
|
-
errors.
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|