@davideasden/pi-undo 0.2.9 → 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/native/bin/pi-undo-fs-darwin-arm64 +0 -0
- package/package.json +1 -1
- package/src/encoding.ts +30 -0
- package/src/native-metadata.ts +231 -0
- package/src/native-restore.ts +1 -1
- package/src/path-safety.ts +24 -0
- package/src/pi-runtime.ts +3 -1
- package/src/snapshot-store.ts +306 -74
|
Binary file
|
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";
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { constants } from "node:fs";
|
|
4
|
+
import { access, rm, writeFile } from "node:fs/promises";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
|
|
7
|
+
import { nativeExecutable } from "./native-restore.ts";
|
|
8
|
+
|
|
9
|
+
const NATIVE_INSPECT_TIMEOUT_MS = 30_000;
|
|
10
|
+
const NATIVE_INSPECT_OUTPUT_LIMIT = 32 * 1024 * 1024;
|
|
11
|
+
|
|
12
|
+
export interface NativeMetadataEntry {
|
|
13
|
+
readonly path: string;
|
|
14
|
+
readonly kind: "absent" | "file" | "symlink" | "other";
|
|
15
|
+
readonly dev?: bigint;
|
|
16
|
+
readonly ino?: bigint;
|
|
17
|
+
readonly mode?: bigint;
|
|
18
|
+
readonly size?: bigint;
|
|
19
|
+
readonly mtimeNs?: bigint;
|
|
20
|
+
readonly ctimeNs?: bigint;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface NativeMetadataPort {
|
|
24
|
+
inspect(
|
|
25
|
+
workspaceRoot: string,
|
|
26
|
+
paths: readonly string[],
|
|
27
|
+
requestDirectory: string,
|
|
28
|
+
): Promise<readonly NativeMetadataEntry[] | undefined>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 能力探测不支持时回退 TypeScript;已确认支持后的 inspect 错误保持 fail-closed。 */
|
|
32
|
+
export class NativeMetadataInspector implements NativeMetadataPort {
|
|
33
|
+
private readonly executable: string | undefined;
|
|
34
|
+
private capability: Promise<boolean> | undefined;
|
|
35
|
+
|
|
36
|
+
constructor(executable = nativeExecutable()) {
|
|
37
|
+
this.executable = process.env.PI_UNDO_DISABLE_NATIVE === "1" ? undefined : executable;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async inspect(
|
|
41
|
+
workspaceRoot: string,
|
|
42
|
+
paths: readonly string[],
|
|
43
|
+
requestDirectory: string,
|
|
44
|
+
): Promise<readonly NativeMetadataEntry[] | undefined> {
|
|
45
|
+
if (paths.length === 0) return [];
|
|
46
|
+
if (!await this.supportsInspect(requestDirectory)) return undefined;
|
|
47
|
+
const executable = this.executable!;
|
|
48
|
+
const requestPath = join(requestDirectory, `native-inspect-${process.pid}-${randomUUID()}.json`);
|
|
49
|
+
try {
|
|
50
|
+
await writeFile(requestPath, JSON.stringify({
|
|
51
|
+
schemaVersion: 1,
|
|
52
|
+
workspaceRoot,
|
|
53
|
+
paths,
|
|
54
|
+
}), { mode: 0o600, flag: "wx" });
|
|
55
|
+
return await runNativeInspect(executable, requestPath, paths);
|
|
56
|
+
} finally {
|
|
57
|
+
await rm(requestPath, { force: true }).catch(() => {});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
private supportsInspect(requestDirectory: string): Promise<boolean> {
|
|
62
|
+
if (this.capability !== undefined) return this.capability;
|
|
63
|
+
this.capability = (async () => {
|
|
64
|
+
if (this.executable === undefined) return false;
|
|
65
|
+
try {
|
|
66
|
+
await access(this.executable, constants.X_OK);
|
|
67
|
+
return await probeNativeInspect(this.executable, requestDirectory);
|
|
68
|
+
} catch {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
})();
|
|
72
|
+
return this.capability;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function probeNativeInspect(executable: string, isolatedDirectory: string): Promise<boolean> {
|
|
77
|
+
return new Promise((resolve) => {
|
|
78
|
+
const child = spawn(executable, ["--capabilities"], {
|
|
79
|
+
cwd: isolatedDirectory,
|
|
80
|
+
shell: false,
|
|
81
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
82
|
+
windowsHide: true,
|
|
83
|
+
});
|
|
84
|
+
const stdout: Buffer[] = [];
|
|
85
|
+
let bytes = 0;
|
|
86
|
+
let settled = false;
|
|
87
|
+
const timeout = setTimeout(() => child.kill("SIGKILL"), 5_000);
|
|
88
|
+
child.stdout?.on("data", (chunk: Buffer | string) => {
|
|
89
|
+
const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
90
|
+
bytes += value.length;
|
|
91
|
+
if (bytes <= 64 * 1024) stdout.push(value);
|
|
92
|
+
else child.kill("SIGKILL");
|
|
93
|
+
});
|
|
94
|
+
child.once("error", () => {
|
|
95
|
+
if (settled) return;
|
|
96
|
+
settled = true;
|
|
97
|
+
clearTimeout(timeout);
|
|
98
|
+
resolve(false);
|
|
99
|
+
});
|
|
100
|
+
child.once("close", (code) => {
|
|
101
|
+
if (settled) return;
|
|
102
|
+
settled = true;
|
|
103
|
+
clearTimeout(timeout);
|
|
104
|
+
if (code !== 0 || bytes > 64 * 1024) return resolve(false);
|
|
105
|
+
try {
|
|
106
|
+
const value: unknown = JSON.parse(Buffer.concat(stdout).toString("utf8"));
|
|
107
|
+
resolve(isRecord(value) && value.ok === true && Array.isArray(value.capabilities) &&
|
|
108
|
+
value.capabilities.includes("inspect-v1"));
|
|
109
|
+
} catch {
|
|
110
|
+
resolve(false);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function runNativeInspect(
|
|
117
|
+
executable: string,
|
|
118
|
+
requestPath: string,
|
|
119
|
+
expectedPaths: readonly string[],
|
|
120
|
+
): Promise<readonly NativeMetadataEntry[]> {
|
|
121
|
+
return new Promise((resolve, reject) => {
|
|
122
|
+
const child = spawn(executable, ["--inspect", requestPath], {
|
|
123
|
+
shell: false,
|
|
124
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
125
|
+
windowsHide: true,
|
|
126
|
+
});
|
|
127
|
+
const stdout: Buffer[] = [];
|
|
128
|
+
const stderr: Buffer[] = [];
|
|
129
|
+
let outputBytes = 0;
|
|
130
|
+
let settled = false;
|
|
131
|
+
let overflow = false;
|
|
132
|
+
const timeout = setTimeout(() => child.kill("SIGKILL"), NATIVE_INSPECT_TIMEOUT_MS);
|
|
133
|
+
const capture = (target: Buffer[]) => (chunk: Buffer | string): void => {
|
|
134
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
135
|
+
if (outputBytes + bytes.length > NATIVE_INSPECT_OUTPUT_LIMIT) {
|
|
136
|
+
overflow = true;
|
|
137
|
+
child.kill("SIGKILL");
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
target.push(bytes);
|
|
141
|
+
outputBytes += bytes.length;
|
|
142
|
+
};
|
|
143
|
+
child.stdout?.on("data", capture(stdout));
|
|
144
|
+
child.stderr?.on("data", capture(stderr));
|
|
145
|
+
child.once("error", (error) => {
|
|
146
|
+
if (settled) return;
|
|
147
|
+
settled = true;
|
|
148
|
+
clearTimeout(timeout);
|
|
149
|
+
reject(error);
|
|
150
|
+
});
|
|
151
|
+
child.once("close", (code) => {
|
|
152
|
+
if (settled) return;
|
|
153
|
+
settled = true;
|
|
154
|
+
clearTimeout(timeout);
|
|
155
|
+
if (overflow) {
|
|
156
|
+
reject(new Error("native metadata inspect 输出超过限制"));
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (code !== 0) {
|
|
160
|
+
reject(new Error(`native metadata inspect 失败:${Buffer.concat(stderr).toString("utf8").trim()}`));
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
try {
|
|
164
|
+
resolve(parseInspectResponse(Buffer.concat(stdout).toString("utf8"), expectedPaths));
|
|
165
|
+
} catch (error) {
|
|
166
|
+
reject(error);
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function parseInspectResponse(text: string, expectedPaths: readonly string[]): readonly NativeMetadataEntry[] {
|
|
173
|
+
const value: unknown = JSON.parse(text);
|
|
174
|
+
if (!isRecord(value) || value.ok !== true || value.processed !== expectedPaths.length || !Array.isArray(value.entries)) {
|
|
175
|
+
throw new Error("native metadata inspect 响应无效");
|
|
176
|
+
}
|
|
177
|
+
if (value.entries.length !== expectedPaths.length) throw new Error("native metadata inspect 条目数量不匹配");
|
|
178
|
+
return value.entries.map((candidate, index) => {
|
|
179
|
+
if (!isRecord(candidate) || candidate.path !== expectedPaths[index] ||
|
|
180
|
+
!isMetadataKind(candidate.kind)) {
|
|
181
|
+
throw new Error("native metadata inspect 条目无效");
|
|
182
|
+
}
|
|
183
|
+
if (candidate.kind === "absent") {
|
|
184
|
+
if ([candidate.dev, candidate.ino, candidate.mode, candidate.size, candidate.mtimeNs, candidate.ctimeNs]
|
|
185
|
+
.some((field) => field !== null && field !== undefined)) {
|
|
186
|
+
throw new Error("native metadata absent 条目包含 metadata");
|
|
187
|
+
}
|
|
188
|
+
return { path: candidate.path as string, kind: "absent" as const };
|
|
189
|
+
}
|
|
190
|
+
return {
|
|
191
|
+
path: candidate.path as string,
|
|
192
|
+
kind: candidate.kind,
|
|
193
|
+
dev: parseUnsigned(candidate.dev, 64),
|
|
194
|
+
ino: parseUnsigned(candidate.ino, 64),
|
|
195
|
+
mode: parseUnsigned(candidate.mode, 32),
|
|
196
|
+
size: parseUnsigned(candidate.size, 64),
|
|
197
|
+
mtimeNs: parseTimestamp(candidate.mtimeNs),
|
|
198
|
+
ctimeNs: parseTimestamp(candidate.ctimeNs),
|
|
199
|
+
};
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function parseUnsigned(value: unknown, bits: 32 | 64): bigint {
|
|
204
|
+
const maxDigits = bits === 32 ? 10 : 20;
|
|
205
|
+
if (typeof value !== "string" || value.length > maxDigits || !/^(?:0|[1-9][0-9]*)$/.test(value)) {
|
|
206
|
+
throw new Error("native metadata unsigned 字段无效");
|
|
207
|
+
}
|
|
208
|
+
const parsed = BigInt(value);
|
|
209
|
+
if (parsed > (1n << BigInt(bits)) - 1n) throw new Error("native metadata unsigned 字段越界");
|
|
210
|
+
return parsed;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function parseTimestamp(value: unknown): bigint {
|
|
214
|
+
if (typeof value !== "string" || value.length > 30 || !/^-?(?:0|[1-9][0-9]*)$/.test(value)) {
|
|
215
|
+
throw new Error("native metadata timestamp 字段无效");
|
|
216
|
+
}
|
|
217
|
+
const parsed = BigInt(value);
|
|
218
|
+
const billion = 1_000_000_000n;
|
|
219
|
+
const minimum = -(1n << 63n) * billion;
|
|
220
|
+
const maximum = ((1n << 63n) - 1n) * billion + (billion - 1n);
|
|
221
|
+
if (parsed < minimum || parsed > maximum) throw new Error("native metadata timestamp 字段越界");
|
|
222
|
+
return parsed;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function isMetadataKind(value: unknown): value is NativeMetadataEntry["kind"] {
|
|
226
|
+
return value === "absent" || value === "file" || value === "symlink" || value === "other";
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
230
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
231
|
+
}
|
package/src/native-restore.ts
CHANGED
|
@@ -79,7 +79,7 @@ export async function createNativeFileBatch(options: {
|
|
|
79
79
|
};
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
function nativeExecutable(): string | undefined {
|
|
82
|
+
export function nativeExecutable(): string | undefined {
|
|
83
83
|
const platform = process.platform === "darwin"
|
|
84
84
|
? "darwin"
|
|
85
85
|
: process.platform === "linux" ? "linux"
|
package/src/path-safety.ts
CHANGED
|
@@ -64,6 +64,30 @@ export async function assertNoSymlinkEscape(root: string, relativePath: string):
|
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
/** 批量核验叶子路径的全部父目录;共享目录只执行一次 lstat。 */
|
|
68
|
+
export async function assertNoSymlinkParents(root: string, relativePaths: readonly string[]): Promise<void> {
|
|
69
|
+
const directories = new Set<string>();
|
|
70
|
+
for (const relativePath of relativePaths) {
|
|
71
|
+
const safePath = relativeSafePath(root, relativePath);
|
|
72
|
+
if (safePath === ".") continue;
|
|
73
|
+
const parts = safePath.split("/");
|
|
74
|
+
for (let index = 1; index < parts.length; index += 1) {
|
|
75
|
+
directories.add(parts.slice(0, index).join("/"));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const ordered = [...directories].sort((left, right) => pathDepth(left) - pathDepth(right) || left.localeCompare(right));
|
|
79
|
+
for (const directory of ordered) {
|
|
80
|
+
try {
|
|
81
|
+
const metadata = await lstat(join(resolve(root), ...directory.split("/")));
|
|
82
|
+
if (metadata.isSymbolicLink()) fail("symlink_escape", "中间路径组件不能是 symlink");
|
|
83
|
+
if (!metadata.isDirectory()) fail("unsafe_path", "中间路径组件不是目录");
|
|
84
|
+
} catch (error) {
|
|
85
|
+
if (hasErrorCode(error, "ENOENT")) continue;
|
|
86
|
+
throw error;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
67
91
|
export function pathSetsOverlap(leftPaths: readonly string[], rightPaths: readonly string[]): boolean {
|
|
68
92
|
for (const path of leftPaths) assertRelativeCandidate(path);
|
|
69
93
|
for (const path of rightPaths) assertRelativeCandidate(path);
|
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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { BigIntStats } from "node:fs";
|
|
2
2
|
import { lstat, mkdir, mkdtemp, readFile, readdir, readlink, realpath, rm, stat } from "node:fs/promises";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
@@ -20,7 +20,9 @@ import type {
|
|
|
20
20
|
SnapshotManifest,
|
|
21
21
|
SnapshotRoot,
|
|
22
22
|
} from "./model.ts";
|
|
23
|
-
import {
|
|
23
|
+
import type { NativeMetadataEntry, NativeMetadataPort } from "./native-metadata.ts";
|
|
24
|
+
import { NativeMetadataInspector } from "./native-metadata.ts";
|
|
25
|
+
import { assertNoSymlinkEscape, assertNoSymlinkParents, pathSetsOverlap, relativeSafePath } from "./path-safety.ts";
|
|
24
26
|
import { RootDiscovery, type RootTopology } from "./root-discovery.ts";
|
|
25
27
|
import { WorkspaceLock } from "./workspace-lock.ts";
|
|
26
28
|
|
|
@@ -36,12 +38,15 @@ const TREE_BLOB_MEMBERSHIP_LIMIT = 65_536;
|
|
|
36
38
|
const HASH_BATCH_MAX_PATHS = process.platform === "win32" ? 128 : 2_048;
|
|
37
39
|
const HASH_BATCH_MAX_ARGUMENT_BYTES = process.platform === "win32" ? 24 * 1024 : 128 * 1024;
|
|
38
40
|
const HASH_BATCH_CONCURRENCY = 4;
|
|
41
|
+
const ROOT_CAPTURE_CONCURRENCY = 4;
|
|
39
42
|
const FILE_SYSTEM_INSPECTION_CONCURRENCY = 32;
|
|
43
|
+
const IGNORED_METADATA_BATCH_SIZE = 1_024;
|
|
40
44
|
const INDEX_BATCH_MAX_ENTRIES = 4_096;
|
|
41
45
|
const INDEX_BATCH_MAX_BYTES = 8 * 1024 * 1024;
|
|
42
46
|
const BLOB_CACHE_MAX_BYTES = 128 * 1024 * 1024;
|
|
43
47
|
const BLOB_BATCH_MAX_BYTES = 16 * 1024 * 1024;
|
|
44
48
|
const BLOB_BATCH_MAX_ENTRIES = process.platform === "win32" ? 256 : 2_048;
|
|
49
|
+
const RACY_CLEAN_WINDOW_NS = 2_000_000_000n;
|
|
45
50
|
|
|
46
51
|
interface PinRecord {
|
|
47
52
|
readonly schemaVersion: 1;
|
|
@@ -70,6 +75,17 @@ interface CapturedRootResult {
|
|
|
70
75
|
readonly ignoredPresentPaths: readonly string[];
|
|
71
76
|
readonly ignoreClosure: string;
|
|
72
77
|
readonly objectClosure: string;
|
|
78
|
+
readonly cacheUpdate: VisibleLeafCacheUpdate;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
interface VisibleLeafMetadata {
|
|
82
|
+
readonly kind: "file" | "symlink" | "other";
|
|
83
|
+
readonly dev: bigint;
|
|
84
|
+
readonly ino: bigint;
|
|
85
|
+
readonly mode: bigint;
|
|
86
|
+
readonly size: bigint;
|
|
87
|
+
readonly mtimeNs: bigint;
|
|
88
|
+
readonly ctimeNs: bigint;
|
|
73
89
|
}
|
|
74
90
|
|
|
75
91
|
interface VisibleLeaf {
|
|
@@ -77,6 +93,25 @@ interface VisibleLeaf {
|
|
|
77
93
|
readonly kind: "file" | "symlink";
|
|
78
94
|
readonly mode: number;
|
|
79
95
|
readonly fingerprint: string;
|
|
96
|
+
readonly changedAtNs: bigint;
|
|
97
|
+
readonly cacheable: boolean;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
interface CachedVisibleLeaf extends VisibleLeaf {
|
|
101
|
+
readonly objectId: string;
|
|
102
|
+
readonly verifiedAtNs: bigint;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
interface StagedWorktree {
|
|
106
|
+
readonly leaves: readonly VisibleLeaf[];
|
|
107
|
+
readonly objectIds: ReadonlyMap<string, string>;
|
|
108
|
+
readonly verifiedAtNs: bigint;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
interface VisibleLeafCacheUpdate {
|
|
112
|
+
readonly gitDirectory: string;
|
|
113
|
+
readonly staged: StagedWorktree;
|
|
114
|
+
readonly inclusions: readonly string[] | null;
|
|
80
115
|
}
|
|
81
116
|
|
|
82
117
|
interface CachedBlob {
|
|
@@ -90,6 +125,7 @@ export interface SnapshotStoreOptions {
|
|
|
90
125
|
readonly discovery?: RootDiscovery;
|
|
91
126
|
readonly lock?: WorkspaceLock;
|
|
92
127
|
readonly clock?: () => number;
|
|
128
|
+
readonly nativeMetadata?: NativeMetadataPort;
|
|
93
129
|
}
|
|
94
130
|
|
|
95
131
|
export interface CaptureOptions {
|
|
@@ -141,11 +177,13 @@ export class SnapshotStore {
|
|
|
141
177
|
private readonly discovery: RootDiscovery;
|
|
142
178
|
private readonly lock: WorkspaceLock;
|
|
143
179
|
private readonly clock: () => number;
|
|
180
|
+
private readonly nativeMetadata: NativeMetadataPort;
|
|
144
181
|
private readonly manifestLocations = new Map<string, string>();
|
|
145
182
|
// Tree 与 blob 都由 object ID 内容寻址;缓存只复用已从私有 ODB 读取的不可变内容。
|
|
146
183
|
private readonly treeEntriesCache = new Map<string, Promise<CapturedTreeEntry[]>>();
|
|
147
184
|
private readonly treeBlobMembership = new Map<string, string>();
|
|
148
185
|
private readonly blobCache = new Map<string, CachedBlob>();
|
|
186
|
+
private readonly visibleLeafCache = new Map<string, Map<string, CachedVisibleLeaf>>();
|
|
149
187
|
private blobCacheBytes = 0;
|
|
150
188
|
|
|
151
189
|
constructor(options: SnapshotStoreOptions = {}) {
|
|
@@ -155,6 +193,7 @@ export class SnapshotStore {
|
|
|
155
193
|
this.discovery = options.discovery ?? new RootDiscovery(this.git);
|
|
156
194
|
this.lock = options.lock ?? new WorkspaceLock();
|
|
157
195
|
this.clock = options.clock ?? Date.now;
|
|
196
|
+
this.nativeMetadata = options.nativeMetadata ?? new NativeMetadataInspector();
|
|
158
197
|
}
|
|
159
198
|
|
|
160
199
|
static supportsValidatedBlobBatch(store: SnapshotStore): boolean {
|
|
@@ -221,28 +260,41 @@ export class SnapshotStore {
|
|
|
221
260
|
const transactionsRoot = join(storeDirectory, "transactions");
|
|
222
261
|
await mkdir(transactionsRoot, { recursive: true });
|
|
223
262
|
transactionDirectory = await mkdtemp(join(transactionsRoot, "capture-"));
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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]);
|
|
246
298
|
|
|
247
299
|
await this.assertTopology(topology, "捕获期间 topology 已变化");
|
|
248
300
|
const content = {
|
|
@@ -261,6 +313,7 @@ export class SnapshotStore {
|
|
|
261
313
|
await this.touchStore(storeDirectory);
|
|
262
314
|
await writeContentAddressed(manifestPath, Buffer.from(canonicalJson(manifest), "utf8"));
|
|
263
315
|
this.manifestLocations.set(manifestId, manifestPath);
|
|
316
|
+
for (const update of cacheUpdates) this.rememberVisibleLeaves(update);
|
|
264
317
|
return manifest;
|
|
265
318
|
} catch (error) {
|
|
266
319
|
if (error instanceof SnapshotStoreError) {
|
|
@@ -624,6 +677,9 @@ export class SnapshotStore {
|
|
|
624
677
|
this.manifestLocations.delete(id);
|
|
625
678
|
}
|
|
626
679
|
}
|
|
680
|
+
for (const gitDirectory of this.visibleLeafCache.keys()) {
|
|
681
|
+
if (gitDirectory.startsWith(`${storeDirectory}${sep}`)) this.visibleLeafCache.delete(gitDirectory);
|
|
682
|
+
}
|
|
627
683
|
} catch (error) {
|
|
628
684
|
await writeJsonAtomic(join(storeDirectory, GC_METADATA_FILE), {
|
|
629
685
|
schemaVersion: SCHEMA_VERSION,
|
|
@@ -659,13 +715,15 @@ export class SnapshotStore {
|
|
|
659
715
|
.map((candidate) => rootRelativePath(root.relativeRoot, candidate.relativeRoot));
|
|
660
716
|
const exactExclusions = ownedArtifactExclusions(topology.roots, root.relativeRoot, artifactExclusions);
|
|
661
717
|
const inclusions = ownedRootInclusions(requestedInclusions, exclusions);
|
|
662
|
-
await this.stageWorktree(
|
|
718
|
+
const staged = await this.stageWorktree(
|
|
663
719
|
absoluteRoot,
|
|
664
720
|
environment,
|
|
665
721
|
root.gitBacked,
|
|
666
722
|
inclusions,
|
|
667
723
|
exclusions,
|
|
668
724
|
exactExclusions,
|
|
725
|
+
this.visibleLeafCache.get(gitDirectory),
|
|
726
|
+
transactionDirectory,
|
|
669
727
|
);
|
|
670
728
|
const coverage = rootCoverageFromInclusions(inclusions);
|
|
671
729
|
const ignoredPresentPaths = await this.captureIgnoredPresentPaths(
|
|
@@ -675,6 +733,7 @@ export class SnapshotStore {
|
|
|
675
733
|
inclusions,
|
|
676
734
|
exclusions,
|
|
677
735
|
exactExclusions,
|
|
736
|
+
transactionDirectory,
|
|
678
737
|
);
|
|
679
738
|
const treeId = (await this.runGit(["write-tree"], { cwd: absoluteRoot, env: environment })).trim();
|
|
680
739
|
if (!isObjectId(treeId)) {
|
|
@@ -687,6 +746,7 @@ export class SnapshotStore {
|
|
|
687
746
|
coverage,
|
|
688
747
|
...ignoredPresentProof(coverage, ignoredPresentPaths),
|
|
689
748
|
objectClosure: treeObjectClosure(treeId, entries),
|
|
749
|
+
cacheUpdate: { gitDirectory, staged, inclusions },
|
|
690
750
|
};
|
|
691
751
|
}
|
|
692
752
|
|
|
@@ -697,6 +757,7 @@ export class SnapshotStore {
|
|
|
697
757
|
inclusions: readonly string[] | null,
|
|
698
758
|
exclusions: readonly string[],
|
|
699
759
|
exactExclusions: ReadonlySet<string>,
|
|
760
|
+
requestDirectory: string,
|
|
700
761
|
): Promise<string[]> {
|
|
701
762
|
if (inclusions === null) {
|
|
702
763
|
return [];
|
|
@@ -715,7 +776,8 @@ export class SnapshotStore {
|
|
|
715
776
|
"--",
|
|
716
777
|
...pathspecs,
|
|
717
778
|
], { cwd, env: gitBacked ? sourceGitEnvironment() : environment });
|
|
718
|
-
const
|
|
779
|
+
const candidates: string[] = [];
|
|
780
|
+
const seen = new Set<string>();
|
|
719
781
|
for (const relativePath of parseNulPaths(output)) {
|
|
720
782
|
if (
|
|
721
783
|
exclusions.some((excluded) => isPathAtOrBelow(excluded, relativePath)) ||
|
|
@@ -723,23 +785,74 @@ export class SnapshotStore {
|
|
|
723
785
|
) {
|
|
724
786
|
continue;
|
|
725
787
|
}
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
if (hasErrorCode(error, "ENOENT")) return null;
|
|
729
|
-
throw error;
|
|
730
|
-
});
|
|
731
|
-
if (metadata === null) {
|
|
732
|
-
continue;
|
|
788
|
+
if (seen.has(relativePath)) {
|
|
789
|
+
throw new SnapshotStoreError("capture_failed", `ignored-present proof 包含重复路径:${relativePath}`);
|
|
733
790
|
}
|
|
734
|
-
|
|
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") {
|
|
735
806
|
throw new SnapshotStoreError("capture_failed", `ignored-present proof 只接受叶子路径:${relativePath}`);
|
|
736
807
|
}
|
|
737
|
-
|
|
738
|
-
|
|
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;
|
|
739
827
|
}
|
|
740
|
-
result.
|
|
828
|
+
result.push(...inspected);
|
|
741
829
|
}
|
|
742
|
-
return
|
|
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);
|
|
854
|
+
}
|
|
855
|
+
return result;
|
|
743
856
|
}
|
|
744
857
|
|
|
745
858
|
private async stageWorktree(
|
|
@@ -749,7 +862,9 @@ export class SnapshotStore {
|
|
|
749
862
|
inclusions: readonly string[] | null,
|
|
750
863
|
exclusions: readonly string[],
|
|
751
864
|
exactExclusions: ReadonlySet<string>,
|
|
752
|
-
|
|
865
|
+
cache: ReadonlyMap<string, CachedVisibleLeaf> | undefined,
|
|
866
|
+
requestDirectory: string,
|
|
867
|
+
): Promise<StagedWorktree> {
|
|
753
868
|
const leaves = await this.collectVisibleLeaves(
|
|
754
869
|
cwd,
|
|
755
870
|
environment,
|
|
@@ -757,12 +872,25 @@ export class SnapshotStore {
|
|
|
757
872
|
inclusions,
|
|
758
873
|
exclusions,
|
|
759
874
|
exactExclusions,
|
|
875
|
+
requestDirectory,
|
|
760
876
|
);
|
|
761
877
|
const objectIds = new Map<string, string>();
|
|
762
|
-
const
|
|
878
|
+
const uncached: VisibleLeaf[] = [];
|
|
879
|
+
for (const leaf of leaves) {
|
|
880
|
+
const cached = cache?.get(leaf.relativePath);
|
|
881
|
+
if (
|
|
882
|
+
leaf.cacheable && cached?.cacheable === true && cached.kind === leaf.kind &&
|
|
883
|
+
cached.mode === leaf.mode && cached.fingerprint === leaf.fingerprint &&
|
|
884
|
+
cached.verifiedAtNs > cached.changedAtNs + RACY_CLEAN_WINDOW_NS
|
|
885
|
+
) {
|
|
886
|
+
objectIds.set(leaf.relativePath, cached.objectId);
|
|
887
|
+
} else {
|
|
888
|
+
uncached.push(leaf);
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
const hashBatches = hashPathBatches(uncached.filter((leaf) => leaf.kind === "file"));
|
|
763
892
|
const hashedBatches = await mapConcurrentOrdered(hashBatches, HASH_BATCH_CONCURRENCY, async (batch) => {
|
|
764
|
-
await
|
|
765
|
-
this.assertVisibleLeafUnchanged(cwd, leaf));
|
|
893
|
+
await this.assertVisibleLeavesUnchanged(cwd, batch);
|
|
766
894
|
const output = await this.runGit([
|
|
767
895
|
"hash-object",
|
|
768
896
|
"-w",
|
|
@@ -771,15 +899,15 @@ export class SnapshotStore {
|
|
|
771
899
|
...batch.map((leaf) => leaf.relativePath),
|
|
772
900
|
], { cwd, env: environment });
|
|
773
901
|
const hashes = parseObjectIdLines(output, batch.length);
|
|
774
|
-
await
|
|
775
|
-
this.assertVisibleLeafUnchanged(cwd, leaf));
|
|
902
|
+
await this.assertVisibleLeavesUnchanged(cwd, batch);
|
|
776
903
|
return batch.map((leaf, index) => [leaf.relativePath, hashes[index]!] as const);
|
|
777
904
|
});
|
|
778
905
|
for (const batch of hashedBatches) {
|
|
779
906
|
for (const [relativePath, objectId] of batch) objectIds.set(relativePath, objectId);
|
|
780
907
|
}
|
|
781
|
-
for (const leaf of
|
|
908
|
+
for (const leaf of uncached) {
|
|
782
909
|
if (leaf.kind !== "symlink") continue;
|
|
910
|
+
await assertNoSymlinkEscape(cwd, leaf.relativePath);
|
|
783
911
|
await this.assertVisibleLeafUnchanged(cwd, leaf);
|
|
784
912
|
const linkText = await readlink(join(cwd, ...leaf.relativePath.split("/")), { encoding: "buffer" });
|
|
785
913
|
decodeUtf8(linkText, "symlink target 不是可无损表示的 UTF-8");
|
|
@@ -791,6 +919,7 @@ export class SnapshotStore {
|
|
|
791
919
|
if (!isObjectId(objectId)) {
|
|
792
920
|
throw new SnapshotStoreError("capture_failed", `文件对象 materialize 失败:${leaf.relativePath}`);
|
|
793
921
|
}
|
|
922
|
+
await assertNoSymlinkEscape(cwd, leaf.relativePath);
|
|
794
923
|
await this.assertVisibleLeafUnchanged(cwd, leaf);
|
|
795
924
|
objectIds.set(leaf.relativePath, objectId);
|
|
796
925
|
}
|
|
@@ -801,15 +930,67 @@ export class SnapshotStore {
|
|
|
801
930
|
stdin: indexInput,
|
|
802
931
|
});
|
|
803
932
|
}
|
|
933
|
+
await this.assertVisibleLeavesUnchanged(cwd, leaves, requestDirectory);
|
|
934
|
+
return { leaves, objectIds, verifiedAtNs: BigInt(Date.now()) * 1_000_000n };
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
private rememberVisibleLeaves(update: VisibleLeafCacheUpdate): void {
|
|
938
|
+
const { gitDirectory, staged, inclusions } = update;
|
|
939
|
+
const cache = inclusions !== null && inclusions.length === 0
|
|
940
|
+
? new Map<string, CachedVisibleLeaf>()
|
|
941
|
+
: new Map(this.visibleLeafCache.get(gitDirectory));
|
|
942
|
+
if (inclusions !== null && inclusions.length > 0) {
|
|
943
|
+
for (const relativePath of cache.keys()) {
|
|
944
|
+
if (inclusions.some((inclusion) => isPathAtOrBelow(inclusion, relativePath))) {
|
|
945
|
+
cache.delete(relativePath);
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
for (const leaf of staged.leaves) {
|
|
950
|
+
const objectId = staged.objectIds.get(leaf.relativePath);
|
|
951
|
+
if (objectId === undefined) continue;
|
|
952
|
+
if (!leaf.cacheable) {
|
|
953
|
+
cache.delete(leaf.relativePath);
|
|
954
|
+
continue;
|
|
955
|
+
}
|
|
956
|
+
cache.set(leaf.relativePath, { ...leaf, objectId, verifiedAtNs: staged.verifiedAtNs });
|
|
957
|
+
}
|
|
958
|
+
this.visibleLeafCache.set(gitDirectory, cache);
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
private async assertVisibleLeavesUnchanged(
|
|
962
|
+
cwd: string,
|
|
963
|
+
leaves: readonly VisibleLeaf[],
|
|
964
|
+
requestDirectory?: string,
|
|
965
|
+
): Promise<void> {
|
|
966
|
+
if (requestDirectory !== undefined) {
|
|
967
|
+
const inspected = await this.nativeMetadata.inspect(
|
|
968
|
+
cwd,
|
|
969
|
+
leaves.map((leaf) => leaf.relativePath),
|
|
970
|
+
requestDirectory,
|
|
971
|
+
);
|
|
972
|
+
if (inspected !== undefined) {
|
|
973
|
+
for (let index = 0; index < leaves.length; index += 1) {
|
|
974
|
+
const leaf = leaves[index]!;
|
|
975
|
+
const metadata = nativeVisibleLeafMetadata(inspected[index]!);
|
|
976
|
+
if (metadata === null || visibleLeafFingerprint(metadata) !== leaf.fingerprint) {
|
|
977
|
+
throw new SnapshotStoreError("capture_failed", `捕获期间工作区叶子已变化:${leaf.relativePath}`);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
return;
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
await assertNoSymlinkParents(cwd, leaves.map((leaf) => leaf.relativePath));
|
|
984
|
+
await mapConcurrentOrdered(leaves, FILE_SYSTEM_INSPECTION_CONCURRENCY, (leaf) =>
|
|
985
|
+
this.assertVisibleLeafUnchanged(cwd, leaf));
|
|
804
986
|
}
|
|
805
987
|
|
|
806
988
|
private async assertVisibleLeafUnchanged(cwd: string, leaf: VisibleLeaf): Promise<void> {
|
|
807
|
-
await
|
|
808
|
-
const metadata = await lstat(join(cwd, ...leaf.relativePath.split("/"))).catch((error) => {
|
|
989
|
+
const metadata = await lstat(join(cwd, ...leaf.relativePath.split("/")), { bigint: true }).catch((error) => {
|
|
809
990
|
if (hasErrorCode(error, "ENOENT")) return null;
|
|
810
991
|
throw error;
|
|
811
992
|
});
|
|
812
|
-
if (metadata === null || visibleLeafFingerprint(metadata) !== leaf.fingerprint) {
|
|
993
|
+
if (metadata === null || visibleLeafFingerprint(visibleLeafMetadataFromStats(metadata)) !== leaf.fingerprint) {
|
|
813
994
|
throw new SnapshotStoreError("capture_failed", `捕获期间工作区叶子已变化:${leaf.relativePath}`);
|
|
814
995
|
}
|
|
815
996
|
}
|
|
@@ -864,6 +1045,7 @@ export class SnapshotStore {
|
|
|
864
1045
|
inclusions: readonly string[] | null,
|
|
865
1046
|
exclusions: readonly string[],
|
|
866
1047
|
exactExclusions: ReadonlySet<string>,
|
|
1048
|
+
requestDirectory: string,
|
|
867
1049
|
): Promise<VisibleLeaf[]> {
|
|
868
1050
|
const paths = await this.queryVisibleLeafPaths(
|
|
869
1051
|
cwd,
|
|
@@ -873,33 +1055,46 @@ export class SnapshotStore {
|
|
|
873
1055
|
exclusions,
|
|
874
1056
|
exactExclusions,
|
|
875
1057
|
);
|
|
876
|
-
const
|
|
1058
|
+
const nativeEntries = await this.nativeMetadata.inspect(cwd, paths, requestDirectory);
|
|
1059
|
+
const metadataEntries = nativeEntries === undefined
|
|
1060
|
+
? await this.collectVisibleLeafMetadataFallback(cwd, paths)
|
|
1061
|
+
: nativeEntries.map((entry) => nativeVisibleLeafMetadata(entry));
|
|
1062
|
+
const leaves: VisibleLeaf[] = [];
|
|
1063
|
+
for (let index = 0; index < paths.length; index += 1) {
|
|
1064
|
+
const relativePath = paths[index]!;
|
|
1065
|
+
const metadata = metadataEntries[index]!;
|
|
1066
|
+
if (metadata === null) continue;
|
|
1067
|
+
if (metadata.kind === "other") {
|
|
1068
|
+
throw new SnapshotStoreError("capture_failed", `不支持的工作区文件类型:${relativePath}`);
|
|
1069
|
+
}
|
|
1070
|
+
const cacheable = visibleLeafMetadataCacheable(metadata);
|
|
1071
|
+
leaves.push({
|
|
1072
|
+
relativePath,
|
|
1073
|
+
kind: metadata.kind,
|
|
1074
|
+
mode: metadata.kind === "symlink"
|
|
1075
|
+
? 0o120000
|
|
1076
|
+
: (metadata.mode & 0o111n) === 0n ? 0o100644 : 0o100755,
|
|
1077
|
+
fingerprint: visibleLeafFingerprint(metadata),
|
|
1078
|
+
changedAtNs: metadata.mtimeNs > metadata.ctimeNs ? metadata.mtimeNs : metadata.ctimeNs,
|
|
1079
|
+
cacheable,
|
|
1080
|
+
});
|
|
1081
|
+
}
|
|
1082
|
+
return leaves;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
private async collectVisibleLeafMetadataFallback(
|
|
1086
|
+
cwd: string,
|
|
1087
|
+
paths: readonly string[],
|
|
1088
|
+
): Promise<readonly (VisibleLeafMetadata | null)[]> {
|
|
1089
|
+
await assertNoSymlinkParents(cwd, paths);
|
|
1090
|
+
return mapConcurrentOrdered(paths, FILE_SYSTEM_INSPECTION_CONCURRENCY, async (relativePath) => {
|
|
877
1091
|
relativeSafePath(cwd, relativePath);
|
|
878
|
-
await
|
|
879
|
-
const metadata = await lstat(join(cwd, ...relativePath.split("/"))).catch((error) => {
|
|
1092
|
+
const metadata = await lstat(join(cwd, ...relativePath.split("/")), { bigint: true }).catch((error) => {
|
|
880
1093
|
if (hasErrorCode(error, "ENOENT")) return null;
|
|
881
1094
|
throw error;
|
|
882
1095
|
});
|
|
883
|
-
|
|
884
|
-
if (metadata.isSymbolicLink()) {
|
|
885
|
-
return {
|
|
886
|
-
relativePath,
|
|
887
|
-
kind: "symlink" as const,
|
|
888
|
-
mode: 0o120000,
|
|
889
|
-
fingerprint: visibleLeafFingerprint(metadata),
|
|
890
|
-
};
|
|
891
|
-
}
|
|
892
|
-
if (metadata.isFile()) {
|
|
893
|
-
return {
|
|
894
|
-
relativePath,
|
|
895
|
-
kind: "file" as const,
|
|
896
|
-
mode: (metadata.mode & 0o111) === 0 ? 0o100644 : 0o100755,
|
|
897
|
-
fingerprint: visibleLeafFingerprint(metadata),
|
|
898
|
-
};
|
|
899
|
-
}
|
|
900
|
-
throw new SnapshotStoreError("capture_failed", `不支持的工作区文件类型:${relativePath}`);
|
|
1096
|
+
return metadata === null ? null : visibleLeafMetadataFromStats(metadata);
|
|
901
1097
|
});
|
|
902
|
-
return leaves.filter((leaf): leaf is VisibleLeaf => leaf !== null);
|
|
903
1098
|
}
|
|
904
1099
|
|
|
905
1100
|
private async validateIgnoreQuery(
|
|
@@ -937,6 +1132,7 @@ export class SnapshotStore {
|
|
|
937
1132
|
}
|
|
938
1133
|
await mkdir(dirname(gitDirectory), { recursive: true });
|
|
939
1134
|
await this.runGit(["init", "--bare", "--quiet", gitDirectory], { env: cleanGitEnvironment() });
|
|
1135
|
+
this.visibleLeafCache.delete(gitDirectory);
|
|
940
1136
|
await this.configurePrivateRepository(gitDirectory);
|
|
941
1137
|
}
|
|
942
1138
|
|
|
@@ -1559,18 +1755,54 @@ function indexInfoBatches(
|
|
|
1559
1755
|
return result;
|
|
1560
1756
|
}
|
|
1561
1757
|
|
|
1562
|
-
function
|
|
1563
|
-
return
|
|
1758
|
+
function visibleLeafMetadataFromStats(metadata: BigIntStats): VisibleLeafMetadata {
|
|
1759
|
+
return {
|
|
1564
1760
|
kind: metadata.isSymbolicLink() ? "symlink" : metadata.isFile() ? "file" : "other",
|
|
1565
1761
|
dev: metadata.dev,
|
|
1566
1762
|
ino: metadata.ino,
|
|
1567
1763
|
mode: metadata.mode,
|
|
1568
1764
|
size: metadata.size,
|
|
1569
|
-
|
|
1570
|
-
|
|
1765
|
+
mtimeNs: metadata.mtimeNs,
|
|
1766
|
+
ctimeNs: metadata.ctimeNs,
|
|
1767
|
+
};
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
function nativeVisibleLeafMetadata(entry: NativeMetadataEntry): VisibleLeafMetadata | null {
|
|
1771
|
+
if (entry.kind === "absent") return null;
|
|
1772
|
+
if (
|
|
1773
|
+
entry.dev === undefined || entry.ino === undefined || entry.mode === undefined ||
|
|
1774
|
+
entry.size === undefined || entry.mtimeNs === undefined || entry.ctimeNs === undefined
|
|
1775
|
+
) {
|
|
1776
|
+
throw new SnapshotStoreError("capture_failed", `native metadata 缺少字段:${entry.path}`);
|
|
1777
|
+
}
|
|
1778
|
+
return {
|
|
1779
|
+
kind: entry.kind,
|
|
1780
|
+
dev: entry.dev,
|
|
1781
|
+
ino: entry.ino,
|
|
1782
|
+
mode: entry.mode,
|
|
1783
|
+
size: entry.size,
|
|
1784
|
+
mtimeNs: entry.mtimeNs,
|
|
1785
|
+
ctimeNs: entry.ctimeNs,
|
|
1786
|
+
};
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
function visibleLeafFingerprint(metadata: VisibleLeafMetadata): string {
|
|
1790
|
+
return checksum(canonicalJson({
|
|
1791
|
+
kind: metadata.kind,
|
|
1792
|
+
dev: metadata.dev.toString(),
|
|
1793
|
+
ino: metadata.ino.toString(),
|
|
1794
|
+
mode: metadata.mode.toString(),
|
|
1795
|
+
size: metadata.size.toString(),
|
|
1796
|
+
mtimeNs: metadata.mtimeNs.toString(),
|
|
1797
|
+
ctimeNs: metadata.ctimeNs.toString(),
|
|
1571
1798
|
}));
|
|
1572
1799
|
}
|
|
1573
1800
|
|
|
1801
|
+
function visibleLeafMetadataCacheable(metadata: VisibleLeafMetadata): boolean {
|
|
1802
|
+
// dev/ino/ctime 缺失时无法证明路径仍指向同一未修改对象,必须回退内容 hash。
|
|
1803
|
+
return metadata.dev !== 0n && metadata.ino !== 0n && metadata.ctimeNs > 0n;
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1574
1806
|
async function mapConcurrentOrdered<T, R>(
|
|
1575
1807
|
values: readonly T[],
|
|
1576
1808
|
concurrency: number,
|