@davideasden/pi-undo 0.2.24 → 0.2.26

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.
@@ -17,7 +17,8 @@ import {
17
17
  } from "./durable-pack.ts";
18
18
  import { assertManifest, assertOperationId, canonicalJson, checksum } from "./encoding.ts";
19
19
  import { MutationJournal } from "./mutation-journal.ts";
20
- import { createNativeFileBatch } from "./native-restore.ts";
20
+ import { allCompleted, checkOperation, rethrowOperationFailure, isUnconfirmedExit, operationFailure, withRecoveryBudget } from "./operation-context.ts";
21
+ import { createNativeFileBatch, nativeRestoreCapability } from "./native-restore.ts";
21
22
  import { recoverPackedMutations } from "./packed-recovery.ts";
22
23
  import type { ManifestId, RestorePath, SnapshotManifest, SnapshotRoot } from "./model.ts";
23
24
  import {
@@ -61,6 +62,7 @@ export interface RestoreResult {
61
62
  verifiedPaths: number;
62
63
  totalPaths: number;
63
64
  postFingerprint?: string;
65
+ failureCode?: "operation_cancelled" | "operation_timeout";
64
66
  }
65
67
 
66
68
  export interface RestoreEngine {
@@ -97,6 +99,16 @@ interface OwnedPath {
97
99
  readonly root: SnapshotRoot;
98
100
  }
99
101
 
102
+ interface VisibleSubsetCheck {
103
+ /**
104
+ * 调用方刚刚完成 topology discovery 且随后没有文件 mutation 时为 true,可跳过枚举入口的重复校验。
105
+ * 枚举结束后的校验仍然执行,所以该窗口内的漂移依然被拒绝。
106
+ */
107
+ readonly topologyValidated: boolean;
108
+ readonly extraExclusions?: readonly string[];
109
+ readonly ownedPaths?: readonly (ReadonlyMap<string, OwnedPath> | undefined)[];
110
+ }
111
+
100
112
  interface PreparedRestorePlan {
101
113
  readonly plan: RestorePlan;
102
114
  readonly currentPaths: ReadonlyMap<string, OwnedPath>;
@@ -207,12 +219,12 @@ export class RestoreEngine {
207
219
  const canonicalScopePaths = scope === undefined ? undefined : [...scope];
208
220
  assertCompatibleManifests(current, target, scope);
209
221
  const isScopedPath = (path: string): boolean => scope === undefined || scope.has(path);
210
- await Promise.all([
222
+ await allCompleted([
211
223
  this.store.assertComplete(current.manifestId, canonicalScopePaths),
212
224
  this.store.assertComplete(target.manifestId, canonicalScopePaths),
213
225
  ]);
214
226
 
215
- const [currentPaths, targetPaths] = await Promise.all([
227
+ const [currentPaths, targetPaths] = await allCompleted([
216
228
  this.readOwnedPaths(current, canonicalScopePaths),
217
229
  this.readOwnedPaths(target, canonicalScopePaths),
218
230
  ]);
@@ -293,12 +305,13 @@ export class RestoreEngine {
293
305
  const expected = await this.plan(current, target, scopePaths);
294
306
  if (expected.planDigest !== cached.planDigest) return false;
295
307
  }
296
- const topology = await this.discovery.discover(this.workspaceRoot);
308
+ const topology = await this.discovery.discover(this.workspaceRoot, "safety-snapshot");
297
309
  this.assertCurrentTopology(current, target, topology);
298
310
  const native = await createNativeFileBatch({
299
311
  workspaceRoot: this.workspaceRoot,
300
312
  planDigest: cached.planDigest,
301
313
  journal: cacheJournal,
314
+ requiredCapability: nativeRestoreCapability(cached.pack),
302
315
  });
303
316
  if (native === undefined || !await native.verifySource(cached.pack)) return false;
304
317
  if (fromPersistentIndex) {
@@ -313,7 +326,8 @@ export class RestoreEngine {
313
326
  );
314
327
  }
315
328
  return true;
316
- } catch {
329
+ } catch (error) {
330
+ rethrowOperationFailure(error);
317
331
  return false;
318
332
  }
319
333
  }
@@ -517,7 +531,8 @@ export class RestoreEngine {
517
531
  private async acquireDurableIndexLease(): Promise<{ release(): Promise<void> } | undefined> {
518
532
  try {
519
533
  return await this.durableIndexLock.acquire(await this.durableIndexLockIdentity());
520
- } catch {
534
+ } catch (error) {
535
+ rethrowOperationFailure(error);
521
536
  return undefined;
522
537
  }
523
538
  }
@@ -737,7 +752,7 @@ export class RestoreEngine {
737
752
  compatibilityMode: boolean,
738
753
  pinsDeferred: boolean,
739
754
  ): Promise<RestoreResult> {
740
- const [current, storedTarget] = await Promise.all([
755
+ const [current, storedTarget] = await allCompleted([
741
756
  this.store.loadManifest(plan.currentManifestId),
742
757
  this.store.loadManifest(target.manifestId),
743
758
  ]);
@@ -773,13 +788,14 @@ export class RestoreEngine {
773
788
  if (await this.assertWorkspaceRootIdentity() !== current.workspaceIdentity) {
774
789
  throw new Error("restore workspace root 必须使用 canonical identity");
775
790
  }
776
- topologyBefore = await this.discovery.discover(this.workspaceRoot);
791
+ topologyBefore = await this.discovery.discover(this.workspaceRoot, "restore-pre");
777
792
  this.assertCurrentTopology(current, target, topologyBefore);
778
- } catch {
793
+ } catch (error) {
794
+ rethrowOperationFailure(error);
779
795
  return { code: "restore_failed_safe", verifiedPaths: 0, totalPaths: 0 };
780
796
  }
781
797
  const [currentPaths, targetPaths] = prepared === undefined
782
- ? await Promise.all([
798
+ ? await allCompleted([
783
799
  this.readOwnedPaths(current, plan.scopePaths),
784
800
  this.readOwnedPaths(target, plan.scopePaths),
785
801
  ])
@@ -796,14 +812,18 @@ export class RestoreEngine {
796
812
  return { code: "recovery_required", verifiedPaths: 0, totalPaths: currentPaths.size };
797
813
  }
798
814
  try {
815
+ // 兼容恢复路径会先在此窗口内补偿 pending mutations,因此只有非补偿路径可以跳过枚举入口的重复发现。
799
816
  await this.assertCompleteVisibleSubset(
800
817
  topologyBefore,
801
818
  [current, target],
802
819
  options.mutationJournal,
803
- [],
804
- this.completeCoverageOwnedPaths(plan.scopePaths, [currentPaths, targetPaths]),
820
+ {
821
+ topologyValidated: !compatibilityMode,
822
+ ownedPaths: this.completeCoverageOwnedPaths(plan.scopePaths, [currentPaths, targetPaths]),
823
+ },
805
824
  );
806
- } catch {
825
+ } catch (error) {
826
+ rethrowOperationFailure(error);
807
827
  return { code: "restore_failed_safe", verifiedPaths: 0, totalPaths: 0 };
808
828
  }
809
829
  let durablePack: DurablePack | undefined;
@@ -833,7 +853,8 @@ export class RestoreEngine {
833
853
  entries: await this.durablePackEntries(current, target, currentPaths, targetPaths, plan, options.opId),
834
854
  });
835
855
  }
836
- } catch {
856
+ } catch (error) {
857
+ rethrowOperationFailure(error);
837
858
  await removeDurablePack(options.mutationJournal).catch(() => {});
838
859
  }
839
860
  }
@@ -842,6 +863,7 @@ export class RestoreEngine {
842
863
  workspaceRoot: this.workspaceRoot,
843
864
  planDigest: plan.planDigest,
844
865
  journal: options.mutationJournal,
866
+ requiredCapability: nativeRestoreCapability(durablePack),
845
867
  })
846
868
  : undefined;
847
869
  if (durablePack !== undefined && nativeFileBatch === undefined) {
@@ -873,7 +895,8 @@ export class RestoreEngine {
873
895
  }
874
896
  try {
875
897
  await this.prefetchCompleteRestoreBlobs(plan, current, target, currentPaths, targetPaths);
876
- } catch {
898
+ } catch (error) {
899
+ rethrowOperationFailure(error);
877
900
  // 预取是性能优化;失败时继续走原有逐文件校验和可恢复 mutation 路径。
878
901
  }
879
902
  const preflight = await this.verifyKnownState(current, target, currentPaths, targetPaths, plan.scopePaths);
@@ -911,14 +934,16 @@ export class RestoreEngine {
911
934
  );
912
935
  await this.writePlannedPaths(target.manifestId, targetPaths, plan.writePaths, mutationContext);
913
936
 
914
- const topologyAfter = await this.discovery.discover(this.workspaceRoot);
937
+ const topologyAfter = await this.discovery.discover(this.workspaceRoot, "restore-post");
915
938
  assertUnchangedTopology(topologyBefore, topologyAfter);
916
939
  await this.assertCompleteVisibleSubset(
917
940
  topologyAfter,
918
941
  [target],
919
942
  options.mutationJournal,
920
- [],
921
- this.completeCoverageOwnedPaths(plan.scopePaths, [targetPaths]),
943
+ {
944
+ topologyValidated: true,
945
+ ownedPaths: this.completeCoverageOwnedPaths(plan.scopePaths, [targetPaths]),
946
+ },
922
947
  );
923
948
  const verification = await this.verifyTarget(
924
949
  target,
@@ -936,20 +961,17 @@ export class RestoreEngine {
936
961
  return await this.mutationsAreClean(options.mutationJournal)
937
962
  ? result
938
963
  : { code: "recovery_required", verifiedPaths: 0, totalPaths: verification.totalPaths };
939
- } catch {
940
- if (!await this.restorePendingMutations(mutationContext.quarantine, options.mutationJournal)) {
941
- return { code: "recovery_required", verifiedPaths: 0, totalPaths: currentPaths.size };
942
- }
943
- return this.rollback(
944
- current,
945
- target,
946
- topologyBefore,
947
- currentPaths,
948
- targetPaths,
949
- options,
950
- plan.scopePaths,
951
- !durablePackEnabled,
952
- );
964
+ } catch (error) {
965
+ if (isUnconfirmedExit(error)) throw error;
966
+ return withRecoveryBudget(async () => {
967
+ if (!await this.restorePendingMutations(mutationContext.quarantine, options.mutationJournal)) {
968
+ return { code: "recovery_required", verifiedPaths: 0, totalPaths: currentPaths.size };
969
+ }
970
+ const result = await this.rollback(
971
+ current, target, topologyBefore, currentPaths, targetPaths, options, plan.scopePaths, !durablePackEnabled,
972
+ );
973
+ return { ...result, failureCode: operationFailure(error) };
974
+ });
953
975
  }
954
976
  }
955
977
 
@@ -958,17 +980,16 @@ export class RestoreEngine {
958
980
  currentPaths: ReadonlyMap<string, OwnedPath>,
959
981
  targetPaths: ReadonlyMap<string, OwnedPath>,
960
982
  ): boolean {
961
- const writeOnly = plan.deletePaths.length === 0 &&
962
- plan.writePaths.length > 0 &&
963
- plan.writePaths.every((path) => targetPaths.get(path)?.entry.kind === "file");
964
- const deleteOnly = process.platform !== "win32" &&
965
- plan.writePaths.length === 0 &&
966
- plan.deletePaths.length > 0 &&
967
- plan.deletePaths.every((path) =>
968
- currentPaths.get(path)?.entry.kind === "file" &&
969
- targetPaths.get(path) === undefined &&
970
- !path.includes("/"));
971
- return writeOnly || deleteOnly;
983
+ if (plan.deletePaths.length + plan.writePaths.length === 0) return false;
984
+ if (process.platform === "win32" && plan.deletePaths.length > 0) return false;
985
+ if (!plan.deletePaths.every((path) =>
986
+ currentPaths.get(path)?.entry.kind === "file" && targetPaths.get(path) === undefined)) return false;
987
+ if (!plan.writePaths.every((path) =>
988
+ targetPaths.get(path)?.entry.kind === "file" &&
989
+ (currentPaths.get(path) === undefined || currentPaths.get(path)?.entry.kind === "file"))) return false;
990
+ // helper 的句柄上限一致;目录创建、类型替换及过多父目录继续使用 TypeScript。
991
+ const parents = new Set([...plan.deletePaths, ...plan.writePaths].flatMap(strictPathAncestors));
992
+ return process.platform === "win32" || parents.size <= 128;
972
993
  }
973
994
 
974
995
  private async applyNativeFilePlan(
@@ -984,49 +1005,45 @@ export class RestoreEngine {
984
1005
  ): Promise<RestoreResult> {
985
1006
  try {
986
1007
  await nativeRun(pack);
987
- const topologyAfter = await this.discovery.discover(this.workspaceRoot);
1008
+ const topologyAfter = await this.discovery.discover(this.workspaceRoot, "restore-post");
988
1009
  assertUnchangedTopology(topologyBefore, topologyAfter);
989
1010
  await this.assertCompleteVisibleSubset(
990
1011
  topologyAfter,
991
1012
  [target],
992
1013
  options.mutationJournal,
993
- pack.paths().flatMap((path) => {
994
- const artifacts = pack.artifacts(path);
995
- return artifacts === undefined
996
- ? []
997
- : [artifacts.source, ...(artifacts.target === null ? [] : [artifacts.target])];
998
- }),
999
- this.completeCoverageOwnedPaths(plan.scopePaths, [targetPaths]),
1014
+ {
1015
+ topologyValidated: true,
1016
+ extraExclusions: pack.paths().flatMap((path) => {
1017
+ const artifacts = pack.artifacts(path);
1018
+ return artifacts === undefined
1019
+ ? []
1020
+ : [artifacts.source, ...(artifacts.target === null ? [] : [artifacts.target])];
1021
+ }),
1022
+ ownedPaths: this.completeCoverageOwnedPaths(plan.scopePaths, [targetPaths]),
1023
+ },
1000
1024
  );
1001
1025
  const totalPaths = plan.deletePaths.length + plan.writePaths.length;
1002
1026
  if ((await options.mutationJournal.load()).length !== 0) {
1003
1027
  return { code: "recovery_required", verifiedPaths: 0, totalPaths };
1004
1028
  }
1005
1029
  return { code: "ok", verifiedPaths: totalPaths, totalPaths };
1006
- } catch {
1007
- const packedRecovery = await recoverPackedMutations({
1008
- workspaceRoot: this.workspaceRoot,
1009
- journal: options.mutationJournal,
1010
- planDigest: plan.planDigest,
1011
- decision: "rollback",
1030
+ } catch (error) {
1031
+ if (isUnconfirmedExit(error)) throw error;
1032
+ return withRecoveryBudget(async () => {
1033
+ const packedRecovery = await recoverPackedMutations({
1034
+ workspaceRoot: this.workspaceRoot,
1035
+ journal: options.mutationJournal,
1036
+ planDigest: plan.planDigest,
1037
+ decision: "rollback",
1038
+ });
1039
+ if (packedRecovery.kind !== "clean") {
1040
+ return { code: "recovery_required", verifiedPaths: 0, totalPaths: plan.deletePaths.length + plan.writePaths.length };
1041
+ }
1042
+ const result = await this.rollback(
1043
+ current, target, topologyBefore, currentPaths, targetPaths, options, plan.scopePaths, false,
1044
+ );
1045
+ return { ...result, failureCode: operationFailure(error) };
1012
1046
  });
1013
- if (packedRecovery.kind !== "clean") {
1014
- return {
1015
- code: "recovery_required",
1016
- verifiedPaths: 0,
1017
- totalPaths: plan.deletePaths.length + plan.writePaths.length,
1018
- };
1019
- }
1020
- return this.rollback(
1021
- current,
1022
- target,
1023
- topologyBefore,
1024
- currentPaths,
1025
- targetPaths,
1026
- options,
1027
- plan.scopePaths,
1028
- false,
1029
- );
1030
1047
  }
1031
1048
  }
1032
1049
 
@@ -1041,7 +1058,7 @@ export class RestoreEngine {
1041
1058
  const paths = [...new Set([...plan.deletePaths, ...plan.writePaths])].sort(comparePaths);
1042
1059
  const useValidatedBatch = SnapshotStore.supportsValidatedBlobBatch(this.store);
1043
1060
  const [currentBlobBytes, targetBlobBytes] = useValidatedBatch
1044
- ? await Promise.all([
1061
+ ? await allCompleted([
1045
1062
  this.readDurableBlobBytes(current.manifestId, paths, currentPaths),
1046
1063
  this.readDurableBlobBytes(target.manifestId, paths, targetPaths),
1047
1064
  ])
@@ -1211,7 +1228,7 @@ export class RestoreEngine {
1211
1228
  }
1212
1229
  return requests;
1213
1230
  };
1214
- await Promise.all([
1231
+ await allCompleted([
1215
1232
  this.store.prefetchBlobs(current.manifestId, requestsFor(currentPaths, plan.deletePaths)),
1216
1233
  this.store.prefetchBlobs(target.manifestId, requestsFor(targetPaths)),
1217
1234
  ]);
@@ -1332,6 +1349,7 @@ export class RestoreEngine {
1332
1349
  fileBatchRoot = undefined;
1333
1350
  };
1334
1351
  for (const path of deletePaths) {
1352
+ checkOperation();
1335
1353
  if (await this.pathIsShadowedByTarget(targetManifestId, path, targetPaths)) continue;
1336
1354
  const source = context.sourcePaths.get(path);
1337
1355
  const live = await lstat(this.absolutePath(path)).catch((error) => {
@@ -1516,14 +1534,16 @@ export class RestoreEngine {
1516
1534
  context,
1517
1535
  );
1518
1536
  await this.writePlannedPaths(current.manifestId, currentPaths, rollbackPlan.writePaths, context);
1519
- const topologyAfter = await this.discovery.discover(this.workspaceRoot);
1537
+ const topologyAfter = await this.discovery.discover(this.workspaceRoot, "restore-post");
1520
1538
  assertUnchangedTopology(topologyBefore, topologyAfter);
1521
1539
  await this.assertCompleteVisibleSubset(
1522
1540
  topologyAfter,
1523
1541
  [current],
1524
1542
  options.mutationJournal,
1525
- [],
1526
- this.completeCoverageOwnedPaths(scopePaths, [currentPaths]),
1543
+ {
1544
+ topologyValidated: true,
1545
+ ownedPaths: this.completeCoverageOwnedPaths(scopePaths, [currentPaths]),
1546
+ },
1527
1547
  );
1528
1548
  const verification = await this.verifyTarget(
1529
1549
  current,
@@ -1559,14 +1579,16 @@ export class RestoreEngine {
1559
1579
  }
1560
1580
  if (rollbackPlan !== undefined) {
1561
1581
  try {
1562
- const topologyAfter = await this.discovery.discover(this.workspaceRoot);
1582
+ const topologyAfter = await this.discovery.discover(this.workspaceRoot, "compensation");
1563
1583
  assertUnchangedTopology(topologyBefore, topologyAfter);
1564
1584
  await this.assertCompleteVisibleSubset(
1565
1585
  topologyAfter,
1566
1586
  [current],
1567
1587
  options.mutationJournal,
1568
- [],
1569
- this.completeCoverageOwnedPaths(scopePaths, [currentPaths]),
1588
+ {
1589
+ topologyValidated: true,
1590
+ ownedPaths: this.completeCoverageOwnedPaths(scopePaths, [currentPaths]),
1591
+ },
1570
1592
  );
1571
1593
  const verification = await this.verifyTarget(
1572
1594
  current,
@@ -1621,7 +1643,9 @@ export class RestoreEngine {
1621
1643
  context.ordinal += 1;
1622
1644
  const ordinal = context.ordinal;
1623
1645
  const beforeInstall = async (): Promise<void> => {
1646
+ checkOperation();
1624
1647
  await this.beforeMutation?.({ phase: context.phase, ordinal, kind, path });
1648
+ checkOperation();
1625
1649
  };
1626
1650
  if (!deferHook) await beforeInstall();
1627
1651
  await this.assertMutationPath(path);
@@ -1656,6 +1680,7 @@ export class RestoreEngine {
1656
1680
  };
1657
1681
  for (const kind of ["directory", "leaf"] as const) {
1658
1682
  for (const path of writePaths) {
1683
+ checkOperation();
1659
1684
  const target = targetPaths.get(path);
1660
1685
  if (target === undefined) {
1661
1686
  throw new Error(`${context.phase} plan 引用了 manifest 外路径:${path}`);
@@ -1715,6 +1740,7 @@ export class RestoreEngine {
1715
1740
  targetFingerprint: fingerprintBytes(target.absolutePath, bytes, target.entry.mode),
1716
1741
  ...(this.beforeMutation === undefined ? {} : {
1717
1742
  beforeInstall: async () => {
1743
+ checkOperation();
1718
1744
  await this.beforeMutation?.({
1719
1745
  phase: context.phase,
1720
1746
  ordinal,
@@ -1757,9 +1783,8 @@ export class RestoreEngine {
1757
1783
  private async assertCompleteVisibleSubset(
1758
1784
  topology: RootTopology,
1759
1785
  allowedManifests: readonly SnapshotManifest[],
1760
- mutationJournal?: MutationJournal,
1761
- extraExclusions: readonly string[] = [],
1762
- ownedPaths?: readonly (ReadonlyMap<string, OwnedPath> | undefined)[],
1786
+ mutationJournal: MutationJournal | undefined,
1787
+ check: VisibleSubsetCheck,
1763
1788
  ): Promise<void> {
1764
1789
  if (allowedManifests.some((manifest) => manifest.coverage !== "complete")) {
1765
1790
  return;
@@ -1769,7 +1794,7 @@ export class RestoreEngine {
1769
1794
  for (const path of ignoredWorkspacePaths(manifest)) {
1770
1795
  allowedPaths.add(path);
1771
1796
  }
1772
- const paths = ownedPaths?.[index] ?? await this.readOwnedPaths(manifest);
1797
+ const paths = check.ownedPaths?.[index] ?? await this.readOwnedPaths(manifest);
1773
1798
  for (const [path, owned] of paths) {
1774
1799
  if (owned.entry.kind !== "directory") {
1775
1800
  allowedPaths.add(path);
@@ -1777,12 +1802,13 @@ export class RestoreEngine {
1777
1802
  }
1778
1803
  }
1779
1804
 
1780
- const exclusions = new Set(extraExclusions);
1805
+ const exclusions = new Set(check.extraExclusions ?? []);
1781
1806
  if (mutationJournal !== undefined) {
1782
1807
  for (const path of await mutationJournal.activeArtifacts()) exclusions.add(path);
1783
1808
  }
1784
1809
  const livePaths = await this.store.listVisibleLeafPaths(topology, {
1785
1810
  excludePaths: exclusions.size === 0 ? undefined : [...exclusions],
1811
+ topologyAlreadyValidated: check.topologyValidated,
1786
1812
  });
1787
1813
  for (const path of livePaths) {
1788
1814
  if (!allowedPaths.has(path)) {
@@ -1933,7 +1959,7 @@ export class RestoreEngine {
1933
1959
  if (owned.entry.blobId === null) {
1934
1960
  throw new Error(`普通文件缺少 blob:${path}`);
1935
1961
  }
1936
- const [actual, expected] = await Promise.all([
1962
+ const [actual, expected] = await allCompleted([
1937
1963
  readFile(this.absolutePath(path)),
1938
1964
  this.store.readBlob(
1939
1965
  manifestId,
@@ -2262,6 +2288,7 @@ async function mapConcurrentOrdered<T, R>(
2262
2288
  const index = nextIndex;
2263
2289
  nextIndex += 1;
2264
2290
  try {
2291
+ checkOperation();
2265
2292
  results[index] = await operation(values[index]!);
2266
2293
  } catch (error) {
2267
2294
  if (!failed) failure = error;
@@ -2,10 +2,13 @@ import { lstat, readdir, readFile, realpath, stat } from "node:fs/promises";
2
2
  import { isAbsolute, join, relative, resolve, sep } from "node:path";
3
3
 
4
4
  import { checksum, topologyFingerprint } from "./encoding.ts";
5
- import { GitRunner } from "./git-runner.ts";
5
+ import { GitRunError, GitRunner } from "./git-runner.ts";
6
6
  import type { DiscoveryRoot } from "./model.ts";
7
+ import { NativeDirectoryScanner, type NativeDirectoryScanPort, type NativeRepositoryCandidate } from "./native-directory-scan.ts";
8
+ import { allCompleted, checkOperation, OperationError, reportOperationProgress } from "./operation-context.ts";
7
9
 
8
10
  const DIRECTORY_SCAN_CONCURRENCY = 16;
11
+ const NATIVE_SCAN_MIN_DIRECTORIES = 64;
9
12
  const GIT_POINTER_MAX_BYTES = 4096;
10
13
 
11
14
  interface RepositoryInfo {
@@ -38,31 +41,48 @@ export interface RootTopology {
38
41
  readonly fingerprint: string;
39
42
  }
40
43
 
44
+ /**
45
+ * discover 调用原因:区分安全快照、恢复前、可见路径枚举、恢复后与补偿路径。
46
+ * 扫描行为不依赖该标签,它只作为调用方诊断与调用次数回归的归属标记。
47
+ */
48
+ export type RootDiscoveryReason =
49
+ | "safety-snapshot"
50
+ | "restore-pre"
51
+ | "visible-paths-pre"
52
+ | "visible-paths-post"
53
+ | "restore-post"
54
+ | "compensation"
55
+ | "unspecified";
56
+
41
57
  export type RootDiscoveryErrorCode = "workspace_not_found" | "discovery_failed";
42
58
 
43
59
  export class RootDiscoveryError extends Error {
44
60
  readonly code: RootDiscoveryErrorCode;
45
61
 
46
- constructor(code: RootDiscoveryErrorCode, message: string) {
47
- super(message);
62
+ constructor(code: RootDiscoveryErrorCode, message: string, options?: ErrorOptions) {
63
+ super(message, options);
48
64
  this.name = "RootDiscoveryError";
49
65
  this.code = code;
50
66
  }
51
67
  }
52
68
 
53
69
  export interface RootDiscovery {
54
- discover(workspaceRoot: string): Promise<RootTopology>;
70
+ discover(workspaceRoot: string, reason?: RootDiscoveryReason): Promise<RootTopology>;
55
71
  }
56
72
 
57
73
  export class RootDiscovery {
58
74
  private readonly git: GitRunner;
75
+ private lastDirectoryCount: number | undefined;
59
76
 
60
- constructor(git = new GitRunner()) {
77
+ constructor(git = new GitRunner(), private readonly nativeScan: NativeDirectoryScanPort = new NativeDirectoryScanner()) {
61
78
  this.git = git;
62
79
  }
63
80
 
64
- async discover(workspaceRoot: string): Promise<RootTopology> {
81
+ async discover(workspaceRoot: string, reason: RootDiscoveryReason = "unspecified"): Promise<RootTopology> {
82
+ checkOperation();
83
+ reportOperationProgress(`discover_roots:${reason}`);
65
84
  const workspaceIdentity = await canonicalWorkspaceRoot(workspaceRoot);
85
+ checkOperation();
66
86
  const activeRoots = new Map<string, DiscoveredRoot>();
67
87
  const outerRepository = await this.inspectRepository(workspaceIdentity, workspaceIdentity);
68
88
  if (outerRepository.kind === "active") {
@@ -77,7 +97,10 @@ export class RootDiscovery {
77
97
  }
78
98
 
79
99
  await this.scanDirectory(workspaceIdentity, workspaceIdentity, activeRoots);
100
+ checkOperation();
101
+ reportOperationProgress("discover_gitlinks");
80
102
  const gitlinkRoots = await this.discoverGitlinks(workspaceIdentity, activeRoots);
103
+ checkOperation();
81
104
  const roots = buildRoots([...activeRoots.values(), ...gitlinkRoots.values()]);
82
105
  return {
83
106
  workspaceIdentity,
@@ -92,16 +115,41 @@ export class RootDiscovery {
92
115
  activeRoots: Map<string, DiscoveredRoot>,
93
116
  ): Promise<void> {
94
117
  let level: Array<{ readonly path: string; readonly inspect: boolean }> = [{ path: directory, inspect: false }];
118
+ let scanned = 0;
119
+ reportOperationProgress("scan_directories");
120
+ // 上次规模只选择执行器;小工作区仍完整重扫,目录增长后会重新使用 native。
121
+ const native = this.lastDirectoryCount !== undefined && this.lastDirectoryCount < NATIVE_SCAN_MIN_DIRECTORIES
122
+ ? undefined : await this.nativeScan.scan(workspaceIdentity);
123
+ if (native !== undefined) {
124
+ this.lastDirectoryCount = native.directories;
125
+ for (let index = 0; index < native.repositories.length; index += DIRECTORY_SCAN_CONCURRENCY) {
126
+ await allCompleted(native.repositories.slice(index, index + DIRECTORY_SCAN_CONCURRENCY).map(async (candidate) => {
127
+ checkOperation();
128
+ const path = join(workspaceIdentity, candidate.path);
129
+ await assertNativeCandidate(path, candidate);
130
+ const inspection = await this.inspectRepository(path, workspaceIdentity);
131
+ await assertNativeCandidate(path, candidate);
132
+ this.recordInspection(workspaceIdentity, inspection, activeRoots);
133
+ }));
134
+ }
135
+ reportOperationProgress(`scan_directories:${native.directories}`);
136
+ return;
137
+ }
95
138
  while (level.length > 0) {
139
+ checkOperation();
96
140
  const next: Array<{ readonly path: string; readonly inspect: true }> = [];
97
141
  for (let index = 0; index < level.length; index += DIRECTORY_SCAN_CONCURRENCY) {
98
- const children = await Promise.all(level.slice(index, index + DIRECTORY_SCAN_CONCURRENCY).map(
142
+ checkOperation();
143
+ const children = await allCompleted(level.slice(index, index + DIRECTORY_SCAN_CONCURRENCY).map(
99
144
  (candidate) => this.scanDirectoryNode(workspaceIdentity, candidate, activeRoots),
100
145
  ));
101
146
  for (const group of children) next.push(...group);
147
+ scanned += children.length;
148
+ reportOperationProgress(`scan_directories:${scanned}`);
102
149
  }
103
150
  level = next;
104
151
  }
152
+ this.lastDirectoryCount = scanned;
105
153
  }
106
154
 
107
155
  private async scanDirectoryNode(
@@ -109,21 +157,14 @@ export class RootDiscovery {
109
157
  candidate: { readonly path: string; readonly inspect: boolean },
110
158
  activeRoots: Map<string, DiscoveredRoot>,
111
159
  ): Promise<Array<{ readonly path: string; readonly inspect: true }>> {
160
+ checkOperation();
112
161
  if (!await isSafeDirectory(candidate.path, workspaceIdentity)) return [];
113
162
  if (candidate.inspect) {
114
163
  const inspection = await this.inspectRepository(candidate.path, workspaceIdentity);
115
- if (inspection.kind === "active") {
116
- activeRoots.set(
117
- inspection.repository.absoluteRoot,
118
- this.activeRoot(workspaceIdentity, inspection.repository),
119
- );
120
- } else if (inspection.kind === "broken") {
121
- activeRoots.set(inspection.absoluteRoot, brokenRoot(workspaceIdentity, inspection.absoluteRoot));
122
- } else if (inspection.kind === "stale") {
123
- activeRoots.set(inspection.absoluteRoot, staleWorktreeRoot(workspaceIdentity, inspection.absoluteRoot));
124
- }
164
+ this.recordInspection(workspaceIdentity, inspection, activeRoots);
125
165
  if (!await isSafeDirectory(candidate.path, workspaceIdentity)) return [];
126
166
  }
167
+ checkOperation();
127
168
  const entries = await readdir(candidate.path, { withFileTypes: true });
128
169
  if (!await isSafeDirectory(candidate.path, workspaceIdentity)) return [];
129
170
  return entries
@@ -131,6 +172,20 @@ export class RootDiscovery {
131
172
  .map((entry) => ({ path: join(candidate.path, entry.name), inspect: true as const }));
132
173
  }
133
174
 
175
+ private recordInspection(
176
+ workspaceIdentity: string,
177
+ inspection: RepositoryInspection,
178
+ activeRoots: Map<string, DiscoveredRoot>,
179
+ ): void {
180
+ if (inspection.kind === "active") {
181
+ activeRoots.set(inspection.repository.absoluteRoot, this.activeRoot(workspaceIdentity, inspection.repository));
182
+ } else if (inspection.kind === "broken") {
183
+ activeRoots.set(inspection.absoluteRoot, brokenRoot(workspaceIdentity, inspection.absoluteRoot));
184
+ } else if (inspection.kind === "stale") {
185
+ activeRoots.set(inspection.absoluteRoot, staleWorktreeRoot(workspaceIdentity, inspection.absoluteRoot));
186
+ }
187
+ }
188
+
134
189
  private async inspectRepository(candidate: string, workspaceIdentity: string): Promise<RepositoryInspection> {
135
190
  const marker = await gitMarkerState(candidate);
136
191
  if (marker === "absent") {
@@ -208,6 +263,7 @@ export class RootDiscovery {
208
263
  ): Promise<Map<string, DiscoveredRoot>> {
209
264
  const result = new Map<string, DiscoveredRoot>();
210
265
  for (const root of activeRoots.values()) {
266
+ checkOperation();
211
267
  if (!root.gitBacked || root.state !== "active") {
212
268
  continue;
213
269
  }
@@ -248,13 +304,37 @@ export class RootDiscovery {
248
304
  const result = await this.git.run(["-c", "core.fsmonitor=false", ...args], {
249
305
  env: cleanGitEnvironment(),
250
306
  });
307
+ if (result.aborted || result.timedOut) {
308
+ // 取消/超时不应被当成"仓库损坏";有 context 时在这里重新抛出具体原因。
309
+ checkOperation();
310
+ }
251
311
  return result.killed ? null : result.stdout;
252
- } catch {
312
+ } catch (error) {
313
+ if (error instanceof GitRunError && error.code === "git_termination_failed") {
314
+ // 无法证明子进程已停止:上层必须保留 lease 并走恢复,不能继续扫描。
315
+ throw error;
316
+ }
317
+ if (error instanceof OperationError) {
318
+ throw error;
319
+ }
253
320
  return null;
254
321
  }
255
322
  }
256
323
  }
257
324
 
325
+ async function assertNativeCandidate(path: string, candidate: NativeRepositoryCandidate): Promise<void> {
326
+ try {
327
+ const metadata = await lstat(path, { bigint: true });
328
+ if (!metadata.isDirectory() || metadata.dev !== candidate.dev || metadata.ino !== candidate.ino ||
329
+ await realpath(path) !== path) {
330
+ throw new RootDiscoveryError("discovery_failed", `native 扫描后仓库目录身份发生变化:${candidate.path}`);
331
+ }
332
+ } catch (error) {
333
+ if (error instanceof RootDiscoveryError) throw error;
334
+ throw new RootDiscoveryError("discovery_failed", `无法复核 native 仓库候选:${candidate.path}`, { cause: error });
335
+ }
336
+ }
337
+
258
338
  function syntheticRoot(workspaceIdentity: string): DiscoveredRoot {
259
339
  return {
260
340
  absoluteRoot: workspaceIdentity,
@@ -391,6 +471,7 @@ async function gitlinkState(absolutePath: string): Promise<DiscoveryRoot["state"
391
471
  const SKELETON_NOISE_FILES = new Set([".DS_Store", "desktop.ini", "Thumbs.db"]);
392
472
 
393
473
  async function directoryTreeContainsFile(directory: string): Promise<boolean> {
474
+ checkOperation();
394
475
  let entries;
395
476
  try {
396
477
  entries = await readdir(directory, { withFileTypes: true });