pi-openai-codex-compat 0.0.6 → 0.0.7

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.
@@ -1,6 +1,33 @@
1
- import { lstat, mkdir, readFile, stat, unlink, writeFile } from "node:fs/promises";
2
- import { dirname, isAbsolute, resolve } from "node:path";
1
+ import { constants } from "node:fs";
2
+ import {
3
+ chmod,
4
+ copyFile,
5
+ lstat,
6
+ mkdir,
7
+ readFile,
8
+ readdir,
9
+ readlink,
10
+ realpath,
11
+ rename,
12
+ stat,
13
+ symlink,
14
+ unlink,
15
+ utimes,
16
+ writeFile,
17
+ } from "node:fs/promises";
18
+ import type { Stats } from "node:fs";
19
+ import { randomUUID } from "node:crypto";
20
+ import { basename, dirname, isAbsolute, join, parse, relative, resolve, sep } from "node:path";
3
21
  import { generateDiffString, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
22
+ import {
23
+ deriveNewContent,
24
+ FormatterMatchError,
25
+ type FormatterMatchFailureDetails,
26
+ type UpdateChunk,
27
+ type UpdateHunkLine,
28
+ } from "./apply-patch-matcher.ts";
29
+
30
+ export type { UpdateChunk, UpdateHunkLine } from "./apply-patch-matcher.ts";
4
31
 
5
32
  const BEGIN_PATCH = "*** Begin Patch";
6
33
  const END_PATCH = "*** End Patch";
@@ -53,13 +80,6 @@ type ParserMode =
53
80
  | { kind: "update"; hunkLineNumber: number }
54
81
  | { kind: "ended" };
55
82
 
56
- export type UpdateChunk = {
57
- context?: string;
58
- oldLines: string[];
59
- newLines: string[];
60
- endOfFile: boolean;
61
- };
62
-
63
83
  export type PatchOperation =
64
84
  | { kind: "add"; path: string; content: string }
65
85
  | { kind: "delete"; path: string }
@@ -84,7 +104,8 @@ export type AppliedPatchChange =
84
104
  | {
85
105
  kind: "delete";
86
106
  path: string;
87
- content: string;
107
+ entryType: "regular-file" | "symlink";
108
+ content?: string;
88
109
  displayDiff: string;
89
110
  additions: number;
90
111
  deletions: number;
@@ -99,8 +120,106 @@ export type AppliedPatchChange =
99
120
  displayDiff: string;
100
121
  additions: number;
101
122
  deletions: number;
123
+ }
124
+ | {
125
+ kind: "move";
126
+ sourcePath: string;
127
+ destinationPath: string;
128
+ replacedDestination: boolean;
129
+ entryType: "regular-file" | "symlink";
130
+ exact: boolean;
131
+ displayDiff: "";
132
+ additions: 0;
133
+ deletions: 0;
102
134
  };
103
135
 
136
+ export type ApplyPatchInstructionStatus =
137
+ | "applied"
138
+ | "planned"
139
+ | "no-op"
140
+ | "dead"
141
+ | "failed"
142
+ | "not-run";
143
+
144
+ export type ApplyPatchInstructionReasonCode =
145
+ | "empty-update"
146
+ | "identity-update"
147
+ | "content-already-present"
148
+ | "update-result-unchanged"
149
+ | "path-already-absent"
150
+ | "same-entry-move"
151
+ | "move-already-fulfilled"
152
+ | "dead-dominated";
153
+
154
+ export type ApplyPatchInstructionReason = {
155
+ code: ApplyPatchInstructionReasonCode;
156
+ message: string;
157
+ dominatingInstructions?: number[];
158
+ relatedInstructions?: number[];
159
+ };
160
+
161
+ export type ApplyPatchFileEntryDetails =
162
+ | { entryType: "regular-file" }
163
+ | { entryType: "symlink"; target: string };
164
+
165
+ export type ApplyPatchInstructionEffect =
166
+ | {
167
+ kind: "created" | "updated" | "deleted" | "directory-created" | "temporary-entry-remains";
168
+ path: string;
169
+ }
170
+ | {
171
+ kind: "replaced";
172
+ path: string;
173
+ previousEntry: ApplyPatchFileEntryDetails;
174
+ replacementEntry: ApplyPatchFileEntryDetails;
175
+ }
176
+ | { kind: "source-remains"; path: string }
177
+ | {
178
+ kind: "symlink-removed" | "symlink-moved";
179
+ path: string;
180
+ target: string;
181
+ }
182
+ | { kind: "symlink-target-modified"; path: string; target: string };
183
+
184
+ export type ApplyPatchFinalPathState = {
185
+ path: string;
186
+ state:
187
+ | "absent"
188
+ | "regular-file"
189
+ | "symlink"
190
+ | "directory"
191
+ | "other-entry"
192
+ | "unchanged"
193
+ | "requested-content"
194
+ | "different-from-requested-content"
195
+ | "different-from-requested-and-previous-content"
196
+ | "different-from-previous-content"
197
+ | "different-entry"
198
+ | "different-entry-type"
199
+ | "not-verified";
200
+ };
201
+
202
+ export type ApplyPatchInstructionDetails = {
203
+ index: number;
204
+ kind: "add" | "delete" | "update" | "move";
205
+ path: string;
206
+ moveTo?: string;
207
+ status: ApplyPatchInstructionStatus;
208
+ reason?: ApplyPatchInstructionReason;
209
+ effects?: ApplyPatchInstructionEffect[];
210
+ finalStates?: ApplyPatchFinalPathState[];
211
+ matcher?: FormatterMatchFailureDetails;
212
+ changeIndexes?: number[];
213
+ error?: string;
214
+ };
215
+
216
+ export type ApplyPatchFailureDetails = {
217
+ phase: "input" | "parse" | "preflight" | "execution";
218
+ message: string;
219
+ failedInstruction?: number;
220
+ matcher?: FormatterMatchFailureDetails;
221
+ };
222
+
104
223
  export type ApplyPatchDetails = {
105
224
  status: "completed" | "failed";
106
225
  exact: boolean;
@@ -108,6 +227,8 @@ export type ApplyPatchDetails = {
108
227
  added: string[];
109
228
  modified: string[];
110
229
  deleted: string[];
230
+ instructions?: ApplyPatchInstructionDetails[];
231
+ failure?: ApplyPatchFailureDetails;
111
232
  error?: string;
112
233
  };
113
234
 
@@ -138,9 +259,23 @@ export class ApplyPatchParseError extends Error {
138
259
  }
139
260
  }
140
261
 
141
- export class ApplyPatchInputError extends Error {}
262
+ export class ApplyPatchInputError extends Error {
263
+ readonly details: ApplyPatchDetails | undefined;
264
+
265
+ constructor(message: string, details?: ApplyPatchDetails) {
266
+ super(message);
267
+ this.details = details;
268
+ }
269
+ }
270
+
271
+ export class ApplyPatchVerificationError extends Error {
272
+ readonly details: ApplyPatchDetails;
142
273
 
143
- export class ApplyPatchVerificationError extends Error {}
274
+ constructor(message: string, details: ApplyPatchDetails) {
275
+ super(message);
276
+ this.details = details;
277
+ }
278
+ }
144
279
 
145
280
  export class ApplyPatchExecutionError extends Error {
146
281
  readonly details: ApplyPatchDetails;
@@ -161,7 +296,6 @@ class PatchParser {
161
296
  for (const [index, line] of lines.entries()) {
162
297
  this.lineNumber += 1;
163
298
  if (index === lines.length - 1 && rustTrim(line) === END_PATCH) {
164
- this.ensureUpdateHunkIsNotEmpty(rustTrim(line));
165
299
  this.mode = { kind: "ended" };
166
300
  } else {
167
301
  this.processLine(line);
@@ -182,28 +316,6 @@ class PatchParser {
182
316
  return operation?.kind === "update" ? operation : undefined;
183
317
  }
184
318
 
185
- private ensureUpdateHunkIsNotEmpty(line: string): void {
186
- const operation = this.lastUpdate();
187
- if (!operation || this.mode.kind !== "update") return;
188
- if (operation.chunks.length === 0) {
189
- throw new ApplyPatchParseError(
190
- "hunk",
191
- `Update file hunk for path '${operation.path}' is empty`,
192
- this.mode.hunkLineNumber,
193
- );
194
- }
195
- const chunk = operation.chunks.at(-1);
196
- if (!chunk || chunk.oldLines.length !== 0 || chunk.newLines.length !== 0) return;
197
- if (line === END_PATCH) {
198
- throw new ApplyPatchParseError(
199
- "hunk",
200
- "Update hunk does not contain any lines",
201
- this.lineNumber,
202
- );
203
- }
204
- throw this.unexpectedUpdateLine(line);
205
- }
206
-
207
319
  private invalidHunkHeader(line: string): ApplyPatchParseError {
208
320
  return new ApplyPatchParseError(
209
321
  "hunk",
@@ -236,24 +348,20 @@ class PatchParser {
236
348
  return true;
237
349
  }
238
350
  if (line === END_PATCH) {
239
- this.ensureUpdateHunkIsNotEmpty(line);
240
351
  this.mode = { kind: "ended" };
241
352
  return true;
242
353
  }
243
354
  if (line.startsWith(ADD_FILE)) {
244
- this.ensureUpdateHunkIsNotEmpty(line);
245
355
  this.operations.push({ kind: "add", path: line.slice(ADD_FILE.length), content: "" });
246
356
  this.mode = { kind: "add" };
247
357
  return true;
248
358
  }
249
359
  if (line.startsWith(DELETE_FILE)) {
250
- this.ensureUpdateHunkIsNotEmpty(line);
251
360
  this.operations.push({ kind: "delete", path: line.slice(DELETE_FILE.length) });
252
361
  this.mode = { kind: "delete" };
253
362
  return true;
254
363
  }
255
364
  if (line.startsWith(UPDATE_FILE)) {
256
- this.ensureUpdateHunkIsNotEmpty(line);
257
365
  this.operations.push({
258
366
  kind: "update",
259
367
  path: line.slice(UPDATE_FILE.length),
@@ -268,12 +376,22 @@ class PatchParser {
268
376
  private ensureUpdateChunk(operation: Extract<PatchOperation, { kind: "update" }>): UpdateChunk {
269
377
  let chunk = operation.chunks.at(-1);
270
378
  if (!chunk) {
271
- chunk = { oldLines: [], newLines: [], endOfFile: false };
379
+ chunk = { oldLines: [], newLines: [], lines: [], endOfFile: false };
272
380
  operation.chunks.push(chunk);
273
381
  }
274
382
  return chunk;
275
383
  }
276
384
 
385
+ private appendUpdateLine(
386
+ operation: Extract<PatchOperation, { kind: "update" }>,
387
+ line: UpdateHunkLine,
388
+ ): void {
389
+ const chunk = this.ensureUpdateChunk(operation);
390
+ chunk.lines.push(line);
391
+ if (line.kind !== "add") chunk.oldLines.push(line.text);
392
+ if (line.kind !== "delete") chunk.newLines.push(line.text);
393
+ }
394
+
277
395
  private processLine(line: string): void {
278
396
  const trimmed = rustTrim(line);
279
397
  switch (this.mode.kind) {
@@ -328,17 +446,8 @@ class PatchParser {
328
446
  return;
329
447
  }
330
448
 
331
- if (
332
- (updateLine === EMPTY_CHANGE_CONTEXT || updateLine.startsWith(CHANGE_CONTEXT)) &&
333
- lastChunk &&
334
- lastChunk.oldLines.length === 0 &&
335
- lastChunk.newLines.length === 0
336
- ) {
337
- throw this.unexpectedUpdateLine(line);
338
- }
339
-
340
449
  if (updateLine === EMPTY_CHANGE_CONTEXT) {
341
- operation.chunks.push({ oldLines: [], newLines: [], endOfFile: false });
450
+ operation.chunks.push({ oldLines: [], newLines: [], lines: [], endOfFile: false });
342
451
  return;
343
452
  }
344
453
  if (updateLine.startsWith(CHANGE_CONTEXT)) {
@@ -346,41 +455,31 @@ class PatchParser {
346
455
  context: updateLine.slice(CHANGE_CONTEXT.length),
347
456
  oldLines: [],
348
457
  newLines: [],
458
+ lines: [],
349
459
  endOfFile: false,
350
460
  });
351
461
  return;
352
462
  }
353
463
  if (updateLine === END_OF_FILE) {
354
464
  const chunk = operation.chunks.at(-1);
355
- if (chunk && chunk.oldLines.length === 0 && chunk.newLines.length === 0) {
356
- throw new ApplyPatchParseError(
357
- "hunk",
358
- "Update hunk does not contain any lines",
359
- this.lineNumber,
360
- );
361
- }
362
465
  if (chunk) chunk.endOfFile = true;
363
466
  return;
364
467
  }
365
468
 
366
469
  if (line === "") {
367
- const chunk = this.ensureUpdateChunk(operation);
368
- chunk.oldLines.push("");
369
- chunk.newLines.push("");
470
+ this.appendUpdateLine(operation, { kind: "context", text: "" });
370
471
  return;
371
472
  }
372
473
  if (line.startsWith(" ")) {
373
- const chunk = this.ensureUpdateChunk(operation);
374
- chunk.oldLines.push(line.slice(1));
375
- chunk.newLines.push(line.slice(1));
474
+ this.appendUpdateLine(operation, { kind: "context", text: line.slice(1) });
376
475
  return;
377
476
  }
378
477
  if (line.startsWith("+")) {
379
- this.ensureUpdateChunk(operation).newLines.push(line.slice(1));
478
+ this.appendUpdateLine(operation, { kind: "add", text: line.slice(1) });
380
479
  return;
381
480
  }
382
481
  if (line.startsWith("-")) {
383
- this.ensureUpdateChunk(operation).oldLines.push(line.slice(1));
482
+ this.appendUpdateLine(operation, { kind: "delete", text: line.slice(1) });
384
483
  return;
385
484
  }
386
485
 
@@ -450,135 +549,6 @@ export function parsePatch(patch: string): PatchOperation[] {
450
549
  return parsePatchDocument(patch).operations;
451
550
  }
452
551
 
453
- function normalizeFuzzyText(value: string): string {
454
- const replacements: Record<string, string> = {
455
- "\u2010": "-",
456
- "\u2011": "-",
457
- "\u2012": "-",
458
- "\u2013": "-",
459
- "\u2014": "-",
460
- "\u2015": "-",
461
- "\u2212": "-",
462
- "\u2018": "'",
463
- "\u2019": "'",
464
- "\u201a": "'",
465
- "\u201b": "'",
466
- "\u201c": '"',
467
- "\u201d": '"',
468
- "\u201e": '"',
469
- "\u201f": '"',
470
- "\u00a0": " ",
471
- "\u2002": " ",
472
- "\u2003": " ",
473
- "\u2004": " ",
474
- "\u2005": " ",
475
- "\u2006": " ",
476
- "\u2007": " ",
477
- "\u2008": " ",
478
- "\u2009": " ",
479
- "\u200a": " ",
480
- "\u202f": " ",
481
- "\u205f": " ",
482
- "\u3000": " ",
483
- };
484
- return Array.from(rustTrim(value))
485
- .map((character) => replacements[character] ?? character)
486
- .join("");
487
- }
488
-
489
- function sequenceMatches(
490
- lines: readonly string[],
491
- pattern: readonly string[],
492
- index: number,
493
- mode: "exact" | "trim-end" | "trim" | "unicode",
494
- ): boolean {
495
- const candidate = lines.slice(index, index + pattern.length);
496
- if (candidate.length !== pattern.length) return false;
497
- return candidate.every((line, offset) => {
498
- const expected = pattern[offset]!;
499
- switch (mode) {
500
- case "exact":
501
- return line === expected;
502
- case "trim-end":
503
- return rustTrimEnd(line) === rustTrimEnd(expected);
504
- case "trim":
505
- return rustTrim(line) === rustTrim(expected);
506
- case "unicode":
507
- return normalizeFuzzyText(line) === normalizeFuzzyText(expected);
508
- }
509
- });
510
- }
511
-
512
- function findSequence(
513
- lines: readonly string[],
514
- pattern: readonly string[],
515
- start: number,
516
- endOfFile: boolean,
517
- ): number | undefined {
518
- if (pattern.length === 0) return start;
519
- if (pattern.length > lines.length) return undefined;
520
- const last = lines.length - pattern.length;
521
- const searchStart = endOfFile ? last : start;
522
- for (const mode of ["exact", "trim-end", "trim", "unicode"] as const) {
523
- for (let index = searchStart; index <= last; index++) {
524
- if (sequenceMatches(lines, pattern, index, mode)) return index;
525
- }
526
- }
527
- return undefined;
528
- }
529
-
530
- function deriveNewContent(content: string, chunks: readonly UpdateChunk[], path: string): string {
531
- const lines = content.split("\n");
532
- if (lines.at(-1) === "") lines.pop();
533
- const replacements: Array<{ index: number; oldLength: number; newLines: string[] }> = [];
534
- let cursor = 0;
535
-
536
- for (const chunk of chunks) {
537
- if (chunk.context) {
538
- const contextIndex = findSequence(lines, [chunk.context], cursor, false);
539
- if (contextIndex === undefined) {
540
- throw new Error(`Failed to find context '${chunk.context}' in ${path}`);
541
- }
542
- cursor = contextIndex + 1;
543
- }
544
-
545
- if (chunk.oldLines.length === 0) {
546
- const insertionIndex = lines.at(-1) === "" ? lines.length - 1 : lines.length;
547
- replacements.push({
548
- index: insertionIndex,
549
- oldLength: 0,
550
- newLines: [...chunk.newLines],
551
- });
552
- continue;
553
- }
554
-
555
- let oldLines = chunk.oldLines;
556
- let newLines = chunk.newLines;
557
- let found = findSequence(lines, oldLines, cursor, chunk.endOfFile);
558
- if (found === undefined && oldLines.at(-1) === "") {
559
- oldLines = oldLines.slice(0, -1);
560
- if (newLines.at(-1) === "") newLines = newLines.slice(0, -1);
561
- found = findSequence(lines, oldLines, cursor, chunk.endOfFile);
562
- }
563
- if (found === undefined) {
564
- throw new Error(`Failed to find expected lines in ${path}:\n${chunk.oldLines.join("\n")}`);
565
- }
566
- replacements.push({
567
- index: found,
568
- oldLength: oldLines.length,
569
- newLines: [...newLines],
570
- });
571
- cursor = found + oldLines.length;
572
- }
573
-
574
- replacements.sort((left, right) => left.index - right.index);
575
- for (const replacement of replacements.toReversed()) {
576
- lines.splice(replacement.index, replacement.oldLength, ...replacement.newLines);
577
- }
578
- if (lines.at(-1) !== "") lines.push("");
579
- return lines.join("\n");
580
- }
581
-
582
552
  function hasErrorCode(error: unknown, code: string): boolean {
583
553
  return typeof error === "object" && error !== null && "code" in error && error.code === code;
584
554
  }
@@ -614,48 +584,6 @@ function errorMessage(error: unknown): string {
614
584
  return error instanceof Error ? error.message : String(error);
615
585
  }
616
586
 
617
- async function readUtf8(path: string, context: string): Promise<string> {
618
- try {
619
- return UTF8_DECODER.decode(await readFile(path));
620
- } catch (error) {
621
- throw new Error(`${context}: ${errorMessage(error)}`);
622
- }
623
- }
624
-
625
- async function supportsExactDelta(path: string): Promise<boolean> {
626
- try {
627
- const metadata = await lstat(path);
628
- return metadata.isFile() && !metadata.isSymbolicLink();
629
- } catch (error) {
630
- return isNotFound(error);
631
- }
632
- }
633
-
634
- async function readOptionalUtf8(path: string): Promise<{ content?: string; exact: boolean }> {
635
- const exact = await supportsExactDelta(path);
636
- try {
637
- return { content: UTF8_DECODER.decode(await readFile(path)), exact };
638
- } catch (error) {
639
- if (isNotFound(error)) return { exact };
640
- return { exact: false };
641
- }
642
- }
643
-
644
- async function verifyOperations(operations: readonly ResolvedOperation[]): Promise<void> {
645
- for (const operation of operations) {
646
- if (operation.kind === "add") continue;
647
- if (operation.kind === "delete") {
648
- await readUtf8(operation.absolutePath, `Failed to read ${operation.absolutePath}`);
649
- continue;
650
- }
651
- const current = await readUtf8(
652
- operation.absolutePath,
653
- `Failed to read file to update ${operation.absolutePath}`,
654
- );
655
- deriveNewContent(current, operation.chunks, operation.absolutePath);
656
- }
657
- }
658
-
659
587
  function diffDetails(
660
588
  oldContent: string,
661
589
  newContent: string,
@@ -675,12 +603,14 @@ function diffDetails(
675
603
  }
676
604
 
677
605
  function initialContent(change: AppliedPatchChange): string | undefined {
606
+ if (change.kind === "move") return undefined;
678
607
  if (change.kind === "add") return change.overwrittenContent;
679
608
  if (change.kind === "delete") return change.content;
680
609
  return change.oldContent;
681
610
  }
682
611
 
683
612
  function finalContent(change: AppliedPatchChange): string | undefined {
613
+ if (change.kind === "move") return undefined;
684
614
  if (change.kind === "delete") return undefined;
685
615
  if (change.kind === "add") return change.content;
686
616
  return change.newContent;
@@ -690,21 +620,23 @@ export function coalesceAppliedPatchChangesForRendering(
690
620
  changes: readonly AppliedPatchChange[],
691
621
  cwd: string,
692
622
  ): AppliedPatchChange[] {
693
- const groups = new Map<string, { firstIndex: number; changes: AppliedPatchChange[] }>();
623
+ type TextualChange = Exclude<AppliedPatchChange, { kind: "move" }>;
624
+ const groups = new Map<string, { firstIndex: number; changes: TextualChange[] }>();
694
625
  const rendered: Array<{ index: number; change: AppliedPatchChange }> = [];
695
626
 
696
627
  for (const [index, change] of changes.entries()) {
697
- if (change.kind === "update" && change.moveTo) {
628
+ if (change.kind === "move" || (change.kind === "update" && change.moveTo)) {
698
629
  // Moves span source and destination identities, so retain their existing operation-level row.
699
630
  rendered.push({ index, change });
700
631
  continue;
701
632
  }
633
+ const textualChange: TextualChange = change;
702
634
  const key = resolvePatchPath(cwd, change.path);
703
635
  const group = groups.get(key);
704
636
  if (group) {
705
- group.changes.push(change);
637
+ group.changes.push(textualChange);
706
638
  } else {
707
- groups.set(key, { firstIndex: index, changes: [change] });
639
+ groups.set(key, { firstIndex: index, changes: [textualChange] });
708
640
  }
709
641
  }
710
642
 
@@ -715,6 +647,12 @@ export function coalesceAppliedPatchChangesForRendering(
715
647
  rendered.push({ index: group.firstIndex, change: first });
716
648
  continue;
717
649
  }
650
+ if (group.changes.some((change) => change.kind === "delete" && change.content === undefined)) {
651
+ for (const [offset, change] of group.changes.entries()) {
652
+ rendered.push({ index: group.firstIndex + offset / group.changes.length, change });
653
+ }
654
+ continue;
655
+ }
718
656
 
719
657
  const oldContent = initialContent(first);
720
658
  const newContent = finalContent(last);
@@ -737,6 +675,7 @@ export function coalesceAppliedPatchChangesForRendering(
737
675
  change: {
738
676
  kind: "delete",
739
677
  path: first.path,
678
+ entryType: "regular-file",
740
679
  content: oldContent,
741
680
  ...diffDetails(oldContent, ""),
742
681
  },
@@ -770,29 +709,111 @@ function emptyDetails(): ApplyPatchDetails {
770
709
  }
771
710
 
772
711
  export function cloneApplyPatchDetails(details: ApplyPatchDetails): ApplyPatchDetails {
712
+ const cloneMatcher = (matcher: FormatterMatchFailureDetails): FormatterMatchFailureDetails => ({
713
+ ...matcher,
714
+ candidates: matcher.candidates.map((range) => ({ ...range })),
715
+ ...(matcher.previousCandidates
716
+ ? { previousCandidates: matcher.previousCandidates.map((range) => ({ ...range })) }
717
+ : {}),
718
+ ...(matcher.replacementCandidates
719
+ ? { replacementCandidates: matcher.replacementCandidates.map((range) => ({ ...range })) }
720
+ : {}),
721
+ });
722
+ const cloneEffect = (effect: ApplyPatchInstructionEffect): ApplyPatchInstructionEffect =>
723
+ effect.kind === "replaced"
724
+ ? {
725
+ ...effect,
726
+ previousEntry: { ...effect.previousEntry },
727
+ replacementEntry: { ...effect.replacementEntry },
728
+ }
729
+ : { ...effect };
773
730
  return {
774
731
  ...details,
775
732
  changes: details.changes.map((change) => ({ ...change })),
776
733
  added: [...details.added],
777
734
  modified: [...details.modified],
778
735
  deleted: [...details.deleted],
736
+ ...(details.instructions
737
+ ? {
738
+ instructions: details.instructions.map((instruction) => ({
739
+ ...instruction,
740
+ ...(instruction.effects ? { effects: instruction.effects.map(cloneEffect) } : {}),
741
+ ...(instruction.finalStates
742
+ ? { finalStates: instruction.finalStates.map((state) => ({ ...state })) }
743
+ : {}),
744
+ ...(instruction.matcher ? { matcher: cloneMatcher(instruction.matcher) } : {}),
745
+ ...(instruction.changeIndexes ? { changeIndexes: [...instruction.changeIndexes] } : {}),
746
+ ...(instruction.reason
747
+ ? {
748
+ reason: {
749
+ ...instruction.reason,
750
+ ...(instruction.reason.dominatingInstructions
751
+ ? {
752
+ dominatingInstructions: [...instruction.reason.dominatingInstructions],
753
+ }
754
+ : {}),
755
+ ...(instruction.reason.relatedInstructions
756
+ ? {
757
+ relatedInstructions: [...instruction.reason.relatedInstructions],
758
+ }
759
+ : {}),
760
+ },
761
+ }
762
+ : {}),
763
+ })),
764
+ }
765
+ : {}),
766
+ ...(details.failure
767
+ ? {
768
+ failure: {
769
+ ...details.failure,
770
+ ...(details.failure.matcher ? { matcher: cloneMatcher(details.failure.matcher) } : {}),
771
+ },
772
+ }
773
+ : {}),
779
774
  };
780
775
  }
781
776
 
782
777
  export type ApplyPatchExecutionHooks = {
783
- onExecutionStart?: () => void;
778
+ onExecutionStart?: () => void | Promise<void>;
784
779
  onProgress?: (details: ApplyPatchDetails) => void;
780
+ selectMoveStrategy?: (
781
+ sourcePath: string,
782
+ destinationPath: string,
783
+ detected: "rename" | "copy-unlink",
784
+ ) => "rename" | "copy-unlink" | Promise<"rename" | "copy-unlink">;
785
+ filesystem?: Partial<ApplyPatchExecutionFilesystem>;
785
786
  };
786
787
 
787
- async function writeFileWithParents(path: string, content: string): Promise<void> {
788
- try {
789
- await writeFile(path, content, "utf8");
790
- } catch (error) {
791
- if (!isNotFound(error)) throw error;
792
- await mkdir(dirname(path), { recursive: true });
793
- await writeFile(path, content, "utf8");
794
- }
795
- }
788
+ export type ApplyPatchExecutionFilesystem = {
789
+ chmod: typeof chmod;
790
+ copyFile: typeof copyFile;
791
+ lstat: typeof lstat;
792
+ mkdir: typeof mkdir;
793
+ readFile: typeof readFile;
794
+ readlink: typeof readlink;
795
+ readdir: typeof readdir;
796
+ rename: typeof rename;
797
+ symlink: typeof symlink;
798
+ unlink: typeof unlink;
799
+ utimes: typeof utimes;
800
+ writeFile: typeof writeFile;
801
+ };
802
+
803
+ const DEFAULT_EXECUTION_FILESYSTEM: ApplyPatchExecutionFilesystem = {
804
+ chmod,
805
+ copyFile,
806
+ lstat,
807
+ mkdir,
808
+ readFile,
809
+ readlink,
810
+ readdir,
811
+ rename,
812
+ symlink,
813
+ unlink,
814
+ utimes,
815
+ writeFile,
816
+ };
796
817
 
797
818
  function throwIfAborted(signal: AbortSignal | undefined): void {
798
819
  if (signal?.aborted) throw new Error("apply_patch was cancelled.");
@@ -808,268 +829,3841 @@ async function withMutationQueues<T>(
808
829
  return withFileMutationQueue(path, () => withMutationQueues(paths, callback, index + 1));
809
830
  }
810
831
 
811
- async function applyOperations(
812
- operations: readonly ResolvedOperation[],
813
- signal: AbortSignal | undefined,
814
- onProgress?: (details: ApplyPatchDetails) => void,
815
- ): Promise<ApplyPatchDetails> {
816
- const details = emptyDetails();
832
+ const logicalMutationQueues = new Map<string, Promise<void>>();
833
+ let logicalQueueRegistration = Promise.resolve();
834
+
835
+ async function withLogicalMutationQueue<T>(key: string, callback: () => Promise<T>): Promise<T> {
836
+ const registration = logicalQueueRegistration.then(() => {
837
+ const currentQueue = logicalMutationQueues.get(key) ?? Promise.resolve();
838
+ let releaseNext!: () => void;
839
+ const nextQueue = new Promise<void>((resolveQueue) => {
840
+ releaseNext = resolveQueue;
841
+ });
842
+ const chainedQueue = currentQueue.then(() => nextQueue);
843
+ logicalMutationQueues.set(key, chainedQueue);
844
+ return { currentQueue, chainedQueue, releaseNext };
845
+ });
846
+ logicalQueueRegistration = registration.then(
847
+ () => undefined,
848
+ () => undefined,
849
+ );
850
+ const { currentQueue, chainedQueue, releaseNext } = await registration;
851
+ await currentQueue;
817
852
  try {
818
- for (const operation of operations) {
819
- throwIfAborted(signal);
820
- if (operation.kind === "add") {
821
- const previous = await readOptionalUtf8(operation.absolutePath);
822
- details.exact &&= previous.exact;
823
- try {
824
- await writeFileWithParents(operation.absolutePath, operation.content);
825
- } catch (error) {
826
- details.exact = false;
827
- throw new Error(`Failed to write file ${operation.absolutePath}: ${errorMessage(error)}`);
828
- }
829
- const diff = diffDetails("", operation.content);
830
- details.changes.push({
831
- kind: "add",
832
- path: operation.path,
833
- content: operation.content,
834
- ...(previous.content !== undefined ? { overwrittenContent: previous.content } : {}),
835
- ...diff,
836
- });
837
- details.added.push(operation.path);
838
- } else if (operation.kind === "delete") {
839
- const previous = await readOptionalUtf8(operation.absolutePath);
840
- details.exact &&= previous.exact;
841
- try {
842
- const metadata = await stat(operation.absolutePath);
843
- if (metadata.isDirectory()) throw new Error("path is a directory");
844
- await unlink(operation.absolutePath);
845
- } catch (error) {
846
- if (previous.content !== undefined) {
847
- try {
848
- details.exact &&=
849
- (await readUtf8(operation.absolutePath, "Failed to inspect delete failure")) ===
850
- previous.content;
851
- } catch {
852
- details.exact = false;
853
- }
854
- } else {
855
- details.exact = false;
856
- }
857
- throw new Error(
858
- `Failed to delete file ${operation.absolutePath}: ${errorMessage(error)}`,
859
- );
860
- }
861
- const content = previous.content ?? "";
862
- if (previous.content !== undefined) {
863
- details.changes.push({
864
- kind: "delete",
865
- path: operation.path,
866
- content,
867
- ...diffDetails(content, ""),
868
- });
869
- }
870
- details.deleted.push(operation.path);
871
- } else {
872
- details.exact &&= await supportsExactDelta(operation.absolutePath);
873
- const oldContent = await readUtf8(
874
- operation.absolutePath,
875
- `Failed to read file to update ${operation.absolutePath}`,
876
- );
877
- const newContent = deriveNewContent(oldContent, operation.chunks, operation.absolutePath);
878
- if (operation.moveAbsolutePath && operation.moveTo) {
879
- const previousDestination = await readOptionalUtf8(operation.moveAbsolutePath);
880
- details.exact &&= previousDestination.exact;
881
- try {
882
- await writeFileWithParents(operation.moveAbsolutePath, newContent);
883
- } catch (error) {
884
- details.exact = false;
885
- throw new Error(
886
- `Failed to write file ${operation.moveAbsolutePath}: ${errorMessage(error)}`,
887
- );
888
- }
889
- const provisionalIndex = details.changes.length;
890
- details.changes.push({
891
- kind: "add",
892
- path: operation.moveTo,
893
- content: newContent,
894
- ...(previousDestination.content !== undefined
895
- ? { overwrittenContent: previousDestination.content }
896
- : {}),
897
- ...diffDetails("", newContent),
898
- });
899
- try {
900
- const metadata = await stat(operation.absolutePath);
901
- if (metadata.isDirectory()) throw new Error("path is a directory");
902
- await unlink(operation.absolutePath);
903
- } catch (error) {
904
- try {
905
- details.exact &&=
906
- (await readUtf8(operation.absolutePath, "Failed to inspect move failure")) ===
907
- oldContent;
908
- } catch {
909
- details.exact = false;
910
- }
911
- throw new Error(
912
- `Failed to remove original ${operation.absolutePath}: ${errorMessage(error)}`,
913
- );
914
- }
915
- details.changes[provisionalIndex] = {
916
- kind: "update",
917
- path: operation.path,
918
- moveTo: operation.moveTo,
919
- oldContent,
920
- newContent,
921
- ...(previousDestination.content !== undefined
922
- ? { overwrittenMoveContent: previousDestination.content }
923
- : {}),
924
- ...diffDetails(oldContent, newContent),
925
- };
926
- details.modified.push(operation.moveTo);
927
- } else {
853
+ return await callback();
854
+ } finally {
855
+ releaseNext();
856
+ if (logicalMutationQueues.get(key) === chainedQueue) logicalMutationQueues.delete(key);
857
+ }
858
+ }
859
+
860
+ async function withLogicalMutationQueues<T>(
861
+ keys: readonly string[],
862
+ callback: () => Promise<T>,
863
+ index = 0,
864
+ ): Promise<T> {
865
+ const key = keys[index];
866
+ if (!key) return callback();
867
+ return withLogicalMutationQueue(key, () => withLogicalMutationQueues(keys, callback, index + 1));
868
+ }
869
+
870
+ function normalizedAliasName(name: string): string {
871
+ return process.platform === "darwin" ? name.normalize("NFD") : name;
872
+ }
873
+
874
+ async function directoryIsCaseInsensitive(
875
+ directory: string,
876
+ cache: Map<string, Promise<boolean>>,
877
+ ): Promise<boolean> {
878
+ let cached = cache.get(directory);
879
+ if (!cached) {
880
+ cached = (async () => {
881
+ if (process.platform === "win32") return true;
882
+ let candidate = directory;
883
+ while (candidate !== parse(candidate).root) {
884
+ const name = basename(candidate);
885
+ const toggled = Array.from(name)
886
+ .map((character) =>
887
+ character.toLowerCase() === character
888
+ ? character.toUpperCase()
889
+ : character.toLowerCase(),
890
+ )
891
+ .join("");
892
+ if (toggled !== name) {
928
893
  try {
929
- await writeFile(operation.absolutePath, newContent, "utf8");
930
- } catch (error) {
931
- details.exact = false;
932
- throw new Error(
933
- `Failed to write file ${operation.absolutePath}: ${errorMessage(error)}`,
934
- );
894
+ const [original, alias] = await Promise.all([
895
+ lstat(candidate),
896
+ lstat(join(dirname(candidate), toggled)),
897
+ ]);
898
+ return original.dev === alias.dev && original.ino === alias.ino;
899
+ } catch {
900
+ candidate = dirname(candidate);
901
+ continue;
935
902
  }
936
- details.changes.push({
937
- kind: "update",
938
- path: operation.path,
939
- oldContent,
940
- newContent,
941
- ...diffDetails(oldContent, newContent),
942
- });
943
- details.modified.push(operation.path);
944
903
  }
904
+ candidate = dirname(candidate);
945
905
  }
946
- throwIfAborted(signal);
947
- onProgress?.(cloneApplyPatchDetails(details));
948
- }
949
- return details;
950
- } catch (error) {
951
- details.status = "failed";
952
- details.error = errorMessage(error);
953
- throw new ApplyPatchExecutionError(details.error, cloneApplyPatchDetails(details));
906
+ return false;
907
+ })();
908
+ cache.set(directory, cached);
954
909
  }
910
+ return cached;
955
911
  }
956
912
 
957
- export async function previewPatch(cwd: string, patch: string): Promise<ApplyPatchDetails> {
958
- const parsed = parsePatchDocument(patch);
959
- if (parsed.environmentId) {
960
- throw new ApplyPatchInputError(
961
- "apply_patch environment selection is unavailable for this turn",
962
- );
963
- }
964
- if (parsed.operations.length === 0) {
965
- throw new ApplyPatchInputError("patch rejected: empty patch");
966
- }
967
- const operations = resolveOperations(cwd, parsed.operations);
968
- await verifyOperations(operations);
969
- const details = emptyDetails();
970
- const changes: AppliedPatchChange[] = [];
971
- for (const operation of operations) {
972
- if (operation.kind === "add") {
973
- changes.push({
974
- kind: "add",
975
- path: operation.path,
976
- content: operation.content,
977
- ...diffDetails("", operation.content),
978
- });
979
- } else if (operation.kind === "delete") {
980
- const content = await readUtf8(
981
- operation.absolutePath,
982
- `Failed to read ${operation.absolutePath}`,
983
- );
984
- changes.push({
985
- kind: "delete",
986
- path: operation.path,
987
- content,
988
- ...diffDetails(content, ""),
989
- });
990
- } else {
991
- const oldContent = await readUtf8(
992
- operation.absolutePath,
993
- `Failed to read file to update ${operation.absolutePath}`,
994
- );
995
- const newContent = deriveNewContent(oldContent, operation.chunks, operation.absolutePath);
996
- changes.push({
997
- kind: "update",
998
- path: operation.path,
999
- ...(operation.moveTo ? { moveTo: operation.moveTo } : {}),
1000
- oldContent,
1001
- newContent,
1002
- ...diffDetails(oldContent, newContent),
1003
- });
1004
- }
1005
- }
1006
- details.changes = coalesceAppliedPatchChangesForRendering(changes, cwd);
1007
- for (const change of details.changes) {
1008
- if (change.kind === "add") details.added.push(change.path);
1009
- else if (change.kind === "delete") details.deleted.push(change.path);
1010
- else details.modified.push(change.moveTo ?? change.path);
1011
- }
1012
- return details;
913
+ async function namesAlias(
914
+ directory: string,
915
+ left: string,
916
+ right: string,
917
+ caseInsensitiveDirectories: Map<string, Promise<boolean>>,
918
+ ): Promise<boolean> {
919
+ const normalizedLeft = normalizedAliasName(left);
920
+ const normalizedRight = normalizedAliasName(right);
921
+ if (normalizedLeft === normalizedRight) return true;
922
+ return (
923
+ (await directoryIsCaseInsensitive(directory, caseInsensitiveDirectories)) &&
924
+ normalizedLeft.toLowerCase() === normalizedRight.toLowerCase()
925
+ );
1013
926
  }
1014
927
 
1015
- export async function applyPatch(
1016
- cwd: string,
1017
- patch: string,
1018
- signal?: AbortSignal,
1019
- hooks: ApplyPatchExecutionHooks = {},
1020
- ): Promise<ApplyPatchDetails> {
1021
- throwIfAborted(signal);
1022
- let parsed: ParsedPatch;
1023
- let operations: ResolvedOperation[];
928
+ async function logicalEntryQueueKey(
929
+ path: string,
930
+ caseInsensitiveDirectories: Map<string, Promise<boolean>>,
931
+ ): Promise<string> {
932
+ const parent = await realpathWithMissingTail(dirname(path));
933
+ const requestedName = basename(path);
934
+ let entryName = requestedName;
1024
935
  try {
1025
- parsed = parsePatchDocument(patch);
1026
- if (parsed.environmentId) {
1027
- throw new ApplyPatchInputError(
1028
- "apply_patch environment selection is unavailable for this turn",
1029
- );
1030
- }
1031
- if (parsed.operations.length === 0) {
1032
- throw new ApplyPatchInputError("patch rejected: empty patch");
936
+ const requestedMetadata = await lstat(path);
937
+ for (const name of await readdir(parent)) {
938
+ if (!(await namesAlias(parent, name, requestedName, caseInsensitiveDirectories))) {
939
+ continue;
940
+ }
941
+ const metadata = await lstat(join(parent, name));
942
+ if (metadata.dev === requestedMetadata.dev && metadata.ino === requestedMetadata.ino) {
943
+ entryName = name;
944
+ break;
945
+ }
1033
946
  }
1034
- operations = resolveOperations(cwd, parsed.operations);
1035
947
  } catch (error) {
1036
- if (error instanceof ApplyPatchInputError) throw error;
1037
- throw new ApplyPatchVerificationError(
1038
- `apply_patch verification failed: ${errorMessage(error)}`,
1039
- );
948
+ if (!isNotFound(error) && !hasErrorCode(error, "ENOTDIR")) throw error;
949
+ }
950
+ entryName = normalizedAliasName(entryName);
951
+ if (await directoryIsCaseInsensitive(parent, caseInsensitiveDirectories)) {
952
+ entryName = entryName.toLowerCase();
1040
953
  }
954
+ return `entry:${join(parent, entryName)}`;
955
+ }
1041
956
 
1042
- const queuePaths = [
1043
- ...new Set(
1044
- operations.flatMap((operation) => [
1045
- operation.absolutePath,
1046
- ...(operation.kind === "update" && operation.moveAbsolutePath
1047
- ? [operation.moveAbsolutePath]
1048
- : []),
1049
- ]),
1050
- ),
1051
- ].sort();
957
+ type MutationQueueTarget = {
958
+ path: string;
959
+ followSymlink: boolean;
960
+ };
1052
961
 
1053
- return withMutationQueues(queuePaths, async () => {
1054
- throwIfAborted(signal);
1055
- try {
1056
- await verifyOperations(operations);
1057
- } catch (error) {
1058
- throw new ApplyPatchVerificationError(
1059
- `apply_patch verification failed: ${errorMessage(error)}`,
962
+ function mutationQueueTargets(operations: readonly ResolvedOperation[]): MutationQueueTarget[] {
963
+ return operations.flatMap((operation) => {
964
+ if (operation.kind !== "update") {
965
+ return [{ path: operation.absolutePath, followSymlink: false }];
966
+ }
967
+ const targets: MutationQueueTarget[] = [
968
+ {
969
+ path: operation.absolutePath,
970
+ followSymlink: !chunksAreIdentity(operation.chunks),
971
+ },
972
+ ];
973
+ if (operation.moveAbsolutePath) {
974
+ targets.push({ path: operation.moveAbsolutePath, followSymlink: false });
975
+ }
976
+ return targets;
977
+ });
978
+ }
979
+
980
+ async function logicalMutationQueueKeys(
981
+ targets: readonly MutationQueueTarget[],
982
+ ): Promise<string[]> {
983
+ const caseInsensitiveDirectories = new Map<string, Promise<boolean>>();
984
+ const keys = new Set<string>();
985
+ for (const { path, followSymlink } of targets) {
986
+ keys.add(await logicalEntryQueueKey(path, caseInsensitiveDirectories));
987
+ try {
988
+ const entryMetadata = await lstat(path);
989
+ if (entryMetadata.isFile()) {
990
+ keys.add(`physical:${entryMetadata.dev}:${entryMetadata.ino}`);
991
+ } else if (entryMetadata.isSymbolicLink() && followSymlink) {
992
+ try {
993
+ const targetMetadata = await stat(path);
994
+ if (targetMetadata.isFile()) {
995
+ keys.add(`physical:${targetMetadata.dev}:${targetMetadata.ino}`);
996
+ }
997
+ } catch {
998
+ // The semantic planner reports inaccessible, dangling, or cyclic targets.
999
+ }
1000
+ }
1001
+ } catch (error) {
1002
+ if (!isNotFound(error) && !hasErrorCode(error, "ENOTDIR")) throw error;
1003
+ }
1004
+ }
1005
+ return [...keys].sort();
1006
+ }
1007
+
1008
+ async function symlinkEntryQueuePath(path: string): Promise<string> {
1009
+ const parent = await realpathWithMissingTail(dirname(path));
1010
+ return join(parent, ".apply-patch-entry-locks", normalizedAliasName(basename(path)));
1011
+ }
1012
+
1013
+ async function canonicalMutationQueuePaths(
1014
+ targets: readonly MutationQueueTarget[],
1015
+ ): Promise<string[]> {
1016
+ const canonicalPaths = await Promise.all(
1017
+ targets.map(async ({ path, followSymlink }) => {
1018
+ try {
1019
+ const metadata = await lstat(path);
1020
+ if (metadata.isSymbolicLink() && !followSymlink) {
1021
+ return symlinkEntryQueuePath(path);
1022
+ }
1023
+ } catch (error) {
1024
+ if (!isNotFound(error) && !hasErrorCode(error, "ENOTDIR")) throw error;
1025
+ }
1026
+ try {
1027
+ return await realpath(path);
1028
+ } catch (error) {
1029
+ if (isNotFound(error) || hasErrorCode(error, "ENOTDIR")) {
1030
+ return realpathWithMissingTail(path);
1031
+ }
1032
+ try {
1033
+ if ((await lstat(path)).isSymbolicLink()) {
1034
+ return symlinkEntryQueuePath(path);
1035
+ }
1036
+ } catch {}
1037
+ throw error;
1038
+ }
1039
+ }),
1040
+ );
1041
+ return [...new Set(canonicalPaths)].sort();
1042
+ }
1043
+
1044
+ async function realpathWithMissingTail(path: string): Promise<string> {
1045
+ const missingNames: string[] = [];
1046
+ let candidate = resolve(path);
1047
+ while (true) {
1048
+ try {
1049
+ return join(await realpath(candidate), ...missingNames.toReversed());
1050
+ } catch (error) {
1051
+ if (!isNotFound(error) && !hasErrorCode(error, "ENOTDIR")) throw error;
1052
+ }
1053
+ const parent = dirname(candidate);
1054
+ if (parent === candidate) return resolve(path);
1055
+ missingNames.push(basename(candidate));
1056
+ candidate = parent;
1057
+ }
1058
+ }
1059
+
1060
+ type EntryFingerprint = {
1061
+ device: number;
1062
+ inode: number;
1063
+ mode: number;
1064
+ linkCount: number;
1065
+ size: number;
1066
+ modifiedMs: number;
1067
+ };
1068
+
1069
+ type KnownContent = {
1070
+ bytes: Buffer;
1071
+ text?: string;
1072
+ };
1073
+
1074
+ type PlannedEntryMutation = {
1075
+ path: string;
1076
+ key: string;
1077
+ kind: "absent" | "regular" | "symlink";
1078
+ releasedFingerprint?: EntryFingerprint;
1079
+ };
1080
+
1081
+ type CommittedEntryMutation = Omit<PlannedEntryMutation, "kind"> & {
1082
+ expected: VirtualEntry;
1083
+ };
1084
+
1085
+ type ContentCell = {
1086
+ value?: KnownContent;
1087
+ planned: boolean;
1088
+ };
1089
+
1090
+ type PhysicalFileState = {
1091
+ id: string;
1092
+ linkCount: number;
1093
+ };
1094
+
1095
+ type VirtualEntry =
1096
+ | { kind: "absent" }
1097
+ | { kind: "directory"; fingerprint?: EntryFingerprint }
1098
+ | { kind: "unsupported"; entryType: string; fingerprint: EntryFingerprint }
1099
+ | {
1100
+ kind: "regular";
1101
+ id: string;
1102
+ entryPath: string;
1103
+ sourcePath?: string;
1104
+ fingerprint?: EntryFingerprint;
1105
+ content: ContentCell;
1106
+ physical?: PhysicalFileState;
1107
+ }
1108
+ | {
1109
+ kind: "symlink";
1110
+ id: string;
1111
+ entryPath: string;
1112
+ sourcePath?: string;
1113
+ fingerprint?: EntryFingerprint;
1114
+ target: string;
1115
+ targetPath: string;
1116
+ content: ContentCell;
1117
+ };
1118
+
1119
+ type ExistingFileEntry = Extract<VirtualEntry, { kind: "regular" | "symlink" }>;
1120
+ type ReplaceableFileEntry = Extract<VirtualEntry, { kind: "absent" | "regular" | "symlink" }>;
1121
+
1122
+ type ParentPlan = {
1123
+ createdPaths: string[];
1124
+ expectations: Array<{ path: string; kind: "absent" | "directory" | "directory-symlink" }>;
1125
+ };
1126
+
1127
+ type PlannedMutation = (
1128
+ | {
1129
+ kind: "add";
1130
+ operation: Extract<ResolvedOperation, { kind: "add" }>;
1131
+ expectedTarget: ReplaceableFileEntry;
1132
+ parents: ParentPlan;
1133
+ content: Buffer;
1134
+ replacementMode?: number;
1135
+ targetKey: string;
1136
+ entryMutations: PlannedEntryMutation[];
1137
+ change: Extract<AppliedPatchChange, { kind: "add" }>;
1138
+ }
1139
+ | {
1140
+ kind: "delete";
1141
+ operation: Extract<ResolvedOperation, { kind: "delete" }>;
1142
+ expectedTarget: ExistingFileEntry;
1143
+ targetKey: string;
1144
+ entryMutations: PlannedEntryMutation[];
1145
+ change: Extract<AppliedPatchChange, { kind: "delete" }>;
1146
+ }
1147
+ | {
1148
+ kind: "text-update";
1149
+ operation: Extract<ResolvedOperation, { kind: "update" }>;
1150
+ expectedSource: ExistingFileEntry;
1151
+ expectedDestination?: ReplaceableFileEntry;
1152
+ parents: ParentPlan;
1153
+ content: Buffer;
1154
+ replacementMode?: number;
1155
+ sourceKey: string;
1156
+ destinationKey?: string;
1157
+ sameEntryMove?: "rename" | "satisfied";
1158
+ entryMutations: PlannedEntryMutation[];
1159
+ change: Extract<AppliedPatchChange, { kind: "update" }>;
1160
+ provisionalChange?: Extract<AppliedPatchChange, { kind: "add" | "update" }>;
1161
+ }
1162
+ | {
1163
+ kind: "move";
1164
+ operation: Extract<ResolvedOperation, { kind: "update" }>;
1165
+ expectedSource: ExistingFileEntry;
1166
+ expectedDestination: ReplaceableFileEntry;
1167
+ parents: ParentPlan;
1168
+ sourceKey: string;
1169
+ destinationKey: string;
1170
+ moveStrategy: "rename" | "copy-unlink";
1171
+ entryMutations: PlannedEntryMutation[];
1172
+ change: Extract<AppliedPatchChange, { kind: "move" }>;
1173
+ }
1174
+ ) & { instructionIndex: number };
1175
+
1176
+ type SemanticPlan = {
1177
+ mutations: PlannedMutation[];
1178
+ exact: boolean;
1179
+ instructions: ApplyPatchInstructionDetails[];
1180
+ };
1181
+
1182
+ class SemanticPlanningError extends Error {
1183
+ readonly instructions: ApplyPatchInstructionDetails[];
1184
+ readonly failedInstruction: number;
1185
+ readonly matcher: FormatterMatchFailureDetails | undefined;
1186
+
1187
+ constructor(
1188
+ message: string,
1189
+ instructions: ApplyPatchInstructionDetails[],
1190
+ failedInstruction: number,
1191
+ matcher?: FormatterMatchFailureDetails,
1192
+ ) {
1193
+ super(message);
1194
+ this.instructions = instructions;
1195
+ this.failedInstruction = failedInstruction;
1196
+ this.matcher = matcher;
1197
+ }
1198
+ }
1199
+
1200
+ const ABSENT_ENTRY: VirtualEntry = { kind: "absent" };
1201
+
1202
+ function fingerprint(metadata: Stats): EntryFingerprint {
1203
+ return {
1204
+ device: metadata.dev,
1205
+ inode: metadata.ino,
1206
+ mode: metadata.mode,
1207
+ linkCount: metadata.nlink,
1208
+ size: metadata.size,
1209
+ modifiedMs: metadata.mtimeMs,
1210
+ };
1211
+ }
1212
+
1213
+ function sameFingerprint(left: EntryFingerprint, right: EntryFingerprint): boolean {
1214
+ return (
1215
+ left.device === right.device &&
1216
+ left.inode === right.inode &&
1217
+ left.mode === right.mode &&
1218
+ left.linkCount === right.linkCount &&
1219
+ left.size === right.size &&
1220
+ left.modifiedMs === right.modifiedMs
1221
+ );
1222
+ }
1223
+
1224
+ function sameFingerprintExceptLinkCount(left: EntryFingerprint, right: EntryFingerprint): boolean {
1225
+ return (
1226
+ left.device === right.device &&
1227
+ left.inode === right.inode &&
1228
+ left.mode === right.mode &&
1229
+ left.size === right.size &&
1230
+ left.modifiedMs === right.modifiedMs
1231
+ );
1232
+ }
1233
+
1234
+ function samePhysicalEntry(left: EntryFingerprint, right: EntryFingerprint): boolean {
1235
+ return left.device === right.device && left.inode === right.inode;
1236
+ }
1237
+
1238
+ function entryType(metadata: Stats): string {
1239
+ if (metadata.isDirectory()) return "directory";
1240
+ if (metadata.isSocket()) return "socket";
1241
+ if (metadata.isFIFO()) return "fifo";
1242
+ if (metadata.isCharacterDevice()) return "character device";
1243
+ if (metadata.isBlockDevice()) return "block device";
1244
+ return "special file";
1245
+ }
1246
+
1247
+ function chunksAreIdentity(chunks: readonly UpdateChunk[]): boolean {
1248
+ return chunks.every(
1249
+ (chunk) =>
1250
+ chunk.oldLines.length === chunk.newLines.length &&
1251
+ chunk.oldLines.every((line, index) => line === chunk.newLines[index]),
1252
+ );
1253
+ }
1254
+
1255
+ function buffersEqual(left: Buffer, right: Buffer): boolean {
1256
+ return left.length === right.length && left.equals(right);
1257
+ }
1258
+
1259
+ function pathIsRelated(left: string, right: string): boolean {
1260
+ const leftToRight = relative(left, right);
1261
+ const rightToLeft = relative(right, left);
1262
+ const isWithin = (value: string): boolean =>
1263
+ value !== "" && value !== ".." && !value.startsWith(`..${sep}`) && !isAbsolute(value);
1264
+ return leftToRight === "" || isWithin(leftToRight) || isWithin(rightToLeft);
1265
+ }
1266
+
1267
+ function updateHasSemanticMove(operation: Extract<ResolvedOperation, { kind: "update" }>): boolean {
1268
+ return (
1269
+ operation.moveAbsolutePath !== undefined &&
1270
+ operation.moveAbsolutePath !== operation.absolutePath
1271
+ );
1272
+ }
1273
+
1274
+ type DeadOperationProof = {
1275
+ dominatingInstructions: number[];
1276
+ };
1277
+
1278
+ function instructionReason(
1279
+ code: Exclude<ApplyPatchInstructionReasonCode, "move-already-fulfilled">,
1280
+ relatedInstructions: readonly number[] = [],
1281
+ ): ApplyPatchInstructionReason {
1282
+ switch (code) {
1283
+ case "empty-update":
1284
+ return { code, message: "The instruction contains no changes." };
1285
+ case "identity-update":
1286
+ return { code, message: "Old and replacement content are identical." };
1287
+ case "content-already-present":
1288
+ return { code, message: "The file already contains the requested content byte-for-byte." };
1289
+ case "update-result-unchanged":
1290
+ return { code, message: "Applying the update would not change the file." };
1291
+ case "path-already-absent":
1292
+ return { code, message: "Path already absent." };
1293
+ case "same-entry-move":
1294
+ return { code, message: "Source and destination identify the same entry." };
1295
+ case "dead-dominated": {
1296
+ const instructions = [...new Set(relatedInstructions)].toSorted(
1297
+ (left, right) => left - right,
1298
+ );
1299
+ const noun = instructions.length === 1 ? "instruction" : "instructions";
1300
+ return {
1301
+ code,
1302
+ message: `${noun[0]!.toUpperCase()}${noun.slice(1)} ${instructions.join(", ")} ${instructions.length === 1 ? "determines" : "determine"} the final filesystem state before another instruction reads it.`,
1303
+ dominatingInstructions: instructions,
1304
+ relatedInstructions: instructions,
1305
+ };
1306
+ }
1307
+ }
1308
+ }
1309
+
1310
+ function moveAlreadyFulfilledReason(instruction: number): ApplyPatchInstructionReason {
1311
+ return {
1312
+ code: "move-already-fulfilled",
1313
+ message: `Instruction ${instruction} already moved this entry.`,
1314
+ relatedInstructions: [instruction],
1315
+ };
1316
+ }
1317
+
1318
+ function instructionForOperation(
1319
+ operation: ResolvedOperation,
1320
+ index: number,
1321
+ ): ApplyPatchInstructionDetails {
1322
+ if (operation.kind === "update") {
1323
+ return {
1324
+ index: index + 1,
1325
+ kind: operation.moveTo && chunksAreIdentity(operation.chunks) ? "move" : "update",
1326
+ path: operation.path,
1327
+ ...(operation.moveTo ? { moveTo: operation.moveTo } : {}),
1328
+ status: "not-run",
1329
+ };
1330
+ }
1331
+ return {
1332
+ index: index + 1,
1333
+ kind: operation.kind,
1334
+ path: operation.path,
1335
+ status: "not-run",
1336
+ };
1337
+ }
1338
+
1339
+ class SemanticPlanner {
1340
+ private readonly states = new Map<string, VirtualEntry>();
1341
+ private readonly physicalFiles = new Map<
1342
+ string,
1343
+ { content: ContentCell; physical: PhysicalFileState }
1344
+ >();
1345
+ private readonly mutations: PlannedMutation[] = [];
1346
+ private readonly fulfilledMoves = new Map<
1347
+ string,
1348
+ { destinationKey: string; destinationEntryId: string; instruction: number }
1349
+ >();
1350
+ private nextEntryId = 0;
1351
+ private nextPhysicalId = 0;
1352
+ private exact = true;
1353
+ private readonly operations: readonly ResolvedOperation[];
1354
+ private readonly instructions: ApplyPatchInstructionDetails[];
1355
+ private readonly signal: AbortSignal | undefined;
1356
+ private readonly selectMoveStrategy: ApplyPatchExecutionHooks["selectMoveStrategy"] | undefined;
1357
+ private readonly pathKeys = new Map<string, string>();
1358
+ private readonly caseInsensitiveDirectories = new Map<string, Promise<boolean>>();
1359
+
1360
+ constructor(
1361
+ operations: readonly ResolvedOperation[],
1362
+ signal?: AbortSignal,
1363
+ selectMoveStrategy?: ApplyPatchExecutionHooks["selectMoveStrategy"],
1364
+ ) {
1365
+ this.operations = operations;
1366
+ this.instructions = operations.map(instructionForOperation);
1367
+ this.signal = signal;
1368
+ this.selectMoveStrategy = selectMoveStrategy;
1369
+ }
1370
+
1371
+ async plan(): Promise<SemanticPlan> {
1372
+ for (const [index, operation] of this.operations.entries()) {
1373
+ const instruction = this.instructions[index]!;
1374
+ const mutationCount = this.mutations.length;
1375
+ try {
1376
+ throwIfAborted(this.signal);
1377
+ if (operation.kind === "add") {
1378
+ await this.planAdd(operation, index);
1379
+ } else if (operation.kind === "delete") {
1380
+ await this.planDelete(operation, index);
1381
+ } else {
1382
+ await this.planUpdate(operation, index);
1383
+ }
1384
+ if (this.mutations.length > mutationCount) {
1385
+ instruction.status = "planned";
1386
+ } else {
1387
+ if (!instruction.reason) {
1388
+ throw new Error(`Instruction ${instruction.index} has no recorded no-op reason`);
1389
+ }
1390
+ instruction.status = "no-op";
1391
+ }
1392
+ } catch (error) {
1393
+ if (operation.kind === "update") {
1394
+ const deadProof = !updateHasSemanticMove(operation)
1395
+ ? await this.deadUpdateProof(index, operation.absolutePath)
1396
+ : await this.deadMoveProof(index, operation.absolutePath, operation.moveAbsolutePath!);
1397
+ if (deadProof) {
1398
+ instruction.status = "dead";
1399
+ instruction.reason = instructionReason(
1400
+ "dead-dominated",
1401
+ deadProof.dominatingInstructions,
1402
+ );
1403
+ continue;
1404
+ }
1405
+ }
1406
+ for (const planned of this.instructions) {
1407
+ if (planned.status === "planned") planned.status = "not-run";
1408
+ }
1409
+ instruction.status = "failed";
1410
+ instruction.error = errorMessage(error);
1411
+ if (error instanceof FormatterMatchError) instruction.matcher = error.details;
1412
+ throw new SemanticPlanningError(
1413
+ instruction.error,
1414
+ this.instructions.map((item) => ({ ...item })),
1415
+ instruction.index,
1416
+ error instanceof FormatterMatchError ? error.details : undefined,
1417
+ );
1418
+ }
1419
+ }
1420
+ return {
1421
+ mutations: this.mutations,
1422
+ exact: this.exact,
1423
+ instructions: this.instructions.map((instruction) => ({ ...instruction })),
1424
+ };
1425
+ }
1426
+
1427
+ private newEntryId(): string {
1428
+ this.nextEntryId += 1;
1429
+ return `planned-entry-${this.nextEntryId}`;
1430
+ }
1431
+
1432
+ private newPhysicalFile(linkCount = 1): PhysicalFileState {
1433
+ this.nextPhysicalId += 1;
1434
+ return {
1435
+ id: `planned-physical-${this.nextPhysicalId}`,
1436
+ linkCount,
1437
+ };
1438
+ }
1439
+
1440
+ private releasePhysicalLink(entry: VirtualEntry): void {
1441
+ if (entry.kind === "regular" && entry.physical && entry.physical.linkCount > 0) {
1442
+ entry.physical.linkCount -= 1;
1443
+ }
1444
+ }
1445
+
1446
+ private markNoOp(
1447
+ instructionIndex: number,
1448
+ code: Exclude<ApplyPatchInstructionReasonCode, "dead-dominated" | "move-already-fulfilled">,
1449
+ ): void {
1450
+ this.instructions[instructionIndex]!.reason = instructionReason(code);
1451
+ }
1452
+
1453
+ private async directoryIsCaseInsensitive(directory: string): Promise<boolean> {
1454
+ return directoryIsCaseInsensitive(directory, this.caseInsensitiveDirectories);
1455
+ }
1456
+
1457
+ private async namesAlias(directory: string, left: string, right: string): Promise<boolean> {
1458
+ return namesAlias(directory, left, right, this.caseInsensitiveDirectories);
1459
+ }
1460
+
1461
+ private async pathKey(path: string): Promise<string> {
1462
+ const known = this.pathKeys.get(path);
1463
+ if (known) return known;
1464
+ const parent = await realpathWithMissingTail(dirname(path));
1465
+ const requestedName = basename(path);
1466
+ let actualName = requestedName;
1467
+ try {
1468
+ const requestedMetadata = await lstat(path);
1469
+ const names = await readdir(parent);
1470
+ const exactName = names.find((name) => name === requestedName);
1471
+ if (exactName) {
1472
+ actualName = exactName;
1473
+ } else {
1474
+ for (const name of names) {
1475
+ const metadata = await lstat(join(parent, name));
1476
+ if (
1477
+ metadata.dev === requestedMetadata.dev &&
1478
+ metadata.ino === requestedMetadata.ino &&
1479
+ (await this.namesAlias(parent, name, requestedName))
1480
+ ) {
1481
+ actualName = name;
1482
+ break;
1483
+ }
1484
+ }
1485
+ }
1486
+ } catch {
1487
+ for (const [knownPath, knownKey] of this.pathKeys) {
1488
+ const knownParent = await realpathWithMissingTail(dirname(knownPath));
1489
+ if (
1490
+ knownParent === parent &&
1491
+ (await this.namesAlias(parent, basename(knownPath), requestedName))
1492
+ ) {
1493
+ this.pathKeys.set(path, knownKey);
1494
+ return knownKey;
1495
+ }
1496
+ }
1497
+ }
1498
+ const key = join(parent, actualName);
1499
+ this.pathKeys.set(path, key);
1500
+ return key;
1501
+ }
1502
+
1503
+ private async stateAt(path: string): Promise<VirtualEntry> {
1504
+ const key = await this.pathKey(path);
1505
+ const known = this.states.get(key);
1506
+ if (known) return known;
1507
+
1508
+ let result: VirtualEntry;
1509
+ try {
1510
+ const metadata = await lstat(path);
1511
+ const entryFingerprint = fingerprint(metadata);
1512
+ if (metadata.isFile()) {
1513
+ const physicalKey = `${metadata.dev}:${metadata.ino}`;
1514
+ let file = this.physicalFiles.get(physicalKey);
1515
+ if (!file) {
1516
+ file = {
1517
+ content: { planned: false },
1518
+ physical: this.newPhysicalFile(metadata.nlink),
1519
+ };
1520
+ this.physicalFiles.set(physicalKey, file);
1521
+ }
1522
+ result = {
1523
+ kind: "regular",
1524
+ id: this.newEntryId(),
1525
+ entryPath: path,
1526
+ sourcePath: path,
1527
+ fingerprint: entryFingerprint,
1528
+ content: file.content,
1529
+ physical: file.physical,
1530
+ };
1531
+ } else if (metadata.isSymbolicLink()) {
1532
+ const target = await readlink(path);
1533
+ result = {
1534
+ kind: "symlink",
1535
+ id: this.newEntryId(),
1536
+ entryPath: path,
1537
+ sourcePath: path,
1538
+ fingerprint: entryFingerprint,
1539
+ target,
1540
+ targetPath: resolve(dirname(path), target),
1541
+ content: { planned: false },
1542
+ };
1543
+ } else if (metadata.isDirectory()) {
1544
+ result = { kind: "directory", fingerprint: entryFingerprint };
1545
+ } else {
1546
+ result = {
1547
+ kind: "unsupported",
1548
+ entryType: entryType(metadata),
1549
+ fingerprint: entryFingerprint,
1550
+ };
1551
+ }
1552
+ } catch (error) {
1553
+ if (!isNotFound(error)) {
1554
+ throw new Error(`Failed to inspect ${path}: ${errorMessage(error)}`);
1555
+ }
1556
+ result = ABSENT_ENTRY;
1557
+ }
1558
+ this.states.set(key, result);
1559
+ return result;
1560
+ }
1561
+
1562
+ private async setState(path: string, entry: VirtualEntry): Promise<void> {
1563
+ this.states.set(await this.pathKey(path), entry);
1564
+ }
1565
+
1566
+ private async sameEntryMoveEffect(
1567
+ sourcePath: string,
1568
+ destinationPath: string,
1569
+ ): Promise<"rename" | "satisfied"> {
1570
+ const [sourceParent, destinationParent] = await Promise.all([
1571
+ realpath(dirname(sourcePath)).catch(() => dirname(sourcePath)),
1572
+ realpath(dirname(destinationPath)).catch(() => dirname(destinationPath)),
1573
+ ]);
1574
+ return sourceParent === destinationParent &&
1575
+ dirname(sourcePath) === dirname(destinationPath) &&
1576
+ basename(sourcePath) !== basename(destinationPath)
1577
+ ? "rename"
1578
+ : "satisfied";
1579
+ }
1580
+
1581
+ private virtualSpellingSatisfied(entryPath: string, requestedPath: string): boolean {
1582
+ return basename(entryPath) === basename(requestedPath);
1583
+ }
1584
+
1585
+ private snapshot<T extends VirtualEntry>(entry: T): T {
1586
+ if (entry.kind !== "regular" && entry.kind !== "symlink") return { ...entry };
1587
+ return {
1588
+ ...entry,
1589
+ content: {
1590
+ ...(entry.content.value ? { value: entry.content.value } : {}),
1591
+ planned: entry.content.planned,
1592
+ },
1593
+ };
1594
+ }
1595
+
1596
+ private async readBytes(
1597
+ entry: Extract<VirtualEntry, { kind: "regular" | "symlink" }>,
1598
+ path: string,
1599
+ visitedSymlinks = new Set<string>(),
1600
+ ): Promise<Buffer> {
1601
+ if (entry.kind === "regular" && entry.content.value) return entry.content.value.bytes;
1602
+ try {
1603
+ if (entry.kind === "symlink") {
1604
+ const key = await this.pathKey(entry.entryPath);
1605
+ if (visitedSymlinks.has(key)) throw new Error("symlink cycle");
1606
+ visitedSymlinks.add(key);
1607
+ const target = await this.stateAt(entry.targetPath);
1608
+ if (target.kind !== "regular" && target.kind !== "symlink") {
1609
+ throw new Error(
1610
+ target.kind === "absent"
1611
+ ? "symlink target does not exist"
1612
+ : target.kind === "directory"
1613
+ ? "symlink target is a directory"
1614
+ : `symlink target is a ${target.entryType}`,
1615
+ );
1616
+ }
1617
+ const bytes = await this.readBytes(target, entry.targetPath, visitedSymlinks);
1618
+ entry.content = target.content;
1619
+ return bytes;
1620
+ }
1621
+ const bytes = await readFile(entry.sourcePath ?? path);
1622
+ entry.content.value = { bytes };
1623
+ return bytes;
1624
+ } catch (error) {
1625
+ throw new Error(`Failed to read file to update ${path}: ${errorMessage(error)}`);
1626
+ }
1627
+ }
1628
+
1629
+ private async readText(
1630
+ entry: Extract<VirtualEntry, { kind: "regular" | "symlink" }>,
1631
+ path: string,
1632
+ ): Promise<string> {
1633
+ if (entry.content.value?.text !== undefined) return entry.content.value.text;
1634
+ const bytes = await this.readBytes(entry, path);
1635
+ let text: string;
1636
+ try {
1637
+ text = UTF8_DECODER.decode(bytes);
1638
+ } catch (error) {
1639
+ throw new Error(`Failed to read file to update ${path}: ${errorMessage(error)}`);
1640
+ }
1641
+ entry.content.value = { bytes, text };
1642
+ return text;
1643
+ }
1644
+
1645
+ private async optionalText(
1646
+ entry: Extract<VirtualEntry, { kind: "regular" | "symlink" }>,
1647
+ path: string,
1648
+ ): Promise<string | undefined> {
1649
+ if (entry.content.value?.text !== undefined) return entry.content.value.text;
1650
+ try {
1651
+ const bytes = await this.readBytes(entry, path);
1652
+ const text = UTF8_DECODER.decode(bytes);
1653
+ entry.content.value = { bytes, text };
1654
+ return text;
1655
+ } catch {
1656
+ return undefined;
1657
+ }
1658
+ }
1659
+
1660
+ private async ensureParents(targetPath: string): Promise<ParentPlan> {
1661
+ const missing: string[] = [];
1662
+ const expectations: ParentPlan["expectations"] = [];
1663
+ const root = parse(targetPath).root;
1664
+ let parent = dirname(targetPath);
1665
+ while (parent !== root) {
1666
+ const entry = await this.stateAt(parent);
1667
+ if (entry.kind === "absent") {
1668
+ expectations.push({ path: parent, kind: "absent" });
1669
+ missing.push(parent);
1670
+ parent = dirname(parent);
1671
+ continue;
1672
+ }
1673
+ if (entry.kind === "directory") {
1674
+ expectations.push({ path: parent, kind: "directory" });
1675
+ break;
1676
+ }
1677
+ if (entry.kind === "symlink") {
1678
+ try {
1679
+ const metadata = await stat(entry.sourcePath ?? parent);
1680
+ if (metadata.isDirectory()) {
1681
+ expectations.push({ path: parent, kind: "directory-symlink" });
1682
+ break;
1683
+ }
1684
+ } catch {}
1685
+ }
1686
+ throw new Error(`Cannot create ${targetPath}: parent path ${parent} is not a directory`);
1687
+ }
1688
+
1689
+ const created = missing.toReversed();
1690
+ for (const path of created) await this.setState(path, { kind: "directory" });
1691
+ return { createdPaths: created, expectations };
1692
+ }
1693
+
1694
+ private async entryFilesystemDevice(path: string): Promise<number> {
1695
+ const root = parse(path).root;
1696
+ let parent = dirname(path);
1697
+ while (true) {
1698
+ const entry = await this.stateAt(parent);
1699
+ if (entry.kind === "directory" && entry.fingerprint) {
1700
+ return entry.fingerprint.device;
1701
+ }
1702
+ if (entry.kind === "symlink") {
1703
+ try {
1704
+ const metadata = await stat(entry.sourcePath ?? parent);
1705
+ if (metadata.isDirectory()) return metadata.dev;
1706
+ } catch {}
1707
+ }
1708
+ if (parent === root) {
1709
+ throw new Error(`Cannot determine filesystem for ${path}`);
1710
+ }
1711
+ parent = dirname(parent);
1712
+ }
1713
+ }
1714
+
1715
+ private operationRelatedPaths(operation: ResolvedOperation): string[] {
1716
+ if (operation.kind !== "update" || !operation.moveAbsolutePath) {
1717
+ return [operation.absolutePath];
1718
+ }
1719
+ return [operation.absolutePath, operation.moveAbsolutePath];
1720
+ }
1721
+
1722
+ private async resolvedTextTarget(
1723
+ path: string,
1724
+ ): Promise<{ path: string; entry: VirtualEntry } | undefined> {
1725
+ let targetPath = path;
1726
+ let target = await this.stateAt(targetPath);
1727
+ const visited = new Set<string>();
1728
+ while (target.kind === "symlink") {
1729
+ const key = await this.pathKey(target.entryPath);
1730
+ if (visited.has(key)) return undefined;
1731
+ visited.add(key);
1732
+ targetPath = target.targetPath;
1733
+ target = await this.stateAt(targetPath);
1734
+ }
1735
+ return { path: targetPath, entry: target };
1736
+ }
1737
+
1738
+ private async operationObservesPhysicalEntry(
1739
+ operation: ResolvedOperation,
1740
+ affected: Extract<VirtualEntry, { kind: "regular" }>,
1741
+ ): Promise<boolean> {
1742
+ const sameEntry = (entry: VirtualEntry | undefined): boolean => {
1743
+ if (entry?.kind !== "regular") return false;
1744
+ if (affected.physical && entry.physical) {
1745
+ return affected.physical.id === entry.physical.id;
1746
+ }
1747
+ return (
1748
+ affected.fingerprint !== undefined &&
1749
+ entry.fingerprint !== undefined &&
1750
+ samePhysicalEntry(affected.fingerprint, entry.fingerprint)
1751
+ );
1752
+ };
1753
+ if (operation.kind === "update" && !chunksAreIdentity(operation.chunks)) {
1754
+ const target = await this.resolvedTextTarget(operation.absolutePath);
1755
+ return sameEntry(target?.entry);
1756
+ }
1757
+ if (operation.kind === "update" && chunksAreIdentity(operation.chunks)) {
1758
+ if (!updateHasSemanticMove(operation)) return false;
1759
+ const source = await this.stateAt(operation.absolutePath);
1760
+ const destination = await this.stateAt(operation.moveAbsolutePath!);
1761
+ return [source, destination].some(sameEntry);
1762
+ }
1763
+ return false;
1764
+ }
1765
+
1766
+ private async deadUpdateProof(
1767
+ index: number,
1768
+ targetPath: string,
1769
+ ): Promise<DeadOperationProof | undefined> {
1770
+ const target = await this.resolvedTextTarget(targetPath);
1771
+ if (!target) return undefined;
1772
+
1773
+ const targetKey = await this.pathKey(targetPath);
1774
+ if (target.entry.kind === "absent") {
1775
+ for (let futureIndex = index + 1; futureIndex < this.operations.length; futureIndex += 1) {
1776
+ const operation = this.operations[futureIndex]!;
1777
+ if (
1778
+ (operation.kind === "add" || operation.kind === "delete") &&
1779
+ (await this.pathKey(operation.absolutePath)) === targetKey
1780
+ ) {
1781
+ return { dominatingInstructions: [futureIndex + 1] };
1782
+ }
1783
+ if (
1784
+ operation.kind === "update" &&
1785
+ !updateHasSemanticMove(operation) &&
1786
+ chunksAreIdentity(operation.chunks)
1787
+ ) {
1788
+ continue;
1789
+ }
1790
+ if (
1791
+ (
1792
+ await Promise.all(
1793
+ this.operationRelatedPaths(operation).map(async (path) => {
1794
+ return (await this.pathKey(path)) === targetKey || pathIsRelated(path, targetPath);
1795
+ }),
1796
+ )
1797
+ ).some(Boolean)
1798
+ ) {
1799
+ return undefined;
1800
+ }
1801
+ }
1802
+ return undefined;
1803
+ }
1804
+
1805
+ if (target.entry.kind !== "regular") return undefined;
1806
+ const affectedFingerprint = target.entry.fingerprint;
1807
+ const affectedPhysical = target.entry.physical;
1808
+ if (!affectedFingerprint && !affectedPhysical) return undefined;
1809
+ const effectiveLinkCount = affectedPhysical?.linkCount ?? affectedFingerprint!.linkCount;
1810
+ const affectedKey = await this.pathKey(target.path);
1811
+ const removedEntryInstructions = new Map<string, number>();
1812
+ const samePhysicalFile = (
1813
+ entry: VirtualEntry,
1814
+ ): entry is Extract<VirtualEntry, { kind: "regular" }> => {
1815
+ if (entry.kind !== "regular") return false;
1816
+ if (affectedPhysical && entry.physical && affectedPhysical.id === entry.physical.id) {
1817
+ return true;
1818
+ }
1819
+ return (
1820
+ affectedFingerprint !== undefined &&
1821
+ entry.fingerprint !== undefined &&
1822
+ samePhysicalEntry(entry.fingerprint, affectedFingerprint)
1823
+ );
1824
+ };
1825
+ type ProofEntryState = "absent" | "affected" | "other";
1826
+ const proofEntryStates = new Map<string, ProofEntryState>();
1827
+ const proofEntryAt = async (
1828
+ path: string,
1829
+ ): Promise<{
1830
+ key: string;
1831
+ state: ProofEntryState;
1832
+ entry?: Extract<VirtualEntry, { kind: "regular" }>;
1833
+ }> => {
1834
+ const key = await this.pathKey(path);
1835
+ const known = proofEntryStates.get(key);
1836
+ if (known) return { key, state: known };
1837
+ const entry = await this.stateAt(path);
1838
+ if (samePhysicalFile(entry)) return { key, state: "affected", entry };
1839
+ return { key, state: entry.kind === "absent" ? "absent" : "other" };
1840
+ };
1841
+ const completedProof = (): DeadOperationProof | undefined => {
1842
+ return removedEntryInstructions.size >= effectiveLinkCount
1843
+ ? { dominatingInstructions: [...removedEntryInstructions.values()] }
1844
+ : undefined;
1845
+ };
1846
+ for (let futureIndex = index + 1; futureIndex < this.operations.length; futureIndex += 1) {
1847
+ const operation = this.operations[futureIndex]!;
1848
+ if (
1849
+ operation.kind === "update" &&
1850
+ !updateHasSemanticMove(operation) &&
1851
+ chunksAreIdentity(operation.chunks)
1852
+ ) {
1853
+ continue;
1854
+ }
1855
+ if (operation.kind === "delete") {
1856
+ const deleted = await proofEntryAt(operation.absolutePath);
1857
+ if (deleted.state === "affected") {
1858
+ removedEntryInstructions.set(deleted.key, futureIndex + 1);
1859
+ const proof = completedProof();
1860
+ if (proof) return proof;
1861
+ }
1862
+ proofEntryStates.set(deleted.key, "absent");
1863
+ continue;
1864
+ }
1865
+ if (operation.kind === "add") {
1866
+ const replaced = await proofEntryAt(operation.absolutePath);
1867
+ if (replaced.state === "affected") {
1868
+ const entry = replaced.entry!;
1869
+ const addIsNoOp =
1870
+ buffersEqual(
1871
+ await this.readBytes(entry, operation.absolutePath),
1872
+ Buffer.from(operation.content, "utf8"),
1873
+ ) && this.virtualSpellingSatisfied(entry.entryPath, operation.absolutePath);
1874
+ if (addIsNoOp) {
1875
+ // Whether this add replaces the entry would depend on the unknown update.
1876
+ return undefined;
1877
+ }
1878
+ removedEntryInstructions.set(replaced.key, futureIndex + 1);
1879
+ }
1880
+ proofEntryStates.set(replaced.key, "other");
1881
+ const proof = completedProof();
1882
+ if (proof) return proof;
1883
+ continue;
1884
+ }
1885
+ if (await this.operationObservesPhysicalEntry(operation, target.entry)) {
1886
+ return undefined;
1887
+ }
1888
+ if (
1889
+ (
1890
+ await Promise.all(
1891
+ this.operationRelatedPaths(operation).map(async (path) => {
1892
+ return (await this.pathKey(path)) === affectedKey || pathIsRelated(path, target.path);
1893
+ }),
1894
+ )
1895
+ ).some(Boolean)
1896
+ ) {
1897
+ return undefined;
1898
+ }
1899
+ }
1900
+ return undefined;
1901
+ }
1902
+
1903
+ private async deadMoveProof(
1904
+ index: number,
1905
+ sourcePath: string,
1906
+ destinationPath: string,
1907
+ ): Promise<DeadOperationProof | undefined> {
1908
+ const source = await this.stateAt(sourcePath);
1909
+ if (source.kind !== "absent" && source.kind !== "regular" && source.kind !== "symlink") {
1910
+ return undefined;
1911
+ }
1912
+ const sourceKey = await this.pathKey(sourcePath);
1913
+ const destinationKey = await this.pathKey(destinationPath);
1914
+ let sourceDominated = source.kind === "absent";
1915
+ let destinationDominated = false;
1916
+ let destinationParentsReproduced = false;
1917
+ const dominatingInstructions = new Set<number>();
1918
+ const defaultFileMode = 0o666 & ~process.umask();
1919
+ const materializedMode =
1920
+ source.kind === "regular" && source.fingerprint
1921
+ ? source.fingerprint.mode & 0o7777
1922
+ : defaultFileMode;
1923
+
1924
+ const destinationParent = await this.stateAt(dirname(destinationPath));
1925
+ const destination = await this.stateAt(destinationPath);
1926
+ const addResultMode = (entry: VirtualEntry): number | undefined => {
1927
+ if (entry.kind === "absent" || entry.kind === "symlink") return defaultFileMode;
1928
+ if (entry.kind === "regular" && entry.fingerprint) {
1929
+ return entry.fingerprint.mode & 0o7777;
1930
+ }
1931
+ return undefined;
1932
+ };
1933
+ const addDominates = async (
1934
+ entry: VirtualEntry,
1935
+ operation: Extract<ResolvedOperation, { kind: "add" }>,
1936
+ expectedMode: number,
1937
+ ): Promise<boolean> => {
1938
+ if (entry.kind === "regular") {
1939
+ if (
1940
+ buffersEqual(
1941
+ await this.readBytes(entry, operation.absolutePath),
1942
+ Buffer.from(operation.content, "utf8"),
1943
+ ) &&
1944
+ this.virtualSpellingSatisfied(entry.entryPath, operation.absolutePath)
1945
+ ) {
1946
+ return false;
1947
+ }
1948
+ }
1949
+ return addResultMode(entry) === expectedMode;
1950
+ };
1951
+ if (destinationParent.kind === "directory") {
1952
+ destinationParentsReproduced = true;
1953
+ } else if (destinationParent.kind === "symlink") {
1954
+ try {
1955
+ destinationParentsReproduced = (await stat(destinationParent.entryPath)).isDirectory();
1956
+ } catch {
1957
+ return undefined;
1958
+ }
1959
+ }
1960
+
1961
+ for (let futureIndex = index + 1; futureIndex < this.operations.length; futureIndex += 1) {
1962
+ const operation = this.operations[futureIndex]!;
1963
+ const instructionNumber = futureIndex + 1;
1964
+ const operationPaths = this.operationRelatedPaths(operation);
1965
+ const operationKeys = await Promise.all(operationPaths.map((path) => this.pathKey(path)));
1966
+ const targetKey = operationKeys[0]!;
1967
+ if (operation.kind === "add" || operation.kind === "delete") {
1968
+ if (
1969
+ targetKey === sourceKey &&
1970
+ (operation.kind === "delete" || (await addDominates(source, operation, defaultFileMode)))
1971
+ ) {
1972
+ sourceDominated = true;
1973
+ dominatingInstructions.add(instructionNumber);
1974
+ }
1975
+ if (targetKey === destinationKey) {
1976
+ if (
1977
+ operation.kind === "delete" ||
1978
+ (await addDominates(destination, operation, materializedMode))
1979
+ ) {
1980
+ destinationDominated = true;
1981
+ dominatingInstructions.add(instructionNumber);
1982
+ }
1983
+ if (operation.kind === "add") {
1984
+ destinationParentsReproduced = true;
1985
+ dominatingInstructions.add(instructionNumber);
1986
+ }
1987
+ }
1988
+ if (sourceDominated && destinationDominated && destinationParentsReproduced) {
1989
+ return { dominatingInstructions: [...dominatingInstructions] };
1990
+ }
1991
+ continue;
1992
+ }
1993
+ if (
1994
+ operation.kind === "update" &&
1995
+ !updateHasSemanticMove(operation) &&
1996
+ chunksAreIdentity(operation.chunks)
1997
+ ) {
1998
+ continue;
1999
+ }
2000
+ if (
2001
+ operationPaths.some((path, pathIndex) => {
2002
+ const key = operationKeys[pathIndex];
2003
+ return (
2004
+ (!sourceDominated && (key === sourceKey || pathIsRelated(path, sourcePath))) ||
2005
+ (!destinationDominated &&
2006
+ (key === destinationKey || pathIsRelated(path, destinationPath)))
2007
+ );
2008
+ })
2009
+ ) {
2010
+ return undefined;
2011
+ }
2012
+ }
2013
+ return sourceDominated && destinationDominated && destinationParentsReproduced
2014
+ ? { dominatingInstructions: [...dominatingInstructions] }
2015
+ : undefined;
2016
+ }
2017
+
2018
+ private async planAdd(
2019
+ operation: Extract<ResolvedOperation, { kind: "add" }>,
2020
+ instructionIndex: number,
2021
+ ): Promise<void> {
2022
+ const target = await this.stateAt(operation.absolutePath);
2023
+ if (target.kind === "directory" || target.kind === "unsupported") {
2024
+ throw new Error(
2025
+ `Cannot add ${operation.absolutePath}: path is ${target.kind === "directory" ? "a directory" : `a ${target.entryType}`}`,
2026
+ );
2027
+ }
2028
+ const content = Buffer.from(operation.content, "utf8");
2029
+ if (target.kind === "regular") {
2030
+ try {
2031
+ if (
2032
+ buffersEqual(await this.readBytes(target, operation.absolutePath), content) &&
2033
+ (await requestedSpellingExists(operation.absolutePath))
2034
+ ) {
2035
+ this.markNoOp(instructionIndex, "content-already-present");
2036
+ return;
2037
+ }
2038
+ } catch {
2039
+ this.exact = false;
2040
+ }
2041
+ }
2042
+
2043
+ const parents =
2044
+ target.kind === "absent"
2045
+ ? await this.ensureParents(operation.absolutePath)
2046
+ : { createdPaths: [], expectations: [] };
2047
+ const overwrittenContent =
2048
+ target.kind === "regular" || target.kind === "symlink"
2049
+ ? await this.optionalText(target, operation.absolutePath)
2050
+ : undefined;
2051
+ const expectedTarget = this.snapshot(target);
2052
+ const change: Extract<AppliedPatchChange, { kind: "add" }> = {
2053
+ kind: "add",
2054
+ path: operation.path,
2055
+ content: operation.content,
2056
+ ...(overwrittenContent !== undefined ? { overwrittenContent } : {}),
2057
+ ...diffDetails("", operation.content),
2058
+ };
2059
+ const targetKey = await this.pathKey(operation.absolutePath);
2060
+ this.mutations.push({
2061
+ instructionIndex,
2062
+ kind: "add",
2063
+ operation,
2064
+ expectedTarget,
2065
+ parents,
2066
+ content,
2067
+ ...(target.kind === "regular" && target.fingerprint
2068
+ ? { replacementMode: target.fingerprint.mode }
2069
+ : {}),
2070
+ targetKey,
2071
+ entryMutations: [
2072
+ {
2073
+ path: operation.absolutePath,
2074
+ key: targetKey,
2075
+ kind: "regular",
2076
+ ...((target.kind === "regular" || target.kind === "symlink") && target.fingerprint
2077
+ ? { releasedFingerprint: target.fingerprint }
2078
+ : {}),
2079
+ },
2080
+ ],
2081
+ change,
2082
+ });
2083
+ this.releasePhysicalLink(target);
2084
+ await this.setState(operation.absolutePath, {
2085
+ kind: "regular",
2086
+ id: this.newEntryId(),
2087
+ entryPath: operation.absolutePath,
2088
+ physical: this.newPhysicalFile(),
2089
+ content: {
2090
+ value: { bytes: content, text: operation.content },
2091
+ planned: true,
2092
+ },
2093
+ });
2094
+ }
2095
+
2096
+ private async planDelete(
2097
+ operation: Extract<ResolvedOperation, { kind: "delete" }>,
2098
+ index: number,
2099
+ ): Promise<void> {
2100
+ const target = await this.stateAt(operation.absolutePath);
2101
+ if (target.kind === "absent") {
2102
+ this.markNoOp(index, "path-already-absent");
2103
+ return;
2104
+ }
2105
+ if (target.kind === "directory" || target.kind === "unsupported") {
2106
+ throw new Error(
2107
+ `Cannot delete ${operation.absolutePath}: path is ${target.kind === "directory" ? "a directory" : `a ${target.entryType}`}`,
2108
+ );
2109
+ }
2110
+
2111
+ const content =
2112
+ target.kind === "regular"
2113
+ ? (target.content.value?.text ??
2114
+ (this.hasLaterTextEdit(index, operation.absolutePath)
2115
+ ? await this.optionalText(target, operation.absolutePath)
2116
+ : undefined))
2117
+ : undefined;
2118
+ const expectedTarget = this.snapshot(target);
2119
+ const change: Extract<AppliedPatchChange, { kind: "delete" }> = {
2120
+ kind: "delete",
2121
+ path: operation.path,
2122
+ entryType: target.kind === "regular" ? "regular-file" : "symlink",
2123
+ ...(content !== undefined ? { content } : {}),
2124
+ ...(content === undefined
2125
+ ? { displayDiff: "", additions: 0, deletions: 0 }
2126
+ : diffDetails(content, "")),
2127
+ };
2128
+ const targetKey = await this.pathKey(operation.absolutePath);
2129
+ this.mutations.push({
2130
+ instructionIndex: index,
2131
+ kind: "delete",
2132
+ operation,
2133
+ expectedTarget,
2134
+ targetKey,
2135
+ entryMutations: [
2136
+ {
2137
+ path: operation.absolutePath,
2138
+ key: targetKey,
2139
+ kind: "absent",
2140
+ ...((target.kind === "regular" || target.kind === "symlink") && target.fingerprint
2141
+ ? { releasedFingerprint: target.fingerprint }
2142
+ : {}),
2143
+ },
2144
+ ],
2145
+ change,
2146
+ });
2147
+ this.releasePhysicalLink(target);
2148
+ await this.setState(operation.absolutePath, ABSENT_ENTRY);
2149
+ }
2150
+
2151
+ private hasLaterTextEdit(index: number, targetPath: string): boolean {
2152
+ for (const operation of this.operations.slice(index + 1)) {
2153
+ if (operation.absolutePath !== targetPath) {
2154
+ if (this.operationRelatedPaths(operation).some((path) => pathIsRelated(path, targetPath))) {
2155
+ return false;
2156
+ }
2157
+ continue;
2158
+ }
2159
+ if (operation.kind === "add") return true;
2160
+ if (operation.kind === "delete") return false;
2161
+ return !chunksAreIdentity(operation.chunks);
2162
+ }
2163
+ return false;
2164
+ }
2165
+
2166
+ private async planUpdate(
2167
+ operation: Extract<ResolvedOperation, { kind: "update" }>,
2168
+ instructionIndex: number,
2169
+ ): Promise<void> {
2170
+ const identity = chunksAreIdentity(operation.chunks);
2171
+ if (identity) {
2172
+ if (operation.moveAbsolutePath !== undefined) {
2173
+ if (updateHasSemanticMove(operation)) {
2174
+ await this.planPureMove(operation, instructionIndex);
2175
+ } else {
2176
+ this.markNoOp(instructionIndex, "same-entry-move");
2177
+ }
2178
+ } else {
2179
+ this.markNoOp(
2180
+ instructionIndex,
2181
+ operation.chunks.length === 0 ? "empty-update" : "identity-update",
2182
+ );
2183
+ }
2184
+ return;
2185
+ }
2186
+
2187
+ const source = await this.stateAt(operation.absolutePath);
2188
+ if (source.kind !== "regular" && source.kind !== "symlink") {
2189
+ const description =
2190
+ source.kind === "absent"
2191
+ ? "path does not exist"
2192
+ : source.kind === "unsupported"
2193
+ ? `path is a ${source.entryType}`
2194
+ : "path is a directory";
2195
+ throw new Error(`Failed to read file to update ${operation.absolutePath}: ${description}`);
2196
+ }
2197
+
2198
+ const oldContent = await this.readText(source, operation.absolutePath);
2199
+ const newContent = await deriveNewContent(
2200
+ oldContent,
2201
+ operation.chunks,
2202
+ operation.absolutePath,
2203
+ this.signal,
2204
+ );
2205
+ const content = Buffer.from(newContent, "utf8");
2206
+ const semanticMove = updateHasSemanticMove(operation);
2207
+ const sourceKey = await this.pathKey(operation.absolutePath);
2208
+ if (!semanticMove && buffersEqual(source.content.value!.bytes, content)) {
2209
+ this.markNoOp(instructionIndex, "update-result-unchanged");
2210
+ return;
2211
+ }
2212
+
2213
+ if (!semanticMove) {
2214
+ const expectedSource = this.snapshot(source) as Extract<
2215
+ VirtualEntry,
2216
+ { kind: "regular" | "symlink" }
2217
+ >;
2218
+ expectedSource.content.planned = true;
2219
+ const change: Extract<AppliedPatchChange, { kind: "update" }> = {
2220
+ kind: "update",
2221
+ path: operation.path,
2222
+ oldContent,
2223
+ newContent,
2224
+ ...diffDetails(oldContent, newContent),
2225
+ };
2226
+ this.mutations.push({
2227
+ instructionIndex,
2228
+ kind: "text-update",
2229
+ operation,
2230
+ expectedSource,
2231
+ parents: { createdPaths: [], expectations: [] },
2232
+ content,
2233
+ sourceKey,
2234
+ entryMutations: [],
2235
+ change,
2236
+ });
2237
+ source.content.value = { bytes: content, text: newContent };
2238
+ source.content.planned = true;
2239
+ if (source.kind === "symlink") {
2240
+ await this.setState(operation.absolutePath, {
2241
+ ...source,
2242
+ });
2243
+ } else {
2244
+ await this.setState(operation.absolutePath, {
2245
+ kind: "regular",
2246
+ id: this.newEntryId(),
2247
+ entryPath: operation.absolutePath,
2248
+ ...(source.fingerprint?.linkCount === 1 ? {} : { sourcePath: source.sourcePath }),
2249
+ content: source.content,
2250
+ ...(source.physical ? { physical: source.physical } : {}),
2251
+ });
2252
+ }
2253
+ return;
2254
+ }
2255
+
2256
+ const destinationPath = operation.moveAbsolutePath!;
2257
+ const destinationKey = await this.pathKey(destinationPath);
2258
+ if (sourceKey === destinationKey) {
2259
+ const expectedSource = this.snapshot(source) as Extract<
2260
+ VirtualEntry,
2261
+ { kind: "regular" | "symlink" }
2262
+ >;
2263
+ expectedSource.content.planned = true;
2264
+ const sameEntryMove = await this.sameEntryMoveEffect(operation.absolutePath, destinationPath);
2265
+ const change: Extract<AppliedPatchChange, { kind: "update" }> = {
2266
+ kind: "update",
2267
+ path: operation.path,
2268
+ moveTo: operation.moveTo!,
2269
+ oldContent,
2270
+ newContent,
2271
+ ...diffDetails(oldContent, newContent),
2272
+ };
2273
+ this.mutations.push({
2274
+ instructionIndex,
2275
+ kind: "text-update",
2276
+ operation,
2277
+ expectedSource,
2278
+ expectedDestination: expectedSource,
2279
+ parents: { createdPaths: [], expectations: [] },
2280
+ content,
2281
+ ...(source.kind === "regular" && source.fingerprint
2282
+ ? { replacementMode: source.fingerprint.mode }
2283
+ : {}),
2284
+ sourceKey,
2285
+ destinationKey,
2286
+ sameEntryMove,
2287
+ entryMutations: [
2288
+ {
2289
+ path: destinationPath,
2290
+ key: destinationKey,
2291
+ kind: "regular",
2292
+ ...(source.fingerprint ? { releasedFingerprint: source.fingerprint } : {}),
2293
+ },
2294
+ ],
2295
+ change,
2296
+ provisionalChange: {
2297
+ kind: "update",
2298
+ path: operation.path,
2299
+ oldContent,
2300
+ newContent,
2301
+ ...diffDetails(oldContent, newContent),
2302
+ },
2303
+ });
2304
+ this.releasePhysicalLink(source);
2305
+ const resultingEntry: Extract<VirtualEntry, { kind: "regular" }> = {
2306
+ kind: "regular",
2307
+ id: this.newEntryId(),
2308
+ entryPath: destinationPath,
2309
+ physical: this.newPhysicalFile(),
2310
+ content: {
2311
+ value: { bytes: content, text: newContent },
2312
+ planned: true,
2313
+ },
2314
+ };
2315
+ await this.setState(destinationPath, resultingEntry);
2316
+ this.fulfilledMoves.set(sourceKey, {
2317
+ destinationKey,
2318
+ destinationEntryId: resultingEntry.id,
2319
+ instruction: instructionIndex + 1,
2320
+ });
2321
+ return;
2322
+ }
2323
+
2324
+ const destination = await this.stateAt(destinationPath);
2325
+ if (destination.kind === "directory" || destination.kind === "unsupported") {
2326
+ throw new Error(
2327
+ `Cannot move update to ${destinationPath}: destination is ${destination.kind === "directory" ? "a directory" : `a ${destination.entryType}`}`,
2328
+ );
2329
+ }
2330
+ const parents =
2331
+ destination.kind === "absent"
2332
+ ? await this.ensureParents(destinationPath)
2333
+ : { createdPaths: [], expectations: [] };
2334
+ const overwrittenMoveContent =
2335
+ destination.kind === "regular" || destination.kind === "symlink"
2336
+ ? await this.optionalText(destination, destinationPath)
2337
+ : undefined;
2338
+ const expectedSource = this.snapshot(source) as Extract<
2339
+ VirtualEntry,
2340
+ { kind: "regular" | "symlink" }
2341
+ >;
2342
+ expectedSource.content.planned = true;
2343
+ const expectedDestination = this.snapshot(destination);
2344
+ const change: Extract<AppliedPatchChange, { kind: "update" }> = {
2345
+ kind: "update",
2346
+ path: operation.path,
2347
+ moveTo: operation.moveTo!,
2348
+ oldContent,
2349
+ newContent,
2350
+ ...(overwrittenMoveContent !== undefined ? { overwrittenMoveContent } : {}),
2351
+ ...diffDetails(oldContent, newContent),
2352
+ };
2353
+ const provisionalChange: Extract<AppliedPatchChange, { kind: "add" }> = {
2354
+ kind: "add",
2355
+ path: operation.moveTo!,
2356
+ content: newContent,
2357
+ ...(overwrittenMoveContent !== undefined
2358
+ ? { overwrittenContent: overwrittenMoveContent }
2359
+ : {}),
2360
+ ...diffDetails("", newContent),
2361
+ };
2362
+ this.mutations.push({
2363
+ instructionIndex,
2364
+ kind: "text-update",
2365
+ operation,
2366
+ expectedSource,
2367
+ expectedDestination,
2368
+ parents,
2369
+ content,
2370
+ ...(source.kind === "regular" && source.fingerprint
2371
+ ? { replacementMode: source.fingerprint.mode }
2372
+ : {}),
2373
+ sourceKey,
2374
+ destinationKey,
2375
+ entryMutations: [
2376
+ {
2377
+ path: operation.absolutePath,
2378
+ key: sourceKey,
2379
+ kind: "absent",
2380
+ ...(source.fingerprint ? { releasedFingerprint: source.fingerprint } : {}),
2381
+ },
2382
+ {
2383
+ path: destinationPath,
2384
+ key: destinationKey,
2385
+ kind: "regular",
2386
+ ...((destination.kind === "regular" || destination.kind === "symlink") &&
2387
+ destination.fingerprint
2388
+ ? { releasedFingerprint: destination.fingerprint }
2389
+ : {}),
2390
+ },
2391
+ ],
2392
+ change,
2393
+ provisionalChange,
2394
+ });
2395
+ this.releasePhysicalLink(source);
2396
+ this.releasePhysicalLink(destination);
2397
+ await this.setState(operation.absolutePath, ABSENT_ENTRY);
2398
+ const resultingEntry: Extract<VirtualEntry, { kind: "regular" }> = {
2399
+ kind: "regular",
2400
+ id: this.newEntryId(),
2401
+ entryPath: destinationPath,
2402
+ physical: this.newPhysicalFile(),
2403
+ content: {
2404
+ value: { bytes: content, text: newContent },
2405
+ planned: true,
2406
+ },
2407
+ };
2408
+ await this.setState(destinationPath, resultingEntry);
2409
+ this.fulfilledMoves.set(sourceKey, {
2410
+ destinationKey,
2411
+ destinationEntryId: resultingEntry.id,
2412
+ instruction: instructionIndex + 1,
2413
+ });
2414
+ }
2415
+
2416
+ private async planPureMove(
2417
+ operation: Extract<ResolvedOperation, { kind: "update" }>,
2418
+ instructionIndex: number,
2419
+ ): Promise<void> {
2420
+ const destinationPath = operation.moveAbsolutePath!;
2421
+ const sourceKey = await this.pathKey(operation.absolutePath);
2422
+ const destinationKey = await this.pathKey(destinationPath);
2423
+ if (sourceKey === destinationKey) {
2424
+ if (operation.absolutePath === destinationPath) {
2425
+ this.markNoOp(instructionIndex, "same-entry-move");
2426
+ return;
2427
+ }
2428
+ const source = await this.stateAt(operation.absolutePath);
2429
+ if (
2430
+ (source.kind === "regular" || source.kind === "symlink") &&
2431
+ this.virtualSpellingSatisfied(source.entryPath, destinationPath)
2432
+ ) {
2433
+ this.markNoOp(instructionIndex, "same-entry-move");
2434
+ return;
2435
+ }
2436
+ if ((await this.sameEntryMoveEffect(operation.absolutePath, destinationPath)) === "rename") {
2437
+ if (source.kind !== "regular" && source.kind !== "symlink") {
2438
+ throw new Error(
2439
+ `Failed to move ${operation.absolutePath}: source is ${source.kind === "directory" ? "a directory" : source.kind === "absent" ? "absent" : `a ${source.entryType}`}`,
2440
+ );
2441
+ }
2442
+ const expectedSource = this.snapshot(source) as Extract<
2443
+ VirtualEntry,
2444
+ { kind: "regular" | "symlink" }
2445
+ >;
2446
+ const change: Extract<AppliedPatchChange, { kind: "move" }> = {
2447
+ kind: "move",
2448
+ sourcePath: operation.path,
2449
+ destinationPath: operation.moveTo!,
2450
+ replacedDestination: false,
2451
+ entryType: expectedSource.kind === "regular" ? "regular-file" : "symlink",
2452
+ exact: true,
2453
+ displayDiff: "",
2454
+ additions: 0,
2455
+ deletions: 0,
2456
+ };
2457
+ this.mutations.push({
2458
+ instructionIndex,
2459
+ kind: "move",
2460
+ operation,
2461
+ expectedSource,
2462
+ expectedDestination: this.snapshot(source),
2463
+ parents: { createdPaths: [], expectations: [] },
2464
+ sourceKey,
2465
+ destinationKey,
2466
+ moveStrategy: "rename",
2467
+ entryMutations: [
2468
+ {
2469
+ path: destinationPath,
2470
+ key: destinationKey,
2471
+ kind: expectedSource.kind,
2472
+ },
2473
+ ],
2474
+ change,
2475
+ });
2476
+ await this.setState(destinationPath, { ...source, entryPath: destinationPath });
2477
+ return;
2478
+ }
2479
+ this.markNoOp(instructionIndex, "same-entry-move");
2480
+ return;
2481
+ }
2482
+
2483
+ const source = await this.stateAt(operation.absolutePath);
2484
+ if (source.kind === "absent") {
2485
+ const fulfilled = this.fulfilledMoves.get(sourceKey);
2486
+ const destination = await this.stateAt(destinationPath);
2487
+ if (
2488
+ fulfilled?.destinationKey === destinationKey &&
2489
+ (destination.kind === "regular" || destination.kind === "symlink") &&
2490
+ fulfilled.destinationEntryId === destination.id
2491
+ ) {
2492
+ this.instructions[instructionIndex]!.reason = moveAlreadyFulfilledReason(
2493
+ fulfilled.instruction,
2494
+ );
2495
+ return;
2496
+ }
2497
+ throw new Error(
2498
+ `Failed to move ${operation.absolutePath}: source does not exist, and no earlier instruction moved it to ${destinationPath}`,
2499
+ );
2500
+ }
2501
+ if (source.kind !== "regular" && source.kind !== "symlink") {
2502
+ throw new Error(
2503
+ `Failed to move ${operation.absolutePath}: source is ${source.kind === "directory" ? "a directory" : `a ${source.entryType}`}`,
2504
+ );
2505
+ }
2506
+
2507
+ const expectedDestination = await this.stateAt(destinationPath);
2508
+ if (expectedDestination.kind === "directory" || expectedDestination.kind === "unsupported") {
2509
+ throw new Error(
2510
+ `Failed to move to ${destinationPath}: destination is ${expectedDestination.kind === "directory" ? "a directory" : `a ${expectedDestination.entryType}`}`,
2511
+ );
2512
+ }
2513
+ const parents =
2514
+ expectedDestination.kind === "absent"
2515
+ ? await this.ensureParents(destinationPath)
2516
+ : { createdPaths: [], expectations: [] };
2517
+ const [sourceDevice, destinationDevice] = await Promise.all([
2518
+ source.fingerprint?.device ?? this.entryFilesystemDevice(operation.absolutePath),
2519
+ this.entryFilesystemDevice(destinationPath),
2520
+ ]);
2521
+ const detectedMoveStrategy = sourceDevice === destinationDevice ? "rename" : "copy-unlink";
2522
+ const moveStrategy = this.selectMoveStrategy
2523
+ ? await this.selectMoveStrategy(operation.absolutePath, destinationPath, detectedMoveStrategy)
2524
+ : detectedMoveStrategy;
2525
+ const replacedDestination = expectedDestination.kind !== "absent";
2526
+ const expectedSource = this.snapshot(source) as Extract<
2527
+ VirtualEntry,
2528
+ { kind: "regular" | "symlink" }
2529
+ >;
2530
+ const destinationSnapshot = this.snapshot(expectedDestination);
2531
+ const change: Extract<AppliedPatchChange, { kind: "move" }> = {
2532
+ kind: "move",
2533
+ sourcePath: operation.path,
2534
+ destinationPath: operation.moveTo!,
2535
+ replacedDestination,
2536
+ entryType: expectedSource.kind === "regular" ? "regular-file" : "symlink",
2537
+ exact: true,
2538
+ displayDiff: "",
2539
+ additions: 0,
2540
+ deletions: 0,
2541
+ };
2542
+ this.mutations.push({
2543
+ instructionIndex,
2544
+ kind: "move",
2545
+ operation,
2546
+ expectedSource,
2547
+ expectedDestination: destinationSnapshot,
2548
+ parents,
2549
+ sourceKey,
2550
+ destinationKey,
2551
+ moveStrategy,
2552
+ entryMutations: [
2553
+ {
2554
+ path: operation.absolutePath,
2555
+ key: sourceKey,
2556
+ kind: "absent",
2557
+ ...(source.fingerprint ? { releasedFingerprint: source.fingerprint } : {}),
2558
+ },
2559
+ {
2560
+ path: destinationPath,
2561
+ key: destinationKey,
2562
+ kind: expectedSource.kind === "regular" ? "regular" : "symlink",
2563
+ ...((expectedDestination.kind === "regular" || expectedDestination.kind === "symlink") &&
2564
+ expectedDestination.fingerprint
2565
+ ? { releasedFingerprint: expectedDestination.fingerprint }
2566
+ : {}),
2567
+ },
2568
+ ],
2569
+ change,
2570
+ });
2571
+ this.releasePhysicalLink(expectedDestination);
2572
+ if (moveStrategy === "copy-unlink") this.releasePhysicalLink(source);
2573
+ await this.setState(operation.absolutePath, ABSENT_ENTRY);
2574
+ let resultingEntry: Extract<VirtualEntry, { kind: "regular" | "symlink" }>;
2575
+ if (moveStrategy === "copy-unlink" && source.kind === "regular") {
2576
+ const content: ContentCell = {
2577
+ ...(source.content.value ? { value: source.content.value } : {}),
2578
+ planned: source.content.planned,
2579
+ };
2580
+ resultingEntry = {
2581
+ kind: "regular",
2582
+ id: this.newEntryId(),
2583
+ entryPath: destinationPath,
2584
+ sourcePath: source.sourcePath ?? source.entryPath,
2585
+ content,
2586
+ physical: this.newPhysicalFile(),
2587
+ };
2588
+ } else if (moveStrategy === "copy-unlink" && source.kind === "symlink") {
2589
+ resultingEntry = {
2590
+ kind: "symlink",
2591
+ id: this.newEntryId(),
2592
+ entryPath: destinationPath,
2593
+ target: source.target,
2594
+ targetPath: resolve(dirname(destinationPath), source.target),
2595
+ content: { planned: false },
2596
+ };
2597
+ } else if (source.kind === "symlink") {
2598
+ resultingEntry = {
2599
+ kind: "symlink",
2600
+ id: source.id,
2601
+ entryPath: destinationPath,
2602
+ ...(source.fingerprint ? { fingerprint: source.fingerprint } : {}),
2603
+ target: source.target,
2604
+ targetPath: resolve(dirname(destinationPath), source.target),
2605
+ content: { planned: false },
2606
+ };
2607
+ } else {
2608
+ resultingEntry = { ...source, entryPath: destinationPath };
2609
+ }
2610
+ await this.setState(destinationPath, resultingEntry);
2611
+ this.fulfilledMoves.set(sourceKey, {
2612
+ destinationKey,
2613
+ destinationEntryId: resultingEntry.id,
2614
+ instruction: instructionIndex + 1,
2615
+ });
2616
+ }
2617
+ }
2618
+
2619
+ class RegularFileReplacementError extends Error {
2620
+ readonly destinationChanged: boolean;
2621
+ readonly temporaryPath: string | undefined;
2622
+
2623
+ constructor(message: string, destinationChanged: boolean, temporaryPath?: string) {
2624
+ super(message);
2625
+ this.destinationChanged = destinationChanged;
2626
+ this.temporaryPath = temporaryPath;
2627
+ }
2628
+ }
2629
+
2630
+ async function replaceRegularFile(
2631
+ path: string,
2632
+ content: Buffer,
2633
+ filesystem: ApplyPatchExecutionFilesystem,
2634
+ mode?: number,
2635
+ ): Promise<void> {
2636
+ const temporaryPath = resolve(
2637
+ dirname(path),
2638
+ `.${basename(path)}.apply-patch-${randomUUID()}.tmp`,
2639
+ );
2640
+ let destinationChanged = false;
2641
+ let temporaryEntryRemains = false;
2642
+ let pendingError: unknown;
2643
+ try {
2644
+ await filesystem.writeFile(temporaryPath, content);
2645
+ if (mode !== undefined) await filesystem.chmod(temporaryPath, mode & 0o7777);
2646
+ await filesystem.rename(temporaryPath, path);
2647
+ destinationChanged = true;
2648
+ await establishExactSpelling(path, filesystem);
2649
+ } catch (error) {
2650
+ pendingError = error;
2651
+ } finally {
2652
+ try {
2653
+ await filesystem.unlink(temporaryPath);
2654
+ } catch (error) {
2655
+ if (!isNotFound(error)) {
2656
+ temporaryEntryRemains = true;
2657
+ if (pendingError === undefined) pendingError = error;
2658
+ }
2659
+ }
2660
+ }
2661
+ if (pendingError !== undefined) {
2662
+ throw new RegularFileReplacementError(
2663
+ errorMessage(pendingError),
2664
+ destinationChanged,
2665
+ temporaryEntryRemains ? temporaryPath : undefined,
2666
+ );
2667
+ }
2668
+ }
2669
+
2670
+ function namesPotentiallyAlias(left: string, right: string): boolean {
2671
+ const normalizedLeft = process.platform === "darwin" ? left.normalize("NFD") : left;
2672
+ const normalizedRight = process.platform === "darwin" ? right.normalize("NFD") : right;
2673
+ return normalizedLeft.toLocaleLowerCase() === normalizedRight.toLocaleLowerCase();
2674
+ }
2675
+
2676
+ async function establishExactSpelling(
2677
+ path: string,
2678
+ filesystem: ApplyPatchExecutionFilesystem,
2679
+ ): Promise<void> {
2680
+ if (await exactSpellingExists(path, filesystem)) return;
2681
+ const directory = dirname(path);
2682
+ const requestedName = basename(path);
2683
+ const requestedMetadata = await filesystem.lstat(path);
2684
+ let actualPath: string | undefined;
2685
+ for (const name of await filesystem.readdir(directory)) {
2686
+ if (!namesPotentiallyAlias(name, requestedName)) continue;
2687
+ const metadata = await filesystem.lstat(join(directory, name));
2688
+ if (metadata.dev === requestedMetadata.dev && metadata.ino === requestedMetadata.ino) {
2689
+ actualPath = join(directory, name);
2690
+ break;
2691
+ }
2692
+ }
2693
+ if (!actualPath) throw new Error(`could not locate filesystem spelling for ${path}`);
2694
+
2695
+ const temporaryPath = resolve(
2696
+ directory,
2697
+ `.${requestedName}.apply-patch-spelling-${randomUUID()}.tmp`,
2698
+ );
2699
+ await filesystem.rename(actualPath, temporaryPath);
2700
+ try {
2701
+ await filesystem.rename(temporaryPath, path);
2702
+ } catch (error) {
2703
+ try {
2704
+ await filesystem.rename(temporaryPath, actualPath);
2705
+ } catch {
2706
+ // Preserve the original error; the executor reports that the mutation is inexact.
2707
+ }
2708
+ throw error;
2709
+ }
2710
+ }
2711
+
2712
+ function appendChange(
2713
+ details: ApplyPatchDetails,
2714
+ change: AppliedPatchChange,
2715
+ instructionIndex?: number,
2716
+ ): void {
2717
+ const changeIndex = details.changes.length;
2718
+ details.changes.push(change);
2719
+ if (instructionIndex !== undefined) {
2720
+ const instruction = details.instructions?.[instructionIndex];
2721
+ if (instruction) {
2722
+ instruction.changeIndexes ??= [];
2723
+ instruction.changeIndexes.push(changeIndex);
2724
+ }
2725
+ }
2726
+ if (change.kind === "add") details.added.push(change.path);
2727
+ else if (change.kind === "delete") details.deleted.push(change.path);
2728
+ else if (change.kind === "move") details.modified.push(change.destinationPath);
2729
+ else details.modified.push(change.moveTo ?? change.path);
2730
+ }
2731
+
2732
+ function detailsForPlan(plan: SemanticPlan): ApplyPatchDetails {
2733
+ const details = emptyDetails();
2734
+ details.exact = plan.exact;
2735
+ details.instructions = plan.instructions.map((instruction) => ({ ...instruction }));
2736
+ for (const mutation of plan.mutations) {
2737
+ appendChange(details, mutation.change, mutation.instructionIndex);
2738
+ }
2739
+ return details;
2740
+ }
2741
+
2742
+ function previewDetailsForPlan(plan: SemanticPlan, cwd: string): ApplyPatchDetails {
2743
+ const details = detailsForPlan(plan);
2744
+ details.changes = coalesceAppliedPatchChangesForRendering(details.changes, cwd);
2745
+ details.added = [];
2746
+ details.modified = [];
2747
+ details.deleted = [];
2748
+ for (const change of details.changes) {
2749
+ if (change.kind === "add") details.added.push(change.path);
2750
+ else if (change.kind === "delete") details.deleted.push(change.path);
2751
+ else if (change.kind === "move") details.modified.push(change.destinationPath);
2752
+ else details.modified.push(change.moveTo ?? change.path);
2753
+ }
2754
+ return details;
2755
+ }
2756
+
2757
+ async function currentEntry(path: string): Promise<VirtualEntry> {
2758
+ try {
2759
+ const metadata = await lstat(path);
2760
+ const entryFingerprint = fingerprint(metadata);
2761
+ if (metadata.isFile()) {
2762
+ return {
2763
+ kind: "regular",
2764
+ id: "",
2765
+ entryPath: path,
2766
+ fingerprint: entryFingerprint,
2767
+ content: { planned: false },
2768
+ };
2769
+ }
2770
+ if (metadata.isSymbolicLink()) {
2771
+ const target = await readlink(path);
2772
+ return {
2773
+ kind: "symlink",
2774
+ id: "",
2775
+ entryPath: path,
2776
+ fingerprint: entryFingerprint,
2777
+ target,
2778
+ targetPath: resolve(dirname(path), target),
2779
+ content: { planned: false },
2780
+ };
2781
+ }
2782
+ if (metadata.isDirectory()) return { kind: "directory", fingerprint: entryFingerprint };
2783
+ return {
2784
+ kind: "unsupported",
2785
+ entryType: entryType(metadata),
2786
+ fingerprint: entryFingerprint,
2787
+ };
2788
+ } catch (error) {
2789
+ if (isNotFound(error)) return ABSENT_ENTRY;
2790
+ throw error;
2791
+ }
2792
+ }
2793
+
2794
+ async function assertEntryMatches(path: string, expected: VirtualEntry): Promise<void> {
2795
+ let actual: VirtualEntry;
2796
+ try {
2797
+ actual = await currentEntry(path);
2798
+ } catch (error) {
2799
+ throw new Error(`Failed to verify ${path} before mutation: ${errorMessage(error)}`);
2800
+ }
2801
+ if (actual.kind !== expected.kind) {
2802
+ throw new Error(`Filesystem changed after apply_patch preflight at ${path}`);
2803
+ }
2804
+ if (
2805
+ "fingerprint" in expected &&
2806
+ expected.fingerprint &&
2807
+ "fingerprint" in actual &&
2808
+ actual.fingerprint &&
2809
+ !sameFingerprint(expected.fingerprint, actual.fingerprint)
2810
+ ) {
2811
+ const contentMatch =
2812
+ (expected.kind === "regular" || expected.kind === "symlink") &&
2813
+ expected.content.planned &&
2814
+ expected.content.value &&
2815
+ buffersEqual(await readFile(path), expected.content.value.bytes);
2816
+ if (!contentMatch) {
2817
+ throw new Error(`Filesystem changed after apply_patch preflight at ${path}`);
2818
+ }
2819
+ }
2820
+ if (expected.kind === "symlink" && actual.kind === "symlink") {
2821
+ if (expected.target !== actual.target) {
2822
+ throw new Error(`Filesystem changed after apply_patch preflight at ${path}`);
2823
+ }
2824
+ if (
2825
+ expected.content.planned &&
2826
+ expected.content.value &&
2827
+ !buffersEqual(await readFile(path), expected.content.value.bytes)
2828
+ ) {
2829
+ throw new Error(`Filesystem changed after apply_patch preflight at ${path}`);
2830
+ }
2831
+ }
2832
+ if (
2833
+ expected.kind === "regular" &&
2834
+ actual.kind === "regular" &&
2835
+ expected.content.planned &&
2836
+ expected.content.value &&
2837
+ !buffersEqual(await readFile(path), expected.content.value.bytes)
2838
+ ) {
2839
+ throw new Error(`Filesystem changed after apply_patch preflight at ${path}`);
2840
+ }
2841
+ }
2842
+
2843
+ async function assertMutationEntryMatches(
2844
+ path: string,
2845
+ key: string,
2846
+ expected: VirtualEntry,
2847
+ priorMutations: readonly CommittedEntryMutation[],
2848
+ ): Promise<void> {
2849
+ const prior = priorMutations.findLast((mutation) => mutation.key === key);
2850
+ const effectiveExpected = prior?.expected ?? expected;
2851
+
2852
+ try {
2853
+ await assertEntryMatches(path, effectiveExpected);
2854
+ } catch (error) {
2855
+ if (
2856
+ (effectiveExpected.kind === "regular" || effectiveExpected.kind === "symlink") &&
2857
+ effectiveExpected.fingerprint
2858
+ ) {
2859
+ const expectedFingerprint = effectiveExpected.fingerprint;
2860
+ const actual = await currentEntry(path);
2861
+ const linkCountWasChangedByPlan =
2862
+ (actual.kind === "regular" || actual.kind === "symlink") &&
2863
+ actual.fingerprint !== undefined &&
2864
+ sameFingerprintExceptLinkCount(expectedFingerprint, actual.fingerprint) &&
2865
+ priorMutations.some(({ releasedFingerprint }) => {
2866
+ return (
2867
+ releasedFingerprint?.device === expectedFingerprint.device &&
2868
+ releasedFingerprint.inode === expectedFingerprint.inode
2869
+ );
2870
+ });
2871
+ if (linkCountWasChangedByPlan) return;
2872
+ }
2873
+ throw error;
2874
+ }
2875
+ }
2876
+
2877
+ async function captureCommittedEntryMutations(
2878
+ mutations: readonly PlannedEntryMutation[],
2879
+ ): Promise<CommittedEntryMutation[]> {
2880
+ const committed: CommittedEntryMutation[] = [];
2881
+ for (const mutation of mutations) {
2882
+ const expected = await currentEntry(mutation.path);
2883
+ if (expected.kind !== mutation.kind) {
2884
+ throw new Error(`Filesystem changed while committing apply_patch at ${mutation.path}`);
2885
+ }
2886
+ committed.push({
2887
+ path: mutation.path,
2888
+ key: mutation.key,
2889
+ expected,
2890
+ ...(mutation.releasedFingerprint
2891
+ ? { releasedFingerprint: mutation.releasedFingerprint }
2892
+ : {}),
2893
+ });
2894
+ }
2895
+ return committed;
2896
+ }
2897
+
2898
+ async function assertParentPlanMatches(parents: ParentPlan): Promise<void> {
2899
+ for (const expectation of parents.expectations) {
2900
+ let actual: VirtualEntry;
2901
+ try {
2902
+ actual = await currentEntry(expectation.path);
2903
+ } catch (error) {
2904
+ throw new Error(
2905
+ `Failed to verify parent ${expectation.path} before mutation: ${errorMessage(error)}`,
2906
+ );
2907
+ }
2908
+ if (expectation.kind === "absent") {
2909
+ if (actual.kind !== "absent") {
2910
+ throw new Error(`Filesystem changed after apply_patch preflight at ${expectation.path}`);
2911
+ }
2912
+ continue;
2913
+ }
2914
+ if (expectation.kind === "directory") {
2915
+ if (actual.kind !== "directory") {
2916
+ throw new Error(`Filesystem changed after apply_patch preflight at ${expectation.path}`);
2917
+ }
2918
+ continue;
2919
+ }
2920
+ if (actual.kind !== "symlink") {
2921
+ throw new Error(`Filesystem changed after apply_patch preflight at ${expectation.path}`);
2922
+ }
2923
+ try {
2924
+ if (!(await stat(expectation.path)).isDirectory()) {
2925
+ throw new Error(`Filesystem changed after apply_patch preflight at ${expectation.path}`);
2926
+ }
2927
+ } catch (error) {
2928
+ throw new Error(
2929
+ `Failed to verify parent ${expectation.path} before mutation: ${errorMessage(error)}`,
2930
+ );
2931
+ }
2932
+ }
2933
+ }
2934
+
2935
+ async function createPlannedParents(
2936
+ parents: ParentPlan,
2937
+ filesystem: ApplyPatchExecutionFilesystem,
2938
+ ): Promise<void> {
2939
+ const deepest = parents.createdPaths.at(-1);
2940
+ if (deepest) await filesystem.mkdir(deepest, { recursive: true });
2941
+ }
2942
+
2943
+ async function exactSpellingExists(
2944
+ path: string,
2945
+ filesystem: Pick<ApplyPatchExecutionFilesystem, "readdir"> = DEFAULT_EXECUTION_FILESYSTEM,
2946
+ ): Promise<boolean> {
2947
+ try {
2948
+ return (await filesystem.readdir(dirname(path))).includes(basename(path));
2949
+ } catch {
2950
+ return false;
2951
+ }
2952
+ }
2953
+
2954
+ async function requestedSpellingExists(path: string): Promise<boolean> {
2955
+ try {
2956
+ return (await readdir(dirname(path))).includes(basename(path));
2957
+ } catch {
2958
+ return false;
2959
+ }
2960
+ }
2961
+
2962
+ async function finishSameInodeRename(
2963
+ sourcePath: string,
2964
+ destinationPath: string,
2965
+ filesystem: ApplyPatchExecutionFilesystem,
2966
+ ): Promise<void> {
2967
+ let sourceMetadata: Stats;
2968
+ let destinationMetadata: Stats;
2969
+ try {
2970
+ [sourceMetadata, destinationMetadata] = await Promise.all([
2971
+ filesystem.lstat(sourcePath),
2972
+ filesystem.lstat(destinationPath),
2973
+ ]);
2974
+ } catch (error) {
2975
+ if (isNotFound(error)) return;
2976
+ throw error;
2977
+ }
2978
+ if (
2979
+ sourceMetadata.dev !== destinationMetadata.dev ||
2980
+ sourceMetadata.ino !== destinationMetadata.ino
2981
+ ) {
2982
+ throw new Error(`rename returned without removing source ${sourcePath}`);
2983
+ }
2984
+
2985
+ const [sourceNameExists, destinationNameExists] = await Promise.all([
2986
+ exactSpellingExists(sourcePath, filesystem),
2987
+ exactSpellingExists(destinationPath, filesystem),
2988
+ ]);
2989
+ if (!destinationNameExists) {
2990
+ throw new Error(`rename completed without creating destination entry ${destinationPath}`);
2991
+ }
2992
+ if (sourceNameExists) await filesystem.unlink(sourcePath);
2993
+ }
2994
+
2995
+ class PureMoveExecutionError extends Error {
2996
+ readonly destinationState: "unchanged" | "removed" | "created" | "replaced";
2997
+ readonly temporaryPath: string | undefined;
2998
+
2999
+ constructor(
3000
+ message: string,
3001
+ destinationState: "unchanged" | "removed" | "created" | "replaced",
3002
+ temporaryPath?: string,
3003
+ ) {
3004
+ super(message);
3005
+ this.destinationState = destinationState;
3006
+ this.temporaryPath = temporaryPath;
3007
+ }
3008
+ }
3009
+
3010
+ async function executeCrossDeviceMove(
3011
+ mutation: Extract<PlannedMutation, { kind: "move" }>,
3012
+ filesystem: ApplyPatchExecutionFilesystem,
3013
+ ): Promise<void> {
3014
+ const sourcePath = mutation.operation.absolutePath;
3015
+ const destinationPath = mutation.operation.moveAbsolutePath!;
3016
+ const temporaryPath = resolve(
3017
+ dirname(destinationPath),
3018
+ `.${basename(destinationPath)}.apply-patch-${randomUUID()}.tmp`,
3019
+ );
3020
+ let destinationChanged = false;
3021
+ let destinationRemoved = false;
3022
+ let temporaryEntryRemains = false;
3023
+ let pendingError: unknown;
3024
+ try {
3025
+ if (mutation.expectedSource.kind === "regular") {
3026
+ await filesystem.copyFile(sourcePath, temporaryPath, constants.COPYFILE_EXCL);
3027
+ if (mutation.expectedSource.fingerprint) {
3028
+ await filesystem.chmod(temporaryPath, mutation.expectedSource.fingerprint.mode);
3029
+ const metadata = await filesystem.lstat(sourcePath);
3030
+ await filesystem.utimes(temporaryPath, metadata.atime, metadata.mtime);
3031
+ }
3032
+ } else {
3033
+ await filesystem.symlink(await filesystem.readlink(sourcePath), temporaryPath);
3034
+ }
3035
+
3036
+ try {
3037
+ await filesystem.rename(temporaryPath, destinationPath);
3038
+ } catch (error) {
3039
+ if (
3040
+ mutation.expectedDestination.kind === "absent" ||
3041
+ (!hasErrorCode(error, "EEXIST") &&
3042
+ !hasErrorCode(error, "ENOTEMPTY") &&
3043
+ !hasErrorCode(error, "EPERM"))
3044
+ ) {
3045
+ throw error;
3046
+ }
3047
+ await filesystem.unlink(destinationPath);
3048
+ destinationRemoved = true;
3049
+ await filesystem.rename(temporaryPath, destinationPath);
3050
+ }
3051
+ destinationChanged = true;
3052
+ await filesystem.unlink(sourcePath);
3053
+ } catch (error) {
3054
+ pendingError = error;
3055
+ } finally {
3056
+ try {
3057
+ await filesystem.unlink(temporaryPath);
3058
+ } catch (error) {
3059
+ if (!isNotFound(error)) {
3060
+ temporaryEntryRemains = true;
3061
+ if (pendingError === undefined) pendingError = error;
3062
+ }
3063
+ }
3064
+ }
3065
+ if (pendingError !== undefined) {
3066
+ const message =
3067
+ destinationRemoved && !destinationChanged
3068
+ ? `${errorMessage(pendingError)}; destination was removed before replacement failed`
3069
+ : errorMessage(pendingError);
3070
+ throw new PureMoveExecutionError(
3071
+ message,
3072
+ destinationChanged
3073
+ ? mutation.expectedDestination.kind === "absent"
3074
+ ? "created"
3075
+ : "replaced"
3076
+ : destinationRemoved
3077
+ ? "removed"
3078
+ : "unchanged",
3079
+ temporaryEntryRemains ? temporaryPath : undefined,
3080
+ );
3081
+ }
3082
+ }
3083
+
3084
+ async function executePureMove(
3085
+ mutation: Extract<PlannedMutation, { kind: "move" }>,
3086
+ filesystem: ApplyPatchExecutionFilesystem,
3087
+ ): Promise<void> {
3088
+ const sourcePath = mutation.operation.absolutePath;
3089
+ const destinationPath = mutation.operation.moveAbsolutePath!;
3090
+ if (mutation.moveStrategy === "copy-unlink") {
3091
+ await executeCrossDeviceMove(mutation, filesystem);
3092
+ return;
3093
+ }
3094
+ try {
3095
+ await filesystem.rename(sourcePath, destinationPath);
3096
+ } catch (error) {
3097
+ if (hasErrorCode(error, "EXDEV")) {
3098
+ throw new Error("rename unexpectedly crossed filesystem boundaries after validation");
3099
+ }
3100
+ throw error;
3101
+ }
3102
+ try {
3103
+ await finishSameInodeRename(sourcePath, destinationPath, filesystem);
3104
+ } catch (error) {
3105
+ let destinationChanged = false;
3106
+ try {
3107
+ await filesystem.lstat(destinationPath);
3108
+ destinationChanged = true;
3109
+ } catch {}
3110
+ throw new PureMoveExecutionError(
3111
+ errorMessage(error),
3112
+ destinationChanged
3113
+ ? mutation.expectedDestination.kind === "absent"
3114
+ ? "created"
3115
+ : "replaced"
3116
+ : "unchanged",
3117
+ );
3118
+ }
3119
+ }
3120
+
3121
+ function addInstructionEffect(
3122
+ instruction: ApplyPatchInstructionDetails,
3123
+ effect: ApplyPatchInstructionEffect,
3124
+ ): void {
3125
+ instruction.effects ??= [];
3126
+ if (
3127
+ !instruction.effects.some(
3128
+ (candidate) => candidate.kind === effect.kind && candidate.path === effect.path,
3129
+ )
3130
+ ) {
3131
+ instruction.effects.push(effect);
3132
+ }
3133
+ }
3134
+
3135
+ function fileEntryDetails(entry: ExistingFileEntry): ApplyPatchFileEntryDetails {
3136
+ return entry.kind === "regular"
3137
+ ? { entryType: "regular-file" }
3138
+ : { entryType: "symlink", target: entry.target };
3139
+ }
3140
+
3141
+ function replacedInstructionEffect(
3142
+ path: string,
3143
+ previousEntry: ReplaceableFileEntry,
3144
+ replacementEntry: ApplyPatchFileEntryDetails,
3145
+ ): ApplyPatchInstructionEffect {
3146
+ if (previousEntry.kind === "absent") {
3147
+ throw new Error(`replacement effect for ${path} requires an existing entry`);
3148
+ }
3149
+ return {
3150
+ kind: "replaced",
3151
+ path,
3152
+ previousEntry: fileEntryDetails(previousEntry),
3153
+ replacementEntry,
3154
+ };
3155
+ }
3156
+
3157
+ function addInstructionFinalState(
3158
+ instruction: ApplyPatchInstructionDetails,
3159
+ state: ApplyPatchFinalPathState,
3160
+ ): void {
3161
+ instruction.finalStates ??= [];
3162
+ const existing = instruction.finalStates.findIndex((candidate) => candidate.path === state.path);
3163
+ if (existing === -1) instruction.finalStates.push(state);
3164
+ else instruction.finalStates[existing] = state;
3165
+ }
3166
+
3167
+ function currentEntryFinalState(entry: VirtualEntry): ApplyPatchFinalPathState["state"] {
3168
+ switch (entry.kind) {
3169
+ case "absent":
3170
+ return "absent";
3171
+ case "regular":
3172
+ return "regular-file";
3173
+ case "symlink":
3174
+ return "symlink";
3175
+ case "directory":
3176
+ return "directory";
3177
+ case "unsupported":
3178
+ return "other-entry";
3179
+ }
3180
+ }
3181
+
3182
+ function entriesHaveSameIdentity(actual: VirtualEntry, expected: VirtualEntry): boolean {
3183
+ if (actual.kind !== expected.kind) return false;
3184
+ if (
3185
+ (actual.kind === "regular" || actual.kind === "symlink") &&
3186
+ (expected.kind === "regular" || expected.kind === "symlink")
3187
+ ) {
3188
+ if (actual.fingerprint && expected.fingerprint) {
3189
+ return sameFingerprintExceptLinkCount(actual.fingerprint, expected.fingerprint);
3190
+ }
3191
+ return actual.kind === "symlink" && expected.kind === "symlink"
3192
+ ? actual.target === expected.target
3193
+ : false;
3194
+ }
3195
+ if (actual.kind === "directory" && expected.kind === "directory") {
3196
+ return (
3197
+ actual.fingerprint !== undefined &&
3198
+ expected.fingerprint !== undefined &&
3199
+ sameFingerprintExceptLinkCount(actual.fingerprint, expected.fingerprint)
3200
+ );
3201
+ }
3202
+ return actual.kind === "absent" && expected.kind === "absent";
3203
+ }
3204
+
3205
+ async function currentExecutionEntry(
3206
+ path: string,
3207
+ filesystem: ApplyPatchExecutionFilesystem,
3208
+ ): Promise<VirtualEntry> {
3209
+ try {
3210
+ const metadata = await filesystem.lstat(path);
3211
+ const entryFingerprint = fingerprint(metadata);
3212
+ if (metadata.isFile()) {
3213
+ return {
3214
+ kind: "regular",
3215
+ id: "",
3216
+ entryPath: path,
3217
+ fingerprint: entryFingerprint,
3218
+ content: { planned: false },
3219
+ };
3220
+ }
3221
+ if (metadata.isSymbolicLink()) {
3222
+ const target = await filesystem.readlink(path);
3223
+ return {
3224
+ kind: "symlink",
3225
+ id: "",
3226
+ entryPath: path,
3227
+ fingerprint: entryFingerprint,
3228
+ target,
3229
+ targetPath: resolve(dirname(path), target),
3230
+ content: { planned: false },
3231
+ };
3232
+ }
3233
+ if (metadata.isDirectory()) return { kind: "directory", fingerprint: entryFingerprint };
3234
+ return {
3235
+ kind: "unsupported",
3236
+ entryType: entryType(metadata),
3237
+ fingerprint: entryFingerprint,
3238
+ };
3239
+ } catch (error) {
3240
+ if (isNotFound(error)) return ABSENT_ENTRY;
3241
+ throw error;
3242
+ }
3243
+ }
3244
+
3245
+ type ApplyPatchFinalPathInspection = {
3246
+ finalState: ApplyPatchFinalPathState;
3247
+ entry?: VirtualEntry;
3248
+ };
3249
+
3250
+ function finalPathInspection(
3251
+ path: string,
3252
+ state: ApplyPatchFinalPathState["state"],
3253
+ entry?: VirtualEntry,
3254
+ ): ApplyPatchFinalPathInspection {
3255
+ return {
3256
+ finalState: { path, state },
3257
+ ...(entry ? { entry } : {}),
3258
+ };
3259
+ }
3260
+
3261
+ async function inspectFinalPath(
3262
+ absolutePath: string,
3263
+ displayPath: string,
3264
+ expected: VirtualEntry,
3265
+ filesystem: ApplyPatchExecutionFilesystem,
3266
+ requestedContent?: Buffer,
3267
+ ): Promise<ApplyPatchFinalPathInspection> {
3268
+ try {
3269
+ const actual = await currentExecutionEntry(absolutePath, filesystem);
3270
+ const physicalEntryChanged =
3271
+ actual.kind === expected.kind &&
3272
+ (actual.kind === "regular" || actual.kind === "symlink") &&
3273
+ (expected.kind === "regular" || expected.kind === "symlink") &&
3274
+ actual.fingerprint !== undefined &&
3275
+ expected.fingerprint !== undefined &&
3276
+ !samePhysicalEntry(actual.fingerprint, expected.fingerprint);
3277
+ if (requestedContent && (actual.kind === "regular" || actual.kind === "symlink")) {
3278
+ try {
3279
+ const bytes = await filesystem.readFile(absolutePath);
3280
+ if (buffersEqual(bytes, requestedContent)) {
3281
+ return finalPathInspection(displayPath, "requested-content", actual);
3282
+ }
3283
+ if (physicalEntryChanged) {
3284
+ return finalPathInspection(displayPath, "different-entry", actual);
3285
+ }
3286
+ if (expected.kind === "regular" || expected.kind === "symlink") {
3287
+ const expectedBytes = expected.content.value?.bytes;
3288
+ if (expectedBytes && buffersEqual(bytes, expectedBytes)) {
3289
+ return finalPathInspection(displayPath, "unchanged", actual);
3290
+ }
3291
+ if (expectedBytes) {
3292
+ return finalPathInspection(
3293
+ displayPath,
3294
+ "different-from-requested-and-previous-content",
3295
+ actual,
3296
+ );
3297
+ }
3298
+ }
3299
+ return finalPathInspection(displayPath, "different-from-requested-content", actual);
3300
+ } catch {
3301
+ return finalPathInspection(
3302
+ displayPath,
3303
+ physicalEntryChanged ? "different-entry" : "not-verified",
3304
+ actual,
3305
+ );
3306
+ }
3307
+ }
3308
+ if (physicalEntryChanged) {
3309
+ return finalPathInspection(displayPath, "different-entry", actual);
3310
+ }
3311
+ if (
3312
+ (actual.kind === "regular" || actual.kind === "symlink") &&
3313
+ (expected.kind === "regular" || expected.kind === "symlink") &&
3314
+ expected.content.value
3315
+ ) {
3316
+ try {
3317
+ const bytes = await filesystem.readFile(absolutePath);
3318
+ return finalPathInspection(
3319
+ displayPath,
3320
+ buffersEqual(bytes, expected.content.value.bytes)
3321
+ ? "unchanged"
3322
+ : "different-from-previous-content",
3323
+ actual,
3324
+ );
3325
+ } catch {
3326
+ return finalPathInspection(displayPath, "not-verified", actual);
3327
+ }
3328
+ }
3329
+ if (entriesHaveSameIdentity(actual, expected)) {
3330
+ return finalPathInspection(displayPath, "unchanged", actual);
3331
+ }
3332
+ if (actual.kind !== expected.kind && actual.kind !== "absent" && expected.kind !== "absent") {
3333
+ return finalPathInspection(displayPath, "different-entry-type", actual);
3334
+ }
3335
+ if (
3336
+ actual.kind === expected.kind &&
3337
+ "fingerprint" in actual &&
3338
+ actual.fingerprint &&
3339
+ "fingerprint" in expected &&
3340
+ expected.fingerprint
3341
+ ) {
3342
+ return finalPathInspection(displayPath, "different-entry", actual);
3343
+ }
3344
+ return finalPathInspection(displayPath, currentEntryFinalState(actual), actual);
3345
+ } catch {
3346
+ return finalPathInspection(displayPath, "not-verified");
3347
+ }
3348
+ }
3349
+
3350
+ function inspectedFileEntry(
3351
+ inspection: ApplyPatchFinalPathInspection | undefined,
3352
+ ): ApplyPatchFileEntryDetails | undefined {
3353
+ const entry = inspection?.entry;
3354
+ return entry?.kind === "regular" || entry?.kind === "symlink"
3355
+ ? fileEntryDetails(entry)
3356
+ : undefined;
3357
+ }
3358
+
3359
+ function addInspectedReplacementEffect(
3360
+ instruction: ApplyPatchInstructionDetails,
3361
+ path: string,
3362
+ previousEntry: ExistingFileEntry,
3363
+ inspection: ApplyPatchFinalPathInspection | undefined,
3364
+ ): void {
3365
+ const replacementEntry = inspectedFileEntry(inspection);
3366
+ if (!replacementEntry) return;
3367
+ addInstructionEffect(
3368
+ instruction,
3369
+ replacedInstructionEffect(path, previousEntry, replacementEntry),
3370
+ );
3371
+ }
3372
+
3373
+ function finalStateHasChangedPresentEntry(
3374
+ state: ApplyPatchFinalPathState | undefined,
3375
+ expected: VirtualEntry,
3376
+ ): boolean {
3377
+ if (!state) return false;
3378
+ switch (state.state) {
3379
+ case "requested-content":
3380
+ case "different-from-requested-content":
3381
+ case "different-from-requested-and-previous-content":
3382
+ case "different-from-previous-content":
3383
+ case "different-entry":
3384
+ case "different-entry-type":
3385
+ return true;
3386
+ case "regular-file":
3387
+ case "symlink":
3388
+ case "directory":
3389
+ case "other-entry":
3390
+ return expected.kind === "absent";
3391
+ case "unchanged":
3392
+ case "absent":
3393
+ case "not-verified":
3394
+ return false;
3395
+ }
3396
+ }
3397
+
3398
+ async function recordFailureInspection(
3399
+ mutation: PlannedMutation,
3400
+ instruction: ApplyPatchInstructionDetails,
3401
+ filesystem: ApplyPatchExecutionFilesystem,
3402
+ filesystemMutationStarted: boolean,
3403
+ temporaryPath?: string,
3404
+ ): Promise<void> {
3405
+ const inspected: ApplyPatchFinalPathInspection[] = [];
3406
+ if (mutation.kind === "add") {
3407
+ inspected.push(
3408
+ await inspectFinalPath(
3409
+ mutation.operation.absolutePath,
3410
+ mutation.operation.path,
3411
+ mutation.expectedTarget,
3412
+ filesystem,
3413
+ mutation.content,
3414
+ ),
3415
+ );
3416
+ } else if (mutation.kind === "delete") {
3417
+ inspected.push(
3418
+ await inspectFinalPath(
3419
+ mutation.operation.absolutePath,
3420
+ mutation.operation.path,
3421
+ mutation.expectedTarget,
3422
+ filesystem,
3423
+ ),
3424
+ );
3425
+ } else if (mutation.kind === "text-update") {
3426
+ inspected.push(
3427
+ await inspectFinalPath(
3428
+ mutation.operation.absolutePath,
3429
+ mutation.operation.path,
3430
+ mutation.expectedSource,
3431
+ filesystem,
3432
+ mutation.operation.moveAbsolutePath ? undefined : mutation.content,
3433
+ ),
3434
+ );
3435
+ if (mutation.operation.moveAbsolutePath && mutation.expectedDestination) {
3436
+ inspected.push(
3437
+ await inspectFinalPath(
3438
+ mutation.operation.moveAbsolutePath,
3439
+ mutation.operation.moveTo!,
3440
+ mutation.expectedDestination,
3441
+ filesystem,
3442
+ mutation.content,
3443
+ ),
3444
+ );
3445
+ }
3446
+ } else {
3447
+ inspected.push(
3448
+ await inspectFinalPath(
3449
+ mutation.operation.absolutePath,
3450
+ mutation.operation.path,
3451
+ mutation.expectedSource,
3452
+ filesystem,
3453
+ ),
3454
+ );
3455
+ inspected.push(
3456
+ await inspectFinalPath(
3457
+ mutation.operation.moveAbsolutePath!,
3458
+ mutation.operation.moveTo!,
3459
+ mutation.expectedDestination,
3460
+ filesystem,
3461
+ ),
3462
+ );
3463
+ }
3464
+
3465
+ for (const inspection of inspected) {
3466
+ addInstructionFinalState(instruction, inspection.finalState);
3467
+ }
3468
+ if (!filesystemMutationStarted) return;
3469
+
3470
+ const sourceInspection = inspected.find(
3471
+ ({ finalState }) => finalState.path === mutation.operation.path,
3472
+ );
3473
+ const sourceState = sourceInspection?.finalState;
3474
+ const destinationPath =
3475
+ mutation.kind === "text-update" || mutation.kind === "move"
3476
+ ? mutation.operation.moveTo
3477
+ : undefined;
3478
+ const destinationInspection = destinationPath
3479
+ ? inspected.find(({ finalState }) => finalState.path === destinationPath)
3480
+ : undefined;
3481
+ const destinationState = destinationInspection?.finalState;
3482
+
3483
+ if (mutation.kind === "add") {
3484
+ if (finalStateHasChangedPresentEntry(sourceState, mutation.expectedTarget)) {
3485
+ if (mutation.expectedTarget.kind === "absent") {
3486
+ addInstructionEffect(instruction, {
3487
+ kind: "created",
3488
+ path: mutation.operation.path,
3489
+ });
3490
+ } else {
3491
+ addInspectedReplacementEffect(
3492
+ instruction,
3493
+ mutation.operation.path,
3494
+ mutation.expectedTarget,
3495
+ sourceInspection,
3496
+ );
3497
+ }
3498
+ }
3499
+ } else if (mutation.kind === "delete") {
3500
+ if (sourceState?.state === "absent") {
3501
+ addInstructionEffect(instruction, { kind: "deleted", path: mutation.operation.path });
3502
+ } else if (finalStateHasChangedPresentEntry(sourceState, mutation.expectedTarget)) {
3503
+ addInspectedReplacementEffect(
3504
+ instruction,
3505
+ mutation.operation.path,
3506
+ mutation.expectedTarget,
3507
+ sourceInspection,
3508
+ );
3509
+ }
3510
+ } else if (mutation.kind === "text-update") {
3511
+ if (mutation.operation.moveTo) {
3512
+ if (
3513
+ destinationState &&
3514
+ mutation.expectedDestination &&
3515
+ finalStateHasChangedPresentEntry(destinationState, mutation.expectedDestination)
3516
+ ) {
3517
+ if (mutation.expectedDestination.kind === "absent") {
3518
+ addInstructionEffect(instruction, {
3519
+ kind: "created",
3520
+ path: mutation.operation.moveTo,
3521
+ });
3522
+ } else {
3523
+ addInspectedReplacementEffect(
3524
+ instruction,
3525
+ mutation.operation.moveTo,
3526
+ mutation.expectedDestination,
3527
+ destinationInspection,
3528
+ );
3529
+ }
3530
+ } else if (
3531
+ destinationState?.state === "absent" &&
3532
+ mutation.expectedDestination?.kind !== "absent"
3533
+ ) {
3534
+ addInstructionEffect(instruction, { kind: "deleted", path: mutation.operation.moveTo });
3535
+ }
3536
+ if (sourceState?.state === "unchanged") {
3537
+ addInstructionEffect(instruction, {
3538
+ kind: "source-remains",
3539
+ path: mutation.operation.path,
3540
+ });
3541
+ } else if (sourceState?.state === "absent") {
3542
+ addInstructionEffect(instruction, { kind: "deleted", path: mutation.operation.path });
3543
+ } else if (finalStateHasChangedPresentEntry(sourceState, mutation.expectedSource)) {
3544
+ addInspectedReplacementEffect(
3545
+ instruction,
3546
+ mutation.operation.path,
3547
+ mutation.expectedSource,
3548
+ sourceInspection,
3549
+ );
3550
+ }
3551
+ } else if (sourceState?.state === "absent") {
3552
+ addInstructionEffect(instruction, { kind: "deleted", path: mutation.operation.path });
3553
+ } else if (finalStateHasChangedPresentEntry(sourceState, mutation.expectedSource)) {
3554
+ addInstructionEffect(instruction, { kind: "updated", path: mutation.operation.path });
3555
+ }
3556
+ } else {
3557
+ if (
3558
+ destinationState &&
3559
+ finalStateHasChangedPresentEntry(destinationState, mutation.expectedDestination)
3560
+ ) {
3561
+ if (mutation.expectedDestination.kind === "absent") {
3562
+ addInstructionEffect(instruction, {
3563
+ kind: "created",
3564
+ path: mutation.operation.moveTo!,
3565
+ });
3566
+ } else {
3567
+ addInspectedReplacementEffect(
3568
+ instruction,
3569
+ mutation.operation.moveTo!,
3570
+ mutation.expectedDestination,
3571
+ destinationInspection,
3572
+ );
3573
+ }
3574
+ } else if (
3575
+ destinationState?.state === "absent" &&
3576
+ mutation.expectedDestination.kind !== "absent"
3577
+ ) {
3578
+ addInstructionEffect(instruction, {
3579
+ kind: "deleted",
3580
+ path: mutation.operation.moveTo!,
3581
+ });
3582
+ }
3583
+ if (sourceState?.state === "unchanged") {
3584
+ addInstructionEffect(instruction, {
3585
+ kind: "source-remains",
3586
+ path: mutation.operation.path,
3587
+ });
3588
+ } else if (sourceState?.state === "absent") {
3589
+ addInstructionEffect(instruction, { kind: "deleted", path: mutation.operation.path });
3590
+ } else if (finalStateHasChangedPresentEntry(sourceState, mutation.expectedSource)) {
3591
+ addInspectedReplacementEffect(
3592
+ instruction,
3593
+ mutation.operation.path,
3594
+ mutation.expectedSource,
3595
+ sourceInspection,
1060
3596
  );
1061
3597
  }
3598
+ }
3599
+
3600
+ const createdParents = "parents" in mutation ? mutation.parents.createdPaths : [];
3601
+ for (const parent of createdParents) {
3602
+ try {
3603
+ if ((await filesystem.lstat(parent)).isDirectory()) {
3604
+ addInstructionEffect(instruction, { kind: "directory-created", path: parent });
3605
+ }
3606
+ } catch {}
3607
+ }
3608
+
3609
+ if (temporaryPath) {
3610
+ try {
3611
+ await filesystem.lstat(temporaryPath);
3612
+ addInstructionEffect(instruction, {
3613
+ kind: "temporary-entry-remains",
3614
+ path: temporaryPath,
3615
+ });
3616
+ } catch {}
3617
+ }
3618
+ }
3619
+
3620
+ function recordAppliedInstructionEffects(
3621
+ mutation: PlannedMutation,
3622
+ instruction: ApplyPatchInstructionDetails,
3623
+ ): void {
3624
+ switch (mutation.kind) {
3625
+ case "add":
3626
+ if (mutation.expectedTarget.kind !== "absent") {
3627
+ addInstructionEffect(
3628
+ instruction,
3629
+ replacedInstructionEffect(mutation.operation.path, mutation.expectedTarget, {
3630
+ entryType: "regular-file",
3631
+ }),
3632
+ );
3633
+ }
3634
+ return;
3635
+ case "delete":
3636
+ if (mutation.expectedTarget.kind === "symlink") {
3637
+ addInstructionEffect(instruction, {
3638
+ kind: "symlink-removed",
3639
+ path: mutation.operation.path,
3640
+ target: mutation.expectedTarget.target,
3641
+ });
3642
+ }
3643
+ return;
3644
+ case "text-update":
3645
+ if (mutation.expectedSource.kind === "symlink") {
3646
+ addInstructionEffect(
3647
+ instruction,
3648
+ mutation.operation.moveTo
3649
+ ? {
3650
+ kind: "symlink-removed",
3651
+ path: mutation.operation.path,
3652
+ target: mutation.expectedSource.target,
3653
+ }
3654
+ : {
3655
+ kind: "symlink-target-modified",
3656
+ path: mutation.operation.path,
3657
+ target: mutation.expectedSource.target,
3658
+ },
3659
+ );
3660
+ }
3661
+ if (
3662
+ mutation.operation.moveTo &&
3663
+ mutation.expectedDestination &&
3664
+ mutation.expectedDestination.kind !== "absent"
3665
+ ) {
3666
+ addInstructionEffect(
3667
+ instruction,
3668
+ replacedInstructionEffect(mutation.operation.moveTo, mutation.expectedDestination, {
3669
+ entryType: "regular-file",
3670
+ }),
3671
+ );
3672
+ }
3673
+ return;
3674
+ case "move":
3675
+ if (mutation.expectedSource.kind === "symlink") {
3676
+ addInstructionEffect(instruction, {
3677
+ kind: "symlink-moved",
3678
+ path: mutation.operation.path,
3679
+ target: mutation.expectedSource.target,
3680
+ });
3681
+ }
3682
+ if (mutation.change.replacedDestination) {
3683
+ addInstructionEffect(
3684
+ instruction,
3685
+ replacedInstructionEffect(
3686
+ mutation.operation.moveTo!,
3687
+ mutation.expectedDestination,
3688
+ fileEntryDetails(mutation.expectedSource),
3689
+ ),
3690
+ );
3691
+ }
3692
+ return;
3693
+ }
3694
+ }
3695
+
3696
+ async function executePlan(
3697
+ plan: SemanticPlan,
3698
+ signal: AbortSignal | undefined,
3699
+ filesystem: ApplyPatchExecutionFilesystem,
3700
+ onProgress?: (details: ApplyPatchDetails) => void,
3701
+ ): Promise<ApplyPatchDetails> {
3702
+ const details = emptyDetails();
3703
+ details.exact = plan.exact;
3704
+ details.instructions = plan.instructions.map((instruction) => ({ ...instruction }));
3705
+ let activeInstruction: ApplyPatchInstructionDetails | undefined;
3706
+ let activeMutation: PlannedMutation | undefined;
3707
+ let activeTemporaryPath: string | undefined;
3708
+ let activeFilesystemMutationStarted = false;
3709
+ const committedEntryMutations: CommittedEntryMutation[] = [];
3710
+ try {
3711
+ for (const mutation of plan.mutations) {
3712
+ activeInstruction = details.instructions[mutation.instructionIndex];
3713
+ activeMutation = mutation;
3714
+ activeTemporaryPath = undefined;
3715
+ activeFilesystemMutationStarted = false;
3716
+ throwIfAborted(signal);
3717
+ if (mutation.kind === "add") {
3718
+ await assertMutationEntryMatches(
3719
+ mutation.operation.absolutePath,
3720
+ mutation.targetKey,
3721
+ mutation.expectedTarget,
3722
+ committedEntryMutations,
3723
+ );
3724
+ await assertParentPlanMatches(mutation.parents);
3725
+ try {
3726
+ activeFilesystemMutationStarted = true;
3727
+ await createPlannedParents(mutation.parents, filesystem);
3728
+ await replaceRegularFile(
3729
+ mutation.operation.absolutePath,
3730
+ mutation.content,
3731
+ filesystem,
3732
+ mutation.replacementMode,
3733
+ );
3734
+ } catch (error) {
3735
+ details.exact = false;
3736
+ if (error instanceof RegularFileReplacementError) {
3737
+ activeTemporaryPath = error.temporaryPath;
3738
+ if (error.destinationChanged) {
3739
+ appendChange(details, mutation.change, mutation.instructionIndex);
3740
+ if (activeInstruction) {
3741
+ addInstructionEffect(
3742
+ activeInstruction,
3743
+ mutation.expectedTarget.kind === "absent"
3744
+ ? { kind: "created", path: mutation.operation.path }
3745
+ : replacedInstructionEffect(mutation.operation.path, mutation.expectedTarget, {
3746
+ entryType: "regular-file",
3747
+ }),
3748
+ );
3749
+ }
3750
+ }
3751
+ }
3752
+ throw new Error(
3753
+ `Failed to write file ${mutation.operation.absolutePath}: ${errorMessage(error)}`,
3754
+ );
3755
+ }
3756
+ appendChange(details, mutation.change, mutation.instructionIndex);
3757
+ } else if (mutation.kind === "delete") {
3758
+ await assertMutationEntryMatches(
3759
+ mutation.operation.absolutePath,
3760
+ mutation.targetKey,
3761
+ mutation.expectedTarget,
3762
+ committedEntryMutations,
3763
+ );
3764
+ try {
3765
+ activeFilesystemMutationStarted = true;
3766
+ await filesystem.unlink(mutation.operation.absolutePath);
3767
+ } catch (error) {
3768
+ throw new Error(
3769
+ `Failed to delete file ${mutation.operation.absolutePath}: ${errorMessage(error)}`,
3770
+ );
3771
+ }
3772
+ appendChange(details, mutation.change, mutation.instructionIndex);
3773
+ } else if (mutation.kind === "text-update") {
3774
+ await assertMutationEntryMatches(
3775
+ mutation.operation.absolutePath,
3776
+ mutation.sourceKey,
3777
+ mutation.expectedSource,
3778
+ committedEntryMutations,
3779
+ );
3780
+ if (mutation.sameEntryMove) {
3781
+ try {
3782
+ activeFilesystemMutationStarted = true;
3783
+ await replaceRegularFile(
3784
+ mutation.operation.absolutePath,
3785
+ mutation.content,
3786
+ filesystem,
3787
+ mutation.replacementMode,
3788
+ );
3789
+ } catch (error) {
3790
+ details.exact = false;
3791
+ if (error instanceof RegularFileReplacementError) {
3792
+ activeTemporaryPath = error.temporaryPath;
3793
+ if (error.destinationChanged) {
3794
+ appendChange(details, mutation.provisionalChange!, mutation.instructionIndex);
3795
+ if (activeInstruction) {
3796
+ addInstructionEffect(activeInstruction, {
3797
+ kind: "updated",
3798
+ path: mutation.operation.path,
3799
+ });
3800
+ }
3801
+ }
3802
+ }
3803
+ throw new Error(
3804
+ `Failed to write file ${mutation.operation.absolutePath}: ${errorMessage(error)}`,
3805
+ );
3806
+ }
3807
+ appendChange(details, mutation.provisionalChange!, mutation.instructionIndex);
3808
+ if (mutation.sameEntryMove === "rename") {
3809
+ try {
3810
+ await filesystem.rename(
3811
+ mutation.operation.absolutePath,
3812
+ mutation.operation.moveAbsolutePath!,
3813
+ );
3814
+ await finishSameInodeRename(
3815
+ mutation.operation.absolutePath,
3816
+ mutation.operation.moveAbsolutePath!,
3817
+ filesystem,
3818
+ );
3819
+ } catch (error) {
3820
+ details.exact = false;
3821
+ throw new Error(
3822
+ `Failed to establish move from ${mutation.operation.absolutePath} to ${mutation.operation.moveAbsolutePath}: ${errorMessage(error)}`,
3823
+ );
3824
+ }
3825
+ }
3826
+ details.changes[details.changes.length - 1] = mutation.change;
3827
+ details.modified[details.modified.length - 1] = mutation.operation.moveTo!;
3828
+ } else if (mutation.operation.moveAbsolutePath && mutation.expectedDestination) {
3829
+ await assertMutationEntryMatches(
3830
+ mutation.operation.moveAbsolutePath,
3831
+ mutation.destinationKey!,
3832
+ mutation.expectedDestination,
3833
+ committedEntryMutations,
3834
+ );
3835
+ await assertParentPlanMatches(mutation.parents);
3836
+ try {
3837
+ activeFilesystemMutationStarted = true;
3838
+ await createPlannedParents(mutation.parents, filesystem);
3839
+ await replaceRegularFile(
3840
+ mutation.operation.moveAbsolutePath,
3841
+ mutation.content,
3842
+ filesystem,
3843
+ mutation.replacementMode,
3844
+ );
3845
+ } catch (error) {
3846
+ details.exact = false;
3847
+ if (error instanceof RegularFileReplacementError) {
3848
+ activeTemporaryPath = error.temporaryPath;
3849
+ if (error.destinationChanged) {
3850
+ appendChange(details, mutation.provisionalChange!, mutation.instructionIndex);
3851
+ if (activeInstruction) {
3852
+ addInstructionEffect(
3853
+ activeInstruction,
3854
+ mutation.expectedDestination.kind === "absent"
3855
+ ? { kind: "created", path: mutation.operation.moveTo! }
3856
+ : replacedInstructionEffect(
3857
+ mutation.operation.moveTo!,
3858
+ mutation.expectedDestination,
3859
+ { entryType: "regular-file" },
3860
+ ),
3861
+ );
3862
+ }
3863
+ }
3864
+ }
3865
+ throw new Error(
3866
+ `Failed to write file ${mutation.operation.moveAbsolutePath}: ${errorMessage(error)}`,
3867
+ );
3868
+ }
3869
+ appendChange(details, mutation.provisionalChange!, mutation.instructionIndex);
3870
+ try {
3871
+ await filesystem.unlink(mutation.operation.absolutePath);
3872
+ } catch (error) {
3873
+ details.exact = false;
3874
+ throw new Error(
3875
+ `Failed to remove original ${mutation.operation.absolutePath}: ${errorMessage(error)}`,
3876
+ );
3877
+ }
3878
+ details.changes[details.changes.length - 1] = mutation.change;
3879
+ details.added.pop();
3880
+ details.modified.push(mutation.operation.moveTo!);
3881
+ } else {
3882
+ try {
3883
+ activeFilesystemMutationStarted = true;
3884
+ await filesystem.writeFile(mutation.operation.absolutePath, mutation.content);
3885
+ } catch (error) {
3886
+ details.exact = false;
3887
+ throw new Error(
3888
+ `Failed to write file ${mutation.operation.absolutePath}: ${errorMessage(error)}`,
3889
+ );
3890
+ }
3891
+ appendChange(details, mutation.change, mutation.instructionIndex);
3892
+ }
3893
+ } else {
3894
+ await assertMutationEntryMatches(
3895
+ mutation.operation.absolutePath,
3896
+ mutation.sourceKey,
3897
+ mutation.expectedSource,
3898
+ committedEntryMutations,
3899
+ );
3900
+ await assertMutationEntryMatches(
3901
+ mutation.operation.moveAbsolutePath!,
3902
+ mutation.destinationKey,
3903
+ mutation.expectedDestination,
3904
+ committedEntryMutations,
3905
+ );
3906
+ await assertParentPlanMatches(mutation.parents);
3907
+ try {
3908
+ activeFilesystemMutationStarted = true;
3909
+ await createPlannedParents(mutation.parents, filesystem);
3910
+ await executePureMove(mutation, filesystem);
3911
+ } catch (error) {
3912
+ if (error instanceof PureMoveExecutionError) {
3913
+ activeTemporaryPath = error.temporaryPath;
3914
+ }
3915
+ if (mutation.parents.createdPaths.length > 0) details.exact = false;
3916
+ if (
3917
+ error instanceof PureMoveExecutionError &&
3918
+ (error.destinationState === "created" || error.destinationState === "replaced")
3919
+ ) {
3920
+ const inexactMove = { ...mutation.change, exact: false };
3921
+ appendChange(details, inexactMove, mutation.instructionIndex);
3922
+ if (activeInstruction) {
3923
+ addInstructionEffect(
3924
+ activeInstruction,
3925
+ error.destinationState === "created"
3926
+ ? { kind: "created", path: mutation.operation.moveTo! }
3927
+ : replacedInstructionEffect(
3928
+ mutation.operation.moveTo!,
3929
+ mutation.expectedDestination,
3930
+ fileEntryDetails(mutation.expectedSource),
3931
+ ),
3932
+ );
3933
+ }
3934
+ details.exact = false;
3935
+ }
3936
+ if (error instanceof PureMoveExecutionError && error.destinationState === "removed") {
3937
+ if (activeInstruction) {
3938
+ addInstructionEffect(activeInstruction, {
3939
+ kind: "deleted",
3940
+ path: mutation.operation.moveTo!,
3941
+ });
3942
+ }
3943
+ details.exact = false;
3944
+ }
3945
+ throw new Error(
3946
+ `Failed to move ${mutation.operation.absolutePath} to ${mutation.operation.moveAbsolutePath}: ${errorMessage(error)}`,
3947
+ );
3948
+ }
3949
+ appendChange(details, mutation.change, mutation.instructionIndex);
3950
+ }
3951
+ if (activeInstruction) recordAppliedInstructionEffects(mutation, activeInstruction);
3952
+ try {
3953
+ committedEntryMutations.push(
3954
+ ...(await captureCommittedEntryMutations(mutation.entryMutations)),
3955
+ );
3956
+ } catch (error) {
3957
+ details.exact = false;
3958
+ throw error;
3959
+ }
3960
+ if (activeInstruction) {
3961
+ activeInstruction.status = "applied";
3962
+ delete activeInstruction.error;
3963
+ }
3964
+ activeInstruction = undefined;
3965
+ activeMutation = undefined;
3966
+ throwIfAborted(signal);
3967
+ onProgress?.(cloneApplyPatchDetails(details));
3968
+ }
3969
+ return details;
3970
+ } catch (error) {
3971
+ const message = errorMessage(error);
3972
+ if (activeInstruction) {
3973
+ if (activeMutation) {
3974
+ await recordFailureInspection(
3975
+ activeMutation,
3976
+ activeInstruction,
3977
+ filesystem,
3978
+ activeFilesystemMutationStarted,
3979
+ activeTemporaryPath,
3980
+ );
3981
+ }
3982
+ activeInstruction.status = "failed";
3983
+ activeInstruction.error = message;
3984
+ }
3985
+ for (const instruction of details.instructions) {
3986
+ if (instruction.status === "planned") instruction.status = "not-run";
3987
+ }
3988
+ details.status = "failed";
3989
+ details.error = message;
3990
+ details.failure = {
3991
+ phase: "execution",
3992
+ message,
3993
+ ...(activeInstruction ? { failedInstruction: activeInstruction.index } : {}),
3994
+ };
3995
+ throw new ApplyPatchExecutionError(details.error, cloneApplyPatchDetails(details));
3996
+ }
3997
+ }
3998
+
3999
+ type ScannedPatchInstruction = ApplyPatchInstructionDetails & { sourceLine: number };
4000
+
4001
+ function scanPatchInstructions(patch: string): ScannedPatchInstruction[] {
4002
+ const instructions: ScannedPatchInstruction[] = [];
4003
+ let current: ScannedPatchInstruction | undefined;
4004
+ let mode: ParserMode["kind"] = "not-started";
4005
+ let lines = patch.split("\n").map((line) => (line.endsWith("\r") ? line.slice(0, -1) : line));
4006
+ const first = lines[0];
4007
+ const last = lines.at(-1);
4008
+ if ((first === "<<EOF" || first === "<<'EOF'" || first === '<<"EOF"') && last?.endsWith("EOF")) {
4009
+ lines = lines.slice(1, -1);
4010
+ }
4011
+
4012
+ for (const [lineIndex, rawLine] of lines.entries()) {
4013
+ const line: string = mode === "update" ? rustTrimEnd(rawLine) : rustTrim(rawLine);
4014
+ if (mode === "not-started") {
4015
+ if (rustTrim(line) === BEGIN_PATCH) mode = "started";
4016
+ continue;
4017
+ }
4018
+ if (mode === "ended") continue;
4019
+ if (line === END_PATCH) {
4020
+ mode = "ended";
4021
+ continue;
4022
+ }
4023
+ const headers = [
4024
+ { prefix: ADD_FILE, kind: "add" as const },
4025
+ { prefix: DELETE_FILE, kind: "delete" as const },
4026
+ { prefix: UPDATE_FILE, kind: "update" as const },
4027
+ ];
4028
+ const header: (typeof headers)[number] | undefined = headers.find(({ prefix }) =>
4029
+ line.startsWith(prefix),
4030
+ );
4031
+ if (header) {
4032
+ current = {
4033
+ index: instructions.length + 1,
4034
+ kind: header.kind,
4035
+ path: line.slice(header.prefix.length),
4036
+ status: "not-run",
4037
+ sourceLine: lineIndex + 1,
4038
+ };
4039
+ instructions.push(current);
4040
+ mode = header.kind;
4041
+ continue;
4042
+ }
4043
+ if (mode === "update" && current?.kind === "update" && line.startsWith(MOVE_TO)) {
4044
+ current.kind = "move";
4045
+ current.moveTo = line.slice(MOVE_TO.length);
4046
+ }
4047
+ }
4048
+ return instructions;
4049
+ }
4050
+
4051
+ function failedApplyPatchDetails(
4052
+ phase: ApplyPatchFailureDetails["phase"],
4053
+ message: string,
4054
+ instructions: readonly ApplyPatchInstructionDetails[],
4055
+ failedInstruction?: number,
4056
+ matcher?: FormatterMatchFailureDetails,
4057
+ ): ApplyPatchDetails {
4058
+ const details = emptyDetails();
4059
+ details.status = "failed";
4060
+ details.error = message;
4061
+ details.instructions = instructions.map((instruction) => ({ ...instruction }));
4062
+ if (failedInstruction !== undefined) {
4063
+ const failed = details.instructions.find(
4064
+ (instruction) => instruction.index === failedInstruction,
4065
+ );
4066
+ if (failed) {
4067
+ failed.status = "failed";
4068
+ failed.error = message;
4069
+ }
4070
+ }
4071
+ details.failure = {
4072
+ phase,
4073
+ message,
4074
+ ...(failedInstruction !== undefined ? { failedInstruction } : {}),
4075
+ ...(matcher ? { matcher } : {}),
4076
+ };
4077
+ return details;
4078
+ }
4079
+
4080
+ function parseFailureDetails(patch: string, error: unknown): ApplyPatchDetails {
4081
+ const scanned = scanPatchInstructions(patch);
4082
+ const lineNumber = error instanceof ApplyPatchParseError ? error.lineNumber : undefined;
4083
+ const failedInstruction =
4084
+ lineNumber === undefined
4085
+ ? undefined
4086
+ : scanned.findLast((instruction) => instruction.sourceLine <= lineNumber)?.index;
4087
+ return failedApplyPatchDetails("parse", errorMessage(error), scanned, failedInstruction);
4088
+ }
4089
+
4090
+ function parseAndResolvePatch(cwd: string, patch: string): ResolvedOperation[] {
4091
+ const parsed = parsePatchDocument(patch);
4092
+ if (parsed.environmentId) {
4093
+ throw new ApplyPatchInputError(
4094
+ "apply_patch environment selection is unavailable for this turn",
4095
+ );
4096
+ }
4097
+ if (parsed.operations.length === 0) {
4098
+ throw new ApplyPatchInputError("patch rejected: empty patch");
4099
+ }
4100
+ return resolveOperations(cwd, parsed.operations);
4101
+ }
4102
+
4103
+ async function buildPlan(
4104
+ operations: readonly ResolvedOperation[],
4105
+ signal?: AbortSignal,
4106
+ selectMoveStrategy?: ApplyPatchExecutionHooks["selectMoveStrategy"],
4107
+ ): Promise<SemanticPlan> {
4108
+ try {
4109
+ return await new SemanticPlanner(operations, signal, selectMoveStrategy).plan();
4110
+ } catch (error) {
4111
+ if (error instanceof ApplyPatchInputError) throw error;
4112
+ const message = errorMessage(error);
4113
+ const instructions =
4114
+ error instanceof SemanticPlanningError
4115
+ ? error.instructions
4116
+ : operations.map(instructionForOperation);
4117
+ const details = failedApplyPatchDetails(
4118
+ "preflight",
4119
+ message,
4120
+ instructions,
4121
+ error instanceof SemanticPlanningError ? error.failedInstruction : undefined,
4122
+ error instanceof SemanticPlanningError ? error.matcher : undefined,
4123
+ );
4124
+ throw new ApplyPatchVerificationError(`apply_patch verification failed: ${message}`, details);
4125
+ }
4126
+ }
4127
+
4128
+ export async function previewPatch(cwd: string, patch: string): Promise<ApplyPatchDetails> {
4129
+ const operations = parseAndResolvePatch(cwd, patch);
4130
+ return previewDetailsForPlan(await buildPlan(operations), cwd);
4131
+ }
4132
+
4133
+ export async function applyPatch(
4134
+ cwd: string,
4135
+ patch: string,
4136
+ signal?: AbortSignal,
4137
+ hooks: ApplyPatchExecutionHooks = {},
4138
+ ): Promise<ApplyPatchDetails> {
4139
+ try {
1062
4140
  throwIfAborted(signal);
1063
- hooks.onExecutionStart?.();
1064
- return applyOperations(operations, signal, hooks.onProgress);
1065
- });
4141
+ } catch (error) {
4142
+ const message = errorMessage(error);
4143
+ throw new ApplyPatchInputError(
4144
+ message,
4145
+ failedApplyPatchDetails("input", message, scanPatchInstructions(patch)),
4146
+ );
4147
+ }
4148
+ let operations: ResolvedOperation[];
4149
+ try {
4150
+ operations = parseAndResolvePatch(cwd, patch);
4151
+ } catch (error) {
4152
+ if (error instanceof ApplyPatchInputError) {
4153
+ throw new ApplyPatchInputError(
4154
+ error.message,
4155
+ error.details ??
4156
+ failedApplyPatchDetails("input", error.message, scanPatchInstructions(patch)),
4157
+ );
4158
+ }
4159
+ const message = errorMessage(error);
4160
+ throw new ApplyPatchVerificationError(
4161
+ `apply_patch verification failed: ${message}`,
4162
+ parseFailureDetails(patch, error),
4163
+ );
4164
+ }
4165
+
4166
+ try {
4167
+ const queueTargets = mutationQueueTargets(operations);
4168
+ const logicalKeys = await logicalMutationQueueKeys(queueTargets);
4169
+ const queuePaths = await canonicalMutationQueuePaths(queueTargets);
4170
+ const filesystem: ApplyPatchExecutionFilesystem = {
4171
+ ...DEFAULT_EXECUTION_FILESYSTEM,
4172
+ ...hooks.filesystem,
4173
+ };
4174
+
4175
+ return await withLogicalMutationQueues(logicalKeys, () => {
4176
+ return withMutationQueues(queuePaths, async () => {
4177
+ throwIfAborted(signal);
4178
+ const plan = await buildPlan(operations, signal, hooks.selectMoveStrategy);
4179
+ throwIfAborted(signal);
4180
+ await hooks.onExecutionStart?.();
4181
+ return executePlan(plan, signal, filesystem, hooks.onProgress);
4182
+ });
4183
+ });
4184
+ } catch (error) {
4185
+ if (
4186
+ error instanceof ApplyPatchInputError ||
4187
+ error instanceof ApplyPatchVerificationError ||
4188
+ error instanceof ApplyPatchExecutionError
4189
+ ) {
4190
+ throw error;
4191
+ }
4192
+ const message = errorMessage(error);
4193
+ const details = failedApplyPatchDetails(
4194
+ "preflight",
4195
+ message,
4196
+ operations.map(instructionForOperation),
4197
+ );
4198
+ throw new ApplyPatchVerificationError(`apply_patch verification failed: ${message}`, details);
4199
+ }
4200
+ }
4201
+
4202
+ export function formatApplyPatchInstructionLabel(
4203
+ instruction: ApplyPatchInstructionDetails,
4204
+ ): string {
4205
+ const verb =
4206
+ instruction.kind === "add"
4207
+ ? "Add"
4208
+ : instruction.kind === "delete"
4209
+ ? "Delete"
4210
+ : instruction.kind === "move"
4211
+ ? "Move"
4212
+ : "Update";
4213
+ if (!instruction.moveTo) return `${verb} ${instruction.path}`;
4214
+ return instruction.kind === "update"
4215
+ ? `Update & Move ${instruction.path} -> ${instruction.moveTo}`
4216
+ : `${verb} ${instruction.path} -> ${instruction.moveTo}`;
4217
+ }
4218
+
4219
+ function feedbackPath(path: string, cwd: string): string {
4220
+ if (!isAbsolute(path)) return path;
4221
+ const relativePath = relative(cwd, path);
4222
+ return relativePath === "" ||
4223
+ relativePath === ".." ||
4224
+ relativePath.startsWith(`..${sep}`) ||
4225
+ isAbsolute(relativePath)
4226
+ ? path
4227
+ : relativePath;
4228
+ }
4229
+
4230
+ function matcherRangeLabel(range: { startLine: number; endLine: number }): string {
4231
+ return range.startLine === range.endLine
4232
+ ? `line ${range.startLine}`
4233
+ : `lines ${range.startLine}-${range.endLine}`;
4234
+ }
4235
+
4236
+ const UPDATED_PATCH_GUIDANCE =
4237
+ "Use apply_patch again with more specific surrounding context or smaller changes if needed.";
4238
+
4239
+ function matcherInstructionFeedback(matcher: FormatterMatchFailureDetails): string {
4240
+ const ranges = matcher.candidates.map(matcherRangeLabel).join(" and ");
4241
+ switch (matcher.reason) {
4242
+ case "no-candidate": {
4243
+ const replacements = matcher.replacementCandidates?.map(matcherRangeLabel).join(" and ");
4244
+ return replacements
4245
+ ? `Requested replacement found at ${replacements}, but old content was not found. Inspect the reported lines and use apply_patch again with updated instructions if needed.`
4246
+ : "Old content was not found. Read the current file and use apply_patch again with updated instructions if needed.";
4247
+ }
4248
+ case "no-ordered-mapping": {
4249
+ const previous = matcher.previousCandidates?.map(matcherRangeLabel).join(" and ");
4250
+ if (matcher.reverseOrdered) {
4251
+ return `The requested changes match in reverse source-file order at ${ranges} and ${previous}. Use apply_patch again with the requested changes in source-file order if needed.`;
4252
+ }
4253
+ if (matcher.overlapping) {
4254
+ return `The requested changes overlap at ${ranges} and ${previous}. Use apply_patch again with non-overlapping changes if needed.`;
4255
+ }
4256
+ return `The requested changes cannot be matched in source-file order; matches were found at ${ranges} and ${previous}. Use apply_patch again with the requested changes in source-file order if needed.`;
4257
+ }
4258
+ case "too-many-candidates":
4259
+ return `${matcher.candidateCount} matching locations exceed the 64-location limit. ${UPDATED_PATCH_GUIDANCE}`;
4260
+ case "ambiguous-output":
4261
+ return `Matching locations${ranges ? ` at ${ranges}` : ""} produce different results. ${UPDATED_PATCH_GUIDANCE}`;
4262
+ case "mapping-limit":
4263
+ return `More than 256 possible ways to apply the requested changes were found. ${UPDATED_PATCH_GUIDANCE}`;
4264
+ case "overlapping-edits":
4265
+ return `The requested changes${ranges ? ` at ${ranges}` : ""} overlap. Use apply_patch again with non-overlapping changes if needed.`;
4266
+ }
4267
+ }
4268
+
4269
+ function conciseInstructionError(error: string): string {
4270
+ const message = error
4271
+ .replace(/^apply_patch verification failed:\s*/u, "")
4272
+ .replace(/^invalid patch:\s*/u, "")
4273
+ .replace(/^invalid hunk at line \d+,\s*/u, "")
4274
+ .replace(/^Failed to write file .*?:\s*/u, "Write failed: ")
4275
+ .replace(/^Failed to delete file .*?:\s*/u, "Delete failed: ")
4276
+ .replace(
4277
+ /^Failed to remove original .*?:\s*/u,
4278
+ "The updated content was written to the destination, but removing the source failed: ",
4279
+ )
4280
+ .replace(/^Failed to establish move from .*?:\s*/u, "Rename failed: ")
4281
+ .replace(/^Failed to read file to update .*?:\s*/u, "Read failed: ")
4282
+ .replace(/^Failed to inspect /u, "Failed to read filesystem metadata for ")
4283
+ .replace(/^Cannot add .*?: path is\s*/u, "Validation failed: Path is ")
4284
+ .replace(/^Cannot delete .*?: path is\s*/u, "Validation failed: Path is ")
4285
+ .replace(/^Cannot move update to .*?: destination is\s*/u, "Validation failed: Destination is ")
4286
+ .replace(/^Failed to move to .*?: destination is\s*/u, "Validation failed: Destination is ")
4287
+ .replace(/^Failed to move .*?: source is\s*/u, "Validation failed: Source is ")
4288
+ .replace(
4289
+ /^Failed to move .*?: source does not exist, and no earlier instruction moved it to .*$/u,
4290
+ "Validation failed: The move source does not exist, and no earlier instruction moved it to the destination.",
4291
+ )
4292
+ .replace(
4293
+ /^Cannot create .*?: parent path .*? is not a directory$/u,
4294
+ "Validation failed: Parent path is not a directory.",
4295
+ )
4296
+ .replace(/^Cannot determine filesystem for .*$/u, "Filesystem check failed.")
4297
+ .replace(/^Failed to move .*? to .*?:\s*/u, "Move failed: ")
4298
+ .replace(/^Failed to find context [^\n]*/u, "Context was not found.")
4299
+ .replace(/^Failed to find expected lines in [^\n]*/u, "Old content was not found.")
4300
+ .replace(
4301
+ /^Filesystem changed after apply_patch preflight at .*$/u,
4302
+ "Filesystem changed after validation.",
4303
+ )
4304
+ .replace(
4305
+ /^Filesystem changed while committing apply_patch at .*$/u,
4306
+ "Filesystem changed after the operation.",
4307
+ )
4308
+ .replace(/; destination was removed before replacement failed$/u, "")
4309
+ .replace(/^apply_patch was cancelled\.$/u, "apply_patch was cancelled.");
4310
+ return message.split("\n")[0]!;
4311
+ }
4312
+
4313
+ function fileEntryFeedback(entry: ApplyPatchFileEntryDetails): string {
4314
+ return entry.entryType === "regular-file" ? "a regular file" : `a symlink to ${entry.target}`;
4315
+ }
4316
+
4317
+ function instructionEffectFeedback(
4318
+ effect: ApplyPatchInstructionEffect,
4319
+ instruction: ApplyPatchInstructionDetails,
4320
+ cwd: string,
4321
+ ): string {
4322
+ const path = feedbackPath(effect.path, cwd);
4323
+ switch (effect.kind) {
4324
+ case "created":
4325
+ return `Created ${path}.`;
4326
+ case "replaced":
4327
+ if (
4328
+ effect.previousEntry.entryType === "regular-file" &&
4329
+ effect.replacementEntry.entryType === "regular-file"
4330
+ ) {
4331
+ return `${path} is still a regular file.`;
4332
+ }
4333
+ return `${path}, previously ${fileEntryFeedback(effect.previousEntry)}, is now ${fileEntryFeedback(effect.replacementEntry)}.`;
4334
+ case "updated":
4335
+ return `Updated ${path}.`;
4336
+ case "deleted":
4337
+ return `Deleted ${path}.`;
4338
+ case "directory-created":
4339
+ return `Created directory ${path}.`;
4340
+ case "temporary-entry-remains":
4341
+ return `Temporary entry remains at ${path}.`;
4342
+ case "source-remains":
4343
+ return `${path} remains.`;
4344
+ case "symlink-removed":
4345
+ return `Removed the symlink ${path}; its target was ${effect.target}.`;
4346
+ case "symlink-moved": {
4347
+ if (!instruction.moveTo) {
4348
+ throw new Error(`moved symlink effect for ${instruction.path} requires a destination`);
4349
+ }
4350
+ return instruction.effects?.some(
4351
+ (candidate) => candidate.kind === "replaced" && candidate.path === instruction.moveTo,
4352
+ )
4353
+ ? `Moved the symlink ${path}.`
4354
+ : `Moved the symlink ${path}; ${feedbackPath(instruction.moveTo, cwd)} is now a symlink to ${effect.target}.`;
4355
+ }
4356
+ case "symlink-target-modified":
4357
+ return `Modified file content through the symlink at ${path} (target: ${effect.target}); the symlink was not modified.`;
4358
+ }
4359
+ }
4360
+
4361
+ function finalStateFeedback(state: ApplyPatchFinalPathState, cwd: string): string {
4362
+ const path = feedbackPath(state.path, cwd);
4363
+ switch (state.state) {
4364
+ case "absent":
4365
+ return `${path} is absent.`;
4366
+ case "regular-file":
4367
+ return `${path} is present as a regular file.`;
4368
+ case "symlink":
4369
+ return `${path} is present as a symlink.`;
4370
+ case "directory":
4371
+ return `${path} is present as a directory.`;
4372
+ case "other-entry":
4373
+ return `${path} is present as another entry type.`;
4374
+ case "unchanged":
4375
+ return `${path} is unchanged.`;
4376
+ case "requested-content":
4377
+ return `The file at ${path} contains the requested content byte-for-byte despite the reported error.`;
4378
+ case "different-from-requested-content":
4379
+ return `The content at ${path} does not match the requested content byte-for-byte.`;
4380
+ case "different-from-requested-and-previous-content":
4381
+ return `The content at ${path} matches neither the requested content nor the previously observed content.`;
4382
+ case "different-from-previous-content":
4383
+ return `The content at ${path} does not match the previously observed content.`;
4384
+ case "different-entry":
4385
+ return `${path} is a different filesystem entry.`;
4386
+ case "different-entry-type":
4387
+ return `Entry type changed for ${path}.`;
4388
+ case "not-verified":
4389
+ return `Final state not verified for ${path}.`;
4390
+ }
4391
+ }
4392
+
4393
+ export function formatApplyPatchInstructionStatusLabel(
4394
+ status: ApplyPatchInstructionStatus,
4395
+ ): string {
4396
+ switch (status) {
4397
+ case "applied":
4398
+ return "APPLIED";
4399
+ case "planned":
4400
+ return "PLANNED";
4401
+ case "no-op":
4402
+ return "NO CHANGE";
4403
+ case "dead":
4404
+ return "SKIPPED";
4405
+ case "failed":
4406
+ return "FAILED";
4407
+ case "not-run":
4408
+ return "NOT RUN";
4409
+ }
4410
+ }
4411
+
4412
+ function sentenceClause(value: string): string {
4413
+ return value.endsWith(".") ? value.slice(0, -1) : value;
4414
+ }
4415
+
4416
+ export function formatApplyPatchInstructionFeedback(
4417
+ instruction: ApplyPatchInstructionDetails,
4418
+ details: ApplyPatchDetails,
4419
+ cwd = process.cwd(),
4420
+ ): string | undefined {
4421
+ const clauses: string[] = [];
4422
+ if (instruction.status === "no-op" || instruction.status === "dead") {
4423
+ if (instruction.reason) clauses.push(instruction.reason.message);
4424
+ }
4425
+ for (const effect of instruction.effects ?? []) {
4426
+ if (instruction.status === "failed" && effect.kind === "updated") continue;
4427
+ clauses.push(instructionEffectFeedback(effect, instruction, cwd));
4428
+ }
4429
+ if (instruction.status === "failed") {
4430
+ if (instruction.matcher) clauses.push(matcherInstructionFeedback(instruction.matcher));
4431
+ else if (instruction.error) clauses.push(conciseInstructionError(instruction.error));
4432
+ const effectPaths = new Set((instruction.effects ?? []).map((effect) => effect.path));
4433
+ const replacementPaths = new Set(
4434
+ (instruction.effects ?? []).flatMap((effect) =>
4435
+ effect.kind === "replaced" ? [effect.path] : [],
4436
+ ),
4437
+ );
4438
+ for (const state of instruction.finalStates ?? []) {
4439
+ if (
4440
+ state.state === "not-verified" ||
4441
+ state.state === "requested-content" ||
4442
+ state.state === "different-from-requested-content" ||
4443
+ state.state === "different-from-requested-and-previous-content" ||
4444
+ state.state === "different-from-previous-content" ||
4445
+ ((state.state === "different-entry" || state.state === "different-entry-type") &&
4446
+ !replacementPaths.has(state.path)) ||
4447
+ !effectPaths.has(state.path)
4448
+ ) {
4449
+ clauses.push(finalStateFeedback(state, cwd));
4450
+ }
4451
+ }
4452
+ }
4453
+ if (instruction.status === "not-run") {
4454
+ if (details.failure?.failedInstruction !== undefined) {
4455
+ clauses.push(`Instruction ${details.failure.failedInstruction} failed.`);
4456
+ } else if (details.failure?.message === "apply_patch was cancelled.") {
4457
+ clauses.push("apply_patch was cancelled before this instruction was executed.");
4458
+ } else if (details.failure?.phase === "parse") {
4459
+ clauses.push("Patch format error.");
4460
+ } else if (details.failure?.phase === "preflight") {
4461
+ clauses.push("apply_patch setup failed before this instruction was executed.");
4462
+ } else if (details.failure?.phase === "input") {
4463
+ clauses.push("The apply_patch request was rejected before this instruction was executed.");
4464
+ } else {
4465
+ clauses.push("apply_patch stopped before this instruction was executed.");
4466
+ }
4467
+ }
4468
+
4469
+ if (clauses.length === 0) return undefined;
4470
+ return `${clauses.map(sentenceClause).join("; ")}.`;
4471
+ }
4472
+
4473
+ export function formatApplyPatchInstructionResult(
4474
+ instruction: ApplyPatchInstructionDetails,
4475
+ details: ApplyPatchDetails,
4476
+ cwd = process.cwd(),
4477
+ ): string {
4478
+ const result = `${instruction.index}. [${formatApplyPatchInstructionStatusLabel(instruction.status)}] ${formatApplyPatchInstructionLabel(instruction)}`;
4479
+ const feedback = formatApplyPatchInstructionFeedback(instruction, details, cwd);
4480
+ return feedback ? `${result} - ${feedback}` : result;
4481
+ }
4482
+
4483
+ export function applyPatchSummaryPaths(details: ApplyPatchDetails): {
4484
+ added: string[];
4485
+ modified: string[];
4486
+ deleted: string[];
4487
+ } {
4488
+ const added = new Set(details.added);
4489
+ const modified = new Set(details.modified);
4490
+ const deleted = new Set(details.deleted);
4491
+ const partialMoveChanges = new Set(
4492
+ (details.instructions ?? []).flatMap((instruction) =>
4493
+ instruction.status === "failed"
4494
+ ? (instruction.changeIndexes ?? []).filter((index) => {
4495
+ const change = details.changes[index];
4496
+ return change?.kind === "move" && !change.exact;
4497
+ })
4498
+ : [],
4499
+ ),
4500
+ );
4501
+ const completedChangePaths = new Set(
4502
+ details.changes.flatMap((change, index) => {
4503
+ if (partialMoveChanges.has(index)) return [];
4504
+ if (change.kind === "move") return [change.destinationPath];
4505
+ if (change.kind === "update") return [change.moveTo ?? change.path];
4506
+ return [change.path];
4507
+ }),
4508
+ );
4509
+ for (const index of partialMoveChanges) {
4510
+ const change = details.changes[index];
4511
+ if (change?.kind === "move" && !completedChangePaths.has(change.destinationPath)) {
4512
+ modified.delete(change.destinationPath);
4513
+ }
4514
+ }
4515
+ const confirmedPaths = new Set([...added, ...modified, ...deleted]);
4516
+ for (const instruction of details.instructions ?? []) {
4517
+ for (const effect of instruction.effects ?? []) {
4518
+ if (added.has(effect.path) || modified.has(effect.path) || deleted.has(effect.path)) {
4519
+ if (
4520
+ effect.kind === "created" ||
4521
+ effect.kind === "replaced" ||
4522
+ effect.kind === "updated" ||
4523
+ effect.kind === "deleted"
4524
+ ) {
4525
+ confirmedPaths.add(effect.path);
4526
+ }
4527
+ continue;
4528
+ }
4529
+ if (effect.kind === "created") {
4530
+ added.add(effect.path);
4531
+ confirmedPaths.add(effect.path);
4532
+ } else if (effect.kind === "replaced" || effect.kind === "updated") {
4533
+ modified.add(effect.path);
4534
+ confirmedPaths.add(effect.path);
4535
+ } else if (effect.kind === "deleted") {
4536
+ deleted.add(effect.path);
4537
+ confirmedPaths.add(effect.path);
4538
+ }
4539
+ }
4540
+ }
4541
+ const unverifiedPaths = new Set(
4542
+ (details.instructions ?? []).flatMap((instruction) =>
4543
+ (instruction.finalStates ?? []).flatMap((state) =>
4544
+ state.state === "not-verified" ? [state.path] : [],
4545
+ ),
4546
+ ),
4547
+ );
4548
+ for (const path of unverifiedPaths) {
4549
+ if (confirmedPaths.has(path)) continue;
4550
+ added.delete(path);
4551
+ modified.delete(path);
4552
+ deleted.delete(path);
4553
+ }
4554
+ return { added: [...added], modified: [...modified], deleted: [...deleted] };
4555
+ }
4556
+
4557
+ export function applyPatchHasOtherFilesystemChanges(details: ApplyPatchDetails): boolean {
4558
+ return (details.instructions ?? []).some((instruction) =>
4559
+ instruction.effects?.some(
4560
+ (effect) => effect.kind === "directory-created" || effect.kind === "temporary-entry-remains",
4561
+ ),
4562
+ );
4563
+ }
4564
+
4565
+ export function applyPatchNeedsInstructionResults(
4566
+ details: ApplyPatchDetails,
4567
+ cwd = process.cwd(),
4568
+ ): boolean {
4569
+ return (details.instructions ?? []).some(
4570
+ (instruction) =>
4571
+ instruction.status !== "applied" ||
4572
+ formatApplyPatchInstructionFeedback(instruction, details, cwd) !== undefined,
4573
+ );
4574
+ }
4575
+
4576
+ function instructionResults(details: ApplyPatchDetails, cwd: string): string[] {
4577
+ const instructions = details.instructions ?? [];
4578
+ if (!applyPatchNeedsInstructionResults(details, cwd)) return [];
4579
+ return [
4580
+ "Patch instruction results:",
4581
+ ...instructions.map((instruction) =>
4582
+ formatApplyPatchInstructionResult(instruction, details, cwd),
4583
+ ),
4584
+ ];
4585
+ }
4586
+
4587
+ export function formatApplyPatchSummary(details: ApplyPatchDetails, cwd = process.cwd()): string {
4588
+ const lines: string[] = [];
4589
+ const summary = applyPatchSummaryPaths(details);
4590
+ if (summary.added.length === 0 && summary.modified.length === 0 && summary.deleted.length === 0) {
4591
+ lines.push("Success. No files were changed.");
4592
+ } else {
4593
+ lines.push("Success. Updated the following files:");
4594
+ for (const path of summary.added) lines.push(`A ${feedbackPath(path, cwd)}`);
4595
+ for (const path of summary.modified) lines.push(`M ${feedbackPath(path, cwd)}`);
4596
+ for (const path of summary.deleted) lines.push(`D ${feedbackPath(path, cwd)}`);
4597
+ }
4598
+ const results = instructionResults(details, cwd);
4599
+ if (results.length > 0) lines.push("", ...results);
4600
+ return `${lines.join("\n")}\n`;
4601
+ }
4602
+
4603
+ export function formatApplyPatchFailureHeading(details: ApplyPatchDetails): string[] {
4604
+ const lines: string[] = [];
4605
+ const instructions = details.instructions ?? [];
4606
+ const failed = instructions.find((instruction) => instruction.status === "failed");
4607
+ const lastApplied = instructions.findLast((instruction) => instruction.status === "applied");
4608
+ if (failed) {
4609
+ lines.push(`Patch failed at instruction ${failed.index} of ${instructions.length}.`);
4610
+ } else if (details.failure?.message === "apply_patch was cancelled.") {
4611
+ lines.push(
4612
+ lastApplied
4613
+ ? `apply_patch was cancelled after instruction ${lastApplied.index}.`
4614
+ : "apply_patch was cancelled before execution.",
4615
+ );
4616
+ } else if (details.failure?.phase === "parse") {
4617
+ const line = details.failure.message.match(/line (\d+)/u)?.[1];
4618
+ lines.push(
4619
+ `Patch format error${line ? ` at line ${line}` : ""}: ${conciseInstructionError(details.failure.message)}`,
4620
+ );
4621
+ } else if (details.failure?.phase === "preflight") {
4622
+ lines.push(`apply_patch setup failed: ${conciseInstructionError(details.failure.message)}`);
4623
+ } else if (
4624
+ details.failure?.phase === "input" &&
4625
+ details.failure.message !== "apply_patch was cancelled."
4626
+ ) {
4627
+ lines.push(`apply_patch request rejected: ${conciseInstructionError(details.failure.message)}`);
4628
+ } else {
4629
+ lines.push(
4630
+ lastApplied
4631
+ ? `apply_patch stopped after instruction ${lastApplied.index}.`
4632
+ : "apply_patch stopped before execution.",
4633
+ );
4634
+ if (details.failure?.message && details.failure.message !== "apply_patch was cancelled.") {
4635
+ lines.push(`Patch error: ${conciseInstructionError(details.failure.message)}`);
4636
+ }
4637
+ }
4638
+ return lines;
1066
4639
  }
1067
4640
 
1068
- export function formatApplyPatchSummary(details: ApplyPatchDetails): string {
1069
- const lines = ["Success. Updated the following files:"];
1070
- for (const path of details.added) lines.push(`A ${path}`);
1071
- for (const path of details.modified) lines.push(`M ${path}`);
1072
- for (const path of details.deleted) lines.push(`D ${path}`);
4641
+ export function formatApplyPatchFailureSummary(
4642
+ details: ApplyPatchDetails,
4643
+ cwd = process.cwd(),
4644
+ ): string {
4645
+ const lines = formatApplyPatchFailureHeading(details);
4646
+ const instructions = details.instructions ?? [];
4647
+ const summary = applyPatchSummaryPaths(details);
4648
+ const hasSummary =
4649
+ summary.added.length > 0 || summary.modified.length > 0 || summary.deleted.length > 0;
4650
+ const hasOtherFilesystemChanges = applyPatchHasOtherFilesystemChanges(details);
4651
+ const hasUnverifiedState = instructions.some((instruction) =>
4652
+ instruction.finalStates?.some((state) => state.state === "not-verified"),
4653
+ );
4654
+ if (hasSummary) {
4655
+ lines.push("Files changed:");
4656
+ for (const path of summary.added) lines.push(`A ${feedbackPath(path, cwd)}`);
4657
+ for (const path of summary.modified) lines.push(`M ${feedbackPath(path, cwd)}`);
4658
+ for (const path of summary.deleted) lines.push(`D ${feedbackPath(path, cwd)}`);
4659
+ } else if (hasOtherFilesystemChanges) {
4660
+ lines.push("Filesystem changed.");
4661
+ } else if (!hasUnverifiedState) {
4662
+ lines.push("No files were changed.");
4663
+ }
4664
+
4665
+ const results = instructionResults(details, cwd);
4666
+ if (results.length > 0) lines.push("", ...results);
1073
4667
  return `${lines.join("\n")}\n`;
1074
4668
  }
1075
4669