@davideasden/pi-undo 0.2.10 → 0.2.11

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davideasden/pi-undo",
3
- "version": "0.2.10",
3
+ "version": "0.2.11",
4
4
  "description": "Persistent workspace undo and redo for Pi",
5
5
  "type": "module",
6
6
  "keywords": [
package/src/encoding.ts CHANGED
@@ -44,6 +44,32 @@ export function checksum(value: string | Uint8Array): string {
44
44
  return createHash("sha256").update(input).digest("hex");
45
45
  }
46
46
 
47
+ export function sameWorkspaceSnapshot(left: SnapshotManifest, right: SnapshotManifest): boolean {
48
+ if (
49
+ left.schemaVersion !== right.schemaVersion ||
50
+ left.workspaceIdentity !== right.workspaceIdentity ||
51
+ left.topologyFingerprint !== right.topologyFingerprint ||
52
+ left.coverage !== right.coverage ||
53
+ left.roots.length !== right.roots.length
54
+ ) return false;
55
+ return left.roots.every((root, index) => {
56
+ const candidate = right.roots[index];
57
+ return candidate !== undefined &&
58
+ root.relativeRoot === candidate.relativeRoot &&
59
+ root.parentRoot === candidate.parentRoot &&
60
+ root.state === candidate.state &&
61
+ root.sourceIdentity === candidate.sourceIdentity &&
62
+ root.privateRepositoryId === candidate.privateRepositoryId &&
63
+ (root.gitlinkOid ?? null) === (candidate.gitlinkOid ?? null) &&
64
+ root.treeId === candidate.treeId &&
65
+ root.coverage === candidate.coverage &&
66
+ root.ignorePolicy === candidate.ignorePolicy &&
67
+ root.ignoreClosure === candidate.ignoreClosure &&
68
+ root.objectClosure === candidate.objectClosure &&
69
+ sameStrings(root.ignoredPresentPaths, candidate.ignoredPresentPaths);
70
+ });
71
+ }
72
+
47
73
  export function ignoredPresentClosure(
48
74
  root: Pick<SnapshotRoot, "coverage" | "ignorePolicy" | "ignoredPresentPaths">,
49
75
  ): string {
@@ -202,6 +228,10 @@ export function assertOperationId(value: unknown): string {
202
228
  return value;
203
229
  }
204
230
 
231
+ function sameStrings(left: readonly string[], right: readonly string[]): boolean {
232
+ return left.length === right.length && left.every((value, index) => value === right[index]);
233
+ }
234
+
205
235
  function encodeJson(value: unknown, ancestors: Set<object>): string {
206
236
  if (value === null) {
207
237
  return "null";
package/src/pi-runtime.ts CHANGED
@@ -12,7 +12,7 @@ import {
12
12
  type ControllerInitialState,
13
13
  } from "./controller.ts";
14
14
  import { finalizeDurablePack, hasDurablePack, loadDurablePack } from "./durable-pack.ts";
15
- import { assertCursor, canonicalJson, checksum } from "./encoding.ts";
15
+ import { assertCursor, canonicalJson, checksum, sameWorkspaceSnapshot } from "./encoding.ts";
16
16
  import { JournalStore, finalizeCursorMarker, inspectCursorMarkers } from "./journal.ts";
17
17
  import type { CheckpointRecord, ManifestId, SessionFileIdentity } from "./model.ts";
18
18
  import {
@@ -223,6 +223,8 @@ export async function createPiUndoRuntime(context: ExtensionContext, pi: Extensi
223
223
  return capture(scopePaths);
224
224
  },
225
225
  changedPaths: async (before, after) => {
226
+ // 完全相同的 workspace snapshot 只撤回 session 分支;无需再次展开所有 root tree。
227
+ if (sameWorkspaceSnapshot(before, after)) return [];
226
228
  const plan = await restore.plan(before, after);
227
229
  return [...new Set([...plan.deletePaths, ...plan.writePaths])].sort();
228
230
  },
@@ -38,7 +38,9 @@ const TREE_BLOB_MEMBERSHIP_LIMIT = 65_536;
38
38
  const HASH_BATCH_MAX_PATHS = process.platform === "win32" ? 128 : 2_048;
39
39
  const HASH_BATCH_MAX_ARGUMENT_BYTES = process.platform === "win32" ? 24 * 1024 : 128 * 1024;
40
40
  const HASH_BATCH_CONCURRENCY = 4;
41
+ const ROOT_CAPTURE_CONCURRENCY = 4;
41
42
  const FILE_SYSTEM_INSPECTION_CONCURRENCY = 32;
43
+ const IGNORED_METADATA_BATCH_SIZE = 1_024;
42
44
  const INDEX_BATCH_MAX_ENTRIES = 4_096;
43
45
  const INDEX_BATCH_MAX_BYTES = 8 * 1024 * 1024;
44
46
  const BLOB_CACHE_MAX_BYTES = 128 * 1024 * 1024;
@@ -258,30 +260,41 @@ export class SnapshotStore {
258
260
  const transactionsRoot = join(storeDirectory, "transactions");
259
261
  await mkdir(transactionsRoot, { recursive: true });
260
262
  transactionDirectory = await mkdtemp(join(transactionsRoot, "capture-"));
261
-
262
- const roots: SnapshotRoot[] = [];
263
- const cacheUpdates: VisibleLeafCacheUpdate[] = [];
264
- for (const root of topology.roots) {
265
- if (root.state !== "active") {
266
- const coverage = rootCaptureCoverage(root.relativeRoot, scope);
267
- roots.push(snapshotRoot(root, {
268
- treeId: null,
269
- coverage,
270
- ...ignoredPresentProof(coverage, []),
271
- objectClosure: inactiveRootClosure(root),
272
- }));
273
- continue;
274
- }
275
- const captured = await this.captureRoot(
276
- topology,
277
- root,
278
- transactionDirectory,
279
- scope,
280
- artifactExclusions,
281
- );
282
- roots.push(snapshotRoot(root, captured));
283
- cacheUpdates.push(captured.cacheUpdate);
284
- }
263
+ const activeTransactionDirectory = transactionDirectory;
264
+
265
+ // 每个 root 使用独立私有 ODB、index 与 worktree;结果保持输入顺序,缓存仍在整个 capture
266
+ // 持久化成功后统一发布,因此可并行缩短 nested-repository workspace 的关键路径。
267
+ const capturedRoots = await mapConcurrentOrdered(
268
+ topology.roots,
269
+ ROOT_CAPTURE_CONCURRENCY,
270
+ async (root): Promise<{ readonly root: SnapshotRoot; readonly cacheUpdate?: VisibleLeafCacheUpdate }> => {
271
+ if (root.state !== "active") {
272
+ const coverage = rootCaptureCoverage(root.relativeRoot, scope);
273
+ return {
274
+ root: snapshotRoot(root, {
275
+ treeId: null,
276
+ coverage,
277
+ ...ignoredPresentProof(coverage, []),
278
+ objectClosure: inactiveRootClosure(root),
279
+ }),
280
+ };
281
+ }
282
+ const captured = await this.captureRoot(
283
+ topology,
284
+ root,
285
+ activeTransactionDirectory,
286
+ scope,
287
+ artifactExclusions,
288
+ );
289
+ return {
290
+ root: snapshotRoot(root, captured),
291
+ cacheUpdate: captured.cacheUpdate,
292
+ };
293
+ },
294
+ );
295
+ const roots = capturedRoots.map((captured) => captured.root);
296
+ const cacheUpdates = capturedRoots.flatMap((captured) =>
297
+ captured.cacheUpdate === undefined ? [] : [captured.cacheUpdate]);
285
298
 
286
299
  await this.assertTopology(topology, "捕获期间 topology 已变化");
287
300
  const content = {
@@ -720,6 +733,7 @@ export class SnapshotStore {
720
733
  inclusions,
721
734
  exclusions,
722
735
  exactExclusions,
736
+ transactionDirectory,
723
737
  );
724
738
  const treeId = (await this.runGit(["write-tree"], { cwd: absoluteRoot, env: environment })).trim();
725
739
  if (!isObjectId(treeId)) {
@@ -743,6 +757,7 @@ export class SnapshotStore {
743
757
  inclusions: readonly string[] | null,
744
758
  exclusions: readonly string[],
745
759
  exactExclusions: ReadonlySet<string>,
760
+ requestDirectory: string,
746
761
  ): Promise<string[]> {
747
762
  if (inclusions === null) {
748
763
  return [];
@@ -761,7 +776,8 @@ export class SnapshotStore {
761
776
  "--",
762
777
  ...pathspecs,
763
778
  ], { cwd, env: gitBacked ? sourceGitEnvironment() : environment });
764
- const result = new Set<string>();
779
+ const candidates: string[] = [];
780
+ const seen = new Set<string>();
765
781
  for (const relativePath of parseNulPaths(output)) {
766
782
  if (
767
783
  exclusions.some((excluded) => isPathAtOrBelow(excluded, relativePath)) ||
@@ -769,23 +785,74 @@ export class SnapshotStore {
769
785
  ) {
770
786
  continue;
771
787
  }
772
- await assertNoSymlinkEscape(cwd, relativePath);
773
- const metadata = await lstat(join(cwd, ...relativePath.split("/"))).catch((error) => {
774
- if (hasErrorCode(error, "ENOENT")) return null;
775
- throw error;
776
- });
777
- if (metadata === null) {
778
- continue;
788
+ if (seen.has(relativePath)) {
789
+ throw new SnapshotStoreError("capture_failed", `ignored-present proof 包含重复路径:${relativePath}`);
779
790
  }
780
- if (!metadata.isFile() && !metadata.isSymbolicLink()) {
791
+ seen.add(relativePath);
792
+ candidates.push(relativePath);
793
+ }
794
+ // ignored build/vendor trees 常含数万叶子;复用同一批量 metadata 协议,避免逐路径重复
795
+ // 遍历父目录。Native 与 fallback 都在叶子扫描前后复核共享父目录。
796
+ const nativeEntries = await this.inspectIgnoredMetadataBatches(cwd, candidates, requestDirectory);
797
+ const kinds = nativeEntries === undefined
798
+ ? await this.collectIgnoredPresentKindsFallback(cwd, candidates)
799
+ : nativeEntries.map((entry) => entry.kind);
800
+ const result: string[] = [];
801
+ for (let index = 0; index < candidates.length; index += 1) {
802
+ const relativePath = candidates[index]!;
803
+ const kind = kinds[index]!;
804
+ if (kind === "absent") continue;
805
+ if (kind !== "file" && kind !== "symlink") {
781
806
  throw new SnapshotStoreError("capture_failed", `ignored-present proof 只接受叶子路径:${relativePath}`);
782
807
  }
783
- if (result.has(relativePath)) {
784
- throw new SnapshotStoreError("capture_failed", `ignored-present proof 包含重复路径:${relativePath}`);
808
+ result.push(relativePath);
809
+ }
810
+ return result.sort(comparePaths);
811
+ }
812
+
813
+ private async inspectIgnoredMetadataBatches(
814
+ cwd: string,
815
+ paths: readonly string[],
816
+ requestDirectory: string,
817
+ ): Promise<readonly NativeMetadataEntry[] | undefined> {
818
+ const result: NativeMetadataEntry[] = [];
819
+ for (let offset = 0; offset < paths.length; offset += IGNORED_METADATA_BATCH_SIZE) {
820
+ const batch = paths.slice(offset, offset + IGNORED_METADATA_BATCH_SIZE);
821
+ const inspected = await this.nativeMetadata.inspect(cwd, batch, requestDirectory);
822
+ if (inspected === undefined) {
823
+ if (result.length > 0) {
824
+ throw new SnapshotStoreError("capture_failed", "native ignored metadata 能力在批次间变化");
825
+ }
826
+ return undefined;
785
827
  }
786
- result.add(relativePath);
828
+ result.push(...inspected);
829
+ }
830
+ return result;
831
+ }
832
+
833
+ private async collectIgnoredPresentKindsFallback(
834
+ cwd: string,
835
+ paths: readonly string[],
836
+ ): Promise<readonly NativeMetadataEntry["kind"][]> {
837
+ const result: NativeMetadataEntry["kind"][] = [];
838
+ for (let offset = 0; offset < paths.length; offset += IGNORED_METADATA_BATCH_SIZE) {
839
+ const batch = paths.slice(offset, offset + IGNORED_METADATA_BATCH_SIZE);
840
+ await assertNoSymlinkParents(cwd, batch);
841
+ const kinds = await mapConcurrentOrdered(batch, FILE_SYSTEM_INSPECTION_CONCURRENCY, async (relativePath) => {
842
+ const metadata = await lstat(join(cwd, ...relativePath.split("/"))).catch((error) => {
843
+ if (hasErrorCode(error, "ENOENT")) return null;
844
+ throw error;
845
+ });
846
+ return metadata === null
847
+ ? "absent" as const
848
+ : metadata.isFile() ? "file" as const
849
+ : metadata.isSymbolicLink() ? "symlink" as const
850
+ : "other" as const;
851
+ });
852
+ await assertNoSymlinkParents(cwd, batch);
853
+ result.push(...kinds);
787
854
  }
788
- return [...result].sort(comparePaths);
855
+ return result;
789
856
  }
790
857
 
791
858
  private async stageWorktree(