blockpatch 1.0.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.
Files changed (53) hide show
  1. package/CONTRIBUTING.md +9 -1
  2. package/README.md +100 -468
  3. package/conformance/README.md +29 -0
  4. package/conformance/cases/ambiguous-source/case.json +8 -0
  5. package/conformance/cases/ambiguous-target/case.json +8 -0
  6. package/conformance/cases/create-file/case.json +8 -0
  7. package/conformance/cases/crlf/case.json +10 -0
  8. package/conformance/cases/cross-file-relocation/case.json +12 -0
  9. package/conformance/cases/delete-existing-file/case.json +10 -0
  10. package/conformance/cases/insert-existing-file/case.json +10 -0
  11. package/conformance/cases/no-trailing-newline/case.json +10 -0
  12. package/conformance/cases/remove-file/case.json +9 -0
  13. package/conformance/cases/same-file-relocation/case.json +10 -0
  14. package/conformance/runner.mjs +221 -0
  15. package/dist/cli.js +967 -523
  16. package/docs/behavior.md +207 -0
  17. package/docs/commands.md +84 -0
  18. package/docs/spec.md +356 -0
  19. package/examples/README.md +15 -0
  20. package/examples/create-file/README.md +9 -0
  21. package/examples/create-file/expected/file.txt +1 -0
  22. package/examples/create-file/patch.blockpatch +8 -0
  23. package/examples/create-file/work/.gitkeep +1 -0
  24. package/examples/cross-file-relocation/README.md +10 -0
  25. package/examples/cross-file-relocation/expected/source.txt +2 -0
  26. package/examples/cross-file-relocation/expected/target.txt +6 -0
  27. package/examples/cross-file-relocation/patch.blockpatch +26 -0
  28. package/examples/cross-file-relocation/work/source.txt +5 -0
  29. package/examples/cross-file-relocation/work/target.txt +3 -0
  30. package/examples/delete-existing-file/README.md +9 -0
  31. package/examples/delete-existing-file/expected/file.txt +2 -0
  32. package/examples/delete-existing-file/patch.blockpatch +10 -0
  33. package/examples/delete-existing-file/work/file.txt +3 -0
  34. package/examples/failure-ambiguous-target/README.md +9 -0
  35. package/examples/failure-ambiguous-target/patch.blockpatch +14 -0
  36. package/examples/failure-ambiguous-target/work/file.txt +5 -0
  37. package/examples/insert-existing-file/README.md +9 -0
  38. package/examples/insert-existing-file/expected/file.txt +3 -0
  39. package/examples/insert-existing-file/patch.blockpatch +10 -0
  40. package/examples/insert-existing-file/work/file.txt +2 -0
  41. package/examples/remove-file/README.md +9 -0
  42. package/examples/remove-file/expected/.gitkeep +1 -0
  43. package/examples/remove-file/patch.blockpatch +8 -0
  44. package/examples/remove-file/work/file.txt +1 -0
  45. package/examples/reverse/README.md +9 -0
  46. package/examples/reverse/expected/file.txt +4 -0
  47. package/examples/reverse/patch.blockpatch +14 -0
  48. package/examples/reverse/work/file.txt +4 -0
  49. package/examples/same-file-relocation/README.md +9 -0
  50. package/examples/same-file-relocation/expected/file.txt +4 -0
  51. package/examples/same-file-relocation/patch.blockpatch +14 -0
  52. package/examples/same-file-relocation/work/file.txt +4 -0
  53. package/package.json +13 -5
