@davideasden/pi-undo 0.1.1 → 0.2.0

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.
@@ -1,3 +1,4 @@
1
+ import type { Stats } from "node:fs";
1
2
  import { lstat, mkdir, mkdtemp, readFile, readdir, readlink, realpath, rm, stat } from "node:fs/promises";
2
3
  import { tmpdir } from "node:os";
3
4
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
@@ -30,6 +31,11 @@ const GC_METADATA_FILE = "gc.json";
30
31
  const GC_RETENTION_MS = 7 * 24 * 60 * 60 * 1_000;
31
32
  const IGNORE_POLICY = "git-check-ignore-v1";
32
33
  const NULL_DEVICE = process.platform === "win32" ? "NUL" : "/dev/null";
34
+ const TREE_CACHE_LIMIT = 256;
35
+ const HASH_BATCH_MAX_PATHS = 128;
36
+ const HASH_BATCH_MAX_ARGUMENT_BYTES = 24 * 1024;
37
+ const INDEX_BATCH_MAX_ENTRIES = 4_096;
38
+ const INDEX_BATCH_MAX_BYTES = 8 * 1024 * 1024;
33
39
 
34
40
  interface PinRecord {
35
41
  readonly schemaVersion: 1;
@@ -60,6 +66,13 @@ interface CapturedRootResult {
60
66
  readonly objectClosure: string;
61
67
  }
62
68
 
69
+ interface VisibleLeaf {
70
+ readonly relativePath: string;
71
+ readonly kind: "file" | "symlink";
72
+ readonly mode: number;
73
+ readonly fingerprint: string;
74
+ }
75
+
63
76
  export interface SnapshotStoreOptions {
64
77
  readonly storeRoot?: string;
65
78
  readonly git?: GitRunner;
@@ -93,6 +106,7 @@ export class SnapshotStoreError extends Error {
93
106
 
94
107
  export interface SnapshotStore {
95
108
  capture(topology: RootTopology, scope?: readonly string[], options?: CaptureOptions): Promise<SnapshotManifest>;
109
+ listVisibleLeafPaths(topology: RootTopology, options?: CaptureOptions): Promise<readonly string[]>;
96
110
  loadManifest(id: ManifestId): Promise<SnapshotManifest>;
97
111
  assertComplete(id: ManifestId): Promise<void>;
98
112
  listTree(id: ManifestId, root: string): Promise<readonly RestorePath[]>;
@@ -110,6 +124,8 @@ export class SnapshotStore {
110
124
  private readonly lock: WorkspaceLock;
111
125
  private readonly clock: () => number;
112
126
  private readonly manifestLocations = new Map<string, string>();
127
+ // Tree ID 是内容寻址且不可变;这里只缓存解析元数据,blob bytes 与完整性仍逐次读取/校验。
128
+ private readonly treeEntriesCache = new Map<string, Promise<CapturedTreeEntry[]>>();
113
129
 
114
130
  constructor(options: SnapshotStoreOptions = {}) {
115
131
  this.storeRoot = resolve(options.storeRoot ?? join(tmpdir(), "pi-undo-snapshot-store"));
@@ -204,6 +220,72 @@ export class SnapshotStore {
204
220
  }
205
221
  }
206
222
 
223
+ async listVisibleLeafPaths(
224
+ topology: RootTopology,
225
+ options: CaptureOptions = {},
226
+ ): Promise<readonly string[]> {
227
+ await this.assertPrivateStore(topology.workspaceIdentity);
228
+ const lockIdentity = `snapshot-store:${await prospectiveCanonicalPath(this.storesRoot)}`;
229
+ return this.lock.withLock(lockIdentity, () => this.listVisibleLeafPathsLocked(topology, options));
230
+ }
231
+
232
+ private async listVisibleLeafPathsLocked(
233
+ topology: RootTopology,
234
+ options: CaptureOptions,
235
+ ): Promise<readonly string[]> {
236
+ let transactionDirectory: string | undefined;
237
+ try {
238
+ if (topology.fingerprint !== topologyFingerprint(topology.workspaceIdentity, topology.roots)) {
239
+ throw new SnapshotStoreError("capture_failed", "topology fingerprint 与 roots 不匹配");
240
+ }
241
+ const artifactExclusions = captureExclusions(topology.workspaceIdentity, options.excludePaths);
242
+ await this.assertTopology(topology, "可见路径枚举前 topology 已变化");
243
+ if (topology.roots.some((root) => root.state === "broken")) {
244
+ throw new SnapshotStoreError("capture_failed", "broken root 不能静默进入可见路径枚举");
245
+ }
246
+
247
+ const storeDirectory = this.storeDirectory(topology);
248
+ const transactionsRoot = join(storeDirectory, "transactions");
249
+ await mkdir(transactionsRoot, { recursive: true });
250
+ transactionDirectory = await mkdtemp(join(transactionsRoot, "visible-"));
251
+ const result = new Set<string>();
252
+ for (const root of topology.roots) {
253
+ if (root.state !== "active") continue;
254
+ const gitDirectory = this.rootGitDirectory(storeDirectory, root);
255
+ await this.ensurePrivateRepository(gitDirectory);
256
+ await this.assertNoAlternates(gitDirectory);
257
+ const absoluteRoot = workspaceRootPath(topology.workspaceIdentity, root.relativeRoot);
258
+ const indexPath = join(transactionDirectory, `${rootStoreId(root)}.index`);
259
+ const environment = privateGitEnvironment(gitDirectory, absoluteRoot, indexPath);
260
+ await this.runGit(["read-tree", "--empty"], { cwd: absoluteRoot, env: environment });
261
+ await this.validateIgnoreQuery(absoluteRoot, environment, root.gitBacked);
262
+ const exclusions = topology.roots
263
+ .filter((candidate) => isStrictRootAncestor(root.relativeRoot, candidate.relativeRoot))
264
+ .map((candidate) => rootRelativePath(root.relativeRoot, candidate.relativeRoot));
265
+ const exactExclusions = ownedArtifactExclusions(topology.roots, root.relativeRoot, artifactExclusions);
266
+ for (const leaf of await this.collectVisibleLeaves(
267
+ absoluteRoot,
268
+ environment,
269
+ root.gitBacked,
270
+ [],
271
+ exclusions,
272
+ exactExclusions,
273
+ )) {
274
+ result.add(workspaceRelativePath(root.relativeRoot, leaf.relativePath));
275
+ }
276
+ }
277
+ await this.assertTopology(topology, "可见路径枚举期间 topology 已变化");
278
+ return [...result].sort(comparePaths);
279
+ } catch (error) {
280
+ if (error instanceof SnapshotStoreError) throw error;
281
+ throw new SnapshotStoreError("capture_failed", errorMessage(error), { cause: error });
282
+ } finally {
283
+ if (transactionDirectory !== undefined) {
284
+ await rm(transactionDirectory, { recursive: true, force: true }).catch(() => {});
285
+ }
286
+ }
287
+ }
288
+
207
289
  async loadManifest(id: ManifestId): Promise<SnapshotManifest> {
208
290
  const manifestPath = await this.findManifestPath(id);
209
291
  try {
@@ -242,7 +324,6 @@ export class SnapshotStore {
242
324
  }
243
325
  const gitDirectory = this.rootGitDirectory(storeDirectory, root);
244
326
  await this.assertNoAlternates(gitDirectory);
245
- await this.runPrivateGit(gitDirectory, ["cat-file", "-e", `${root.treeId}^{tree}`]);
246
327
  const entries = await this.readTreeEntries(gitDirectory, root.treeId);
247
328
  if (root.ignoredPresentPaths.some((ignoredPath) => entries.some(
248
329
  (entry) => isPathAtOrBelow(ignoredPath, entry.relativePath) ||
@@ -250,9 +331,7 @@ export class SnapshotStore {
250
331
  ))) {
251
332
  throw new SnapshotStoreError("object_missing", "ignored-present proof 与 root tree 冲突");
252
333
  }
253
- for (const entry of entries) {
254
- await this.runPrivateGit(gitDirectory, ["cat-file", "-e", `${entry.objectId}^{blob}`]);
255
- }
334
+ await this.assertObjectsComplete(gitDirectory, root.treeId, entries);
256
335
  if (root.objectClosure !== treeObjectClosure(root.treeId, entries)) {
257
336
  throw new SnapshotStoreError("object_missing", "root tree 对象闭包校验失败");
258
337
  }
@@ -493,10 +572,7 @@ export class SnapshotStore {
493
572
  throw new SnapshotStoreError("capture_failed", "git write-tree 未返回有效对象 ID");
494
573
  }
495
574
  const entries = await this.readTreeEntries(gitDirectory, treeId);
496
- await this.runPrivateGit(gitDirectory, ["cat-file", "-e", `${treeId}^{tree}`]);
497
- for (const entry of entries) {
498
- await this.runPrivateGit(gitDirectory, ["cat-file", "-e", `${entry.objectId}^{blob}`]);
499
- }
575
+ await this.assertObjectsComplete(gitDirectory, treeId, entries);
500
576
  return {
501
577
  treeId,
502
578
  coverage,
@@ -565,13 +641,77 @@ export class SnapshotStore {
565
641
  exclusions: readonly string[],
566
642
  exactExclusions: readonly string[],
567
643
  ): Promise<void> {
568
- if (inclusions === null) {
569
- return;
644
+ const leaves = await this.collectVisibleLeaves(
645
+ cwd,
646
+ environment,
647
+ gitBacked,
648
+ inclusions,
649
+ exclusions,
650
+ exactExclusions,
651
+ );
652
+ 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);
655
+ const output = await this.runGit([
656
+ "hash-object",
657
+ "-w",
658
+ "--no-filters",
659
+ "--",
660
+ ...batch.map((leaf) => leaf.relativePath),
661
+ ], { cwd, env: environment });
662
+ 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
+ }
570
667
  }
571
- const pathspecs = inclusions.length === 0 ? ["."] : inclusions.map(literalPathspec);
572
- for (const excluded of exclusions) {
573
- pathspecs.push(excludeLiteralPathspec(excluded));
668
+ for (const leaf of leaves) {
669
+ if (leaf.kind !== "symlink") continue;
670
+ await this.assertVisibleLeafUnchanged(cwd, leaf);
671
+ const linkText = await readlink(join(cwd, ...leaf.relativePath.split("/")), { encoding: "buffer" });
672
+ decodeUtf8(linkText, "symlink target 不是可无损表示的 UTF-8");
673
+ const objectId = (await this.runGit(["hash-object", "-w", "--stdin"], {
674
+ cwd,
675
+ env: environment,
676
+ stdin: linkText,
677
+ })).trim();
678
+ if (!isObjectId(objectId)) {
679
+ throw new SnapshotStoreError("capture_failed", `文件对象 materialize 失败:${leaf.relativePath}`);
680
+ }
681
+ await this.assertVisibleLeafUnchanged(cwd, leaf);
682
+ objectIds.set(leaf.relativePath, objectId);
683
+ }
684
+ for (const indexInput of indexInfoBatches(leaves, objectIds)) {
685
+ await this.runGit(["update-index", "-z", "--index-info"], {
686
+ cwd,
687
+ env: environment,
688
+ stdin: indexInput,
689
+ });
690
+ }
691
+ }
692
+
693
+ private async assertVisibleLeafUnchanged(cwd: string, leaf: VisibleLeaf): Promise<void> {
694
+ await assertNoSymlinkEscape(cwd, leaf.relativePath);
695
+ const metadata = await lstat(join(cwd, ...leaf.relativePath.split("/"))).catch((error) => {
696
+ if (hasErrorCode(error, "ENOENT")) return null;
697
+ throw error;
698
+ });
699
+ if (metadata === null || visibleLeafFingerprint(metadata) !== leaf.fingerprint) {
700
+ throw new SnapshotStoreError("capture_failed", `捕获期间工作区叶子已变化:${leaf.relativePath}`);
574
701
  }
702
+ }
703
+
704
+ private async collectVisibleLeaves(
705
+ cwd: string,
706
+ environment: Readonly<Record<string, string | undefined>>,
707
+ gitBacked: boolean,
708
+ inclusions: readonly string[] | null,
709
+ exclusions: readonly string[],
710
+ exactExclusions: readonly string[],
711
+ ): Promise<VisibleLeaf[]> {
712
+ if (inclusions === null) return [];
713
+ const pathspecs = inclusions.length === 0 ? ["."] : inclusions.map(literalPathspec);
714
+ for (const excluded of exclusions) pathspecs.push(excludeLiteralPathspec(excluded));
575
715
  const queryEnvironment = gitBacked ? sourceGitEnvironment() : environment;
576
716
  const output = await this.runGitBytes([
577
717
  ...(gitBacked ? ["-c", "core.fsmonitor=false"] : []),
@@ -583,53 +723,38 @@ export class SnapshotStore {
583
723
  "--",
584
724
  ...pathspecs,
585
725
  ], { cwd, env: queryEnvironment });
726
+ const result: VisibleLeaf[] = [];
586
727
  for (const relativePath of parseNulPaths(output)) {
587
728
  if (
588
729
  exclusions.some((excluded) => isPathAtOrBelow(excluded, relativePath)) ||
589
730
  exactExclusions.includes(relativePath)
590
- ) {
591
- continue;
592
- }
731
+ ) continue;
593
732
  relativeSafePath(cwd, relativePath);
594
733
  await assertNoSymlinkEscape(cwd, relativePath);
595
- const absolutePath = join(cwd, ...relativePath.split("/"));
596
- const metadata = await lstat(absolutePath).catch((error) => {
597
- if (hasErrorCode(error, "ENOENT")) {
598
- return null;
599
- }
734
+ const metadata = await lstat(join(cwd, ...relativePath.split("/"))).catch((error) => {
735
+ if (hasErrorCode(error, "ENOENT")) return null;
600
736
  throw error;
601
737
  });
602
- if (metadata === null) {
603
- continue;
604
- }
605
- let mode: string;
606
- let objectId: string;
738
+ if (metadata === null) continue;
607
739
  if (metadata.isSymbolicLink()) {
608
- mode = "120000";
609
- const linkText = await readlink(absolutePath, { encoding: "buffer" });
610
- decodeUtf8(linkText, "symlink target 不是可无损表示的 UTF-8");
611
- objectId = (await this.runGit(["hash-object", "-w", "--stdin"], {
612
- cwd,
613
- env: environment,
614
- stdin: linkText,
615
- })).trim();
740
+ result.push({
741
+ relativePath,
742
+ kind: "symlink",
743
+ mode: 0o120000,
744
+ fingerprint: visibleLeafFingerprint(metadata),
745
+ });
616
746
  } else if (metadata.isFile()) {
617
- mode = (metadata.mode & 0o111) === 0 ? "100644" : "100755";
618
- objectId = (await this.runGit(["hash-object", "-w", "--no-filters", "--", relativePath], {
619
- cwd,
620
- env: environment,
621
- })).trim();
747
+ result.push({
748
+ relativePath,
749
+ kind: "file",
750
+ mode: (metadata.mode & 0o111) === 0 ? 0o100644 : 0o100755,
751
+ fingerprint: visibleLeafFingerprint(metadata),
752
+ });
622
753
  } else {
623
754
  throw new SnapshotStoreError("capture_failed", `不支持的工作区文件类型:${relativePath}`);
624
755
  }
625
- if (!isObjectId(objectId)) {
626
- throw new SnapshotStoreError("capture_failed", `文件对象 materialize 失败:${relativePath}`);
627
- }
628
- await this.runGit(["update-index", "--add", "--cacheinfo", mode, objectId, relativePath], {
629
- cwd,
630
- env: environment,
631
- });
632
756
  }
757
+ return result;
633
758
  }
634
759
 
635
760
  private async validateIgnoreQuery(
@@ -688,8 +813,52 @@ export class SnapshotStore {
688
813
  }
689
814
 
690
815
  private async readTreeEntries(gitDirectory: string, treeId: string): Promise<CapturedTreeEntry[]> {
691
- const output = await this.runPrivateGitBytes(gitDirectory, ["ls-tree", "-r", "-l", "-z", treeId]);
692
- return parseTreeEntries(output);
816
+ const key = `${gitDirectory}\0${treeId}`;
817
+ const cached = this.treeEntriesCache.get(key);
818
+ if (cached !== undefined) return cached;
819
+ const pending = this.runPrivateGitBytes(gitDirectory, ["ls-tree", "-r", "-l", "-z", treeId])
820
+ .then(parseTreeEntries);
821
+ this.treeEntriesCache.set(key, pending);
822
+ while (this.treeEntriesCache.size > TREE_CACHE_LIMIT) {
823
+ const oldest = this.treeEntriesCache.keys().next().value as string | undefined;
824
+ if (oldest === undefined || oldest === key) break;
825
+ this.treeEntriesCache.delete(oldest);
826
+ }
827
+ try {
828
+ return await pending;
829
+ } catch (error) {
830
+ if (this.treeEntriesCache.get(key) === pending) this.treeEntriesCache.delete(key);
831
+ throw error;
832
+ }
833
+ }
834
+
835
+ private async assertObjectsComplete(
836
+ gitDirectory: string,
837
+ treeId: string,
838
+ entries: readonly CapturedTreeEntry[],
839
+ ): Promise<void> {
840
+ const expected = [
841
+ { objectId: treeId, type: "tree" },
842
+ ...[...new Set(entries.map((entry) => entry.objectId))].map((objectId) => ({ objectId, type: "blob" })),
843
+ ];
844
+ const output = await this.runGit(["cat-file", "--batch-check"], {
845
+ env: privateObjectEnvironment(gitDirectory),
846
+ stdin: `${expected.map((object) => object.objectId).join("\n")}\n`,
847
+ });
848
+ const lines = output.endsWith("\n") ? output.slice(0, -1).split("\n") : output.split("\n");
849
+ if (lines.length !== expected.length) throw new Error("Git object batch-check 输出数量不匹配");
850
+ for (let index = 0; index < expected.length; index += 1) {
851
+ const object = expected[index]!;
852
+ const match = lines[index]!.match(/^([0-9a-f]{40,64}) (blob|tree) ([0-9]+)$/);
853
+ if (
854
+ match === null ||
855
+ match[1] !== object.objectId ||
856
+ match[2] !== object.type ||
857
+ !Number.isSafeInteger(Number(match[3]))
858
+ ) {
859
+ throw new Error(`Git object batch-check 校验失败:${object.objectId}`);
860
+ }
861
+ }
693
862
  }
694
863
 
695
864
  private async readBlobText(gitDirectory: string, objectId: string): Promise<string> {
@@ -980,6 +1149,10 @@ function workspaceRootPath(workspaceIdentity: string, rootPath: string): string
980
1149
  return safe === "." ? workspaceIdentity : join(workspaceIdentity, ...safe.split("/"));
981
1150
  }
982
1151
 
1152
+ function workspaceRelativePath(rootPath: string, relativePath: string): string {
1153
+ return rootPath === "." ? relativePath : `${rootPath}/${relativePath}`;
1154
+ }
1155
+
983
1156
  function rootStoreId(root: RootTopologyIdentity): string {
984
1157
  return checksum(canonicalJson({
985
1158
  relativeRoot: root.relativeRoot,
@@ -1061,6 +1234,76 @@ function isolatedGitConfiguration(): Readonly<Record<string, string | undefined>
1061
1234
  };
1062
1235
  }
1063
1236
 
1237
+ function indexInfoBatches(
1238
+ leaves: readonly VisibleLeaf[],
1239
+ objectIds: ReadonlyMap<string, string>,
1240
+ ): Buffer[] {
1241
+ const result: Buffer[] = [];
1242
+ let records: string[] = [];
1243
+ let bytes = 0;
1244
+ for (const leaf of leaves) {
1245
+ const objectId = objectIds.get(leaf.relativePath);
1246
+ if (objectId === undefined) {
1247
+ throw new SnapshotStoreError("capture_failed", `文件对象 materialize 结果缺失:${leaf.relativePath}`);
1248
+ }
1249
+ const record = `${leaf.mode.toString(8)} ${objectId}\t${leaf.relativePath}\0`;
1250
+ const recordBytes = Buffer.byteLength(record, "utf8");
1251
+ if (
1252
+ records.length > 0 &&
1253
+ (records.length >= INDEX_BATCH_MAX_ENTRIES || bytes + recordBytes > INDEX_BATCH_MAX_BYTES)
1254
+ ) {
1255
+ result.push(Buffer.from(records.join(""), "utf8"));
1256
+ records = [];
1257
+ bytes = 0;
1258
+ }
1259
+ records.push(record);
1260
+ bytes += recordBytes;
1261
+ }
1262
+ if (records.length > 0) result.push(Buffer.from(records.join(""), "utf8"));
1263
+ return result;
1264
+ }
1265
+
1266
+ function visibleLeafFingerprint(metadata: Stats): string {
1267
+ return checksum(canonicalJson({
1268
+ kind: metadata.isSymbolicLink() ? "symlink" : metadata.isFile() ? "file" : "other",
1269
+ dev: metadata.dev,
1270
+ ino: metadata.ino,
1271
+ mode: metadata.mode,
1272
+ size: metadata.size,
1273
+ mtimeMs: metadata.mtimeMs,
1274
+ ctimeMs: metadata.ctimeMs,
1275
+ }));
1276
+ }
1277
+
1278
+ function hashPathBatches(leaves: readonly VisibleLeaf[]): VisibleLeaf[][] {
1279
+ const result: VisibleLeaf[][] = [];
1280
+ let current: VisibleLeaf[] = [];
1281
+ let argumentBytes = 0;
1282
+ for (const leaf of leaves) {
1283
+ const leafBytes = Buffer.byteLength(leaf.relativePath, "utf8") + 1;
1284
+ if (
1285
+ current.length > 0 &&
1286
+ (current.length >= HASH_BATCH_MAX_PATHS || argumentBytes + leafBytes > HASH_BATCH_MAX_ARGUMENT_BYTES)
1287
+ ) {
1288
+ result.push(current);
1289
+ current = [];
1290
+ argumentBytes = 0;
1291
+ }
1292
+ current.push(leaf);
1293
+ argumentBytes += leafBytes;
1294
+ }
1295
+ if (current.length > 0) result.push(current);
1296
+ return result;
1297
+ }
1298
+
1299
+ function parseObjectIdLines(output: string, expectedCount: number): string[] {
1300
+ const lines = output.endsWith("\n") ? output.slice(0, -1).split("\n") : output.split("\n");
1301
+ if (lines.length !== expectedCount || lines.some((line) => !isObjectId(line))) {
1302
+ throw new SnapshotStoreError("capture_failed", "git hash-object 批量输出无效");
1303
+ }
1304
+ return lines;
1305
+ }
1306
+
1064
1307
  function parseTreeEntries(output: Uint8Array): CapturedTreeEntry[] {
1065
1308
  const entries: CapturedTreeEntry[] = [];
1066
1309
  for (const record of splitNulRecords(output)) {