@davideasden/pi-undo 0.2.11 → 0.2.15

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 CHANGED
@@ -19,7 +19,7 @@ Each completed agent run creates a checkpoint that captures both the Pi session
19
19
 
20
20
  ## Requirements
21
21
 
22
- - Pi `0.80.10` or a compatible release.
22
+ - Pi `0.80.10` 或更高版本(已在 0.84.4 上验证)。
23
23
  - Node.js `22.19.0` or later.
24
24
  - Git available on `PATH` (used internally for content-addressed snapshots).
25
25
 
@@ -46,6 +46,7 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
46
46
  let acceptedReplay: DeferredPrompt | undefined;
47
47
  let activeCommands = new Set<symbol>();
48
48
  let activeAction: "undo" | "redo" | undefined;
49
+ let captureFailureNotified = false;
49
50
 
50
51
  const initialize = async (context: ExtensionContext): Promise<void> => {
51
52
  const currentGeneration = ++generation;
@@ -55,6 +56,7 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
55
56
  acceptedReplay = undefined;
56
57
  activeCommands = new Set<symbol>();
57
58
  activeAction = undefined;
59
+ captureFailureNotified = false;
58
60
  try {
59
61
  const next = await runtimeFactory(context, pi);
60
62
  if (currentGeneration !== generation) return;
@@ -63,6 +65,8 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
63
65
  const history = next.controller.history();
64
66
  if (history.locked) next.reporter.setRecoveryRequired("pending journal", next.recovery);
65
67
  else next.reporter.setReady(history.undoCount, history.redoCount);
68
+ // 后台预热快照缓存,把新会话首次冷 capture 移出第一条 prompt 的关键路径。
69
+ next.controller.warmUp();
66
70
  } catch (error) {
67
71
  if (currentGeneration !== generation) return;
68
72
  runtime = undefined;
@@ -213,7 +217,7 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
213
217
  pi.on("session_start", async (_event: unknown, context: ExtensionContext) => initialize(context));
214
218
  pi.on("input", async (event: InputEvent, context: ExtensionContext) => {
215
219
  const active = runtime;
216
- if (active === undefined) return { action: "handled" as const };
220
+ if (active === undefined) return { action: "continue" as const };
217
221
  const result = await active.controller.prepareInput(event.text, {
218
222
  streaming: event.streamingBehavior !== undefined,
219
223
  });
@@ -250,6 +254,14 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
250
254
  }
251
255
  }
252
256
  }
257
+ if (result.action === "continue" && active.controller.captureFailed() && !captureFailureNotified) {
258
+ captureFailureNotified = true;
259
+ const reason = active.controller.captureFailureReason();
260
+ context.ui.notify(
261
+ `pi-undo: pre-input snapshot failed${reason === undefined || reason.length === 0 ? "" : ` (${reason})`}; this run will not be undoable`,
262
+ "warning",
263
+ );
264
+ }
253
265
  return result;
254
266
  });
255
267
  pi.on("before_agent_start", async () => {
@@ -286,11 +298,16 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
286
298
  return result;
287
299
  });
