@davideasden/pi-undo 0.1.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/LICENSE +21 -0
- package/README.md +84 -0
- package/extensions/pi-undo.ts +133 -0
- package/package.json +54 -0
- package/src/atomic-fs.ts +156 -0
- package/src/controller.ts +598 -0
- package/src/encoding.ts +620 -0
- package/src/git-runner.ts +308 -0
- package/src/journal.ts +297 -0
- package/src/model.ts +160 -0
- package/src/mutation-journal.ts +229 -0
- package/src/path-safety.ts +121 -0
- package/src/pi-runtime.ts +415 -0
- package/src/quarantine.ts +591 -0
- package/src/recovery.ts +143 -0
- package/src/restore-engine.ts +1184 -0
- package/src/root-discovery.ts +388 -0
- package/src/session-state.ts +448 -0
- package/src/snapshot-store.ts +1279 -0
- package/src/status-reporter.ts +80 -0
- package/src/workspace-lock.ts +407 -0
|
@@ -0,0 +1,1279 @@
|
|
|
1
|
+
import { lstat, mkdir, mkdtemp, readFile, readdir, readlink, realpath, rm, stat } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
|
+
|
|
5
|
+
import { fsyncDirectory, writeContentAddressed, writeJsonAtomic } from "./atomic-fs.ts";
|
|
6
|
+
import {
|
|
7
|
+
assertManifest,
|
|
8
|
+
canonicalJson,
|
|
9
|
+
checksum,
|
|
10
|
+
ignoredPresentClosure,
|
|
11
|
+
topologyFingerprint,
|
|
12
|
+
} from "./encoding.ts";
|
|
13
|
+
import { GitRunner, type GitRunOptions } from "./git-runner.ts";
|
|
14
|
+
import type {
|
|
15
|
+
DiscoveryRoot,
|
|
16
|
+
ManifestId,
|
|
17
|
+
RestorePath,
|
|
18
|
+
RootTopologyIdentity,
|
|
19
|
+
SnapshotManifest,
|
|
20
|
+
SnapshotRoot,
|
|
21
|
+
} from "./model.ts";
|
|
22
|
+
import { assertNoSymlinkEscape, relativeSafePath } from "./path-safety.ts";
|
|
23
|
+
import { RootDiscovery, type RootTopology } from "./root-discovery.ts";
|
|
24
|
+
import { WorkspaceLock } from "./workspace-lock.ts";
|
|
25
|
+
|
|
26
|
+
const SCHEMA_VERSION = 1;
|
|
27
|
+
const COMPLETE_COVERAGE = "complete";
|
|
28
|
+
const MANIFEST_SUFFIX = ".json";
|
|
29
|
+
const GC_METADATA_FILE = "gc.json";
|
|
30
|
+
const GC_RETENTION_MS = 7 * 24 * 60 * 60 * 1_000;
|
|
31
|
+
const IGNORE_POLICY = "git-check-ignore-v1";
|
|
32
|
+
const NULL_DEVICE = process.platform === "win32" ? "NUL" : "/dev/null";
|
|
33
|
+
|
|
34
|
+
interface PinRecord {
|
|
35
|
+
readonly schemaVersion: 1;
|
|
36
|
+
readonly manifestId: ManifestId;
|
|
37
|
+
readonly reasons: readonly string[];
|
|
38
|
+
readonly updatedAt: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface StoreGcRecord {
|
|
42
|
+
readonly schemaVersion: 1;
|
|
43
|
+
readonly lastUsedAt: number;
|
|
44
|
+
readonly cleanupPending?: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface CapturedTreeEntry {
|
|
48
|
+
readonly mode: number;
|
|
49
|
+
readonly objectId: string;
|
|
50
|
+
readonly size: number;
|
|
51
|
+
readonly relativePath: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface CapturedRootResult {
|
|
55
|
+
readonly treeId: string;
|
|
56
|
+
readonly coverage: string;
|
|
57
|
+
readonly ignorePolicy: string;
|
|
58
|
+
readonly ignoredPresentPaths: readonly string[];
|
|
59
|
+
readonly ignoreClosure: string;
|
|
60
|
+
readonly objectClosure: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface SnapshotStoreOptions {
|
|
64
|
+
readonly storeRoot?: string;
|
|
65
|
+
readonly git?: GitRunner;
|
|
66
|
+
readonly discovery?: RootDiscovery;
|
|
67
|
+
readonly lock?: WorkspaceLock;
|
|
68
|
+
readonly clock?: () => number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface CaptureOptions {
|
|
72
|
+
readonly excludePaths?: readonly string[];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export type SnapshotStoreErrorCode =
|
|
76
|
+
| "capture_failed"
|
|
77
|
+
| "invalid_manifest_id"
|
|
78
|
+
| "manifest_not_found"
|
|
79
|
+
| "manifest_invalid"
|
|
80
|
+
| "object_missing"
|
|
81
|
+
| "root_not_found"
|
|
82
|
+
| "invalid_pin";
|
|
83
|
+
|
|
84
|
+
export class SnapshotStoreError extends Error {
|
|
85
|
+
readonly code: SnapshotStoreErrorCode;
|
|
86
|
+
|
|
87
|
+
constructor(code: SnapshotStoreErrorCode, message: string, options?: ErrorOptions) {
|
|
88
|
+
super(message, options);
|
|
89
|
+
this.name = "SnapshotStoreError";
|
|
90
|
+
this.code = code;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface SnapshotStore {
|
|
95
|
+
capture(topology: RootTopology, scope?: readonly string[], options?: CaptureOptions): Promise<SnapshotManifest>;
|
|
96
|
+
loadManifest(id: ManifestId): Promise<SnapshotManifest>;
|
|
97
|
+
assertComplete(id: ManifestId): Promise<void>;
|
|
98
|
+
listTree(id: ManifestId, root: string): Promise<readonly RestorePath[]>;
|
|
99
|
+
readBlob(id: ManifestId, root: string, blobId: string): Promise<Uint8Array>;
|
|
100
|
+
pin(id: ManifestId, reason: string): Promise<void>;
|
|
101
|
+
unpin(id: ManifestId, reason: string): Promise<void>;
|
|
102
|
+
collectGarbage(): Promise<number>;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export class SnapshotStore {
|
|
106
|
+
private readonly storeRoot: string;
|
|
107
|
+
private readonly storesRoot: string;
|
|
108
|
+
private readonly git: GitRunner;
|
|
109
|
+
private readonly discovery: RootDiscovery;
|
|
110
|
+
private readonly lock: WorkspaceLock;
|
|
111
|
+
private readonly clock: () => number;
|
|
112
|
+
private readonly manifestLocations = new Map<string, string>();
|
|
113
|
+
|
|
114
|
+
constructor(options: SnapshotStoreOptions = {}) {
|
|
115
|
+
this.storeRoot = resolve(options.storeRoot ?? join(tmpdir(), "pi-undo-snapshot-store"));
|
|
116
|
+
this.storesRoot = join(this.storeRoot, "stores");
|
|
117
|
+
this.git = options.git ?? new GitRunner();
|
|
118
|
+
this.discovery = options.discovery ?? new RootDiscovery(this.git);
|
|
119
|
+
this.lock = options.lock ?? new WorkspaceLock();
|
|
120
|
+
this.clock = options.clock ?? Date.now;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async capture(
|
|
124
|
+
topology: RootTopology,
|
|
125
|
+
scope?: readonly string[],
|
|
126
|
+
options: CaptureOptions = {},
|
|
127
|
+
): Promise<SnapshotManifest> {
|
|
128
|
+
await this.assertPrivateStore(topology.workspaceIdentity);
|
|
129
|
+
const lockIdentity = `snapshot-store:${await prospectiveCanonicalPath(this.storesRoot)}`;
|
|
130
|
+
return this.lock.withLock(lockIdentity, () => this.captureLocked(topology, scope, options));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private async captureLocked(
|
|
134
|
+
topology: RootTopology,
|
|
135
|
+
scope: readonly string[] | undefined,
|
|
136
|
+
options: CaptureOptions,
|
|
137
|
+
): Promise<SnapshotManifest> {
|
|
138
|
+
let transactionDirectory: string | undefined;
|
|
139
|
+
try {
|
|
140
|
+
if (topology.fingerprint !== topologyFingerprint(topology.workspaceIdentity, topology.roots)) {
|
|
141
|
+
throw new SnapshotStoreError("capture_failed", "topology fingerprint 与 roots 不匹配");
|
|
142
|
+
}
|
|
143
|
+
const coverage = captureCoverage(topology.workspaceIdentity, scope);
|
|
144
|
+
const artifactExclusions = captureExclusions(topology.workspaceIdentity, options.excludePaths);
|
|
145
|
+
await this.assertTopology(topology, "捕获前 topology 已变化");
|
|
146
|
+
if (topology.roots.some((root) => root.state === "broken")) {
|
|
147
|
+
throw new SnapshotStoreError("capture_failed", "broken root 不能静默进入快照");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const storeDirectory = this.storeDirectory(topology);
|
|
151
|
+
const transactionsRoot = join(storeDirectory, "transactions");
|
|
152
|
+
await mkdir(transactionsRoot, { recursive: true });
|
|
153
|
+
transactionDirectory = await mkdtemp(join(transactionsRoot, "capture-"));
|
|
154
|
+
|
|
155
|
+
const roots: SnapshotRoot[] = [];
|
|
156
|
+
for (const root of topology.roots) {
|
|
157
|
+
if (root.state !== "active") {
|
|
158
|
+
const coverage = rootCaptureCoverage(root.relativeRoot, scope);
|
|
159
|
+
roots.push(snapshotRoot(root, {
|
|
160
|
+
treeId: null,
|
|
161
|
+
coverage,
|
|
162
|
+
...ignoredPresentProof(coverage, []),
|
|
163
|
+
objectClosure: inactiveRootClosure(root),
|
|
164
|
+
}));
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
const captured = await this.captureRoot(
|
|
168
|
+
topology,
|
|
169
|
+
root,
|
|
170
|
+
transactionDirectory,
|
|
171
|
+
scope,
|
|
172
|
+
artifactExclusions,
|
|
173
|
+
);
|
|
174
|
+
roots.push(snapshotRoot(root, captured));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
await this.assertTopology(topology, "捕获期间 topology 已变化");
|
|
178
|
+
const content = {
|
|
179
|
+
schemaVersion: SCHEMA_VERSION as 1,
|
|
180
|
+
workspaceIdentity: topology.workspaceIdentity,
|
|
181
|
+
topologyFingerprint: topology.fingerprint,
|
|
182
|
+
coverage,
|
|
183
|
+
roots,
|
|
184
|
+
createdAt: new Date(this.clock()).toISOString(),
|
|
185
|
+
};
|
|
186
|
+
const manifestId = checksum(canonicalJson(content)) as ManifestId;
|
|
187
|
+
const manifest: SnapshotManifest = { ...content, manifestId };
|
|
188
|
+
assertManifest(manifest);
|
|
189
|
+
|
|
190
|
+
const manifestPath = join(storeDirectory, "manifests", `${manifestId}${MANIFEST_SUFFIX}`);
|
|
191
|
+
await this.touchStore(storeDirectory);
|
|
192
|
+
await writeContentAddressed(manifestPath, Buffer.from(canonicalJson(manifest), "utf8"));
|
|
193
|
+
this.manifestLocations.set(manifestId, manifestPath);
|
|
194
|
+
return manifest;
|
|
195
|
+
} catch (error) {
|
|
196
|
+
if (error instanceof SnapshotStoreError) {
|
|
197
|
+
throw error;
|
|
198
|
+
}
|
|
199
|
+
throw new SnapshotStoreError("capture_failed", errorMessage(error), { cause: error });
|
|
200
|
+
} finally {
|
|
201
|
+
if (transactionDirectory !== undefined) {
|
|
202
|
+
await rm(transactionDirectory, { recursive: true, force: true }).catch(() => {});
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async loadManifest(id: ManifestId): Promise<SnapshotManifest> {
|
|
208
|
+
const manifestPath = await this.findManifestPath(id);
|
|
209
|
+
try {
|
|
210
|
+
const value: unknown = JSON.parse(await readFile(manifestPath, "utf8"));
|
|
211
|
+
const manifest = assertManifest(value);
|
|
212
|
+
if (manifest.manifestId !== id) {
|
|
213
|
+
throw new SnapshotStoreError("manifest_invalid", "manifest 文件名与内容 ID 不一致");
|
|
214
|
+
}
|
|
215
|
+
return manifest;
|
|
216
|
+
} catch (error) {
|
|
217
|
+
if (error instanceof SnapshotStoreError) {
|
|
218
|
+
throw error;
|
|
219
|
+
}
|
|
220
|
+
throw new SnapshotStoreError("manifest_invalid", "manifest 无法读取或校验", { cause: error });
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async assertComplete(id: ManifestId): Promise<void> {
|
|
225
|
+
const manifestPath = await this.findManifestPath(id);
|
|
226
|
+
const manifest = await this.loadManifest(id);
|
|
227
|
+
const storeDirectory = dirname(dirname(manifestPath));
|
|
228
|
+
try {
|
|
229
|
+
for (const root of manifest.roots) {
|
|
230
|
+
if (
|
|
231
|
+
root.ignorePolicy !== IGNORE_POLICY ||
|
|
232
|
+
root.ignoreClosure !== ignoredPresentClosure(root) ||
|
|
233
|
+
root.objectClosure === undefined
|
|
234
|
+
) {
|
|
235
|
+
throw new SnapshotStoreError("object_missing", "manifest root 元数据不受支持");
|
|
236
|
+
}
|
|
237
|
+
if (root.state !== "active" || root.treeId === null) {
|
|
238
|
+
if (root.objectClosure !== inactiveRootClosure(root)) {
|
|
239
|
+
throw new SnapshotStoreError("object_missing", "非活动 root 的对象闭包校验失败");
|
|
240
|
+
}
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
const gitDirectory = this.rootGitDirectory(storeDirectory, root);
|
|
244
|
+
await this.assertNoAlternates(gitDirectory);
|
|
245
|
+
await this.runPrivateGit(gitDirectory, ["cat-file", "-e", `${root.treeId}^{tree}`]);
|
|
246
|
+
const entries = await this.readTreeEntries(gitDirectory, root.treeId);
|
|
247
|
+
if (root.ignoredPresentPaths.some((ignoredPath) => entries.some(
|
|
248
|
+
(entry) => isPathAtOrBelow(ignoredPath, entry.relativePath) ||
|
|
249
|
+
isPathAtOrBelow(entry.relativePath, ignoredPath),
|
|
250
|
+
))) {
|
|
251
|
+
throw new SnapshotStoreError("object_missing", "ignored-present proof 与 root tree 冲突");
|
|
252
|
+
}
|
|
253
|
+
for (const entry of entries) {
|
|
254
|
+
await this.runPrivateGit(gitDirectory, ["cat-file", "-e", `${entry.objectId}^{blob}`]);
|
|
255
|
+
}
|
|
256
|
+
if (root.objectClosure !== treeObjectClosure(root.treeId, entries)) {
|
|
257
|
+
throw new SnapshotStoreError("object_missing", "root tree 对象闭包校验失败");
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
} catch (error) {
|
|
261
|
+
if (error instanceof SnapshotStoreError) {
|
|
262
|
+
throw error;
|
|
263
|
+
}
|
|
264
|
+
throw new SnapshotStoreError("object_missing", "manifest 引用的 Git 对象不完整", { cause: error });
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async listTree(id: ManifestId, rootPath: string): Promise<readonly RestorePath[]> {
|
|
269
|
+
relativeSafePath("/", rootPath);
|
|
270
|
+
const manifestPath = await this.findManifestPath(id);
|
|
271
|
+
const manifest = await this.loadManifest(id);
|
|
272
|
+
const root = manifest.roots.find((candidate) => candidate.relativeRoot === rootPath);
|
|
273
|
+
if (root === undefined) {
|
|
274
|
+
throw new SnapshotStoreError("root_not_found", "manifest 中不存在指定 root");
|
|
275
|
+
}
|
|
276
|
+
if (root.state !== "active" || root.treeId === null) {
|
|
277
|
+
return [];
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const storeDirectory = dirname(dirname(manifestPath));
|
|
281
|
+
const gitDirectory = this.rootGitDirectory(storeDirectory, root);
|
|
282
|
+
try {
|
|
283
|
+
const treeEntries = await this.readTreeEntries(gitDirectory, root.treeId);
|
|
284
|
+
const directories = new Set<string>();
|
|
285
|
+
for (const entry of treeEntries) {
|
|
286
|
+
const parts = entry.relativePath.split("/");
|
|
287
|
+
for (let index = 1; index < parts.length; index += 1) {
|
|
288
|
+
directories.add(parts.slice(0, index).join("/"));
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const result: RestorePath[] = [...directories].map((relativePath) => ({
|
|
293
|
+
relativePath,
|
|
294
|
+
kind: "directory",
|
|
295
|
+
mode: 0o755,
|
|
296
|
+
blobId: null,
|
|
297
|
+
size: 0,
|
|
298
|
+
rootHash: root.treeId as string,
|
|
299
|
+
}));
|
|
300
|
+
for (const entry of treeEntries) {
|
|
301
|
+
const symlink = entry.mode === 0o120000;
|
|
302
|
+
result.push({
|
|
303
|
+
relativePath: entry.relativePath,
|
|
304
|
+
kind: symlink ? "symlink" : "file",
|
|
305
|
+
mode: entry.mode,
|
|
306
|
+
blobId: entry.objectId,
|
|
307
|
+
size: entry.size,
|
|
308
|
+
rootHash: root.treeId,
|
|
309
|
+
...(symlink ? { linkText: await this.readBlobText(gitDirectory, entry.objectId) } : {}),
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
return result.sort((left, right) => comparePaths(left.relativePath, right.relativePath));
|
|
313
|
+
} catch (error) {
|
|
314
|
+
if (error instanceof SnapshotStoreError) {
|
|
315
|
+
throw error;
|
|
316
|
+
}
|
|
317
|
+
throw new SnapshotStoreError("object_missing", "root tree 无法读取", { cause: error });
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async readBlob(id: ManifestId, rootPath: string, blobId: string): Promise<Uint8Array> {
|
|
322
|
+
relativeSafePath("/", rootPath);
|
|
323
|
+
if (!isObjectId(blobId)) {
|
|
324
|
+
throw new SnapshotStoreError("object_missing", "blob ID 无效");
|
|
325
|
+
}
|
|
326
|
+
const manifestPath = await this.findManifestPath(id);
|
|
327
|
+
const manifest = await this.loadManifest(id);
|
|
328
|
+
const root = manifest.roots.find((candidate) => candidate.relativeRoot === rootPath);
|
|
329
|
+
if (root === undefined) {
|
|
330
|
+
throw new SnapshotStoreError("root_not_found", "manifest 中不存在指定 root");
|
|
331
|
+
}
|
|
332
|
+
if (root.state !== "active" || root.treeId === null) {
|
|
333
|
+
throw new SnapshotStoreError("object_missing", "指定 root 没有可读取的 tree");
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const storeDirectory = dirname(dirname(manifestPath));
|
|
337
|
+
const gitDirectory = this.rootGitDirectory(storeDirectory, root);
|
|
338
|
+
try {
|
|
339
|
+
const entries = await this.readTreeEntries(gitDirectory, root.treeId);
|
|
340
|
+
if (!entries.some((entry) => entry.objectId === blobId)) {
|
|
341
|
+
throw new SnapshotStoreError("object_missing", "blob 不属于指定 root tree");
|
|
342
|
+
}
|
|
343
|
+
return await this.readBlobBytes(gitDirectory, blobId);
|
|
344
|
+
} catch (error) {
|
|
345
|
+
if (error instanceof SnapshotStoreError) {
|
|
346
|
+
throw error;
|
|
347
|
+
}
|
|
348
|
+
throw new SnapshotStoreError("object_missing", "blob 无法读取", { cause: error });
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async pin(id: ManifestId, reason: string): Promise<void> {
|
|
353
|
+
const lockIdentity = `snapshot-store:${await prospectiveCanonicalPath(this.storesRoot)}`;
|
|
354
|
+
return this.lock.withLock(lockIdentity, () => this.pinLocked(id, reason));
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
private async pinLocked(id: ManifestId, reason: string): Promise<void> {
|
|
358
|
+
assertPinReason(reason);
|
|
359
|
+
const manifestPath = await this.findManifestPath(id);
|
|
360
|
+
await this.loadManifest(id);
|
|
361
|
+
const pinPath = join(dirname(dirname(manifestPath)), "pins", `${id}${MANIFEST_SUFFIX}`);
|
|
362
|
+
const current = await readPin(pinPath, id);
|
|
363
|
+
const reasons = [...new Set([...(current?.reasons ?? []), reason])].sort(comparePaths);
|
|
364
|
+
await writeJsonAtomic(pinPath, {
|
|
365
|
+
schemaVersion: SCHEMA_VERSION,
|
|
366
|
+
manifestId: id,
|
|
367
|
+
reasons,
|
|
368
|
+
updatedAt: new Date(this.clock()).toISOString(),
|
|
369
|
+
} satisfies PinRecord);
|
|
370
|
+
await this.touchStore(dirname(dirname(manifestPath)));
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async unpin(id: ManifestId, reason: string): Promise<void> {
|
|
374
|
+
const lockIdentity = `snapshot-store:${await prospectiveCanonicalPath(this.storesRoot)}`;
|
|
375
|
+
return this.lock.withLock(lockIdentity, () => this.unpinLocked(id, reason));
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
private async unpinLocked(id: ManifestId, reason: string): Promise<void> {
|
|
379
|
+
assertPinReason(reason);
|
|
380
|
+
const manifestPath = await this.findManifestPath(id);
|
|
381
|
+
const pinPath = join(dirname(dirname(manifestPath)), "pins", `${id}${MANIFEST_SUFFIX}`);
|
|
382
|
+
const current = await readPin(pinPath, id);
|
|
383
|
+
if (current === null) {
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
const reasons = current.reasons.filter((candidate) => candidate !== reason);
|
|
387
|
+
if (reasons.length === 0) {
|
|
388
|
+
await this.touchStore(dirname(dirname(manifestPath)));
|
|
389
|
+
await rm(pinPath, { force: true });
|
|
390
|
+
await fsyncDirectory(dirname(pinPath));
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
await writeJsonAtomic(pinPath, {
|
|
394
|
+
...current,
|
|
395
|
+
reasons,
|
|
396
|
+
updatedAt: new Date(this.clock()).toISOString(),
|
|
397
|
+
});
|
|
398
|
+
await this.touchStore(dirname(dirname(manifestPath)));
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
async collectGarbage(): Promise<number> {
|
|
402
|
+
const lockIdentity = `snapshot-store:${await prospectiveCanonicalPath(this.storesRoot)}`;
|
|
403
|
+
return this.lock.withLock(lockIdentity, () => this.collectGarbageLocked());
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
private async collectGarbageLocked(): Promise<number> {
|
|
407
|
+
let stores;
|
|
408
|
+
try {
|
|
409
|
+
stores = await readdir(this.storesRoot, { withFileTypes: true });
|
|
410
|
+
} catch (error) {
|
|
411
|
+
if (hasErrorCode(error, "ENOENT")) {
|
|
412
|
+
return 0;
|
|
413
|
+
}
|
|
414
|
+
throw error;
|
|
415
|
+
}
|
|
416
|
+
const cutoff = this.clock() - GC_RETENTION_MS;
|
|
417
|
+
let removed = 0;
|
|
418
|
+
for (const store of stores) {
|
|
419
|
+
if (!store.isDirectory() || store.isSymbolicLink()) {
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
const storeDirectory = join(this.storesRoot, store.name);
|
|
423
|
+
if (await hasPinnedManifest(storeDirectory)) {
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
const metadata = await readGcRecord(join(storeDirectory, GC_METADATA_FILE));
|
|
427
|
+
const lastUsedAt = metadata?.lastUsedAt ?? (await statMtime(storeDirectory));
|
|
428
|
+
if (lastUsedAt > cutoff) {
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
try {
|
|
432
|
+
await rm(storeDirectory, { recursive: true, force: true });
|
|
433
|
+
removed += 1;
|
|
434
|
+
for (const [id, path] of this.manifestLocations) {
|
|
435
|
+
if (path.startsWith(`${storeDirectory}${sep}`)) {
|
|
436
|
+
this.manifestLocations.delete(id);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
} catch (error) {
|
|
440
|
+
await writeJsonAtomic(join(storeDirectory, GC_METADATA_FILE), {
|
|
441
|
+
schemaVersion: SCHEMA_VERSION,
|
|
442
|
+
lastUsedAt,
|
|
443
|
+
cleanupPending: true,
|
|
444
|
+
} satisfies StoreGcRecord).catch(() => {});
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
return removed;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
private async captureRoot(
|
|
451
|
+
topology: RootTopology,
|
|
452
|
+
root: DiscoveryRoot,
|
|
453
|
+
transactionDirectory: string,
|
|
454
|
+
scope: readonly string[] | undefined,
|
|
455
|
+
artifactExclusions: readonly string[],
|
|
456
|
+
): Promise<CapturedRootResult> {
|
|
457
|
+
const storeDirectory = this.storeDirectory(topology);
|
|
458
|
+
const gitDirectory = this.rootGitDirectory(storeDirectory, root);
|
|
459
|
+
await this.ensurePrivateRepository(gitDirectory);
|
|
460
|
+
await this.assertNoAlternates(gitDirectory);
|
|
461
|
+
|
|
462
|
+
const absoluteRoot = workspaceRootPath(topology.workspaceIdentity, root.relativeRoot);
|
|
463
|
+
const indexPath = join(transactionDirectory, `${rootStoreId(root)}.index`);
|
|
464
|
+
const environment = privateGitEnvironment(gitDirectory, absoluteRoot, indexPath);
|
|
465
|
+
await this.runGit(["read-tree", "--empty"], { cwd: absoluteRoot, env: environment });
|
|
466
|
+
await this.validateIgnoreQuery(absoluteRoot, environment, root.gitBacked);
|
|
467
|
+
|
|
468
|
+
const requestedInclusions = rootScopePathspecs(root.relativeRoot, scope);
|
|
469
|
+
const exclusions = topology.roots
|
|
470
|
+
.filter((candidate) => isStrictRootAncestor(root.relativeRoot, candidate.relativeRoot))
|
|
471
|
+
.map((candidate) => rootRelativePath(root.relativeRoot, candidate.relativeRoot));
|
|
472
|
+
const exactExclusions = ownedArtifactExclusions(topology.roots, root.relativeRoot, artifactExclusions);
|
|
473
|
+
const inclusions = ownedRootInclusions(requestedInclusions, exclusions);
|
|
474
|
+
await this.stageWorktree(
|
|
475
|
+
absoluteRoot,
|
|
476
|
+
environment,
|
|
477
|
+
root.gitBacked,
|
|
478
|
+
inclusions,
|
|
479
|
+
exclusions,
|
|
480
|
+
exactExclusions,
|
|
481
|
+
);
|
|
482
|
+
const coverage = rootCoverageFromInclusions(inclusions);
|
|
483
|
+
const ignoredPresentPaths = await this.captureIgnoredPresentPaths(
|
|
484
|
+
absoluteRoot,
|
|
485
|
+
environment,
|
|
486
|
+
root.gitBacked,
|
|
487
|
+
inclusions,
|
|
488
|
+
exclusions,
|
|
489
|
+
exactExclusions,
|
|
490
|
+
);
|
|
491
|
+
const treeId = (await this.runGit(["write-tree"], { cwd: absoluteRoot, env: environment })).trim();
|
|
492
|
+
if (!isObjectId(treeId)) {
|
|
493
|
+
throw new SnapshotStoreError("capture_failed", "git write-tree 未返回有效对象 ID");
|
|
494
|
+
}
|
|
495
|
+
const entries = await this.readTreeEntries(gitDirectory, treeId);
|
|
496
|
+
await this.runPrivateGit(gitDirectory, ["cat-file", "-e", `${treeId}^{tree}`]);
|
|
497
|
+
for (const entry of entries) {
|
|
498
|
+
await this.runPrivateGit(gitDirectory, ["cat-file", "-e", `${entry.objectId}^{blob}`]);
|
|
499
|
+
}
|
|
500
|
+
return {
|
|
501
|
+
treeId,
|
|
502
|
+
coverage,
|
|
503
|
+
...ignoredPresentProof(coverage, ignoredPresentPaths),
|
|
504
|
+
objectClosure: treeObjectClosure(treeId, entries),
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
private async captureIgnoredPresentPaths(
|
|
509
|
+
cwd: string,
|
|
510
|
+
environment: Readonly<Record<string, string | undefined>>,
|
|
511
|
+
gitBacked: boolean,
|
|
512
|
+
inclusions: readonly string[] | null,
|
|
513
|
+
exclusions: readonly string[],
|
|
514
|
+
exactExclusions: readonly string[],
|
|
515
|
+
): Promise<string[]> {
|
|
516
|
+
if (inclusions === null) {
|
|
517
|
+
return [];
|
|
518
|
+
}
|
|
519
|
+
const pathspecs = inclusions.length === 0 ? ["."] : inclusions.map(literalPathspec);
|
|
520
|
+
for (const excluded of exclusions) {
|
|
521
|
+
pathspecs.push(excludeLiteralPathspec(excluded));
|
|
522
|
+
}
|
|
523
|
+
const output = await this.runGitBytes([
|
|
524
|
+
...(gitBacked ? ["-c", "core.fsmonitor=false"] : []),
|
|
525
|
+
"ls-files",
|
|
526
|
+
"--others",
|
|
527
|
+
"--ignored",
|
|
528
|
+
"--exclude-standard",
|
|
529
|
+
"-z",
|
|
530
|
+
"--",
|
|
531
|
+
...pathspecs,
|
|
532
|
+
], { cwd, env: gitBacked ? sourceGitEnvironment() : environment });
|
|
533
|
+
const result = new Set<string>();
|
|
534
|
+
for (const relativePath of parseNulPaths(output)) {
|
|
535
|
+
if (
|
|
536
|
+
exclusions.some((excluded) => isPathAtOrBelow(excluded, relativePath)) ||
|
|
537
|
+
exactExclusions.includes(relativePath)
|
|
538
|
+
) {
|
|
539
|
+
continue;
|
|
540
|
+
}
|
|
541
|
+
await assertNoSymlinkEscape(cwd, relativePath);
|
|
542
|
+
const metadata = await lstat(join(cwd, ...relativePath.split("/"))).catch((error) => {
|
|
543
|
+
if (hasErrorCode(error, "ENOENT")) return null;
|
|
544
|
+
throw error;
|
|
545
|
+
});
|
|
546
|
+
if (metadata === null) {
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
if (!metadata.isFile() && !metadata.isSymbolicLink()) {
|
|
550
|
+
throw new SnapshotStoreError("capture_failed", `ignored-present proof 只接受叶子路径:${relativePath}`);
|
|
551
|
+
}
|
|
552
|
+
if (result.has(relativePath)) {
|
|
553
|
+
throw new SnapshotStoreError("capture_failed", `ignored-present proof 包含重复路径:${relativePath}`);
|
|
554
|
+
}
|
|
555
|
+
result.add(relativePath);
|
|
556
|
+
}
|
|
557
|
+
return [...result].sort(comparePaths);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
private async stageWorktree(
|
|
561
|
+
cwd: string,
|
|
562
|
+
environment: Readonly<Record<string, string | undefined>>,
|
|
563
|
+
gitBacked: boolean,
|
|
564
|
+
inclusions: readonly string[] | null,
|
|
565
|
+
exclusions: readonly string[],
|
|
566
|
+
exactExclusions: readonly string[],
|
|
567
|
+
): Promise<void> {
|
|
568
|
+
if (inclusions === null) {
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
const pathspecs = inclusions.length === 0 ? ["."] : inclusions.map(literalPathspec);
|
|
572
|
+
for (const excluded of exclusions) {
|
|
573
|
+
pathspecs.push(excludeLiteralPathspec(excluded));
|
|
574
|
+
}
|
|
575
|
+
const queryEnvironment = gitBacked ? sourceGitEnvironment() : environment;
|
|
576
|
+
const output = await this.runGitBytes([
|
|
577
|
+
...(gitBacked ? ["-c", "core.fsmonitor=false"] : []),
|
|
578
|
+
"ls-files",
|
|
579
|
+
...(gitBacked ? ["--cached"] : []),
|
|
580
|
+
"--others",
|
|
581
|
+
"--exclude-standard",
|
|
582
|
+
"-z",
|
|
583
|
+
"--",
|
|
584
|
+
...pathspecs,
|
|
585
|
+
], { cwd, env: queryEnvironment });
|
|
586
|
+
for (const relativePath of parseNulPaths(output)) {
|
|
587
|
+
if (
|
|
588
|
+
exclusions.some((excluded) => isPathAtOrBelow(excluded, relativePath)) ||
|
|
589
|
+
exactExclusions.includes(relativePath)
|
|
590
|
+
) {
|
|
591
|
+
continue;
|
|
592
|
+
}
|
|
593
|
+
relativeSafePath(cwd, relativePath);
|
|
594
|
+
await assertNoSymlinkEscape(cwd, relativePath);
|
|
595
|
+
const absolutePath = join(cwd, ...relativePath.split("/"));
|
|
596
|
+
const metadata = await lstat(absolutePath).catch((error) => {
|
|
597
|
+
if (hasErrorCode(error, "ENOENT")) {
|
|
598
|
+
return null;
|
|
599
|
+
}
|
|
600
|
+
throw error;
|
|
601
|
+
});
|
|
602
|
+
if (metadata === null) {
|
|
603
|
+
continue;
|
|
604
|
+
}
|
|
605
|
+
let mode: string;
|
|
606
|
+
let objectId: string;
|
|
607
|
+
if (metadata.isSymbolicLink()) {
|
|
608
|
+
mode = "120000";
|
|
609
|
+
const linkText = await readlink(absolutePath, { encoding: "buffer" });
|
|
610
|
+
decodeUtf8(linkText, "symlink target 不是可无损表示的 UTF-8");
|
|
611
|
+
objectId = (await this.runGit(["hash-object", "-w", "--stdin"], {
|
|
612
|
+
cwd,
|
|
613
|
+
env: environment,
|
|
614
|
+
stdin: linkText,
|
|
615
|
+
})).trim();
|
|
616
|
+
} else if (metadata.isFile()) {
|
|
617
|
+
mode = (metadata.mode & 0o111) === 0 ? "100644" : "100755";
|
|
618
|
+
objectId = (await this.runGit(["hash-object", "-w", "--no-filters", "--", relativePath], {
|
|
619
|
+
cwd,
|
|
620
|
+
env: environment,
|
|
621
|
+
})).trim();
|
|
622
|
+
} else {
|
|
623
|
+
throw new SnapshotStoreError("capture_failed", `不支持的工作区文件类型:${relativePath}`);
|
|
624
|
+
}
|
|
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
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
private async validateIgnoreQuery(
|
|
636
|
+
cwd: string,
|
|
637
|
+
environment: Readonly<Record<string, string | undefined>>,
|
|
638
|
+
gitBacked: boolean,
|
|
639
|
+
): Promise<void> {
|
|
640
|
+
try {
|
|
641
|
+
await this.runGit([
|
|
642
|
+
...(gitBacked ? ["-c", "core.fsmonitor=false"] : []),
|
|
643
|
+
"check-ignore",
|
|
644
|
+
"--quiet",
|
|
645
|
+
"--no-index",
|
|
646
|
+
"--",
|
|
647
|
+
".gitignore",
|
|
648
|
+
], { cwd, env: gitBacked ? sourceGitEnvironment() : environment });
|
|
649
|
+
} catch (error) {
|
|
650
|
+
if (gitExitCode(error) !== 1) {
|
|
651
|
+
throw error;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
private async ensurePrivateRepository(gitDirectory: string): Promise<void> {
|
|
657
|
+
try {
|
|
658
|
+
const metadata = await lstat(join(gitDirectory, "objects"));
|
|
659
|
+
if (metadata.isDirectory()) {
|
|
660
|
+
await this.configurePrivateRepository(gitDirectory);
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
} catch (error) {
|
|
664
|
+
if (!hasErrorCode(error, "ENOENT")) {
|
|
665
|
+
throw error;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
await mkdir(dirname(gitDirectory), { recursive: true });
|
|
669
|
+
await this.runGit(["init", "--bare", "--quiet", gitDirectory], { env: cleanGitEnvironment() });
|
|
670
|
+
await this.configurePrivateRepository(gitDirectory);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
private async configurePrivateRepository(gitDirectory: string): Promise<void> {
|
|
674
|
+
const environment = cleanGitEnvironment();
|
|
675
|
+
await this.runGit(["--git-dir", gitDirectory, "config", "gc.auto", "0"], { env: environment });
|
|
676
|
+
await this.runGit(["--git-dir", gitDirectory, "config", "maintenance.auto", "false"], { env: environment });
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
private async assertNoAlternates(gitDirectory: string): Promise<void> {
|
|
680
|
+
try {
|
|
681
|
+
await lstat(join(gitDirectory, "objects", "info", "alternates"));
|
|
682
|
+
throw new SnapshotStoreError("object_missing", "私有 Git object database 不能使用 alternates");
|
|
683
|
+
} catch (error) {
|
|
684
|
+
if (!hasErrorCode(error, "ENOENT")) {
|
|
685
|
+
throw error;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
private async readTreeEntries(gitDirectory: string, treeId: string): Promise<CapturedTreeEntry[]> {
|
|
691
|
+
const output = await this.runPrivateGitBytes(gitDirectory, ["ls-tree", "-r", "-l", "-z", treeId]);
|
|
692
|
+
return parseTreeEntries(output);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
private async readBlobText(gitDirectory: string, objectId: string): Promise<string> {
|
|
696
|
+
return decodeUtf8(
|
|
697
|
+
await this.readBlobBytes(gitDirectory, objectId),
|
|
698
|
+
"symlink target 不是可无损表示的 UTF-8",
|
|
699
|
+
);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
private async readBlobBytes(gitDirectory: string, objectId: string): Promise<Uint8Array> {
|
|
703
|
+
return this.runPrivateGitBytes(gitDirectory, ["cat-file", "blob", objectId]);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
private runPrivateGit(gitDirectory: string, args: readonly string[]): Promise<string> {
|
|
707
|
+
return this.runGit(args, { env: privateObjectEnvironment(gitDirectory) });
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
private runPrivateGitBytes(gitDirectory: string, args: readonly string[]): Promise<Uint8Array> {
|
|
711
|
+
return this.runGitBytes(args, { env: privateObjectEnvironment(gitDirectory) });
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
private async runGit(args: readonly string[], options: GitRunOptions = {}): Promise<string> {
|
|
715
|
+
const result = await this.git.run(args, options);
|
|
716
|
+
if (result.killed) {
|
|
717
|
+
throw new SnapshotStoreError("capture_failed", "Git 命令未正常结束");
|
|
718
|
+
}
|
|
719
|
+
return result.stdout;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
private async runGitBytes(args: readonly string[], options: GitRunOptions = {}): Promise<Uint8Array> {
|
|
723
|
+
const result = await this.git.run(args, options);
|
|
724
|
+
if (result.killed) {
|
|
725
|
+
throw new SnapshotStoreError("capture_failed", "Git 命令未正常结束");
|
|
726
|
+
}
|
|
727
|
+
return result.stdoutBytes;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
private async assertTopology(expected: RootTopology, message: string): Promise<void> {
|
|
731
|
+
const actual = await this.discovery.discover(expected.workspaceIdentity);
|
|
732
|
+
const rootKindsMatch = actual.roots.length === expected.roots.length && actual.roots.every((root, index) => {
|
|
733
|
+
const expectedRoot = expected.roots[index];
|
|
734
|
+
return expectedRoot !== undefined &&
|
|
735
|
+
root.relativeRoot === expectedRoot.relativeRoot &&
|
|
736
|
+
root.gitBacked === expectedRoot.gitBacked;
|
|
737
|
+
});
|
|
738
|
+
if (
|
|
739
|
+
actual.workspaceIdentity !== expected.workspaceIdentity ||
|
|
740
|
+
actual.fingerprint !== expected.fingerprint ||
|
|
741
|
+
!rootKindsMatch
|
|
742
|
+
) {
|
|
743
|
+
throw new SnapshotStoreError("capture_failed", message);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
private async assertPrivateStore(workspaceIdentity: string): Promise<void> {
|
|
748
|
+
const targets = [this.storeRoot, this.storesRoot];
|
|
749
|
+
if (targets.some((target) => isWithin(workspaceIdentity, target))) {
|
|
750
|
+
throw new SnapshotStoreError("capture_failed", "私有 store 不能位于 workspace 内");
|
|
751
|
+
}
|
|
752
|
+
const prospectiveTargets = await Promise.all(targets.map(prospectiveCanonicalPath));
|
|
753
|
+
if (prospectiveTargets.some((target) => isWithin(workspaceIdentity, target))) {
|
|
754
|
+
throw new SnapshotStoreError("capture_failed", "私有 store 不能位于 workspace 内");
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
await mkdir(this.storesRoot, { recursive: true });
|
|
758
|
+
const canonicalTargets = await Promise.all(targets.map((target) => realpath(target)));
|
|
759
|
+
if (canonicalTargets.some((target) => isWithin(workspaceIdentity, target))) {
|
|
760
|
+
throw new SnapshotStoreError("capture_failed", "私有 store 不能位于 workspace 内");
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
private storeDirectory(topology: RootTopology): string {
|
|
765
|
+
const outer = topology.roots.find((root) => root.relativeRoot === ".");
|
|
766
|
+
if (outer === undefined) {
|
|
767
|
+
throw new SnapshotStoreError("capture_failed", "topology 缺少 workspace root");
|
|
768
|
+
}
|
|
769
|
+
const storeId = checksum(canonicalJson({
|
|
770
|
+
schemaVersion: SCHEMA_VERSION,
|
|
771
|
+
workspaceIdentity: topology.workspaceIdentity,
|
|
772
|
+
sourceIdentity: outer.sourceIdentity,
|
|
773
|
+
}));
|
|
774
|
+
return join(this.storesRoot, storeId);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
private rootGitDirectory(storeDirectory: string, root: RootTopologyIdentity): string {
|
|
778
|
+
return join(storeDirectory, "roots", rootStoreId(root), "git");
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
private async findManifestPath(id: ManifestId): Promise<string> {
|
|
782
|
+
assertManifestId(id);
|
|
783
|
+
const known = this.manifestLocations.get(id);
|
|
784
|
+
if (known !== undefined) {
|
|
785
|
+
return known;
|
|
786
|
+
}
|
|
787
|
+
let stores;
|
|
788
|
+
try {
|
|
789
|
+
stores = await readdir(this.storesRoot, { withFileTypes: true });
|
|
790
|
+
} catch (error) {
|
|
791
|
+
if (hasErrorCode(error, "ENOENT")) {
|
|
792
|
+
throw new SnapshotStoreError("manifest_not_found", "manifest 不存在");
|
|
793
|
+
}
|
|
794
|
+
throw error;
|
|
795
|
+
}
|
|
796
|
+
for (const store of stores) {
|
|
797
|
+
if (!store.isDirectory() || store.isSymbolicLink()) {
|
|
798
|
+
continue;
|
|
799
|
+
}
|
|
800
|
+
const candidate = join(this.storesRoot, store.name, "manifests", `${id}${MANIFEST_SUFFIX}`);
|
|
801
|
+
try {
|
|
802
|
+
const metadata = await lstat(candidate);
|
|
803
|
+
if (metadata.isFile() && !metadata.isSymbolicLink()) {
|
|
804
|
+
this.manifestLocations.set(id, candidate);
|
|
805
|
+
return candidate;
|
|
806
|
+
}
|
|
807
|
+
} catch (error) {
|
|
808
|
+
if (!hasErrorCode(error, "ENOENT")) {
|
|
809
|
+
throw error;
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
throw new SnapshotStoreError("manifest_not_found", "manifest 不存在");
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
private async touchStore(storeDirectory: string): Promise<void> {
|
|
817
|
+
await writeJsonAtomic(join(storeDirectory, GC_METADATA_FILE), {
|
|
818
|
+
schemaVersion: SCHEMA_VERSION,
|
|
819
|
+
lastUsedAt: this.clock(),
|
|
820
|
+
} satisfies StoreGcRecord);
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
function captureCoverage(workspaceIdentity: string, scope: readonly string[] | undefined): string {
|
|
825
|
+
if (scope === undefined || scope.length === 0) {
|
|
826
|
+
return COMPLETE_COVERAGE;
|
|
827
|
+
}
|
|
828
|
+
const paths = [...new Set(scope.map((path) => relativeSafePath(workspaceIdentity, path)))].sort(comparePaths);
|
|
829
|
+
if (paths.includes(".")) {
|
|
830
|
+
return COMPLETE_COVERAGE;
|
|
831
|
+
}
|
|
832
|
+
return `paths:${checksum(canonicalJson(paths))}`;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
function captureExclusions(
|
|
836
|
+
workspaceIdentity: string,
|
|
837
|
+
excludePaths: readonly string[] | undefined,
|
|
838
|
+
): string[] {
|
|
839
|
+
if (excludePaths === undefined) {
|
|
840
|
+
return [];
|
|
841
|
+
}
|
|
842
|
+
const result = new Set<string>();
|
|
843
|
+
for (const path of excludePaths) {
|
|
844
|
+
const safe = relativeSafePath(workspaceIdentity, path);
|
|
845
|
+
if (safe === "." || safe.split("/").some((part) => part.toLowerCase() === ".git")) {
|
|
846
|
+
throw new SnapshotStoreError("capture_failed", `artifact exclusion 路径无效:${path}`);
|
|
847
|
+
}
|
|
848
|
+
result.add(safe);
|
|
849
|
+
}
|
|
850
|
+
return [...result].sort(comparePaths);
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
function ownedArtifactExclusions(
|
|
854
|
+
roots: readonly DiscoveryRoot[],
|
|
855
|
+
rootPath: string,
|
|
856
|
+
exclusions: readonly string[],
|
|
857
|
+
): string[] {
|
|
858
|
+
const result: string[] = [];
|
|
859
|
+
for (const exclusion of exclusions) {
|
|
860
|
+
const owner = roots
|
|
861
|
+
.filter((candidate) => (
|
|
862
|
+
candidate.relativeRoot === "." ||
|
|
863
|
+
exclusion === candidate.relativeRoot ||
|
|
864
|
+
isStrictRootAncestor(candidate.relativeRoot, exclusion)
|
|
865
|
+
))
|
|
866
|
+
.sort((left, right) => right.relativeRoot.length - left.relativeRoot.length)[0];
|
|
867
|
+
if (owner?.relativeRoot !== rootPath || exclusion === rootPath) {
|
|
868
|
+
continue;
|
|
869
|
+
}
|
|
870
|
+
result.push(rootRelativePath(rootPath, exclusion));
|
|
871
|
+
}
|
|
872
|
+
return result;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
function rootScopePathspecs(rootPath: string, scope: readonly string[] | undefined): string[] | null {
|
|
876
|
+
if (scope === undefined || scope.length === 0) {
|
|
877
|
+
return [];
|
|
878
|
+
}
|
|
879
|
+
const result = new Set<string>();
|
|
880
|
+
for (const path of scope) {
|
|
881
|
+
if (path === "." || path === rootPath || isStrictRootAncestor(path, rootPath)) {
|
|
882
|
+
return [];
|
|
883
|
+
}
|
|
884
|
+
if (isStrictRootAncestor(rootPath, path)) {
|
|
885
|
+
result.add(rootRelativePath(rootPath, path));
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
return result.size === 0 ? null : [...result].sort(comparePaths);
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
function ownedRootInclusions(
|
|
892
|
+
inclusions: readonly string[] | null,
|
|
893
|
+
exclusions: readonly string[],
|
|
894
|
+
): string[] | null {
|
|
895
|
+
if (inclusions === null || inclusions.length === 0) {
|
|
896
|
+
return inclusions === null ? null : [];
|
|
897
|
+
}
|
|
898
|
+
const owned = inclusions.filter(
|
|
899
|
+
(path) => !exclusions.some((excluded) => isPathAtOrBelow(excluded, path)),
|
|
900
|
+
);
|
|
901
|
+
return owned.length === 0 ? null : owned;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
function rootCaptureCoverage(rootPath: string, scope: readonly string[] | undefined): string {
|
|
905
|
+
return rootCoverageFromInclusions(rootScopePathspecs(rootPath, scope));
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
function rootCoverageFromInclusions(inclusions: readonly string[] | null): string {
|
|
909
|
+
if (inclusions === null) {
|
|
910
|
+
return "none";
|
|
911
|
+
}
|
|
912
|
+
if (inclusions.length === 0) {
|
|
913
|
+
return COMPLETE_COVERAGE;
|
|
914
|
+
}
|
|
915
|
+
return `paths:${checksum(canonicalJson([...inclusions].sort(comparePaths)))}`;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
function treeObjectClosure(treeId: string, entries: readonly CapturedTreeEntry[]): string {
|
|
919
|
+
return checksum(canonicalJson({
|
|
920
|
+
treeId,
|
|
921
|
+
entries: entries.map((entry) => ({
|
|
922
|
+
mode: entry.mode,
|
|
923
|
+
objectId: entry.objectId,
|
|
924
|
+
relativePath: entry.relativePath,
|
|
925
|
+
size: entry.size,
|
|
926
|
+
})),
|
|
927
|
+
}));
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
function snapshotRoot(
|
|
931
|
+
root: DiscoveryRoot,
|
|
932
|
+
capture: {
|
|
933
|
+
readonly treeId: string | null;
|
|
934
|
+
readonly coverage: string;
|
|
935
|
+
readonly ignorePolicy: string;
|
|
936
|
+
readonly ignoredPresentPaths: readonly string[];
|
|
937
|
+
readonly ignoreClosure: string;
|
|
938
|
+
readonly objectClosure: string;
|
|
939
|
+
},
|
|
940
|
+
): SnapshotRoot {
|
|
941
|
+
return {
|
|
942
|
+
relativeRoot: root.relativeRoot,
|
|
943
|
+
parentRoot: root.parentRoot,
|
|
944
|
+
state: root.state,
|
|
945
|
+
sourceIdentity: root.sourceIdentity,
|
|
946
|
+
privateRepositoryId: root.privateRepositoryId,
|
|
947
|
+
treeId: capture.treeId,
|
|
948
|
+
coverage: capture.coverage,
|
|
949
|
+
ignorePolicy: capture.ignorePolicy,
|
|
950
|
+
ignoredPresentPaths: capture.ignoredPresentPaths,
|
|
951
|
+
ignoreClosure: capture.ignoreClosure,
|
|
952
|
+
objectClosure: capture.objectClosure,
|
|
953
|
+
...(root.gitlinkOid === undefined ? {} : { gitlinkOid: root.gitlinkOid }),
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
function ignoredPresentProof(coverage: string, ignoredPresentPaths: readonly string[]) {
|
|
958
|
+
const proof = {
|
|
959
|
+
coverage,
|
|
960
|
+
ignorePolicy: IGNORE_POLICY,
|
|
961
|
+
ignoredPresentPaths,
|
|
962
|
+
};
|
|
963
|
+
return {
|
|
964
|
+
ignorePolicy: proof.ignorePolicy,
|
|
965
|
+
ignoredPresentPaths,
|
|
966
|
+
ignoreClosure: ignoredPresentClosure(proof),
|
|
967
|
+
};
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
function inactiveRootClosure(root: Pick<RootTopologyIdentity, "relativeRoot" | "state">): string {
|
|
971
|
+
return checksum(canonicalJson({
|
|
972
|
+
relativeRoot: root.relativeRoot,
|
|
973
|
+
state: root.state,
|
|
974
|
+
treeId: null,
|
|
975
|
+
}));
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
function workspaceRootPath(workspaceIdentity: string, rootPath: string): string {
|
|
979
|
+
const safe = relativeSafePath(workspaceIdentity, rootPath);
|
|
980
|
+
return safe === "." ? workspaceIdentity : join(workspaceIdentity, ...safe.split("/"));
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
function rootStoreId(root: RootTopologyIdentity): string {
|
|
984
|
+
return checksum(canonicalJson({
|
|
985
|
+
relativeRoot: root.relativeRoot,
|
|
986
|
+
sourceIdentity: root.sourceIdentity,
|
|
987
|
+
privateRepositoryId: root.privateRepositoryId,
|
|
988
|
+
}));
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
function privateGitEnvironment(
|
|
992
|
+
gitDirectory: string,
|
|
993
|
+
workTree: string,
|
|
994
|
+
indexFile: string,
|
|
995
|
+
): Readonly<Record<string, string | undefined>> {
|
|
996
|
+
return {
|
|
997
|
+
...privateObjectEnvironment(gitDirectory),
|
|
998
|
+
GIT_WORK_TREE: workTree,
|
|
999
|
+
GIT_INDEX_FILE: indexFile,
|
|
1000
|
+
GIT_COMMON_DIR: undefined,
|
|
1001
|
+
GIT_OPTIONAL_LOCKS: "0",
|
|
1002
|
+
};
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
function privateObjectEnvironment(gitDirectory: string): Readonly<Record<string, string | undefined>> {
|
|
1006
|
+
return {
|
|
1007
|
+
GIT_DIR: gitDirectory,
|
|
1008
|
+
GIT_WORK_TREE: undefined,
|
|
1009
|
+
GIT_INDEX_FILE: undefined,
|
|
1010
|
+
GIT_COMMON_DIR: undefined,
|
|
1011
|
+
GIT_OBJECT_DIRECTORY: undefined,
|
|
1012
|
+
GIT_ALTERNATE_OBJECT_DIRECTORIES: undefined,
|
|
1013
|
+
GIT_NAMESPACE: undefined,
|
|
1014
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
1015
|
+
...isolatedGitConfiguration(),
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
function cleanGitEnvironment(): Readonly<Record<string, string | undefined>> {
|
|
1020
|
+
return {
|
|
1021
|
+
GIT_DIR: undefined,
|
|
1022
|
+
GIT_WORK_TREE: undefined,
|
|
1023
|
+
GIT_INDEX_FILE: undefined,
|
|
1024
|
+
GIT_COMMON_DIR: undefined,
|
|
1025
|
+
GIT_OBJECT_DIRECTORY: undefined,
|
|
1026
|
+
GIT_ALTERNATE_OBJECT_DIRECTORIES: undefined,
|
|
1027
|
+
GIT_NAMESPACE: undefined,
|
|
1028
|
+
GIT_OPTIONAL_LOCKS: "0",
|
|
1029
|
+
...isolatedGitConfiguration(),
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
function sourceGitEnvironment(): Readonly<Record<string, string | undefined>> {
|
|
1034
|
+
return {
|
|
1035
|
+
GIT_DIR: undefined,
|
|
1036
|
+
GIT_WORK_TREE: undefined,
|
|
1037
|
+
GIT_INDEX_FILE: undefined,
|
|
1038
|
+
GIT_COMMON_DIR: undefined,
|
|
1039
|
+
GIT_OBJECT_DIRECTORY: undefined,
|
|
1040
|
+
GIT_ALTERNATE_OBJECT_DIRECTORIES: undefined,
|
|
1041
|
+
GIT_NAMESPACE: undefined,
|
|
1042
|
+
GIT_OPTIONAL_LOCKS: "0",
|
|
1043
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
1044
|
+
GIT_CONFIG_COUNT: undefined,
|
|
1045
|
+
GIT_CONFIG_PARAMETERS: undefined,
|
|
1046
|
+
GIT_CONFIG_SYSTEM: undefined,
|
|
1047
|
+
GIT_CONFIG_GLOBAL: undefined,
|
|
1048
|
+
GIT_CONFIG_NOSYSTEM: undefined,
|
|
1049
|
+
GIT_ATTR_NOSYSTEM: undefined,
|
|
1050
|
+
};
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
function isolatedGitConfiguration(): Readonly<Record<string, string | undefined>> {
|
|
1054
|
+
return {
|
|
1055
|
+
GIT_CONFIG_COUNT: undefined,
|
|
1056
|
+
GIT_CONFIG_PARAMETERS: undefined,
|
|
1057
|
+
GIT_CONFIG_SYSTEM: undefined,
|
|
1058
|
+
GIT_CONFIG_GLOBAL: NULL_DEVICE,
|
|
1059
|
+
GIT_CONFIG_NOSYSTEM: "1",
|
|
1060
|
+
GIT_ATTR_NOSYSTEM: "1",
|
|
1061
|
+
};
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
function parseTreeEntries(output: Uint8Array): CapturedTreeEntry[] {
|
|
1065
|
+
const entries: CapturedTreeEntry[] = [];
|
|
1066
|
+
for (const record of splitNulRecords(output)) {
|
|
1067
|
+
const tab = record.indexOf(0x09);
|
|
1068
|
+
if (tab < 0) {
|
|
1069
|
+
throw new SnapshotStoreError("object_missing", "git ls-tree 输出格式无效");
|
|
1070
|
+
}
|
|
1071
|
+
const [modeText, type, objectId, sizeText] = decodeUtf8(record.subarray(0, tab)).split(/\s+/);
|
|
1072
|
+
const mode = Number.parseInt(modeText, 8);
|
|
1073
|
+
const size = Number.parseInt(sizeText, 10);
|
|
1074
|
+
if (type !== "blob" || !Number.isInteger(mode) || !isObjectId(objectId) || !Number.isInteger(size) || size < 0) {
|
|
1075
|
+
throw new SnapshotStoreError("object_missing", "root tree 包含不支持或损坏的对象");
|
|
1076
|
+
}
|
|
1077
|
+
const relativePath = decodeUtf8(record.subarray(tab + 1));
|
|
1078
|
+
relativeSafePath("/", relativePath);
|
|
1079
|
+
entries.push({ mode, objectId, size, relativePath });
|
|
1080
|
+
}
|
|
1081
|
+
return entries.sort((left, right) => comparePaths(left.relativePath, right.relativePath));
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
function parseNulPaths(output: Uint8Array): string[] {
|
|
1085
|
+
return splitNulRecords(output).map((record) => {
|
|
1086
|
+
const path = decodeUtf8(record);
|
|
1087
|
+
relativeSafePath("/", path);
|
|
1088
|
+
return path;
|
|
1089
|
+
});
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
function splitNulRecords(output: Uint8Array): Uint8Array[] {
|
|
1093
|
+
if (output.length === 0) {
|
|
1094
|
+
return [];
|
|
1095
|
+
}
|
|
1096
|
+
const records: Uint8Array[] = [];
|
|
1097
|
+
let start = 0;
|
|
1098
|
+
for (let index = 0; index < output.length; index += 1) {
|
|
1099
|
+
if (output[index] !== 0) {
|
|
1100
|
+
continue;
|
|
1101
|
+
}
|
|
1102
|
+
if (index === start) {
|
|
1103
|
+
throw new SnapshotStoreError("capture_failed", "Git NUL 路径输出包含空记录");
|
|
1104
|
+
}
|
|
1105
|
+
records.push(output.slice(start, index));
|
|
1106
|
+
start = index + 1;
|
|
1107
|
+
}
|
|
1108
|
+
if (start !== output.length) {
|
|
1109
|
+
throw new SnapshotStoreError("capture_failed", "Git NUL 路径输出不完整");
|
|
1110
|
+
}
|
|
1111
|
+
return records;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
function decodeUtf8(bytes: Uint8Array, message = "工作区路径不是可无损表示的 UTF-8"): string {
|
|
1115
|
+
const buffer = Buffer.from(bytes);
|
|
1116
|
+
const value = buffer.toString("utf8");
|
|
1117
|
+
if (!Buffer.from(value, "utf8").equals(buffer)) {
|
|
1118
|
+
throw new SnapshotStoreError("capture_failed", message);
|
|
1119
|
+
}
|
|
1120
|
+
return value;
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
async function readPin(path: string, expectedManifestId: ManifestId): Promise<PinRecord | null> {
|
|
1124
|
+
try {
|
|
1125
|
+
const value: unknown = JSON.parse(await readFile(path, "utf8"));
|
|
1126
|
+
if (!isPinRecord(value) || value.manifestId !== expectedManifestId) {
|
|
1127
|
+
throw new SnapshotStoreError("invalid_pin", "pin 记录无效");
|
|
1128
|
+
}
|
|
1129
|
+
return value;
|
|
1130
|
+
} catch (error) {
|
|
1131
|
+
if (hasErrorCode(error, "ENOENT")) {
|
|
1132
|
+
return null;
|
|
1133
|
+
}
|
|
1134
|
+
throw error;
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
async function hasPinnedManifest(storeDirectory: string): Promise<boolean> {
|
|
1139
|
+
const pinsDirectory = join(storeDirectory, "pins");
|
|
1140
|
+
let entries;
|
|
1141
|
+
try {
|
|
1142
|
+
entries = await readdir(pinsDirectory, { withFileTypes: true });
|
|
1143
|
+
} catch (error) {
|
|
1144
|
+
return hasErrorCode(error, "ENOENT") ? false : true;
|
|
1145
|
+
}
|
|
1146
|
+
for (const entry of entries) {
|
|
1147
|
+
if (!entry.isFile() || entry.isSymbolicLink() || !entry.name.endsWith(MANIFEST_SUFFIX)) {
|
|
1148
|
+
continue;
|
|
1149
|
+
}
|
|
1150
|
+
try {
|
|
1151
|
+
const value: unknown = JSON.parse(await readFile(join(pinsDirectory, entry.name), "utf8"));
|
|
1152
|
+
if (!isPinRecord(value) || value.reasons.length > 0) {
|
|
1153
|
+
return true;
|
|
1154
|
+
}
|
|
1155
|
+
} catch {
|
|
1156
|
+
return true;
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
return false;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
async function readGcRecord(path: string): Promise<StoreGcRecord | null> {
|
|
1163
|
+
try {
|
|
1164
|
+
const value: unknown = JSON.parse(await readFile(path, "utf8"));
|
|
1165
|
+
if (
|
|
1166
|
+
typeof value === "object" &&
|
|
1167
|
+
value !== null &&
|
|
1168
|
+
!Array.isArray(value) &&
|
|
1169
|
+
(value as Record<string, unknown>).schemaVersion === SCHEMA_VERSION &&
|
|
1170
|
+
typeof (value as Record<string, unknown>).lastUsedAt === "number"
|
|
1171
|
+
) {
|
|
1172
|
+
return value as StoreGcRecord;
|
|
1173
|
+
}
|
|
1174
|
+
return null;
|
|
1175
|
+
} catch {
|
|
1176
|
+
return null;
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
async function statMtime(path: string): Promise<number> {
|
|
1181
|
+
return (await stat(path).catch(() => null))?.mtimeMs ?? 0;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
function isPinRecord(value: unknown): value is PinRecord {
|
|
1185
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
1186
|
+
return false;
|
|
1187
|
+
}
|
|
1188
|
+
const record = value as Record<string, unknown>;
|
|
1189
|
+
return (
|
|
1190
|
+
record.schemaVersion === SCHEMA_VERSION &&
|
|
1191
|
+
typeof record.manifestId === "string" &&
|
|
1192
|
+
Array.isArray(record.reasons) &&
|
|
1193
|
+
record.reasons.every((reason) => typeof reason === "string" && reason.length > 0) &&
|
|
1194
|
+
typeof record.updatedAt === "string"
|
|
1195
|
+
);
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
function assertManifestId(id: ManifestId): void {
|
|
1199
|
+
if (typeof id !== "string" || !/^[0-9a-f]{64}$/.test(id)) {
|
|
1200
|
+
throw new SnapshotStoreError("invalid_manifest_id", "manifest ID 必须是 SHA-256");
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
function assertPinReason(reason: string): void {
|
|
1205
|
+
if (typeof reason !== "string" || reason.trim().length === 0 || reason.includes("\0")) {
|
|
1206
|
+
throw new SnapshotStoreError("invalid_pin", "pin reason 不能为空");
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
function rootRelativePath(parent: string, child: string): string {
|
|
1211
|
+
return parent === "." ? child : child.slice(parent.length + 1);
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
function literalPathspec(path: string): string {
|
|
1215
|
+
return `:(top,literal)${path}`;
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
function excludeLiteralPathspec(path: string): string {
|
|
1219
|
+
return `:(top,exclude,literal)${path}`;
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
function isPathAtOrBelow(parent: string, candidate: string): boolean {
|
|
1223
|
+
return candidate === parent || candidate.startsWith(`${parent}/`);
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
function isStrictRootAncestor(parent: string, child: string): boolean {
|
|
1227
|
+
return parent === "." ? child !== "." : child.startsWith(`${parent}/`);
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
function isWithin(parent: string, candidate: string): boolean {
|
|
1231
|
+
const value = relative(parent, candidate);
|
|
1232
|
+
return value.length === 0 || (!value.startsWith(`..${sep}`) && value !== ".." && !isAbsolute(value));
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
async function prospectiveCanonicalPath(path: string): Promise<string> {
|
|
1236
|
+
const target = resolve(path);
|
|
1237
|
+
let ancestor = target;
|
|
1238
|
+
while (true) {
|
|
1239
|
+
try {
|
|
1240
|
+
const canonicalAncestor = await realpath(ancestor);
|
|
1241
|
+
return resolve(canonicalAncestor, relative(ancestor, target));
|
|
1242
|
+
} catch (error) {
|
|
1243
|
+
if (!hasErrorCode(error, "ENOENT")) {
|
|
1244
|
+
throw error;
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
const parent = dirname(ancestor);
|
|
1248
|
+
if (parent === ancestor) {
|
|
1249
|
+
throw new SnapshotStoreError("capture_failed", "无法解析私有 store 路径");
|
|
1250
|
+
}
|
|
1251
|
+
ancestor = parent;
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
function isObjectId(value: string | undefined): value is string {
|
|
1256
|
+
return typeof value === "string" && /^[0-9a-f]{40,64}$/.test(value);
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
function gitExitCode(error: unknown): number | null | undefined {
|
|
1260
|
+
if (typeof error !== "object" || error === null || !("result" in error)) {
|
|
1261
|
+
return undefined;
|
|
1262
|
+
}
|
|
1263
|
+
const result = error.result;
|
|
1264
|
+
return typeof result === "object" && result !== null && "code" in result && typeof result.code === "number"
|
|
1265
|
+
? result.code
|
|
1266
|
+
: undefined;
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
function comparePaths(left: string, right: string): number {
|
|
1270
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
function errorMessage(error: unknown): string {
|
|
1274
|
+
return error instanceof Error ? error.message : String(error);
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
function hasErrorCode(error: unknown, code: string): error is NodeJS.ErrnoException {
|
|
1278
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === code;
|
|
1279
|
+
}
|