blockpatch 0.1.0 → 1.1.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/CONTRIBUTING.md +26 -0
- package/README.md +131 -74
- package/conformance/README.md +29 -0
- package/conformance/cases/ambiguous-source/case.json +8 -0
- package/conformance/cases/ambiguous-target/case.json +8 -0
- package/conformance/cases/create-file/case.json +8 -0
- package/conformance/cases/crlf/case.json +10 -0
- package/conformance/cases/cross-file-relocation/case.json +12 -0
- package/conformance/cases/delete-existing-file/case.json +10 -0
- package/conformance/cases/insert-existing-file/case.json +10 -0
- package/conformance/cases/no-trailing-newline/case.json +10 -0
- package/conformance/cases/remove-file/case.json +9 -0
- package/conformance/cases/same-file-relocation/case.json +10 -0
- package/conformance/runner.mjs +221 -0
- package/dist/cli.js +2908 -244
- package/docs/behavior.md +207 -0
- package/docs/commands.md +84 -0
- package/docs/spec.md +356 -0
- package/examples/README.md +15 -0
- package/examples/create-file/README.md +9 -0
- package/examples/create-file/expected/file.txt +1 -0
- package/examples/create-file/patch.blockpatch +8 -0
- package/examples/create-file/work/.gitkeep +1 -0
- package/examples/cross-file-relocation/README.md +10 -0
- package/examples/cross-file-relocation/expected/source.txt +2 -0
- package/examples/cross-file-relocation/expected/target.txt +6 -0
- package/examples/cross-file-relocation/patch.blockpatch +26 -0
- package/examples/cross-file-relocation/work/source.txt +5 -0
- package/examples/cross-file-relocation/work/target.txt +3 -0
- package/examples/delete-existing-file/README.md +9 -0
- package/examples/delete-existing-file/expected/file.txt +2 -0
- package/examples/delete-existing-file/patch.blockpatch +10 -0
- package/examples/delete-existing-file/work/file.txt +3 -0
- package/examples/failure-ambiguous-target/README.md +9 -0
- package/examples/failure-ambiguous-target/patch.blockpatch +14 -0
- package/examples/failure-ambiguous-target/work/file.txt +5 -0
- package/examples/insert-existing-file/README.md +9 -0
- package/examples/insert-existing-file/expected/file.txt +3 -0
- package/examples/insert-existing-file/patch.blockpatch +10 -0
- package/examples/insert-existing-file/work/file.txt +2 -0
- package/examples/remove-file/README.md +9 -0
- package/examples/remove-file/expected/.gitkeep +1 -0
- package/examples/remove-file/patch.blockpatch +8 -0
- package/examples/remove-file/work/file.txt +1 -0
- package/examples/reverse/README.md +9 -0
- package/examples/reverse/expected/file.txt +4 -0
- package/examples/reverse/patch.blockpatch +14 -0
- package/examples/reverse/work/file.txt +4 -0
- package/examples/same-file-relocation/README.md +9 -0
- package/examples/same-file-relocation/expected/file.txt +4 -0
- package/examples/same-file-relocation/patch.blockpatch +14 -0
- package/examples/same-file-relocation/work/file.txt +4 -0
- package/package.json +33 -13
- package/dist/cli.d.ts +0 -2
- package/dist/engine.d.ts +0 -19
- package/dist/errors.d.ts +0 -5
- package/dist/index.d.ts +0 -4
- package/dist/index.js +0 -263
- package/dist/parser.d.ts +0 -2
- package/dist/types.d.ts +0 -20
package/dist/cli.js
CHANGED
|
@@ -1,265 +1,2612 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
|
-
import {
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
|
+
import { resolve as resolve3 } from "node:path";
|
|
5
6
|
|
|
6
7
|
// src/errors.ts
|
|
8
|
+
var maxErrorRanges = 10;
|
|
9
|
+
|
|
7
10
|
class BlockPatchError extends Error {
|
|
8
11
|
code;
|
|
9
|
-
|
|
12
|
+
details;
|
|
13
|
+
constructor(code, message, details = {}) {
|
|
10
14
|
super(message);
|
|
11
15
|
this.name = "BlockPatchError";
|
|
12
16
|
this.code = code;
|
|
17
|
+
this.details = details;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function fail(code, message, details) {
|
|
21
|
+
throw new BlockPatchError(code, message, details);
|
|
22
|
+
}
|
|
23
|
+
function matchedLocations(count, truncated) {
|
|
24
|
+
return `matched ${truncated ? "at least " : ""}${count} locations`;
|
|
25
|
+
}
|
|
26
|
+
function matchCountDetails(count, truncated) {
|
|
27
|
+
return truncated ? { matches: count, matches_truncated: true } : { matches: count };
|
|
28
|
+
}
|
|
29
|
+
function boundedMatchRanges(matches, byteLength) {
|
|
30
|
+
const ranges = [];
|
|
31
|
+
for (const start of matches) {
|
|
32
|
+
if (ranges.length >= maxErrorRanges) {
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
ranges.push({ start, end: start + byteLength });
|
|
36
|
+
}
|
|
37
|
+
return ranges;
|
|
38
|
+
}
|
|
39
|
+
function boundedRanges(matches) {
|
|
40
|
+
const ranges = [];
|
|
41
|
+
for (const range of matches) {
|
|
42
|
+
if (ranges.length >= maxErrorRanges) {
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
ranges.push({ start: range.start, end: range.end });
|
|
46
|
+
}
|
|
47
|
+
return ranges;
|
|
48
|
+
}
|
|
49
|
+
function boundedMatchLineRanges(file, matches, byteLength) {
|
|
50
|
+
const lineRanges = [];
|
|
51
|
+
for (const start of matches) {
|
|
52
|
+
if (lineRanges.length >= maxErrorRanges) {
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
lineRanges.push(byteRangeToLineRange(file, { start, end: start + byteLength }));
|
|
56
|
+
}
|
|
57
|
+
return lineRanges;
|
|
58
|
+
}
|
|
59
|
+
function boundedLineRanges(file, ranges) {
|
|
60
|
+
return boundedRanges(ranges).map((range) => byteRangeToLineRange(file, range));
|
|
61
|
+
}
|
|
62
|
+
function byteRangeToLineRange(file, range) {
|
|
63
|
+
const start = clamp(range.start, 0, file.length);
|
|
64
|
+
const end = clamp(range.end, start, file.length);
|
|
65
|
+
const endByte = end > start ? end - 1 : start;
|
|
66
|
+
return {
|
|
67
|
+
start: lineNumberAt(file, start),
|
|
68
|
+
end: lineNumberAt(file, endByte)
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
function lineNumberAt(file, byteIndex) {
|
|
72
|
+
let line = 1;
|
|
73
|
+
const end = Math.min(byteIndex, file.length);
|
|
74
|
+
for (let index = 0;index < end; index += 1) {
|
|
75
|
+
if (file[index] === 10) {
|
|
76
|
+
line += 1;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return line;
|
|
80
|
+
}
|
|
81
|
+
function clamp(value, min, max) {
|
|
82
|
+
return Math.min(Math.max(value, min), max);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// src/engine.ts
|
|
86
|
+
import { Buffer as Buffer3 } from "node:buffer";
|
|
87
|
+
import { randomBytes } from "node:crypto";
|
|
88
|
+
import { chmod, lstat, mkdir, rename, rmdir, stat as stat2, unlink, writeFile } from "node:fs/promises";
|
|
89
|
+
import { basename, dirname, isAbsolute as isAbsolute2, join as join2, relative as relative2, resolve as resolve2, sep as sep2 } from "node:path";
|
|
90
|
+
|
|
91
|
+
// src/files.ts
|
|
92
|
+
import { createHash } from "node:crypto";
|
|
93
|
+
import { lstatSync, realpathSync } from "node:fs";
|
|
94
|
+
import { readFile, stat } from "node:fs/promises";
|
|
95
|
+
async function readFileChecked(path, label) {
|
|
96
|
+
try {
|
|
97
|
+
return await readFile(path);
|
|
98
|
+
} catch (error) {
|
|
99
|
+
failFileSystem(error, path, `Could not read ${label}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
async function readFileSnapshot(path, label) {
|
|
103
|
+
const bytes = await readFileChecked(path, label);
|
|
104
|
+
const info = await statChecked(path, label);
|
|
105
|
+
assertRegularFile(info, path, label);
|
|
106
|
+
return fileSnapshot(bytes, info);
|
|
107
|
+
}
|
|
108
|
+
function fileSnapshot(bytes, info) {
|
|
109
|
+
return {
|
|
110
|
+
bytes,
|
|
111
|
+
sha256: hashBytes(bytes),
|
|
112
|
+
stat: statSnapshot(info)
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
function hashBytes(bytes) {
|
|
116
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
117
|
+
}
|
|
118
|
+
async function statChecked(path, label) {
|
|
119
|
+
try {
|
|
120
|
+
return await stat(path);
|
|
121
|
+
} catch (error) {
|
|
122
|
+
failFileSystem(error, path, `Could not stat ${label}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
function lstatSyncChecked(path, label, userPath = path) {
|
|
126
|
+
try {
|
|
127
|
+
return lstatSync(path);
|
|
128
|
+
} catch (error) {
|
|
129
|
+
failFileSystem(error, userPath, `Could not stat ${label}`, "path");
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
function realpathSyncChecked(path, label, userPath = path) {
|
|
133
|
+
try {
|
|
134
|
+
return realpathSync(path);
|
|
135
|
+
} catch (error) {
|
|
136
|
+
failFileSystem(error, userPath, `Could not resolve ${label}`, "path");
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
function assertRegularFile(info, path, label, phase = "io") {
|
|
140
|
+
if (!info.isFile()) {
|
|
141
|
+
fail("not_regular_file", `${label} must be a regular file: ${path}`, { path, phase });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
function failFileSystem(error, path, action, phase = "io") {
|
|
145
|
+
if (error instanceof BlockPatchError) {
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
148
|
+
const message = error instanceof Error ? `${action}: ${error.message}` : `${action}: ${path}`;
|
|
149
|
+
fail(fileSystemErrorCode(error), message, { path, phase });
|
|
150
|
+
}
|
|
151
|
+
function statSnapshot(info) {
|
|
152
|
+
return {
|
|
153
|
+
dev: info.dev,
|
|
154
|
+
ino: info.ino,
|
|
155
|
+
mode: info.mode,
|
|
156
|
+
size: info.size,
|
|
157
|
+
mtimeMs: info.mtimeMs,
|
|
158
|
+
ctimeMs: info.ctimeMs
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
function fileSystemErrorCode(error) {
|
|
162
|
+
switch (error.code) {
|
|
163
|
+
case "ENOENT":
|
|
164
|
+
case "ENOTDIR":
|
|
165
|
+
return "file_not_found";
|
|
166
|
+
case "EISDIR":
|
|
167
|
+
return "not_regular_file";
|
|
168
|
+
case "EACCES":
|
|
169
|
+
case "EPERM":
|
|
170
|
+
return "permission_denied";
|
|
171
|
+
default:
|
|
172
|
+
return "io_error";
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// src/parser.ts
|
|
177
|
+
import { Buffer as Buffer2 } from "node:buffer";
|
|
178
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
179
|
+
import { posix } from "node:path";
|
|
180
|
+
|
|
181
|
+
// src/paths.ts
|
|
182
|
+
import { lstatSync as lstatSync2 } from "node:fs";
|
|
183
|
+
import { isAbsolute, join, relative, resolve, sep, win32 } from "node:path";
|
|
184
|
+
function validateOperationPath(path, label) {
|
|
185
|
+
if (path === "") {
|
|
186
|
+
fail("invalid_path", `Invalid ${label}: ${path}`, { path, phase: "path" });
|
|
187
|
+
}
|
|
188
|
+
rejectUnsafeDisplayPath(path, label);
|
|
189
|
+
rejectAbsolutePath(path, label);
|
|
190
|
+
rejectBackslashPath(path, label);
|
|
191
|
+
}
|
|
192
|
+
function rejectUnsafeDisplayPath(path, label) {
|
|
193
|
+
if (/[\x00-\x1f\x7f-\x9f]/u.test(path)) {
|
|
194
|
+
fail("invalid_path", `${label} contains unsupported control characters`, {
|
|
195
|
+
path,
|
|
196
|
+
phase: "path"
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
function rejectBackslashPath(path, label) {
|
|
201
|
+
if (path.includes("\\")) {
|
|
202
|
+
fail("invalid_path", `${label} must use POSIX-style / separators: ${path}`, {
|
|
203
|
+
path,
|
|
204
|
+
phase: "path"
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
function rejectAbsolutePath(path, label) {
|
|
209
|
+
if (isAbsolute(path) || win32.isAbsolute(path)) {
|
|
210
|
+
fail("path_outside_cwd", `${label} must be relative to the working directory: ${path}`, {
|
|
211
|
+
path,
|
|
212
|
+
phase: "path"
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
function rejectDotPathSegments(path, label) {
|
|
217
|
+
if (path.split("/").some((part) => part === "." || part === "..")) {
|
|
218
|
+
fail("invalid_path", `${label} must not contain . or .. path segments: ${path}`, {
|
|
219
|
+
path,
|
|
220
|
+
phase: "path"
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
function resolvePath(cwd, path, label) {
|
|
225
|
+
validateOperationPath(path, label);
|
|
226
|
+
const root = resolve(cwd);
|
|
227
|
+
const resolved = resolve(root, path);
|
|
228
|
+
const realRoot = realpathSyncChecked(root, "working directory", root);
|
|
229
|
+
if (!isInside(root, resolved)) {
|
|
230
|
+
fail("path_outside_cwd", `${label} escapes the working directory: ${path}`, { path, phase: "path" });
|
|
231
|
+
}
|
|
232
|
+
rejectDotPathSegments(path, label);
|
|
233
|
+
rejectSymlinkComponents(root, resolved, path, label);
|
|
234
|
+
const info = lstatSyncChecked(resolved, label, path);
|
|
235
|
+
assertRegularFile(info, path, label, "path");
|
|
236
|
+
const realResolved = realpathSyncChecked(resolved, label, path);
|
|
237
|
+
if (!isInside(realRoot, realResolved)) {
|
|
238
|
+
fail("path_outside_cwd", `${label} resolves outside the working directory: ${path}`, { path, phase: "path" });
|
|
239
|
+
}
|
|
240
|
+
return resolved;
|
|
241
|
+
}
|
|
242
|
+
function resolvePathAllowMissing(cwd, path, label) {
|
|
243
|
+
validateOperationPath(path, label);
|
|
244
|
+
const root = resolve(cwd);
|
|
245
|
+
const resolved = resolve(root, path);
|
|
246
|
+
const realRoot = realpathSyncChecked(root, "working directory", root);
|
|
247
|
+
if (!isInside(root, resolved)) {
|
|
248
|
+
fail("path_outside_cwd", `${label} escapes the working directory: ${path}`, { path, phase: "path" });
|
|
249
|
+
}
|
|
250
|
+
rejectDotPathSegments(path, label);
|
|
251
|
+
const deepestExisting = rejectExistingSymlinkComponents(root, resolved, path, label);
|
|
252
|
+
if (deepestExisting !== resolved) {
|
|
253
|
+
return { path: resolved, exists: false };
|
|
254
|
+
}
|
|
255
|
+
const info = lstatSyncChecked(resolved, label, path);
|
|
256
|
+
assertRegularFile(info, path, label, "path");
|
|
257
|
+
const realResolved = realpathSyncChecked(resolved, label, path);
|
|
258
|
+
if (!isInside(realRoot, realResolved)) {
|
|
259
|
+
fail("path_outside_cwd", `${label} resolves outside the working directory: ${path}`, { path, phase: "path" });
|
|
260
|
+
}
|
|
261
|
+
return { path: resolved, exists: true };
|
|
262
|
+
}
|
|
263
|
+
async function sameFileIdentity(left, right) {
|
|
264
|
+
if (left === right) {
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
267
|
+
const [leftInfo, rightInfo] = await Promise.all([
|
|
268
|
+
statChecked(left, "source path"),
|
|
269
|
+
statChecked(right, "destination path")
|
|
270
|
+
]);
|
|
271
|
+
return leftInfo.dev === rightInfo.dev && leftInfo.ino === rightInfo.ino;
|
|
272
|
+
}
|
|
273
|
+
function rejectSymlinkComponents(root, resolved, originalPath, label) {
|
|
274
|
+
const relativePath = relative(root, resolved);
|
|
275
|
+
if (relativePath === "") {
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
let current = root;
|
|
279
|
+
for (const part of relativePath.split(sep)) {
|
|
280
|
+
current = join(current, part);
|
|
281
|
+
if (lstatSyncChecked(current, label, originalPath).isSymbolicLink()) {
|
|
282
|
+
fail("symlink_path", `${label} must not contain symbolic links: ${originalPath}`, {
|
|
283
|
+
path: originalPath,
|
|
284
|
+
phase: "path"
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
function rejectExistingSymlinkComponents(root, resolved, originalPath, label) {
|
|
290
|
+
const relativePath = relative(root, resolved);
|
|
291
|
+
if (relativePath === "") {
|
|
292
|
+
return resolved;
|
|
293
|
+
}
|
|
294
|
+
let current = root;
|
|
295
|
+
for (const part of relativePath.split(sep)) {
|
|
296
|
+
const next = join(current, part);
|
|
297
|
+
let info;
|
|
298
|
+
try {
|
|
299
|
+
info = lstatSync2(next);
|
|
300
|
+
} catch (error) {
|
|
301
|
+
const code = error.code;
|
|
302
|
+
if (code === "ENOENT" || code === "ENOTDIR") {
|
|
303
|
+
return current;
|
|
304
|
+
}
|
|
305
|
+
failFileSystem(error, originalPath, `Could not stat ${label}`, "path");
|
|
306
|
+
}
|
|
307
|
+
if (info.isSymbolicLink()) {
|
|
308
|
+
fail("symlink_path", `${label} must not contain symbolic links: ${originalPath}`, {
|
|
309
|
+
path: originalPath,
|
|
310
|
+
phase: "path"
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
current = next;
|
|
314
|
+
}
|
|
315
|
+
return current;
|
|
316
|
+
}
|
|
317
|
+
function isInside(root, path) {
|
|
318
|
+
const fromRoot = relative(root, path);
|
|
319
|
+
return fromRoot === "" || !fromRoot.startsWith("..") && !isAbsolute(fromRoot);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// src/parser.ts
|
|
323
|
+
var devNull = "/dev/null";
|
|
324
|
+
var noNewlineMarker = "\";
|
|
325
|
+
var movePrefix = "blockpatch move ";
|
|
326
|
+
var allowedMetadataKeys = new Set(["id", "payload-sha256", "role"]);
|
|
327
|
+
var hunkPattern = /^@@ -\d+(?:,(\d+))? \+\d+(?:,(\d+))? @@ blockpatch-(source|target) id=([^\s]+)(?: .*)?$/;
|
|
328
|
+
function parseBlockPatch(input, options = {}) {
|
|
329
|
+
const lines = splitLines(input);
|
|
330
|
+
const sections = parseSections(lines, normalizeStripComponents(options.stripComponents ?? 1));
|
|
331
|
+
if (sections.length === 1) {
|
|
332
|
+
return parseSingleSectionMove(sections[0]);
|
|
333
|
+
}
|
|
334
|
+
if (sections.length === 2) {
|
|
335
|
+
return parseSplitSectionMove(sections);
|
|
336
|
+
}
|
|
337
|
+
fail("parse_error", "Patch must contain one same-file move or one split cross-file move");
|
|
338
|
+
}
|
|
339
|
+
function parseSingleSectionMove(section) {
|
|
340
|
+
if (section.src === null && section.dst === null) {
|
|
341
|
+
fail("parse_error", "Patch cannot use /dev/null for both endpoints");
|
|
342
|
+
}
|
|
343
|
+
if (section.role !== undefined) {
|
|
344
|
+
if (section.role === "source" && section.dst !== null) {
|
|
345
|
+
fail("parse_error", "A source role section without a target role section must target /dev/null");
|
|
346
|
+
}
|
|
347
|
+
if (section.role === "target" && section.src !== null) {
|
|
348
|
+
fail("parse_error", "A target role section without a source role section must source /dev/null");
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
if (section.src !== null && section.dst !== null && !samePatchPath(section.src, section.dst)) {
|
|
352
|
+
fail("parse_error", "Cross-file moves must use separate source and target file sections");
|
|
353
|
+
}
|
|
354
|
+
if (section.hunks.length < 1 || section.hunks.length > 2) {
|
|
355
|
+
fail("parse_error", "Patch must contain one or two blockpatch hunks");
|
|
356
|
+
}
|
|
357
|
+
const sourceHunks = section.hunks.filter((hunk) => hunk.kind === "source");
|
|
358
|
+
const targetHunks = section.hunks.filter((hunk) => hunk.kind === "target");
|
|
359
|
+
if (sourceHunks.length > 1 || targetHunks.length > 1) {
|
|
360
|
+
fail("parse_error", "Patch must contain at most one source hunk and at most one target hunk");
|
|
361
|
+
}
|
|
362
|
+
const sourceHunk = sourceHunks[0];
|
|
363
|
+
const targetHunk = targetHunks[0];
|
|
364
|
+
for (const hunk of section.hunks) {
|
|
365
|
+
if (hunk.id !== section.moveId) {
|
|
366
|
+
fail("parse_error", "Move metadata id must match source and target hunk ids");
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
if (section.src === null) {
|
|
370
|
+
if (sourceHunk !== undefined || targetHunk === undefined) {
|
|
371
|
+
fail("parse_error", "A move from /dev/null must contain exactly one blockpatch-target hunk");
|
|
372
|
+
}
|
|
373
|
+
return buildTargetOnlyBlockPatch(section, targetHunk, { pathState: true });
|
|
374
|
+
}
|
|
375
|
+
if (section.dst === null) {
|
|
376
|
+
if (sourceHunk === undefined || targetHunk !== undefined) {
|
|
377
|
+
fail("parse_error", "A move to /dev/null must contain exactly one blockpatch-source hunk");
|
|
378
|
+
}
|
|
379
|
+
return buildSourceOnlyBlockPatch(section, sourceHunk, { pathState: true });
|
|
380
|
+
}
|
|
381
|
+
if (sourceHunk !== undefined && targetHunk !== undefined) {
|
|
382
|
+
return buildPairedBlockPatch(section.src, section.dst, section.moveId, section.payloadSha256, sourceHunk, targetHunk);
|
|
383
|
+
}
|
|
384
|
+
if (sourceHunk !== undefined) {
|
|
385
|
+
return buildSourceOnlyBlockPatch(section, sourceHunk, { pathState: false });
|
|
386
|
+
}
|
|
387
|
+
if (targetHunk !== undefined) {
|
|
388
|
+
return buildTargetOnlyBlockPatch(section, targetHunk, { pathState: false });
|
|
389
|
+
}
|
|
390
|
+
fail("parse_error", "Patch must contain at least one blockpatch hunk");
|
|
391
|
+
}
|
|
392
|
+
function parseSplitSectionMove(sections) {
|
|
393
|
+
if (sections.some((section) => section.src === null || section.dst === null)) {
|
|
394
|
+
fail("parse_error", "/dev/null endpoints are only valid in single-section moves");
|
|
395
|
+
}
|
|
396
|
+
if (sections.some((section) => section.role === undefined)) {
|
|
397
|
+
fail("parse_error", "Split cross-file move sections must include role=source or role=target");
|
|
398
|
+
}
|
|
399
|
+
const sourceSections = sections.filter((section) => section.role === "source");
|
|
400
|
+
const targetSections = sections.filter((section) => section.role === "target");
|
|
401
|
+
if (sourceSections.length !== 1 || targetSections.length !== 1) {
|
|
402
|
+
fail("parse_error", "Split cross-file moves must contain one source role section and one target role section");
|
|
403
|
+
}
|
|
404
|
+
const sourceSection = sourceSections[0];
|
|
405
|
+
const targetSection = targetSections[0];
|
|
406
|
+
const sourceFile = sourceSection.src;
|
|
407
|
+
const targetFile = targetSection.src;
|
|
408
|
+
if (!samePatchPath(sourceFile, sourceSection.dst) || !samePatchPath(targetFile, targetSection.dst)) {
|
|
409
|
+
fail("parse_error", "Split cross-file section headers must name the same file in --- and +++");
|
|
410
|
+
}
|
|
411
|
+
if (samePatchPath(sourceFile, targetFile)) {
|
|
412
|
+
fail("parse_error", "Split cross-file moves require different source and target files");
|
|
413
|
+
}
|
|
414
|
+
if (sourceSection.moveId !== targetSection.moveId) {
|
|
415
|
+
fail("parse_error", "Split cross-file move sections must use the same move id");
|
|
416
|
+
}
|
|
417
|
+
if (sourceSection.payloadSha256 !== targetSection.payloadSha256) {
|
|
418
|
+
fail("parse_error", "Split cross-file move sections must use the same payload-sha256");
|
|
419
|
+
}
|
|
420
|
+
if (sourceSection.hunks.length !== 1 || sourceSection.hunks[0].kind !== "source") {
|
|
421
|
+
fail("parse_error", "Source role section must contain exactly one blockpatch-source hunk");
|
|
422
|
+
}
|
|
423
|
+
if (targetSection.hunks.length !== 1 || targetSection.hunks[0].kind !== "target") {
|
|
424
|
+
fail("parse_error", "Target role section must contain exactly one blockpatch-target hunk");
|
|
425
|
+
}
|
|
426
|
+
const sourceHunk = sourceSection.hunks[0];
|
|
427
|
+
const targetHunk = targetSection.hunks[0];
|
|
428
|
+
if (sourceHunk.id !== sourceSection.moveId || targetHunk.id !== targetSection.moveId) {
|
|
429
|
+
fail("parse_error", "Move metadata id must match source and target hunk ids");
|
|
430
|
+
}
|
|
431
|
+
return buildPairedBlockPatch(sourceFile, targetFile, sourceSection.moveId, sourceSection.payloadSha256, sourceHunk, targetHunk);
|
|
432
|
+
}
|
|
433
|
+
function buildPairedBlockPatch(src, dst, moveId, payloadSha256, sourceHunk, targetHunk) {
|
|
434
|
+
const source = parseSourceHunk(sourceHunk);
|
|
435
|
+
const target = parseTargetHunk(targetHunk);
|
|
436
|
+
const targetPayload = payloadBytes(targetHunk, "+");
|
|
437
|
+
if (!source.payload.equals(targetPayload)) {
|
|
438
|
+
fail("payload_mismatch", "Target added payload does not match source removed payload", {
|
|
439
|
+
phase: "payload",
|
|
440
|
+
anchor: "blockpatch-target"
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
verifyPayloadHash(source.payload, payloadSha256);
|
|
444
|
+
return {
|
|
445
|
+
type: "move",
|
|
446
|
+
id: moveId,
|
|
447
|
+
src,
|
|
448
|
+
dst,
|
|
449
|
+
payloadSha256,
|
|
450
|
+
hasSourceHunk: true,
|
|
451
|
+
sourceBefore: source.before,
|
|
452
|
+
sourcePayload: source.payload,
|
|
453
|
+
sourceAfter: source.after,
|
|
454
|
+
target
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
function buildSourceOnlyBlockPatch(section, sourceHunk, options) {
|
|
458
|
+
const source = parseSourceHunk(sourceHunk, { allowEmptyPayload: options.pathState });
|
|
459
|
+
if (options.pathState && (source.before.length !== 0 || source.after.length !== 0)) {
|
|
460
|
+
fail("parse_error", "A move to /dev/null must describe whole-file payload without context");
|
|
461
|
+
}
|
|
462
|
+
verifyPayloadHash(source.payload, section.payloadSha256);
|
|
463
|
+
return {
|
|
464
|
+
type: "move",
|
|
465
|
+
id: section.moveId,
|
|
466
|
+
src: section.src,
|
|
467
|
+
dst: section.dst,
|
|
468
|
+
payloadSha256: section.payloadSha256,
|
|
469
|
+
hasSourceHunk: true,
|
|
470
|
+
sourceBefore: source.before,
|
|
471
|
+
sourcePayload: source.payload,
|
|
472
|
+
sourceAfter: source.after,
|
|
473
|
+
target: null
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
function buildTargetOnlyBlockPatch(section, targetHunk, options) {
|
|
477
|
+
const target = parseTargetHunk(targetHunk, {
|
|
478
|
+
allowEmptyAnchors: options.pathState,
|
|
479
|
+
allowEmptyPayload: options.pathState
|
|
480
|
+
});
|
|
481
|
+
if (options.pathState && (target.before.length !== 0 || target.after.length !== 0)) {
|
|
482
|
+
fail("parse_error", "A move from /dev/null must describe whole-file payload without context");
|
|
483
|
+
}
|
|
484
|
+
const payload = payloadBytes(targetHunk, "+");
|
|
485
|
+
verifyPayloadHash(payload, section.payloadSha256);
|
|
486
|
+
return {
|
|
487
|
+
type: "move",
|
|
488
|
+
id: section.moveId,
|
|
489
|
+
src: section.src,
|
|
490
|
+
dst: section.dst,
|
|
491
|
+
payloadSha256: section.payloadSha256,
|
|
492
|
+
hasSourceHunk: false,
|
|
493
|
+
sourceBefore: Buffer2.alloc(0),
|
|
494
|
+
sourcePayload: payload,
|
|
495
|
+
sourceAfter: Buffer2.alloc(0),
|
|
496
|
+
target
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
function verifyPayloadHash(payload, payloadSha256) {
|
|
500
|
+
const actualHash = createHash2("sha256").update(payload).digest("hex");
|
|
501
|
+
if (actualHash !== payloadSha256) {
|
|
502
|
+
fail("hash_mismatch", "payload-sha256 does not match moved payload", {
|
|
503
|
+
phase: "payload",
|
|
504
|
+
anchor: "payload-sha256"
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
function parseSections(lines, stripComponents) {
|
|
509
|
+
if (text(lines[0])?.startsWith("diff --blockpatch ") !== true) {
|
|
510
|
+
fail("parse_error", "Patch must start with diff --blockpatch");
|
|
511
|
+
}
|
|
512
|
+
const starts = [];
|
|
513
|
+
for (let index = 0;index < lines.length; index += 1) {
|
|
514
|
+
if (text(lines[index])?.startsWith("diff --blockpatch ") === true) {
|
|
515
|
+
starts.push(index);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
return starts.map((start, index) => {
|
|
519
|
+
const end = starts[index + 1] ?? lines.length;
|
|
520
|
+
return parseSection(lines, start, end, stripComponents);
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
function parseSection(lines, start, end, stripComponents) {
|
|
524
|
+
if (text(lines[start + 1]) !== "blockpatch version 1") {
|
|
525
|
+
fail("parse_error", "Patch must declare blockpatch version 1");
|
|
526
|
+
}
|
|
527
|
+
const moveLine = text(lines[start + 2]);
|
|
528
|
+
if (moveLine?.startsWith(movePrefix) !== true) {
|
|
529
|
+
fail("parse_error", "Patch must declare blockpatch move metadata");
|
|
530
|
+
}
|
|
531
|
+
const metadata = parseMetadata(moveLine.slice(movePrefix.length));
|
|
532
|
+
const moveId = metadata.get("id");
|
|
533
|
+
const payloadSha256 = metadata.get("payload-sha256");
|
|
534
|
+
const role = metadata.get("role");
|
|
535
|
+
if (!moveId) {
|
|
536
|
+
fail("parse_error", "blockpatch move metadata must include id=<id>");
|
|
537
|
+
}
|
|
538
|
+
if (!payloadSha256 || !/^[a-f0-9]{64}$/.test(payloadSha256)) {
|
|
539
|
+
fail("parse_error", "blockpatch move metadata must include payload-sha256=<64 hex chars>");
|
|
540
|
+
}
|
|
541
|
+
if (role !== undefined && role !== "source" && role !== "target") {
|
|
542
|
+
fail("parse_error", "blockpatch move role must be source or target");
|
|
543
|
+
}
|
|
544
|
+
const oldRawPath = parseFileHeader(text(lines[start + 3]), "---", "a/");
|
|
545
|
+
const newRawPath = parseFileHeader(text(lines[start + 4]), "+++", "b/");
|
|
546
|
+
if (text(lines[start])?.trimEnd() !== `diff --blockpatch ${oldRawPath} ${newRawPath}`) {
|
|
547
|
+
fail("parse_error", "diff --blockpatch paths must match the --- and +++ headers");
|
|
548
|
+
}
|
|
549
|
+
let hunkStart = start + 5;
|
|
550
|
+
while (hunkStart < end && text(lines[hunkStart]) === "") {
|
|
551
|
+
hunkStart += 1;
|
|
552
|
+
}
|
|
553
|
+
return {
|
|
554
|
+
src: oldRawPath === devNull ? null : stripPath(oldRawPath, stripComponents),
|
|
555
|
+
dst: newRawPath === devNull ? null : stripPath(newRawPath, stripComponents),
|
|
556
|
+
moveId,
|
|
557
|
+
payloadSha256,
|
|
558
|
+
role,
|
|
559
|
+
hunks: parseHunks(lines, hunkStart, end)
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
function normalizeStripComponents(value) {
|
|
563
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
564
|
+
fail("parse_error", "stripComponents must be a non-negative integer");
|
|
565
|
+
}
|
|
566
|
+
return value;
|
|
567
|
+
}
|
|
568
|
+
function parseHunks(lines, start, end) {
|
|
569
|
+
const hunks = [];
|
|
570
|
+
let index = start;
|
|
571
|
+
while (index < end) {
|
|
572
|
+
const header = text(lines[index]);
|
|
573
|
+
if (header === "") {
|
|
574
|
+
index += 1;
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
const parsedHeader = parseHunkHeader(header);
|
|
578
|
+
if (parsedHeader === undefined) {
|
|
579
|
+
fail("parse_error", "Expected blockpatch source/target hunk header");
|
|
580
|
+
}
|
|
581
|
+
const hunk = {
|
|
582
|
+
kind: parsedHeader.kind,
|
|
583
|
+
id: parsedHeader.id,
|
|
584
|
+
oldCount: parsedHeader.oldCount,
|
|
585
|
+
newCount: parsedHeader.newCount,
|
|
586
|
+
lines: []
|
|
587
|
+
};
|
|
588
|
+
index += 1;
|
|
589
|
+
while (index < end) {
|
|
590
|
+
const maybeHeader = text(lines[index]);
|
|
591
|
+
if (parseHunkHeader(maybeHeader) !== undefined) {
|
|
592
|
+
break;
|
|
593
|
+
}
|
|
594
|
+
const body = lines[index].body;
|
|
595
|
+
if (body.length === 0) {
|
|
596
|
+
let lookahead = index + 1;
|
|
597
|
+
while (lookahead < end && lines[lookahead].body.length === 0) {
|
|
598
|
+
lookahead += 1;
|
|
599
|
+
}
|
|
600
|
+
if (lookahead < end && parseHunkHeader(text(lines[lookahead])) === undefined) {
|
|
601
|
+
fail("parse_error", "Hunk bodies must not contain blank lines; encode an empty context line as a single space");
|
|
602
|
+
}
|
|
603
|
+
break;
|
|
604
|
+
}
|
|
605
|
+
const first = body[0];
|
|
606
|
+
if (first !== 32 && first !== 45 && first !== 43) {
|
|
607
|
+
fail("parse_error", "Hunk body lines must start with space, -, or +");
|
|
608
|
+
}
|
|
609
|
+
let content = Buffer2.concat([body.subarray(1), lines[index].eol]);
|
|
610
|
+
if (index + 1 < lines.length && text(lines[index + 1]) === noNewlineMarker) {
|
|
611
|
+
const bareCr = lines[index].eol[0] === 13 ? lines[index].eol.subarray(0, 1) : Buffer2.alloc(0);
|
|
612
|
+
content = Buffer2.concat([body.subarray(1), bareCr]);
|
|
613
|
+
index += 1;
|
|
614
|
+
}
|
|
615
|
+
hunk.lines.push({
|
|
616
|
+
prefix: String.fromCharCode(first),
|
|
617
|
+
content
|
|
618
|
+
});
|
|
619
|
+
index += 1;
|
|
620
|
+
}
|
|
621
|
+
validateHunkCounts(hunk);
|
|
622
|
+
hunks.push(hunk);
|
|
623
|
+
}
|
|
624
|
+
return hunks;
|
|
625
|
+
}
|
|
626
|
+
function samePatchPath(left, right) {
|
|
627
|
+
return posix.normalize(left) === posix.normalize(right);
|
|
628
|
+
}
|
|
629
|
+
function parseHunkHeader(header) {
|
|
630
|
+
const match = header?.match(hunkPattern);
|
|
631
|
+
if (match === undefined || match === null) {
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
return {
|
|
635
|
+
oldCount: match[1] === undefined ? 1 : Number(match[1]),
|
|
636
|
+
newCount: match[2] === undefined ? 1 : Number(match[2]),
|
|
637
|
+
kind: match[3] === "source" ? "source" : "target",
|
|
638
|
+
id: match[4]
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
function validateHunkCounts(hunk) {
|
|
642
|
+
const oldCount = hunk.lines.filter((line) => line.prefix === " " || line.prefix === "-").length;
|
|
643
|
+
const newCount = hunk.lines.filter((line) => line.prefix === " " || line.prefix === "+").length;
|
|
644
|
+
if (oldCount !== hunk.oldCount || newCount !== hunk.newCount) {
|
|
645
|
+
fail("parse_error", `Hunk line counts do not match header for blockpatch-${hunk.kind} id=${hunk.id}`);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
function parseSourceHunk(hunk, options = {}) {
|
|
649
|
+
const removedIndexes = hunk.lines.map((line, index) => line.prefix === "-" ? index : -1).filter((index) => index !== -1);
|
|
650
|
+
if (removedIndexes.length === 0) {
|
|
651
|
+
if (options.allowEmptyPayload === true && hunk.lines.length === 0) {
|
|
652
|
+
return { before: Buffer2.alloc(0), payload: Buffer2.alloc(0), after: Buffer2.alloc(0) };
|
|
653
|
+
}
|
|
654
|
+
fail("parse_error", "Source hunk must contain removed payload lines");
|
|
655
|
+
}
|
|
656
|
+
assertContiguous(removedIndexes, "Source payload lines must be contiguous");
|
|
657
|
+
const firstRemoved = removedIndexes[0];
|
|
658
|
+
const lastRemoved = removedIndexes[removedIndexes.length - 1];
|
|
659
|
+
const beforeLines = hunk.lines.slice(0, firstRemoved);
|
|
660
|
+
const afterLines = hunk.lines.slice(lastRemoved + 1);
|
|
661
|
+
if (!beforeLines.every((line) => line.prefix === " ") || !afterLines.every((line) => line.prefix === " ")) {
|
|
662
|
+
fail("parse_error", "Source hunk may only contain context lines around removed payload");
|
|
663
|
+
}
|
|
664
|
+
const before = concatLines(beforeLines);
|
|
665
|
+
const payload = payloadBytes(hunk, "-");
|
|
666
|
+
const after = concatLines(afterLines);
|
|
667
|
+
return { before, payload, after };
|
|
668
|
+
}
|
|
669
|
+
function parseTargetHunk(hunk, options = {}) {
|
|
670
|
+
const addedIndexes = hunk.lines.map((line, index) => line.prefix === "+" ? index : -1).filter((index) => index !== -1);
|
|
671
|
+
if (addedIndexes.length === 0) {
|
|
672
|
+
if (options.allowEmptyPayload === true && hunk.lines.length === 0) {
|
|
673
|
+
return { before: Buffer2.alloc(0), after: Buffer2.alloc(0) };
|
|
674
|
+
}
|
|
675
|
+
fail("parse_error", "Target hunk must contain added payload lines");
|
|
676
|
+
}
|
|
677
|
+
assertContiguous(addedIndexes, "Target payload lines must be contiguous");
|
|
678
|
+
const firstAdded = addedIndexes[0];
|
|
679
|
+
const lastAdded = addedIndexes[addedIndexes.length - 1];
|
|
680
|
+
const beforeLines = hunk.lines.slice(0, firstAdded);
|
|
681
|
+
const afterLines = hunk.lines.slice(lastAdded + 1);
|
|
682
|
+
if (!beforeLines.every((line) => line.prefix === " ") || !afterLines.every((line) => line.prefix === " ")) {
|
|
683
|
+
fail("parse_error", "Target hunk may only contain context lines around added payload");
|
|
684
|
+
}
|
|
685
|
+
const before = concatLines(beforeLines);
|
|
686
|
+
const after = concatLines(afterLines);
|
|
687
|
+
if (before.length === 0 && after.length === 0 && options.allowEmptyAnchors !== true) {
|
|
688
|
+
fail("parse_error", "Target hunk must include context before or after the moved payload");
|
|
689
|
+
}
|
|
690
|
+
return { before, after };
|
|
691
|
+
}
|
|
692
|
+
function payloadBytes(hunk, prefix) {
|
|
693
|
+
return concatLines(hunk.lines.filter((line) => line.prefix === prefix));
|
|
694
|
+
}
|
|
695
|
+
function concatLines(lines) {
|
|
696
|
+
return Buffer2.concat(lines.map((line) => line.content));
|
|
697
|
+
}
|
|
698
|
+
function assertContiguous(indexes, message) {
|
|
699
|
+
for (let index = 1;index < indexes.length; index += 1) {
|
|
700
|
+
if (indexes[index] !== indexes[index - 1] + 1) {
|
|
701
|
+
fail("parse_error", message);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
function parseMetadata(input) {
|
|
706
|
+
const metadata = new Map;
|
|
707
|
+
for (const part of input.trim().split(/\s+/)) {
|
|
708
|
+
const equals = part.indexOf("=");
|
|
709
|
+
if (equals <= 0) {
|
|
710
|
+
fail("parse_error", `Invalid blockpatch move metadata field: ${part}`);
|
|
711
|
+
}
|
|
712
|
+
const key = part.slice(0, equals);
|
|
713
|
+
if (metadata.has(key)) {
|
|
714
|
+
fail("parse_error", `Duplicate blockpatch move metadata field: ${key}`);
|
|
715
|
+
}
|
|
716
|
+
if (!allowedMetadataKeys.has(key) && !key.startsWith("x-")) {
|
|
717
|
+
fail("parse_error", `Unknown blockpatch move metadata field: ${key}`);
|
|
718
|
+
}
|
|
719
|
+
metadata.set(key, part.slice(equals + 1));
|
|
720
|
+
}
|
|
721
|
+
return metadata;
|
|
722
|
+
}
|
|
723
|
+
function parseFileHeader(line, prefix, requiredPathPrefix) {
|
|
724
|
+
const marker = `${prefix} `;
|
|
725
|
+
const rawPath = line?.startsWith(marker) === true ? line.slice(marker.length) : undefined;
|
|
726
|
+
if (rawPath !== undefined) {
|
|
727
|
+
rejectUnsafeDisplayPath(rawPath, `${prefix} file header path`);
|
|
728
|
+
}
|
|
729
|
+
if (rawPath?.trim() === devNull && line?.startsWith(marker)) {
|
|
730
|
+
return devNull;
|
|
731
|
+
}
|
|
732
|
+
if (line?.startsWith(`${marker}${requiredPathPrefix}`) !== true) {
|
|
733
|
+
fail("parse_error", `Patch must include a ${marker}${requiredPathPrefix}<path> or ${marker}${devNull} header`);
|
|
734
|
+
}
|
|
735
|
+
const path = rawPath?.trim() ?? "";
|
|
736
|
+
if (!path || path === requiredPathPrefix) {
|
|
737
|
+
fail("parse_error", `${prefix} file header must include a path`);
|
|
738
|
+
}
|
|
739
|
+
return path;
|
|
740
|
+
}
|
|
741
|
+
function stripPath(path, stripComponents) {
|
|
742
|
+
const stripped = path.split("/").slice(stripComponents).join("/");
|
|
743
|
+
if (!stripped) {
|
|
744
|
+
fail("parse_error", `-p${stripComponents} removes the entire path: ${path}`);
|
|
745
|
+
}
|
|
746
|
+
return stripped;
|
|
747
|
+
}
|
|
748
|
+
function splitLines(input) {
|
|
749
|
+
const lines = [];
|
|
750
|
+
let position = 0;
|
|
751
|
+
while (position < input.length) {
|
|
752
|
+
const lf = input.indexOf(10, position);
|
|
753
|
+
const lineEnd = lf === -1 ? input.length : lf;
|
|
754
|
+
const hasCr = lf !== -1 && lineEnd > position && input[lineEnd - 1] === 13;
|
|
755
|
+
const bodyEnd = hasCr ? lineEnd - 1 : lineEnd;
|
|
756
|
+
const eol = lf === -1 ? Buffer2.alloc(0) : input.subarray(hasCr ? lineEnd - 1 : lineEnd, lf + 1);
|
|
757
|
+
lines.push({
|
|
758
|
+
body: Buffer2.from(input.subarray(position, bodyEnd)),
|
|
759
|
+
eol: Buffer2.from(eol)
|
|
760
|
+
});
|
|
761
|
+
if (lf === -1) {
|
|
762
|
+
break;
|
|
763
|
+
}
|
|
764
|
+
position = lf + 1;
|
|
765
|
+
}
|
|
766
|
+
return lines;
|
|
767
|
+
}
|
|
768
|
+
function text(line) {
|
|
769
|
+
return line?.body.toString("utf8");
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// src/engine.ts
|
|
773
|
+
async function checkPatchFile(patchPath, options = {}) {
|
|
774
|
+
return runPatchFile(patchPath, { ...options, dryRun: true });
|
|
775
|
+
}
|
|
776
|
+
async function checkPatchBytes(patchBytes, options = {}) {
|
|
777
|
+
return runPatchBytes(patchBytes, { ...options, dryRun: true });
|
|
778
|
+
}
|
|
779
|
+
function checkPatchBytesInMemory(patchBytes, files, options = {}) {
|
|
780
|
+
const patch = parseBlockPatch(patchBytes, { stripComponents: options.stripComponents });
|
|
781
|
+
const effectivePatch = options.reverse === true ? reverseMovePatch(patch) : patch;
|
|
782
|
+
return planMovePatch(effectivePatch, memoryFileMap(files)).result;
|
|
783
|
+
}
|
|
784
|
+
async function applyPatchFile(patchPath, options = {}) {
|
|
785
|
+
return runPatchFile(patchPath, options);
|
|
786
|
+
}
|
|
787
|
+
async function applyPatchBytes(patchBytes, options = {}) {
|
|
788
|
+
return runPatchBytes(patchBytes, options);
|
|
789
|
+
}
|
|
790
|
+
async function runPatchFile(patchPath, options) {
|
|
791
|
+
const cwd = resolve2(options.cwd ?? process.cwd());
|
|
792
|
+
const patchBytes = await readFileChecked(resolve2(cwd, patchPath), "patch file");
|
|
793
|
+
return runPatchBytes(patchBytes, { ...options, cwd });
|
|
794
|
+
}
|
|
795
|
+
async function runPatchBytes(patchBytes, options) {
|
|
796
|
+
const cwd = resolve2(options.cwd ?? process.cwd());
|
|
797
|
+
const patch = parseBlockPatch(patchBytes, { stripComponents: options.stripComponents });
|
|
798
|
+
return applyMovePatch(patch, cwd, options.dryRun ?? false, options.reverse ?? false);
|
|
799
|
+
}
|
|
800
|
+
async function applyMovePatch(patch, cwd, dryRun, reverse) {
|
|
801
|
+
const effectivePatch = reverse ? reverseMovePatch(patch) : patch;
|
|
802
|
+
const workspace = await readPatchWorkspace(effectivePatch, cwd);
|
|
803
|
+
const plan = planMovePatch(effectivePatch, workspace.files);
|
|
804
|
+
if (!dryRun) {
|
|
805
|
+
await commitPatchMutations(plan.mutations, workspace.paths);
|
|
806
|
+
}
|
|
807
|
+
return resultWithWriteFlag(plan.result, dryRun);
|
|
808
|
+
}
|
|
809
|
+
async function readPatchWorkspace(patch, cwd) {
|
|
810
|
+
const workspace = {
|
|
811
|
+
files: new Map,
|
|
812
|
+
paths: new Map
|
|
813
|
+
};
|
|
814
|
+
if (patch.src === null || patch.dst === null) {
|
|
815
|
+
if (patch.src === null && patch.dst !== null && !patch.hasSourceHunk) {
|
|
816
|
+
const resolved = resolvePathAllowMissing(cwd, patch.dst, "destination path");
|
|
817
|
+
if (resolved.exists) {
|
|
818
|
+
rememberFile(workspace, patch.dst, resolved.path, await readFileSnapshot(resolved.path, "destination file"));
|
|
819
|
+
} else {
|
|
820
|
+
rememberMissingPath(workspace, patch.dst, resolved.path);
|
|
821
|
+
}
|
|
822
|
+
return workspace;
|
|
823
|
+
}
|
|
824
|
+
if (patch.dst === null && patch.src !== null && patch.hasSourceHunk) {
|
|
825
|
+
const resolved = resolvePathAllowMissing(cwd, patch.src, "source path");
|
|
826
|
+
if (resolved.exists) {
|
|
827
|
+
rememberFile(workspace, patch.src, resolved.path, await readFileSnapshot(resolved.path, "source file"));
|
|
828
|
+
} else {
|
|
829
|
+
rememberMissingPath(workspace, patch.src, resolved.path);
|
|
830
|
+
}
|
|
831
|
+
return workspace;
|
|
832
|
+
}
|
|
833
|
+
fail("parse_error", "Invalid /dev/null endpoint move shape");
|
|
834
|
+
}
|
|
835
|
+
if (!patch.hasSourceHunk) {
|
|
836
|
+
const dstPath2 = resolvePath(cwd, patch.dst, "destination path");
|
|
837
|
+
rememberFile(workspace, patch.dst, dstPath2, await readFileSnapshot(dstPath2, "destination file"));
|
|
838
|
+
return workspace;
|
|
839
|
+
}
|
|
840
|
+
if (patch.target === null) {
|
|
841
|
+
const srcPath2 = resolvePath(cwd, patch.src, "source path");
|
|
842
|
+
rememberFile(workspace, patch.src, srcPath2, await readFileSnapshot(srcPath2, "source file"));
|
|
843
|
+
return workspace;
|
|
844
|
+
}
|
|
845
|
+
const srcPath = resolvePath(cwd, patch.src, "source path");
|
|
846
|
+
const dstPath = resolvePath(cwd, patch.dst, "destination path");
|
|
847
|
+
const sameFile = await sameFileIdentity(srcPath, dstPath);
|
|
848
|
+
const srcSnapshot = await readFileSnapshot(srcPath, "source file");
|
|
849
|
+
const dstSnapshot = sameFile ? srcSnapshot : await readFileSnapshot(dstPath, "destination file");
|
|
850
|
+
const identity = sameFile ? "paired-file" : undefined;
|
|
851
|
+
rememberFile(workspace, patch.src, srcPath, srcSnapshot, identity);
|
|
852
|
+
rememberFile(workspace, patch.dst, dstPath, dstSnapshot, identity);
|
|
853
|
+
return workspace;
|
|
854
|
+
}
|
|
855
|
+
function rememberMissingPath(workspace, label, path) {
|
|
856
|
+
workspace.paths.set(label, { path, missing: true });
|
|
857
|
+
}
|
|
858
|
+
function rememberFile(workspace, label, path, snapshot, identity = path) {
|
|
859
|
+
workspace.paths.set(label, { path, snapshot });
|
|
860
|
+
workspace.files.set(label, { bytes: snapshot.bytes, identity });
|
|
861
|
+
}
|
|
862
|
+
async function commitPatchMutations(mutations, paths) {
|
|
863
|
+
const writes = [];
|
|
864
|
+
const deletes = [];
|
|
865
|
+
for (const mutation of mutations) {
|
|
866
|
+
const pathState = mutationPath(paths, mutation.label);
|
|
867
|
+
if (mutation.kind === "write") {
|
|
868
|
+
writes.push({
|
|
869
|
+
path: pathState.path,
|
|
870
|
+
bytes: mutation.bytes,
|
|
871
|
+
create: mutation.create,
|
|
872
|
+
label: mutation.label,
|
|
873
|
+
expected: writeExpectation(mutation, pathState)
|
|
874
|
+
});
|
|
875
|
+
} else {
|
|
876
|
+
deletes.push({
|
|
877
|
+
path: pathState.path,
|
|
878
|
+
label: mutation.label,
|
|
879
|
+
expected: fileExpectation(mutation.label, pathState)
|
|
880
|
+
});
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
await writeAtomically(writes, deletes);
|
|
884
|
+
}
|
|
885
|
+
function writeExpectation(mutation, state) {
|
|
886
|
+
if (state.snapshot !== undefined) {
|
|
887
|
+
return { kind: "file", label: mutation.label, snapshot: state.snapshot };
|
|
888
|
+
}
|
|
889
|
+
if (state.missing === true && mutation.create === true) {
|
|
890
|
+
return { kind: "missing", label: mutation.label, bytesIfExists: mutation.bytes };
|
|
891
|
+
}
|
|
892
|
+
fail("parse_error", `No original state for planned write: ${mutation.label}`, { path: mutation.label, phase: "path" });
|
|
893
|
+
}
|
|
894
|
+
function fileExpectation(label, state) {
|
|
895
|
+
if (state.snapshot !== undefined) {
|
|
896
|
+
return { kind: "file", label, snapshot: state.snapshot };
|
|
897
|
+
}
|
|
898
|
+
fail("parse_error", `No original file state for planned mutation: ${label}`, { path: label, phase: "path" });
|
|
899
|
+
}
|
|
900
|
+
function mutationPath(paths, label) {
|
|
901
|
+
const state = paths.get(label);
|
|
902
|
+
if (state === undefined) {
|
|
903
|
+
fail("parse_error", `No resolved path for planned mutation: ${label}`, { path: label, phase: "path" });
|
|
904
|
+
}
|
|
905
|
+
return state;
|
|
906
|
+
}
|
|
907
|
+
function resultWithWriteFlag(result, dryRun) {
|
|
908
|
+
return {
|
|
909
|
+
...result,
|
|
910
|
+
written: result.status === "applied" && !dryRun && result.changed.length > 0
|
|
911
|
+
};
|
|
912
|
+
}
|
|
913
|
+
function planMovePatch(effectivePatch, files) {
|
|
914
|
+
if (effectivePatch.src === null || effectivePatch.dst === null) {
|
|
915
|
+
if (effectivePatch.src === null && effectivePatch.dst !== null && !effectivePatch.hasSourceHunk) {
|
|
916
|
+
return planPathCreationMove(effectivePatch, effectivePatch.dst, files);
|
|
917
|
+
}
|
|
918
|
+
if (effectivePatch.dst === null && effectivePatch.src !== null && effectivePatch.hasSourceHunk) {
|
|
919
|
+
return planPathDeletionMove(effectivePatch, effectivePatch.src, files);
|
|
920
|
+
}
|
|
921
|
+
fail("parse_error", "Invalid /dev/null endpoint move shape");
|
|
922
|
+
}
|
|
923
|
+
if (!effectivePatch.hasSourceHunk) {
|
|
924
|
+
return planInFileInsertionMove(effectivePatch, effectivePatch.src, effectivePatch.dst, files);
|
|
925
|
+
}
|
|
926
|
+
if (effectivePatch.target === null) {
|
|
927
|
+
return planInFileDeletionMove(effectivePatch, effectivePatch.src, effectivePatch.dst, files);
|
|
928
|
+
}
|
|
929
|
+
return planPairedMove(effectivePatch, files);
|
|
930
|
+
}
|
|
931
|
+
function planPairedMove(patch, files) {
|
|
932
|
+
if (patch.src === null || patch.dst === null) {
|
|
933
|
+
fail("parse_error", "Paired move requires source and destination paths");
|
|
934
|
+
}
|
|
935
|
+
const srcLabel = patch.src;
|
|
936
|
+
const dstLabel = patch.dst;
|
|
937
|
+
const srcFile = readMemoryFile(files, srcLabel, "source file");
|
|
938
|
+
const sameFile = sameMemoryFile(files, srcLabel, dstLabel);
|
|
939
|
+
const dstFile = sameFile ? srcFile : readMemoryFile(files, dstLabel, "destination file");
|
|
940
|
+
const plan = selectMovePlan(srcFile, dstFile, patch, sameFile);
|
|
941
|
+
if (plan.status === "already_applied") {
|
|
942
|
+
return {
|
|
943
|
+
result: {
|
|
944
|
+
changed: [],
|
|
945
|
+
affected: unique([srcLabel, dstLabel]),
|
|
946
|
+
written: false,
|
|
947
|
+
noop: true,
|
|
948
|
+
status: "already_applied",
|
|
949
|
+
moves: [plan.details]
|
|
950
|
+
},
|
|
951
|
+
mutations: []
|
|
952
|
+
};
|
|
953
|
+
}
|
|
954
|
+
const selection = plan.selection;
|
|
955
|
+
const next = sameFile ? applyMove(srcFile, selection) : applyCrossFileMove(srcFile, dstFile, selection);
|
|
956
|
+
const changed = changedMoveLabels(srcLabel, dstLabel, sameFile, srcFile, dstFile, next);
|
|
957
|
+
return {
|
|
958
|
+
result: {
|
|
959
|
+
changed,
|
|
960
|
+
affected: unique([srcLabel, dstLabel]),
|
|
961
|
+
written: false,
|
|
962
|
+
noop: changed.length === 0,
|
|
963
|
+
status: changed.length === 0 ? "noop" : "applied",
|
|
964
|
+
moves: [
|
|
965
|
+
moveResultDetails({
|
|
966
|
+
id: patch.id,
|
|
967
|
+
src: srcLabel,
|
|
968
|
+
dst: dstLabel,
|
|
969
|
+
payloadSha256: patch.payloadSha256,
|
|
970
|
+
selection
|
|
971
|
+
})
|
|
972
|
+
]
|
|
973
|
+
},
|
|
974
|
+
mutations: moveMutations(srcLabel, dstLabel, sameFile, srcFile, dstFile, next)
|
|
975
|
+
};
|
|
976
|
+
}
|
|
977
|
+
function reverseMovePatch(patch) {
|
|
978
|
+
const reversedTarget = patch.hasSourceHunk ? {
|
|
979
|
+
before: patch.sourceBefore,
|
|
980
|
+
after: patch.sourceAfter
|
|
981
|
+
} : null;
|
|
982
|
+
return {
|
|
983
|
+
...patch,
|
|
984
|
+
src: patch.dst,
|
|
985
|
+
dst: patch.src,
|
|
986
|
+
hasSourceHunk: patch.target !== null,
|
|
987
|
+
sourceBefore: patch.target?.before ?? Buffer3.alloc(0),
|
|
988
|
+
sourcePayload: patch.sourcePayload,
|
|
989
|
+
sourceAfter: patch.target?.after ?? Buffer3.alloc(0),
|
|
990
|
+
target: reversedTarget
|
|
991
|
+
};
|
|
992
|
+
}
|
|
993
|
+
function planInFileInsertionMove(patch, srcLabel, dstLabel, files) {
|
|
994
|
+
const target = requireTarget(patch);
|
|
995
|
+
const original = readMemoryFile(files, dstLabel, "destination file");
|
|
996
|
+
const alreadyApplied = findAlreadyAppliedTargetSelection(original, patch, dstLabel);
|
|
997
|
+
if (alreadyApplied !== undefined) {
|
|
998
|
+
return {
|
|
999
|
+
result: oneSidedResult({
|
|
1000
|
+
patch,
|
|
1001
|
+
src: srcLabel,
|
|
1002
|
+
dst: dstLabel,
|
|
1003
|
+
changed: [],
|
|
1004
|
+
status: "already_applied",
|
|
1005
|
+
sourceRange: null,
|
|
1006
|
+
targetRange: alreadyApplied.range,
|
|
1007
|
+
insertIndex: alreadyApplied.insertIndex
|
|
1008
|
+
}),
|
|
1009
|
+
mutations: []
|
|
1010
|
+
};
|
|
1011
|
+
}
|
|
1012
|
+
const selection = findTargetSelection(original, target.before, target.after, dstLabel, {
|
|
1013
|
+
phase: "target",
|
|
1014
|
+
anchor: "blockpatch-target"
|
|
1015
|
+
});
|
|
1016
|
+
const next = Buffer3.concat([
|
|
1017
|
+
original.subarray(0, selection.insertIndex),
|
|
1018
|
+
patch.sourcePayload,
|
|
1019
|
+
original.subarray(selection.insertIndex)
|
|
1020
|
+
]);
|
|
1021
|
+
const changed = next.equals(original) ? [] : unique([srcLabel, dstLabel]);
|
|
1022
|
+
return {
|
|
1023
|
+
result: oneSidedResult({
|
|
1024
|
+
patch,
|
|
1025
|
+
src: srcLabel,
|
|
1026
|
+
dst: dstLabel,
|
|
1027
|
+
changed,
|
|
1028
|
+
status: changed.length === 0 ? "noop" : "applied",
|
|
1029
|
+
sourceRange: null,
|
|
1030
|
+
targetRange: selection.range,
|
|
1031
|
+
insertIndex: selection.insertIndex
|
|
1032
|
+
}),
|
|
1033
|
+
mutations: changed.length === 0 ? [] : [{ kind: "write", label: dstLabel, bytes: next }]
|
|
1034
|
+
};
|
|
1035
|
+
}
|
|
1036
|
+
function planInFileDeletionMove(patch, srcLabel, dstLabel, files) {
|
|
1037
|
+
const original = readMemoryFile(files, srcLabel, "source file");
|
|
1038
|
+
const source = findDeletionSourceRange(original, patch, srcLabel);
|
|
1039
|
+
if (source === undefined) {
|
|
1040
|
+
return {
|
|
1041
|
+
result: oneSidedResult({
|
|
1042
|
+
patch,
|
|
1043
|
+
src: srcLabel,
|
|
1044
|
+
dst: dstLabel,
|
|
1045
|
+
changed: [],
|
|
1046
|
+
status: "already_applied",
|
|
1047
|
+
sourceRange: null,
|
|
1048
|
+
targetRange: null,
|
|
1049
|
+
insertIndex: null
|
|
1050
|
+
}),
|
|
1051
|
+
mutations: []
|
|
1052
|
+
};
|
|
1053
|
+
}
|
|
1054
|
+
const next = Buffer3.concat([original.subarray(0, source.start), original.subarray(source.end)]);
|
|
1055
|
+
const changed = next.equals(original) ? [] : unique([srcLabel, dstLabel]);
|
|
1056
|
+
return {
|
|
1057
|
+
result: oneSidedResult({
|
|
1058
|
+
patch,
|
|
1059
|
+
src: srcLabel,
|
|
1060
|
+
dst: dstLabel,
|
|
1061
|
+
changed,
|
|
1062
|
+
status: changed.length === 0 ? "noop" : "applied",
|
|
1063
|
+
sourceRange: source,
|
|
1064
|
+
targetRange: null,
|
|
1065
|
+
insertIndex: null
|
|
1066
|
+
}),
|
|
1067
|
+
mutations: changed.length === 0 ? [] : [{ kind: "write", label: srcLabel, bytes: next }]
|
|
1068
|
+
};
|
|
1069
|
+
}
|
|
1070
|
+
function oneSidedResult(args) {
|
|
1071
|
+
return {
|
|
1072
|
+
changed: args.changed,
|
|
1073
|
+
affected: unique([args.src, args.dst]),
|
|
1074
|
+
written: false,
|
|
1075
|
+
noop: args.status !== "applied" || args.changed.length === 0,
|
|
1076
|
+
status: args.status,
|
|
1077
|
+
moves: [
|
|
1078
|
+
{
|
|
1079
|
+
id: args.patch.id,
|
|
1080
|
+
src: args.src,
|
|
1081
|
+
dst: args.dst,
|
|
1082
|
+
payload_sha256: args.patch.payloadSha256,
|
|
1083
|
+
payload_bytes: args.patch.sourcePayload.length,
|
|
1084
|
+
source_range: args.sourceRange,
|
|
1085
|
+
target_range: args.targetRange,
|
|
1086
|
+
insert_index: args.insertIndex
|
|
1087
|
+
}
|
|
1088
|
+
]
|
|
1089
|
+
};
|
|
1090
|
+
}
|
|
1091
|
+
function planPathCreationMove(patch, dstLabel, files) {
|
|
1092
|
+
const fullTarget = fullTargetBytes(patch);
|
|
1093
|
+
const original = files.get(dstLabel)?.bytes;
|
|
1094
|
+
if (original !== undefined) {
|
|
1095
|
+
if (original.equals(fullTarget)) {
|
|
1096
|
+
return {
|
|
1097
|
+
result: nullSourceResult(patch, dstLabel, "already_applied", { start: 0, end: original.length }, 0),
|
|
1098
|
+
mutations: []
|
|
1099
|
+
};
|
|
1100
|
+
}
|
|
1101
|
+
fail("destination_exists", `Destination path for file creation already exists with different bytes: ${dstLabel}`, {
|
|
1102
|
+
path: dstLabel,
|
|
1103
|
+
phase: "target",
|
|
1104
|
+
anchor: "blockpatch-target"
|
|
1105
|
+
});
|
|
1106
|
+
}
|
|
1107
|
+
return {
|
|
1108
|
+
result: nullSourceResult(patch, dstLabel, "applied", { start: 0, end: 0 }, 0),
|
|
1109
|
+
mutations: [{ kind: "write", label: dstLabel, bytes: fullTarget, create: true }]
|
|
1110
|
+
};
|
|
1111
|
+
}
|
|
1112
|
+
function planPathDeletionMove(patch, srcLabel, files) {
|
|
1113
|
+
const original = files.get(srcLabel)?.bytes;
|
|
1114
|
+
if (original === undefined) {
|
|
1115
|
+
return {
|
|
1116
|
+
result: nullTargetResult(patch, srcLabel, "already_applied", null),
|
|
1117
|
+
mutations: []
|
|
1118
|
+
};
|
|
1119
|
+
}
|
|
1120
|
+
const fullSource = Buffer3.concat([patch.sourceBefore, patch.sourcePayload, patch.sourceAfter]);
|
|
1121
|
+
if (!original.equals(fullSource)) {
|
|
1122
|
+
fail("source_not_found", `Whole-file source payload was not found in ${srcLabel}`, {
|
|
1123
|
+
path: srcLabel,
|
|
1124
|
+
phase: "source",
|
|
1125
|
+
anchor: "blockpatch-source",
|
|
1126
|
+
matches: 0
|
|
1127
|
+
});
|
|
1128
|
+
}
|
|
1129
|
+
return {
|
|
1130
|
+
result: nullTargetResult(patch, srcLabel, "applied", {
|
|
1131
|
+
start: patch.sourceBefore.length,
|
|
1132
|
+
end: patch.sourceBefore.length + patch.sourcePayload.length
|
|
1133
|
+
}),
|
|
1134
|
+
mutations: [{ kind: "delete", label: srcLabel }]
|
|
1135
|
+
};
|
|
1136
|
+
}
|
|
1137
|
+
function fullTargetBytes(patch) {
|
|
1138
|
+
const target = requireTarget(patch);
|
|
1139
|
+
return Buffer3.concat([target.before, patch.sourcePayload, target.after]);
|
|
1140
|
+
}
|
|
1141
|
+
function requireTarget(patch) {
|
|
1142
|
+
if (patch.target === null) {
|
|
1143
|
+
fail("parse_error", "Patch shape requires a blockpatch-target hunk");
|
|
1144
|
+
}
|
|
1145
|
+
return patch.target;
|
|
1146
|
+
}
|
|
1147
|
+
function findDeletionSourceRange(file, patch, srcLabel) {
|
|
1148
|
+
const fullSource = Buffer3.concat([patch.sourceBefore, patch.sourcePayload, patch.sourceAfter]);
|
|
1149
|
+
const fullMatchResult = indexesOfLimited(file, fullSource);
|
|
1150
|
+
const fullMatches = fullMatchResult.matches;
|
|
1151
|
+
if (fullMatches.length === 1 && !fullMatchResult.truncated) {
|
|
1152
|
+
const start = fullMatches[0] + patch.sourceBefore.length;
|
|
1153
|
+
return { start, end: start + patch.sourcePayload.length };
|
|
1154
|
+
}
|
|
1155
|
+
if (fullMatches.length > 1 || fullMatchResult.truncated) {
|
|
1156
|
+
fail("source_ambiguous", `Source block is ambiguous in ${srcLabel}; ${matchedLocations(fullMatches.length, fullMatchResult.truncated)}`, {
|
|
1157
|
+
path: srcLabel,
|
|
1158
|
+
phase: "source",
|
|
1159
|
+
anchor: "blockpatch-source",
|
|
1160
|
+
...matchCountDetails(fullMatches.length, fullMatchResult.truncated),
|
|
1161
|
+
ranges: boundedMatchRanges(fullMatches, fullSource.length),
|
|
1162
|
+
line_ranges: boundedMatchLineRanges(file, fullMatches, fullSource.length)
|
|
1163
|
+
});
|
|
1164
|
+
}
|
|
1165
|
+
return deletionAlreadyAppliedOrFail(file, patch, srcLabel);
|
|
1166
|
+
}
|
|
1167
|
+
function deletionAlreadyAppliedOrFail(file, patch, srcLabel) {
|
|
1168
|
+
const anchorless = patch.sourceBefore.length === 0 && patch.sourceAfter.length === 0;
|
|
1169
|
+
if (anchorless) {
|
|
1170
|
+
return;
|
|
1171
|
+
}
|
|
1172
|
+
const adjacent = Buffer3.concat([patch.sourceBefore, patch.sourceAfter]);
|
|
1173
|
+
const adjacentMatchResult = indexesOfLimitedWhere(file, adjacent, (start) => isDeletionAlreadyAppliedMatch(file, patch, start));
|
|
1174
|
+
const adjacentMatches = adjacentMatchResult.matches;
|
|
1175
|
+
if (adjacentMatches.length === 1 && !adjacentMatchResult.truncated) {
|
|
1176
|
+
return;
|
|
1177
|
+
}
|
|
1178
|
+
if (adjacentMatches.length > 1 || adjacentMatchResult.truncated) {
|
|
1179
|
+
fail("source_ambiguous", `Already-deleted source anchors are ambiguous in ${srcLabel}`, {
|
|
1180
|
+
path: srcLabel,
|
|
1181
|
+
phase: "source",
|
|
1182
|
+
anchor: "blockpatch-source",
|
|
1183
|
+
...matchCountDetails(adjacentMatches.length, adjacentMatchResult.truncated),
|
|
1184
|
+
ranges: boundedMatchRanges(adjacentMatches, adjacent.length),
|
|
1185
|
+
line_ranges: boundedMatchLineRanges(file, adjacentMatches, adjacent.length)
|
|
1186
|
+
});
|
|
1187
|
+
}
|
|
1188
|
+
const envelopes = findSourceEnvelopes(file, patch);
|
|
1189
|
+
if (envelopes.ranges.length === 1 && !envelopes.truncated) {
|
|
1190
|
+
fail("payload_mismatch", `Source payload does not match located source anchors in ${srcLabel}`, {
|
|
1191
|
+
path: srcLabel,
|
|
1192
|
+
phase: "payload",
|
|
1193
|
+
anchor: "blockpatch-source"
|
|
1194
|
+
});
|
|
1195
|
+
}
|
|
1196
|
+
if (envelopes.ranges.length > 1 || envelopes.truncated) {
|
|
1197
|
+
fail("source_ambiguous", `Source anchors are ambiguous in ${srcLabel}; ${matchedLocations(envelopes.ranges.length, envelopes.truncated)}`, {
|
|
1198
|
+
path: srcLabel,
|
|
1199
|
+
phase: "source",
|
|
1200
|
+
anchor: "blockpatch-source",
|
|
1201
|
+
...matchCountDetails(envelopes.ranges.length, envelopes.truncated),
|
|
1202
|
+
ranges: boundedRanges(envelopes.ranges),
|
|
1203
|
+
line_ranges: boundedLineRanges(file, envelopes.ranges)
|
|
1204
|
+
});
|
|
1205
|
+
}
|
|
1206
|
+
fail("source_not_found", `Source anchors were not found in ${srcLabel}`, {
|
|
1207
|
+
path: srcLabel,
|
|
1208
|
+
phase: "source",
|
|
1209
|
+
anchor: "blockpatch-source",
|
|
1210
|
+
matches: 0
|
|
1211
|
+
});
|
|
1212
|
+
}
|
|
1213
|
+
function isDeletionAlreadyAppliedMatch(file, patch, start) {
|
|
1214
|
+
if (patch.sourceBefore.length === 0) {
|
|
1215
|
+
return start === 0;
|
|
1216
|
+
}
|
|
1217
|
+
if (patch.sourceAfter.length === 0) {
|
|
1218
|
+
return start + patch.sourceBefore.length === file.length;
|
|
1219
|
+
}
|
|
1220
|
+
return true;
|
|
1221
|
+
}
|
|
1222
|
+
function nullSourceResult(patch, dstLabel, status, targetRange, insertIndex) {
|
|
1223
|
+
const applied = status === "applied";
|
|
1224
|
+
return {
|
|
1225
|
+
changed: applied ? [dstLabel] : [],
|
|
1226
|
+
affected: [dstLabel],
|
|
1227
|
+
written: false,
|
|
1228
|
+
noop: !applied,
|
|
1229
|
+
status,
|
|
1230
|
+
moves: [
|
|
1231
|
+
{
|
|
1232
|
+
id: patch.id,
|
|
1233
|
+
src: devNull,
|
|
1234
|
+
dst: dstLabel,
|
|
1235
|
+
payload_sha256: patch.payloadSha256,
|
|
1236
|
+
payload_bytes: patch.sourcePayload.length,
|
|
1237
|
+
source_range: null,
|
|
1238
|
+
target_range: targetRange,
|
|
1239
|
+
insert_index: insertIndex
|
|
1240
|
+
}
|
|
1241
|
+
]
|
|
1242
|
+
};
|
|
1243
|
+
}
|
|
1244
|
+
function nullTargetResult(patch, srcLabel, status, sourceRange) {
|
|
1245
|
+
const applied = status === "applied";
|
|
1246
|
+
return {
|
|
1247
|
+
changed: applied ? [srcLabel] : [],
|
|
1248
|
+
affected: [srcLabel],
|
|
1249
|
+
written: false,
|
|
1250
|
+
noop: !applied,
|
|
1251
|
+
status,
|
|
1252
|
+
moves: [
|
|
1253
|
+
{
|
|
1254
|
+
id: patch.id,
|
|
1255
|
+
src: srcLabel,
|
|
1256
|
+
dst: devNull,
|
|
1257
|
+
payload_sha256: patch.payloadSha256,
|
|
1258
|
+
payload_bytes: patch.sourcePayload.length,
|
|
1259
|
+
source_range: sourceRange,
|
|
1260
|
+
target_range: null,
|
|
1261
|
+
insert_index: null
|
|
1262
|
+
}
|
|
1263
|
+
]
|
|
1264
|
+
};
|
|
1265
|
+
}
|
|
1266
|
+
function selectMovePlan(srcFile, dstFile, patch, sameFile) {
|
|
1267
|
+
const srcLabel = patch.src ?? devNull;
|
|
1268
|
+
const dstLabel = patch.dst ?? devNull;
|
|
1269
|
+
const targetAnchor = requireTarget(patch);
|
|
1270
|
+
const source = findSourceRange(srcFile, dstFile, patch);
|
|
1271
|
+
if (source === undefined) {
|
|
1272
|
+
const target2 = findAlreadyAppliedTargetSelection(dstFile, patch);
|
|
1273
|
+
if (target2 === undefined) {
|
|
1274
|
+
fail("source_not_found", `Source anchors were not found in ${srcLabel}`, {
|
|
1275
|
+
path: srcLabel,
|
|
1276
|
+
phase: "source",
|
|
1277
|
+
anchor: "blockpatch-source",
|
|
1278
|
+
matches: 0
|
|
1279
|
+
});
|
|
1280
|
+
}
|
|
1281
|
+
return {
|
|
1282
|
+
status: "already_applied",
|
|
1283
|
+
details: alreadyAppliedMoveResultDetails({
|
|
1284
|
+
id: patch.id,
|
|
1285
|
+
src: srcLabel,
|
|
1286
|
+
dst: dstLabel,
|
|
1287
|
+
payloadSha256: patch.payloadSha256,
|
|
1288
|
+
payload: patch.sourcePayload,
|
|
1289
|
+
target: target2
|
|
1290
|
+
})
|
|
1291
|
+
};
|
|
1292
|
+
}
|
|
1293
|
+
if (!sameFile) {
|
|
1294
|
+
const alreadyAppliedTarget = findAlreadyAppliedTargetSelection(dstFile, patch, dstLabel);
|
|
1295
|
+
if (alreadyAppliedTarget !== undefined) {
|
|
1296
|
+
failPartialAppliedDuplicate(srcLabel, dstLabel, patch, source, alreadyAppliedTarget);
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
const target = findTargetSelection(dstFile, targetAnchor.before, targetAnchor.after, dstLabel, {
|
|
1300
|
+
phase: "target",
|
|
1301
|
+
anchor: "blockpatch-target"
|
|
1302
|
+
});
|
|
1303
|
+
return {
|
|
1304
|
+
status: "pending",
|
|
1305
|
+
selection: buildMoveSelection(srcFile, source, target, sameFile, dstLabel)
|
|
1306
|
+
};
|
|
1307
|
+
}
|
|
1308
|
+
function failPartialAppliedDuplicate(srcLabel, dstLabel, patch, source, target) {
|
|
1309
|
+
fail("partial_applied_duplicate", `Cross-file move appears partially applied: ${dstLabel} already contains the moved payload while ${srcLabel} still contains it`, {
|
|
1310
|
+
path: dstLabel,
|
|
1311
|
+
phase: "target",
|
|
1312
|
+
anchor: "blockpatch-target",
|
|
1313
|
+
source_range: source,
|
|
1314
|
+
target_range: target.range,
|
|
1315
|
+
payload_sha256: patch.payloadSha256,
|
|
1316
|
+
suggested_action: "review_then_remove_source"
|
|
1317
|
+
});
|
|
1318
|
+
}
|
|
1319
|
+
function buildMoveSelection(srcFile, source, target, sameFile, dstLabel) {
|
|
1320
|
+
if (sameFile && rangesOverlap(source, target.range)) {
|
|
1321
|
+
fail("target_overlaps_source", `Target anchor for ${dstLabel} overlaps the source block`, {
|
|
1322
|
+
path: dstLabel,
|
|
1323
|
+
phase: "target",
|
|
1324
|
+
anchor: "blockpatch-target"
|
|
1325
|
+
});
|
|
1326
|
+
}
|
|
1327
|
+
return {
|
|
1328
|
+
source,
|
|
1329
|
+
target,
|
|
1330
|
+
payload: Buffer3.from(srcFile.subarray(source.start, source.end))
|
|
1331
|
+
};
|
|
1332
|
+
}
|
|
1333
|
+
async function commitMove(args) {
|
|
1334
|
+
const next = args.sameFile ? applyMove(args.srcOriginal, args.selection) : applyCrossFileMove(args.srcOriginal, args.dstOriginal, args.selection);
|
|
1335
|
+
const srcChanged = !next.src.equals(args.srcOriginal);
|
|
1336
|
+
const dstChanged = !args.sameFile && !next.dst.equals(args.dstOriginal);
|
|
1337
|
+
const sameFileAlias = args.sameFile && args.srcPath !== args.dstPath;
|
|
1338
|
+
if (!args.dryRun) {
|
|
1339
|
+
const writes = [];
|
|
1340
|
+
if (dstChanged || sameFileAlias && srcChanged) {
|
|
1341
|
+
writes.push({
|
|
1342
|
+
path: args.dstPath,
|
|
1343
|
+
bytes: next.dst,
|
|
1344
|
+
label: args.dstLabel,
|
|
1345
|
+
expected: args.dstSnapshot === undefined ? undefined : { kind: "file", label: args.dstLabel, snapshot: args.dstSnapshot }
|
|
1346
|
+
});
|
|
1347
|
+
}
|
|
1348
|
+
if (srcChanged) {
|
|
1349
|
+
writes.push({
|
|
1350
|
+
path: args.srcPath,
|
|
1351
|
+
bytes: next.src,
|
|
1352
|
+
label: args.srcLabel,
|
|
1353
|
+
expected: args.srcSnapshot === undefined ? undefined : { kind: "file", label: args.srcLabel, snapshot: args.srcSnapshot }
|
|
1354
|
+
});
|
|
1355
|
+
}
|
|
1356
|
+
await writeAtomically(writes);
|
|
1357
|
+
}
|
|
1358
|
+
const changed = [];
|
|
1359
|
+
if (srcChanged) {
|
|
1360
|
+
changed.push(args.srcLabel);
|
|
1361
|
+
if (sameFileAlias) {
|
|
1362
|
+
changed.push(args.dstLabel);
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
if (dstChanged) {
|
|
1366
|
+
changed.push(args.dstLabel);
|
|
1367
|
+
}
|
|
1368
|
+
return unique(changed);
|
|
1369
|
+
}
|
|
1370
|
+
function applyMove(file, selection) {
|
|
1371
|
+
const withoutSource = Buffer3.concat([
|
|
1372
|
+
file.subarray(0, selection.source.start),
|
|
1373
|
+
file.subarray(selection.source.end)
|
|
1374
|
+
]);
|
|
1375
|
+
const targetIndex = selection.target.insertIndex >= selection.source.end ? selection.target.insertIndex - selection.payload.length : selection.target.insertIndex;
|
|
1376
|
+
const next = Buffer3.concat([
|
|
1377
|
+
withoutSource.subarray(0, targetIndex),
|
|
1378
|
+
selection.payload,
|
|
1379
|
+
withoutSource.subarray(targetIndex)
|
|
1380
|
+
]);
|
|
1381
|
+
return { src: next, dst: next };
|
|
1382
|
+
}
|
|
1383
|
+
function applyCrossFileMove(srcFile, dstFile, selection) {
|
|
1384
|
+
return {
|
|
1385
|
+
src: Buffer3.concat([srcFile.subarray(0, selection.source.start), srcFile.subarray(selection.source.end)]),
|
|
1386
|
+
dst: Buffer3.concat([
|
|
1387
|
+
dstFile.subarray(0, selection.target.insertIndex),
|
|
1388
|
+
selection.payload,
|
|
1389
|
+
dstFile.subarray(selection.target.insertIndex)
|
|
1390
|
+
])
|
|
1391
|
+
};
|
|
1392
|
+
}
|
|
1393
|
+
function findSourceRange(srcFile, dstFile, patch) {
|
|
1394
|
+
const srcLabel = patch.src ?? devNull;
|
|
1395
|
+
const fullSource = Buffer3.concat([patch.sourceBefore, patch.sourcePayload, patch.sourceAfter]);
|
|
1396
|
+
const fullMatchResult = indexesOfLimited(srcFile, fullSource);
|
|
1397
|
+
const fullMatches = fullMatchResult.matches;
|
|
1398
|
+
if (fullMatches.length === 1 && !fullMatchResult.truncated) {
|
|
1399
|
+
const start = fullMatches[0] + patch.sourceBefore.length;
|
|
1400
|
+
return { start, end: start + patch.sourcePayload.length };
|
|
1401
|
+
}
|
|
1402
|
+
if (fullMatches.length > 1 || fullMatchResult.truncated) {
|
|
1403
|
+
fail("source_ambiguous", `Source block is ambiguous in ${srcLabel}; ${matchedLocations(fullMatches.length, fullMatchResult.truncated)}`, {
|
|
1404
|
+
path: srcLabel,
|
|
1405
|
+
phase: "source",
|
|
1406
|
+
anchor: "blockpatch-source",
|
|
1407
|
+
...matchCountDetails(fullMatches.length, fullMatchResult.truncated),
|
|
1408
|
+
ranges: boundedMatchRanges(fullMatches, fullSource.length),
|
|
1409
|
+
line_ranges: boundedMatchLineRanges(srcFile, fullMatches, fullSource.length)
|
|
1410
|
+
});
|
|
1411
|
+
}
|
|
1412
|
+
const alreadyApplied = findAlreadyAppliedTargetSelection(dstFile, patch);
|
|
1413
|
+
if (alreadyApplied !== undefined) {
|
|
1414
|
+
return;
|
|
1415
|
+
}
|
|
1416
|
+
const envelopes = findSourceEnvelopes(srcFile, patch);
|
|
1417
|
+
if (envelopes.ranges.length === 1 && !envelopes.truncated) {
|
|
1418
|
+
fail("payload_mismatch", `Source payload does not match located source anchors in ${srcLabel}`, {
|
|
1419
|
+
path: srcLabel,
|
|
1420
|
+
phase: "payload",
|
|
1421
|
+
anchor: "blockpatch-source"
|
|
1422
|
+
});
|
|
1423
|
+
}
|
|
1424
|
+
if (envelopes.ranges.length > 1 || envelopes.truncated) {
|
|
1425
|
+
fail("source_ambiguous", `Source anchors are ambiguous in ${srcLabel}; ${matchedLocations(envelopes.ranges.length, envelopes.truncated)}`, {
|
|
1426
|
+
path: srcLabel,
|
|
1427
|
+
phase: "source",
|
|
1428
|
+
anchor: "blockpatch-source",
|
|
1429
|
+
...matchCountDetails(envelopes.ranges.length, envelopes.truncated),
|
|
1430
|
+
ranges: boundedRanges(envelopes.ranges),
|
|
1431
|
+
line_ranges: boundedLineRanges(srcFile, envelopes.ranges)
|
|
1432
|
+
});
|
|
1433
|
+
}
|
|
1434
|
+
fail("source_not_found", `Source anchors were not found in ${srcLabel}`, {
|
|
1435
|
+
path: srcLabel,
|
|
1436
|
+
phase: "source",
|
|
1437
|
+
anchor: "blockpatch-source",
|
|
1438
|
+
matches: 0
|
|
1439
|
+
});
|
|
1440
|
+
}
|
|
1441
|
+
function findAlreadyAppliedTargetSelection(file, patch, dstLabel = patch.dst ?? devNull) {
|
|
1442
|
+
const target = requireTarget(patch);
|
|
1443
|
+
const alreadyApplied = Buffer3.concat([target.before, patch.sourcePayload, target.after]);
|
|
1444
|
+
const matchResult = indexesOfLimited(file, alreadyApplied);
|
|
1445
|
+
const matches = matchResult.matches;
|
|
1446
|
+
if (matches.length === 0) {
|
|
1447
|
+
return;
|
|
1448
|
+
}
|
|
1449
|
+
if (matches.length > 1 || matchResult.truncated) {
|
|
1450
|
+
fail("target_ambiguous", `Already-applied target is ambiguous in ${dstLabel}; ${matchedLocations(matches.length, matchResult.truncated)}`, {
|
|
1451
|
+
path: dstLabel,
|
|
1452
|
+
phase: "target",
|
|
1453
|
+
anchor: "blockpatch-target",
|
|
1454
|
+
...matchCountDetails(matches.length, matchResult.truncated),
|
|
1455
|
+
ranges: boundedMatchRanges(matches, alreadyApplied.length),
|
|
1456
|
+
line_ranges: boundedMatchLineRanges(file, matches, alreadyApplied.length)
|
|
1457
|
+
});
|
|
1458
|
+
}
|
|
1459
|
+
const start = matches[0];
|
|
1460
|
+
return {
|
|
1461
|
+
range: { start, end: start + alreadyApplied.length },
|
|
1462
|
+
insertIndex: start + target.before.length
|
|
1463
|
+
};
|
|
1464
|
+
}
|
|
1465
|
+
function findSourceEnvelopes(file, patch, limit = 11) {
|
|
1466
|
+
if (patch.sourceBefore.length === 0 || patch.sourceAfter.length === 0) {
|
|
1467
|
+
return { ranges: [], truncated: false };
|
|
1468
|
+
}
|
|
1469
|
+
const maxMatches = normalizedLimit(limit);
|
|
1470
|
+
const ranges = [];
|
|
1471
|
+
let beforeStart = file.indexOf(patch.sourceBefore);
|
|
1472
|
+
while (beforeStart !== -1) {
|
|
1473
|
+
const payloadStart = beforeStart + patch.sourceBefore.length;
|
|
1474
|
+
const afterStart = file.indexOf(patch.sourceAfter, payloadStart);
|
|
1475
|
+
if (afterStart !== -1) {
|
|
1476
|
+
if (ranges.length >= maxMatches) {
|
|
1477
|
+
return { ranges, truncated: true };
|
|
1478
|
+
}
|
|
1479
|
+
ranges.push({ start: payloadStart, end: afterStart });
|
|
1480
|
+
}
|
|
1481
|
+
beforeStart = file.indexOf(patch.sourceBefore, beforeStart + 1);
|
|
1482
|
+
}
|
|
1483
|
+
return { ranges, truncated: false };
|
|
1484
|
+
}
|
|
1485
|
+
function findTargetSelection(file, before, after, dstLabel, details = {}) {
|
|
1486
|
+
const anchor = Buffer3.concat([before, after]);
|
|
1487
|
+
const matchResult = indexesOfLimited(file, anchor);
|
|
1488
|
+
const matches = matchResult.matches;
|
|
1489
|
+
if (matches.length === 0) {
|
|
1490
|
+
fail("target_not_found", `Target anchor was not found in ${dstLabel}`, {
|
|
1491
|
+
path: dstLabel,
|
|
1492
|
+
...details,
|
|
1493
|
+
matches: 0
|
|
1494
|
+
});
|
|
1495
|
+
}
|
|
1496
|
+
if (matches.length > 1 || matchResult.truncated) {
|
|
1497
|
+
fail("target_ambiguous", `Target anchor is ambiguous in ${dstLabel}; ${matchedLocations(matches.length, matchResult.truncated)}`, {
|
|
1498
|
+
path: dstLabel,
|
|
1499
|
+
...details,
|
|
1500
|
+
...matchCountDetails(matches.length, matchResult.truncated),
|
|
1501
|
+
ranges: boundedMatchRanges(matches, anchor.length),
|
|
1502
|
+
line_ranges: boundedMatchLineRanges(file, matches, anchor.length)
|
|
1503
|
+
});
|
|
1504
|
+
}
|
|
1505
|
+
const start = matches[0];
|
|
1506
|
+
return {
|
|
1507
|
+
range: { start, end: start + anchor.length },
|
|
1508
|
+
insertIndex: start + before.length
|
|
1509
|
+
};
|
|
1510
|
+
}
|
|
1511
|
+
function indexesOfLimited(haystack, needle, limit = 11) {
|
|
1512
|
+
if (needle.length === 0) {
|
|
1513
|
+
return { matches: [], truncated: false };
|
|
1514
|
+
}
|
|
1515
|
+
const maxMatches = normalizedLimit(limit);
|
|
1516
|
+
const matches = [];
|
|
1517
|
+
let index = haystack.indexOf(needle);
|
|
1518
|
+
while (index !== -1) {
|
|
1519
|
+
if (matches.length >= maxMatches) {
|
|
1520
|
+
return { matches, truncated: true };
|
|
1521
|
+
}
|
|
1522
|
+
matches.push(index);
|
|
1523
|
+
index = haystack.indexOf(needle, index + 1);
|
|
1524
|
+
}
|
|
1525
|
+
return { matches, truncated: false };
|
|
1526
|
+
}
|
|
1527
|
+
function indexesOfLimitedWhere(haystack, needle, predicate, limit = 11) {
|
|
1528
|
+
if (needle.length === 0) {
|
|
1529
|
+
return { matches: [], truncated: false };
|
|
1530
|
+
}
|
|
1531
|
+
const maxMatches = normalizedLimit(limit);
|
|
1532
|
+
const matches = [];
|
|
1533
|
+
let index = haystack.indexOf(needle);
|
|
1534
|
+
while (index !== -1) {
|
|
1535
|
+
if (predicate(index)) {
|
|
1536
|
+
if (matches.length >= maxMatches) {
|
|
1537
|
+
return { matches, truncated: true };
|
|
1538
|
+
}
|
|
1539
|
+
matches.push(index);
|
|
1540
|
+
}
|
|
1541
|
+
index = haystack.indexOf(needle, index + 1);
|
|
1542
|
+
}
|
|
1543
|
+
return { matches, truncated: false };
|
|
1544
|
+
}
|
|
1545
|
+
function normalizedLimit(limit) {
|
|
1546
|
+
if (!Number.isFinite(limit)) {
|
|
1547
|
+
return Number.POSITIVE_INFINITY;
|
|
1548
|
+
}
|
|
1549
|
+
return Math.max(0, Math.trunc(limit));
|
|
1550
|
+
}
|
|
1551
|
+
function rangesOverlap(left, right) {
|
|
1552
|
+
return left.start < right.end && right.start < left.end;
|
|
1553
|
+
}
|
|
1554
|
+
function moveResultDetails(args) {
|
|
1555
|
+
return {
|
|
1556
|
+
id: args.id,
|
|
1557
|
+
src: args.src,
|
|
1558
|
+
dst: args.dst,
|
|
1559
|
+
payload_sha256: args.payloadSha256,
|
|
1560
|
+
payload_bytes: args.selection.payload.length,
|
|
1561
|
+
source_range: args.selection.source,
|
|
1562
|
+
target_range: args.selection.target.range,
|
|
1563
|
+
insert_index: args.selection.target.insertIndex
|
|
1564
|
+
};
|
|
1565
|
+
}
|
|
1566
|
+
function alreadyAppliedMoveResultDetails(args) {
|
|
1567
|
+
return {
|
|
1568
|
+
id: args.id,
|
|
1569
|
+
src: args.src,
|
|
1570
|
+
dst: args.dst,
|
|
1571
|
+
payload_sha256: args.payloadSha256,
|
|
1572
|
+
payload_bytes: args.payload.length,
|
|
1573
|
+
source_range: null,
|
|
1574
|
+
target_range: args.target.range,
|
|
1575
|
+
insert_index: args.target.insertIndex
|
|
1576
|
+
};
|
|
1577
|
+
}
|
|
1578
|
+
function unique(paths) {
|
|
1579
|
+
return [...new Set(paths)];
|
|
1580
|
+
}
|
|
1581
|
+
function changedMoveLabels(srcLabel, dstLabel, sameFile, srcOriginal, dstOriginal, next) {
|
|
1582
|
+
const srcChanged = !next.src.equals(srcOriginal);
|
|
1583
|
+
const dstChanged = !sameFile && !next.dst.equals(dstOriginal);
|
|
1584
|
+
const sameFileAlias = sameFile && srcLabel !== dstLabel;
|
|
1585
|
+
const changed = [];
|
|
1586
|
+
if (srcChanged) {
|
|
1587
|
+
changed.push(srcLabel);
|
|
1588
|
+
if (sameFileAlias) {
|
|
1589
|
+
changed.push(dstLabel);
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
if (dstChanged) {
|
|
1593
|
+
changed.push(dstLabel);
|
|
1594
|
+
}
|
|
1595
|
+
return unique(changed);
|
|
1596
|
+
}
|
|
1597
|
+
function moveMutations(srcLabel, dstLabel, sameFile, srcOriginal, dstOriginal, next) {
|
|
1598
|
+
const srcChanged = !next.src.equals(srcOriginal);
|
|
1599
|
+
const dstChanged = !sameFile && !next.dst.equals(dstOriginal);
|
|
1600
|
+
const sameFileAlias = sameFile && srcLabel !== dstLabel;
|
|
1601
|
+
const mutations = [];
|
|
1602
|
+
if (dstChanged || sameFileAlias && srcChanged) {
|
|
1603
|
+
mutations.push({ kind: "write", label: dstLabel, bytes: next.dst });
|
|
1604
|
+
}
|
|
1605
|
+
if (srcChanged) {
|
|
1606
|
+
mutations.push({ kind: "write", label: srcLabel, bytes: next.src });
|
|
1607
|
+
}
|
|
1608
|
+
return mutations;
|
|
1609
|
+
}
|
|
1610
|
+
function memoryFileMap(files) {
|
|
1611
|
+
const mapped = new Map;
|
|
1612
|
+
for (const file of files) {
|
|
1613
|
+
if (mapped.has(file.path)) {
|
|
1614
|
+
fail("parse_error", `Duplicate in-memory file: ${file.path}`, { path: file.path, phase: "check" });
|
|
1615
|
+
}
|
|
1616
|
+
mapped.set(file.path, {
|
|
1617
|
+
bytes: file.bytes,
|
|
1618
|
+
identity: file.identity ?? file.path
|
|
1619
|
+
});
|
|
1620
|
+
}
|
|
1621
|
+
return mapped;
|
|
1622
|
+
}
|
|
1623
|
+
function readMemoryFile(files, path, label) {
|
|
1624
|
+
const file = files.get(path);
|
|
1625
|
+
if (file === undefined) {
|
|
1626
|
+
fail("file_not_found", `Could not read ${label}: ${path}`, { path, phase: "io" });
|
|
1627
|
+
}
|
|
1628
|
+
return file.bytes;
|
|
1629
|
+
}
|
|
1630
|
+
function sameMemoryFile(files, left, right) {
|
|
1631
|
+
if (left === right) {
|
|
1632
|
+
return true;
|
|
1633
|
+
}
|
|
1634
|
+
const leftFile = files.get(left);
|
|
1635
|
+
const rightFile = files.get(right);
|
|
1636
|
+
return leftFile !== undefined && rightFile !== undefined && leftFile.identity === rightFile.identity;
|
|
1637
|
+
}
|
|
1638
|
+
async function writeAtomic(path, bytes, options = {}) {
|
|
1639
|
+
await writeAtomically([
|
|
1640
|
+
{
|
|
1641
|
+
path,
|
|
1642
|
+
bytes,
|
|
1643
|
+
create: options.create,
|
|
1644
|
+
expected: options.expected,
|
|
1645
|
+
label: options.label
|
|
1646
|
+
}
|
|
1647
|
+
]);
|
|
1648
|
+
}
|
|
1649
|
+
async function writeAtomically(writes, deletes = []) {
|
|
1650
|
+
const staged = [];
|
|
1651
|
+
try {
|
|
1652
|
+
for (const write of writes) {
|
|
1653
|
+
staged.push(await stageAtomicWrite(write));
|
|
1654
|
+
}
|
|
1655
|
+
const decisions = [];
|
|
1656
|
+
for (const write of staged) {
|
|
1657
|
+
decisions.push(await verifyStagedWrite(write));
|
|
1658
|
+
}
|
|
1659
|
+
for (const deletion of deletes) {
|
|
1660
|
+
await verifyAtomicDelete(deletion);
|
|
1661
|
+
}
|
|
1662
|
+
for (const [index, write] of staged.entries()) {
|
|
1663
|
+
if (decisions[index] === "skip") {
|
|
1664
|
+
await cleanupStagedWrite(write);
|
|
1665
|
+
continue;
|
|
1666
|
+
}
|
|
1667
|
+
try {
|
|
1668
|
+
await rename(write.temp, write.path);
|
|
1669
|
+
} catch (error) {
|
|
1670
|
+
failFileSystem(error, write.path, "Could not replace file");
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
for (const deletion of deletes) {
|
|
1674
|
+
try {
|
|
1675
|
+
await unlink(deletion.path);
|
|
1676
|
+
} catch (error) {
|
|
1677
|
+
failFileSystem(error, deletion.path, "Could not remove file");
|
|
1678
|
+
}
|
|
1679
|
+
}
|
|
1680
|
+
} catch (error) {
|
|
1681
|
+
await cleanupStagedWrites(staged);
|
|
1682
|
+
throw error;
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
async function verifyStagedWrite(write) {
|
|
1686
|
+
const expected = write.request.expected;
|
|
1687
|
+
if (expected === undefined) {
|
|
1688
|
+
return "rename";
|
|
1689
|
+
}
|
|
1690
|
+
if (expected.kind === "missing") {
|
|
1691
|
+
const current2 = await readFileSnapshotOptional(write.path, expected.label);
|
|
1692
|
+
if (current2 === undefined) {
|
|
1693
|
+
return "rename";
|
|
1694
|
+
}
|
|
1695
|
+
if (expected.bytesIfExists !== undefined && current2.bytes.equals(expected.bytesIfExists)) {
|
|
1696
|
+
return "skip";
|
|
1697
|
+
}
|
|
1698
|
+
failConcurrentModification(expected.label);
|
|
1699
|
+
}
|
|
1700
|
+
const current = await readFileSnapshotOptional(write.path, expected.label);
|
|
1701
|
+
if (current === undefined || !sameFileSnapshot(expected.snapshot, current)) {
|
|
1702
|
+
failConcurrentModification(expected.label);
|
|
1703
|
+
}
|
|
1704
|
+
return "rename";
|
|
1705
|
+
}
|
|
1706
|
+
async function verifyAtomicDelete(deletion) {
|
|
1707
|
+
const expected = deletion.expected;
|
|
1708
|
+
if (expected === undefined) {
|
|
1709
|
+
return;
|
|
1710
|
+
}
|
|
1711
|
+
if (expected.kind === "missing") {
|
|
1712
|
+
const current2 = await readFileSnapshotOptional(deletion.path, expected.label);
|
|
1713
|
+
if (current2 === undefined) {
|
|
1714
|
+
return;
|
|
1715
|
+
}
|
|
1716
|
+
failConcurrentModification(expected.label);
|
|
1717
|
+
}
|
|
1718
|
+
const current = await readFileSnapshotOptional(deletion.path, expected.label);
|
|
1719
|
+
if (current === undefined || !sameFileSnapshot(expected.snapshot, current)) {
|
|
1720
|
+
failConcurrentModification(expected.label);
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
async function readFileSnapshotOptional(path, label) {
|
|
1724
|
+
try {
|
|
1725
|
+
return await readFileSnapshot(path, "current file");
|
|
1726
|
+
} catch (error) {
|
|
1727
|
+
if (error instanceof BlockPatchError && error.code === "file_not_found") {
|
|
1728
|
+
return;
|
|
1729
|
+
}
|
|
1730
|
+
if (error instanceof BlockPatchError && error.code === "not_regular_file") {
|
|
1731
|
+
failConcurrentModification(label);
|
|
1732
|
+
}
|
|
1733
|
+
throw error;
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
function sameFileSnapshot(left, right) {
|
|
1737
|
+
return left.sha256 === right.sha256 && sameStatSnapshot(left.stat, right.stat);
|
|
1738
|
+
}
|
|
1739
|
+
function sameStatSnapshot(left, right) {
|
|
1740
|
+
return left.dev === right.dev && left.ino === right.ino && left.mode === right.mode && left.size === right.size && left.mtimeMs === right.mtimeMs && left.ctimeMs === right.ctimeMs;
|
|
1741
|
+
}
|
|
1742
|
+
function failConcurrentModification(label) {
|
|
1743
|
+
fail("concurrent_modification", `File changed after blockpatch verified its input: ${label}`, {
|
|
1744
|
+
path: label,
|
|
1745
|
+
phase: "write"
|
|
1746
|
+
});
|
|
1747
|
+
}
|
|
1748
|
+
async function stageAtomicWrite(write) {
|
|
1749
|
+
let mode;
|
|
1750
|
+
let createMissing = false;
|
|
1751
|
+
if (write.create === true) {
|
|
1752
|
+
const info = await statOptional(write.path);
|
|
1753
|
+
if (info !== undefined) {
|
|
1754
|
+
assertExpectedRegularFile(info, write.path, "output file", write.expected);
|
|
1755
|
+
mode = info.mode;
|
|
1756
|
+
}
|
|
1757
|
+
if (mode === undefined) {
|
|
1758
|
+
createMissing = true;
|
|
1759
|
+
mode = 420;
|
|
1760
|
+
}
|
|
1761
|
+
} else {
|
|
1762
|
+
const info = await statOptional(write.path);
|
|
1763
|
+
if (info === undefined) {
|
|
1764
|
+
if (write.expected !== undefined) {
|
|
1765
|
+
failConcurrentModification(write.expected.label);
|
|
1766
|
+
}
|
|
1767
|
+
fail("file_not_found", `Could not stat output file: ${write.path}`, { path: write.path, phase: "io" });
|
|
1768
|
+
}
|
|
1769
|
+
assertExpectedRegularFile(info, write.path, "output file", write.expected);
|
|
1770
|
+
mode = info.mode;
|
|
1771
|
+
}
|
|
1772
|
+
const dir = dirname(write.path);
|
|
1773
|
+
const base = basename(write.path);
|
|
1774
|
+
const temp = join2(dir, `.${base}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`);
|
|
1775
|
+
let createdDirectory;
|
|
1776
|
+
try {
|
|
1777
|
+
if (createMissing) {
|
|
1778
|
+
const firstCreated = await mkdir(dir, { recursive: true });
|
|
1779
|
+
if (firstCreated !== undefined) {
|
|
1780
|
+
createdDirectory = { first: firstCreated, target: dir };
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
await assertSafeOutputParentDirectory(dir, write.path);
|
|
1784
|
+
await writeFile(temp, write.bytes, { flag: "wx" });
|
|
1785
|
+
if (mode !== undefined) {
|
|
1786
|
+
await chmod(temp, mode);
|
|
1787
|
+
}
|
|
1788
|
+
} catch (error) {
|
|
1789
|
+
await cleanupStagedWrite({ temp, createdDirectory });
|
|
1790
|
+
failFileSystem(error, write.path, "Could not stage file replacement");
|
|
1791
|
+
}
|
|
1792
|
+
return { path: write.path, temp, request: write, createdDirectory };
|
|
1793
|
+
}
|
|
1794
|
+
async function assertSafeOutputParentDirectory(dir, outputPath) {
|
|
1795
|
+
let info;
|
|
1796
|
+
try {
|
|
1797
|
+
info = await lstat(dir);
|
|
1798
|
+
} catch (error) {
|
|
1799
|
+
failFileSystem(error, outputPath, "Could not stat output directory", "path");
|
|
1800
|
+
}
|
|
1801
|
+
if (info.isSymbolicLink()) {
|
|
1802
|
+
fail("symlink_path", `output directory must not be a symbolic link: ${outputPath}`, {
|
|
1803
|
+
path: outputPath,
|
|
1804
|
+
phase: "path"
|
|
1805
|
+
});
|
|
1806
|
+
}
|
|
1807
|
+
if (!info.isDirectory()) {
|
|
1808
|
+
fail("not_regular_file", `output directory must be a directory: ${outputPath}`, {
|
|
1809
|
+
path: outputPath,
|
|
1810
|
+
phase: "path"
|
|
1811
|
+
});
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
async function cleanupStagedWrites(staged) {
|
|
1815
|
+
await Promise.all(staged.map((write) => unlink(write.temp).catch(() => {
|
|
1816
|
+
return;
|
|
1817
|
+
})));
|
|
1818
|
+
for (const write of [...staged].reverse()) {
|
|
1819
|
+
await cleanupCreatedDirectoryChain(write.createdDirectory);
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1822
|
+
async function cleanupStagedWrite(write) {
|
|
1823
|
+
await unlink(write.temp).catch(() => {
|
|
1824
|
+
return;
|
|
1825
|
+
});
|
|
1826
|
+
await cleanupCreatedDirectoryChain(write.createdDirectory);
|
|
1827
|
+
}
|
|
1828
|
+
async function cleanupCreatedDirectoryChain(chain) {
|
|
1829
|
+
if (chain === undefined || !isSameOrChildPath(chain.target, chain.first)) {
|
|
1830
|
+
return;
|
|
1831
|
+
}
|
|
1832
|
+
let current = chain.target;
|
|
1833
|
+
while (true) {
|
|
1834
|
+
try {
|
|
1835
|
+
await rmdir(current);
|
|
1836
|
+
} catch (error) {
|
|
1837
|
+
const code = error.code;
|
|
1838
|
+
if (code !== "ENOENT") {
|
|
1839
|
+
return;
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
if (resolve2(current) === resolve2(chain.first)) {
|
|
1843
|
+
return;
|
|
1844
|
+
}
|
|
1845
|
+
const parent = dirname(current);
|
|
1846
|
+
if (parent === current) {
|
|
1847
|
+
return;
|
|
1848
|
+
}
|
|
1849
|
+
current = parent;
|
|
1850
|
+
}
|
|
1851
|
+
}
|
|
1852
|
+
function isSameOrChildPath(child, parent) {
|
|
1853
|
+
const childFromParent = relative2(resolve2(parent), resolve2(child));
|
|
1854
|
+
return childFromParent === "" || childFromParent !== ".." && !childFromParent.startsWith(`..${sep2}`) && !isAbsolute2(childFromParent);
|
|
1855
|
+
}
|
|
1856
|
+
function assertExpectedRegularFile(info, path, label, expected) {
|
|
1857
|
+
if (!info.isFile() && expected !== undefined) {
|
|
1858
|
+
failConcurrentModification(expected.label);
|
|
1859
|
+
}
|
|
1860
|
+
assertRegularFile(info, path, label);
|
|
1861
|
+
}
|
|
1862
|
+
async function statOptional(path) {
|
|
1863
|
+
try {
|
|
1864
|
+
return await stat2(path);
|
|
1865
|
+
} catch (error) {
|
|
1866
|
+
const code = error.code;
|
|
1867
|
+
if (code === "ENOENT" || code === "ENOTDIR") {
|
|
1868
|
+
return;
|
|
1869
|
+
}
|
|
1870
|
+
failFileSystem(error, path, "Could not stat output file");
|
|
1871
|
+
}
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1874
|
+
// src/move.ts
|
|
1875
|
+
import { createHash as createHash3 } from "node:crypto";
|
|
1876
|
+
import { posix as posix2 } from "node:path";
|
|
1877
|
+
import { TextDecoder } from "node:util";
|
|
1878
|
+
var moveArgTypes = {
|
|
1879
|
+
src: "string",
|
|
1880
|
+
src_start: "string",
|
|
1881
|
+
src_end: "string",
|
|
1882
|
+
dst: "string",
|
|
1883
|
+
payload: "string",
|
|
1884
|
+
target_before: "string",
|
|
1885
|
+
target_after: "string",
|
|
1886
|
+
expected_payload_sha256: "string",
|
|
1887
|
+
mode: "string",
|
|
1888
|
+
dry_run: "boolean"
|
|
1889
|
+
};
|
|
1890
|
+
var moveModes = new Set(["create_file", "remove_file"]);
|
|
1891
|
+
var utf8Decoder = new TextDecoder("utf-8", { fatal: true });
|
|
1892
|
+
async function moveBlock(args, options = {}) {
|
|
1893
|
+
const validated = validateMoveArgs(args);
|
|
1894
|
+
const normalized = normalizeArgs(validated);
|
|
1895
|
+
const cwd = options.cwd ?? process.cwd();
|
|
1896
|
+
const dryRun = options.dryRun ?? validated.dry_run ?? false;
|
|
1897
|
+
if (normalized.kind === "create_file") {
|
|
1898
|
+
return createFile(normalized, validated, cwd, dryRun, options);
|
|
1899
|
+
}
|
|
1900
|
+
if (normalized.kind === "remove_file") {
|
|
1901
|
+
return removeFile(normalized, validated, cwd, dryRun, options);
|
|
1902
|
+
}
|
|
1903
|
+
if (normalized.kind === "insertion") {
|
|
1904
|
+
return insertPayload(normalized, validated, cwd, dryRun, options);
|
|
1905
|
+
}
|
|
1906
|
+
if (normalized.kind === "deletion") {
|
|
1907
|
+
return deletePayload(normalized, validated, cwd, dryRun, options);
|
|
1908
|
+
}
|
|
1909
|
+
const srcPath = resolvePath(cwd, normalized.src, "source path");
|
|
1910
|
+
const dstPath = resolvePath(cwd, normalized.dst, "destination path");
|
|
1911
|
+
const sameFile = await sameFileIdentity(srcPath, dstPath);
|
|
1912
|
+
const srcSnapshot = await readFileSnapshot(srcPath, "source file");
|
|
1913
|
+
const dstSnapshot = sameFile ? srcSnapshot : await readFileSnapshot(dstPath, "destination file");
|
|
1914
|
+
const srcOriginal = srcSnapshot.bytes;
|
|
1915
|
+
const dstOriginal = dstSnapshot.bytes;
|
|
1916
|
+
const source = findSource(srcOriginal, normalized);
|
|
1917
|
+
const target = findTargetSelection(dstOriginal, normalized.targetBefore, normalized.targetAfter, normalized.dst, {
|
|
1918
|
+
phase: "target",
|
|
1919
|
+
anchor: targetAnchorName(normalized)
|
|
1920
|
+
});
|
|
1921
|
+
const selection = buildMoveSelection(srcOriginal, source, target, sameFile, normalized.dst);
|
|
1922
|
+
const payloadSha256 = createHash3("sha256").update(selection.payload).digest("hex");
|
|
1923
|
+
verifyExpectedPayloadHash(validated, payloadSha256);
|
|
1924
|
+
const samePatchLabel = samePatchPath2(normalized.src, normalized.dst);
|
|
1925
|
+
const patch = options.diff ? renderMovePatch(normalized, srcOriginal, dstOriginal, selection, sameFile && samePatchLabel, payloadSha256) : undefined;
|
|
1926
|
+
await selfCheckRenderedPatch(patch, pairedSelfCheckFiles(normalized.src, normalized.dst, srcOriginal, dstOriginal, sameFile));
|
|
1927
|
+
const writeSuppressed = dryRun || options.diff === true;
|
|
1928
|
+
const changed = await commitMove({
|
|
1929
|
+
srcPath,
|
|
1930
|
+
dstPath,
|
|
1931
|
+
sameFile,
|
|
1932
|
+
dryRun: writeSuppressed,
|
|
1933
|
+
srcOriginal,
|
|
1934
|
+
dstOriginal,
|
|
1935
|
+
srcSnapshot,
|
|
1936
|
+
dstSnapshot,
|
|
1937
|
+
selection,
|
|
1938
|
+
srcLabel: normalized.src,
|
|
1939
|
+
dstLabel: normalized.dst
|
|
1940
|
+
});
|
|
1941
|
+
return {
|
|
1942
|
+
changed,
|
|
1943
|
+
affected: unique([normalized.src, normalized.dst]),
|
|
1944
|
+
written: !writeSuppressed && changed.length > 0,
|
|
1945
|
+
noop: changed.length === 0,
|
|
1946
|
+
status: changed.length === 0 ? "noop" : "applied",
|
|
1947
|
+
moves: [
|
|
1948
|
+
moveResultDetails({
|
|
1949
|
+
id: "move-1",
|
|
1950
|
+
src: normalized.src,
|
|
1951
|
+
dst: normalized.dst,
|
|
1952
|
+
payloadSha256,
|
|
1953
|
+
selection
|
|
1954
|
+
})
|
|
1955
|
+
],
|
|
1956
|
+
patch
|
|
1957
|
+
};
|
|
1958
|
+
}
|
|
1959
|
+
function validateMoveArgs(value) {
|
|
1960
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
1961
|
+
fail("invalid_move_args", "move arguments must be a JSON object");
|
|
1962
|
+
}
|
|
1963
|
+
for (const [key, fieldValue] of Object.entries(value)) {
|
|
1964
|
+
const expected = moveArgTypes[key];
|
|
1965
|
+
if (expected === undefined) {
|
|
1966
|
+
fail("invalid_move_args", `Unknown move argument: ${key}`, { field: key });
|
|
1967
|
+
}
|
|
1968
|
+
if (typeof fieldValue !== expected) {
|
|
1969
|
+
fail("invalid_move_args", `move argument ${key} must be a ${expected}`, { field: key });
|
|
1970
|
+
}
|
|
1971
|
+
}
|
|
1972
|
+
const args = value;
|
|
1973
|
+
if (args.expected_payload_sha256 !== undefined && !/^[a-f0-9]{64}$/.test(args.expected_payload_sha256)) {
|
|
1974
|
+
fail("invalid_move_args", "expected_payload_sha256 must be a 64-character lowercase sha256 hex digest", {
|
|
1975
|
+
field: "expected_payload_sha256"
|
|
1976
|
+
});
|
|
1977
|
+
}
|
|
1978
|
+
if (args.mode !== undefined && !moveModes.has(args.mode)) {
|
|
1979
|
+
fail("invalid_move_args", "mode must be create_file or remove_file", { field: "mode" });
|
|
1980
|
+
}
|
|
1981
|
+
return args;
|
|
1982
|
+
}
|
|
1983
|
+
function normalizeArgs(args) {
|
|
1984
|
+
if (!args.src) {
|
|
1985
|
+
fail("invalid_move_args", "move requires src", { field: "src" });
|
|
1986
|
+
}
|
|
1987
|
+
if (args.src !== devNull) {
|
|
1988
|
+
validateOperationPath(args.src, "source path");
|
|
1989
|
+
}
|
|
1990
|
+
if (args.dst !== undefined && args.dst !== devNull) {
|
|
1991
|
+
validateOperationPath(args.dst, "destination path");
|
|
1992
|
+
}
|
|
1993
|
+
if (args.mode === "create_file") {
|
|
1994
|
+
return normalizeFileCreationArgs(args);
|
|
1995
|
+
}
|
|
1996
|
+
if (args.mode === "remove_file") {
|
|
1997
|
+
return normalizeFileRemovalArgs(args);
|
|
1998
|
+
}
|
|
1999
|
+
const dst = args.dst ?? args.src;
|
|
2000
|
+
if (args.src === devNull && dst === devNull) {
|
|
2001
|
+
fail("invalid_move_args", "move cannot use /dev/null for both src and dst", { field: "dst" });
|
|
2002
|
+
}
|
|
2003
|
+
if (args.src === devNull) {
|
|
2004
|
+
if (args.dst === undefined) {
|
|
2005
|
+
fail("invalid_move_args", "move from /dev/null requires dst", { field: "dst" });
|
|
2006
|
+
}
|
|
2007
|
+
if (args.src_start !== undefined || args.src_end !== undefined) {
|
|
2008
|
+
fail("invalid_move_args", "move from /dev/null uses payload instead of src_start/src_end", {
|
|
2009
|
+
field: args.src_start !== undefined ? "src_start" : "src_end"
|
|
2010
|
+
});
|
|
2011
|
+
}
|
|
2012
|
+
if (args.payload === undefined) {
|
|
2013
|
+
fail("invalid_move_args", "move from /dev/null requires payload", { field: "payload" });
|
|
2014
|
+
}
|
|
2015
|
+
if (args.payload.length === 0) {
|
|
2016
|
+
fail("invalid_move_args", "move from /dev/null requires non-empty payload", { field: "payload" });
|
|
2017
|
+
}
|
|
2018
|
+
const target2 = normalizeTargetArgs(args);
|
|
2019
|
+
return {
|
|
2020
|
+
kind: "insertion",
|
|
2021
|
+
src: devNull,
|
|
2022
|
+
dst: args.dst,
|
|
2023
|
+
payload: Buffer.from(args.payload, "utf8"),
|
|
2024
|
+
targetBefore: target2.before,
|
|
2025
|
+
targetAfter: target2.after
|
|
2026
|
+
};
|
|
2027
|
+
}
|
|
2028
|
+
if (dst === devNull) {
|
|
2029
|
+
if (args.payload !== undefined) {
|
|
2030
|
+
fail("invalid_move_args", "move to /dev/null selects payload from src_start/src_end", { field: "payload" });
|
|
2031
|
+
}
|
|
2032
|
+
if (args.target_before !== undefined || args.target_after !== undefined) {
|
|
2033
|
+
fail("invalid_move_args", "move to /dev/null must not include target anchors", {
|
|
2034
|
+
field: args.target_before !== undefined ? "target_before" : "target_after"
|
|
2035
|
+
});
|
|
2036
|
+
}
|
|
2037
|
+
return {
|
|
2038
|
+
kind: "deletion",
|
|
2039
|
+
src: args.src,
|
|
2040
|
+
srcStart: requiredBuffer(args.src_start, "src_start"),
|
|
2041
|
+
srcEnd: requiredBuffer(args.src_end, "src_end"),
|
|
2042
|
+
dst: devNull
|
|
2043
|
+
};
|
|
13
2044
|
}
|
|
2045
|
+
if (args.payload !== undefined) {
|
|
2046
|
+
fail("invalid_move_args", "payload is only valid when src is /dev/null", { field: "payload" });
|
|
2047
|
+
}
|
|
2048
|
+
const target = normalizeTargetArgs(args);
|
|
2049
|
+
return {
|
|
2050
|
+
kind: "relocation",
|
|
2051
|
+
src: args.src,
|
|
2052
|
+
srcStart: requiredBuffer(args.src_start, "src_start"),
|
|
2053
|
+
srcEnd: requiredBuffer(args.src_end, "src_end"),
|
|
2054
|
+
dst,
|
|
2055
|
+
targetBefore: target.before,
|
|
2056
|
+
targetAfter: target.after
|
|
2057
|
+
};
|
|
14
2058
|
}
|
|
15
|
-
function
|
|
16
|
-
|
|
2059
|
+
function normalizeFileCreationArgs(args) {
|
|
2060
|
+
if (args.src !== devNull) {
|
|
2061
|
+
fail("invalid_move_args", "create_file mode requires src to be /dev/null", { field: "src" });
|
|
2062
|
+
}
|
|
2063
|
+
if (args.dst === undefined) {
|
|
2064
|
+
fail("invalid_move_args", "create_file mode requires dst", { field: "dst" });
|
|
2065
|
+
}
|
|
2066
|
+
if (args.dst === devNull) {
|
|
2067
|
+
fail("invalid_move_args", "create_file mode cannot target /dev/null", { field: "dst" });
|
|
2068
|
+
}
|
|
2069
|
+
if (args.payload === undefined) {
|
|
2070
|
+
fail("invalid_move_args", "create_file mode requires payload", { field: "payload" });
|
|
2071
|
+
}
|
|
2072
|
+
rejectSourceSelectionArgs(args, "create_file mode uses payload as the whole-file content");
|
|
2073
|
+
rejectTargetAnchorArgs(args, "create_file mode must not include target anchors");
|
|
2074
|
+
return {
|
|
2075
|
+
kind: "create_file",
|
|
2076
|
+
src: devNull,
|
|
2077
|
+
dst: args.dst,
|
|
2078
|
+
payload: Buffer.from(args.payload, "utf8")
|
|
2079
|
+
};
|
|
17
2080
|
}
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
import { chmod, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
|
|
22
|
-
import { basename, dirname, join, resolve } from "node:path";
|
|
23
|
-
|
|
24
|
-
// src/parser.ts
|
|
25
|
-
var beginLine = "*** Begin BlockPatch";
|
|
26
|
-
var sourceBeforeLine = "*** Source Before";
|
|
27
|
-
var sourcePayloadLine = "*** Source Payload";
|
|
28
|
-
var sourceAfterLine = "*** Source After";
|
|
29
|
-
var targetBeforeLine = "*** Target Before";
|
|
30
|
-
var targetAfterLine = "*** Target After";
|
|
31
|
-
var endLine = "*** End BlockPatch";
|
|
32
|
-
var moveFilePrefix = "*** Move File: ";
|
|
33
|
-
function parseBlockPatch(input) {
|
|
34
|
-
const markers = collectMarkers(input);
|
|
35
|
-
const sequence = markers.map((marker) => marker.name);
|
|
36
|
-
const targetIndex = sequence.findIndex((name) => name === "targetBefore" || name === "targetAfter");
|
|
37
|
-
if (targetIndex === -1) {
|
|
38
|
-
fail("parse_error", "Patch must contain *** Target Before or *** Target After");
|
|
39
|
-
}
|
|
40
|
-
const expectedPrefix = ["begin", "moveFile", "sourceBefore", "sourcePayload", "sourceAfter"];
|
|
41
|
-
const expectedSuffix = ["end"];
|
|
42
|
-
const expected = [...expectedPrefix, sequence[targetIndex], ...expectedSuffix];
|
|
43
|
-
if (sequence.length !== expected.length || sequence.some((name, index) => name !== expected[index])) {
|
|
44
|
-
fail("parse_error", "Patch markers must appear in the v0 order");
|
|
45
|
-
}
|
|
46
|
-
const moveFile = markers[1];
|
|
47
|
-
const path = moveFile.value?.trim();
|
|
48
|
-
if (!path) {
|
|
49
|
-
fail("parse_error", "*** Move File must include a path");
|
|
50
|
-
}
|
|
51
|
-
const sourceBefore = section(input, markers[2], markers[3]);
|
|
52
|
-
const sourcePayload = section(input, markers[3], markers[4]);
|
|
53
|
-
const sourceAfter = section(input, markers[4], markers[5]);
|
|
54
|
-
const targetAnchor = section(input, markers[5], markers[6]);
|
|
55
|
-
const target = {
|
|
56
|
-
kind: markers[5].name === "targetBefore" ? "before" : "after",
|
|
57
|
-
anchor: targetAnchor
|
|
58
|
-
};
|
|
59
|
-
if (sourceBefore.length === 0) {
|
|
60
|
-
fail("parse_error", "*** Source Before must not be empty");
|
|
61
|
-
}
|
|
62
|
-
if (sourcePayload.length === 0) {
|
|
63
|
-
fail("parse_error", "*** Source Payload must not be empty");
|
|
64
|
-
}
|
|
65
|
-
if (sourceAfter.length === 0) {
|
|
66
|
-
fail("parse_error", "*** Source After must not be empty");
|
|
67
|
-
}
|
|
68
|
-
if (target.anchor.length === 0) {
|
|
69
|
-
fail("parse_error", "*** Target Before/After must not be empty");
|
|
2081
|
+
function normalizeFileRemovalArgs(args) {
|
|
2082
|
+
if (args.src === devNull) {
|
|
2083
|
+
fail("invalid_move_args", "remove_file mode requires a real src path", { field: "src" });
|
|
70
2084
|
}
|
|
2085
|
+
if (args.dst !== devNull) {
|
|
2086
|
+
fail("invalid_move_args", "remove_file mode requires dst to be /dev/null", { field: "dst" });
|
|
2087
|
+
}
|
|
2088
|
+
if (args.payload !== undefined) {
|
|
2089
|
+
fail("invalid_move_args", "remove_file mode reads the whole-file payload from src", { field: "payload" });
|
|
2090
|
+
}
|
|
2091
|
+
rejectSourceSelectionArgs(args, "remove_file mode removes the whole src file");
|
|
2092
|
+
rejectTargetAnchorArgs(args, "remove_file mode must not include target anchors");
|
|
71
2093
|
return {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
sourcePayload,
|
|
76
|
-
sourceAfter,
|
|
77
|
-
target
|
|
2094
|
+
kind: "remove_file",
|
|
2095
|
+
src: args.src,
|
|
2096
|
+
dst: devNull
|
|
78
2097
|
};
|
|
79
2098
|
}
|
|
80
|
-
function
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const lf = input.indexOf(10, position);
|
|
86
|
-
const lineEnd = lf === -1 ? input.length : lf;
|
|
87
|
-
const contentStart = lf === -1 ? input.length : lf + 1;
|
|
88
|
-
const textEnd = lineEnd > lineStart && input[lineEnd - 1] === 13 ? lineEnd - 1 : lineEnd;
|
|
89
|
-
const line = input.subarray(lineStart, textEnd).toString("utf8");
|
|
90
|
-
const marker = markerForLine(line, lineStart, contentStart);
|
|
91
|
-
if (marker !== undefined) {
|
|
92
|
-
markers.push(marker);
|
|
93
|
-
}
|
|
94
|
-
if (lf === -1) {
|
|
95
|
-
break;
|
|
96
|
-
}
|
|
97
|
-
position = lf + 1;
|
|
2099
|
+
function rejectSourceSelectionArgs(args, message) {
|
|
2100
|
+
if (args.src_start !== undefined || args.src_end !== undefined) {
|
|
2101
|
+
fail("invalid_move_args", message, {
|
|
2102
|
+
field: args.src_start !== undefined ? "src_start" : "src_end"
|
|
2103
|
+
});
|
|
98
2104
|
}
|
|
99
|
-
if (markers.length === 0) {
|
|
100
|
-
fail("parse_error", "Patch does not contain BlockPatch markers");
|
|
101
|
-
}
|
|
102
|
-
return markers;
|
|
103
|
-
}
|
|
104
|
-
function markerForLine(line, lineStart, contentStart) {
|
|
105
|
-
if (line === beginLine)
|
|
106
|
-
return { name: "begin", lineStart, contentStart };
|
|
107
|
-
if (line.startsWith(moveFilePrefix)) {
|
|
108
|
-
return { name: "moveFile", value: line.slice(moveFilePrefix.length), lineStart, contentStart };
|
|
109
|
-
}
|
|
110
|
-
if (line === sourceBeforeLine)
|
|
111
|
-
return { name: "sourceBefore", lineStart, contentStart };
|
|
112
|
-
if (line === sourcePayloadLine)
|
|
113
|
-
return { name: "sourcePayload", lineStart, contentStart };
|
|
114
|
-
if (line === sourceAfterLine)
|
|
115
|
-
return { name: "sourceAfter", lineStart, contentStart };
|
|
116
|
-
if (line === targetBeforeLine)
|
|
117
|
-
return { name: "targetBefore", lineStart, contentStart };
|
|
118
|
-
if (line === targetAfterLine)
|
|
119
|
-
return { name: "targetAfter", lineStart, contentStart };
|
|
120
|
-
if (line === endLine)
|
|
121
|
-
return { name: "end", lineStart, contentStart };
|
|
122
|
-
return;
|
|
123
2105
|
}
|
|
124
|
-
function
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
end -= 1;
|
|
130
|
-
}
|
|
2106
|
+
function rejectTargetAnchorArgs(args, message) {
|
|
2107
|
+
if (args.target_before !== undefined || args.target_after !== undefined) {
|
|
2108
|
+
fail("invalid_move_args", message, {
|
|
2109
|
+
field: args.target_before !== undefined ? "target_before" : "target_after"
|
|
2110
|
+
});
|
|
131
2111
|
}
|
|
132
|
-
return Buffer.from(input.subarray(marker.contentStart, end));
|
|
133
2112
|
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
2113
|
+
function normalizeTargetArgs(args) {
|
|
2114
|
+
const hasTargetBefore = args.target_before !== undefined;
|
|
2115
|
+
const hasTargetAfter = args.target_after !== undefined;
|
|
2116
|
+
if (!hasTargetBefore && !hasTargetAfter) {
|
|
2117
|
+
fail("invalid_move_args", "move requires target_before or target_after", { field: "target_before" });
|
|
2118
|
+
}
|
|
2119
|
+
const targetBefore = Buffer.from(args.target_before ?? "", "utf8");
|
|
2120
|
+
const targetAfter = Buffer.from(args.target_after ?? "", "utf8");
|
|
2121
|
+
if (targetBefore.length === 0 && targetAfter.length === 0) {
|
|
2122
|
+
fail("invalid_move_args", "move requires non-empty target context", { field: "target_before" });
|
|
2123
|
+
}
|
|
2124
|
+
return { before: targetBefore, after: targetAfter };
|
|
138
2125
|
}
|
|
139
|
-
|
|
140
|
-
|
|
2126
|
+
function requiredBuffer(value, field) {
|
|
2127
|
+
if (!value) {
|
|
2128
|
+
fail("invalid_move_args", `move requires ${field}`, { field });
|
|
2129
|
+
}
|
|
2130
|
+
return Buffer.from(value, "utf8");
|
|
141
2131
|
}
|
|
142
|
-
async function
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
const patch =
|
|
146
|
-
|
|
147
|
-
return { changed: [changedPath] };
|
|
2132
|
+
async function createFile(args, validated, cwd, dryRun, options) {
|
|
2133
|
+
const payloadSha256 = createHash3("sha256").update(args.payload).digest("hex");
|
|
2134
|
+
verifyExpectedPayloadHash(validated, payloadSha256);
|
|
2135
|
+
const patch = renderFileCreationPatch(args, payloadSha256);
|
|
2136
|
+
return runRenderedPatch(patch, cwd, dryRun, options);
|
|
148
2137
|
}
|
|
149
|
-
async function
|
|
150
|
-
const
|
|
151
|
-
const
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
}
|
|
157
|
-
return patch.path;
|
|
2138
|
+
async function removeFile(args, validated, cwd, dryRun, options) {
|
|
2139
|
+
const srcPath = resolvePath(cwd, args.src, "source path");
|
|
2140
|
+
const payload = await readFileChecked(srcPath, "source file");
|
|
2141
|
+
const payloadSha256 = createHash3("sha256").update(payload).digest("hex");
|
|
2142
|
+
verifyExpectedPayloadHash(validated, payloadSha256);
|
|
2143
|
+
const patch = renderFileRemovalPatch(args, payload, payloadSha256);
|
|
2144
|
+
return runRenderedPatch(patch, cwd, dryRun, options);
|
|
158
2145
|
}
|
|
159
|
-
function
|
|
160
|
-
const
|
|
161
|
-
const
|
|
162
|
-
if (rangesOverlap(source, target.range)) {
|
|
163
|
-
fail("target_overlaps_source", `Target anchor for ${patch.path} overlaps the source block`);
|
|
164
|
-
}
|
|
2146
|
+
async function runRenderedPatch(patch, cwd, dryRun, options) {
|
|
2147
|
+
const writeSuppressed = dryRun || options.diff === true;
|
|
2148
|
+
const result = await applyPatchBytes(Buffer.from(patch, "utf8"), { cwd, dryRun: writeSuppressed });
|
|
165
2149
|
return {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
payload: Buffer.from(file.subarray(source.start, source.end))
|
|
2150
|
+
...result,
|
|
2151
|
+
patch: options.diff === true ? patch : undefined
|
|
169
2152
|
};
|
|
170
2153
|
}
|
|
171
|
-
function
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
2154
|
+
async function insertPayload(args, validated, cwd, dryRun, options) {
|
|
2155
|
+
const dstPath = resolvePath(cwd, args.dst, "destination path");
|
|
2156
|
+
const snapshot = await readFileSnapshot(dstPath, "destination file");
|
|
2157
|
+
const original = snapshot.bytes;
|
|
2158
|
+
const payloadSha256 = createHash3("sha256").update(args.payload).digest("hex");
|
|
2159
|
+
verifyExpectedPayloadHash(validated, payloadSha256);
|
|
2160
|
+
const alreadyApplied = findAlreadyAppliedTarget(original, args, args.dst);
|
|
2161
|
+
if (alreadyApplied !== undefined) {
|
|
2162
|
+
const renderedPatch2 = options.diff ? renderInsertionPatch(args, original, alreadyApplied, payloadSha256) : undefined;
|
|
2163
|
+
await selfCheckRenderedPatch(renderedPatch2, [{ path: args.dst, bytes: original }]);
|
|
2164
|
+
return {
|
|
2165
|
+
changed: [],
|
|
2166
|
+
affected: [args.dst],
|
|
2167
|
+
written: false,
|
|
2168
|
+
noop: true,
|
|
2169
|
+
status: "already_applied",
|
|
2170
|
+
moves: [
|
|
2171
|
+
{
|
|
2172
|
+
id: "move-1",
|
|
2173
|
+
src: devNull,
|
|
2174
|
+
dst: args.dst,
|
|
2175
|
+
payload_sha256: payloadSha256,
|
|
2176
|
+
payload_bytes: args.payload.length,
|
|
2177
|
+
source_range: null,
|
|
2178
|
+
target_range: alreadyApplied.range,
|
|
2179
|
+
insert_index: alreadyApplied.insertIndex
|
|
2180
|
+
}
|
|
2181
|
+
],
|
|
2182
|
+
patch: renderedPatch2
|
|
2183
|
+
};
|
|
2184
|
+
}
|
|
2185
|
+
const target = findTargetSelection(original, args.targetBefore, args.targetAfter, args.dst, {
|
|
2186
|
+
phase: "target",
|
|
2187
|
+
anchor: targetAnchorName(args)
|
|
2188
|
+
});
|
|
2189
|
+
const renderedPatch = options.diff ? renderInsertionPatch(args, original, target, payloadSha256) : undefined;
|
|
2190
|
+
await selfCheckRenderedPatch(renderedPatch, [{ path: args.dst, bytes: original }]);
|
|
2191
|
+
const writeSuppressed = dryRun || options.diff === true;
|
|
2192
|
+
const next = Buffer.concat([
|
|
2193
|
+
original.subarray(0, target.insertIndex),
|
|
2194
|
+
args.payload,
|
|
2195
|
+
original.subarray(target.insertIndex)
|
|
181
2196
|
]);
|
|
2197
|
+
const changed = next.equals(original) ? [] : [args.dst];
|
|
2198
|
+
if (!writeSuppressed && changed.length > 0) {
|
|
2199
|
+
await writeAtomic(dstPath, next, {
|
|
2200
|
+
expected: { kind: "file", label: args.dst, snapshot }
|
|
2201
|
+
});
|
|
2202
|
+
}
|
|
2203
|
+
return {
|
|
2204
|
+
changed,
|
|
2205
|
+
affected: [args.dst],
|
|
2206
|
+
written: !writeSuppressed && changed.length > 0,
|
|
2207
|
+
noop: changed.length === 0,
|
|
2208
|
+
status: changed.length === 0 ? "noop" : "applied",
|
|
2209
|
+
moves: [
|
|
2210
|
+
{
|
|
2211
|
+
id: "move-1",
|
|
2212
|
+
src: devNull,
|
|
2213
|
+
dst: args.dst,
|
|
2214
|
+
payload_sha256: payloadSha256,
|
|
2215
|
+
payload_bytes: args.payload.length,
|
|
2216
|
+
source_range: null,
|
|
2217
|
+
target_range: target.range,
|
|
2218
|
+
insert_index: target.insertIndex
|
|
2219
|
+
}
|
|
2220
|
+
],
|
|
2221
|
+
patch: renderedPatch
|
|
2222
|
+
};
|
|
182
2223
|
}
|
|
183
|
-
function
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
2224
|
+
function verifyExpectedPayloadHash(args, payloadSha256) {
|
|
2225
|
+
if (args.expected_payload_sha256 !== undefined && args.expected_payload_sha256 !== payloadSha256) {
|
|
2226
|
+
fail("hash_mismatch", "expected_payload_sha256 does not match selected source payload", {
|
|
2227
|
+
field: "expected_payload_sha256",
|
|
2228
|
+
phase: "payload",
|
|
2229
|
+
anchor: "expected_payload_sha256"
|
|
2230
|
+
});
|
|
189
2231
|
}
|
|
190
|
-
|
|
191
|
-
|
|
2232
|
+
}
|
|
2233
|
+
function findAlreadyAppliedTarget(file, args, dstLabel) {
|
|
2234
|
+
const alreadyApplied = Buffer.concat([args.targetBefore, args.payload, args.targetAfter]);
|
|
2235
|
+
const matchResult = indexesOfLimited(file, alreadyApplied);
|
|
2236
|
+
const matches = matchResult.matches;
|
|
2237
|
+
if (matches.length === 0) {
|
|
2238
|
+
return;
|
|
192
2239
|
}
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
fail("
|
|
2240
|
+
if (matches.length > 1 || matchResult.truncated) {
|
|
2241
|
+
const ranges = boundedRanges(matches.map((start2) => ({ start: start2, end: start2 + alreadyApplied.length })));
|
|
2242
|
+
fail("target_ambiguous", `Already-applied target is ambiguous in ${dstLabel}; ${matchedLocations(matches.length, matchResult.truncated)}`, {
|
|
2243
|
+
path: dstLabel,
|
|
2244
|
+
phase: "target",
|
|
2245
|
+
anchor: "target_before+payload+target_after",
|
|
2246
|
+
...matchCountDetails(matches.length, matchResult.truncated),
|
|
2247
|
+
ranges,
|
|
2248
|
+
line_ranges: boundedLineRanges(file, ranges)
|
|
2249
|
+
});
|
|
2250
|
+
}
|
|
2251
|
+
const start = matches[0];
|
|
2252
|
+
return {
|
|
2253
|
+
range: { start, end: start + alreadyApplied.length },
|
|
2254
|
+
insertIndex: start + args.targetBefore.length
|
|
2255
|
+
};
|
|
2256
|
+
}
|
|
2257
|
+
async function deletePayload(args, validated, cwd, dryRun, options) {
|
|
2258
|
+
const srcPath = resolvePath(cwd, args.src, "source path");
|
|
2259
|
+
const snapshot = await readFileSnapshot(srcPath, "source file");
|
|
2260
|
+
const original = snapshot.bytes;
|
|
2261
|
+
const source = findSource(original, args);
|
|
2262
|
+
const payload = Buffer.from(original.subarray(source.start, source.end));
|
|
2263
|
+
const payloadSha256 = createHash3("sha256").update(payload).digest("hex");
|
|
2264
|
+
verifyExpectedPayloadHash(validated, payloadSha256);
|
|
2265
|
+
const renderedPatch = options.diff ? renderDeletionPatch(args, original, source, payload, payloadSha256) : undefined;
|
|
2266
|
+
await selfCheckRenderedPatch(renderedPatch, [{ path: args.src, bytes: original }]);
|
|
2267
|
+
const writeSuppressed = dryRun || options.diff === true;
|
|
2268
|
+
const next = Buffer.concat([original.subarray(0, source.start), original.subarray(source.end)]);
|
|
2269
|
+
const changed = next.equals(original) ? [] : [args.src];
|
|
2270
|
+
if (!writeSuppressed && changed.length > 0) {
|
|
2271
|
+
await writeAtomic(srcPath, next, {
|
|
2272
|
+
expected: { kind: "file", label: args.src, snapshot }
|
|
2273
|
+
});
|
|
2274
|
+
}
|
|
2275
|
+
return {
|
|
2276
|
+
changed,
|
|
2277
|
+
affected: [args.src],
|
|
2278
|
+
written: !writeSuppressed && changed.length > 0,
|
|
2279
|
+
noop: changed.length === 0,
|
|
2280
|
+
status: changed.length === 0 ? "noop" : "applied",
|
|
2281
|
+
moves: [
|
|
2282
|
+
{
|
|
2283
|
+
id: "move-1",
|
|
2284
|
+
src: args.src,
|
|
2285
|
+
dst: devNull,
|
|
2286
|
+
payload_sha256: payloadSha256,
|
|
2287
|
+
payload_bytes: payload.length,
|
|
2288
|
+
source_range: source,
|
|
2289
|
+
target_range: null,
|
|
2290
|
+
insert_index: null
|
|
2291
|
+
}
|
|
2292
|
+
],
|
|
2293
|
+
patch: renderedPatch
|
|
2294
|
+
};
|
|
2295
|
+
}
|
|
2296
|
+
function findSource(file, args) {
|
|
2297
|
+
const result = findDelimitedRanges(file, args.srcStart, args.srcEnd);
|
|
2298
|
+
const ranges = result.ranges;
|
|
2299
|
+
if (ranges.length === 0) {
|
|
2300
|
+
fail("source_not_found", `Source delimiters were not found in ${args.src}`, {
|
|
2301
|
+
path: args.src,
|
|
2302
|
+
phase: "source",
|
|
2303
|
+
anchor: "src_start/src_end",
|
|
2304
|
+
matches: 0
|
|
2305
|
+
});
|
|
196
2306
|
}
|
|
197
|
-
if (
|
|
198
|
-
fail("source_ambiguous", `Source
|
|
2307
|
+
if (ranges.length > 1 || result.truncated) {
|
|
2308
|
+
fail("source_ambiguous", `Source delimiters are ambiguous in ${args.src}; ${matchedLocations(ranges.length, result.truncated)}`, {
|
|
2309
|
+
path: args.src,
|
|
2310
|
+
phase: "source",
|
|
2311
|
+
anchor: "src_start/src_end",
|
|
2312
|
+
...matchCountDetails(ranges.length, result.truncated),
|
|
2313
|
+
ranges: boundedRanges(ranges),
|
|
2314
|
+
line_ranges: boundedLineRanges(file, ranges)
|
|
2315
|
+
});
|
|
199
2316
|
}
|
|
200
|
-
|
|
2317
|
+
return ranges[0];
|
|
201
2318
|
}
|
|
202
|
-
function
|
|
203
|
-
|
|
204
|
-
|
|
2319
|
+
function findDelimitedRanges(file, startNeedle, endNeedle, limit = 11) {
|
|
2320
|
+
if (startNeedle.length === 0) {
|
|
2321
|
+
return { ranges: [], truncated: false };
|
|
2322
|
+
}
|
|
2323
|
+
const maxMatches = normalizedLimit2(limit);
|
|
205
2324
|
const ranges = [];
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
|
|
2325
|
+
let start = file.indexOf(startNeedle);
|
|
2326
|
+
while (start !== -1) {
|
|
2327
|
+
const searchFrom = start + startNeedle.length;
|
|
2328
|
+
const endStart = file.indexOf(endNeedle, searchFrom);
|
|
2329
|
+
if (endStart !== -1) {
|
|
2330
|
+
if (ranges.length >= maxMatches) {
|
|
2331
|
+
return { ranges, truncated: true };
|
|
2332
|
+
}
|
|
2333
|
+
ranges.push({ start, end: endStart + endNeedle.length });
|
|
211
2334
|
}
|
|
2335
|
+
start = file.indexOf(startNeedle, start + 1);
|
|
212
2336
|
}
|
|
213
|
-
return ranges;
|
|
2337
|
+
return { ranges, truncated: false };
|
|
214
2338
|
}
|
|
215
|
-
function
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
fail("target_not_found", `Target anchor was not found in ${patch.path}`);
|
|
2339
|
+
function normalizedLimit2(limit) {
|
|
2340
|
+
if (!Number.isFinite(limit)) {
|
|
2341
|
+
return Number.POSITIVE_INFINITY;
|
|
219
2342
|
}
|
|
220
|
-
|
|
221
|
-
|
|
2343
|
+
return Math.max(0, Math.trunc(limit));
|
|
2344
|
+
}
|
|
2345
|
+
function targetAnchorName(args) {
|
|
2346
|
+
if (args.targetBefore.length > 0 && args.targetAfter.length > 0) {
|
|
2347
|
+
return "target_before+target_after";
|
|
222
2348
|
}
|
|
223
|
-
|
|
224
|
-
const end = start + patch.target.anchor.length;
|
|
225
|
-
return {
|
|
226
|
-
range: { start, end },
|
|
227
|
-
insertIndex: patch.target.kind === "before" ? start : end
|
|
228
|
-
};
|
|
2349
|
+
return args.targetBefore.length > 0 ? "target_before" : "target_after";
|
|
229
2350
|
}
|
|
230
|
-
function
|
|
231
|
-
|
|
232
|
-
|
|
2351
|
+
function samePatchPath2(left, right) {
|
|
2352
|
+
return posix2.normalize(left) === posix2.normalize(right);
|
|
2353
|
+
}
|
|
2354
|
+
async function selfCheckRenderedPatch(patch, files) {
|
|
2355
|
+
if (patch === undefined) {
|
|
2356
|
+
return;
|
|
233
2357
|
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
2358
|
+
checkPatchBytesInMemory(Buffer.from(patch, "utf8"), files);
|
|
2359
|
+
}
|
|
2360
|
+
function pairedSelfCheckFiles(srcLabel, dstLabel, srcOriginal, dstOriginal, sameFile) {
|
|
2361
|
+
const identity = sameFile ? "move-file" : undefined;
|
|
2362
|
+
const files = [{ path: srcLabel, bytes: srcOriginal, identity }];
|
|
2363
|
+
if (dstLabel !== srcLabel) {
|
|
2364
|
+
files.push({ path: dstLabel, bytes: dstOriginal, identity });
|
|
239
2365
|
}
|
|
240
|
-
return
|
|
2366
|
+
return files;
|
|
241
2367
|
}
|
|
242
|
-
function
|
|
243
|
-
|
|
2368
|
+
function renderMovePatch(args, srcOriginal, dstOriginal, selection, sameFile, payloadSha256) {
|
|
2369
|
+
const id = "move-1";
|
|
2370
|
+
const { source, target, payload } = selection;
|
|
2371
|
+
const sourceBefore = adjacentLineBefore(srcOriginal, source.start);
|
|
2372
|
+
const sourceAfter = adjacentLineAfter(srcOriginal, source.end);
|
|
2373
|
+
const targetBefore = args.targetBefore.length === 0 ? adjacentLineBefore(dstOriginal, target.range.start) : args.targetBefore;
|
|
2374
|
+
const targetAfter = args.targetAfter.length === 0 ? adjacentLineAfter(dstOriginal, target.range.end) : args.targetAfter;
|
|
2375
|
+
const payloadLines = countLines(payload);
|
|
2376
|
+
const sourceHunkStart = source.start - sourceBefore.length;
|
|
2377
|
+
const sourceOldStart = lineNumberAt2(srcOriginal, sourceHunkStart);
|
|
2378
|
+
const sourceOldCount = countLines(sourceBefore) + payloadLines + countLines(sourceAfter);
|
|
2379
|
+
const sourceNewStart = sameFile && target.insertIndex <= sourceHunkStart ? sourceOldStart + payloadLines : sourceOldStart;
|
|
2380
|
+
const sourceNewCount = sourceOldCount - payloadLines;
|
|
2381
|
+
const targetHunkStart = target.insertIndex - targetBefore.length;
|
|
2382
|
+
const targetOldStart = lineNumberAt2(dstOriginal, targetHunkStart);
|
|
2383
|
+
const targetOldCount = countLines(targetBefore) + countLines(targetAfter);
|
|
2384
|
+
const targetNewStart = sameFile && source.end <= targetHunkStart ? targetOldStart - payloadLines : targetOldStart;
|
|
2385
|
+
const targetNewCount = targetOldCount + payloadLines;
|
|
2386
|
+
const sourceBody = joinPatchChunks([
|
|
2387
|
+
renderHunkBytes(sourceBefore, " "),
|
|
2388
|
+
renderHunkBytes(payload, "-"),
|
|
2389
|
+
renderHunkBytes(sourceAfter, " ")
|
|
2390
|
+
]);
|
|
2391
|
+
const targetBody = joinPatchChunks([
|
|
2392
|
+
renderHunkBytes(targetBefore, " "),
|
|
2393
|
+
renderHunkBytes(payload, "+"),
|
|
2394
|
+
renderHunkBytes(targetAfter, " ")
|
|
2395
|
+
]);
|
|
2396
|
+
const sourceHunkHeader = `@@ -${sourceOldStart},${sourceOldCount} +${sourceNewStart},${sourceNewCount} @@ blockpatch-source id=${id}`;
|
|
2397
|
+
const targetHunkHeader = `@@ -${targetOldStart},${targetOldCount} +${targetNewStart},${targetNewCount} @@ blockpatch-target id=${id}`;
|
|
2398
|
+
if (!sameFile) {
|
|
2399
|
+
return [
|
|
2400
|
+
`diff --blockpatch a/${args.src} b/${args.src}`,
|
|
2401
|
+
"blockpatch version 1",
|
|
2402
|
+
`blockpatch move id=${id} role=source payload-sha256=${payloadSha256}`,
|
|
2403
|
+
`--- a/${args.src}`,
|
|
2404
|
+
`+++ b/${args.src}`,
|
|
2405
|
+
"",
|
|
2406
|
+
sourceHunkHeader,
|
|
2407
|
+
sourceBody,
|
|
2408
|
+
"",
|
|
2409
|
+
`diff --blockpatch a/${args.dst} b/${args.dst}`,
|
|
2410
|
+
"blockpatch version 1",
|
|
2411
|
+
`blockpatch move id=${id} role=target payload-sha256=${payloadSha256}`,
|
|
2412
|
+
`--- a/${args.dst}`,
|
|
2413
|
+
`+++ b/${args.dst}`,
|
|
2414
|
+
"",
|
|
2415
|
+
targetHunkHeader,
|
|
2416
|
+
targetBody
|
|
2417
|
+
].join(`
|
|
2418
|
+
`) + `
|
|
2419
|
+
`;
|
|
2420
|
+
}
|
|
2421
|
+
return [
|
|
2422
|
+
`diff --blockpatch a/${args.src} b/${args.dst}`,
|
|
2423
|
+
"blockpatch version 1",
|
|
2424
|
+
`blockpatch move id=${id} payload-sha256=${payloadSha256}`,
|
|
2425
|
+
`--- a/${args.src}`,
|
|
2426
|
+
`+++ b/${args.dst}`,
|
|
2427
|
+
"",
|
|
2428
|
+
sourceHunkHeader,
|
|
2429
|
+
sourceBody,
|
|
2430
|
+
targetHunkHeader,
|
|
2431
|
+
targetBody
|
|
2432
|
+
].join(`
|
|
2433
|
+
`) + `
|
|
2434
|
+
`;
|
|
2435
|
+
}
|
|
2436
|
+
function renderInsertionPatch(args, dstOriginal, target, payloadSha256) {
|
|
2437
|
+
const id = "move-1";
|
|
2438
|
+
const targetBefore = args.targetBefore.length === 0 ? adjacentLineBefore(dstOriginal, target.range.start) : args.targetBefore;
|
|
2439
|
+
const targetAfter = args.targetAfter.length === 0 ? adjacentLineAfter(dstOriginal, target.range.end) : args.targetAfter;
|
|
2440
|
+
const payloadLines = countLines(args.payload);
|
|
2441
|
+
const targetHunkStart = target.insertIndex - targetBefore.length;
|
|
2442
|
+
const targetOldStart = lineNumberAt2(dstOriginal, targetHunkStart);
|
|
2443
|
+
const targetOldCount = countLines(targetBefore) + countLines(targetAfter);
|
|
2444
|
+
const targetNewCount = targetOldCount + payloadLines;
|
|
2445
|
+
const targetBody = joinPatchChunks([
|
|
2446
|
+
renderHunkBytes(targetBefore, " "),
|
|
2447
|
+
renderHunkBytes(args.payload, "+"),
|
|
2448
|
+
renderHunkBytes(targetAfter, " ")
|
|
2449
|
+
]);
|
|
2450
|
+
return [
|
|
2451
|
+
`diff --blockpatch a/${args.dst} b/${args.dst}`,
|
|
2452
|
+
"blockpatch version 1",
|
|
2453
|
+
`blockpatch move id=${id} payload-sha256=${payloadSha256}`,
|
|
2454
|
+
`--- a/${args.dst}`,
|
|
2455
|
+
`+++ b/${args.dst}`,
|
|
2456
|
+
"",
|
|
2457
|
+
`@@ -${targetOldStart},${targetOldCount} +${targetOldStart},${targetNewCount} @@ blockpatch-target id=${id}`,
|
|
2458
|
+
targetBody
|
|
2459
|
+
].join(`
|
|
2460
|
+
`) + `
|
|
2461
|
+
`;
|
|
2462
|
+
}
|
|
2463
|
+
function renderDeletionPatch(args, srcOriginal, source, payload, payloadSha256) {
|
|
2464
|
+
const id = "move-1";
|
|
2465
|
+
const sourceBefore = adjacentLineBefore(srcOriginal, source.start);
|
|
2466
|
+
const sourceAfter = adjacentLineAfter(srcOriginal, source.end);
|
|
2467
|
+
const payloadLines = countLines(payload);
|
|
2468
|
+
const sourceHunkStart = source.start - sourceBefore.length;
|
|
2469
|
+
const sourceOldStart = lineNumberAt2(srcOriginal, sourceHunkStart);
|
|
2470
|
+
const sourceOldCount = countLines(sourceBefore) + payloadLines + countLines(sourceAfter);
|
|
2471
|
+
const sourceNewCount = sourceOldCount - payloadLines;
|
|
2472
|
+
const sourceBody = joinPatchChunks([
|
|
2473
|
+
renderHunkBytes(sourceBefore, " "),
|
|
2474
|
+
renderHunkBytes(payload, "-"),
|
|
2475
|
+
renderHunkBytes(sourceAfter, " ")
|
|
2476
|
+
]);
|
|
2477
|
+
return [
|
|
2478
|
+
`diff --blockpatch a/${args.src} b/${args.src}`,
|
|
2479
|
+
"blockpatch version 1",
|
|
2480
|
+
`blockpatch move id=${id} payload-sha256=${payloadSha256}`,
|
|
2481
|
+
`--- a/${args.src}`,
|
|
2482
|
+
`+++ b/${args.src}`,
|
|
2483
|
+
"",
|
|
2484
|
+
`@@ -${sourceOldStart},${sourceOldCount} +${sourceOldStart},${sourceNewCount} @@ blockpatch-source id=${id}`,
|
|
2485
|
+
sourceBody
|
|
2486
|
+
].join(`
|
|
2487
|
+
`) + `
|
|
2488
|
+
`;
|
|
2489
|
+
}
|
|
2490
|
+
function renderFileCreationPatch(args, payloadSha256) {
|
|
2491
|
+
const id = "move-1";
|
|
2492
|
+
const payloadLines = countLines(args.payload);
|
|
2493
|
+
const targetBody = renderHunkBytes(args.payload, "+");
|
|
2494
|
+
return [
|
|
2495
|
+
`diff --blockpatch ${devNull} b/${args.dst}`,
|
|
2496
|
+
"blockpatch version 1",
|
|
2497
|
+
`blockpatch move id=${id} payload-sha256=${payloadSha256}`,
|
|
2498
|
+
`--- ${devNull}`,
|
|
2499
|
+
`+++ b/${args.dst}`,
|
|
2500
|
+
"",
|
|
2501
|
+
`@@ -0,0 +${payloadLines === 0 ? "0,0" : `1,${payloadLines}`} @@ blockpatch-target id=${id}`,
|
|
2502
|
+
targetBody
|
|
2503
|
+
].join(`
|
|
2504
|
+
`) + `
|
|
2505
|
+
`;
|
|
2506
|
+
}
|
|
2507
|
+
function renderFileRemovalPatch(args, payload, payloadSha256) {
|
|
2508
|
+
const id = "move-1";
|
|
2509
|
+
const payloadLines = countLines(payload);
|
|
2510
|
+
const sourceBody = renderHunkBytes(payload, "-");
|
|
2511
|
+
return [
|
|
2512
|
+
`diff --blockpatch a/${args.src} ${devNull}`,
|
|
2513
|
+
"blockpatch version 1",
|
|
2514
|
+
`blockpatch move id=${id} payload-sha256=${payloadSha256}`,
|
|
2515
|
+
`--- a/${args.src}`,
|
|
2516
|
+
`+++ ${devNull}`,
|
|
2517
|
+
"",
|
|
2518
|
+
`@@ -${payloadLines === 0 ? "0,0" : `1,${payloadLines}`} +0,0 @@ blockpatch-source id=${id}`,
|
|
2519
|
+
sourceBody
|
|
2520
|
+
].join(`
|
|
2521
|
+
`) + `
|
|
2522
|
+
`;
|
|
2523
|
+
}
|
|
2524
|
+
function lineNumberAt2(file, byteIndex) {
|
|
2525
|
+
let line = 1;
|
|
2526
|
+
for (let index = 0;index < byteIndex; index += 1) {
|
|
2527
|
+
if (file[index] === 10) {
|
|
2528
|
+
line += 1;
|
|
2529
|
+
}
|
|
2530
|
+
}
|
|
2531
|
+
return line;
|
|
2532
|
+
}
|
|
2533
|
+
function countLines(bytes) {
|
|
2534
|
+
if (bytes.length === 0) {
|
|
2535
|
+
return 0;
|
|
2536
|
+
}
|
|
2537
|
+
let newlines = 0;
|
|
2538
|
+
for (const byte of bytes) {
|
|
2539
|
+
if (byte === 10) {
|
|
2540
|
+
newlines += 1;
|
|
2541
|
+
}
|
|
2542
|
+
}
|
|
2543
|
+
return bytes[bytes.length - 1] === 10 ? newlines : newlines + 1;
|
|
2544
|
+
}
|
|
2545
|
+
function joinPatchChunks(chunks) {
|
|
2546
|
+
return chunks.filter((chunk) => chunk.length > 0).join(`
|
|
2547
|
+
`);
|
|
2548
|
+
}
|
|
2549
|
+
function adjacentLineBefore(file, index) {
|
|
2550
|
+
if (index === 0) {
|
|
2551
|
+
return Buffer.alloc(0);
|
|
2552
|
+
}
|
|
2553
|
+
const previousLf = file.lastIndexOf(10, index - 2);
|
|
2554
|
+
return Buffer.from(file.subarray(previousLf === -1 ? 0 : previousLf + 1, index));
|
|
2555
|
+
}
|
|
2556
|
+
function adjacentLineAfter(file, index) {
|
|
2557
|
+
if (index >= file.length) {
|
|
2558
|
+
return Buffer.alloc(0);
|
|
2559
|
+
}
|
|
2560
|
+
const nextLf = file.indexOf(10, index);
|
|
2561
|
+
return Buffer.from(file.subarray(index, nextLf === -1 ? file.length : nextLf + 1));
|
|
2562
|
+
}
|
|
2563
|
+
function renderHunkBytes(bytes, prefix) {
|
|
2564
|
+
const text2 = decodeUtf8(bytes);
|
|
2565
|
+
if (text2.length === 0) {
|
|
2566
|
+
return "";
|
|
2567
|
+
}
|
|
2568
|
+
const lines = text2.split(/(?<=\n)/);
|
|
2569
|
+
return lines.filter((line) => line.length > 0).map((line) => {
|
|
2570
|
+
if (line.endsWith(`
|
|
2571
|
+
`)) {
|
|
2572
|
+
return `${prefix}${line.slice(0, -1)}`;
|
|
2573
|
+
}
|
|
2574
|
+
return `${prefix}${line}
|
|
2575
|
+
\`;
|
|
2576
|
+
}).join(`
|
|
2577
|
+
`);
|
|
244
2578
|
}
|
|
245
|
-
|
|
246
|
-
const info = await stat(path);
|
|
247
|
-
const dir = dirname(path);
|
|
248
|
-
const base = basename(path);
|
|
249
|
-
const temp = join(dir, `.${base}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`);
|
|
2579
|
+
function decodeUtf8(bytes) {
|
|
250
2580
|
try {
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
return;
|
|
2581
|
+
return utf8Decoder.decode(bytes);
|
|
2582
|
+
} catch {
|
|
2583
|
+
fail("invalid_utf8", "move --diff cannot render invalid UTF-8 bytes", {
|
|
2584
|
+
phase: "render",
|
|
2585
|
+
anchor: "move --diff"
|
|
257
2586
|
});
|
|
258
|
-
throw error;
|
|
259
2587
|
}
|
|
260
2588
|
}
|
|
261
2589
|
|
|
262
2590
|
// src/cli.ts
|
|
2591
|
+
var packageJson = createRequire(import.meta.url)("../package.json");
|
|
2592
|
+
var OUTPUT_FLAGS = new Set(["--json-output", "--explain"]);
|
|
2593
|
+
var CWD_VALUE_FLAGS = new Set(["--cwd", "--directory", "-d"]);
|
|
2594
|
+
var PATCH_INPUT_VALUE_FLAGS = new Set(["-i", "--input"]);
|
|
2595
|
+
var STRIP_VALUE_FLAGS = new Set(["-p", "--strip"]);
|
|
2596
|
+
var PATCH_VALUE_FLAGS = new Set([...CWD_VALUE_FLAGS, ...PATCH_INPUT_VALUE_FLAGS, ...STRIP_VALUE_FLAGS]);
|
|
2597
|
+
var MOVE_JSON_VALUE_FLAGS = new Set(["--json"]);
|
|
2598
|
+
var MOVE_KEY_BY_FLAG = {
|
|
2599
|
+
"--src": "src",
|
|
2600
|
+
"--src-start": "src_start",
|
|
2601
|
+
"--src-end": "src_end",
|
|
2602
|
+
"--dst": "dst",
|
|
2603
|
+
"--payload": "payload",
|
|
2604
|
+
"--target-before": "target_before",
|
|
2605
|
+
"--target-after": "target_after",
|
|
2606
|
+
"--expected-payload-sha256": "expected_payload_sha256"
|
|
2607
|
+
};
|
|
2608
|
+
var MOVE_ARG_VALUE_FLAGS = new Set(Object.keys(MOVE_KEY_BY_FLAG));
|
|
2609
|
+
var MOVE_VALUE_FLAGS = new Set([...CWD_VALUE_FLAGS, ...MOVE_JSON_VALUE_FLAGS, ...MOVE_ARG_VALUE_FLAGS]);
|
|
263
2610
|
async function main(argv) {
|
|
264
2611
|
const options = parseArgs(argv);
|
|
265
2612
|
if (options.command === "help") {
|
|
@@ -267,84 +2614,401 @@ async function main(argv) {
|
|
|
267
2614
|
return 0;
|
|
268
2615
|
}
|
|
269
2616
|
if (options.command === "version") {
|
|
270
|
-
|
|
2617
|
+
writeSuccess(options, { version: packageJson.version });
|
|
2618
|
+
return 0;
|
|
2619
|
+
}
|
|
2620
|
+
if (options.command === "move" || options.command === "plan") {
|
|
2621
|
+
const args = await loadMoveArgs(options);
|
|
2622
|
+
const result2 = await moveBlock(args, {
|
|
2623
|
+
cwd: options.cwd,
|
|
2624
|
+
dryRun: options.dryRun ? true : undefined,
|
|
2625
|
+
diff: options.diff
|
|
2626
|
+
});
|
|
2627
|
+
if (options.diff && result2.patch !== undefined) {
|
|
2628
|
+
writeSuccess(options, result2, result2.patch);
|
|
2629
|
+
return 0;
|
|
2630
|
+
}
|
|
2631
|
+
writeChangeResult(options, result2, options.dryRun || args.dry_run === true ? "would change" : "changed");
|
|
271
2632
|
return 0;
|
|
272
2633
|
}
|
|
273
|
-
|
|
274
|
-
|
|
2634
|
+
const result = await runPatchCommand(options);
|
|
2635
|
+
const verb = options.command === "check" || options.dryRun ? "would change" : "changed";
|
|
2636
|
+
writeChangeResult(options, result, verb);
|
|
2637
|
+
return 0;
|
|
2638
|
+
}
|
|
2639
|
+
async function runPatchCommand(options) {
|
|
2640
|
+
const patchPath = options.patchPath ?? "-";
|
|
2641
|
+
const inputPatchPath = patchPath === "-" ? patchPath : resolve3(patchPath);
|
|
2642
|
+
if (options.command === "check") {
|
|
2643
|
+
return patchPath === "-" ? checkPatchBytes(await readStdin(), {
|
|
2644
|
+
cwd: options.cwd,
|
|
2645
|
+
reverse: options.reverse,
|
|
2646
|
+
stripComponents: options.stripComponents
|
|
2647
|
+
}) : checkPatchFile(inputPatchPath, {
|
|
2648
|
+
cwd: options.cwd,
|
|
2649
|
+
reverse: options.reverse,
|
|
2650
|
+
stripComponents: options.stripComponents
|
|
2651
|
+
});
|
|
275
2652
|
}
|
|
276
|
-
|
|
2653
|
+
return patchPath === "-" ? applyPatchBytes(await readStdin(), {
|
|
2654
|
+
cwd: options.cwd,
|
|
2655
|
+
dryRun: options.dryRun,
|
|
2656
|
+
reverse: options.reverse,
|
|
2657
|
+
stripComponents: options.stripComponents
|
|
2658
|
+
}) : applyPatchFile(inputPatchPath, {
|
|
277
2659
|
cwd: options.cwd,
|
|
278
|
-
dryRun: options.dryRun
|
|
2660
|
+
dryRun: options.dryRun,
|
|
2661
|
+
reverse: options.reverse,
|
|
2662
|
+
stripComponents: options.stripComponents
|
|
279
2663
|
});
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
2664
|
+
}
|
|
2665
|
+
async function loadMoveArgs(options) {
|
|
2666
|
+
if (options.moveArgs !== undefined) {
|
|
2667
|
+
return options.moveArgs;
|
|
2668
|
+
}
|
|
2669
|
+
if (options.moveJsonPath === undefined) {
|
|
2670
|
+
throw new BlockPatchError("missing_move_args", `${options.command} requires --json or --src flags`);
|
|
2671
|
+
}
|
|
2672
|
+
const jsonBytes = options.moveJsonPath === "-" ? await readStdin() : await readFileChecked(resolve3(options.moveJsonPath), "move JSON file");
|
|
2673
|
+
try {
|
|
2674
|
+
return JSON.parse(jsonBytes.toString("utf8"));
|
|
2675
|
+
} catch (error) {
|
|
2676
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2677
|
+
throw new BlockPatchError("invalid_json", `Invalid move JSON: ${message}`);
|
|
283
2678
|
}
|
|
284
|
-
return 0;
|
|
285
2679
|
}
|
|
286
2680
|
function parseArgs(argv) {
|
|
287
2681
|
const args = [...argv];
|
|
2682
|
+
const outputFlags = takeLeadingOutputFlags(args);
|
|
288
2683
|
const first = args.shift();
|
|
289
2684
|
if (first === undefined || first === "help" || first === "--help" || first === "-h") {
|
|
290
|
-
return
|
|
2685
|
+
return base("help", outputFlags.jsonOutput);
|
|
291
2686
|
}
|
|
292
2687
|
if (first === "version" || first === "--version" || first === "-v") {
|
|
293
|
-
|
|
2688
|
+
const options = base("version", outputFlags.jsonOutput);
|
|
2689
|
+
for (const arg of args) {
|
|
2690
|
+
if (isOutputFlag(arg)) {
|
|
2691
|
+
options.jsonOutput = true;
|
|
2692
|
+
continue;
|
|
2693
|
+
}
|
|
2694
|
+
if (arg.startsWith("-")) {
|
|
2695
|
+
throw new BlockPatchError("unknown_option", `Unknown option: ${arg}`);
|
|
2696
|
+
}
|
|
2697
|
+
throw new BlockPatchError("too_many_args", `Unexpected argument: ${arg}`);
|
|
2698
|
+
}
|
|
2699
|
+
return options;
|
|
294
2700
|
}
|
|
295
|
-
if (first !== "apply" && first !== "check") {
|
|
2701
|
+
if (first !== "apply" && first !== "check" && first !== "move" && first !== "plan") {
|
|
296
2702
|
throw new BlockPatchError("unknown_command", `Unknown command: ${first}`);
|
|
297
2703
|
}
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
2704
|
+
if (first === "move" || first === "plan") {
|
|
2705
|
+
return parseMoveArgs(first, args, first === "plan" ? true : outputFlags.jsonOutput, outputFlags.explain);
|
|
2706
|
+
}
|
|
2707
|
+
return parsePatchArgs(first, args, outputFlags.jsonOutput, outputFlags.explain);
|
|
2708
|
+
}
|
|
2709
|
+
function parsePatchArgs(command, args, jsonOutput, explain) {
|
|
2710
|
+
const options = base(command, jsonOutput);
|
|
2711
|
+
if (explain && command === "apply") {
|
|
2712
|
+
options.dryRun = true;
|
|
2713
|
+
}
|
|
301
2714
|
while (args.length > 0) {
|
|
302
2715
|
const arg = args.shift();
|
|
2716
|
+
if (takeOutputFlag(options, arg)) {
|
|
2717
|
+
continue;
|
|
2718
|
+
}
|
|
303
2719
|
if (arg === "--dry-run") {
|
|
304
|
-
dryRun = true;
|
|
2720
|
+
options.dryRun = true;
|
|
305
2721
|
continue;
|
|
306
2722
|
}
|
|
307
|
-
if (arg === "--
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
cwd =
|
|
2723
|
+
if (arg === "--reverse" || arg === "-R") {
|
|
2724
|
+
options.reverse = true;
|
|
2725
|
+
continue;
|
|
2726
|
+
}
|
|
2727
|
+
if (isValueFlag(arg, CWD_VALUE_FLAGS)) {
|
|
2728
|
+
options.cwd = requireValue(args, arg, true);
|
|
313
2729
|
continue;
|
|
314
2730
|
}
|
|
315
2731
|
if (arg?.startsWith("--cwd=")) {
|
|
316
|
-
cwd =
|
|
2732
|
+
options.cwd = resolve3(arg.slice("--cwd=".length));
|
|
2733
|
+
continue;
|
|
2734
|
+
}
|
|
2735
|
+
if (arg?.startsWith("--directory=")) {
|
|
2736
|
+
options.cwd = resolve3(arg.slice("--directory=".length));
|
|
2737
|
+
continue;
|
|
2738
|
+
}
|
|
2739
|
+
if (isValueFlag(arg, PATCH_INPUT_VALUE_FLAGS)) {
|
|
2740
|
+
setPatchPath(options, requireValue(args, arg, false));
|
|
2741
|
+
continue;
|
|
2742
|
+
}
|
|
2743
|
+
if (arg?.startsWith("--input=")) {
|
|
2744
|
+
setPatchPath(options, arg.slice("--input=".length));
|
|
2745
|
+
continue;
|
|
2746
|
+
}
|
|
2747
|
+
const stripComponents = parseStripOption(arg, args);
|
|
2748
|
+
if (stripComponents !== undefined) {
|
|
2749
|
+
options.stripComponents = stripComponents;
|
|
317
2750
|
continue;
|
|
318
2751
|
}
|
|
319
|
-
if (arg?.startsWith("-")) {
|
|
2752
|
+
if (arg?.startsWith("-") && arg !== "-") {
|
|
320
2753
|
throw new BlockPatchError("unknown_option", `Unknown option: ${arg}`);
|
|
321
2754
|
}
|
|
322
|
-
|
|
323
|
-
|
|
2755
|
+
setPatchPath(options, arg ?? "");
|
|
2756
|
+
}
|
|
2757
|
+
if (command === "check" && options.dryRun) {
|
|
2758
|
+
throw new BlockPatchError("invalid_option", "--dry-run is only valid with apply or move");
|
|
2759
|
+
}
|
|
2760
|
+
return options;
|
|
2761
|
+
}
|
|
2762
|
+
function parseMoveArgs(command, args, jsonOutput, explain) {
|
|
2763
|
+
const options = base(command, jsonOutput);
|
|
2764
|
+
if (command === "plan") {
|
|
2765
|
+
options.dryRun = true;
|
|
2766
|
+
options.diff = true;
|
|
2767
|
+
options.jsonOutput = true;
|
|
2768
|
+
}
|
|
2769
|
+
if (explain) {
|
|
2770
|
+
options.dryRun = true;
|
|
2771
|
+
}
|
|
2772
|
+
const moveArgs = {};
|
|
2773
|
+
let sawFlagArgs = false;
|
|
2774
|
+
while (args.length > 0) {
|
|
2775
|
+
const arg = args.shift();
|
|
2776
|
+
if (takeOutputFlag(options, arg)) {
|
|
2777
|
+
continue;
|
|
2778
|
+
}
|
|
2779
|
+
if (arg === "--dry-run") {
|
|
2780
|
+
options.dryRun = true;
|
|
2781
|
+
continue;
|
|
2782
|
+
}
|
|
2783
|
+
if (arg === "--diff") {
|
|
2784
|
+
options.diff = true;
|
|
2785
|
+
continue;
|
|
2786
|
+
}
|
|
2787
|
+
if (isValueFlag(arg, CWD_VALUE_FLAGS)) {
|
|
2788
|
+
options.cwd = requireValue(args, arg, true);
|
|
2789
|
+
continue;
|
|
2790
|
+
}
|
|
2791
|
+
if (arg?.startsWith("--cwd=")) {
|
|
2792
|
+
options.cwd = resolve3(arg.slice("--cwd=".length));
|
|
2793
|
+
continue;
|
|
2794
|
+
}
|
|
2795
|
+
if (arg?.startsWith("--directory=")) {
|
|
2796
|
+
options.cwd = resolve3(arg.slice("--directory=".length));
|
|
2797
|
+
continue;
|
|
2798
|
+
}
|
|
2799
|
+
if (isValueFlag(arg, MOVE_JSON_VALUE_FLAGS)) {
|
|
2800
|
+
options.moveJsonPath = requireValue(args, arg, false);
|
|
2801
|
+
continue;
|
|
324
2802
|
}
|
|
325
|
-
|
|
2803
|
+
if (arg?.startsWith("--json=")) {
|
|
2804
|
+
options.moveJsonPath = arg.slice("--json=".length);
|
|
2805
|
+
continue;
|
|
2806
|
+
}
|
|
2807
|
+
const key = argToMoveKey(arg);
|
|
2808
|
+
if (key !== undefined) {
|
|
2809
|
+
moveArgs[key] = requireValue(args, arg ?? "", false);
|
|
2810
|
+
sawFlagArgs = true;
|
|
2811
|
+
continue;
|
|
2812
|
+
}
|
|
2813
|
+
throw new BlockPatchError("unknown_option", `Unknown option: ${String(arg)}`);
|
|
2814
|
+
}
|
|
2815
|
+
if (options.moveJsonPath !== undefined && sawFlagArgs) {
|
|
2816
|
+
throw new BlockPatchError("invalid_option", "move cannot combine --json with --src flags");
|
|
2817
|
+
}
|
|
2818
|
+
if (sawFlagArgs) {
|
|
2819
|
+
options.moveArgs = moveArgs;
|
|
2820
|
+
}
|
|
2821
|
+
return options;
|
|
2822
|
+
}
|
|
2823
|
+
function argToMoveKey(arg) {
|
|
2824
|
+
return MOVE_KEY_BY_FLAG[arg];
|
|
2825
|
+
}
|
|
2826
|
+
function base(command, jsonOutput) {
|
|
2827
|
+
return {
|
|
2828
|
+
command,
|
|
2829
|
+
cwd: process.cwd(),
|
|
2830
|
+
dryRun: false,
|
|
2831
|
+
diff: false,
|
|
2832
|
+
jsonOutput,
|
|
2833
|
+
reverse: false,
|
|
2834
|
+
stripComponents: 1
|
|
2835
|
+
};
|
|
2836
|
+
}
|
|
2837
|
+
function setPatchPath(options, path) {
|
|
2838
|
+
if (options.patchPath !== undefined) {
|
|
2839
|
+
throw new BlockPatchError("too_many_args", `Unexpected argument: ${path}`);
|
|
2840
|
+
}
|
|
2841
|
+
options.patchPath = path;
|
|
2842
|
+
}
|
|
2843
|
+
function parseStripOption(arg, args) {
|
|
2844
|
+
if (isValueFlag(arg, STRIP_VALUE_FLAGS)) {
|
|
2845
|
+
return parseStripComponents(requireValue(args, arg, false), arg);
|
|
2846
|
+
}
|
|
2847
|
+
if (arg?.startsWith("-p") === true && arg.length > 2) {
|
|
2848
|
+
return parseStripComponents(arg.slice(2), "-p");
|
|
2849
|
+
}
|
|
2850
|
+
if (arg?.startsWith("--strip=") === true) {
|
|
2851
|
+
return parseStripComponents(arg.slice("--strip=".length), "--strip");
|
|
2852
|
+
}
|
|
2853
|
+
return;
|
|
2854
|
+
}
|
|
2855
|
+
function parseStripComponents(value, option) {
|
|
2856
|
+
if (!/^\d+$/.test(value)) {
|
|
2857
|
+
throw new BlockPatchError("invalid_option", `Invalid strip count for ${option}: ${value}`);
|
|
2858
|
+
}
|
|
2859
|
+
const parsed = Number(value);
|
|
2860
|
+
if (!Number.isSafeInteger(parsed)) {
|
|
2861
|
+
throw new BlockPatchError("invalid_option", `Invalid strip count for ${option}: ${value}`);
|
|
2862
|
+
}
|
|
2863
|
+
return parsed;
|
|
2864
|
+
}
|
|
2865
|
+
function takeLeadingOutputFlags(args) {
|
|
2866
|
+
let jsonOutput = false;
|
|
2867
|
+
let explain = false;
|
|
2868
|
+
while (isOutputFlag(args[0])) {
|
|
2869
|
+
const arg = args.shift();
|
|
2870
|
+
jsonOutput = true;
|
|
2871
|
+
explain = explain || arg === "--explain";
|
|
2872
|
+
}
|
|
2873
|
+
return { jsonOutput, explain };
|
|
2874
|
+
}
|
|
2875
|
+
function takeOutputFlag(options, arg) {
|
|
2876
|
+
if (arg === "--explain") {
|
|
2877
|
+
options.jsonOutput = true;
|
|
2878
|
+
if (options.command === "apply" || options.command === "move" || options.command === "plan") {
|
|
2879
|
+
options.dryRun = true;
|
|
2880
|
+
}
|
|
2881
|
+
return true;
|
|
2882
|
+
}
|
|
2883
|
+
if (arg === "--json-output") {
|
|
2884
|
+
options.jsonOutput = true;
|
|
2885
|
+
return true;
|
|
2886
|
+
}
|
|
2887
|
+
return false;
|
|
2888
|
+
}
|
|
2889
|
+
function isOutputFlag(arg) {
|
|
2890
|
+
return arg !== undefined && OUTPUT_FLAGS.has(arg);
|
|
2891
|
+
}
|
|
2892
|
+
function isValueFlag(arg, flags) {
|
|
2893
|
+
return arg !== undefined && flags.has(arg);
|
|
2894
|
+
}
|
|
2895
|
+
function requireValue(args, option, pathValue) {
|
|
2896
|
+
const value = args.shift();
|
|
2897
|
+
if (value === undefined) {
|
|
2898
|
+
throw new BlockPatchError("missing_option_value", `Missing value for ${option}`);
|
|
2899
|
+
}
|
|
2900
|
+
return pathValue ? resolve3(value) : value;
|
|
2901
|
+
}
|
|
2902
|
+
async function readStdin() {
|
|
2903
|
+
const chunks = [];
|
|
2904
|
+
for await (const chunk of process.stdin) {
|
|
2905
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
2906
|
+
}
|
|
2907
|
+
return Buffer.concat(chunks);
|
|
2908
|
+
}
|
|
2909
|
+
function writeChangeResult(options, result, verb) {
|
|
2910
|
+
if (options.jsonOutput) {
|
|
2911
|
+
writeSuccess(options, result);
|
|
2912
|
+
return;
|
|
2913
|
+
}
|
|
2914
|
+
for (const path of result.changed) {
|
|
2915
|
+
console.log(`${verb} ${path}`);
|
|
2916
|
+
}
|
|
2917
|
+
if (result.changed.length === 0) {
|
|
2918
|
+
for (const path of result.affected) {
|
|
2919
|
+
console.log(`unchanged ${path}`);
|
|
2920
|
+
}
|
|
2921
|
+
}
|
|
2922
|
+
}
|
|
2923
|
+
function writeSuccess(options, result, plainText) {
|
|
2924
|
+
if (options.jsonOutput) {
|
|
2925
|
+
console.log(JSON.stringify({ ok: true, ...objectResult(result), ...jsonSuccessMetadata(options) }));
|
|
2926
|
+
return;
|
|
326
2927
|
}
|
|
327
|
-
if (
|
|
328
|
-
|
|
2928
|
+
if (plainText !== undefined) {
|
|
2929
|
+
console.log(plainText);
|
|
2930
|
+
return;
|
|
2931
|
+
}
|
|
2932
|
+
if (typeof result === "object" && result !== null && "version" in result) {
|
|
2933
|
+
console.log(`blockpatch ${result.version}`);
|
|
2934
|
+
}
|
|
2935
|
+
}
|
|
2936
|
+
function objectResult(result) {
|
|
2937
|
+
return typeof result === "object" && result !== null ? result : {};
|
|
2938
|
+
}
|
|
2939
|
+
function jsonSuccessMetadata(options) {
|
|
2940
|
+
if (options.command === "apply" || options.command === "check") {
|
|
2941
|
+
return { strip_components: options.stripComponents };
|
|
329
2942
|
}
|
|
330
|
-
return {
|
|
2943
|
+
return {};
|
|
331
2944
|
}
|
|
332
2945
|
function printHelp() {
|
|
333
2946
|
console.log(`blockpatch
|
|
334
2947
|
|
|
335
2948
|
Usage:
|
|
336
|
-
blockpatch check
|
|
337
|
-
blockpatch apply <patch.blockpatch> [
|
|
2949
|
+
blockpatch check [patch.blockpatch|-] [-d <dir>] [-pN] [-R|--reverse] [--json-output|--explain]
|
|
2950
|
+
blockpatch apply [patch.blockpatch|-] [-i <patch.blockpatch>] [-d <dir>] [-pN] [-R|--reverse] [--dry-run] [--json-output|--explain]
|
|
2951
|
+
blockpatch plan --json <path.json|-> [--cwd <dir>]
|
|
2952
|
+
blockpatch move --json <path.json|-> [--cwd <dir>] [--dry-run] [--diff] [--json-output|--explain]
|
|
2953
|
+
blockpatch move --src <path> --src-start <text> --src-end <text> --dst <path> --target-before <text> --target-after <text> [--expected-payload-sha256 <sha256>]
|
|
2954
|
+
blockpatch move --src /dev/null --dst <path> --payload <text> --target-before <text>
|
|
2955
|
+
blockpatch move --src <path> --src-start <text> --src-end <text> --dst /dev/null
|
|
338
2956
|
blockpatch version
|
|
339
2957
|
`);
|
|
340
2958
|
}
|
|
341
2959
|
main(process.argv.slice(2)).then((code) => {
|
|
342
2960
|
process.exitCode = code;
|
|
343
2961
|
}, (error) => {
|
|
2962
|
+
const jsonOutput = hasJsonOutputFlag(process.argv.slice(2));
|
|
344
2963
|
if (error instanceof BlockPatchError) {
|
|
345
|
-
|
|
2964
|
+
writeError(error.code, error.message, jsonOutput, error.details);
|
|
346
2965
|
process.exitCode = 1;
|
|
347
2966
|
return;
|
|
348
2967
|
}
|
|
349
|
-
|
|
2968
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2969
|
+
writeError("unexpected_error", message, jsonOutput);
|
|
2970
|
+
process.exitCode = 1;
|
|
350
2971
|
});
|
|
2972
|
+
function hasJsonOutputFlag(argv) {
|
|
2973
|
+
const args = [...argv];
|
|
2974
|
+
const outputFlags = takeLeadingOutputFlags(args);
|
|
2975
|
+
if (outputFlags.jsonOutput) {
|
|
2976
|
+
return true;
|
|
2977
|
+
}
|
|
2978
|
+
const command = args.shift();
|
|
2979
|
+
if (command === "apply" || command === "check") {
|
|
2980
|
+
return hasPatchJsonOutputFlag(args);
|
|
2981
|
+
}
|
|
2982
|
+
if (command === "move") {
|
|
2983
|
+
return hasMoveJsonOutputFlag(args);
|
|
2984
|
+
}
|
|
2985
|
+
if (command === "plan") {
|
|
2986
|
+
return true;
|
|
2987
|
+
}
|
|
2988
|
+
return args.some(isOutputFlag);
|
|
2989
|
+
}
|
|
2990
|
+
function hasPatchJsonOutputFlag(args) {
|
|
2991
|
+
return hasJsonOutputAfterValueFlags(args, PATCH_VALUE_FLAGS);
|
|
2992
|
+
}
|
|
2993
|
+
function hasMoveJsonOutputFlag(args) {
|
|
2994
|
+
return hasJsonOutputAfterValueFlags(args, MOVE_VALUE_FLAGS);
|
|
2995
|
+
}
|
|
2996
|
+
function hasJsonOutputAfterValueFlags(args, valueFlags) {
|
|
2997
|
+
for (let index = 0;index < args.length; index += 1) {
|
|
2998
|
+
const arg = args[index];
|
|
2999
|
+
if (isOutputFlag(arg)) {
|
|
3000
|
+
return true;
|
|
3001
|
+
}
|
|
3002
|
+
if (isValueFlag(arg, valueFlags)) {
|
|
3003
|
+
index += 1;
|
|
3004
|
+
}
|
|
3005
|
+
}
|
|
3006
|
+
return false;
|
|
3007
|
+
}
|
|
3008
|
+
function writeError(code, message, jsonOutput, details = {}) {
|
|
3009
|
+
if (jsonOutput) {
|
|
3010
|
+
console.error(JSON.stringify({ ok: false, error: { code, message, ...details } }));
|
|
3011
|
+
return;
|
|
3012
|
+
}
|
|
3013
|
+
console.error(`blockpatch: ${message}`);
|
|
3014
|
+
}
|