288
300
  pi.on("session_tree", async (event: PiSessionTreeEvent) => {
289
- if (runtime?.isInternalNavigation?.()) return;
290
- await runtime?.controller.afterTree({
301
+ const active = runtime;
302
+ if (active?.isInternalNavigation?.()) return;
303
+ const treeGeneration = generation;
304
+ await active?.controller.afterTree({
291
305
  newLeafId: event.newLeafId,
292
306
  navigationTargetLeafId: event.summaryEntry?.parentId ?? event.newLeafId,
293
307
  });
308
+ if (active !== undefined && runtime === active && generation === treeGeneration) {
309
+ resumeDeferredPrompts(active, treeGeneration, "session state ambiguous");
310
+ }
294
311
  });
295
312
  pi.on("session_shutdown", async () => {
296
313
  generation += 1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davideasden/pi-undo",
3
- "version": "0.2.11",
3
+ "version": "0.2.15",
4
4
  "description": "Persistent workspace undo and redo for Pi",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -45,14 +45,15 @@
45
45
  "pack:dry-run": "npm pack --dry-run"
46
46
  },
47
47
  "peerDependencies": {
48
- "@earendil-works/pi-coding-agent": "^0.80.10",
48
+ "@earendil-works/pi-coding-agent": ">=0.80.10",
49
49
  "@earendil-works/pi-tui": "*"
50
50
  },
51
51
  "dependencies": {
52
52
  "proper-lockfile": "4.1.2"
53
53
  },
54
54
  "devDependencies": {
55
- "@earendil-works/pi-coding-agent": "0.80.10",
55
+ "@earendil-works/pi-coding-agent": "0.84.4",
56
+ "@earendil-works/pi-tui": "0.84.4",
56
57
  "@types/node": "24.12.4",
57
58
  "@types/proper-lockfile": "4.1.4",
58
59
  "typescript": "5.9.3",
package/src/atomic-fs.ts CHANGED
@@ -86,10 +86,28 @@ export async function fsyncFile(file: string): Promise<void> {
86
86
  }
87
87
  }
88
88
 
89
+ /** 不支持目录 fsync 的平台/文件系统(Windows/NTFS、网络盘、exFAT、FUSE 等)返回的 errno。 */
90
+ const TOLERATED_DIR_FSYNC_CODES = new Set(["EPERM", "EINVAL", "ENOTSUP", "EOPNOTSUPP", "ENOSYS"]);
91
+
92
+ /** 目录 fsync 失败是否因平台/文件系统不支持而可以安全降级跳过。 */
93
+ export function isToleratedDirFsyncError(error: unknown): boolean {
94
+ return typeof error === "object" && error !== null && "code" in error &&
95
+ typeof (error as { code?: unknown }).code === "string" &&
96
+ TOLERATED_DIR_FSYNC_CODES.has((error as { code: string }).code);
97
+ }
98
+
89
99
  export async function fsyncDirectory(directory: string): Promise<void> {
100
+ if (process.platform === "win32") {
101
+ // Windows 对目录句柄调用 fsync 必然返回 EPERM;rename 的持久性由文件系统自身保证。
102
+ return;
103
+ }
90
104
  const handle = await open(directory, "r");
91
105
  try {
92
106
  await handle.sync();
107
+ } catch (error) {
108
+ // 目录 fsync 只是崩溃持久性的 best-effort 强化,文件内容的 fsync 不受影响:
109
+ // 不支持目录 fsync 的文件系统降级跳过,其余错误照常传播。
110
+ if (!isToleratedDirFsyncError(error)) throw error;
93
111
  } finally {
94
112
  await handle.close();
95
113
  }
package/src/controller.ts CHANGED
@@ -145,6 +145,12 @@ export interface UndoController {
145
145
  cancelTree?(): Promise<void>;
146
146
  recover(): Promise<void>;
147
147
  history(): HistoryState;
148
+ /** 后台预热快照缓存:立即返回,失败静默;后续 capture 会先等预热完成。 */
149
+ warmUp(): void;
150
+ /** 最近一次输入前快照是否失败(此时本次 run 不可 undo,但输入不受影响)。 */
151
+ captureFailed(): boolean;
152
+ /** 最近一次输入前快照失败的原因(截断后的错误消息);成功时为 undefined。 */
153
+ captureFailureReason(): string | undefined;
148
154
  }
149
155
 
150
156
  interface StagedRun {
@@ -192,6 +198,9 @@ export class UndoControllerImpl implements UndoController {
192
198
  private operationProfiler: OperationProfiler | undefined;
193
199
  private promptDeferralInFlight = false;
194
200
  private lastSafetyManifestId: ManifestId | null = null;
201
+ private lastCaptureFailed = false;
202
+ private lastCaptureFailureMessage: string | undefined;
203
+ private warmUpInFlight: Promise<void> | undefined;
195
204
 
196
205
  constructor(dependencies: ControllerDependencies, initialState: ControllerInitialState = {}) {
197
206
  this.dependencies = dependencies;
@@ -205,22 +214,46 @@ export class UndoControllerImpl implements UndoController {
205
214
  return { undoCount: this.undoStack.length, redoCount: this.redoStack.length, locked: this.locked };
206
215
  }
207
216
 
217
+ captureFailed(): boolean {
218
+ return this.lastCaptureFailed;
219
+ }
220
+
221
+ warmUp(): void {
222
+ if (this.locked || this.warmUpInFlight !== undefined) return;
223
+ this.warmUpInFlight = (async () => {
224
+ try {
225
+ await this.captureWithWorkspaceLock();
226
+ } catch {
227
+ // 预热是 best-effort:失败静默,正式 capture 会再次尝试并上报。
228
+ }
229
+ })();
230
+ }
231
+
232
+ captureFailureReason(): string | undefined {
233
+ return this.lastCaptureFailed ? this.lastCaptureFailureMessage : undefined;
234
+ }
235
+
208
236
  listCheckpoints(): readonly CheckpointRecord[] {
209
237
  return [...this.undoStack];
210
238
  }
211
239
 
212
240
  async prepareInput(text: string, context: InputContext): Promise<InputEventResult> {
213
241
  if (this.promptDeferralInFlight) return { action: "defer" };
214
- if (this.locked || this.operationInFlight) return { action: "handled" };
242
+ if (this.locked) return { action: "continue" };
243
+ if (this.operationInFlight) return { action: "defer" };
215
244
  if (context.streaming || text.length === 0) return { action: "continue" };
216
245
  try {
217
246
  const before = await this.captureWithWorkspaceLock();
247
+ this.lastCaptureFailed = false;
248
+ this.lastCaptureFailureMessage = undefined;
218
249
  this.historyPaused = false;
219
250
  this.staged = { rawPrompt: text, before, sourceLogicalLeaf: this.dependencies.getLogicalLeafId() };
220
251
  return { action: "continue" };
221
- } catch {
222
- // 无法证明输入前状态时保留编辑器输入;本轮尚未开始,可由用户直接重试。
223
- return { action: "handled" };
252
+ } catch (error) {
253
+ // 无法证明输入前状态:放弃记录本次历史,但绝不吞掉用户输入。
254
+ this.lastCaptureFailed = true;
255
+ this.lastCaptureFailureMessage = truncateReason(error instanceof Error ? error.message : String(error));
256
+ return { action: "continue" };
224
257
  }
225
258
  }
226
259
 
@@ -658,6 +691,10 @@ export class UndoControllerImpl implements UndoController {
658
691
  }
659
692
 
660
693
  private async captureWithWorkspaceLock(): Promise<SnapshotManifest> {
694
+ // 预热可能仍持有 workspace lock:先等它完成再 acquire,避免排队超时;
695
+ // 此时进程内缓存已暖,本次 capture 只需指纹校验。
696
+ const warmUp = this.warmUpInFlight;
697
+ if (warmUp !== undefined) await warmUp;
661
698
  const lease = await this.dependencies.acquireWorkspaceLock();
662
699
  try {
663
700
  return await this.dependencies.capture();
@@ -844,3 +881,7 @@ class OperationProfiler {
844
881
  function noop(): OperationResult {
845
882
  return { code: "noop", changedFiles: 0 };
846
883
  }
884
+
885
+ function truncateReason(reason: string): string {
886
+ return reason.replace(/[\u0000-\u001F\u007F]+/g, " ").trim().slice(0, 120);
887
+ }
@@ -1661,7 +1661,10 @@ function assertCompatibleManifests(
1661
1661
  throw new Error("restore manifest 不属于同一 workspace");
1662
1662
  }
1663
1663
  if (current.roots.some((root) => root.state === "broken") || target.roots.some((root) => root.state === "broken")) {
1664
- throw new Error("broken root 不能用于 restore");
1664
+ const brokenRoots = [...current.roots, ...target.roots]
1665
+ .filter((root, index, all) => root.state === "broken" && all.findIndex((candidate) => candidate.relativeRoot === root.relativeRoot) === index)
1666
+ .map((root) => root.relativeRoot);
1667
+ throw new Error(`broken root 不能用于 restore: ${brokenRoots.join(", ")}`);
1665
1668
  }
1666
1669
  const scopedCoverage = scope === undefined
1667
1670
  ? undefined
@@ -333,10 +333,35 @@ async function isSafeDirectory(directory: string, workspaceIdentity: string): Pr
333
333
  async function gitlinkState(absolutePath: string): Promise<DiscoveryRoot["state"]> {
334
334
  try {
335
335
  await lstat(absolutePath);
336
- return "broken";
337
336
  } catch {
338
337
  return "uninitialized";
339
338
  }
339
+ // git worktree / 部分 checkout 会为 gitlink 留下空目录骨架(可能含指向共享依赖的 symlink);
340
+ // 内容寻址快照只跟踪文件,不含任何文件的目录与未初始化等价,避免整个 workspace 无法快照。
341
+ return (await directoryTreeContainsFile(absolutePath)) ? "broken" : "uninitialized";
342
+ }
343
+
344
+ const SKELETON_NOISE_FILES = new Set([".DS_Store", "desktop.ini", "Thumbs.db"]);
345
+
346
+ async function directoryTreeContainsFile(directory: string): Promise<boolean> {
347
+ let entries;
348
+ try {
349
+ entries = await readdir(directory, { withFileTypes: true });
350
+ } catch {
351
+ return true;
352
+ }
353
+ for (const entry of entries) {
354
+ if (entry.isDirectory()) {
355
+ if (await directoryTreeContainsFile(join(directory, entry.name))) return true;
356
+ } else if (entry.isSymbolicLink() || SKELETON_NOISE_FILES.has(entry.name)) {
357
+ // symlink 不适随也不计内容:uninitialized 根下的内容不被捕获也不会被 restore 触碰,
358
+ // 原样保留不会丢失;OS 噪音文件不应让整个 workspace 无法快照。
359
+ continue;
360
+ } else {
361
+ return true;
362
+ }
363
+ }
364
+ return false;
340
365
  }
341
366
 
342
367
  function cleanGitEnvironment(): Readonly<Record<string, string | undefined>> {
@@ -3,7 +3,7 @@ import { lstat, mkdir, mkdtemp, readFile, readdir, readlink, realpath, rm, stat
3
3
  import { tmpdir } from "node:os";
4
4
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
5
 
6
- import { fsyncDirectory, writeContentAddressed, writeJsonAtomic } from "./atomic-fs.ts";
6
+ import { fsyncDirectory, writeBytesAtomic, writeContentAddressed, writeJsonAtomic } from "./atomic-fs.ts";
7
7
  import {
8
8
  assertManifest,
9
9
  canonicalJson,
@@ -47,6 +47,7 @@ const BLOB_CACHE_MAX_BYTES = 128 * 1024 * 1024;
47
47
  const BLOB_BATCH_MAX_BYTES = 16 * 1024 * 1024;
48
48
  const BLOB_BATCH_MAX_ENTRIES = process.platform === "win32" ? 256 : 2_048;
49
49
  const RACY_CLEAN_WINDOW_NS = 2_000_000_000n;
50
+ const LEAF_CACHE_FILE = "leaf-cache.json";
50
51
 
51
52
  interface PinRecord {
52
53
  readonly schemaVersion: 1;
@@ -119,6 +120,22 @@ interface CachedBlob {
119
120
  size: number;
120
121
  }
121
122
 
123
+ /** 持久化叶子缓存文件(storeDirectory/leaf-cache.json),schema 不匹配时整体忽略。 */
124
+ interface PersistedLeafCacheFile {
125
+ readonly schemaVersion: 1;
126
+ readonly entries: Readonly<Record<string, Readonly<Record<string, PersistedLeafCacheEntry>>>>;
127
+ }
128
+
129
+ interface PersistedLeafCacheEntry {
130
+ readonly kind: "file" | "symlink";
131
+ readonly mode: number;
132
+ readonly fingerprint: string;
133
+ readonly cacheable: boolean;
134
+ readonly objectId: string;
135
+ readonly changedAtNs: string;
136
+ readonly verifiedAtNs: string;
137
+ }
138
+
122
139
  export interface SnapshotStoreOptions {
123
140
  readonly storeRoot?: string;
124
141
  readonly git?: GitRunner;
@@ -184,6 +201,7 @@ export class SnapshotStore {
184
201
  private readonly treeBlobMembership = new Map<string, string>();
185
202
  private readonly blobCache = new Map<string, CachedBlob>();
186
203
  private readonly visibleLeafCache = new Map<string, Map<string, CachedVisibleLeaf>>();
204
+ private readonly leafCacheDirectoriesLoaded = new Set<string>();
187
205
  private blobCacheBytes = 0;
188
206
 
189
207
  constructor(options: SnapshotStoreOptions = {}) {
@@ -252,11 +270,14 @@ export class SnapshotStore {
252
270
  const coverage = captureCoverage(topology.workspaceIdentity, scope);
253
271
  const artifactExclusions = captureExclusions(topology.workspaceIdentity, options.excludePaths);
254
272
  await this.assertTopology(topology, "捕获前 topology 已变化");
255
- if (topology.roots.some((root) => root.state === "broken")) {
256
- throw new SnapshotStoreError("capture_failed", "broken root 不能静默进入快照");
273
+ const brokenRoots = brokenRootPaths(topology);
274
+ if (brokenRoots.length > 0) {
275
+ throw new SnapshotStoreError("capture_failed", `broken root 不能静默进入快照: ${brokenRoots.join(", ")}`);
257
276
  }
258
277
 
259
278
  const storeDirectory = this.storeDirectory(topology);
279
+ // 新进程首次 capture 时从磁盘加载叶子指纹缓存,避免全量重新 hash。
280
+ await this.loadPersistedLeafCache(storeDirectory);
260
281
  const transactionsRoot = join(storeDirectory, "transactions");
261
282
  await mkdir(transactionsRoot, { recursive: true });
262
283
  transactionDirectory = await mkdtemp(join(transactionsRoot, "capture-"));
@@ -314,6 +335,8 @@ export class SnapshotStore {
314
335
  await writeContentAddressed(manifestPath, Buffer.from(canonicalJson(manifest), "utf8"));
315
336
  this.manifestLocations.set(manifestId, manifestPath);
316
337
  for (const update of cacheUpdates) this.rememberVisibleLeaves(update);
338
+ // 指纹缓存落盘:让下一个进程(新会话)的首次 capture 跳过全量内容 hash。
339
+ await this.persistLeafCache(storeDirectory);
317
340
  return manifest;
318
341
  } catch (error) {
319
342
  if (error instanceof SnapshotStoreError) {
@@ -347,8 +370,9 @@ export class SnapshotStore {
347
370
  }
348
371
  const artifactExclusions = captureExclusions(topology.workspaceIdentity, options.excludePaths);
349
372
  await this.assertTopology(topology, "可见路径枚举前 topology 已变化");
350
- if (topology.roots.some((root) => root.state === "broken")) {
351
- throw new SnapshotStoreError("capture_failed", "broken root 不能静默进入可见路径枚举");
373
+ const brokenRoots = brokenRootPaths(topology);
374
+ if (brokenRoots.length > 0) {
375
+ throw new SnapshotStoreError("capture_failed", `broken root 不能静默进入可见路径枚举: ${brokenRoots.join(", ")}`);
352
376
  }
353
377
 
354
378
  const storeDirectory = this.storeDirectory(topology);
@@ -958,6 +982,69 @@ export class SnapshotStore {
958
982
  this.visibleLeafCache.set(gitDirectory, cache);
959
983
  }
960
984
 
985
+ /** 从 storeDirectory 读取持久化叶子缓存并合并进内存;进程内已有条目优先。 */
986
+ private async loadPersistedLeafCache(storeDirectory: string): Promise<void> {
987
+ if (this.leafCacheDirectoriesLoaded.has(storeDirectory)) return;
988
+ this.leafCacheDirectoriesLoaded.add(storeDirectory);
989
+ let file: unknown;
990
+ try {
991
+ file = JSON.parse(await readFile(join(storeDirectory, LEAF_CACHE_FILE), "utf8"));
992
+ } catch {
993
+ return; // 缺失或损坏:忽略,本次 capture 走冷路径并重建缓存。
994
+ }
995
+ if (!isPersistedLeafCacheFile(file)) return;
996
+ const prefix = `${storeDirectory}${sep}`;
997
+ for (const [gitDirectory, entries] of Object.entries(file.entries)) {
998
+ if (!gitDirectory.startsWith(prefix) || this.visibleLeafCache.has(gitDirectory)) continue;
999
+ const cache = new Map<string, CachedVisibleLeaf>();
1000
+ for (const [relativePath, entry] of Object.entries(entries)) {
1001
+ cache.set(relativePath, {
1002
+ relativePath,
1003
+ kind: entry.kind,
1004
+ mode: entry.mode,
1005
+ fingerprint: entry.fingerprint,
1006
+ cacheable: entry.cacheable,
1007
+ changedAtNs: BigInt(entry.changedAtNs),
1008
+ objectId: entry.objectId,
1009
+ verifiedAtNs: BigInt(entry.verifiedAtNs),
1010
+ });
1011
+ }
1012
+ this.visibleLeafCache.set(gitDirectory, cache);
1013
+ }
1014
+ }
1015
+
1016
+ /** 把当前 storeDirectory 范围内的叶子缓存原子写入磁盘(best-effort)。 */
1017
+ private async persistLeafCache(storeDirectory: string): Promise<void> {
1018
+ const prefix = `${storeDirectory}${sep}`;
1019
+ const entries: Record<string, Record<string, PersistedLeafCacheEntry>> = {};
1020
+ for (const [gitDirectory, cache] of this.visibleLeafCache) {
1021
+ if (!gitDirectory.startsWith(prefix)) continue;
1022
+ const rootEntries: Record<string, PersistedLeafCacheEntry> = {};
1023
+ for (const [relativePath, leaf] of cache) {
1024
+ rootEntries[relativePath] = {
1025
+ kind: leaf.kind,
1026
+ mode: leaf.mode,
1027
+ fingerprint: leaf.fingerprint,
1028
+ cacheable: leaf.cacheable,
1029
+ objectId: leaf.objectId,
1030
+ changedAtNs: leaf.changedAtNs.toString(),
1031
+ verifiedAtNs: leaf.verifiedAtNs.toString(),
1032
+ };
1033
+ }
1034
+ entries[gitDirectory] = rootEntries;
1035
+ }
1036
+ try {
1037
+ // 用普通 JSON 序列化(非 canonicalJson):缓存只在本机消费,避免大规模排序开销。
1038
+ await writeBytesAtomic(
1039
+ join(storeDirectory, LEAF_CACHE_FILE),
1040
+ Buffer.from(JSON.stringify({ schemaVersion: 1, entries }), "utf8"),
1041
+ 0o600,
1042
+ );
1043
+ } catch {
1044
+ // 缓存写入是 best-effort:失败只影响下次性能,不影响正确性。
1045
+ }
1046
+ }
1047
+
961
1048
  private async assertVisibleLeavesUnchanged(
962
1049
  cwd: string,
963
1050
  leaves: readonly VisibleLeaf[],
@@ -1572,6 +1659,10 @@ function ignoredPresentProof(coverage: string, ignoredPresentPaths: readonly str
1572
1659
  };
1573
1660
  }
1574
1661
 
1662
+ function brokenRootPaths(topology: Pick<RootTopology, "roots">): string[] {
1663
+ return topology.roots.filter((root) => root.state === "broken").map((root) => root.relativeRoot);
1664
+ }
1665
+
1575
1666
  function inactiveRootClosure(root: Pick<RootTopologyIdentity, "relativeRoot" | "state">): string {
1576
1667
  return checksum(canonicalJson({
1577
1668
  relativeRoot: root.relativeRoot,
@@ -1786,6 +1877,33 @@ function nativeVisibleLeafMetadata(entry: NativeMetadataEntry): VisibleLeafMetad
1786
1877
  };
1787
1878
  }
1788
1879
 
1880
+ function isPersistedLeafCacheFile(value: unknown): value is PersistedLeafCacheFile {
1881
+ if (typeof value !== "object" || value === null) return false;
1882
+ const file = value as { schemaVersion?: unknown; entries?: unknown };
1883
+ if (file.schemaVersion !== 1 || typeof file.entries !== "object" || file.entries === null) return false;
1884
+ for (const entries of Object.values(file.entries as Record<string, unknown>)) {
1885
+ if (typeof entries !== "object" || entries === null) return false;
1886
+ for (const entry of Object.values(entries as Record<string, unknown>)) {
1887
+ if (typeof entry !== "object" || entry === null) return false;
1888
+ const candidate = entry as Partial<PersistedLeafCacheEntry>;
1889
+ if (
1890
+ (candidate.kind !== "file" && candidate.kind !== "symlink") ||
1891
+ typeof candidate.mode !== "number" ||
1892
+ typeof candidate.fingerprint !== "string" ||
1893
+ typeof candidate.cacheable !== "boolean" ||
1894
+ typeof candidate.objectId !== "string" ||
1895
+ typeof candidate.changedAtNs !== "string" ||
1896
+ typeof candidate.verifiedAtNs !== "string" ||
1897
+ !/^[0-9]+$/.test(candidate.changedAtNs) ||
1898
+ !/^[0-9]+$/.test(candidate.verifiedAtNs)
1899
+ ) {
1900
+ return false;
1901
+ }
1902
+ }
1903
+ }
1904
+ return true;
1905
+ }
1906
+
1789
1907
  function visibleLeafFingerprint(metadata: VisibleLeafMetadata): string {
1790
1908
  return checksum(canonicalJson({
1791
1909
  kind: metadata.kind,
@@ -1879,11 +1997,16 @@ function parseTreeEntries(output: Uint8Array): CapturedTreeEntry[] {
1879
1997
  }
1880
1998
 
1881
1999
  function parseNulPaths(output: Uint8Array): string[] {
1882
- return splitNulRecords(output).map((record) => {
2000
+ const paths: string[] = [];
2001
+ for (const record of splitNulRecords(output)) {
1883
2002
  const path = decodeUtf8(record);
2003
+ // git 在嵌套仓库边界会输出折叠的目录项(如 "dir/");该目录由 root discovery
2004
+ // 作为独立 root 捕获,不属于本仓库的路径枚举,直接跳过。
2005
+ if (path.endsWith("/")) continue;
1884
2006
  relativeSafePath("/", path);
1885
- return path;
1886
- });
2007
+ paths.push(path);
2008
+ }
2009
+ return paths;
1887
2010
  }
1888
2011
 
1889
2012
  function splitNulRecords(output: Uint8Array): Uint8Array[] {