@davideasden/pi-undo 0.2.25 → 0.2.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -1
- package/docs/native-performance.md +52 -0
- package/native/bin/pi-undo-fs-darwin-arm64 +0 -0
- package/package.json +4 -3
- package/src/native-capabilities.ts +34 -0
- package/src/native-directory-scan.ts +114 -0
- package/src/native-restore.ts +12 -0
- package/src/restore-engine.ts +13 -12
- package/src/root-discovery.ts +53 -13
package/README.md
CHANGED
|
@@ -161,7 +161,11 @@ recovery_required
|
|
|
161
161
|
|
|
162
162
|
### Performance Notes
|
|
163
163
|
|
|
164
|
-
|
|
164
|
+
普通文件原生恢复路径保留 5 次完整拓扑扫描。macOS/Linux 的新版 helper 使用不跟随符号链接的目录句柄扫描,仍会发现 Git 忽略目录中的嵌套仓库。小工作区根据上次扫描规模选择 TypeScript 完整重扫,避免进程启动开销;没有使用 TTL 缓存或默认跳过 `node_modules`。
|
|
165
|
+
|
|
166
|
+
原生恢复现支持已有父目录中的普通文件删除,以及覆盖、创建、删除混合计划。扩展能力通过 `restore-files-v2` 探测;旧 helper、缺少二进制、目录操作及超出句柄预算的计划继续走 TypeScript 路径。pack 持久化、内容校验、no-clobber 与恢复依据均保留。
|
|
167
|
+
|
|
168
|
+
本机 macOS arm64 的三次 undo 中位数:3,000 包/1 文件从 1,976 ms 降至 924 ms,10,000 包/1 文件从 5,857 ms 降至 2,755 ms;100 文件的子目录撤销从 517 ms 降至 180 ms,混合撤销从 711 ms 降至 186 ms。测量方法和限制见 [原生性能验证记录](docs/native-performance.md)。
|
|
165
169
|
|
|
166
170
|
可通过真实 Pi 0.86.1 SDK 和离线 faux provider 运行大型工作区基准;它覆盖 3,000/10,000 个依赖包、修改 1/100 个文件,每组撤回三次并验证重做:
|
|
167
171
|
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# 原生目录扫描与普通文件恢复性能验证
|
|
2
|
+
|
|
3
|
+
这批优化基于 `d7d80230`,保留 undo 的五个拓扑检查点,降低完整目录扫描成本,并扩大已有父目录中的普通文件原生恢复范围。
|
|
4
|
+
|
|
5
|
+
## 实现与边界
|
|
6
|
+
|
|
7
|
+
- `scan-directories-v1` 在 macOS/Linux 上通过 `openat(O_DIRECTORY | O_NOFOLLOW)` 逐级打开目录,以目录流识别 Git 标记候选。扫描 ignored 目录,不进入名称恰为 `.git` 的元数据目录,不跟随符号链接;标记大小写、损坏仓库、失效 worktree 指针、Git index 与 submodule 仍由现有 TypeScript/Git 逻辑解释。
|
|
8
|
+
- 每次重新扫描。上次目录数量仅选择执行器:小于 64 个目录时使用 TypeScript 完整遍历;增长后可重新进入原生扫描。没有缓存拓扑结果,也没有省略任何检查点。
|
|
9
|
+
- 目录句柄、父目录绑定与扫描前后目录时间戳必须一致。扫描时并发增删目录项会使原生扫描失败,防止静默接受不完整结果;它不提供跨整个工作区的原子快照。能力确认后的路径错误向上传递。超过 128 层时,helper 明确返回 `depth_limit`,丢弃部分结果,由 TypeScript 从根重扫。
|
|
10
|
+
- 原生扫描使用操作剩余预算,并遵守 `PI_UNDO_OPERATION_TIMEOUT_MS`(默认 300 秒)。能力探测只在私有目录运行,缺失能力时回退;取消、超时及无法确认子进程终止均保留现有错误处理契约。探测取消不会永久污染能力缓存。
|
|
11
|
+
- `restore-files-v2` 支持普通文件覆盖、创建、删除的混合计划,以及子目录中的文件删除。父目录必须已经存在。读取、校验、no-clobber 安装及隔离均绑定同一组父目录句柄;最多保留 128 个非根父目录句柄。目录创建/删除、类型替换、过多父目录与不支持的平台继续使用 TypeScript。
|
|
12
|
+
- durable pack 格式与恢复协议保持不变。仍在修改前发布并持久化 pack,逐项校验内容和 mode,保留同 inode 的 ownership artifacts。失败时由现有 packed recovery 回滚;外来冲突文件不会被当成本次产物删除。
|
|
13
|
+
|
|
14
|
+
## 实测结果
|
|
15
|
+
|
|
16
|
+
环境为本机 macOS arm64、Node 23.11.1、真实 Pi 0.86.1 SDK 与离线 faux provider。每个场景执行三次 undo,中间执行 redo 并等待后台恢复/持久化清理结束;校验文件内容、撤回/重做历史状态和扫描次数。表内为中位数,单位毫秒。基线与优化版本在同一机器先后执行,未施加墙钟性能断言。
|
|
17
|
+
|
|
18
|
+
| 场景 | 优化前 | 优化后 | 耗时降低 |
|
|
19
|
+
| --- | ---: | ---: | ---: |
|
|
20
|
+
| 无依赖目录,修改 1 文件 | 146 | 148 | 约持平 |
|
|
21
|
+
| 3,000 包,修改 1 文件 | 1,976 | 924 | 53% |
|
|
22
|
+
| 10,000 包,修改 1 文件 | 5,857 | 2,755 | 53% |
|
|
23
|
+
| 100 文件全部覆盖 | 179 | 182 | 约持平 |
|
|
24
|
+
| 100 文件在根目录新增后撤销 | 177 | 174 | 约持平 |
|
|
25
|
+
| 100 文件在已有子目录新增后撤销 | 517 | 180 | 65% |
|
|
26
|
+
| 50 文件覆盖、50 文件新增后混合撤销 | 711 | 186 | 74% |
|
|
27
|
+
|
|
28
|
+
分阶段计时中,3,000 包场景五次扫描合计从 1,759 ms 降至 716 ms;10,000 包从 5,470 ms 降至 2,398 ms。全部测量仍为五次扫描。新增目录的撤销测试保留父目录中的 `keep.txt`,因此测量的是文件删除,不含目录删除。混合与子目录场景原先未进入 native,优化后三次均调用原生恢复一次。
|
|
29
|
+
|
|
30
|
+
另一个既有大型工作区基准覆盖 3,000/10,000 包 × 1/100 文件,四组均通过内容、redo 和 `[5, 5, 5]` 扫描次数断言。该次单独运行的中位数依次为 878、923、2,582、2,673 ms;与分阶段诊断是不同批次,不把两批耗时混作同一组数据。
|
|
31
|
+
|
|
32
|
+
这些是三次采样的本机结果,主要证明已观察到的热路径得到改善。磁盘、目录结构、并发写入和机器负载会影响绝对耗时;没有从这些结果推断其他操作系统上的收益。
|
|
33
|
+
|
|
34
|
+
## 复现与验证
|
|
35
|
+
|
|
36
|
+
最终验证通过:默认原生路径 617 项、强制 TypeScript 回退 586 项、Rust 单测 8 项;TypeScript 类型检查、四组大型工作区基准及 npm 打包内容检查通过。跳过项为不适用平台/能力及按需基准,不计入通过数。
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
npm run build:native
|
|
40
|
+
cp native/pi-undo-fs/target/release/pi-undo-fs native/bin/pi-undo-fs-darwin-arm64
|
|
41
|
+
npm run test:native
|
|
42
|
+
npm test
|
|
43
|
+
PI_UNDO_DISABLE_NATIVE=1 npm test
|
|
44
|
+
npm run typecheck
|
|
45
|
+
PI_UNDO_LARGE_WORKSPACE=1 npx vitest run test/large-workspace.test.ts
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
上面的二进制复制路径用于本次 macOS arm64 环境;其他平台须使用对应构建产物。此批已更新仓库现有的 macOS arm64 二进制。Linux、其他架构和 Windows 的源码构建/运行不在本机验证范围内;缺少二进制或扩展能力时会回退,发布时仍需现有六平台构建流程提供对应产物。
|
|
49
|
+
|
|
50
|
+
补充测试覆盖:原生/TypeScript 拓扑一致性、ignored 嵌套仓库、大小写不同的 Git 标记、符号链接及目录删除/替换、深度回退、取消后重新探测、超时、混合计划回滚/前滚、外来文件冲突、旧 helper 门控,以及 128/129 个父目录的边界。
|
|
51
|
+
|
|
52
|
+
独立审查运行器异常退出,正式审查结果未能落盘。已保留失败运行的状态与补丁证据,并人工核对日志中的具体意见;这不计为独立审查通过。已修复候选错误类型与扫描预算,补充句柄边界测试。旧 helper 在混合计划中仍可能先准备 pack 再回退,存在额外准备开销;兼容性分类与平台发布覆盖也仍需后续维护。
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@davideasden/pi-undo",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.26",
|
|
4
4
|
"description": "Persistent workspace undo and redo for Pi",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -34,13 +34,14 @@
|
|
|
34
34
|
"extensions",
|
|
35
35
|
"src",
|
|
36
36
|
"native/bin",
|
|
37
|
-
"README.md"
|
|
37
|
+
"README.md",
|
|
38
|
+
"docs/native-performance.md"
|
|
38
39
|
],
|
|
39
40
|
"scripts": {
|
|
40
41
|
"test": "vitest run",
|
|
41
42
|
"test:watch": "vitest",
|
|
42
43
|
"test:integration": "vitest run test/extension.integration.test.ts test/pi-runtime.test.ts test/pi-sdk.integration.test.ts",
|
|
43
|
-
"test:native": "cargo test --manifest-path native/pi-undo-fs/Cargo.toml && vitest run test/native-restore.test.ts test/durable-pack.test.ts test/packed-recovery.test.ts",
|
|
44
|
+
"test:native": "cargo test --manifest-path native/pi-undo-fs/Cargo.toml && vitest run test/native-restore.test.ts test/native-directory-scan.test.ts test/root-discovery.test.ts test/durable-pack.test.ts test/packed-recovery.test.ts",
|
|
44
45
|
"build:native": "cargo build --release --manifest-path native/pi-undo-fs/Cargo.toml",
|
|
45
46
|
"typecheck": "tsc --noEmit",
|
|
46
47
|
"pack:dry-run": "npm pack --dry-run"
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { GitRunError, runSupervisedProcess } from "./git-runner.ts";
|
|
2
|
+
import { checkOperation, OperationError, operationProcessOptions, rethrowOperationFailure } from "./operation-context.ts";
|
|
3
|
+
|
|
4
|
+
/** 在私有目录探测,防止旧 helper 把 --capabilities 当成工作区里的请求文件。 */
|
|
5
|
+
export async function probeNativeCapability(
|
|
6
|
+
executable: string,
|
|
7
|
+
capability: string,
|
|
8
|
+
isolatedDirectory: string,
|
|
9
|
+
): Promise<boolean> {
|
|
10
|
+
const budget = operationProcessOptions(5_000);
|
|
11
|
+
try {
|
|
12
|
+
const result = await runSupervisedProcess({
|
|
13
|
+
command: executable,
|
|
14
|
+
args: ["--capabilities"],
|
|
15
|
+
cwd: isolatedDirectory,
|
|
16
|
+
...budget,
|
|
17
|
+
outputLimitBytes: 64 * 1024,
|
|
18
|
+
outputOverflow: "terminate",
|
|
19
|
+
});
|
|
20
|
+
if (!result.stopped) throw new GitRunError("git_termination_failed", "native 能力探测进程未能确认终止");
|
|
21
|
+
if (result.outcome === "cancelled") {
|
|
22
|
+
checkOperation();
|
|
23
|
+
throw new OperationError("operation_cancelled", "native 能力探测已被取消");
|
|
24
|
+
}
|
|
25
|
+
checkOperation();
|
|
26
|
+
if (result.outcome !== "exit" || result.code !== 0) return false;
|
|
27
|
+
const value: unknown = JSON.parse(result.stdout.toString("utf8"));
|
|
28
|
+
return typeof value === "object" && value !== null && "ok" in value && value.ok === true &&
|
|
29
|
+
"capabilities" in value && Array.isArray(value.capabilities) && value.capabilities.includes(capability);
|
|
30
|
+
} catch (error) {
|
|
31
|
+
rethrowOperationFailure(error);
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { access, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
import { GitRunError, runSupervisedProcess } from "./git-runner.ts";
|
|
7
|
+
import { probeNativeCapability } from "./native-capabilities.ts";
|
|
8
|
+
import { nativeExecutable } from "./native-restore.ts";
|
|
9
|
+
import { checkOperation, configuredTimeout, OperationError, operationProcessOptions } from "./operation-context.ts";
|
|
10
|
+
|
|
11
|
+
export interface NativeRepositoryCandidate {
|
|
12
|
+
readonly path: string;
|
|
13
|
+
readonly dev: bigint;
|
|
14
|
+
readonly ino: bigint;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface NativeDirectoryScan {
|
|
18
|
+
readonly directories: number;
|
|
19
|
+
readonly repositories: readonly NativeRepositoryCandidate[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface NativeDirectoryScanPort {
|
|
23
|
+
scan(workspaceRoot: string): Promise<NativeDirectoryScan | undefined>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** 只缓存 helper 能力;每个拓扑检查点均重新完整扫描工作区,包括 ignored 目录。 */
|
|
27
|
+
export class NativeDirectoryScanner implements NativeDirectoryScanPort {
|
|
28
|
+
private readonly executable: string | undefined;
|
|
29
|
+
private capability: Promise<boolean> | undefined;
|
|
30
|
+
|
|
31
|
+
constructor(executable = nativeExecutable()) {
|
|
32
|
+
this.executable = process.env.PI_UNDO_DISABLE_NATIVE === "1" || process.platform === "win32"
|
|
33
|
+
? undefined : executable;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async scan(workspaceRoot: string): Promise<NativeDirectoryScan | undefined> {
|
|
37
|
+
checkOperation();
|
|
38
|
+
if (this.executable === undefined || (this.capability !== undefined && !await this.capability)) return undefined;
|
|
39
|
+
const requestDirectory = await mkdtemp(join(tmpdir(), "pi-undo-native-scan-"));
|
|
40
|
+
try {
|
|
41
|
+
this.capability ??= this.supportsScan(requestDirectory).catch((error) => {
|
|
42
|
+
// 取消或未确认终止不能污染后续操作的能力缓存。
|
|
43
|
+
this.capability = undefined;
|
|
44
|
+
throw error;
|
|
45
|
+
});
|
|
46
|
+
if (!await this.capability) return undefined;
|
|
47
|
+
const budget = operationProcessOptions(configuredTimeout("PI_UNDO_OPERATION_TIMEOUT_MS", 300_000));
|
|
48
|
+
const requestPath = join(requestDirectory, "request.json");
|
|
49
|
+
await writeFile(requestPath, JSON.stringify({ schemaVersion: 1, workspaceRoot }), { mode: 0o600, flag: "wx" });
|
|
50
|
+
const result = await runSupervisedProcess({
|
|
51
|
+
command: this.executable,
|
|
52
|
+
args: ["--scan-directories", requestPath],
|
|
53
|
+
...budget,
|
|
54
|
+
outputLimitBytes: 32 * 1024 * 1024,
|
|
55
|
+
outputOverflow: "terminate",
|
|
56
|
+
});
|
|
57
|
+
if (!result.stopped) throw new GitRunError("git_termination_failed", "native 目录扫描进程未能确认终止");
|
|
58
|
+
if (result.outcome === "cancelled") {
|
|
59
|
+
checkOperation();
|
|
60
|
+
throw new OperationError("operation_cancelled", "native 目录扫描已被取消");
|
|
61
|
+
}
|
|
62
|
+
if (result.outcome === "timeout") throw new OperationError("operation_timeout", "native 目录扫描超时");
|
|
63
|
+
if (result.outcome !== "exit" || result.code !== 0) {
|
|
64
|
+
throw new Error(`native 目录扫描失败:${result.stderr.toString("utf8").trim() || result.outcome}`);
|
|
65
|
+
}
|
|
66
|
+
checkOperation();
|
|
67
|
+
return parseScanResponse(result.stdout.toString("utf8"));
|
|
68
|
+
} finally {
|
|
69
|
+
await rm(requestDirectory, { recursive: true, force: true }).catch(() => {});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private async supportsScan(directory: string): Promise<boolean> {
|
|
74
|
+
try {
|
|
75
|
+
await access(this.executable!, constants.X_OK);
|
|
76
|
+
} catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
return probeNativeCapability(this.executable!, "scan-directories-v1", directory);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function parseScanResponse(text: string): NativeDirectoryScan | undefined {
|
|
84
|
+
const value: unknown = JSON.parse(text);
|
|
85
|
+
// 深度上限是执行器的资源边界;此时丢弃部分结果,由 TypeScript 从根完整重扫。
|
|
86
|
+
if (isRecord(value) && value.ok === false && value.code === "depth_limit") return undefined;
|
|
87
|
+
if (!isRecord(value) || value.ok !== true || !Number.isSafeInteger(value.directories) ||
|
|
88
|
+
(value.directories as number) < 1 || !Array.isArray(value.repositories) ||
|
|
89
|
+
value.repositories.length >= (value.directories as number)) {
|
|
90
|
+
throw new Error("native 目录扫描响应无效");
|
|
91
|
+
}
|
|
92
|
+
const seen = new Set<string>();
|
|
93
|
+
const repositories = value.repositories.map((candidate): NativeRepositoryCandidate => {
|
|
94
|
+
if (!isRecord(candidate) || typeof candidate.path !== "string" || candidate.path.includes("\0") ||
|
|
95
|
+
candidate.path.split("/").some((part) => part === "" || part === "." || part === ".." || part === ".git") ||
|
|
96
|
+
seen.has(candidate.path)) {
|
|
97
|
+
throw new Error("native 仓库候选路径无效");
|
|
98
|
+
}
|
|
99
|
+
seen.add(candidate.path);
|
|
100
|
+
return { path: candidate.path, dev: parseIdentity(candidate.dev), ino: parseIdentity(candidate.ino) };
|
|
101
|
+
});
|
|
102
|
+
return { directories: value.directories as number, repositories };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function parseIdentity(value: unknown): bigint {
|
|
106
|
+
if (typeof value !== "string" || !/^(?:0|[1-9][0-9]{0,19})$/.test(value) || BigInt(value) > (1n << 64n) - 1n) {
|
|
107
|
+
throw new Error("native 目录身份字段无效");
|
|
108
|
+
}
|
|
109
|
+
return BigInt(value);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
113
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
114
|
+
}
|
package/src/native-restore.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { dirname, join } from "node:path";
|
|
|
6
6
|
import type { DurablePack } from "./durable-pack.ts";
|
|
7
7
|
import { GitRunError, runSupervisedProcess } from "./git-runner.ts";
|
|
8
8
|
import { OperationError, type OperationProcessOptions, operationProcessOptions, rethrowOperationFailure } from "./operation-context.ts";
|
|
9
|
+
import { probeNativeCapability } from "./native-capabilities.ts";
|
|
9
10
|
import type { MutationJournal } from "./mutation-journal.ts";
|
|
10
11
|
|
|
11
12
|
const NATIVE_TIMEOUT_MS = 120_000;
|
|
@@ -23,6 +24,7 @@ export async function createNativeFileBatch(options: {
|
|
|
23
24
|
readonly journal: MutationJournal;
|
|
24
25
|
/** 测试注入用;默认使用随包分发的平台二进制。 */
|
|
25
26
|
readonly executable?: string;
|
|
27
|
+
readonly requiredCapability?: "restore-files-v2";
|
|
26
28
|
}): Promise<NativeFileBatch | undefined> {
|
|
27
29
|
if (process.env.PI_UNDO_DISABLE_NATIVE === "1") return undefined;
|
|
28
30
|
const executable = options.executable ?? nativeExecutable();
|
|
@@ -32,6 +34,9 @@ export async function createNativeFileBatch(options: {
|
|
|
32
34
|
} catch {
|
|
33
35
|
return undefined;
|
|
34
36
|
}
|
|
37
|
+
if (options.requiredCapability !== undefined && !await probeNativeCapability(
|
|
38
|
+
executable, options.requiredCapability, dirname(options.journal.storagePath),
|
|
39
|
+
)) return undefined;
|
|
35
40
|
const execute = async (pack: DurablePack, requestPath: string, verifyOnly: boolean): Promise<void> => {
|
|
36
41
|
const paths = pack.paths();
|
|
37
42
|
if (paths.length === 0) return;
|
|
@@ -85,6 +90,13 @@ export async function createNativeFileBatch(options: {
|
|
|
85
90
|
};
|
|
86
91
|
}
|
|
87
92
|
|
|
93
|
+
export function nativeRestoreCapability(pack: DurablePack): "restore-files-v2" | undefined {
|
|
94
|
+
const paths = pack.paths();
|
|
95
|
+
const deletes = paths.filter((path) => pack.artifacts(path)?.target === null);
|
|
96
|
+
return deletes.some((path) => path.includes("/")) || (deletes.length > 0 && deletes.length < paths.length)
|
|
97
|
+
? "restore-files-v2" : undefined;
|
|
98
|
+
}
|
|
99
|
+
|
|
88
100
|
export function nativeExecutable(): string | undefined {
|
|
89
101
|
const platform = process.platform === "darwin"
|
|
90
102
|
? "darwin"
|
package/src/restore-engine.ts
CHANGED
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
import { assertManifest, assertOperationId, canonicalJson, checksum } from "./encoding.ts";
|
|
19
19
|
import { MutationJournal } from "./mutation-journal.ts";
|
|
20
20
|
import { allCompleted, checkOperation, rethrowOperationFailure, isUnconfirmedExit, operationFailure, withRecoveryBudget } from "./operation-context.ts";
|
|
21
|
-
import { createNativeFileBatch } from "./native-restore.ts";
|
|
21
|
+
import { createNativeFileBatch, nativeRestoreCapability } from "./native-restore.ts";
|
|
22
22
|
import { recoverPackedMutations } from "./packed-recovery.ts";
|
|
23
23
|
import type { ManifestId, RestorePath, SnapshotManifest, SnapshotRoot } from "./model.ts";
|
|
24
24
|
import {
|
|
@@ -311,6 +311,7 @@ export class RestoreEngine {
|
|
|
311
311
|
workspaceRoot: this.workspaceRoot,
|
|
312
312
|
planDigest: cached.planDigest,
|
|
313
313
|
journal: cacheJournal,
|
|
314
|
+
requiredCapability: nativeRestoreCapability(cached.pack),
|
|
314
315
|
});
|
|
315
316
|
if (native === undefined || !await native.verifySource(cached.pack)) return false;
|
|
316
317
|
if (fromPersistentIndex) {
|
|
@@ -862,6 +863,7 @@ export class RestoreEngine {
|
|
|
862
863
|
workspaceRoot: this.workspaceRoot,
|
|
863
864
|
planDigest: plan.planDigest,
|
|
864
865
|
journal: options.mutationJournal,
|
|
866
|
+
requiredCapability: nativeRestoreCapability(durablePack),
|
|
865
867
|
})
|
|
866
868
|
: undefined;
|
|
867
869
|
if (durablePack !== undefined && nativeFileBatch === undefined) {
|
|
@@ -978,17 +980,16 @@ export class RestoreEngine {
|
|
|
978
980
|
currentPaths: ReadonlyMap<string, OwnedPath>,
|
|
979
981
|
targetPaths: ReadonlyMap<string, OwnedPath>,
|
|
980
982
|
): boolean {
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
return writeOnly || deleteOnly;
|
|
983
|
+
if (plan.deletePaths.length + plan.writePaths.length === 0) return false;
|
|
984
|
+
if (process.platform === "win32" && plan.deletePaths.length > 0) return false;
|
|
985
|
+
if (!plan.deletePaths.every((path) =>
|
|
986
|
+
currentPaths.get(path)?.entry.kind === "file" && targetPaths.get(path) === undefined)) return false;
|
|
987
|
+
if (!plan.writePaths.every((path) =>
|
|
988
|
+
targetPaths.get(path)?.entry.kind === "file" &&
|
|
989
|
+
(currentPaths.get(path) === undefined || currentPaths.get(path)?.entry.kind === "file"))) return false;
|
|
990
|
+
// 与 helper 的句柄上限一致;目录创建、类型替换及过多父目录继续使用 TypeScript。
|
|
991
|
+
const parents = new Set([...plan.deletePaths, ...plan.writePaths].flatMap(strictPathAncestors));
|
|
992
|
+
return process.platform === "win32" || parents.size <= 128;
|
|
992
993
|
}
|
|
993
994
|
|
|
994
995
|
private async applyNativeFilePlan(
|
package/src/root-discovery.ts
CHANGED
|
@@ -4,9 +4,11 @@ import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
|
4
4
|
import { checksum, topologyFingerprint } from "./encoding.ts";
|
|
5
5
|
import { GitRunError, GitRunner } from "./git-runner.ts";
|
|
6
6
|
import type { DiscoveryRoot } from "./model.ts";
|
|
7
|
+
import { NativeDirectoryScanner, type NativeDirectoryScanPort, type NativeRepositoryCandidate } from "./native-directory-scan.ts";
|
|
7
8
|
import { allCompleted, checkOperation, OperationError, reportOperationProgress } from "./operation-context.ts";
|
|
8
9
|
|
|
9
10
|
const DIRECTORY_SCAN_CONCURRENCY = 16;
|
|
11
|
+
const NATIVE_SCAN_MIN_DIRECTORIES = 64;
|
|
10
12
|
const GIT_POINTER_MAX_BYTES = 4096;
|
|
11
13
|
|
|
12
14
|
interface RepositoryInfo {
|
|
@@ -57,8 +59,8 @@ export type RootDiscoveryErrorCode = "workspace_not_found" | "discovery_failed";
|
|
|
57
59
|
export class RootDiscoveryError extends Error {
|
|
58
60
|
readonly code: RootDiscoveryErrorCode;
|
|
59
61
|
|
|
60
|
-
constructor(code: RootDiscoveryErrorCode, message: string) {
|
|
61
|
-
super(message);
|
|
62
|
+
constructor(code: RootDiscoveryErrorCode, message: string, options?: ErrorOptions) {
|
|
63
|
+
super(message, options);
|
|
62
64
|
this.name = "RootDiscoveryError";
|
|
63
65
|
this.code = code;
|
|
64
66
|
}
|
|
@@ -70,8 +72,9 @@ export interface RootDiscovery {
|
|
|
70
72
|
|
|
71
73
|
export class RootDiscovery {
|
|
72
74
|
private readonly git: GitRunner;
|
|
75
|
+
private lastDirectoryCount: number | undefined;
|
|
73
76
|
|
|
74
|
-
constructor(git = new GitRunner()) {
|
|
77
|
+
constructor(git = new GitRunner(), private readonly nativeScan: NativeDirectoryScanPort = new NativeDirectoryScanner()) {
|
|
75
78
|
this.git = git;
|
|
76
79
|
}
|
|
77
80
|
|
|
@@ -114,6 +117,24 @@ export class RootDiscovery {
|
|
|
114
117
|
let level: Array<{ readonly path: string; readonly inspect: boolean }> = [{ path: directory, inspect: false }];
|
|
115
118
|
let scanned = 0;
|
|
116
119
|
reportOperationProgress("scan_directories");
|
|
120
|
+
// 上次规模只选择执行器;小工作区仍完整重扫,目录增长后会重新使用 native。
|
|
121
|
+
const native = this.lastDirectoryCount !== undefined && this.lastDirectoryCount < NATIVE_SCAN_MIN_DIRECTORIES
|
|
122
|
+
? undefined : await this.nativeScan.scan(workspaceIdentity);
|
|
123
|
+
if (native !== undefined) {
|
|
124
|
+
this.lastDirectoryCount = native.directories;
|
|
125
|
+
for (let index = 0; index < native.repositories.length; index += DIRECTORY_SCAN_CONCURRENCY) {
|
|
126
|
+
await allCompleted(native.repositories.slice(index, index + DIRECTORY_SCAN_CONCURRENCY).map(async (candidate) => {
|
|
127
|
+
checkOperation();
|
|
128
|
+
const path = join(workspaceIdentity, candidate.path);
|
|
129
|
+
await assertNativeCandidate(path, candidate);
|
|
130
|
+
const inspection = await this.inspectRepository(path, workspaceIdentity);
|
|
131
|
+
await assertNativeCandidate(path, candidate);
|
|
132
|
+
this.recordInspection(workspaceIdentity, inspection, activeRoots);
|
|
133
|
+
}));
|
|
134
|
+
}
|
|
135
|
+
reportOperationProgress(`scan_directories:${native.directories}`);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
117
138
|
while (level.length > 0) {
|
|
118
139
|
checkOperation();
|
|
119
140
|
const next: Array<{ readonly path: string; readonly inspect: true }> = [];
|
|
@@ -128,6 +149,7 @@ export class RootDiscovery {
|
|
|
128
149
|
}
|
|
129
150
|
level = next;
|
|
130
151
|
}
|
|
152
|
+
this.lastDirectoryCount = scanned;
|
|
131
153
|
}
|
|
132
154
|
|
|
133
155
|
private async scanDirectoryNode(
|
|
@@ -139,16 +161,7 @@ export class RootDiscovery {
|
|
|
139
161
|
if (!await isSafeDirectory(candidate.path, workspaceIdentity)) return [];
|
|
140
162
|
if (candidate.inspect) {
|
|
141
163
|
const inspection = await this.inspectRepository(candidate.path, workspaceIdentity);
|
|
142
|
-
|
|
143
|
-
activeRoots.set(
|
|
144
|
-
inspection.repository.absoluteRoot,
|
|
145
|
-
this.activeRoot(workspaceIdentity, inspection.repository),
|
|
146
|
-
);
|
|
147
|
-
} else if (inspection.kind === "broken") {
|
|
148
|
-
activeRoots.set(inspection.absoluteRoot, brokenRoot(workspaceIdentity, inspection.absoluteRoot));
|
|
149
|
-
} else if (inspection.kind === "stale") {
|
|
150
|
-
activeRoots.set(inspection.absoluteRoot, staleWorktreeRoot(workspaceIdentity, inspection.absoluteRoot));
|
|
151
|
-
}
|
|
164
|
+
this.recordInspection(workspaceIdentity, inspection, activeRoots);
|
|
152
165
|
if (!await isSafeDirectory(candidate.path, workspaceIdentity)) return [];
|
|
153
166
|
}
|
|
154
167
|
checkOperation();
|
|
@@ -159,6 +172,20 @@ export class RootDiscovery {
|
|
|
159
172
|
.map((entry) => ({ path: join(candidate.path, entry.name), inspect: true as const }));
|
|
160
173
|
}
|
|
161
174
|
|
|
175
|
+
private recordInspection(
|
|
176
|
+
workspaceIdentity: string,
|
|
177
|
+
inspection: RepositoryInspection,
|
|
178
|
+
activeRoots: Map<string, DiscoveredRoot>,
|
|
179
|
+
): void {
|
|
180
|
+
if (inspection.kind === "active") {
|
|
181
|
+
activeRoots.set(inspection.repository.absoluteRoot, this.activeRoot(workspaceIdentity, inspection.repository));
|
|
182
|
+
} else if (inspection.kind === "broken") {
|
|
183
|
+
activeRoots.set(inspection.absoluteRoot, brokenRoot(workspaceIdentity, inspection.absoluteRoot));
|
|
184
|
+
} else if (inspection.kind === "stale") {
|
|
185
|
+
activeRoots.set(inspection.absoluteRoot, staleWorktreeRoot(workspaceIdentity, inspection.absoluteRoot));
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
162
189
|
private async inspectRepository(candidate: string, workspaceIdentity: string): Promise<RepositoryInspection> {
|
|
163
190
|
const marker = await gitMarkerState(candidate);
|
|
164
191
|
if (marker === "absent") {
|
|
@@ -295,6 +322,19 @@ export class RootDiscovery {
|
|
|
295
322
|
}
|
|
296
323
|
}
|
|
297
324
|
|
|
325
|
+
async function assertNativeCandidate(path: string, candidate: NativeRepositoryCandidate): Promise<void> {
|
|
326
|
+
try {
|
|
327
|
+
const metadata = await lstat(path, { bigint: true });
|
|
328
|
+
if (!metadata.isDirectory() || metadata.dev !== candidate.dev || metadata.ino !== candidate.ino ||
|
|
329
|
+
await realpath(path) !== path) {
|
|
330
|
+
throw new RootDiscoveryError("discovery_failed", `native 扫描后仓库目录身份发生变化:${candidate.path}`);
|
|
331
|
+
}
|
|
332
|
+
} catch (error) {
|
|
333
|
+
if (error instanceof RootDiscoveryError) throw error;
|
|
334
|
+
throw new RootDiscoveryError("discovery_failed", `无法复核 native 仓库候选:${candidate.path}`, { cause: error });
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
298
338
|
function syntheticRoot(workspaceIdentity: string): DiscoveredRoot {
|
|
299
339
|
return {
|
|
300
340
|
absoluteRoot: workspaceIdentity,
|