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.
- package/APPLY_PATCH_INSTRUCTION_FEEDBACK.md +617 -0
- package/CHANGELOG.md +44 -0
- package/LICENSES/tree-sitter-wasms-MIT.txt +21 -0
- package/LICENSES/web-tree-sitter-MIT.txt +21 -0
- package/README.md +37 -9
- package/THIRD_PARTY_NOTICES.md +26 -0
- package/extensions/openai-codex-compat/apply-patch-diff-render.ts +499 -20
- package/extensions/openai-codex-compat/apply-patch-engine.ts +4085 -491
- package/extensions/openai-codex-compat/apply-patch-matcher.ts +1535 -0
- package/extensions/openai-codex-compat/apply-patch-render.ts +85 -19
- package/extensions/openai-codex-compat/apply-patch.ts +41 -5
- package/extensions/openai-codex-compat/codex-provider.ts +252 -14
- package/extensions/openai-codex-compat/codex-stream.ts +78 -61
- package/extensions/openai-codex-compat/compaction-checkpoint.ts +28 -0
- package/extensions/openai-codex-compat/config.ts +18 -0
- package/extensions/openai-codex-compat/footer.ts +4 -8
- package/extensions/openai-codex-compat/index.ts +4 -1
- package/extensions/openai-codex-compat/remote-compaction.ts +4 -0
- package/extensions/openai-codex-compat/settings-pane.ts +11 -0
- package/extensions/openai-codex-compat/tools.ts +2 -1
- package/package.json +10 -4
|
@@ -1,6 +1,33 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
478
|
+
this.appendUpdateLine(operation, { kind: "add", text: line.slice(1) });
|
|
380
479
|
return;
|
|
381
480
|
}
|
|
382
481
|
if (line.startsWith("-")) {
|
|
383
|
-
this.
|
|
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
|
-
|
|
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(
|
|
637
|
+
group.changes.push(textualChange);
|
|
706
638
|
} else {
|
|
707
|
-
groups.set(key, { firstIndex: index, changes: [
|
|
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
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
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
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
)
|
|
816
|
-
|
|
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
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
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
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
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
|
-
|
|
947
|
-
|
|
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
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
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
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
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
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
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
|
|
1037
|
-
|
|
1038
|
-
|
|
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
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
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
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
}
|
|
1058
|
-
|
|
1059
|
-
|
|
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
|
-
|
|
1064
|
-
|
|
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
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
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
|
|