@davideasden/pi-undo 0.2.10 → 0.2.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/encoding.ts +30 -0
- package/src/pi-runtime.ts +3 -1
- package/src/snapshot-store.ts +104 -37
package/package.json
CHANGED
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/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;
|
|
@@ -258,30 +260,41 @@ export class SnapshotStore {
|
|
|
258
260
|
const transactionsRoot = join(storeDirectory, "transactions");
|
|
259
261
|
await mkdir(transactionsRoot, { recursive: true });
|
|
260
262
|
transactionDirectory = await mkdtemp(join(transactionsRoot, "capture-"));
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
263
|
+
const activeTransactionDirectory = transactionDirectory;
|
|
264
|
+
|
|
265
|
+
// 每个 root 使用独立私有 ODB、index 与 worktree;结果保持输入顺序,缓存仍在整个 capture
|
|
266
|
+
// 持久化成功后统一发布,因此可并行缩短 nested-repository workspace 的关键路径。
|
|
267
|
+
const capturedRoots = await mapConcurrentOrdered(
|
|
268
|
+
topology.roots,
|
|
269
|
+
ROOT_CAPTURE_CONCURRENCY,
|
|
270
|
+
async (root): Promise<{ readonly root: SnapshotRoot; readonly cacheUpdate?: VisibleLeafCacheUpdate }> => {
|
|
271
|
+
if (root.state !== "active") {
|
|
272
|
+
const coverage = rootCaptureCoverage(root.relativeRoot, scope);
|
|
273
|
+
return {
|
|
274
|
+
root: snapshotRoot(root, {
|
|
275
|
+
treeId: null,
|
|
276
|
+
coverage,
|
|
277
|
+
...ignoredPresentProof(coverage, []),
|
|
278
|
+
objectClosure: inactiveRootClosure(root),
|
|
279
|
+
}),
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
const captured = await this.captureRoot(
|
|
283
|
+
topology,
|
|
284
|
+
root,
|
|
285
|
+
activeTransactionDirectory,
|
|
286
|
+
scope,
|
|
287
|
+
artifactExclusions,
|
|
288
|
+
);
|
|
289
|
+
return {
|
|
290
|
+
root: snapshotRoot(root, captured),
|
|
291
|
+
cacheUpdate: captured.cacheUpdate,
|
|
292
|
+
};
|
|
293
|
+
},
|
|
294
|
+
);
|
|
295
|
+
const roots = capturedRoots.map((captured) => captured.root);
|
|
296
|
+
const cacheUpdates = capturedRoots.flatMap((captured) =>
|
|
297
|
+
captured.cacheUpdate === undefined ? [] : [captured.cacheUpdate]);
|
|
285
298
|
|
|
286
299
|
await this.assertTopology(topology, "捕获期间 topology 已变化");
|
|
287
300
|
const content = {
|
|
@@ -720,6 +733,7 @@ export class SnapshotStore {
|
|
|
720
733
|
inclusions,
|
|
721
734
|
exclusions,
|
|
722
735
|
exactExclusions,
|
|
736
|
+
transactionDirectory,
|
|
723
737
|
);
|
|
724
738
|
const treeId = (await this.runGit(["write-tree"], { cwd: absoluteRoot, env: environment })).trim();
|
|
725
739
|
if (!isObjectId(treeId)) {
|
|
@@ -743,6 +757,7 @@ export class SnapshotStore {
|
|
|
743
757
|
inclusions: readonly string[] | null,
|
|
744
758
|
exclusions: readonly string[],
|
|
745
759
|
exactExclusions: ReadonlySet<string>,
|
|
760
|
+
requestDirectory: string,
|
|
746
761
|
): Promise<string[]> {
|
|
747
762
|
if (inclusions === null) {
|
|
748
763
|
return [];
|
|
@@ -761,7 +776,8 @@ export class SnapshotStore {
|
|
|
761
776
|
"--",
|
|
762
777
|
...pathspecs,
|
|
763
778
|
], { cwd, env: gitBacked ? sourceGitEnvironment() : environment });
|
|
764
|
-
const
|
|
779
|
+
const candidates: string[] = [];
|
|
780
|
+
const seen = new Set<string>();
|
|
765
781
|
for (const relativePath of parseNulPaths(output)) {
|
|
766
782
|
if (
|
|
767
783
|
exclusions.some((excluded) => isPathAtOrBelow(excluded, relativePath)) ||
|
|
@@ -769,23 +785,74 @@ export class SnapshotStore {
|
|
|
769
785
|
) {
|
|
770
786
|
continue;
|
|
771
787
|
}
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
if (hasErrorCode(error, "ENOENT")) return null;
|
|
775
|
-
throw error;
|
|
776
|
-
});
|
|
777
|
-
if (metadata === null) {
|
|
778
|
-
continue;
|
|
788
|
+
if (seen.has(relativePath)) {
|
|
789
|
+
throw new SnapshotStoreError("capture_failed", `ignored-present proof 包含重复路径:${relativePath}`);
|
|
779
790
|
}
|
|
780
|
-
|
|
791
|
+
seen.add(relativePath);
|
|
792
|
+
candidates.push(relativePath);
|
|
793
|
+
}
|
|
794
|
+
// ignored build/vendor trees 常含数万叶子;复用同一批量 metadata 协议,避免逐路径重复
|
|
795
|
+
// 遍历父目录。Native 与 fallback 都在叶子扫描前后复核共享父目录。
|
|
796
|
+
const nativeEntries = await this.inspectIgnoredMetadataBatches(cwd, candidates, requestDirectory);
|
|
797
|
+
const kinds = nativeEntries === undefined
|
|
798
|
+
? await this.collectIgnoredPresentKindsFallback(cwd, candidates)
|
|
799
|
+
: nativeEntries.map((entry) => entry.kind);
|
|
800
|
+
const result: string[] = [];
|
|
801
|
+
for (let index = 0; index < candidates.length; index += 1) {
|
|
802
|
+
const relativePath = candidates[index]!;
|
|
803
|
+
const kind = kinds[index]!;
|
|
804
|
+
if (kind === "absent") continue;
|
|
805
|
+
if (kind !== "file" && kind !== "symlink") {
|
|
781
806
|
throw new SnapshotStoreError("capture_failed", `ignored-present proof 只接受叶子路径:${relativePath}`);
|
|
782
807
|
}
|
|
783
|
-
|
|
784
|
-
|
|
808
|
+
result.push(relativePath);
|
|
809
|
+
}
|
|
810
|
+
return result.sort(comparePaths);
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
private async inspectIgnoredMetadataBatches(
|
|
814
|
+
cwd: string,
|
|
815
|
+
paths: readonly string[],
|
|
816
|
+
requestDirectory: string,
|
|
817
|
+
): Promise<readonly NativeMetadataEntry[] | undefined> {
|
|
818
|
+
const result: NativeMetadataEntry[] = [];
|
|
819
|
+
for (let offset = 0; offset < paths.length; offset += IGNORED_METADATA_BATCH_SIZE) {
|
|
820
|
+
const batch = paths.slice(offset, offset + IGNORED_METADATA_BATCH_SIZE);
|
|
821
|
+
const inspected = await this.nativeMetadata.inspect(cwd, batch, requestDirectory);
|
|
822
|
+
if (inspected === undefined) {
|
|
823
|
+
if (result.length > 0) {
|
|
824
|
+
throw new SnapshotStoreError("capture_failed", "native ignored metadata 能力在批次间变化");
|
|
825
|
+
}
|
|
826
|
+
return undefined;
|
|
785
827
|
}
|
|
786
|
-
result.
|
|
828
|
+
result.push(...inspected);
|
|
829
|
+
}
|
|
830
|
+
return result;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
private async collectIgnoredPresentKindsFallback(
|
|
834
|
+
cwd: string,
|
|
835
|
+
paths: readonly string[],
|
|
836
|
+
): Promise<readonly NativeMetadataEntry["kind"][]> {
|
|
837
|
+
const result: NativeMetadataEntry["kind"][] = [];
|
|
838
|
+
for (let offset = 0; offset < paths.length; offset += IGNORED_METADATA_BATCH_SIZE) {
|
|
839
|
+
const batch = paths.slice(offset, offset + IGNORED_METADATA_BATCH_SIZE);
|
|
840
|
+
await assertNoSymlinkParents(cwd, batch);
|
|
841
|
+
const kinds = await mapConcurrentOrdered(batch, FILE_SYSTEM_INSPECTION_CONCURRENCY, async (relativePath) => {
|
|
842
|
+
const metadata = await lstat(join(cwd, ...relativePath.split("/"))).catch((error) => {
|
|
843
|
+
if (hasErrorCode(error, "ENOENT")) return null;
|
|
844
|
+
throw error;
|
|
845
|
+
});
|
|
846
|
+
return metadata === null
|
|
847
|
+
? "absent" as const
|
|
848
|
+
: metadata.isFile() ? "file" as const
|
|
849
|
+
: metadata.isSymbolicLink() ? "symlink" as const
|
|
850
|
+
: "other" as const;
|
|
851
|
+
});
|
|
852
|
+
await assertNoSymlinkParents(cwd, batch);
|
|
853
|
+
result.push(...kinds);
|
|
787
854
|
}
|
|
788
|
-
return
|
|
855
|
+
return result;
|
|
789
856
|
}
|
|
790
857
|
|
|
791
858
|
private async stageWorktree(
|