@davideasden/pi-undo 0.2.15 → 0.2.16

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.
@@ -218,9 +218,10 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
218
218
  pi.on("input", async (event: InputEvent, context: ExtensionContext) => {
219
219
  const active = runtime;
220
220
  if (active === undefined) return { action: "continue" as const };
221
- const result = await active.controller.prepareInput(event.text, {
222
- streaming: event.streamingBehavior !== undefined,
223
- });
221
+ const inputContext = { streaming: event.streamingBehavior !== undefined };
222
+ const result = active.controller.beginInput !== undefined
223
+ ? active.controller.beginInput(event.text, inputContext)
224
+ : await active.controller.prepareInput(event.text, inputContext);
224
225
  const replay = replaying;
225
226
  if (result.action === "defer") {
226
227
  if (replay !== undefined && event.source === "extension" && samePrompt(event.text, event.images, replay)) {
@@ -254,7 +255,10 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
254
255
  }
255
256
  }
256
257
  }
257
- if (result.action === "continue" && active.controller.captureFailed() && !captureFailureNotified) {
258
+ if (
259
+ active.controller.beginInput === undefined &&
260
+ result.action === "continue" && active.controller.captureFailed() && !captureFailureNotified
261
+ ) {
258
262
  captureFailureNotified = true;
259
263
  const reason = active.controller.captureFailureReason();
260
264
  context.ui.notify(
@@ -264,6 +268,22 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
264
268
  }
265
269
  return result;
266
270
  });
271
+ pi.on("message_end", async (event, context: ExtensionContext) => {
272
+ if (event.message.role !== "user") return;
273
+ const active = runtime;
274
+ const messageGeneration = generation;
275
+ if (active === undefined || active.controller.commitInput === undefined) return;
276
+ await active.controller.commitInput();
277
+ if (runtime !== active || generation !== messageGeneration) return;
278
+ if (active.controller.captureFailed() && !captureFailureNotified) {
279
+ captureFailureNotified = true;
280
+ const reason = active.controller.captureFailureReason();
281
+ context.ui.notify(
282
+ `pi-undo: pre-input snapshot failed${reason === undefined || reason.length === 0 ? "" : ` (${reason})`}; this run will not be undoable`,
283
+ "warning",
284
+ );
285
+ }
286
+ });
267
287
  pi.on("before_agent_start", async () => {
268
288
  const active = runtime;
269
289
  const startGeneration = generation;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davideasden/pi-undo",
3
- "version": "0.2.15",
3
+ "version": "0.2.16",
4
4
  "description": "Persistent workspace undo and redo for Pi",
5
5
  "type": "module",
6
6
  "keywords": [
package/src/controller.ts CHANGED
@@ -37,6 +37,7 @@ export interface ControllerDependencies {
37
37
  readonly appendControl: (customType: string, data?: unknown) => Promise<string | null>;
38
38
  readonly appendCursor: (cursor: CursorState) => Promise<CursorAppendResult>;
39
39
  readonly capture: (scopePaths?: readonly string[]) => Promise<SnapshotManifest>;
40
+ readonly captureBaseline?: (baseline: SnapshotManifest) => Promise<SnapshotManifest>;
40
41
  readonly captureSafety?: (
41
42
  referenceManifestId: ManifestId,
42
43
  targetManifestId: ManifestId,
@@ -136,6 +137,10 @@ export interface UndoController {
136
137
  /** 只读的 undo 栈视图(栈底在前);仅供 /diff 等展示使用。 */
137
138
  listCheckpoints(): readonly CheckpointRecord[];
138
139
  prepareInput(text: string, context: InputContext): Promise<InputEventResult>;
140
+ /** 在 input hook 中启动快照,但不等待,供 message_end 前的快速路径使用。 */
141
+ beginInput?(text: string, context: InputContext): InputEventResult;
142
+ /** 在用户 message_end 后等待快照并写入 start entry。 */
143
+ commitInput?(): Promise<void>;
139
144
  beforeAgentStart(): Promise<void>;
140
145
  agentSettled(): Promise<void>;
141
146
  undo(): Promise<OperationResult>;
@@ -170,6 +175,7 @@ export interface ControllerInitialState {
170
175
  readonly redoStack?: readonly ControllerRedoEntry[];
171
176
  readonly historyPaused?: boolean;
172
177
  readonly locked?: boolean;
178
+ readonly recoveryCompleted?: boolean;
173
179
  }
174
180
 
175
181
  interface PendingTree {
@@ -201,6 +207,11 @@ export class UndoControllerImpl implements UndoController {
201
207
  private lastCaptureFailed = false;
202
208
  private lastCaptureFailureMessage: string | undefined;
203
209
  private warmUpInFlight: Promise<void> | undefined;
210
+ private warmUpManifest: SnapshotManifest | undefined;
211
+ private pendingInputCapture: { readonly token: symbol; readonly promise: Promise<void> } | undefined;
212
+ private deferAgentStartUntilMessageEnd = false;
213
+ private recoveryInFlight: Promise<void> | undefined;
214
+ private recoveryCompleted = false;
204
215
 
205
216
  constructor(dependencies: ControllerDependencies, initialState: ControllerInitialState = {}) {
206
217
  this.dependencies = dependencies;
@@ -208,6 +219,7 @@ export class UndoControllerImpl implements UndoController {
208
219
  this.redoStack.push(...(initialState.redoStack ?? []));
209
220
  this.historyPaused = initialState.historyPaused ?? false;
210
221
  this.locked = initialState.locked ?? false;
222
+ this.recoveryCompleted = initialState.recoveryCompleted ?? false;
211
223
  }
212
224
 
213
225
  history(): HistoryState {
@@ -222,7 +234,7 @@ export class UndoControllerImpl implements UndoController {
222
234
  if (this.locked || this.warmUpInFlight !== undefined) return;
223
235
  this.warmUpInFlight = (async () => {
224
236
  try {
225
- await this.captureWithWorkspaceLock();
237
+ this.warmUpManifest = await this.captureWithWorkspaceLock();
226
238
  } catch {
227
239
  // 预热是 best-effort:失败静默,正式 capture 会再次尝试并上报。
228
240
  }
@@ -243,21 +255,49 @@ export class UndoControllerImpl implements UndoController {
243
255
  if (this.operationInFlight) return { action: "defer" };
244
256
  if (context.streaming || text.length === 0) return { action: "continue" };
245
257
  try {
246
- const before = await this.captureWithWorkspaceLock();
247
- this.lastCaptureFailed = false;
248
- this.lastCaptureFailureMessage = undefined;
249
- this.historyPaused = false;
250
- this.staged = { rawPrompt: text, before, sourceLogicalLeaf: this.dependencies.getLogicalLeafId() };
258
+ const before = await this.captureInputBaseline();
259
+ this.stageInput(text, before);
251
260
  return { action: "continue" };
252
261
  } catch (error) {
253
262
  // 无法证明输入前状态:放弃记录本次历史,但绝不吞掉用户输入。
254
- this.lastCaptureFailed = true;
255
- this.lastCaptureFailureMessage = truncateReason(error instanceof Error ? error.message : String(error));
263
+ this.recordCaptureFailure(error);
256
264
  return { action: "continue" };
257
265
  }
258
266
  }
259
267
 
268
+ beginInput(text: string, context: InputContext): InputEventResult {
269
+ if (this.promptDeferralInFlight) return { action: "defer" };
270
+ if (this.locked) return { action: "continue" };
271
+ if (this.operationInFlight) return { action: "defer" };
272
+ if (context.streaming || text.length === 0) return { action: "continue" };
273
+ this.staged = undefined;
274
+ this.lastCaptureFailed = false;
275
+ this.lastCaptureFailureMessage = undefined;
276
+ this.deferAgentStartUntilMessageEnd = true;
277
+ const token = Symbol("input-capture");
278
+ const promise = this.captureInputForToken(text, token);
279
+ this.pendingInputCapture = { token, promise };
280
+ void promise.then(() => {
281
+ if (this.pendingInputCapture?.token === token) this.pendingInputCapture = undefined;
282
+ });
283
+ return { action: "continue" };
284
+ }
285
+
286
+ async commitInput(): Promise<void> {
287
+ if (!this.deferAgentStartUntilMessageEnd) return;
288
+ const pending = this.pendingInputCapture;
289
+ if (pending !== undefined) await pending.promise;
290
+ this.pendingInputCapture = undefined;
291
+ this.deferAgentStartUntilMessageEnd = false;
292
+ await this.startAgentRun();
293
+ }
294
+
260
295
  async beforeAgentStart(): Promise<void> {
296
+ if (this.deferAgentStartUntilMessageEnd) return;
297
+ await this.startAgentRun();
298
+ }
299
+
300
+ private async startAgentRun(): Promise<void> {
261
301
  if (this.locked || this.staged === undefined) return;
262
302
  try {
263
303
  this.staged.startEntryId = await this.dependencies.appendControl("pi-undo:start", {
@@ -280,6 +320,41 @@ export class UndoControllerImpl implements UndoController {
280
320
  this.redoStack.length = 0;
281
321
  }
282
322
 
323
+ private async captureInputBaseline(): Promise<SnapshotManifest> {
324
+ // warm-up 仍在进行时先等待,确保随后可以消费已完成的 baseline,而不是再次完整 capture。
325
+ const warmUp = this.warmUpInFlight;
326
+ if (warmUp !== undefined) await warmUp;
327
+ const warmUpManifest = this.warmUpManifest;
328
+ this.warmUpManifest = undefined;
329
+ return warmUpManifest !== undefined && this.dependencies.captureBaseline !== undefined
330
+ ? this.captureBaselineWithWorkspaceLock(warmUpManifest)
331
+ : this.captureWithWorkspaceLock();
332
+ }
333
+
334
+ private async captureInputForToken(text: string, token: symbol): Promise<void> {
335
+ try {
336
+ const before = await this.captureInputBaseline();
337
+ if (this.pendingInputCapture?.token !== token) return;
338
+ this.stageInput(text, before);
339
+ } catch (error) {
340
+ if (this.pendingInputCapture?.token !== token) return;
341
+ // 无法证明输入前状态:放弃记录本次历史,但绝不吞掉用户输入。
342
+ this.recordCaptureFailure(error);
343
+ }
344
+ }
345
+
346
+ private stageInput(text: string, before: SnapshotManifest): void {
347
+ this.lastCaptureFailed = false;
348
+ this.lastCaptureFailureMessage = undefined;
349
+ this.historyPaused = false;
350
+ this.staged = { rawPrompt: text, before, sourceLogicalLeaf: this.dependencies.getLogicalLeafId() };
351
+ }
352
+
353
+ private recordCaptureFailure(error: unknown): void {
354
+ this.lastCaptureFailed = true;
355
+ this.lastCaptureFailureMessage = truncateReason(error instanceof Error ? error.message : String(error));
356
+ }
357
+
283
358
  async agentSettled(): Promise<void> {
284
359
  const staged = this.staged;
285
360
  this.staged = undefined;
@@ -434,12 +509,24 @@ export class UndoControllerImpl implements UndoController {
434
509
  }
435
510
 
436
511
  async recover(): Promise<void> {
437
- try {
438
- const result = await this.dependencies.recoverPending();
439
- if (result.kind === "locked") this.locked = true;
440
- } catch {
441
- this.locked = true;
512
+ if (this.recoveryCompleted) return;
513
+ const recoveryInFlight = this.recoveryInFlight;
514
+ if (recoveryInFlight !== undefined) {
515
+ await recoveryInFlight;
516
+ return;
442
517
  }
518
+ const recovery = (async (): Promise<void> => {
519
+ try {
520
+ const result = await this.dependencies.recoverPending();
521
+ if (result.kind === "locked") this.locked = true;
522
+ } catch {
523
+ this.locked = true;
524
+ } finally {
525
+ this.recoveryCompleted = true;
526
+ }
527
+ })();
528
+ this.recoveryInFlight = recovery;
529
+ await recovery;
443
530
  }
444
531
 
445
532
  private async runOperation(action: "undo" | "redo"): Promise<OperationResult> {
@@ -703,6 +790,17 @@ export class UndoControllerImpl implements UndoController {
703
790
  }
704
791
  }
705
792
 
793
+ private async captureBaselineWithWorkspaceLock(baseline: SnapshotManifest): Promise<SnapshotManifest> {
794
+ const captureBaseline = this.dependencies.captureBaseline;
795
+ if (captureBaseline === undefined) return this.captureWithWorkspaceLock();
796
+ const lease = await this.dependencies.acquireWorkspaceLock();
797
+ try {
798
+ return await captureBaseline(baseline);
799
+ } finally {
800
+ await lease.release();
801
+ }
802
+ }
803
+
706
804
  private async compensate(
707
805
  descriptor: OperationDescriptor,
708
806
  rollback: SnapshotManifest,
package/src/pi-runtime.ts CHANGED
@@ -14,7 +14,7 @@ import {
14
14
  import { finalizeDurablePack, hasDurablePack, loadDurablePack } from "./durable-pack.ts";
15
15
  import { assertCursor, canonicalJson, checksum, sameWorkspaceSnapshot } from "./encoding.ts";
16
16
  import { JournalStore, finalizeCursorMarker, inspectCursorMarkers } from "./journal.ts";
17
- import type { CheckpointRecord, ManifestId, SessionFileIdentity } from "./model.ts";
17
+ import type { CheckpointRecord, ManifestId, SessionFileIdentity, SnapshotManifest } from "./model.ts";
18
18
  import {
19
19
  cleanupPackedMutations,
20
20
  materializePackedMutationJournal,
@@ -50,7 +50,14 @@ export async function createPiUndoRuntime(context: ExtensionContext, pi: Extensi
50
50
  if (topology.workspaceIdentity !== initialTopology.workspaceIdentity) {
51
51
  throw new Error("workspace identity 已变化");
52
52
  }
53
- return store.capture(topology, scopePaths);
53
+ return store.capture(topology, scopePaths, { topologyAlreadyValidated: true });
54
+ };
55
+ const captureBaseline = async (baseline: SnapshotManifest) => {
56
+ const topology = await discovery.discover(context.cwd);
57
+ if (topology.workspaceIdentity !== initialTopology.workspaceIdentity) {
58
+ throw new Error("workspace identity 已变化");
59
+ }
60
+ return store.captureBaseline(topology, baseline, undefined, { topologyAlreadyValidated: true });
54
61
  };
55
62
  const recovery = new JournalRecovery({
56
63
  sessionIdentity,
@@ -214,6 +221,7 @@ export async function createPiUndoRuntime(context: ExtensionContext, pi: Extensi
214
221
  appendControl: async (customType, data) => appendControlEntry(pi, manager, customType, data),
215
222
  appendCursor: async (cursor) => cursorWriter.appendCursor(cursor, pi, sourceFor(manager)),
216
223
  capture,
224
+ captureBaseline,
217
225
  captureSafety: async (referenceManifestId, targetManifestId, scopePaths) => {
218
226
  const [reference, target] = await Promise.all([
219
227
  store.loadManifest(referenceManifestId),
@@ -251,6 +259,7 @@ export async function createPiUndoRuntime(context: ExtensionContext, pi: Extensi
251
259
  const controller = new UndoControllerImpl(dependencies, {
252
260
  ...rebuildControllerState(manager, sessionIdentity),
253
261
  locked: startupRecovery.kind === "locked",
262
+ recoveryCompleted: true,
254
263
  });
255
264
  return {
256
265
  controller,
@@ -147,6 +147,8 @@ export interface SnapshotStoreOptions {
147
147
 
148
148
  export interface CaptureOptions {
149
149
  readonly excludePaths?: readonly string[];
150
+ /** 调用方刚完成 topology discovery 时跳过重复的捕获前校验。捕获后校验仍然执行。 */
151
+ readonly topologyAlreadyValidated?: boolean;
150
152
  }
151
153
 
152
154
  export interface SnapshotBlobRequest {
@@ -176,6 +178,12 @@ export class SnapshotStoreError extends Error {
176
178
 
177
179
  export interface SnapshotStore {
178
180
  capture(topology: RootTopology, scope?: readonly string[], options?: CaptureOptions): Promise<SnapshotManifest>;
181
+ captureBaseline(
182
+ topology: RootTopology,
183
+ baseline: SnapshotManifest,
184
+ scope?: readonly string[],
185
+ options?: CaptureOptions,
186
+ ): Promise<SnapshotManifest>;
179
187
  listVisibleLeafPaths(topology: RootTopology, options?: CaptureOptions): Promise<readonly string[]>;
180
188
  loadManifest(id: ManifestId): Promise<SnapshotManifest>;
181
189
  assertComplete(id: ManifestId, scopePaths?: readonly string[]): Promise<void>;
@@ -269,7 +277,9 @@ export class SnapshotStore {
269
277
  }
270
278
  const coverage = captureCoverage(topology.workspaceIdentity, scope);
271
279
  const artifactExclusions = captureExclusions(topology.workspaceIdentity, options.excludePaths);
272
- await this.assertTopology(topology, "捕获前 topology 已变化");
280
+ if (options.topologyAlreadyValidated !== true) {
281
+ await this.assertTopology(topology, "捕获前 topology 已变化");
282
+ }
273
283
  const brokenRoots = brokenRootPaths(topology);
274
284
  if (brokenRoots.length > 0) {
275
285
  throw new SnapshotStoreError("capture_failed", `broken root 不能静默进入快照: ${brokenRoots.join(", ")}`);
@@ -350,6 +360,189 @@ export class SnapshotStore {
350
360
  }
351
361
  }
352
362
 
363
+ /**
364
+ * 复核 warm-up 生成的 baseline;只有 topology、可见路径、文件 metadata 和 ignored proof
365
+ * 都能由现有证据证明未变化时,才跳过完整 capture。
366
+ */
367
+ async captureBaseline(
368
+ topology: RootTopology,
369
+ baseline: SnapshotManifest,
370
+ scope?: readonly string[],
371
+ options: CaptureOptions = {},
372
+ ): Promise<SnapshotManifest> {
373
+ await this.assertPrivateStore(topology.workspaceIdentity);
374
+ const lockIdentity = `snapshot-store:${await prospectiveCanonicalPath(this.storesRoot)}`;
375
+ return this.lock.withLock(lockIdentity, () => this.captureBaselineLocked(topology, baseline, scope, options));
376
+ }
377
+
378
+ private async captureBaselineLocked(
379
+ topology: RootTopology,
380
+ baseline: SnapshotManifest,
381
+ scope: readonly string[] | undefined,
382
+ options: CaptureOptions,
383
+ ): Promise<SnapshotManifest> {
384
+ try {
385
+ if (topology.fingerprint !== topologyFingerprint(topology.workspaceIdentity, topology.roots)) {
386
+ throw new SnapshotStoreError("capture_failed", "topology fingerprint 与 roots 不匹配");
387
+ }
388
+ if (options.topologyAlreadyValidated !== true) {
389
+ await this.assertTopology(topology, "捕获前 topology 已变化");
390
+ }
391
+ const brokenRoots = brokenRootPaths(topology);
392
+ if (brokenRoots.length > 0) {
393
+ throw new SnapshotStoreError("capture_failed", `broken root 不能静默进入 baseline 校验: ${brokenRoots.join(", ")}`);
394
+ }
395
+ if (await this.isBaselineFresh(topology, baseline, scope, options)) {
396
+ await this.assertTopology(topology, "捕获期间 topology 已变化");
397
+ await this.touchStore(this.storeDirectory(topology));
398
+ return baseline;
399
+ }
400
+ // 已完成一次捕获前 topology 校验;完整回退仍保留捕获后的校验。
401
+ return this.captureLocked(topology, scope, { ...options, topologyAlreadyValidated: true });
402
+ } catch (error) {
403
+ if (error instanceof SnapshotStoreError) {
404
+ throw error;
405
+ }
406
+ throw new SnapshotStoreError("capture_failed", errorMessage(error), { cause: error });
407
+ }
408
+ }
409
+
410
+ private async isBaselineFresh(
411
+ topology: RootTopology,
412
+ baseline: SnapshotManifest,
413
+ scope: readonly string[] | undefined,
414
+ options: CaptureOptions,
415
+ ): Promise<boolean> {
416
+ try {
417
+ assertManifest(baseline);
418
+ } catch {
419
+ return false;
420
+ }
421
+ const artifactExclusions = captureExclusions(topology.workspaceIdentity, options.excludePaths);
422
+ if (
423
+ baseline.workspaceIdentity !== topology.workspaceIdentity ||
424
+ baseline.topologyFingerprint !== topology.fingerprint ||
425
+ baseline.coverage !== captureCoverage(topology.workspaceIdentity, scope) ||
426
+ baseline.roots.length !== topology.roots.length
427
+ ) {
428
+ return false;
429
+ }
430
+
431
+ const storeDirectory = this.storeDirectory(topology);
432
+ await this.loadPersistedLeafCache(storeDirectory);
433
+ const transactionsRoot = join(storeDirectory, "transactions");
434
+ await mkdir(transactionsRoot, { recursive: true });
435
+ const transactionDirectory = await mkdtemp(join(transactionsRoot, "baseline-"));
436
+ try {
437
+ const baselineRoots = new Map(baseline.roots.map((root) => [root.relativeRoot, root]));
438
+ for (const root of topology.roots) {
439
+ const baselineRoot = baselineRoots.get(root.relativeRoot);
440
+ if (
441
+ baselineRoot === undefined ||
442
+ baselineRoot.parentRoot !== root.parentRoot ||
443
+ baselineRoot.state !== root.state ||
444
+ baselineRoot.sourceIdentity !== root.sourceIdentity ||
445
+ baselineRoot.privateRepositoryId !== root.privateRepositoryId ||
446
+ (baselineRoot.gitlinkOid ?? null) !== (root.gitlinkOid ?? null) ||
447
+ baselineRoot.coverage !== rootCaptureCoverage(root.relativeRoot, scope, topology.roots) ||
448
+ baselineRoot.ignorePolicy !== IGNORE_POLICY
449
+ ) {
450
+ return false;
451
+ }
452
+ if (root.state !== "active") {
453
+ if (
454
+ baselineRoot.treeId !== null ||
455
+ baselineRoot.ignoredPresentPaths.length > 0 ||
456
+ baselineRoot.objectClosure !== inactiveRootClosure(root)
457
+ ) {
458
+ return false;
459
+ }
460
+ continue;
461
+ }
462
+
463
+ const treeId = baselineRoot.treeId;
464
+ if (treeId === null) return false;
465
+ const gitDirectory = this.rootGitDirectory(storeDirectory, root);
466
+ await this.ensurePrivateRepository(gitDirectory);
467
+ await this.assertNoAlternates(gitDirectory);
468
+ const absoluteRoot = workspaceRootPath(topology.workspaceIdentity, root.relativeRoot);
469
+ const indexPath = join(transactionDirectory, `${rootStoreId(root)}.index`);
470
+ const environment = privateGitEnvironment(gitDirectory, absoluteRoot, indexPath);
471
+ await this.runGit(["read-tree", "--empty"], { cwd: absoluteRoot, env: environment });
472
+ await this.validateIgnoreQuery(absoluteRoot, environment, root.gitBacked);
473
+
474
+ const requestedInclusions = rootScopePathspecs(root.relativeRoot, scope);
475
+ const exclusions = topology.roots
476
+ .filter((candidate) => isStrictRootAncestor(root.relativeRoot, candidate.relativeRoot))
477
+ .map((candidate) => rootRelativePath(root.relativeRoot, candidate.relativeRoot));
478
+ const exactExclusions = ownedArtifactExclusions(topology.roots, root.relativeRoot, artifactExclusions);
479
+ const inclusions = ownedRootInclusions(requestedInclusions, exclusions);
480
+ const entries = await this.readTreeEntries(gitDirectory, treeId);
481
+ await this.assertObjectsComplete(gitDirectory, treeId, entries);
482
+ if (baselineRoot.objectClosure !== treeObjectClosure(treeId, entries)) return false;
483
+
484
+ const leaves = await this.collectVisibleLeaves(
485
+ absoluteRoot,
486
+ environment,
487
+ root.gitBacked,
488
+ inclusions,
489
+ exclusions,
490
+ exactExclusions,
491
+ transactionDirectory,
492
+ );
493
+ if (!samePathList(
494
+ leaves.map((leaf) => leaf.relativePath).sort(comparePaths),
495
+ entries.map((entry) => entry.relativePath).sort(comparePaths),
496
+ )) {
497
+ return false;
498
+ }
499
+ const cache = this.visibleLeafCache.get(gitDirectory);
500
+ if (cache === undefined) return false;
501
+ const entriesByPath = new Map(entries.map((entry) => [entry.relativePath, entry]));
502
+ for (const leaf of leaves) {
503
+ const entry = entriesByPath.get(leaf.relativePath);
504
+ const cached = cache.get(leaf.relativePath);
505
+ if (
506
+ entry === undefined ||
507
+ !leaf.cacheable ||
508
+ cached?.cacheable !== true ||
509
+ cached.kind !== leaf.kind ||
510
+ cached.mode !== leaf.mode ||
511
+ cached.fingerprint !== leaf.fingerprint ||
512
+ cached.objectId !== entry.objectId ||
513
+ cached.verifiedAtNs <= cached.changedAtNs + RACY_CLEAN_WINDOW_NS
514
+ ) {
515
+ return false;
516
+ }
517
+ }
518
+
519
+ const ignoredPresentPaths = await this.captureIgnoredPresentPaths(
520
+ absoluteRoot,
521
+ environment,
522
+ root.gitBacked,
523
+ inclusions,
524
+ exclusions,
525
+ exactExclusions,
526
+ transactionDirectory,
527
+ );
528
+ if (!samePathList(ignoredPresentPaths, baselineRoot.ignoredPresentPaths)) return false;
529
+ if (baselineRoot.ignoreClosure !== ignoredPresentClosure({
530
+ coverage: baselineRoot.coverage,
531
+ ignorePolicy: IGNORE_POLICY,
532
+ ignoredPresentPaths,
533
+ })) return false;
534
+ // metadata 初检与最终复核之间若有变化,放弃 baseline,回退完整 capture。
535
+ await this.assertVisibleLeavesUnchanged(absoluteRoot, leaves, transactionDirectory);
536
+ }
537
+ return true;
538
+ } catch {
539
+ // baseline 证据读取失败时不复用旧快照;完整 capture 会重新建立对象和缓存。
540
+ return false;
541
+ } finally {
542
+ await rm(transactionDirectory, { recursive: true, force: true }).catch(() => {});
543
+ }
544
+ }
545
+
353
546
  async listVisibleLeafPaths(
354
547
  topology: RootTopology,
355
548
  options: CaptureOptions = {},
@@ -1593,8 +1786,17 @@ function ownedRootInclusions(
1593
1786
  return owned.length === 0 ? null : owned;
1594
1787
  }
1595
1788
 
1596
- function rootCaptureCoverage(rootPath: string, scope: readonly string[] | undefined): string {
1597
- return rootCoverageFromInclusions(rootScopePathspecs(rootPath, scope));
1789
+ function rootCaptureCoverage(
1790
+ rootPath: string,
1791
+ scope: readonly string[] | undefined,
1792
+ roots?: readonly RootTopologyIdentity[],
1793
+ ): string {
1794
+ const requestedInclusions = rootScopePathspecs(rootPath, scope);
1795
+ if (roots === undefined) return rootCoverageFromInclusions(requestedInclusions);
1796
+ const exclusions = roots
1797
+ .filter((root) => isStrictRootAncestor(rootPath, root.relativeRoot))
1798
+ .map((root) => rootRelativePath(rootPath, root.relativeRoot));
1799
+ return rootCoverageFromInclusions(ownedRootInclusions(requestedInclusions, exclusions));
1598
1800
  }
1599
1801
 
1600
1802
  function rootCoverageFromInclusions(inclusions: readonly string[] | null): string {
@@ -2186,6 +2388,10 @@ function gitExitCode(error: unknown): number | null | undefined {
2186
2388
  : undefined;
2187
2389
  }
2188
2390
 
2391
+ function samePathList(left: readonly string[], right: readonly string[]): boolean {
2392
+ return left.length === right.length && left.every((path, index) => path === right[index]);
2393
+ }
2394
+
2189
2395
  function comparePaths(left: string, right: string): number {
2190
2396
  return left < right ? -1 : left > right ? 1 : 0;
2191
2397
  }