package/dist/cli.js CHANGED
@@ -20,6 +20,12 @@ class BlockPatchError extends Error {
20
20
  function fail(code, message, details) {
21
21
  throw new BlockPatchError(code, message, details);
22
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
+ }
23
29
  function boundedMatchRanges(matches, byteLength) {
24
30
  const ranges = [];
25
31
  for (const start of matches) {
@@ -79,10 +85,11 @@ function clamp(value, min, max) {
79
85
  // src/engine.ts
80
86
  import { Buffer as Buffer3 } from "node:buffer";
81
87
  import { randomBytes } from "node:crypto";
82
- import { chmod, mkdir, rename, stat as stat2, unlink, writeFile } from "node:fs/promises";
83
- import { basename, dirname, join as join2, resolve as resolve2 } from "node:path";
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";
84
90
 
85
91
  // src/files.ts
92
+ import { createHash } from "node:crypto";
86
93
  import { lstatSync, realpathSync } from "node:fs";
87
94
  import { readFile, stat } from "node:fs/promises";
88
95
  async function readFileChecked(path, label) {
@@ -92,6 +99,22 @@ async function readFileChecked(path, label) {
92
99
  failFileSystem(error, path, `Could not read ${label}`);
93
100
  }
94
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
+ }
95
118
  async function statChecked(path, label) {
96
119
  try {
97
120
  return await stat(path);
@@ -125,6 +148,16 @@ function failFileSystem(error, path, action, phase = "io") {
125
148
  const message = error instanceof Error ? `${action}: ${error.message}` : `${action}: ${path}`;
126
149
  fail(fileSystemErrorCode(error), message, { path, phase });
127
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
+ }
128
161
  function fileSystemErrorCode(error) {
129
162
  switch (error.code) {
130
163
  case "ENOENT":
@@ -142,8 +175,151 @@ function fileSystemErrorCode(error) {
142
175
 
143
176
  // src/parser.ts
144
177
  import { Buffer as Buffer2 } from "node:buffer";
145
- import { createHash } from "node:crypto";
178
+ import { createHash as createHash2 } from "node:crypto";
146
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
147
323
  var devNull = "/dev/null";
148
324
  var noNewlineMarker = "\";
149
325
  var movePrefix = "blockpatch move ";
@@ -321,7 +497,7 @@ function buildTargetOnlyBlockPatch(section, targetHunk, options) {
321
497
  };
322
498
  }
323
499
  function verifyPayloadHash(payload, payloadSha256) {
324
- const actualHash = createHash("sha256").update(payload).digest("hex");
500
+ const actualHash = createHash2("sha256").update(payload).digest("hex");
325
501
  if (actualHash !== payloadSha256) {
326
502
  fail("hash_mismatch", "payload-sha256 does not match moved payload", {
327
503
  phase: "payload",
@@ -546,13 +722,17 @@ function parseMetadata(input) {
546
722
  }
547
723
  function parseFileHeader(line, prefix, requiredPathPrefix) {
548
724
  const marker = `${prefix} `;
549
- if (line !== undefined && line.slice(marker.length).trim() === devNull && line.startsWith(marker)) {
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)) {
550
730
  return devNull;
551
731
  }
552
732
  if (line?.startsWith(`${marker}${requiredPathPrefix}`) !== true) {
553
733
  fail("parse_error", `Patch must include a ${marker}${requiredPathPrefix}<path> or ${marker}${devNull} header`);
554
734
  }
555
- const path = line.slice(marker.length).trim();
735
+ const path = rawPath?.trim() ?? "";
556
736
  if (!path || path === requiredPathPrefix) {
557
737
  fail("parse_error", `${prefix} file header must include a path`);
558
738
  }
@@ -589,121 +769,6 @@ function text(line) {
589
769
  return line?.body.toString("utf8");
590
770
  }
591
771
 
592
- // src/paths.ts
593
- import { lstatSync as lstatSync2 } from "node:fs";
594
- import { isAbsolute, join, relative, resolve, sep } from "node:path";
595
- function resolvePath(cwd, path, label) {
596
- if (path === "" || path.includes("\x00")) {
597
- fail("invalid_path", `Invalid ${label}: ${path}`, { path, phase: "path" });
598
- }
599
- if (isAbsolute(path)) {
600
- fail("path_outside_cwd", `${label} must be relative to the working directory: ${path}`, {
601
- path,
602
- phase: "path"
603
- });
604
- }
605
- const root = resolve(cwd);
606
- const resolved = resolve(root, path);
607
- const realRoot = realpathSyncChecked(root, "working directory", root);
608
- if (!isInside(root, resolved)) {
609
- fail("path_outside_cwd", `${label} escapes the working directory: ${path}`, { path, phase: "path" });
610
- }
611
- rejectSymlinkComponents(root, resolved, path, label);
612
- const info = lstatSyncChecked(resolved, label, path);
613
- assertRegularFile(info, path, label, "path");
614
- const realResolved = realpathSyncChecked(resolved, label, path);
615
- if (!isInside(realRoot, realResolved)) {
616
- fail("path_outside_cwd", `${label} resolves outside the working directory: ${path}`, { path, phase: "path" });
617
- }
618
- return resolved;
619
- }
620
- function resolvePathAllowMissing(cwd, path, label) {
621
- if (path === "" || path.includes("\x00")) {
622
- fail("invalid_path", `Invalid ${label}: ${path}`, { path, phase: "path" });
623
- }
624
- if (isAbsolute(path)) {
625
- fail("path_outside_cwd", `${label} must be relative to the working directory: ${path}`, {
626
- path,
627
- phase: "path"
628
- });
629
- }
630
- const root = resolve(cwd);
631
- const resolved = resolve(root, path);
632
- const realRoot = realpathSyncChecked(root, "working directory", root);
633
- if (!isInside(root, resolved)) {
634
- fail("path_outside_cwd", `${label} escapes the working directory: ${path}`, { path, phase: "path" });
635
- }
636
- const deepestExisting = rejectExistingSymlinkComponents(root, resolved, path, label);
637
- if (deepestExisting !== resolved) {
638
- return { path: resolved, exists: false };
639
- }
640
- const info = lstatSyncChecked(resolved, label, path);
641
- assertRegularFile(info, path, label, "path");
642
- const realResolved = realpathSyncChecked(resolved, label, path);
643
- if (!isInside(realRoot, realResolved)) {
644
- fail("path_outside_cwd", `${label} resolves outside the working directory: ${path}`, { path, phase: "path" });
645
- }
646
- return { path: resolved, exists: true };
647
- }
648
- async function sameFileIdentity(left, right) {
649
- if (left === right) {
650
- return true;
651
- }
652
- const [leftInfo, rightInfo] = await Promise.all([
653
- statChecked(left, "source path"),
654
- statChecked(right, "destination path")
655
- ]);
656
- return leftInfo.dev === rightInfo.dev && leftInfo.ino === rightInfo.ino;
657
- }
658
- function rejectSymlinkComponents(root, resolved, originalPath, label) {
659
- const relativePath = relative(root, resolved);
660
- if (relativePath === "") {
661
- return;
662
- }
663
- let current = root;
664
- for (const part of relativePath.split(sep)) {
665
- current = join(current, part);
666
- if (lstatSyncChecked(current, label, originalPath).isSymbolicLink()) {
667
- fail("symlink_path", `${label} must not contain symbolic links: ${originalPath}`, {
668
- path: originalPath,
669
- phase: "path"
670
- });
671
- }
672
- }
673
- }
674
- function rejectExistingSymlinkComponents(root, resolved, originalPath, label) {
675
- const relativePath = relative(root, resolved);
676
- if (relativePath === "") {
677
- return resolved;
678
- }
679
- let current = root;
680
- for (const part of relativePath.split(sep)) {
681
- const next = join(current, part);
682
- let info;
683
- try {
684
- info = lstatSync2(next);
685
- } catch (error) {
686
- const code = error.code;
687
- if (code === "ENOENT" || code === "ENOTDIR") {
688
- return current;
689
- }
690
- failFileSystem(error, originalPath, `Could not stat ${label}`, "path");
691
- }
692
- if (info.isSymbolicLink()) {
693
- fail("symlink_path", `${label} must not contain symbolic links: ${originalPath}`, {
694
- path: originalPath,
695
- phase: "path"
696
- });
697
- }
698
- current = next;
699
- }
700
- return current;
701
- }
702
- function isInside(root, path) {
703
- const fromRoot = relative(root, path);
704
- return fromRoot === "" || !fromRoot.startsWith("..") && !isAbsolute(fromRoot);
705
- }
706
-
707
772
  // src/engine.ts
708
773
  async function checkPatchFile(patchPath, options = {}) {
709
774
  return runPatchFile(patchPath, { ...options, dryRun: true });
@@ -713,7 +778,8 @@ async function checkPatchBytes(patchBytes, options = {}) {
713
778
  }
714
779
  function checkPatchBytesInMemory(patchBytes, files, options = {}) {
715
780
  const patch = parseBlockPatch(patchBytes, { stripComponents: options.stripComponents });
716
- return checkMovePatchInMemory(patch, memoryFileMap(files), options.reverse ?? false);
781
+ const effectivePatch = options.reverse === true ? reverseMovePatch(patch) : patch;
782
+ return planMovePatch(effectivePatch, memoryFileMap(files)).result;
717
783
  }
718
784
  async function applyPatchFile(patchPath, options = {}) {
719
785
  return runPatchFile(patchPath, options);
@@ -733,119 +799,179 @@ async function runPatchBytes(patchBytes, options) {
733
799
  }
734
800
  async function applyMovePatch(patch, cwd, dryRun, reverse) {
735
801
  const effectivePatch = reverse ? reverseMovePatch(patch) : patch;
736
- if (effectivePatch.src === null || effectivePatch.dst === null) {
737
- if (effectivePatch.src === null && effectivePatch.dst !== null && !effectivePatch.hasSourceHunk) {
738
- return applyPathCreationMove(effectivePatch, effectivePatch.dst, cwd, dryRun);
739
- }
740
- if (effectivePatch.dst === null && effectivePatch.src !== null && effectivePatch.hasSourceHunk) {
741
- return applyPathDeletionMove(effectivePatch, effectivePatch.src, cwd, dryRun);
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;
742
832
  }
743
833
  fail("parse_error", "Invalid /dev/null endpoint move shape");
744
834
  }
745
- if (!effectivePatch.hasSourceHunk) {
746
- return applyInFileInsertionMove(effectivePatch, effectivePatch.src, effectivePatch.dst, cwd, dryRun);
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;
747
839
  }
748
- if (effectivePatch.target === null) {
749
- return applyInFileDeletionMove(effectivePatch, effectivePatch.src, effectivePatch.dst, cwd, dryRun);
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;
750
844
  }
751
- const srcLabel = effectivePatch.src;
752
- const dstLabel = effectivePatch.dst;
753
- const srcPath = resolvePath(cwd, srcLabel, "source path");
754
- const dstPath = resolvePath(cwd, dstLabel, "destination path");
845
+ const srcPath = resolvePath(cwd, patch.src, "source path");
846
+ const dstPath = resolvePath(cwd, patch.dst, "destination path");
755
847
  const sameFile = await sameFileIdentity(srcPath, dstPath);
756
- const srcOriginal = await readFileChecked(srcPath, "source file");
757
- const dstOriginal = sameFile ? srcOriginal : await readFileChecked(dstPath, "destination file");
758
- const plan = selectMovePlan(srcOriginal, dstOriginal, effectivePatch, sameFile);
759
- if (plan.status === "already_applied") {
760
- return {
761
- changed: [],
762
- affected: unique([srcLabel, dstLabel]),
763
- written: false,
764
- noop: true,
765
- status: "already_applied",
766
- moves: [plan.details]
767
- };
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
+ }
768
882
  }
769
- const selection = plan.selection;
770
- const changed = await commitMove({
771
- srcPath,
772
- dstPath,
773
- sameFile,
774
- dryRun,
775
- srcOriginal,
776
- dstOriginal,
777
- selection,
778
- srcLabel,
779
- dstLabel
780
- });
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) {
781
908
  return {
782
- changed,
783
- affected: unique([srcLabel, dstLabel]),
784
- written: !dryRun && changed.length > 0,
785
- noop: changed.length === 0,
786
- status: changed.length === 0 ? "noop" : "applied",
787
- moves: [
788
- moveResultDetails({
789
- id: effectivePatch.id,
790
- src: srcLabel,
791
- dst: dstLabel,
792
- payloadSha256: effectivePatch.payloadSha256,
793
- selection
794
- })
795
- ]
909
+ ...result,
910
+ written: result.status === "applied" && !dryRun && result.changed.length > 0
796
911
  };
797
912
  }
798
- function checkMovePatchInMemory(patch, files, reverse) {
799
- const effectivePatch = reverse ? reverseMovePatch(patch) : patch;
913
+ function planMovePatch(effectivePatch, files) {
800
914
  if (effectivePatch.src === null || effectivePatch.dst === null) {
801
915
  if (effectivePatch.src === null && effectivePatch.dst !== null && !effectivePatch.hasSourceHunk) {
802
- return checkPathCreationMoveInMemory(effectivePatch, effectivePatch.dst, files);
916
+ return planPathCreationMove(effectivePatch, effectivePatch.dst, files);
803
917
  }
804
918
  if (effectivePatch.dst === null && effectivePatch.src !== null && effectivePatch.hasSourceHunk) {
805
- return checkPathDeletionMoveInMemory(effectivePatch, effectivePatch.src, files);
919
+ return planPathDeletionMove(effectivePatch, effectivePatch.src, files);
806
920
  }
807
921
  fail("parse_error", "Invalid /dev/null endpoint move shape");
808
922
  }
809
923
  if (!effectivePatch.hasSourceHunk) {
810
- return checkInFileInsertionMoveInMemory(effectivePatch, effectivePatch.src, effectivePatch.dst, files);
924
+ return planInFileInsertionMove(effectivePatch, effectivePatch.src, effectivePatch.dst, files);
811
925
  }
812
926
  if (effectivePatch.target === null) {
813
- return checkInFileDeletionMoveInMemory(effectivePatch, effectivePatch.src, effectivePatch.dst, files);
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");
814
934
  }
815
- const srcLabel = effectivePatch.src;
816
- const dstLabel = effectivePatch.dst;
935
+ const srcLabel = patch.src;
936
+ const dstLabel = patch.dst;
817
937
  const srcFile = readMemoryFile(files, srcLabel, "source file");
818
938
  const sameFile = sameMemoryFile(files, srcLabel, dstLabel);
819
939
  const dstFile = sameFile ? srcFile : readMemoryFile(files, dstLabel, "destination file");
820
- const plan = selectMovePlan(srcFile, dstFile, effectivePatch, sameFile);
940
+ const plan = selectMovePlan(srcFile, dstFile, patch, sameFile);
821
941
  if (plan.status === "already_applied") {
822
942
  return {
823
- changed: [],
824
- affected: unique([srcLabel, dstLabel]),
825
- written: false,
826
- noop: true,
827
- status: "already_applied",
828
- moves: [plan.details]
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: []
829
952
  };
830
953
  }
831
954
  const selection = plan.selection;
832
955
  const next = sameFile ? applyMove(srcFile, selection) : applyCrossFileMove(srcFile, dstFile, selection);
833
956
  const changed = changedMoveLabels(srcLabel, dstLabel, sameFile, srcFile, dstFile, next);
834
957
  return {
835
- changed,
836
- affected: unique([srcLabel, dstLabel]),
837
- written: false,
838
- noop: changed.length === 0,
839
- status: changed.length === 0 ? "noop" : "applied",
840
- moves: [
841
- moveResultDetails({
842
- id: effectivePatch.id,
843
- src: srcLabel,
844
- dst: dstLabel,
845
- payloadSha256: effectivePatch.payloadSha256,
846
- selection
847
- })
848
- ]
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)
849
975
  };
850
976
  }
851
977
  function reverseMovePatch(patch) {
@@ -864,64 +990,24 @@ function reverseMovePatch(patch) {
864
990
  target: reversedTarget
865
991
  };
866
992
  }
867
- async function applyInFileInsertionMove(patch, srcLabel, dstLabel, cwd, dryRun) {
868
- const target = requireTarget(patch);
869
- const dstPath = resolvePath(cwd, dstLabel, "destination path");
870
- const original = await readFileChecked(dstPath, "destination file");
871
- const alreadyApplied = findAlreadyAppliedTargetSelection(original, patch, dstLabel);
872
- if (alreadyApplied !== undefined) {
873
- return oneSidedResult({
874
- patch,
875
- src: srcLabel,
876
- dst: dstLabel,
877
- changed: [],
878
- dryRun,
879
- status: "already_applied",
880
- sourceRange: null,
881
- targetRange: alreadyApplied.range,
882
- insertIndex: alreadyApplied.insertIndex
883
- });
884
- }
885
- const selection = findTargetSelection(original, target.before, target.after, dstLabel, {
886
- phase: "target",
887
- anchor: "blockpatch-target"
888
- });
889
- const next = Buffer3.concat([
890
- original.subarray(0, selection.insertIndex),
891
- patch.sourcePayload,
892
- original.subarray(selection.insertIndex)
893
- ]);
894
- if (!dryRun) {
895
- await writeAtomically([{ path: dstPath, bytes: next }]);
896
- }
897
- return oneSidedResult({
898
- patch,
899
- src: srcLabel,
900
- dst: dstLabel,
901
- changed: next.equals(original) ? [] : unique([srcLabel, dstLabel]),
902
- dryRun,
903
- status: next.equals(original) ? "noop" : "applied",
904
- sourceRange: null,
905
- targetRange: selection.range,
906
- insertIndex: selection.insertIndex
907
- });
908
- }
909
- function checkInFileInsertionMoveInMemory(patch, srcLabel, dstLabel, files) {
993
+ function planInFileInsertionMove(patch, srcLabel, dstLabel, files) {
910
994
  const target = requireTarget(patch);
911
995
  const original = readMemoryFile(files, dstLabel, "destination file");
912
996
  const alreadyApplied = findAlreadyAppliedTargetSelection(original, patch, dstLabel);
913
997
  if (alreadyApplied !== undefined) {
914
- return oneSidedResult({
915
- patch,
916
- src: srcLabel,
917
- dst: dstLabel,
918
- changed: [],
919
- dryRun: true,
920
- status: "already_applied",
921
- sourceRange: null,
922
- targetRange: alreadyApplied.range,
923
- insertIndex: alreadyApplied.insertIndex
924
- });
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
+ };
925
1011
  }
926
1012
  const selection = findTargetSelection(original, target.before, target.after, dstLabel, {
927
1013
  phase: "target",
@@ -932,85 +1018,60 @@ function checkInFileInsertionMoveInMemory(patch, srcLabel, dstLabel, files) {
932
1018
  patch.sourcePayload,
933
1019
  original.subarray(selection.insertIndex)
934
1020
  ]);
935
- return oneSidedResult({
936
- patch,
937
- src: srcLabel,
938
- dst: dstLabel,
939
- changed: next.equals(original) ? [] : unique([srcLabel, dstLabel]),
940
- dryRun: true,
941
- status: next.equals(original) ? "noop" : "applied",
942
- sourceRange: null,
943
- targetRange: selection.range,
944
- insertIndex: selection.insertIndex
945
- });
946
- }
947
- async function applyInFileDeletionMove(patch, srcLabel, dstLabel, cwd, dryRun) {
948
- const srcPath = resolvePath(cwd, srcLabel, "source path");
949
- const original = await readFileChecked(srcPath, "source file");
950
- const source = findDeletionSourceRange(original, patch, srcLabel);
951
- if (source === undefined) {
952
- return oneSidedResult({
1021
+ const changed = next.equals(original) ? [] : unique([srcLabel, dstLabel]);
1022
+ return {
1023
+ result: oneSidedResult({
953
1024
  patch,
954
1025
  src: srcLabel,
955
1026
  dst: dstLabel,
956
- changed: [],
957
- dryRun,
958
- status: "already_applied",
1027
+ changed,
1028
+ status: changed.length === 0 ? "noop" : "applied",
959
1029
  sourceRange: null,
960
- targetRange: null,
961
- insertIndex: null
962
- });
963
- }
964
- const next = Buffer3.concat([original.subarray(0, source.start), original.subarray(source.end)]);
965
- if (!dryRun) {
966
- await writeAtomically([{ path: srcPath, bytes: next }]);
967
- }
968
- return oneSidedResult({
969
- patch,
970
- src: srcLabel,
971
- dst: dstLabel,
972
- changed: next.equals(original) ? [] : unique([srcLabel, dstLabel]),
973
- dryRun,
974
- status: next.equals(original) ? "noop" : "applied",
975
- sourceRange: source,
976
- targetRange: null,
977
- insertIndex: null
978
- });
1030
+ targetRange: selection.range,
1031
+ insertIndex: selection.insertIndex
1032
+ }),
1033
+ mutations: changed.length === 0 ? [] : [{ kind: "write", label: dstLabel, bytes: next }]
1034
+ };
979
1035
  }
980
- function checkInFileDeletionMoveInMemory(patch, srcLabel, dstLabel, files) {
1036
+ function planInFileDeletionMove(patch, srcLabel, dstLabel, files) {
981
1037
  const original = readMemoryFile(files, srcLabel, "source file");
982
1038
  const source = findDeletionSourceRange(original, patch, srcLabel);
983
1039
  if (source === undefined) {
984
- return oneSidedResult({
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({
985
1058
  patch,
986
1059
  src: srcLabel,
987
1060
  dst: dstLabel,
988
- changed: [],
989
- dryRun: true,
990
- status: "already_applied",
991
- sourceRange: null,
1061
+ changed,
1062
+ status: changed.length === 0 ? "noop" : "applied",
1063
+ sourceRange: source,
992
1064
  targetRange: null,
993
1065
  insertIndex: null
994
- });
995
- }
996
- const next = Buffer3.concat([original.subarray(0, source.start), original.subarray(source.end)]);
997
- return oneSidedResult({
998
- patch,
999
- src: srcLabel,
1000
- dst: dstLabel,
1001
- changed: next.equals(original) ? [] : unique([srcLabel, dstLabel]),
1002
- dryRun: true,
1003
- status: next.equals(original) ? "noop" : "applied",
1004
- sourceRange: source,
1005
- targetRange: null,
1006
- insertIndex: null
1007
- });
1066
+ }),
1067
+ mutations: changed.length === 0 ? [] : [{ kind: "write", label: srcLabel, bytes: next }]
1068
+ };
1008
1069
  }
1009
1070
  function oneSidedResult(args) {
1010
1071
  return {
1011
1072
  changed: args.changed,
1012
1073
  affected: unique([args.src, args.dst]),
1013
- written: args.status === "applied" && !args.dryRun && args.changed.length > 0,
1074
+ written: false,
1014
1075
  noop: args.status !== "applied" || args.changed.length === 0,
1015
1076
  status: args.status,
1016
1077
  moves: [
@@ -1027,28 +1088,15 @@ function oneSidedResult(args) {
1027
1088
  ]
1028
1089
  };
1029
1090
  }
1030
- function checkPathCreationMoveInMemory(patch, dstLabel, files) {
1091
+ function planPathCreationMove(patch, dstLabel, files) {
1031
1092
  const fullTarget = fullTargetBytes(patch);
1032
1093
  const original = files.get(dstLabel)?.bytes;
1033
1094
  if (original !== undefined) {
1034
1095
  if (original.equals(fullTarget)) {
1035
- return nullSourceResult(patch, dstLabel, true, "already_applied", { start: 0, end: original.length }, 0);
1036
- }
1037
- fail("destination_exists", `Destination path for file creation already exists with different bytes: ${dstLabel}`, {
1038
- path: dstLabel,
1039
- phase: "target",
1040
- anchor: "blockpatch-target"
1041
- });
1042
- }
1043
- return nullSourceResult(patch, dstLabel, true, "applied", { start: 0, end: 0 }, 0);
1044
- }
1045
- async function applyPathCreationMove(patch, dstLabel, cwd, dryRun) {
1046
- const resolved = resolvePathAllowMissing(cwd, dstLabel, "destination path");
1047
- const fullTarget = fullTargetBytes(patch);
1048
- if (resolved.exists) {
1049
- const original = await readFileChecked(resolved.path, "destination file");
1050
- if (original.equals(fullTarget)) {
1051
- return nullSourceResult(patch, dstLabel, dryRun, "already_applied", { start: 0, end: original.length }, 0);
1096
+ return {
1097
+ result: nullSourceResult(patch, dstLabel, "already_applied", { start: 0, end: original.length }, 0),
1098
+ mutations: []
1099
+ };
1052
1100
  }
1053
1101
  fail("destination_exists", `Destination path for file creation already exists with different bytes: ${dstLabel}`, {
1054
1102
  path: dstLabel,
@@ -1056,36 +1104,19 @@ async function applyPathCreationMove(patch, dstLabel, cwd, dryRun) {
1056
1104
  anchor: "blockpatch-target"
1057
1105
  });
1058
1106
  }
1059
- if (!dryRun) {
1060
- await writeAtomically([{ path: resolved.path, bytes: fullTarget, create: true }]);
1061
- }
1062
- return nullSourceResult(patch, dstLabel, dryRun, "applied", { start: 0, end: 0 }, 0);
1107
+ return {
1108
+ result: nullSourceResult(patch, dstLabel, "applied", { start: 0, end: 0 }, 0),
1109
+ mutations: [{ kind: "write", label: dstLabel, bytes: fullTarget, create: true }]
1110
+ };
1063
1111
  }
1064
- function checkPathDeletionMoveInMemory(patch, srcLabel, files) {
1112
+ function planPathDeletionMove(patch, srcLabel, files) {
1065
1113
  const original = files.get(srcLabel)?.bytes;
1066
1114
  if (original === undefined) {
1067
- return nullTargetResult(patch, srcLabel, true, "already_applied", null);
1068
- }
1069
- const fullSource = Buffer3.concat([patch.sourceBefore, patch.sourcePayload, patch.sourceAfter]);
1070
- if (!original.equals(fullSource)) {
1071
- fail("source_not_found", `Whole-file source payload was not found in ${srcLabel}`, {
1072
- path: srcLabel,
1073
- phase: "source",
1074
- anchor: "blockpatch-source",
1075
- matches: 0
1076
- });
1077
- }
1078
- return nullTargetResult(patch, srcLabel, true, "applied", {
1079
- start: patch.sourceBefore.length,
1080
- end: patch.sourceBefore.length + patch.sourcePayload.length
1081
- });
1082
- }
1083
- async function applyPathDeletionMove(patch, srcLabel, cwd, dryRun) {
1084
- const resolved = resolvePathAllowMissing(cwd, srcLabel, "source path");
1085
- if (!resolved.exists) {
1086
- return nullTargetResult(patch, srcLabel, dryRun, "already_applied", null);
1115
+ return {
1116
+ result: nullTargetResult(patch, srcLabel, "already_applied", null),
1117
+ mutations: []
1118
+ };
1087
1119
  }
1088
- const original = await readFileChecked(resolved.path, "source file");
1089
1120
  const fullSource = Buffer3.concat([patch.sourceBefore, patch.sourcePayload, patch.sourceAfter]);
1090
1121
  if (!original.equals(fullSource)) {
1091
1122
  fail("source_not_found", `Whole-file source payload was not found in ${srcLabel}`, {
@@ -1095,17 +1126,13 @@ async function applyPathDeletionMove(patch, srcLabel, cwd, dryRun) {
1095
1126
  matches: 0
1096
1127
  });
1097
1128
  }
1098
- if (!dryRun) {
1099
- try {
1100
- await unlink(resolved.path);
1101
- } catch (error) {
1102
- failFileSystem(error, srcLabel, "Could not remove file");
1103
- }
1104
- }
1105
- return nullTargetResult(patch, srcLabel, dryRun, "applied", {
1106
- start: patch.sourceBefore.length,
1107
- end: patch.sourceBefore.length + patch.sourcePayload.length
1108
- });
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
+ };
1109
1136
  }
1110
1137
  function fullTargetBytes(patch) {
1111
1138
  const target = requireTarget(patch);
@@ -1119,17 +1146,18 @@ function requireTarget(patch) {
1119
1146
  }
1120
1147
  function findDeletionSourceRange(file, patch, srcLabel) {
1121
1148
  const fullSource = Buffer3.concat([patch.sourceBefore, patch.sourcePayload, patch.sourceAfter]);
1122
- const fullMatches = indexesOf(file, fullSource);
1123
- if (fullMatches.length === 1) {
1149
+ const fullMatchResult = indexesOfLimited(file, fullSource);
1150
+ const fullMatches = fullMatchResult.matches;
1151
+ if (fullMatches.length === 1 && !fullMatchResult.truncated) {
1124
1152
  const start = fullMatches[0] + patch.sourceBefore.length;
1125
1153
  return { start, end: start + patch.sourcePayload.length };
1126
1154
  }
1127
- if (fullMatches.length > 1) {
1128
- fail("source_ambiguous", `Source block is ambiguous in ${srcLabel}; matched ${fullMatches.length} locations`, {
1155
+ if (fullMatches.length > 1 || fullMatchResult.truncated) {
1156
+ fail("source_ambiguous", `Source block is ambiguous in ${srcLabel}; ${matchedLocations(fullMatches.length, fullMatchResult.truncated)}`, {
1129
1157
  path: srcLabel,
1130
1158
  phase: "source",
1131
1159
  anchor: "blockpatch-source",
1132
- matches: fullMatches.length,
1160
+ ...matchCountDetails(fullMatches.length, fullMatchResult.truncated),
1133
1161
  ranges: boundedMatchRanges(fullMatches, fullSource.length),
1134
1162
  line_ranges: boundedMatchLineRanges(file, fullMatches, fullSource.length)
1135
1163
  });
@@ -1142,36 +1170,37 @@ function deletionAlreadyAppliedOrFail(file, patch, srcLabel) {
1142
1170
  return;
1143
1171
  }
1144
1172
  const adjacent = Buffer3.concat([patch.sourceBefore, patch.sourceAfter]);
1145
- const adjacentMatches = indexesOf(file, adjacent);
1146
- if (adjacentMatches.length === 1) {
1173
+ const adjacentMatchResult = indexesOfLimitedWhere(file, adjacent, (start) => isDeletionAlreadyAppliedMatch(file, patch, start));
1174
+ const adjacentMatches = adjacentMatchResult.matches;
1175
+ if (adjacentMatches.length === 1 && !adjacentMatchResult.truncated) {
1147
1176
  return;
1148
1177
  }
1149
- if (adjacentMatches.length > 1) {
1178
+ if (adjacentMatches.length > 1 || adjacentMatchResult.truncated) {
1150
1179
  fail("source_ambiguous", `Already-deleted source anchors are ambiguous in ${srcLabel}`, {
1151
1180
  path: srcLabel,
1152
1181
  phase: "source",
1153
1182
  anchor: "blockpatch-source",
1154
- matches: adjacentMatches.length,
1183
+ ...matchCountDetails(adjacentMatches.length, adjacentMatchResult.truncated),
1155
1184
  ranges: boundedMatchRanges(adjacentMatches, adjacent.length),
1156
1185
  line_ranges: boundedMatchLineRanges(file, adjacentMatches, adjacent.length)
1157
1186
  });
1158
1187
  }
1159
1188
  const envelopes = findSourceEnvelopes(file, patch);
1160
- if (envelopes.length === 1) {
1189
+ if (envelopes.ranges.length === 1 && !envelopes.truncated) {
1161
1190
  fail("payload_mismatch", `Source payload does not match located source anchors in ${srcLabel}`, {
1162
1191
  path: srcLabel,
1163
1192
  phase: "payload",
1164
1193
  anchor: "blockpatch-source"
1165
1194
  });
1166
1195
  }
1167
- if (envelopes.length > 1) {
1168
- fail("source_ambiguous", `Source anchors are ambiguous in ${srcLabel}; matched ${envelopes.length} locations`, {
1196
+ if (envelopes.ranges.length > 1 || envelopes.truncated) {
1197
+ fail("source_ambiguous", `Source anchors are ambiguous in ${srcLabel}; ${matchedLocations(envelopes.ranges.length, envelopes.truncated)}`, {
1169
1198
  path: srcLabel,
1170
1199
  phase: "source",
1171
1200
  anchor: "blockpatch-source",
1172
- matches: envelopes.length,
1173
- ranges: boundedRanges(envelopes),
1174
- line_ranges: boundedLineRanges(file, envelopes)
1201
+ ...matchCountDetails(envelopes.ranges.length, envelopes.truncated),
1202
+ ranges: boundedRanges(envelopes.ranges),
1203
+ line_ranges: boundedLineRanges(file, envelopes.ranges)
1175
1204
  });
1176
1205
  }
1177
1206
  fail("source_not_found", `Source anchors were not found in ${srcLabel}`, {
@@ -1181,12 +1210,21 @@ function deletionAlreadyAppliedOrFail(file, patch, srcLabel) {
1181
1210
  matches: 0
1182
1211
  });
1183
1212
  }
1184
- function nullSourceResult(patch, dstLabel, dryRun, status, targetRange, insertIndex) {
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) {
1185
1223
  const applied = status === "applied";
1186
1224
  return {
1187
1225
  changed: applied ? [dstLabel] : [],
1188
1226
  affected: [dstLabel],
1189
- written: applied && !dryRun,
1227
+ written: false,
1190
1228
  noop: !applied,
1191
1229
  status,
1192
1230
  moves: [
@@ -1203,12 +1241,12 @@ function nullSourceResult(patch, dstLabel, dryRun, status, targetRange, insertIn
1203
1241
  ]
1204
1242
  };
1205
1243
  }
1206
- function nullTargetResult(patch, srcLabel, dryRun, status, sourceRange) {
1244
+ function nullTargetResult(patch, srcLabel, status, sourceRange) {
1207
1245
  const applied = status === "applied";
1208
1246
  return {
1209
1247
  changed: applied ? [srcLabel] : [],
1210
1248
  affected: [srcLabel],
1211
- written: applied && !dryRun,
1249
+ written: false,
1212
1250
  noop: !applied,
1213
1251
  status,
1214
1252
  moves: [
@@ -1252,6 +1290,12 @@ function selectMovePlan(srcFile, dstFile, patch, sameFile) {
1252
1290
  })
1253
1291
  };
1254
1292
  }
1293
+ if (!sameFile) {
1294
+ const alreadyAppliedTarget = findAlreadyAppliedTargetSelection(dstFile, patch, dstLabel);
1295
+ if (alreadyAppliedTarget !== undefined) {
1296
+ failPartialAppliedDuplicate(srcLabel, dstLabel, patch, source, alreadyAppliedTarget);
1297
+ }
1298
+ }
1255
1299
  const target = findTargetSelection(dstFile, targetAnchor.before, targetAnchor.after, dstLabel, {
1256
1300
  phase: "target",
1257
1301
  anchor: "blockpatch-target"
@@ -1261,6 +1305,17 @@ function selectMovePlan(srcFile, dstFile, patch, sameFile) {
1261
1305
  selection: buildMoveSelection(srcFile, source, target, sameFile, dstLabel)
1262
1306
  };
1263
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
+ }
1264
1319
  function buildMoveSelection(srcFile, source, target, sameFile, dstLabel) {
1265
1320
  if (sameFile && rangesOverlap(source, target.range)) {
1266
1321
  fail("target_overlaps_source", `Target anchor for ${dstLabel} overlaps the source block`, {
@@ -1283,10 +1338,20 @@ async function commitMove(args) {
1283
1338
  if (!args.dryRun) {
1284
1339
  const writes = [];
1285
1340
  if (dstChanged || sameFileAlias && srcChanged) {
1286
- writes.push({ path: args.dstPath, bytes: next.dst });
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
+ });
1287
1347
  }
1288
1348
  if (srcChanged) {
1289
- writes.push({ path: args.srcPath, bytes: next.src });
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
+ });
1290
1355
  }
1291
1356
  await writeAtomically(writes);
1292
1357
  }
@@ -1328,17 +1393,18 @@ function applyCrossFileMove(srcFile, dstFile, selection) {
1328
1393
  function findSourceRange(srcFile, dstFile, patch) {
1329
1394
  const srcLabel = patch.src ?? devNull;
1330
1395
  const fullSource = Buffer3.concat([patch.sourceBefore, patch.sourcePayload, patch.sourceAfter]);
1331
- const fullMatches = indexesOf(srcFile, fullSource);
1332
- if (fullMatches.length === 1) {
1396
+ const fullMatchResult = indexesOfLimited(srcFile, fullSource);
1397
+ const fullMatches = fullMatchResult.matches;
1398
+ if (fullMatches.length === 1 && !fullMatchResult.truncated) {
1333
1399
  const start = fullMatches[0] + patch.sourceBefore.length;
1334
1400
  return { start, end: start + patch.sourcePayload.length };
1335
1401
  }
1336
- if (fullMatches.length > 1) {
1337
- fail("source_ambiguous", `Source block is ambiguous in ${srcLabel}; matched ${fullMatches.length} locations`, {
1402
+ if (fullMatches.length > 1 || fullMatchResult.truncated) {
1403
+ fail("source_ambiguous", `Source block is ambiguous in ${srcLabel}; ${matchedLocations(fullMatches.length, fullMatchResult.truncated)}`, {
1338
1404
  path: srcLabel,
1339
1405
  phase: "source",
1340
1406
  anchor: "blockpatch-source",
1341
- matches: fullMatches.length,
1407
+ ...matchCountDetails(fullMatches.length, fullMatchResult.truncated),
1342
1408
  ranges: boundedMatchRanges(fullMatches, fullSource.length),
1343
1409
  line_ranges: boundedMatchLineRanges(srcFile, fullMatches, fullSource.length)
1344
1410
  });
@@ -1348,21 +1414,21 @@ function findSourceRange(srcFile, dstFile, patch) {
1348
1414
  return;
1349
1415
  }
1350
1416
  const envelopes = findSourceEnvelopes(srcFile, patch);
1351
- if (envelopes.length === 1) {
1417
+ if (envelopes.ranges.length === 1 && !envelopes.truncated) {
1352
1418
  fail("payload_mismatch", `Source payload does not match located source anchors in ${srcLabel}`, {
1353
1419
  path: srcLabel,
1354
1420
  phase: "payload",
1355
1421
  anchor: "blockpatch-source"
1356
1422
  });
1357
1423
  }
1358
- if (envelopes.length > 1) {
1359
- fail("source_ambiguous", `Source anchors are ambiguous in ${srcLabel}; matched ${envelopes.length} locations`, {
1424
+ if (envelopes.ranges.length > 1 || envelopes.truncated) {
1425
+ fail("source_ambiguous", `Source anchors are ambiguous in ${srcLabel}; ${matchedLocations(envelopes.ranges.length, envelopes.truncated)}`, {
1360
1426
  path: srcLabel,
1361
1427
  phase: "source",
1362
1428
  anchor: "blockpatch-source",
1363
- matches: envelopes.length,
1364
- ranges: boundedRanges(envelopes),
1365
- line_ranges: boundedLineRanges(srcFile, envelopes)
1429
+ ...matchCountDetails(envelopes.ranges.length, envelopes.truncated),
1430
+ ranges: boundedRanges(envelopes.ranges),
1431
+ line_ranges: boundedLineRanges(srcFile, envelopes.ranges)
1366
1432
  });
1367
1433
  }
1368
1434
  fail("source_not_found", `Source anchors were not found in ${srcLabel}`, {
@@ -1375,16 +1441,17 @@ function findSourceRange(srcFile, dstFile, patch) {
1375
1441
  function findAlreadyAppliedTargetSelection(file, patch, dstLabel = patch.dst ?? devNull) {
1376
1442
  const target = requireTarget(patch);
1377
1443
  const alreadyApplied = Buffer3.concat([target.before, patch.sourcePayload, target.after]);
1378
- const matches = indexesOf(file, alreadyApplied);
1444
+ const matchResult = indexesOfLimited(file, alreadyApplied);
1445
+ const matches = matchResult.matches;
1379
1446
  if (matches.length === 0) {
1380
1447
  return;
1381
1448
  }
1382
- if (matches.length > 1) {
1383
- fail("target_ambiguous", `Already-applied target is ambiguous in ${dstLabel}; matched ${matches.length} locations`, {
1449
+ if (matches.length > 1 || matchResult.truncated) {
1450
+ fail("target_ambiguous", `Already-applied target is ambiguous in ${dstLabel}; ${matchedLocations(matches.length, matchResult.truncated)}`, {
1384
1451
  path: dstLabel,
1385
1452
  phase: "target",
1386
1453
  anchor: "blockpatch-target",
1387
- matches: matches.length,
1454
+ ...matchCountDetails(matches.length, matchResult.truncated),
1388
1455
  ranges: boundedMatchRanges(matches, alreadyApplied.length),
1389
1456
  line_ranges: boundedMatchLineRanges(file, matches, alreadyApplied.length)
1390
1457
  });
@@ -1395,22 +1462,30 @@ function findAlreadyAppliedTargetSelection(file, patch, dstLabel = patch.dst ??
1395
1462
  insertIndex: start + target.before.length
1396
1463
  };
1397
1464
  }
1398
- function findSourceEnvelopes(file, patch) {
1399
- const beforeMatches = indexesOf(file, patch.sourceBefore);
1400
- const afterMatches = indexesOf(file, patch.sourceAfter);
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);
1401
1470
  const ranges = [];
1402
- for (const beforeStart of beforeMatches) {
1471
+ let beforeStart = file.indexOf(patch.sourceBefore);
1472
+ while (beforeStart !== -1) {
1403
1473
  const payloadStart = beforeStart + patch.sourceBefore.length;
1404
- const afterStart = afterMatches.find((candidate) => candidate >= payloadStart);
1405
- if (afterStart !== undefined) {
1474
+ const afterStart = file.indexOf(patch.sourceAfter, payloadStart);
1475
+ if (afterStart !== -1) {
1476
+ if (ranges.length >= maxMatches) {
1477
+ return { ranges, truncated: true };
1478
+ }
1406
1479
  ranges.push({ start: payloadStart, end: afterStart });
1407
1480
  }
1481
+ beforeStart = file.indexOf(patch.sourceBefore, beforeStart + 1);
1408
1482
  }
1409
- return ranges;
1483
+ return { ranges, truncated: false };
1410
1484
  }
1411
1485
  function findTargetSelection(file, before, after, dstLabel, details = {}) {
1412
1486
  const anchor = Buffer3.concat([before, after]);
1413
- const matches = indexesOf(file, anchor);
1487
+ const matchResult = indexesOfLimited(file, anchor);
1488
+ const matches = matchResult.matches;
1414
1489
  if (matches.length === 0) {
1415
1490
  fail("target_not_found", `Target anchor was not found in ${dstLabel}`, {
1416
1491
  path: dstLabel,
@@ -1418,11 +1493,11 @@ function findTargetSelection(file, before, after, dstLabel, details = {}) {
1418
1493
  matches: 0
1419
1494
  });
1420
1495
  }
1421
- if (matches.length > 1) {
1422
- fail("target_ambiguous", `Target anchor is ambiguous in ${dstLabel}; matched ${matches.length} locations`, {
1496
+ if (matches.length > 1 || matchResult.truncated) {
1497
+ fail("target_ambiguous", `Target anchor is ambiguous in ${dstLabel}; ${matchedLocations(matches.length, matchResult.truncated)}`, {
1423
1498
  path: dstLabel,
1424
1499
  ...details,
1425
- matches: matches.length,
1500
+ ...matchCountDetails(matches.length, matchResult.truncated),
1426
1501
  ranges: boundedMatchRanges(matches, anchor.length),
1427
1502
  line_ranges: boundedMatchLineRanges(file, matches, anchor.length)
1428
1503
  });
@@ -1433,17 +1508,45 @@ function findTargetSelection(file, before, after, dstLabel, details = {}) {
1433
1508
  insertIndex: start + before.length
1434
1509
  };
1435
1510
  }
1436
- function indexesOf(haystack, needle) {
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) {
1437
1528
  if (needle.length === 0) {
1438
- return [];
1529
+ return { matches: [], truncated: false };
1439
1530
  }
1440
- const indexes = [];
1531
+ const maxMatches = normalizedLimit(limit);
1532
+ const matches = [];
1441
1533
  let index = haystack.indexOf(needle);
1442
1534
  while (index !== -1) {
1443
- indexes.push(index);
1535
+ if (predicate(index)) {
1536
+ if (matches.length >= maxMatches) {
1537
+ return { matches, truncated: true };
1538
+ }
1539
+ matches.push(index);
1540
+ }
1444
1541
  index = haystack.indexOf(needle, index + 1);
1445
1542
  }
1446
- return indexes;
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));
1447
1550
  }
1448
1551
  function rangesOverlap(left, right) {
1449
1552
  return left.start < right.end && right.start < left.end;
@@ -1491,6 +1594,19 @@ function changedMoveLabels(srcLabel, dstLabel, sameFile, srcOriginal, dstOrigina
1491
1594
  }
1492
1595
  return unique(changed);
1493
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
+ }
1494
1610
  function memoryFileMap(files) {
1495
1611
  const mapped = new Map;
1496
1612
  for (const file of files) {
@@ -1519,36 +1635,123 @@ function sameMemoryFile(files, left, right) {
1519
1635
  const rightFile = files.get(right);
1520
1636
  return leftFile !== undefined && rightFile !== undefined && leftFile.identity === rightFile.identity;
1521
1637
  }
1522
- async function writeAtomic(path, bytes) {
1523
- await writeAtomically([{ path, bytes }]);
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
+ ]);
1524
1648
  }
1525
- async function writeAtomically(writes) {
1649
+ async function writeAtomically(writes, deletes = []) {
1526
1650
  const staged = [];
1527
1651
  try {
1528
1652
  for (const write of writes) {
1529
- staged.push(await stageAtomicWrite(write.path, write.bytes, write.create === true));
1653
+ staged.push(await stageAtomicWrite(write));
1530
1654
  }
1655
+ const decisions = [];
1531
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
+ }
1532
1667
  try {
1533
1668
  await rename(write.temp, write.path);
1534
1669
  } catch (error) {
1535
1670
  failFileSystem(error, write.path, "Could not replace file");
1536
1671
  }
1537
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");
1538
1726
  } catch (error) {
1539
- await Promise.all(staged.map((write) => unlink(write.temp).catch(() => {
1727
+ if (error instanceof BlockPatchError && error.code === "file_not_found") {
1540
1728
  return;
1541
- })));
1729
+ }
1730
+ if (error instanceof BlockPatchError && error.code === "not_regular_file") {
1731
+ failConcurrentModification(label);
1732
+ }
1542
1733
  throw error;
1543
1734
  }
1544
1735
  }
1545
- async function stageAtomicWrite(path, bytes, create = false) {
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) {
1546
1749
  let mode;
1547
1750
  let createMissing = false;
1548
- if (create) {
1549
- const info = await statOptional(path);
1751
+ if (write.create === true) {
1752
+ const info = await statOptional(write.path);
1550
1753
  if (info !== undefined) {
1551
- assertRegularFile(info, path, "output file");
1754
+ assertExpectedRegularFile(info, write.path, "output file", write.expected);
1552
1755
  mode = info.mode;
1553
1756
  }
1554
1757
  if (mode === undefined) {
@@ -1556,28 +1759,105 @@ async function stageAtomicWrite(path, bytes, create = false) {
1556
1759
  mode = 420;
1557
1760
  }
1558
1761
  } else {
1559
- const info = await statChecked(path, "output file");
1560
- assertRegularFile(info, path, "output file");
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);
1561
1770
  mode = info.mode;
1562
1771
  }
1563
- const dir = dirname(path);
1564
- const base = basename(path);
1772
+ const dir = dirname(write.path);
1773
+ const base = basename(write.path);
1565
1774
  const temp = join2(dir, `.${base}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`);
1775
+ let createdDirectory;
1566
1776
  try {
1567
1777
  if (createMissing) {
1568
- await mkdir(dir, { recursive: true });
1778
+ const firstCreated = await mkdir(dir, { recursive: true });
1779
+ if (firstCreated !== undefined) {
1780
+ createdDirectory = { first: firstCreated, target: dir };
1781
+ }
1569
1782
  }
1570
- await writeFile(temp, bytes, { flag: "wx" });
1783
+ await assertSafeOutputParentDirectory(dir, write.path);
1784
+ await writeFile(temp, write.bytes, { flag: "wx" });
1571
1785
  if (mode !== undefined) {
1572
1786
  await chmod(temp, mode);
1573
1787
  }
1574
1788
  } catch (error) {
1575
- await unlink(temp).catch(() => {
1576
- return;
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"
1577
1811
  });
1578
- failFileSystem(error, path, "Could not stage file replacement");
1579
1812
  }
1580
- return { path, temp };
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);
1581
1861
  }
1582
1862
  async function statOptional(path) {
1583
1863
  try {
@@ -1592,7 +1872,7 @@ async function statOptional(path) {
1592
1872
  }
1593
1873
 
1594
1874
  // src/move.ts
1595
- import { createHash as createHash2 } from "node:crypto";
1875
+ import { createHash as createHash3 } from "node:crypto";
1596
1876
  import { posix as posix2 } from "node:path";
1597
1877
  import { TextDecoder } from "node:util";
1598
1878
  var moveArgTypes = {
@@ -1604,14 +1884,22 @@ var moveArgTypes = {
1604
1884
  target_before: "string",
1605
1885
  target_after: "string",
1606
1886
  expected_payload_sha256: "string",
1887
+ mode: "string",
1607
1888
  dry_run: "boolean"
1608
1889
  };
1890
+ var moveModes = new Set(["create_file", "remove_file"]);
1609
1891
  var utf8Decoder = new TextDecoder("utf-8", { fatal: true });
1610
1892
  async function moveBlock(args, options = {}) {
1611
1893
  const validated = validateMoveArgs(args);
1612
1894
  const normalized = normalizeArgs(validated);
1613
1895
  const cwd = options.cwd ?? process.cwd();
1614
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
+ }
1615
1903
  if (normalized.kind === "insertion") {
1616
1904
  return insertPayload(normalized, validated, cwd, dryRun, options);
1617
1905
  }
@@ -1621,15 +1909,17 @@ async function moveBlock(args, options = {}) {
1621
1909
  const srcPath = resolvePath(cwd, normalized.src, "source path");
1622
1910
  const dstPath = resolvePath(cwd, normalized.dst, "destination path");
1623
1911
  const sameFile = await sameFileIdentity(srcPath, dstPath);
1624
- const srcOriginal = await readFileChecked(srcPath, "source file");
1625
- const dstOriginal = sameFile ? srcOriginal : await readFileChecked(dstPath, "destination file");
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;
1626
1916
  const source = findSource(srcOriginal, normalized);
1627
1917
  const target = findTargetSelection(dstOriginal, normalized.targetBefore, normalized.targetAfter, normalized.dst, {
1628
1918
  phase: "target",
1629
1919
  anchor: targetAnchorName(normalized)
1630
1920
  });
1631
1921
  const selection = buildMoveSelection(srcOriginal, source, target, sameFile, normalized.dst);
1632
- const payloadSha256 = createHash2("sha256").update(selection.payload).digest("hex");
1922
+ const payloadSha256 = createHash3("sha256").update(selection.payload).digest("hex");
1633
1923
  verifyExpectedPayloadHash(validated, payloadSha256);
1634
1924
  const samePatchLabel = samePatchPath2(normalized.src, normalized.dst);
1635
1925
  const patch = options.diff ? renderMovePatch(normalized, srcOriginal, dstOriginal, selection, sameFile && samePatchLabel, payloadSha256) : undefined;
@@ -1642,6 +1932,8 @@ async function moveBlock(args, options = {}) {
1642
1932
  dryRun: writeSuppressed,
1643
1933
  srcOriginal,
1644
1934
  dstOriginal,
1935
+ srcSnapshot,
1936
+ dstSnapshot,
1645
1937
  selection,
1646
1938
  srcLabel: normalized.src,
1647
1939
  dstLabel: normalized.dst
@@ -1683,12 +1975,27 @@ function validateMoveArgs(value) {
1683
1975
  field: "expected_payload_sha256"
1684
1976
  });
1685
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
+ }
1686
1981
  return args;
1687
1982
  }
1688
1983
  function normalizeArgs(args) {
1689
1984
  if (!args.src) {
1690
1985
  fail("invalid_move_args", "move requires src", { field: "src" });
1691
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
+ }
1692
1999
  const dst = args.dst ?? args.src;
1693
2000
  if (args.src === devNull && dst === devNull) {
1694
2001
  fail("invalid_move_args", "move cannot use /dev/null for both src and dst", { field: "dst" });
@@ -1749,6 +2056,60 @@ function normalizeArgs(args) {
1749
2056
  targetAfter: target.after
1750
2057
  };
1751
2058
  }
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
+ };
2080
+ }
2081
+ function normalizeFileRemovalArgs(args) {
2082
+ if (args.src === devNull) {
2083
+ fail("invalid_move_args", "remove_file mode requires a real src path", { field: "src" });
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");
2093
+ return {
2094
+ kind: "remove_file",
2095
+ src: args.src,
2096
+ dst: devNull
2097
+ };
2098
+ }
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
+ });
2104
+ }
2105
+ }
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
+ });
2111
+ }
2112
+ }
1752
2113
  function normalizeTargetArgs(args) {
1753
2114
  const hasTargetBefore = args.target_before !== undefined;
1754
2115
  const hasTargetAfter = args.target_after !== undefined;
@@ -1768,10 +2129,33 @@ function requiredBuffer(value, field) {
1768
2129
  }
1769
2130
  return Buffer.from(value, "utf8");
1770
2131
  }
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);
2137
+ }
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);
2145
+ }
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 });
2149
+ return {
2150
+ ...result,
2151
+ patch: options.diff === true ? patch : undefined
2152
+ };
2153
+ }
1771
2154
  async function insertPayload(args, validated, cwd, dryRun, options) {
1772
2155
  const dstPath = resolvePath(cwd, args.dst, "destination path");
1773
- const original = await readFileChecked(dstPath, "destination file");
1774
- const payloadSha256 = createHash2("sha256").update(args.payload).digest("hex");
2156
+ const snapshot = await readFileSnapshot(dstPath, "destination file");
2157
+ const original = snapshot.bytes;
2158
+ const payloadSha256 = createHash3("sha256").update(args.payload).digest("hex");
1775
2159
  verifyExpectedPayloadHash(validated, payloadSha256);
1776
2160
  const alreadyApplied = findAlreadyAppliedTarget(original, args, args.dst);
1777
2161
  if (alreadyApplied !== undefined) {
@@ -1812,7 +2196,9 @@ async function insertPayload(args, validated, cwd, dryRun, options) {
1812
2196
  ]);
1813
2197
  const changed = next.equals(original) ? [] : [args.dst];
1814
2198
  if (!writeSuppressed && changed.length > 0) {
1815
- await writeAtomic(dstPath, next);
2199
+ await writeAtomic(dstPath, next, {
2200
+ expected: { kind: "file", label: args.dst, snapshot }
2201
+ });
1816
2202
  }
1817
2203
  return {
1818
2204
  changed,
@@ -1846,17 +2232,18 @@ function verifyExpectedPayloadHash(args, payloadSha256) {
1846
2232
  }
1847
2233
  function findAlreadyAppliedTarget(file, args, dstLabel) {
1848
2234
  const alreadyApplied = Buffer.concat([args.targetBefore, args.payload, args.targetAfter]);
1849
- const matches = indexesOf(file, alreadyApplied);
2235
+ const matchResult = indexesOfLimited(file, alreadyApplied);
2236
+ const matches = matchResult.matches;
1850
2237
  if (matches.length === 0) {
1851
2238
  return;
1852
2239
  }
1853
- if (matches.length > 1) {
2240
+ if (matches.length > 1 || matchResult.truncated) {
1854
2241
  const ranges = boundedRanges(matches.map((start2) => ({ start: start2, end: start2 + alreadyApplied.length })));
1855
- fail("target_ambiguous", `Already-applied target is ambiguous in ${dstLabel}; matched ${matches.length} locations`, {
2242
+ fail("target_ambiguous", `Already-applied target is ambiguous in ${dstLabel}; ${matchedLocations(matches.length, matchResult.truncated)}`, {
1856
2243
  path: dstLabel,
1857
2244
  phase: "target",
1858
2245
  anchor: "target_before+payload+target_after",
1859
- matches: matches.length,
2246
+ ...matchCountDetails(matches.length, matchResult.truncated),
1860
2247
  ranges,
1861
2248
  line_ranges: boundedLineRanges(file, ranges)
1862
2249
  });
@@ -1869,10 +2256,11 @@ function findAlreadyAppliedTarget(file, args, dstLabel) {
1869
2256
  }
1870
2257
  async function deletePayload(args, validated, cwd, dryRun, options) {
1871
2258
  const srcPath = resolvePath(cwd, args.src, "source path");
1872
- const original = await readFileChecked(srcPath, "source file");
2259
+ const snapshot = await readFileSnapshot(srcPath, "source file");
2260
+ const original = snapshot.bytes;
1873
2261
  const source = findSource(original, args);
1874
2262
  const payload = Buffer.from(original.subarray(source.start, source.end));
1875
- const payloadSha256 = createHash2("sha256").update(payload).digest("hex");
2263
+ const payloadSha256 = createHash3("sha256").update(payload).digest("hex");
1876
2264
  verifyExpectedPayloadHash(validated, payloadSha256);
1877
2265
  const renderedPatch = options.diff ? renderDeletionPatch(args, original, source, payload, payloadSha256) : undefined;
1878
2266
  await selfCheckRenderedPatch(renderedPatch, [{ path: args.src, bytes: original }]);
@@ -1880,7 +2268,9 @@ async function deletePayload(args, validated, cwd, dryRun, options) {
1880
2268
  const next = Buffer.concat([original.subarray(0, source.start), original.subarray(source.end)]);
1881
2269
  const changed = next.equals(original) ? [] : [args.src];
1882
2270
  if (!writeSuppressed && changed.length > 0) {
1883
- await writeAtomic(srcPath, next);
2271
+ await writeAtomic(srcPath, next, {
2272
+ expected: { kind: "file", label: args.src, snapshot }
2273
+ });
1884
2274
  }
1885
2275
  return {
1886
2276
  changed,
@@ -1904,15 +2294,8 @@ async function deletePayload(args, validated, cwd, dryRun, options) {
1904
2294
  };
1905
2295
  }
1906
2296
  function findSource(file, args) {
1907
- const startMatches = indexesOf(file, args.srcStart);
1908
- const ranges = [];
1909
- for (const start of startMatches) {
1910
- const searchFrom = start + args.srcStart.length;
1911
- const endStart = file.indexOf(args.srcEnd, searchFrom);
1912
- if (endStart !== -1) {
1913
- ranges.push({ start, end: endStart + args.srcEnd.length });
1914
- }
1915
- }
2297
+ const result = findDelimitedRanges(file, args.srcStart, args.srcEnd);
2298
+ const ranges = result.ranges;
1916
2299
  if (ranges.length === 0) {
1917
2300
  fail("source_not_found", `Source delimiters were not found in ${args.src}`, {
1918
2301
  path: args.src,
@@ -1921,18 +2304,44 @@ function findSource(file, args) {
1921
2304
  matches: 0
1922
2305
  });
1923
2306
  }
1924
- if (ranges.length > 1) {
1925
- fail("source_ambiguous", `Source delimiters are ambiguous in ${args.src}; matched ${ranges.length} locations`, {
2307
+ if (ranges.length > 1 || result.truncated) {
2308
+ fail("source_ambiguous", `Source delimiters are ambiguous in ${args.src}; ${matchedLocations(ranges.length, result.truncated)}`, {
1926
2309
  path: args.src,
1927
2310
  phase: "source",
1928
2311
  anchor: "src_start/src_end",
1929
- matches: ranges.length,
2312
+ ...matchCountDetails(ranges.length, result.truncated),
1930
2313
  ranges: boundedRanges(ranges),
1931
2314
  line_ranges: boundedLineRanges(file, ranges)
1932
2315
  });
1933
2316
  }
1934
2317
  return ranges[0];
1935
2318
  }
2319
+ function findDelimitedRanges(file, startNeedle, endNeedle, limit = 11) {
2320
+ if (startNeedle.length === 0) {
2321
+ return { ranges: [], truncated: false };
2322
+ }
2323
+ const maxMatches = normalizedLimit2(limit);
2324
+ const ranges = [];
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 });
2334
+ }
2335
+ start = file.indexOf(startNeedle, start + 1);
2336
+ }
2337
+ return { ranges, truncated: false };
2338
+ }
2339
+ function normalizedLimit2(limit) {
2340
+ if (!Number.isFinite(limit)) {
2341
+ return Number.POSITIVE_INFINITY;
2342
+ }
2343
+ return Math.max(0, Math.trunc(limit));
2344
+ }
1936
2345
  function targetAnchorName(args) {
1937
2346
  if (args.targetBefore.length > 0 && args.targetAfter.length > 0) {
1938
2347
  return "target_before+target_after";
@@ -2078,6 +2487,40 @@ function renderDeletionPatch(args, srcOriginal, source, payload, payloadSha256)
2078
2487
  `) + `
2079
2488
  `;
2080
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
+ }
2081
2524
  function lineNumberAt2(file, byteIndex) {
2082
2525
  let line = 1;
2083
2526
  for (let index = 0;index < byteIndex; index += 1) {
@@ -2146,6 +2589,24 @@ function decodeUtf8(bytes) {
2146
2589
 
2147
2590
  // src/cli.ts
2148
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]);
2149
2610
  async function main(argv) {
2150
2611
  const options = parseArgs(argv);
2151
2612
  if (options.command === "help") {
@@ -2177,12 +2638,13 @@ async function main(argv) {
2177
2638
  }
2178
2639
  async function runPatchCommand(options) {
2179
2640
  const patchPath = options.patchPath ?? "-";
2641
+ const inputPatchPath = patchPath === "-" ? patchPath : resolve3(patchPath);
2180
2642
  if (options.command === "check") {
2181
2643
  return patchPath === "-" ? checkPatchBytes(await readStdin(), {
2182
2644
  cwd: options.cwd,
2183
2645
  reverse: options.reverse,
2184
2646
  stripComponents: options.stripComponents
2185
- }) : checkPatchFile(patchPath, {
2647
+ }) : checkPatchFile(inputPatchPath, {
2186
2648
  cwd: options.cwd,
2187
2649
  reverse: options.reverse,
2188
2650
  stripComponents: options.stripComponents
@@ -2193,7 +2655,7 @@ async function runPatchCommand(options) {
2193
2655
  dryRun: options.dryRun,
2194
2656
  reverse: options.reverse,
2195
2657
  stripComponents: options.stripComponents
2196
- }) : applyPatchFile(patchPath, {
2658
+ }) : applyPatchFile(inputPatchPath, {
2197
2659
  cwd: options.cwd,
2198
2660
  dryRun: options.dryRun,
2199
2661
  reverse: options.reverse,
@@ -2207,7 +2669,7 @@ async function loadMoveArgs(options) {
2207
2669
  if (options.moveJsonPath === undefined) {
2208
2670
  throw new BlockPatchError("missing_move_args", `${options.command} requires --json or --src flags`);
2209
2671
  }
2210
- const jsonBytes = options.moveJsonPath === "-" ? await readStdin() : await readFileChecked(resolve3(options.cwd, options.moveJsonPath), "move JSON file");
2672
+ const jsonBytes = options.moveJsonPath === "-" ? await readStdin() : await readFileChecked(resolve3(options.moveJsonPath), "move JSON file");
2211
2673
  try {
2212
2674
  return JSON.parse(jsonBytes.toString("utf8"));
2213
2675
  } catch (error) {
@@ -2225,9 +2687,14 @@ function parseArgs(argv) {
2225
2687
  if (first === "version" || first === "--version" || first === "-v") {
2226
2688
  const options = base("version", outputFlags.jsonOutput);
2227
2689
  for (const arg of args) {
2228
- if (arg === "--json-output" || arg === "--explain") {
2690
+ if (isOutputFlag(arg)) {
2229
2691
  options.jsonOutput = true;
2692
+ continue;
2230
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}`);
2231
2698
  }
2232
2699
  return options;
2233
2700
  }
@@ -2257,7 +2724,7 @@ function parsePatchArgs(command, args, jsonOutput, explain) {
2257
2724
  options.reverse = true;
2258
2725
  continue;
2259
2726
  }
2260
- if (arg === "--cwd" || arg === "--directory" || arg === "-d") {
2727
+ if (isValueFlag(arg, CWD_VALUE_FLAGS)) {
2261
2728
  options.cwd = requireValue(args, arg, true);
2262
2729
  continue;
2263
2730
  }
@@ -2269,7 +2736,7 @@ function parsePatchArgs(command, args, jsonOutput, explain) {
2269
2736
  options.cwd = resolve3(arg.slice("--directory=".length));
2270
2737
  continue;
2271
2738
  }
2272
- if (arg === "-i" || arg === "--input") {
2739
+ if (isValueFlag(arg, PATCH_INPUT_VALUE_FLAGS)) {
2273
2740
  setPatchPath(options, requireValue(args, arg, false));
2274
2741
  continue;
2275
2742
  }
@@ -2317,7 +2784,7 @@ function parseMoveArgs(command, args, jsonOutput, explain) {
2317
2784
  options.diff = true;
2318
2785
  continue;
2319
2786
  }
2320
- if (arg === "--cwd" || arg === "--directory" || arg === "-d") {
2787
+ if (isValueFlag(arg, CWD_VALUE_FLAGS)) {
2321
2788
  options.cwd = requireValue(args, arg, true);
2322
2789
  continue;
2323
2790
  }
@@ -2329,8 +2796,8 @@ function parseMoveArgs(command, args, jsonOutput, explain) {
2329
2796
  options.cwd = resolve3(arg.slice("--directory=".length));
2330
2797
  continue;
2331
2798
  }
2332
- if (arg === "--json") {
2333
- options.moveJsonPath = requireValue(args, "--json", false);
2799
+ if (isValueFlag(arg, MOVE_JSON_VALUE_FLAGS)) {
2800
+ options.moveJsonPath = requireValue(args, arg, false);
2334
2801
  continue;
2335
2802
  }
2336
2803
  if (arg?.startsWith("--json=")) {
@@ -2354,26 +2821,7 @@ function parseMoveArgs(command, args, jsonOutput, explain) {
2354
2821
  return options;
2355
2822
  }
2356
2823
  function argToMoveKey(arg) {
2357
- switch (arg) {
2358
- case "--src":
2359
- return "src";
2360
- case "--src-start":
2361
- return "src_start";
2362
- case "--src-end":
2363
- return "src_end";
2364
- case "--dst":
2365
- return "dst";
2366
- case "--payload":
2367
- return "payload";
2368
- case "--target-before":
2369
- return "target_before";
2370
- case "--target-after":
2371
- return "target_after";
2372
- case "--expected-payload-sha256":
2373
- return "expected_payload_sha256";
2374
- default:
2375
- return;
2376
- }
2824
+ return MOVE_KEY_BY_FLAG[arg];
2377
2825
  }
2378
2826
  function base(command, jsonOutput) {
2379
2827
  return {
@@ -2393,7 +2841,7 @@ function setPatchPath(options, path) {
2393
2841
  options.patchPath = path;
2394
2842
  }
2395
2843
  function parseStripOption(arg, args) {
2396
- if (arg === "-p" || arg === "--strip") {
2844
+ if (isValueFlag(arg, STRIP_VALUE_FLAGS)) {
2397
2845
  return parseStripComponents(requireValue(args, arg, false), arg);
2398
2846
  }
2399
2847
  if (arg?.startsWith("-p") === true && arg.length > 2) {
@@ -2417,7 +2865,7 @@ function parseStripComponents(value, option) {
2417
2865
  function takeLeadingOutputFlags(args) {
2418
2866
  let jsonOutput = false;
2419
2867
  let explain = false;
2420
- while (args[0] === "--json-output" || args[0] === "--explain") {
2868
+ while (isOutputFlag(args[0])) {
2421
2869
  const arg = args.shift();
2422
2870
  jsonOutput = true;
2423
2871
  explain = explain || arg === "--explain";
@@ -2425,10 +2873,6 @@ function takeLeadingOutputFlags(args) {
2425
2873
  return { jsonOutput, explain };
2426
2874
  }
2427
2875
  function takeOutputFlag(options, arg) {
2428
- if (arg === "--json-output") {
2429
- options.jsonOutput = true;
2430
- return true;
2431
- }
2432
2876
  if (arg === "--explain") {
2433
2877
  options.jsonOutput = true;
2434
2878
  if (options.command === "apply" || options.command === "move" || options.command === "plan") {
@@ -2436,8 +2880,18 @@ function takeOutputFlag(options, arg) {
2436
2880
  }
2437
2881
  return true;
2438
2882
  }
2883
+ if (arg === "--json-output") {
2884
+ options.jsonOutput = true;
2885
+ return true;
2886
+ }
2439
2887
  return false;
2440
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
+ }
2441
2895
  function requireValue(args, option, pathValue) {
2442
2896
  const value = args.shift();
2443
2897
  if (value === undefined) {
@@ -2531,31 +2985,21 @@ function hasJsonOutputFlag(argv) {
2531
2985
  if (command === "plan") {
2532
2986
  return true;
2533
2987
  }
2534
- return args.includes("--json-output") || args.includes("--explain");
2988
+ return args.some(isOutputFlag);
2535
2989
  }
2536
2990
  function hasPatchJsonOutputFlag(args) {
2537
- for (let index = 0;index < args.length; index += 1) {
2538
- const arg = args[index];
2539
- if (arg === "--json-output" || arg === "--explain") {
2540
- return true;
2541
- }
2542
- if (arg === "--cwd" || arg === "--directory" || arg === "-d" || arg === "-i" || arg === "--input") {
2543
- index += 1;
2544
- continue;
2545
- }
2546
- if (arg === "-p" || arg === "--strip") {
2547
- index += 1;
2548
- }
2549
- }
2550
- return false;
2991
+ return hasJsonOutputAfterValueFlags(args, PATCH_VALUE_FLAGS);
2551
2992
  }
2552
2993
  function hasMoveJsonOutputFlag(args) {
2994
+ return hasJsonOutputAfterValueFlags(args, MOVE_VALUE_FLAGS);
2995
+ }
2996
+ function hasJsonOutputAfterValueFlags(args, valueFlags) {
2553
2997
  for (let index = 0;index < args.length; index += 1) {
2554
2998
  const arg = args[index];
2555
- if (arg === "--json-output" || arg === "--explain") {
2999
+ if (isOutputFlag(arg)) {
2556
3000
  return true;
2557
3001
  }
2558
- if (arg === "--cwd" || arg === "--directory" || arg === "-d" || arg === "--json" || argToMoveKey(arg) !== undefined) {
3002
+ if (isValueFlag(arg, valueFlags)) {
2559
3003
  index += 1;
2560
3004
  }
2561
3005
  }