@davideasden/pi-undo 0.2.10 → 0.2.14
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 +1 -1
- package/extensions/pi-undo.ts +20 -3
- package/package.json +4 -3
- package/src/atomic-fs.ts +18 -0
- package/src/controller.ts +45 -4
- package/src/encoding.ts +30 -0
- package/src/pi-runtime.ts +3 -1
- package/src/restore-engine.ts +4 -1
- package/src/root-discovery.ts +26 -1
- package/src/snapshot-store.ts +122 -44
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`
|
|
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
|
|
package/extensions/pi-undo.ts
CHANGED
|
@@ -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: "
|
|
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
|
-
|
|
290
|
-
|
|
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.
|
|
3
|
+
"version": "0.2.14",
|
|
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": "
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|
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
|
},
|
package/src/restore-engine.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
package/src/root-discovery.ts
CHANGED
|
@@ -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>> {
|
package/src/snapshot-store.ts
CHANGED
|
@@ -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;
|
|
@@ -250,38 +252,50 @@ export class SnapshotStore {
|
|
|
250
252
|
const coverage = captureCoverage(topology.workspaceIdentity, scope);
|
|
251
253
|
const artifactExclusions = captureExclusions(topology.workspaceIdentity, options.excludePaths);
|
|
252
254
|
await this.assertTopology(topology, "捕获前 topology 已变化");
|
|
253
|
-
|
|
254
|
-
|
|
255
|
+
const brokenRoots = brokenRootPaths(topology);
|
|
256
|
+
if (brokenRoots.length > 0) {
|
|
257
|
+
throw new SnapshotStoreError("capture_failed", `broken root 不能静默进入快照: ${brokenRoots.join(", ")}`);
|
|
255
258
|
}
|
|
256
259
|
|
|
257
260
|
const storeDirectory = this.storeDirectory(topology);
|
|
258
261
|
const transactionsRoot = join(storeDirectory, "transactions");
|
|
259
262
|
await mkdir(transactionsRoot, { recursive: true });
|
|
260
263
|
transactionDirectory = await mkdtemp(join(transactionsRoot, "capture-"));
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
264
|
+
const activeTransactionDirectory = transactionDirectory;
|
|
265
|
+
|
|
266
|
+
// 每个 root 使用独立私有 ODB、index 与 worktree;结果保持输入顺序,缓存仍在整个 capture
|
|
267
|
+
// 持久化成功后统一发布,因此可并行缩短 nested-repository workspace 的关键路径。
|
|
268
|
+
const capturedRoots = await mapConcurrentOrdered(
|
|
269
|
+
topology.roots,
|
|
270
|
+
ROOT_CAPTURE_CONCURRENCY,
|
|
271
|
+
async (root): Promise<{ readonly root: SnapshotRoot; readonly cacheUpdate?: VisibleLeafCacheUpdate }> => {
|
|
272
|
+
if (root.state !== "active") {
|
|
273
|
+
const coverage = rootCaptureCoverage(root.relativeRoot, scope);
|
|
274
|
+
return {
|
|
275
|
+
root: snapshotRoot(root, {
|
|
276
|
+
treeId: null,
|
|
277
|
+
coverage,
|
|
278
|
+
...ignoredPresentProof(coverage, []),
|
|
279
|
+
objectClosure: inactiveRootClosure(root),
|
|
280
|
+
}),
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
const captured = await this.captureRoot(
|
|
284
|
+
topology,
|
|
285
|
+
root,
|
|
286
|
+
activeTransactionDirectory,
|
|
287
|
+
scope,
|
|
288
|
+
artifactExclusions,
|
|
289
|
+
);
|
|
290
|
+
return {
|
|
291
|
+
root: snapshotRoot(root, captured),
|
|
292
|
+
cacheUpdate: captured.cacheUpdate,
|
|
293
|
+
};
|
|
294
|
+
},
|
|
295
|
+
);
|
|
296
|
+
const roots = capturedRoots.map((captured) => captured.root);
|
|
297
|
+
const cacheUpdates = capturedRoots.flatMap((captured) =>
|
|
298
|
+
captured.cacheUpdate === undefined ? [] : [captured.cacheUpdate]);
|
|
285
299
|
|
|
286
300
|
await this.assertTopology(topology, "捕获期间 topology 已变化");
|
|
287
301
|
const content = {
|
|
@@ -334,8 +348,9 @@ export class SnapshotStore {
|
|
|
334
348
|
}
|
|
335
349
|
const artifactExclusions = captureExclusions(topology.workspaceIdentity, options.excludePaths);
|
|
336
350
|
await this.assertTopology(topology, "可见路径枚举前 topology 已变化");
|
|
337
|
-
|
|
338
|
-
|
|
351
|
+
const brokenRoots = brokenRootPaths(topology);
|
|
352
|
+
if (brokenRoots.length > 0) {
|
|
353
|
+
throw new SnapshotStoreError("capture_failed", `broken root 不能静默进入可见路径枚举: ${brokenRoots.join(", ")}`);
|
|
339
354
|
}
|
|
340
355
|
|
|
341
356
|
const storeDirectory = this.storeDirectory(topology);
|
|
@@ -720,6 +735,7 @@ export class SnapshotStore {
|
|
|
720
735
|
inclusions,
|
|
721
736
|
exclusions,
|
|
722
737
|
exactExclusions,
|
|
738
|
+
transactionDirectory,
|
|
723
739
|
);
|
|
724
740
|
const treeId = (await this.runGit(["write-tree"], { cwd: absoluteRoot, env: environment })).trim();
|
|
725
741
|
if (!isObjectId(treeId)) {
|
|
@@ -743,6 +759,7 @@ export class SnapshotStore {
|
|
|
743
759
|
inclusions: readonly string[] | null,
|
|
744
760
|
exclusions: readonly string[],
|
|
745
761
|
exactExclusions: ReadonlySet<string>,
|
|
762
|
+
requestDirectory: string,
|
|
746
763
|
): Promise<string[]> {
|
|
747
764
|
if (inclusions === null) {
|
|
748
765
|
return [];
|
|
@@ -761,7 +778,8 @@ export class SnapshotStore {
|
|
|
761
778
|
"--",
|
|
762
779
|
...pathspecs,
|
|
763
780
|
], { cwd, env: gitBacked ? sourceGitEnvironment() : environment });
|
|
764
|
-
const
|
|
781
|
+
const candidates: string[] = [];
|
|
782
|
+
const seen = new Set<string>();
|
|
765
783
|
for (const relativePath of parseNulPaths(output)) {
|
|
766
784
|
if (
|
|
767
785
|
exclusions.some((excluded) => isPathAtOrBelow(excluded, relativePath)) ||
|
|
@@ -769,23 +787,74 @@ export class SnapshotStore {
|
|
|
769
787
|
) {
|
|
770
788
|
continue;
|
|
771
789
|
}
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
if (hasErrorCode(error, "ENOENT")) return null;
|
|
775
|
-
throw error;
|
|
776
|
-
});
|
|
777
|
-
if (metadata === null) {
|
|
778
|
-
continue;
|
|
790
|
+
if (seen.has(relativePath)) {
|
|
791
|
+
throw new SnapshotStoreError("capture_failed", `ignored-present proof 包含重复路径:${relativePath}`);
|
|
779
792
|
}
|
|
780
|
-
|
|
793
|
+
seen.add(relativePath);
|
|
794
|
+
candidates.push(relativePath);
|
|
795
|
+
}
|
|
796
|
+
// ignored build/vendor trees 常含数万叶子;复用同一批量 metadata 协议,避免逐路径重复
|
|
797
|
+
// 遍历父目录。Native 与 fallback 都在叶子扫描前后复核共享父目录。
|
|
798
|
+
const nativeEntries = await this.inspectIgnoredMetadataBatches(cwd, candidates, requestDirectory);
|
|
799
|
+
const kinds = nativeEntries === undefined
|
|
800
|
+
? await this.collectIgnoredPresentKindsFallback(cwd, candidates)
|
|
801
|
+
: nativeEntries.map((entry) => entry.kind);
|
|
802
|
+
const result: string[] = [];
|
|
803
|
+
for (let index = 0; index < candidates.length; index += 1) {
|
|
804
|
+
const relativePath = candidates[index]!;
|
|
805
|
+
const kind = kinds[index]!;
|
|
806
|
+
if (kind === "absent") continue;
|
|
807
|
+
if (kind !== "file" && kind !== "symlink") {
|
|
781
808
|
throw new SnapshotStoreError("capture_failed", `ignored-present proof 只接受叶子路径:${relativePath}`);
|
|
782
809
|
}
|
|
783
|
-
|
|
784
|
-
|
|
810
|
+
result.push(relativePath);
|
|
811
|
+
}
|
|
812
|
+
return result.sort(comparePaths);
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
private async inspectIgnoredMetadataBatches(
|
|
816
|
+
cwd: string,
|
|
817
|
+
paths: readonly string[],
|
|
818
|
+
requestDirectory: string,
|
|
819
|
+
): Promise<readonly NativeMetadataEntry[] | undefined> {
|
|
820
|
+
const result: NativeMetadataEntry[] = [];
|
|
821
|
+
for (let offset = 0; offset < paths.length; offset += IGNORED_METADATA_BATCH_SIZE) {
|
|
822
|
+
const batch = paths.slice(offset, offset + IGNORED_METADATA_BATCH_SIZE);
|
|
823
|
+
const inspected = await this.nativeMetadata.inspect(cwd, batch, requestDirectory);
|
|
824
|
+
if (inspected === undefined) {
|
|
825
|
+
if (result.length > 0) {
|
|
826
|
+
throw new SnapshotStoreError("capture_failed", "native ignored metadata 能力在批次间变化");
|
|
827
|
+
}
|
|
828
|
+
return undefined;
|
|
785
829
|
}
|
|
786
|
-
result.
|
|
830
|
+
result.push(...inspected);
|
|
831
|
+
}
|
|
832
|
+
return result;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
private async collectIgnoredPresentKindsFallback(
|
|
836
|
+
cwd: string,
|
|
837
|
+
paths: readonly string[],
|
|
838
|
+
): Promise<readonly NativeMetadataEntry["kind"][]> {
|
|
839
|
+
const result: NativeMetadataEntry["kind"][] = [];
|
|
840
|
+
for (let offset = 0; offset < paths.length; offset += IGNORED_METADATA_BATCH_SIZE) {
|
|
841
|
+
const batch = paths.slice(offset, offset + IGNORED_METADATA_BATCH_SIZE);
|
|
842
|
+
await assertNoSymlinkParents(cwd, batch);
|
|
843
|
+
const kinds = await mapConcurrentOrdered(batch, FILE_SYSTEM_INSPECTION_CONCURRENCY, async (relativePath) => {
|
|
844
|
+
const metadata = await lstat(join(cwd, ...relativePath.split("/"))).catch((error) => {
|
|
845
|
+
if (hasErrorCode(error, "ENOENT")) return null;
|
|
846
|
+
throw error;
|
|
847
|
+
});
|
|
848
|
+
return metadata === null
|
|
849
|
+
? "absent" as const
|
|
850
|
+
: metadata.isFile() ? "file" as const
|
|
851
|
+
: metadata.isSymbolicLink() ? "symlink" as const
|
|
852
|
+
: "other" as const;
|
|
853
|
+
});
|
|
854
|
+
await assertNoSymlinkParents(cwd, batch);
|
|
855
|
+
result.push(...kinds);
|
|
787
856
|
}
|
|
788
|
-
return
|
|
857
|
+
return result;
|
|
789
858
|
}
|
|
790
859
|
|
|
791
860
|
private async stageWorktree(
|
|
@@ -1505,6 +1574,10 @@ function ignoredPresentProof(coverage: string, ignoredPresentPaths: readonly str
|
|
|
1505
1574
|
};
|
|
1506
1575
|
}
|
|
1507
1576
|
|
|
1577
|
+
function brokenRootPaths(topology: Pick<RootTopology, "roots">): string[] {
|
|
1578
|
+
return topology.roots.filter((root) => root.state === "broken").map((root) => root.relativeRoot);
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1508
1581
|
function inactiveRootClosure(root: Pick<RootTopologyIdentity, "relativeRoot" | "state">): string {
|
|
1509
1582
|
return checksum(canonicalJson({
|
|
1510
1583
|
relativeRoot: root.relativeRoot,
|
|
@@ -1812,11 +1885,16 @@ function parseTreeEntries(output: Uint8Array): CapturedTreeEntry[] {
|
|
|
1812
1885
|
}
|
|
1813
1886
|
|
|
1814
1887
|
function parseNulPaths(output: Uint8Array): string[] {
|
|
1815
|
-
|
|
1888
|
+
const paths: string[] = [];
|
|
1889
|
+
for (const record of splitNulRecords(output)) {
|
|
1816
1890
|
const path = decodeUtf8(record);
|
|
1891
|
+
// git 在嵌套仓库边界会输出折叠的目录项(如 "dir/");该目录由 root discovery
|
|
1892
|
+
// 作为独立 root 捕获,不属于本仓库的路径枚举,直接跳过。
|
|
1893
|
+
if (path.endsWith("/")) continue;
|
|
1817
1894
|
relativeSafePath("/", path);
|
|
1818
|
-
|
|
1819
|
-
}
|
|
1895
|
+
paths.push(path);
|
|
1896
|
+
}
|
|
1897
|
+
return paths;
|
|
1820
1898
|
}
|
|
1821
1899
|
|
|
1822
1900
|
function splitNulRecords(output: Uint8Array): Uint8Array[] {
|