@davideasden/pi-undo 0.1.2 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +147 -85
- package/extensions/pi-undo.ts +172 -16
- package/package.json +1 -1
- package/src/atomic-fs.ts +7 -2
- package/src/controller.ts +97 -38
- package/src/mutation-journal.ts +180 -40
- package/src/pi-runtime.ts +2 -2
- package/src/quarantine.ts +357 -17
- package/src/restore-engine.ts +269 -49
- package/src/snapshot-store.ts +316 -63
- package/src/status-reporter.ts +15 -2
package/src/restore-engine.ts
CHANGED
|
@@ -19,10 +19,15 @@ import {
|
|
|
19
19
|
fingerprintAbsent,
|
|
20
20
|
fingerprintBytes,
|
|
21
21
|
fingerprintSymlink,
|
|
22
|
+
type DeleteLeafRequest,
|
|
23
|
+
type ReplaceFileRequest,
|
|
22
24
|
} from "./quarantine.ts";
|
|
23
25
|
import { SnapshotStoreError, type SnapshotStore } from "./snapshot-store.ts";
|
|
24
26
|
|
|
25
27
|
const PREPARED_PLAN_CACHE_LIMIT = 16;
|
|
28
|
+
const RESTORE_FILE_BATCH_MAX_ENTRIES = 128;
|
|
29
|
+
const RESTORE_FILE_BATCH_MAX_BYTES = 32 * 1024 * 1024;
|
|
30
|
+
const RESTORE_FILE_PREPARE_CONCURRENCY = 32;
|
|
26
31
|
|
|
27
32
|
export interface RestorePlan {
|
|
28
33
|
currentManifestId: ManifestId;
|
|
@@ -128,17 +133,18 @@ export class RestoreEngine {
|
|
|
128
133
|
): Promise<RestorePlan> {
|
|
129
134
|
assertManifest(current);
|
|
130
135
|
assertManifest(target);
|
|
131
|
-
assertCompatibleManifests(current, target);
|
|
132
136
|
const scope = scopePaths === undefined ? undefined : this.canonicalScope(scopePaths);
|
|
137
|
+
const canonicalScopePaths = scope === undefined ? undefined : [...scope];
|
|
138
|
+
assertCompatibleManifests(current, target, scope);
|
|
133
139
|
const isScopedPath = (path: string): boolean => scope === undefined || scope.has(path);
|
|
134
140
|
await Promise.all([
|
|
135
|
-
this.store.assertComplete(current.manifestId),
|
|
136
|
-
this.store.assertComplete(target.manifestId),
|
|
141
|
+
this.store.assertComplete(current.manifestId, canonicalScopePaths),
|
|
142
|
+
this.store.assertComplete(target.manifestId, canonicalScopePaths),
|
|
137
143
|
]);
|
|
138
144
|
|
|
139
145
|
const [currentPaths, targetPaths] = await Promise.all([
|
|
140
|
-
this.readOwnedPaths(current),
|
|
141
|
-
this.readOwnedPaths(target),
|
|
146
|
+
this.readOwnedPaths(current, canonicalScopePaths),
|
|
147
|
+
this.readOwnedPaths(target, canonicalScopePaths),
|
|
142
148
|
]);
|
|
143
149
|
const targetIgnoredPaths = ignoredWorkspacePaths(target);
|
|
144
150
|
const deleteByRoot = new Map<string, string[]>();
|
|
@@ -315,7 +321,11 @@ export class RestoreEngine {
|
|
|
315
321
|
if (canonicalJson(storedTarget) !== canonicalJson(target)) {
|
|
316
322
|
throw new Error("target manifest 与 store 内容不一致");
|
|
317
323
|
}
|
|
318
|
-
assertCompatibleManifests(
|
|
324
|
+
assertCompatibleManifests(
|
|
325
|
+
current,
|
|
326
|
+
target,
|
|
327
|
+
plan.scopePaths === undefined ? undefined : this.canonicalScope(plan.scopePaths),
|
|
328
|
+
);
|
|
319
329
|
let expectedPlan: RestorePlan;
|
|
320
330
|
let prepared: PreparedRestorePlan | undefined;
|
|
321
331
|
try {
|
|
@@ -342,7 +352,10 @@ export class RestoreEngine {
|
|
|
342
352
|
return { code: "restore_failed_safe", verifiedPaths: 0, totalPaths: 0 };
|
|
343
353
|
}
|
|
344
354
|
const [currentPaths, targetPaths] = prepared === undefined
|
|
345
|
-
? await Promise.all([
|
|
355
|
+
? await Promise.all([
|
|
356
|
+
this.readOwnedPaths(current, plan.scopePaths),
|
|
357
|
+
this.readOwnedPaths(target, plan.scopePaths),
|
|
358
|
+
])
|
|
346
359
|
: [prepared.currentPaths, prepared.targetPaths];
|
|
347
360
|
const quarantine = new QuarantineManager({
|
|
348
361
|
workspaceRoot: this.requestedWorkspaceRoot,
|
|
@@ -382,12 +395,12 @@ export class RestoreEngine {
|
|
|
382
395
|
quarantine,
|
|
383
396
|
};
|
|
384
397
|
try {
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
398
|
+
await this.deletePlannedPaths(
|
|
399
|
+
target.manifestId,
|
|
400
|
+
targetPaths,
|
|
401
|
+
plan.deletePaths,
|
|
402
|
+
mutationContext,
|
|
403
|
+
);
|
|
391
404
|
await this.writePlannedPaths(target.manifestId, targetPaths, plan.writePaths, mutationContext);
|
|
392
405
|
|
|
393
406
|
const topologyAfter = await this.discovery.discover(this.workspaceRoot);
|
|
@@ -425,11 +438,16 @@ export class RestoreEngine {
|
|
|
425
438
|
}
|
|
426
439
|
}
|
|
427
440
|
|
|
428
|
-
private async readOwnedPaths(
|
|
441
|
+
private async readOwnedPaths(
|
|
442
|
+
manifest: SnapshotManifest,
|
|
443
|
+
scopePaths?: readonly string[],
|
|
444
|
+
): Promise<Map<string, OwnedPath>> {
|
|
429
445
|
const result = new Map<string, OwnedPath>();
|
|
430
446
|
for (const root of manifest.roots) {
|
|
431
447
|
assertNotGitMetadata(root.relativeRoot);
|
|
432
|
-
const
|
|
448
|
+
const rootScope = rootRelativeScopePaths(root.relativeRoot, scopePaths);
|
|
449
|
+
if (rootScope !== undefined && rootScope.length === 0) continue;
|
|
450
|
+
const entries = await this.store.listTree(manifest.manifestId, root.relativeRoot, rootScope);
|
|
433
451
|
if (entries.length > 0) {
|
|
434
452
|
for (const boundaryPath of rootBoundaryDirectories(root.relativeRoot)) {
|
|
435
453
|
if (!result.has(boundaryPath)) {
|
|
@@ -523,6 +541,63 @@ export class RestoreEngine {
|
|
|
523
541
|
return { ok: true, verifiedPaths, totalPaths: paths.length };
|
|
524
542
|
}
|
|
525
543
|
|
|
544
|
+
private async deletePlannedPaths(
|
|
545
|
+
targetManifestId: ManifestId,
|
|
546
|
+
targetPaths: ReadonlyMap<string, OwnedPath>,
|
|
547
|
+
deletePaths: readonly string[],
|
|
548
|
+
context: MutationContext,
|
|
549
|
+
): Promise<void> {
|
|
550
|
+
let fileBatch: OwnedPath[] = [];
|
|
551
|
+
let fileBatchRoot: string | undefined;
|
|
552
|
+
const flushFiles = async (): Promise<void> => {
|
|
553
|
+
if (fileBatch.length === 0) return;
|
|
554
|
+
const requests: DeleteLeafRequest[] = [];
|
|
555
|
+
for (const source of fileBatch) {
|
|
556
|
+
context.ordinal += 1;
|
|
557
|
+
const ordinal = context.ordinal;
|
|
558
|
+
await this.beforeMutation?.({
|
|
559
|
+
phase: context.phase,
|
|
560
|
+
ordinal,
|
|
561
|
+
kind: "delete",
|
|
562
|
+
path: source.absolutePath,
|
|
563
|
+
});
|
|
564
|
+
await this.assertMutationPath(source.absolutePath);
|
|
565
|
+
await this.assertMutationState(context, "delete", source.absolutePath);
|
|
566
|
+
requests.push({
|
|
567
|
+
path: source.absolutePath,
|
|
568
|
+
sourceFingerprint: await this.expectedMutationFingerprint(context, source.absolutePath),
|
|
569
|
+
targetFingerprint: fingerprintAbsent(source.absolutePath),
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
await context.quarantine.deleteFiles(requests);
|
|
573
|
+
fileBatch = [];
|
|
574
|
+
fileBatchRoot = undefined;
|
|
575
|
+
};
|
|
576
|
+
for (const path of deletePaths) {
|
|
577
|
+
if (await this.pathIsShadowedByTarget(targetManifestId, path, targetPaths)) continue;
|
|
578
|
+
const source = context.sourcePaths.get(path);
|
|
579
|
+
const live = await lstat(this.absolutePath(path)).catch((error) => {
|
|
580
|
+
if (hasErrorCode(error, "ENOENT") || hasErrorCode(error, "ENOTDIR")) return null;
|
|
581
|
+
throw error;
|
|
582
|
+
});
|
|
583
|
+
if (live === null) continue;
|
|
584
|
+
if (source?.entry.kind !== "file" || live.isSymbolicLink() || !live.isFile()) {
|
|
585
|
+
await flushFiles();
|
|
586
|
+
await this.deletePath(path, context);
|
|
587
|
+
continue;
|
|
588
|
+
}
|
|
589
|
+
if (
|
|
590
|
+
fileBatch.length > 0 &&
|
|
591
|
+
(fileBatchRoot !== source.root.relativeRoot || fileBatch.length >= RESTORE_FILE_BATCH_MAX_ENTRIES)
|
|
592
|
+
) {
|
|
593
|
+
await flushFiles();
|
|
594
|
+
}
|
|
595
|
+
fileBatch.push(source);
|
|
596
|
+
fileBatchRoot = source.root.relativeRoot;
|
|
597
|
+
}
|
|
598
|
+
await flushFiles();
|
|
599
|
+
}
|
|
600
|
+
|
|
526
601
|
private async deletePath(path: string, context: MutationContext): Promise<void> {
|
|
527
602
|
const absolutePath = this.absolutePath(path);
|
|
528
603
|
try {
|
|
@@ -557,12 +632,12 @@ export class RestoreEngine {
|
|
|
557
632
|
return;
|
|
558
633
|
}
|
|
559
634
|
await this.mutate(context, "delete", path, async () => {
|
|
560
|
-
await context.quarantine.deleteLeaf({
|
|
635
|
+
const record = await context.quarantine.deleteLeaf({
|
|
561
636
|
path,
|
|
562
637
|
sourceFingerprint: await this.expectedMutationFingerprint(context, path),
|
|
563
638
|
targetFingerprint: fingerprintAbsent(path),
|
|
564
639
|
});
|
|
565
|
-
await
|
|
640
|
+
await context.quarantine.cleanupMutation(record);
|
|
566
641
|
});
|
|
567
642
|
}
|
|
568
643
|
|
|
@@ -604,14 +679,14 @@ export class RestoreEngine {
|
|
|
604
679
|
throw new Error(`symlink 存在类型或内容冲突:${path}`);
|
|
605
680
|
}
|
|
606
681
|
await this.mutate(context, "symlink", path, async (beforeInstall) => {
|
|
607
|
-
await context.quarantine.replaceSymlink({
|
|
682
|
+
const record = await context.quarantine.replaceSymlink({
|
|
608
683
|
path,
|
|
609
684
|
targetLinkText: linkText,
|
|
610
685
|
sourceFingerprint: await this.expectedMutationFingerprint(context, path),
|
|
611
686
|
targetFingerprint: fingerprintSymlink(path, linkText),
|
|
612
687
|
beforeInstall,
|
|
613
688
|
});
|
|
614
|
-
await
|
|
689
|
+
await context.quarantine.cleanupMutation(record);
|
|
615
690
|
}, true);
|
|
616
691
|
return;
|
|
617
692
|
}
|
|
@@ -619,7 +694,12 @@ export class RestoreEngine {
|
|
|
619
694
|
if (target.entry.blobId === null) {
|
|
620
695
|
throw new Error(`普通文件缺少 blob:${path}`);
|
|
621
696
|
}
|
|
622
|
-
const bytes = await this.store.readBlob(
|
|
697
|
+
const bytes = await this.store.readBlob(
|
|
698
|
+
manifestId,
|
|
699
|
+
target.root.relativeRoot,
|
|
700
|
+
target.entry.blobId,
|
|
701
|
+
target.entry.relativePath,
|
|
702
|
+
);
|
|
623
703
|
if (bytes.byteLength !== target.entry.size) {
|
|
624
704
|
throw new Error(`普通文件 blob 大小不匹配:${path}`);
|
|
625
705
|
}
|
|
@@ -628,7 +708,7 @@ export class RestoreEngine {
|
|
|
628
708
|
"write",
|
|
629
709
|
path,
|
|
630
710
|
async (beforeInstall) => {
|
|
631
|
-
await context.quarantine.replaceFile({
|
|
711
|
+
const record = await context.quarantine.replaceFile({
|
|
632
712
|
path,
|
|
633
713
|
targetBytes: bytes,
|
|
634
714
|
targetMode: target.entry.mode & 0o777,
|
|
@@ -636,7 +716,7 @@ export class RestoreEngine {
|
|
|
636
716
|
targetFingerprint: fingerprintBytes(path, bytes, target.entry.mode),
|
|
637
717
|
beforeInstall,
|
|
638
718
|
});
|
|
639
|
-
await
|
|
719
|
+
await context.quarantine.cleanupMutation(record);
|
|
640
720
|
},
|
|
641
721
|
true,
|
|
642
722
|
);
|
|
@@ -669,12 +749,12 @@ export class RestoreEngine {
|
|
|
669
749
|
journal: options.mutationJournal,
|
|
670
750
|
}),
|
|
671
751
|
};
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
752
|
+
await this.deletePlannedPaths(
|
|
753
|
+
current.manifestId,
|
|
754
|
+
currentPaths,
|
|
755
|
+
rollbackPlan.deletePaths,
|
|
756
|
+
context,
|
|
757
|
+
);
|
|
678
758
|
await this.writePlannedPaths(current.manifestId, currentPaths, rollbackPlan.writePaths, context);
|
|
679
759
|
const topologyAfter = await this.discovery.discover(this.workspaceRoot);
|
|
680
760
|
assertUnchangedTopology(topologyBefore, topologyAfter);
|
|
@@ -766,8 +846,9 @@ export class RestoreEngine {
|
|
|
766
846
|
deferHook = false,
|
|
767
847
|
): Promise<void> {
|
|
768
848
|
context.ordinal += 1;
|
|
849
|
+
const ordinal = context.ordinal;
|
|
769
850
|
const beforeInstall = async (): Promise<void> => {
|
|
770
|
-
await this.beforeMutation?.({ phase: context.phase, ordinal
|
|
851
|
+
await this.beforeMutation?.({ phase: context.phase, ordinal, kind, path });
|
|
771
852
|
};
|
|
772
853
|
if (!deferHook) await beforeInstall();
|
|
773
854
|
await this.assertMutationPath(path);
|
|
@@ -781,26 +862,95 @@ export class RestoreEngine {
|
|
|
781
862
|
writePaths: readonly string[],
|
|
782
863
|
context: MutationContext,
|
|
783
864
|
): Promise<void> {
|
|
865
|
+
let fileBatch: OwnedPath[] = [];
|
|
866
|
+
let fileBatchBytes = 0;
|
|
867
|
+
let fileBatchRoot: string | undefined;
|
|
868
|
+
const flushFiles = async (): Promise<void> => {
|
|
869
|
+
if (fileBatch.length === 0) return;
|
|
870
|
+
const pending = fileBatch.map((target) => {
|
|
871
|
+
context.ordinal += 1;
|
|
872
|
+
return { target, ordinal: context.ordinal };
|
|
873
|
+
});
|
|
874
|
+
const requests = await mapConcurrentOrdered(
|
|
875
|
+
pending,
|
|
876
|
+
RESTORE_FILE_PREPARE_CONCURRENCY,
|
|
877
|
+
({ target, ordinal }) => this.prepareFileReplacement(manifestId, target, context, ordinal),
|
|
878
|
+
);
|
|
879
|
+
await context.quarantine.replaceFiles(requests);
|
|
880
|
+
fileBatch = [];
|
|
881
|
+
fileBatchBytes = 0;
|
|
882
|
+
fileBatchRoot = undefined;
|
|
883
|
+
};
|
|
784
884
|
for (const kind of ["directory", "leaf"] as const) {
|
|
785
885
|
for (const path of writePaths) {
|
|
786
886
|
const target = targetPaths.get(path);
|
|
787
887
|
if (target === undefined) {
|
|
788
888
|
throw new Error(`${context.phase} plan 引用了 manifest 外路径:${path}`);
|
|
789
889
|
}
|
|
790
|
-
if ((target.entry.kind === "directory") !== (kind === "directory"))
|
|
791
|
-
continue;
|
|
792
|
-
}
|
|
890
|
+
if ((target.entry.kind === "directory") !== (kind === "directory")) continue;
|
|
793
891
|
if (context.sourceIgnoredPaths.has(path)) {
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
}
|
|
892
|
+
await flushFiles();
|
|
893
|
+
if (await this.entryMatches(manifestId, target)) continue;
|
|
797
894
|
throw new Error(`${context.phase} 的 ignored-present 路径与目标内容冲突:${path}`);
|
|
798
895
|
}
|
|
799
|
-
|
|
896
|
+
if (target.entry.kind !== "file") {
|
|
897
|
+
await flushFiles();
|
|
898
|
+
await this.writePath(manifestId, target, context);
|
|
899
|
+
continue;
|
|
900
|
+
}
|
|
901
|
+
if (
|
|
902
|
+
fileBatch.length > 0 &&
|
|
903
|
+
(fileBatchRoot !== target.root.relativeRoot ||
|
|
904
|
+
fileBatch.length >= RESTORE_FILE_BATCH_MAX_ENTRIES ||
|
|
905
|
+
fileBatchBytes + target.entry.size > RESTORE_FILE_BATCH_MAX_BYTES)
|
|
906
|
+
) {
|
|
907
|
+
await flushFiles();
|
|
908
|
+
}
|
|
909
|
+
fileBatch.push(target);
|
|
910
|
+
fileBatchBytes += target.entry.size;
|
|
911
|
+
fileBatchRoot = target.root.relativeRoot;
|
|
800
912
|
}
|
|
913
|
+
await flushFiles();
|
|
801
914
|
}
|
|
802
915
|
}
|
|
803
916
|
|
|
917
|
+
private async prepareFileReplacement(
|
|
918
|
+
manifestId: ManifestId,
|
|
919
|
+
target: OwnedPath,
|
|
920
|
+
context: MutationContext,
|
|
921
|
+
ordinal: number,
|
|
922
|
+
): Promise<ReplaceFileRequest> {
|
|
923
|
+
if (target.entry.kind !== "file" || target.entry.blobId === null) {
|
|
924
|
+
throw new Error(`批量普通文件缺少 blob:${target.absolutePath}`);
|
|
925
|
+
}
|
|
926
|
+
const bytes = await this.store.readBlob(
|
|
927
|
+
manifestId,
|
|
928
|
+
target.root.relativeRoot,
|
|
929
|
+
target.entry.blobId,
|
|
930
|
+
target.entry.relativePath,
|
|
931
|
+
);
|
|
932
|
+
if (bytes.byteLength !== target.entry.size) {
|
|
933
|
+
throw new Error(`普通文件 blob 大小不匹配:${target.absolutePath}`);
|
|
934
|
+
}
|
|
935
|
+
await this.assertMutationPath(target.absolutePath);
|
|
936
|
+
await this.assertMutationState(context, "write", target.absolutePath);
|
|
937
|
+
return {
|
|
938
|
+
path: target.absolutePath,
|
|
939
|
+
targetBytes: bytes,
|
|
940
|
+
targetMode: target.entry.mode & 0o777,
|
|
941
|
+
sourceFingerprint: await this.expectedMutationFingerprint(context, target.absolutePath),
|
|
942
|
+
targetFingerprint: fingerprintBytes(target.absolutePath, bytes, target.entry.mode),
|
|
943
|
+
beforeInstall: async () => {
|
|
944
|
+
await this.beforeMutation?.({
|
|
945
|
+
phase: context.phase,
|
|
946
|
+
ordinal,
|
|
947
|
+
kind: "write",
|
|
948
|
+
path: target.absolutePath,
|
|
949
|
+
});
|
|
950
|
+
},
|
|
951
|
+
};
|
|
952
|
+
}
|
|
953
|
+
|
|
804
954
|
private async assertMutationState(
|
|
805
955
|
context: MutationContext,
|
|
806
956
|
kind: RestoreMutation["kind"],
|
|
@@ -933,7 +1083,12 @@ export class RestoreEngine {
|
|
|
933
1083
|
return fingerprintSymlink(path, owned.entry.linkText!);
|
|
934
1084
|
}
|
|
935
1085
|
if (owned.entry.blobId === null) throw new Error(`普通文件缺少 blob:${path}`);
|
|
936
|
-
const bytes = await this.store.readBlob(
|
|
1086
|
+
const bytes = await this.store.readBlob(
|
|
1087
|
+
manifestId,
|
|
1088
|
+
owned.root.relativeRoot,
|
|
1089
|
+
owned.entry.blobId,
|
|
1090
|
+
owned.entry.relativePath,
|
|
1091
|
+
);
|
|
937
1092
|
return fingerprintBytes(path, bytes, owned.entry.mode);
|
|
938
1093
|
}
|
|
939
1094
|
if (await this.pathIsAbsent(path)) return fingerprintAbsent(path);
|
|
@@ -949,13 +1104,6 @@ export class RestoreEngine {
|
|
|
949
1104
|
}
|
|
950
1105
|
}
|
|
951
1106
|
|
|
952
|
-
private async cleanupLatestMutation(context: MutationContext): Promise<void> {
|
|
953
|
-
const records = await context.mutationJournal.load();
|
|
954
|
-
const record = records.at(-1);
|
|
955
|
-
if (record === undefined) throw new Error("quarantine mutation 记录缺失");
|
|
956
|
-
await context.quarantine.cleanupMutation(record);
|
|
957
|
-
}
|
|
958
|
-
|
|
959
1107
|
private async restorePendingMutations(
|
|
960
1108
|
quarantine: QuarantineManager,
|
|
961
1109
|
journal: MutationJournal,
|
|
@@ -1012,7 +1160,12 @@ export class RestoreEngine {
|
|
|
1012
1160
|
}
|
|
1013
1161
|
const [actual, expected] = await Promise.all([
|
|
1014
1162
|
readFile(this.absolutePath(path)),
|
|
1015
|
-
this.store.readBlob(
|
|
1163
|
+
this.store.readBlob(
|
|
1164
|
+
manifestId,
|
|
1165
|
+
owned.root.relativeRoot,
|
|
1166
|
+
owned.entry.blobId,
|
|
1167
|
+
owned.entry.relativePath,
|
|
1168
|
+
),
|
|
1016
1169
|
]);
|
|
1017
1170
|
if (!actual.equals(Buffer.from(expected))) {
|
|
1018
1171
|
throw new Error(`普通文件内容校验失败:${path}`);
|
|
@@ -1081,16 +1234,37 @@ function sameEntry(left: RestorePath, right: RestorePath): boolean {
|
|
|
1081
1234
|
left.linkText === right.linkText;
|
|
1082
1235
|
}
|
|
1083
1236
|
|
|
1084
|
-
function assertCompatibleManifests(
|
|
1237
|
+
function assertCompatibleManifests(
|
|
1238
|
+
current: SnapshotManifest,
|
|
1239
|
+
target: SnapshotManifest,
|
|
1240
|
+
scope?: ReadonlySet<string>,
|
|
1241
|
+
): void {
|
|
1085
1242
|
if (current.workspaceIdentity !== target.workspaceIdentity) {
|
|
1086
1243
|
throw new Error("restore manifest 不属于同一 workspace");
|
|
1087
1244
|
}
|
|
1088
|
-
if (current.coverage !== target.coverage) {
|
|
1089
|
-
throw new Error("restore manifest coverage 不一致,不能推断缺失路径");
|
|
1090
|
-
}
|
|
1091
1245
|
if (current.roots.some((root) => root.state === "broken") || target.roots.some((root) => root.state === "broken")) {
|
|
1092
1246
|
throw new Error("broken root 不能用于 restore");
|
|
1093
1247
|
}
|
|
1248
|
+
const scopedCoverage = scope === undefined
|
|
1249
|
+
? undefined
|
|
1250
|
+
: `paths:${checksum(canonicalJson([...scope]))}`;
|
|
1251
|
+
if (current.coverage === target.coverage) {
|
|
1252
|
+
if (
|
|
1253
|
+
scopedCoverage !== undefined && current.coverage !== "complete" &&
|
|
1254
|
+
current.coverage !== scopedCoverage
|
|
1255
|
+
) {
|
|
1256
|
+
throw new Error("restore manifest coverage 与 scope 不匹配");
|
|
1257
|
+
}
|
|
1258
|
+
return;
|
|
1259
|
+
}
|
|
1260
|
+
if (
|
|
1261
|
+
scopedCoverage === undefined ||
|
|
1262
|
+
![current.coverage, target.coverage].every(
|
|
1263
|
+
(coverage) => coverage === "complete" || coverage === scopedCoverage,
|
|
1264
|
+
)
|
|
1265
|
+
) {
|
|
1266
|
+
throw new Error("restore manifest coverage 不一致,不能推断缺失路径");
|
|
1267
|
+
}
|
|
1094
1268
|
}
|
|
1095
1269
|
|
|
1096
1270
|
function orderedWritePaths(
|
|
@@ -1120,6 +1294,26 @@ function appendPath(paths: Map<string, string[]>, root: string, path: string): v
|
|
|
1120
1294
|
owned.push(path);
|
|
1121
1295
|
}
|
|
1122
1296
|
|
|
1297
|
+
function rootRelativeScopePaths(
|
|
1298
|
+
root: string,
|
|
1299
|
+
scopePaths: readonly string[] | undefined,
|
|
1300
|
+
): string[] | undefined {
|
|
1301
|
+
if (scopePaths === undefined) return undefined;
|
|
1302
|
+
if (scopePaths.length === 0) return [];
|
|
1303
|
+
const result = new Set<string>();
|
|
1304
|
+
for (const path of scopePaths) {
|
|
1305
|
+
if (path === "." || path === root || isStrictWorkspaceAncestor(path, root)) return undefined;
|
|
1306
|
+
if (isStrictWorkspaceAncestor(root, path)) {
|
|
1307
|
+
result.add(root === "." ? path : path.slice(root.length + 1));
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
return [...result].sort(comparePaths);
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
function isStrictWorkspaceAncestor(parent: string, child: string): boolean {
|
|
1314
|
+
return parent === "." ? child !== "." : child.startsWith(`${parent}/`);
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1123
1317
|
function workspacePath(root: string, path: string): string {
|
|
1124
1318
|
return root === "." ? path : path === "." ? root : `${root}/${path}`;
|
|
1125
1319
|
}
|
|
@@ -1227,6 +1421,32 @@ function hasValidPlanDigest(plan: RestorePlan): boolean {
|
|
|
1227
1421
|
}
|
|
1228
1422
|
}
|
|
1229
1423
|
|
|
1424
|
+
async function mapConcurrentOrdered<T, R>(
|
|
1425
|
+
values: readonly T[],
|
|
1426
|
+
concurrency: number,
|
|
1427
|
+
operation: (value: T) => Promise<R>,
|
|
1428
|
+
): Promise<R[]> {
|
|
1429
|
+
const results: R[] = new Array(values.length);
|
|
1430
|
+
let nextIndex = 0;
|
|
1431
|
+
let failed = false;
|
|
1432
|
+
let failure: unknown;
|
|
1433
|
+
async function worker(): Promise<void> {
|
|
1434
|
+
while (!failed && nextIndex < values.length) {
|
|
1435
|
+
const index = nextIndex;
|
|
1436
|
+
nextIndex += 1;
|
|
1437
|
+
try {
|
|
1438
|
+
results[index] = await operation(values[index]!);
|
|
1439
|
+
} catch (error) {
|
|
1440
|
+
if (!failed) failure = error;
|
|
1441
|
+
failed = true;
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, values.length) }, () => worker()));
|
|
1446
|
+
if (failed) throw failure;
|
|
1447
|
+
return results;
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1230
1450
|
function hasErrorCode(error: unknown, code: string): error is NodeJS.ErrnoException {
|
|
1231
1451
|
return typeof error === "object" && error !== null && "code" in error && error.code === code;
|
|
1232
1452
|
}
|