@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/snapshot-store.ts
CHANGED
|
@@ -32,10 +32,16 @@ const GC_RETENTION_MS = 7 * 24 * 60 * 60 * 1_000;
|
|
|
32
32
|
const IGNORE_POLICY = "git-check-ignore-v1";
|
|
33
33
|
const NULL_DEVICE = process.platform === "win32" ? "NUL" : "/dev/null";
|
|
34
34
|
const TREE_CACHE_LIMIT = 256;
|
|
35
|
-
const
|
|
36
|
-
const
|
|
35
|
+
const TREE_BLOB_MEMBERSHIP_LIMIT = 65_536;
|
|
36
|
+
const HASH_BATCH_MAX_PATHS = process.platform === "win32" ? 128 : 2_048;
|
|
37
|
+
const HASH_BATCH_MAX_ARGUMENT_BYTES = process.platform === "win32" ? 24 * 1024 : 128 * 1024;
|
|
38
|
+
const HASH_BATCH_CONCURRENCY = 4;
|
|
39
|
+
const FILE_SYSTEM_INSPECTION_CONCURRENCY = 32;
|
|
37
40
|
const INDEX_BATCH_MAX_ENTRIES = 4_096;
|
|
38
41
|
const INDEX_BATCH_MAX_BYTES = 8 * 1024 * 1024;
|
|
42
|
+
const BLOB_CACHE_MAX_BYTES = 128 * 1024 * 1024;
|
|
43
|
+
const BLOB_BATCH_MAX_BYTES = 16 * 1024 * 1024;
|
|
44
|
+
const BLOB_BATCH_MAX_ENTRIES = 256;
|
|
39
45
|
|
|
40
46
|
interface PinRecord {
|
|
41
47
|
readonly schemaVersion: 1;
|
|
@@ -73,6 +79,11 @@ interface VisibleLeaf {
|
|
|
73
79
|
readonly fingerprint: string;
|
|
74
80
|
}
|
|
75
81
|
|
|
82
|
+
interface CachedBlob {
|
|
83
|
+
readonly promise: Promise<Uint8Array>;
|
|
84
|
+
size: number;
|
|
85
|
+
}
|
|
86
|
+
|
|
76
87
|
export interface SnapshotStoreOptions {
|
|
77
88
|
readonly storeRoot?: string;
|
|
78
89
|
readonly git?: GitRunner;
|
|
@@ -108,9 +119,9 @@ export interface SnapshotStore {
|
|
|
108
119
|
capture(topology: RootTopology, scope?: readonly string[], options?: CaptureOptions): Promise<SnapshotManifest>;
|
|
109
120
|
listVisibleLeafPaths(topology: RootTopology, options?: CaptureOptions): Promise<readonly string[]>;
|
|
110
121
|
loadManifest(id: ManifestId): Promise<SnapshotManifest>;
|
|
111
|
-
assertComplete(id: ManifestId): Promise<void>;
|
|
112
|
-
listTree(id: ManifestId, root: string): Promise<readonly RestorePath[]>;
|
|
113
|
-
readBlob(id: ManifestId, root: string, blobId: string): Promise<Uint8Array>;
|
|
122
|
+
assertComplete(id: ManifestId, scopePaths?: readonly string[]): Promise<void>;
|
|
123
|
+
listTree(id: ManifestId, root: string, rootScopePaths?: readonly string[]): Promise<readonly RestorePath[]>;
|
|
124
|
+
readBlob(id: ManifestId, root: string, blobId: string, relativePath?: string): Promise<Uint8Array>;
|
|
114
125
|
pin(id: ManifestId, reason: string): Promise<void>;
|
|
115
126
|
unpin(id: ManifestId, reason: string): Promise<void>;
|
|
116
127
|
collectGarbage(): Promise<number>;
|
|
@@ -124,8 +135,11 @@ export class SnapshotStore {
|
|
|
124
135
|
private readonly lock: WorkspaceLock;
|
|
125
136
|
private readonly clock: () => number;
|
|
126
137
|
private readonly manifestLocations = new Map<string, string>();
|
|
127
|
-
// Tree
|
|
138
|
+
// Tree 与 blob 都由 object ID 内容寻址;缓存只复用已从私有 ODB 读取的不可变内容。
|
|
128
139
|
private readonly treeEntriesCache = new Map<string, Promise<CapturedTreeEntry[]>>();
|
|
140
|
+
private readonly treeBlobMembership = new Map<string, string>();
|
|
141
|
+
private readonly blobCache = new Map<string, CachedBlob>();
|
|
142
|
+
private blobCacheBytes = 0;
|
|
129
143
|
|
|
130
144
|
constructor(options: SnapshotStoreOptions = {}) {
|
|
131
145
|
this.storeRoot = resolve(options.storeRoot ?? join(tmpdir(), "pi-undo-snapshot-store"));
|
|
@@ -263,15 +277,16 @@ export class SnapshotStore {
|
|
|
263
277
|
.filter((candidate) => isStrictRootAncestor(root.relativeRoot, candidate.relativeRoot))
|
|
264
278
|
.map((candidate) => rootRelativePath(root.relativeRoot, candidate.relativeRoot));
|
|
265
279
|
const exactExclusions = ownedArtifactExclusions(topology.roots, root.relativeRoot, artifactExclusions);
|
|
266
|
-
for (const
|
|
280
|
+
for (const relativePath of await this.queryVisibleLeafPaths(
|
|
267
281
|
absoluteRoot,
|
|
268
282
|
environment,
|
|
269
283
|
root.gitBacked,
|
|
270
284
|
[],
|
|
271
285
|
exclusions,
|
|
272
286
|
exactExclusions,
|
|
287
|
+
true,
|
|
273
288
|
)) {
|
|
274
|
-
result.add(workspaceRelativePath(root.relativeRoot,
|
|
289
|
+
result.add(workspaceRelativePath(root.relativeRoot, relativePath));
|
|
275
290
|
}
|
|
276
291
|
}
|
|
277
292
|
await this.assertTopology(topology, "可见路径枚举期间 topology 已变化");
|
|
@@ -303,7 +318,7 @@ export class SnapshotStore {
|
|
|
303
318
|
}
|
|
304
319
|
}
|
|
305
320
|
|
|
306
|
-
async assertComplete(id: ManifestId): Promise<void> {
|
|
321
|
+
async assertComplete(id: ManifestId, scopePaths?: readonly string[]): Promise<void> {
|
|
307
322
|
const manifestPath = await this.findManifestPath(id);
|
|
308
323
|
const manifest = await this.loadManifest(id);
|
|
309
324
|
const storeDirectory = dirname(dirname(manifestPath));
|
|
@@ -322,9 +337,11 @@ export class SnapshotStore {
|
|
|
322
337
|
}
|
|
323
338
|
continue;
|
|
324
339
|
}
|
|
340
|
+
const rootScope = rootRelativeScope(root.relativeRoot, scopePaths);
|
|
341
|
+
if (rootScope !== undefined && rootScope.length === 0) continue;
|
|
325
342
|
const gitDirectory = this.rootGitDirectory(storeDirectory, root);
|
|
326
343
|
await this.assertNoAlternates(gitDirectory);
|
|
327
|
-
const entries = await this.readTreeEntries(gitDirectory, root.treeId);
|
|
344
|
+
const entries = await this.readTreeEntries(gitDirectory, root.treeId, rootScope);
|
|
328
345
|
if (root.ignoredPresentPaths.some((ignoredPath) => entries.some(
|
|
329
346
|
(entry) => isPathAtOrBelow(ignoredPath, entry.relativePath) ||
|
|
330
347
|
isPathAtOrBelow(entry.relativePath, ignoredPath),
|
|
@@ -332,7 +349,8 @@ export class SnapshotStore {
|
|
|
332
349
|
throw new SnapshotStoreError("object_missing", "ignored-present proof 与 root tree 冲突");
|
|
333
350
|
}
|
|
334
351
|
await this.assertObjectsComplete(gitDirectory, root.treeId, entries);
|
|
335
|
-
if (
|
|
352
|
+
if (scopePaths !== undefined) await this.preloadBlobBytes(gitDirectory, entries);
|
|
353
|
+
if (rootScope === undefined && root.objectClosure !== treeObjectClosure(root.treeId, entries)) {
|
|
336
354
|
throw new SnapshotStoreError("object_missing", "root tree 对象闭包校验失败");
|
|
337
355
|
}
|
|
338
356
|
}
|
|
@@ -344,7 +362,11 @@ export class SnapshotStore {
|
|
|
344
362
|
}
|
|
345
363
|
}
|
|
346
364
|
|
|
347
|
-
async listTree(
|
|
365
|
+
async listTree(
|
|
366
|
+
id: ManifestId,
|
|
367
|
+
rootPath: string,
|
|
368
|
+
rootScopePaths?: readonly string[],
|
|
369
|
+
): Promise<readonly RestorePath[]> {
|
|
348
370
|
relativeSafePath("/", rootPath);
|
|
349
371
|
const manifestPath = await this.findManifestPath(id);
|
|
350
372
|
const manifest = await this.loadManifest(id);
|
|
@@ -359,7 +381,7 @@ export class SnapshotStore {
|
|
|
359
381
|
const storeDirectory = dirname(dirname(manifestPath));
|
|
360
382
|
const gitDirectory = this.rootGitDirectory(storeDirectory, root);
|
|
361
383
|
try {
|
|
362
|
-
const treeEntries = await this.readTreeEntries(gitDirectory, root.treeId);
|
|
384
|
+
const treeEntries = await this.readTreeEntries(gitDirectory, root.treeId, rootScopePaths);
|
|
363
385
|
const directories = new Set<string>();
|
|
364
386
|
for (const entry of treeEntries) {
|
|
365
387
|
const parts = entry.relativePath.split("/");
|
|
@@ -397,7 +419,12 @@ export class SnapshotStore {
|
|
|
397
419
|
}
|
|
398
420
|
}
|
|
399
421
|
|
|
400
|
-
async readBlob(
|
|
422
|
+
async readBlob(
|
|
423
|
+
id: ManifestId,
|
|
424
|
+
rootPath: string,
|
|
425
|
+
blobId: string,
|
|
426
|
+
relativePath?: string,
|
|
427
|
+
): Promise<Uint8Array> {
|
|
401
428
|
relativeSafePath("/", rootPath);
|
|
402
429
|
if (!isObjectId(blobId)) {
|
|
403
430
|
throw new SnapshotStoreError("object_missing", "blob ID 无效");
|
|
@@ -415,11 +442,24 @@ export class SnapshotStore {
|
|
|
415
442
|
const storeDirectory = dirname(dirname(manifestPath));
|
|
416
443
|
const gitDirectory = this.rootGitDirectory(storeDirectory, root);
|
|
417
444
|
try {
|
|
418
|
-
const
|
|
419
|
-
if (
|
|
420
|
-
|
|
445
|
+
const safeRelativePath = relativePath === undefined ? undefined : relativeSafePath("/", relativePath);
|
|
446
|
+
if (safeRelativePath === undefined) {
|
|
447
|
+
const entries = await this.readTreeEntries(gitDirectory, root.treeId);
|
|
448
|
+
if (!entries.some((entry) => entry.objectId === blobId)) {
|
|
449
|
+
throw new SnapshotStoreError("object_missing", "blob 不属于指定 root tree");
|
|
450
|
+
}
|
|
451
|
+
} else {
|
|
452
|
+
const membershipKey = treeBlobMembershipKey(gitDirectory, root.treeId, safeRelativePath);
|
|
453
|
+
let ownedBlobId = this.treeBlobMembership.get(membershipKey);
|
|
454
|
+
if (ownedBlobId === undefined) {
|
|
455
|
+
await this.readTreeEntries(gitDirectory, root.treeId, [safeRelativePath]);
|
|
456
|
+
ownedBlobId = this.treeBlobMembership.get(membershipKey);
|
|
457
|
+
}
|
|
458
|
+
if (ownedBlobId !== blobId) {
|
|
459
|
+
throw new SnapshotStoreError("object_missing", "blob 不属于指定 root tree path");
|
|
460
|
+
}
|
|
421
461
|
}
|
|
422
|
-
return await this.readBlobBytes(gitDirectory, blobId);
|
|
462
|
+
return new Uint8Array(await this.readBlobBytes(gitDirectory, blobId));
|
|
423
463
|
} catch (error) {
|
|
424
464
|
if (error instanceof SnapshotStoreError) {
|
|
425
465
|
throw error;
|
|
@@ -650,8 +690,10 @@ export class SnapshotStore {
|
|
|
650
690
|
exactExclusions,
|
|
651
691
|
);
|
|
652
692
|
const objectIds = new Map<string, string>();
|
|
653
|
-
|
|
654
|
-
|
|
693
|
+
const hashBatches = hashPathBatches(leaves.filter((leaf) => leaf.kind === "file"));
|
|
694
|
+
const hashedBatches = await mapConcurrentOrdered(hashBatches, HASH_BATCH_CONCURRENCY, async (batch) => {
|
|
695
|
+
await mapConcurrentOrdered(batch, FILE_SYSTEM_INSPECTION_CONCURRENCY, (leaf) =>
|
|
696
|
+
this.assertVisibleLeafUnchanged(cwd, leaf));
|
|
655
697
|
const output = await this.runGit([
|
|
656
698
|
"hash-object",
|
|
657
699
|
"-w",
|
|
@@ -660,10 +702,12 @@ export class SnapshotStore {
|
|
|
660
702
|
...batch.map((leaf) => leaf.relativePath),
|
|
661
703
|
], { cwd, env: environment });
|
|
662
704
|
const hashes = parseObjectIdLines(output, batch.length);
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
705
|
+
await mapConcurrentOrdered(batch, FILE_SYSTEM_INSPECTION_CONCURRENCY, (leaf) =>
|
|
706
|
+
this.assertVisibleLeafUnchanged(cwd, leaf));
|
|
707
|
+
return batch.map((leaf, index) => [leaf.relativePath, hashes[index]!] as const);
|
|
708
|
+
});
|
|
709
|
+
for (const batch of hashedBatches) {
|
|
710
|
+
for (const [relativePath, objectId] of batch) objectIds.set(relativePath, objectId);
|
|
667
711
|
}
|
|
668
712
|
for (const leaf of leaves) {
|
|
669
713
|
if (leaf.kind !== "symlink") continue;
|
|
@@ -701,60 +745,92 @@ export class SnapshotStore {
|
|
|
701
745
|
}
|
|
702
746
|
}
|
|
703
747
|
|
|
704
|
-
private async
|
|
748
|
+
private async queryVisibleLeafPaths(
|
|
705
749
|
cwd: string,
|
|
706
750
|
environment: Readonly<Record<string, string | undefined>>,
|
|
707
751
|
gitBacked: boolean,
|
|
708
752
|
inclusions: readonly string[] | null,
|
|
709
753
|
exclusions: readonly string[],
|
|
710
754
|
exactExclusions: readonly string[],
|
|
711
|
-
|
|
755
|
+
excludeDeleted = false,
|
|
756
|
+
): Promise<string[]> {
|
|
712
757
|
if (inclusions === null) return [];
|
|
713
758
|
const pathspecs = inclusions.length === 0 ? ["."] : inclusions.map(literalPathspec);
|
|
714
759
|
for (const excluded of exclusions) pathspecs.push(excludeLiteralPathspec(excluded));
|
|
715
760
|
const queryEnvironment = gitBacked ? sourceGitEnvironment() : environment;
|
|
716
|
-
const output = await
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
761
|
+
const [output, deletedOutput] = await Promise.all([
|
|
762
|
+
this.runGitBytes([
|
|
763
|
+
...(gitBacked ? ["-c", "core.fsmonitor=false"] : []),
|
|
764
|
+
"ls-files",
|
|
765
|
+
...(gitBacked ? ["--cached"] : []),
|
|
766
|
+
"--others",
|
|
767
|
+
"--exclude-standard",
|
|
768
|
+
"-z",
|
|
769
|
+
"--",
|
|
770
|
+
...pathspecs,
|
|
771
|
+
], { cwd, env: queryEnvironment }),
|
|
772
|
+
gitBacked && excludeDeleted
|
|
773
|
+
? this.runGitBytes([
|
|
774
|
+
"-c",
|
|
775
|
+
"core.fsmonitor=false",
|
|
776
|
+
"ls-files",
|
|
777
|
+
"--deleted",
|
|
778
|
+
"-z",
|
|
779
|
+
"--",
|
|
780
|
+
...pathspecs,
|
|
781
|
+
], { cwd, env: queryEnvironment })
|
|
782
|
+
: Promise.resolve(new Uint8Array()),
|
|
783
|
+
]);
|
|
784
|
+
const deletedPaths = new Set(parseNulPaths(deletedOutput));
|
|
785
|
+
return parseNulPaths(output).filter((relativePath) =>
|
|
786
|
+
!deletedPaths.has(relativePath) &&
|
|
787
|
+
!exclusions.some((excluded) => isPathAtOrBelow(excluded, relativePath)) &&
|
|
788
|
+
!exactExclusions.includes(relativePath));
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
private async collectVisibleLeaves(
|
|
792
|
+
cwd: string,
|
|
793
|
+
environment: Readonly<Record<string, string | undefined>>,
|
|
794
|
+
gitBacked: boolean,
|
|
795
|
+
inclusions: readonly string[] | null,
|
|
796
|
+
exclusions: readonly string[],
|
|
797
|
+
exactExclusions: readonly string[],
|
|
798
|
+
): Promise<VisibleLeaf[]> {
|
|
799
|
+
const paths = await this.queryVisibleLeafPaths(
|
|
800
|
+
cwd,
|
|
801
|
+
environment,
|
|
802
|
+
gitBacked,
|
|
803
|
+
inclusions,
|
|
804
|
+
exclusions,
|
|
805
|
+
exactExclusions,
|
|
806
|
+
);
|
|
807
|
+
const leaves = await mapConcurrentOrdered(paths, FILE_SYSTEM_INSPECTION_CONCURRENCY, async (relativePath) => {
|
|
732
808
|
relativeSafePath(cwd, relativePath);
|
|
733
809
|
await assertNoSymlinkEscape(cwd, relativePath);
|
|
734
810
|
const metadata = await lstat(join(cwd, ...relativePath.split("/"))).catch((error) => {
|
|
735
811
|
if (hasErrorCode(error, "ENOENT")) return null;
|
|
736
812
|
throw error;
|
|
737
813
|
});
|
|
738
|
-
if (metadata === null)
|
|
814
|
+
if (metadata === null) return null;
|
|
739
815
|
if (metadata.isSymbolicLink()) {
|
|
740
|
-
|
|
816
|
+
return {
|
|
741
817
|
relativePath,
|
|
742
|
-
kind: "symlink",
|
|
818
|
+
kind: "symlink" as const,
|
|
743
819
|
mode: 0o120000,
|
|
744
820
|
fingerprint: visibleLeafFingerprint(metadata),
|
|
745
|
-
}
|
|
746
|
-
}
|
|
747
|
-
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
if (metadata.isFile()) {
|
|
824
|
+
return {
|
|
748
825
|
relativePath,
|
|
749
|
-
kind: "file",
|
|
826
|
+
kind: "file" as const,
|
|
750
827
|
mode: (metadata.mode & 0o111) === 0 ? 0o100644 : 0o100755,
|
|
751
828
|
fingerprint: visibleLeafFingerprint(metadata),
|
|
752
|
-
}
|
|
753
|
-
} else {
|
|
754
|
-
throw new SnapshotStoreError("capture_failed", `不支持的工作区文件类型:${relativePath}`);
|
|
829
|
+
};
|
|
755
830
|
}
|
|
756
|
-
|
|
757
|
-
|
|
831
|
+
throw new SnapshotStoreError("capture_failed", `不支持的工作区文件类型:${relativePath}`);
|
|
832
|
+
});
|
|
833
|
+
return leaves.filter((leaf): leaf is VisibleLeaf => leaf !== null);
|
|
758
834
|
}
|
|
759
835
|
|
|
760
836
|
private async validateIgnoreQuery(
|
|
@@ -812,12 +888,38 @@ export class SnapshotStore {
|
|
|
812
888
|
}
|
|
813
889
|
}
|
|
814
890
|
|
|
815
|
-
private async readTreeEntries(
|
|
816
|
-
|
|
891
|
+
private async readTreeEntries(
|
|
892
|
+
gitDirectory: string,
|
|
893
|
+
treeId: string,
|
|
894
|
+
rootScopePaths?: readonly string[],
|
|
895
|
+
): Promise<CapturedTreeEntry[]> {
|
|
896
|
+
const scope = rootScopePaths === undefined
|
|
897
|
+
? undefined
|
|
898
|
+
: [...new Set(rootScopePaths.map((path) => relativeSafePath("/", path)))].sort(comparePaths);
|
|
899
|
+
const key = `${gitDirectory}\0${treeId}\0${scope === undefined ? "*" : checksum(canonicalJson(scope))}`;
|
|
817
900
|
const cached = this.treeEntriesCache.get(key);
|
|
818
901
|
if (cached !== undefined) return cached;
|
|
819
|
-
const pending = this.runPrivateGitBytes(gitDirectory, [
|
|
820
|
-
|
|
902
|
+
const pending = this.runPrivateGitBytes(gitDirectory, [
|
|
903
|
+
"ls-tree",
|
|
904
|
+
"-r",
|
|
905
|
+
"-l",
|
|
906
|
+
"-z",
|
|
907
|
+
treeId,
|
|
908
|
+
...(scope === undefined ? [] : ["--", ...scope.map(literalPathspec)]),
|
|
909
|
+
]).then((output) => {
|
|
910
|
+
const entries = parseTreeEntries(output);
|
|
911
|
+
for (const entry of entries) {
|
|
912
|
+
const membershipKey = treeBlobMembershipKey(gitDirectory, treeId, entry.relativePath);
|
|
913
|
+
this.treeBlobMembership.delete(membershipKey);
|
|
914
|
+
this.treeBlobMembership.set(membershipKey, entry.objectId);
|
|
915
|
+
}
|
|
916
|
+
while (this.treeBlobMembership.size > TREE_BLOB_MEMBERSHIP_LIMIT) {
|
|
917
|
+
const oldest = this.treeBlobMembership.keys().next().value as string | undefined;
|
|
918
|
+
if (oldest === undefined) break;
|
|
919
|
+
this.treeBlobMembership.delete(oldest);
|
|
920
|
+
}
|
|
921
|
+
return entries;
|
|
922
|
+
});
|
|
821
923
|
this.treeEntriesCache.set(key, pending);
|
|
822
924
|
while (this.treeEntriesCache.size > TREE_CACHE_LIMIT) {
|
|
823
925
|
const oldest = this.treeEntriesCache.keys().next().value as string | undefined;
|
|
@@ -861,6 +963,26 @@ export class SnapshotStore {
|
|
|
861
963
|
}
|
|
862
964
|
}
|
|
863
965
|
|
|
966
|
+
private async preloadBlobBytes(
|
|
967
|
+
gitDirectory: string,
|
|
968
|
+
entries: readonly CapturedTreeEntry[],
|
|
969
|
+
): Promise<void> {
|
|
970
|
+
const unique = new Map<string, CapturedTreeEntry>();
|
|
971
|
+
for (const entry of entries) {
|
|
972
|
+
if (!this.blobCache.has(blobCacheKey(gitDirectory, entry.objectId))) unique.set(entry.objectId, entry);
|
|
973
|
+
}
|
|
974
|
+
for (const batch of blobReadBatches([...unique.values()])) {
|
|
975
|
+
const loaded = parseBatchBlobOutput(
|
|
976
|
+
await this.runGitBytes(["cat-file", "--batch"], {
|
|
977
|
+
env: privateObjectEnvironment(gitDirectory),
|
|
978
|
+
stdin: `${batch.map((entry) => entry.objectId).join("\n")}\n`,
|
|
979
|
+
}),
|
|
980
|
+
batch,
|
|
981
|
+
);
|
|
982
|
+
for (const [objectId, bytes] of loaded) this.rememberBlobBytes(gitDirectory, objectId, bytes);
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
|
|
864
986
|
private async readBlobText(gitDirectory: string, objectId: string): Promise<string> {
|
|
865
987
|
return decodeUtf8(
|
|
866
988
|
await this.readBlobBytes(gitDirectory, objectId),
|
|
@@ -869,7 +991,51 @@ export class SnapshotStore {
|
|
|
869
991
|
}
|
|
870
992
|
|
|
871
993
|
private async readBlobBytes(gitDirectory: string, objectId: string): Promise<Uint8Array> {
|
|
872
|
-
|
|
994
|
+
const key = blobCacheKey(gitDirectory, objectId);
|
|
995
|
+
const cached = this.blobCache.get(key);
|
|
996
|
+
if (cached !== undefined) {
|
|
997
|
+
this.blobCache.delete(key);
|
|
998
|
+
this.blobCache.set(key, cached);
|
|
999
|
+
return cached.promise;
|
|
1000
|
+
}
|
|
1001
|
+
const entry: CachedBlob = {
|
|
1002
|
+
promise: this.runPrivateGitBytes(gitDirectory, ["cat-file", "blob", objectId]),
|
|
1003
|
+
size: 0,
|
|
1004
|
+
};
|
|
1005
|
+
this.blobCache.set(key, entry);
|
|
1006
|
+
try {
|
|
1007
|
+
const bytes = await entry.promise;
|
|
1008
|
+
this.finishBlobCacheEntry(key, entry, bytes.byteLength);
|
|
1009
|
+
return bytes;
|
|
1010
|
+
} catch (error) {
|
|
1011
|
+
if (this.blobCache.get(key) === entry) this.blobCache.delete(key);
|
|
1012
|
+
throw error;
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
private rememberBlobBytes(gitDirectory: string, objectId: string, bytes: Uint8Array): void {
|
|
1017
|
+
const key = blobCacheKey(gitDirectory, objectId);
|
|
1018
|
+
if (this.blobCache.has(key) || bytes.byteLength > BLOB_CACHE_MAX_BYTES) return;
|
|
1019
|
+
const entry: CachedBlob = { promise: Promise.resolve(bytes), size: 0 };
|
|
1020
|
+
this.blobCache.set(key, entry);
|
|
1021
|
+
this.finishBlobCacheEntry(key, entry, bytes.byteLength);
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
private finishBlobCacheEntry(key: string, entry: CachedBlob, size: number): void {
|
|
1025
|
+
if (this.blobCache.get(key) !== entry) return;
|
|
1026
|
+
if (size > BLOB_CACHE_MAX_BYTES) {
|
|
1027
|
+
this.blobCache.delete(key);
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
1030
|
+
entry.size = size;
|
|
1031
|
+
this.blobCacheBytes += size;
|
|
1032
|
+
while (this.blobCacheBytes > BLOB_CACHE_MAX_BYTES) {
|
|
1033
|
+
const oldestKey = this.blobCache.keys().next().value as string | undefined;
|
|
1034
|
+
if (oldestKey === undefined) break;
|
|
1035
|
+
const oldest = this.blobCache.get(oldestKey)!;
|
|
1036
|
+
this.blobCache.delete(oldestKey);
|
|
1037
|
+
this.blobCacheBytes -= oldest.size;
|
|
1038
|
+
}
|
|
873
1039
|
}
|
|
874
1040
|
|
|
875
1041
|
private runPrivateGit(gitDirectory: string, args: readonly string[]): Promise<string> {
|
|
@@ -991,7 +1157,7 @@ export class SnapshotStore {
|
|
|
991
1157
|
}
|
|
992
1158
|
|
|
993
1159
|
function captureCoverage(workspaceIdentity: string, scope: readonly string[] | undefined): string {
|
|
994
|
-
if (scope === undefined
|
|
1160
|
+
if (scope === undefined) {
|
|
995
1161
|
return COMPLETE_COVERAGE;
|
|
996
1162
|
}
|
|
997
1163
|
const paths = [...new Set(scope.map((path) => relativeSafePath(workspaceIdentity, path)))].sort(comparePaths);
|
|
@@ -1041,10 +1207,15 @@ function ownedArtifactExclusions(
|
|
|
1041
1207
|
return result;
|
|
1042
1208
|
}
|
|
1043
1209
|
|
|
1210
|
+
function rootRelativeScope(rootPath: string, scope: readonly string[] | undefined): string[] | undefined {
|
|
1211
|
+
const inclusions = rootScopePathspecs(rootPath, scope);
|
|
1212
|
+
if (inclusions === null) return [];
|
|
1213
|
+
return inclusions.length === 0 ? undefined : inclusions;
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1044
1216
|
function rootScopePathspecs(rootPath: string, scope: readonly string[] | undefined): string[] | null {
|
|
1045
|
-
if (scope === undefined
|
|
1046
|
-
|
|
1047
|
-
}
|
|
1217
|
+
if (scope === undefined) return [];
|
|
1218
|
+
if (scope.length === 0) return null;
|
|
1048
1219
|
const result = new Set<string>();
|
|
1049
1220
|
for (const path of scope) {
|
|
1050
1221
|
if (path === "." || path === rootPath || isStrictRootAncestor(path, rootPath)) {
|
|
@@ -1234,6 +1405,62 @@ function isolatedGitConfiguration(): Readonly<Record<string, string | undefined>
|
|
|
1234
1405
|
};
|
|
1235
1406
|
}
|
|
1236
1407
|
|
|
1408
|
+
function treeBlobMembershipKey(gitDirectory: string, treeId: string, relativePath: string): string {
|
|
1409
|
+
return `${gitDirectory}\0${treeId}\0${relativePath}`;
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
function blobCacheKey(gitDirectory: string, objectId: string): string {
|
|
1413
|
+
return `${gitDirectory}\0${objectId}`;
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
function blobReadBatches(entries: readonly CapturedTreeEntry[]): CapturedTreeEntry[][] {
|
|
1417
|
+
const result: CapturedTreeEntry[][] = [];
|
|
1418
|
+
let batch: CapturedTreeEntry[] = [];
|
|
1419
|
+
let bytes = 0;
|
|
1420
|
+
for (const entry of entries) {
|
|
1421
|
+
if (
|
|
1422
|
+
batch.length > 0 &&
|
|
1423
|
+
(batch.length >= BLOB_BATCH_MAX_ENTRIES || bytes + entry.size > BLOB_BATCH_MAX_BYTES)
|
|
1424
|
+
) {
|
|
1425
|
+
result.push(batch);
|
|
1426
|
+
batch = [];
|
|
1427
|
+
bytes = 0;
|
|
1428
|
+
}
|
|
1429
|
+
batch.push(entry);
|
|
1430
|
+
bytes += entry.size;
|
|
1431
|
+
}
|
|
1432
|
+
if (batch.length > 0) result.push(batch);
|
|
1433
|
+
return result;
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
function parseBatchBlobOutput(
|
|
1437
|
+
output: Uint8Array,
|
|
1438
|
+
expected: readonly CapturedTreeEntry[],
|
|
1439
|
+
): Map<string, Uint8Array> {
|
|
1440
|
+
const result = new Map<string, Uint8Array>();
|
|
1441
|
+
let offset = 0;
|
|
1442
|
+
for (const entry of expected) {
|
|
1443
|
+
const lineEnd = output.indexOf(0x0a, offset);
|
|
1444
|
+
if (lineEnd < 0) throw new SnapshotStoreError("object_missing", "Git blob batch header 不完整");
|
|
1445
|
+
const header = decodeUtf8(output.subarray(offset, lineEnd));
|
|
1446
|
+
const match = header.match(/^([0-9a-f]{40,64}) blob ([0-9]+)$/);
|
|
1447
|
+
if (match === null || match[1] !== entry.objectId || Number(match[2]) !== entry.size) {
|
|
1448
|
+
throw new SnapshotStoreError("object_missing", `Git blob batch header 无效:${entry.objectId}`);
|
|
1449
|
+
}
|
|
1450
|
+
const contentStart = lineEnd + 1;
|
|
1451
|
+
const contentEnd = contentStart + entry.size;
|
|
1452
|
+
if (contentEnd >= output.length || output[contentEnd] !== 0x0a) {
|
|
1453
|
+
throw new SnapshotStoreError("object_missing", `Git blob batch 内容不完整:${entry.objectId}`);
|
|
1454
|
+
}
|
|
1455
|
+
result.set(entry.objectId, output.slice(contentStart, contentEnd));
|
|
1456
|
+
offset = contentEnd + 1;
|
|
1457
|
+
}
|
|
1458
|
+
if (offset !== output.length) {
|
|
1459
|
+
throw new SnapshotStoreError("object_missing", "Git blob batch 输出包含多余内容");
|
|
1460
|
+
}
|
|
1461
|
+
return result;
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1237
1464
|
function indexInfoBatches(
|
|
1238
1465
|
leaves: readonly VisibleLeaf[],
|
|
1239
1466
|
objectIds: ReadonlyMap<string, string>,
|
|
@@ -1275,6 +1502,32 @@ function visibleLeafFingerprint(metadata: Stats): string {
|
|
|
1275
1502
|
}));
|
|
1276
1503
|
}
|
|
1277
1504
|
|
|
1505
|
+
async function mapConcurrentOrdered<T, R>(
|
|
1506
|
+
values: readonly T[],
|
|
1507
|
+
concurrency: number,
|
|
1508
|
+
operation: (value: T) => Promise<R>,
|
|
1509
|
+
): Promise<R[]> {
|
|
1510
|
+
const result = new Array<R>(values.length);
|
|
1511
|
+
let nextIndex = 0;
|
|
1512
|
+
let failed = false;
|
|
1513
|
+
let failure: unknown;
|
|
1514
|
+
async function worker(): Promise<void> {
|
|
1515
|
+
while (!failed && nextIndex < values.length) {
|
|
1516
|
+
const index = nextIndex;
|
|
1517
|
+
nextIndex += 1;
|
|
1518
|
+
try {
|
|
1519
|
+
result[index] = await operation(values[index]!);
|
|
1520
|
+
} catch (error) {
|
|
1521
|
+
if (!failed) failure = error;
|
|
1522
|
+
failed = true;
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, values.length) }, () => worker()));
|
|
1527
|
+
if (failed) throw failure;
|
|
1528
|
+
return result;
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1278
1531
|
function hashPathBatches(leaves: readonly VisibleLeaf[]): VisibleLeaf[][] {
|
|
1279
1532
|
const result: VisibleLeaf[][] = [];
|
|
1280
1533
|
let current: VisibleLeaf[] = [];
|
package/src/status-reporter.ts
CHANGED
|
@@ -43,9 +43,12 @@ export class StatusReporter {
|
|
|
43
43
|
this.context.ui.setStatus("pi-undo", undefined);
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
result(result: OperationResult): void {
|
|
46
|
+
result(result: OperationResult, totalMs?: number): void {
|
|
47
47
|
const details = result.message === undefined ? "" : ` ${sanitize(result.message)}`;
|
|
48
|
-
const
|
|
48
|
+
const timing = totalMs !== undefined && totalMs >= 1_000
|
|
49
|
+
? formatTiming(totalMs, result.timings)
|
|
50
|
+
: "";
|
|
51
|
+
const message = sanitize(`${result.code} files:${result.changedFiles}${details}${timing}`);
|
|
49
52
|
const type = result.code === "ok" || result.code === "noop"
|
|
50
53
|
? "info"
|
|
51
54
|
: result.code === "recovery_required" ? "error" : "warning";
|
|
@@ -68,6 +71,16 @@ export class StatusReporter {
|
|
|
68
71
|
}
|
|
69
72
|
}
|
|
70
73
|
|
|
74
|
+
function formatTiming(totalMs: number, timings: OperationResult["timings"]): string {
|
|
75
|
+
const phases = [...(timings ?? [])]
|
|
76
|
+
.filter((timing) => timing.durationMs >= 5)
|
|
77
|
+
.sort((left, right) => right.durationMs - left.durationMs)
|
|
78
|
+
.slice(0, 5)
|
|
79
|
+
.map((timing) => `${timing.phase}:${timing.durationMs}ms`)
|
|
80
|
+
.join(" ");
|
|
81
|
+
return ` total:${Math.round(totalMs)}ms${phases.length === 0 ? "" : ` ${phases}`}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
71
84
|
function sanitize(value: string): string {
|
|
72
85
|
return value
|
|
73
86
|
.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, "")
|