@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.
@@ -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 HASH_BATCH_MAX_PATHS = 128;
36
- const HASH_BATCH_MAX_ARGUMENT_BYTES = 24 * 1024;
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 ID 是内容寻址且不可变;这里只缓存解析元数据,blob bytes 与完整性仍逐次读取/校验。
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 leaf of await this.collectVisibleLeaves(
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, leaf.relativePath));
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 (root.objectClosure !== treeObjectClosure(root.treeId, entries)) {
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(id: ManifestId, rootPath: string): Promise<readonly RestorePath[]> {
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(id: ManifestId, rootPath: string, blobId: string): Promise<Uint8Array> {
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 entries = await this.readTreeEntries(gitDirectory, root.treeId);
419
- if (!entries.some((entry) => entry.objectId === blobId)) {
420
- throw new SnapshotStoreError("object_missing", "blob 不属于指定 root tree");
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
- for (const batch of hashPathBatches(leaves.filter((leaf) => leaf.kind === "file"))) {
654
- for (const leaf of batch) await this.assertVisibleLeafUnchanged(cwd, leaf);
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
- for (const leaf of batch) await this.assertVisibleLeafUnchanged(cwd, leaf);
664
- for (let index = 0; index < batch.length; index += 1) {
665
- objectIds.set(batch[index]!.relativePath, hashes[index]!);
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 collectVisibleLeaves(
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
- ): Promise<VisibleLeaf[]> {
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 this.runGitBytes([
717
- ...(gitBacked ? ["-c", "core.fsmonitor=false"] : []),
718
- "ls-files",
719
- ...(gitBacked ? ["--cached"] : []),
720
- "--others",
721
- "--exclude-standard",
722
- "-z",
723
- "--",
724
- ...pathspecs,
725
- ], { cwd, env: queryEnvironment });
726
- const result: VisibleLeaf[] = [];
727
- for (const relativePath of parseNulPaths(output)) {
728
- if (
729
- exclusions.some((excluded) => isPathAtOrBelow(excluded, relativePath)) ||
730
- exactExclusions.includes(relativePath)
731
- ) continue;
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) continue;
814
+ if (metadata === null) return null;
739
815
  if (metadata.isSymbolicLink()) {
740
- result.push({
816
+ return {
741
817
  relativePath,
742
- kind: "symlink",
818
+ kind: "symlink" as const,
743
819
  mode: 0o120000,
744
820
  fingerprint: visibleLeafFingerprint(metadata),
745
- });
746
- } else if (metadata.isFile()) {
747
- result.push({
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
- return result;
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(gitDirectory: string, treeId: string): Promise<CapturedTreeEntry[]> {
816
- const key = `${gitDirectory}\0${treeId}`;
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, ["ls-tree", "-r", "-l", "-z", treeId])
820
- .then(parseTreeEntries);
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
- return this.runPrivateGitBytes(gitDirectory, ["cat-file", "blob", objectId]);
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 || scope.length === 0) {
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 || scope.length === 0) {
1046
- return [];
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[] = [];
@@ -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 message = sanitize(`${result.code} files:${result.changedFiles}${details}`);
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, "")