@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.
- package/README.md +170 -39
- package/extensions/pi-undo.ts +219 -14
- package/package.json +3 -2
- package/src/controller.ts +14 -3
- package/src/diff-ui.ts +116 -0
- package/src/diff-view.ts +169 -0
- package/src/pi-runtime.ts +1 -0
- package/src/restore-engine.ts +57 -9
- package/src/root-discovery.ts +28 -16
- package/src/snapshot-store.ts +291 -48
package/src/snapshot-store.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
-
|
|
569
|
-
|
|
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
|
|
572
|
-
|
|
573
|
-
|
|
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
|
|
596
|
-
|
|
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
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
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
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
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
|
|
692
|
-
|
|
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)) {
|