@davideasden/pi-undo 0.1.1 → 0.1.2

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 CHANGED
@@ -1,84 +1,215 @@
1
1
  # pi-undo
2
2
 
3
- `pi-undo` Pi 0.80.10 提供持久化的 `/undo` `/redo`。一次操作对应一个完整 Agent run,并同时恢复 Pi session tree 的逻辑位置和工作区文件。
3
+ Persistent undo and redo for [Pi](https://github.com/badlogic/pi-mono) that restores both the agent session and workspace files without requiring users to manage Git.
4
4
 
5
- 实现使用 Pi session JSONL、私有 snapshot store WAL journal。它不会在项目中创建 `.git`,也不会要求用户执行 `git commit`、`git stash`、`git reset` 或维护隐藏分支。
5
+ Each history entry represents one completed agent run. When you undo or redo, `pi-undo` moves Pi's session tree to the matching point and restores the corresponding workspace state.
6
6
 
7
- ## 安装与本地试用
7
+ ## Features
8
8
 
9
- 安装本地 package:
9
+ - Persistent `/undo` and `/redo` across Pi restarts.
10
+ - Restores the Pi session and workspace files together.
11
+ - Preserves redo state with a safety snapshot taken immediately before undo.
12
+ - Integrates with Pi's native `/tree` navigation.
13
+ - Supports ordinary directories, Git repositories, nested repositories, and initialized submodules.
14
+ - Uses durable journals and restart recovery for interrupted operations.
15
+ - Detects conflicting external file changes and fails closed instead of overwriting unknown content.
16
+ - Does not require `git commit`, `git stash`, `git reset`, hidden branches, or any other user-managed Git workflow.
17
+
18
+ ## Requirements
19
+
20
+ - Pi `0.80.10` or a compatible release.
21
+ - Node.js `22.19.0` or later.
22
+ - Git available on `PATH`.
23
+
24
+ Git is used internally to create private, content-addressed snapshots. You do not need to initialize a repository or run Git commands yourself.
25
+
26
+ ## Installation
27
+
28
+ Install the published Pi package from npm:
29
+
30
+ ```bash
31
+ pi install npm:@davideasden/pi-undo
32
+ ```
33
+
34
+ Restart Pi after installation if the extension is not loaded in the current session.
35
+
36
+ To install a local checkout instead:
10
37
 
11
38
  ```bash
12
39
  pi install ./path/to/pi-undo
13
40
  ```
14
41
 
15
- 开发时也可以直接加载 extension
42
+ During development, you can load the extension directly:
16
43
 
17
44
  ```bash
18
45
  pi -e /absolute/path/to/pi-undo/extensions/pi-undo.ts
19
46
  ```
20
47
 
21
- 要求 Node.js 22.19 或更高版本,并且系统可执行 `git`。
48
+ ## Usage
49
+
50
+ Work with Pi normally. `pi-undo` records a boundary after each completed agent run.
51
+
52
+ ### Diff
53
+
54
+ ```text
55
+ /diff
56
+ ```
57
+
58
+ `/diff` reviews the files changed by the most recent completed agent run. In TUI mode, select a file to open a colored, scrollable before-and-after comparison. Binary files are listed but do not receive a line-by-line diff.
59
+
60
+ Use a one-based history position to inspect an earlier applied run, where `1` is the most recent:
61
+
62
+ ```text
63
+ /diff 2
64
+ ```
65
+
66
+ Print, JSON, and RPC modes report a one-line file and line-count summary instead of opening the interactive viewer.
67
+
68
+ ### Undo
69
+
70
+ ```text
71
+ /undo
72
+ ```
73
+
74
+ `/undo` returns to the previous completed agent run on the current branch. Before restoring that checkpoint, it captures the current workspace as a redo safety snapshot.
22
75
 
23
- ## 使用方式
76
+ After a successful undo, `pi-undo` tries to put the original prompt back into an empty TUI editor. It never overwrites text already present in the editor. RPC mode only reports the refill request, while print and JSON modes do not promise editor refill behavior.
24
77
 
25
- - `/undo`:回退当前 branch 上最后一个完整 Agent run。执行前会保存当前工作区作为 redo safety snapshot。
26
- - `/redo`:恢复最近一次 undo 前保存的工作区状态,而不是简单覆盖成 checkpoint 的原始 after snapshot。
27
- - `/tree`:仍使用 Pi 原生命令。package 在导航前预检目标 boundary 并保存 rescue snapshot,导航完成后恢复该 boundary 对应的文件状态。
78
+ ### Redo
28
79
 
29
- 如果 Agent 正在 streaming,`/undo` 和 `/redo` 会先请求 abort,再等待 Agent idle;等待超时或工具仍未停止时不会恢复文件。streaming 中的普通 `/tree` 会被取消,用户可在 idle 后重试。
80
+ ```text
81
+ /redo
82
+ ```
30
83
 
31
- undo 成功后,TUI 编辑器为空时会尝试回填原始 prompt;编辑器已有文字时不会覆盖用户输入。RPC 只报告回填请求,print/json 模式不承诺编辑器回填。
84
+ `/redo` restores the session and workspace captured immediately before the corresponding undo. It does not simply reapply the checkpoint's original after-snapshot, so edits preserved by the redo safety snapshot are restored correctly.
32
85
 
33
- ## 数据与 Git 边界
86
+ ### Tree Navigation
34
87
 
35
- 私有数据位于 Pi session 目录下:
88
+ Continue to use Pi's native command:
89
+
90
+ ```text
91
+ /tree
92
+ ```
93
+
94
+ Before Pi moves to another session-tree boundary, `pi-undo` validates the target and records a rescue snapshot. After navigation, it restores the workspace associated with the selected boundary.
95
+
96
+ If the agent is streaming, `/undo` and `/redo` request an abort and wait for the agent to become idle. If the wait times out or tools are still running, no files are restored. Native `/tree` navigation is cancelled while streaming and can be retried when Pi is idle.
97
+
98
+ The footer shows the available history, for example:
99
+
100
+ ```text
101
+ undo:2 redo:1
102
+ ```
103
+
104
+ ## How It Works
105
+
106
+ Private state is stored alongside the Pi session:
36
107
 
37
108
  ```text
38
109
  <sessionDir>/.pi-undo/
39
110
  ```
40
111
 
41
- snapshot 使用独立 Git object database 和临时 index,但不会写入用户仓库的 `HEAD`、index、refs、reflog、stash、config 或其他真实 Git metadata。
112
+ For every completed agent run, `pi-undo` captures the session boundary and a workspace manifest. Snapshots use a private Git object database and temporary index. They do not use the repository's normal history.
113
+
114
+ Undo, redo, and tree restoration follow the same high-level flow:
115
+
116
+ 1. Validate the current session, workspace topology, and target manifest.
117
+ 2. Write a durable write-ahead log (WAL) before changing files.
118
+ 3. Capture a safety or rescue snapshot when required.
119
+ 4. Move the Pi session to the target logical boundary.
120
+ 5. Restore workspace paths with fingerprint checks and no-clobber installation.
121
+ 6. Verify the resulting session and workspace before committing the journal.
42
122
 
43
- restore 对普通文件和 symlink 使用原路径同目录、同文件系统的 quarantine。目标安装采用 no-clobber 语义:外部进程在 restore 期间重建路径时,`pi-undo` 不会覆盖该路径,并会保留 transaction mutation WAL 与仍可验证的 source/target artifact,供重启恢复或人工诊断。
123
+ Ordinary files and symbolic links are restored through same-directory, same-filesystem quarantine artifacts. For regular files, source capture uses a hard link and checks the path fingerprint and inode identity again immediately before removing the original path. This narrows the external-concurrency window, but cannot make the final check and unlink operation atomic in pure Node.js.
44
124
 
45
- 普通文件的 source capture 先创建 hard link,再在删除原路径前重新检查路径 fingerprint 和 inode identity。这是纯 Node.js 能提供的 CAS-before-unlink best-effort;它显著缩小并发窗口,但不能原子合并最后一次检查与 `unlink`。
125
+ ## Safety and Recovery
46
126
 
47
- 普通目录、outer repository、nested repository 和已初始化 submodule 都按 root forest 独立捕获和恢复。nested repository/submodule 只恢复内部工作区文件:
127
+ `pi-undo` does not modify the user's Git `HEAD`, index, refs, reflogs, stash, configuration, or other repository metadata. Nested repositories and initialized submodules are captured as independent roots, but only their working files are restored. The extension does not switch their real `HEAD`, run `git submodule update`, or recreate deleted `.git` metadata.
48
128
 
49
- - 不切换真实 HEAD;
50
- - 不运行 `git submodule update`;
51
- - 不修改真实 index、refs、reflog 或 `.git`;
52
- - 不重建被删除的真实 `.git`。
129
+ If Pi or the process stops during a restore, the next session startup reads the transaction journal:
53
130
 
54
- ## 持久化与故障恢复
131
+ - Without a trusted cursor marker, it restores the rollback manifest and marks the transaction aborted.
132
+ - With a trusted cursor marker, it restores the target manifest, completes cursor durability, and commits the transaction.
133
+ - If the journal, session identity, logical leaf, manifest, cursor, or workspace contents conflict, it enters `recovery required` and blocks further history mutation.
55
134
 
56
- 每次 undo、redo tree restore 都先写 WAL journal。Pi 重启后:
135
+ When the footer reports `recovery_required`, first back up the workspace and Pi session JSONL. Transaction diagnostics are stored in:
136
+
137
+ ```text
138
+ <sessionDir>/.pi-undo/transactions/
139
+ ```
57
140
 
58
- - 没有可信 cursor marker:恢复 rollback manifest,并把操作终止为 `ABORTED`;
59
- - 有可信 cursor marker:重新恢复 target manifest、补齐 cursor durability,并把操作完成为 `COMMITTED`;
60
- - journal、session identity、logical leaf、manifest 或 cursor payload 不一致:进入 `recovery required`,停止新的 undo/redo、tree mutation 和 Agent boundary。
141
+ The relevant transaction may contain `descriptor.json`, `restore-plan.json`, `state.json`, and mutation journal data. Do not delete the `.pi-undo` directory without a backup: unresolved quarantine artifacts may contain the only preserved copy of a file version.
61
142
 
62
- 故障 journal 会保留在 `<sessionDir>/.pi-undo/transactions/`,用于诊断和人工恢复。不要在未备份的情况下删除该目录。footer 显示 `recovery required` 时,应先保存工作区和 session JSONL,再检查对应 transaction 的 `descriptor.json`、`restore-plan.json` 和 `state.json`。
143
+ ## Limitations
63
144
 
64
- ## 明确限制
145
+ - Git-ignored files are not included in snapshots and are not created or deleted during restore.
146
+ - Empty directories are not represented in snapshots.
147
+ - Real `.git` metadata is never restored.
148
+ - The workspace lock coordinates `pi-undo` instances, but cannot prevent editors, watchers, or other processes from writing files concurrently.
149
+ - Quarantine and repeated fingerprint checks reduce, but cannot eliminate, the final check-to-unlink race with arbitrary external processes.
150
+ - When ownership or contents cannot be proven, `pi-undo` preserves the available versions and enters `recovery required` instead of guessing, deleting, or overwriting.
151
+ - `--no-session` mode has no durable session cursor. Undo and redo can work in-process, but crash persistence is not guaranteed.
152
+ - Uninitialized gitlinks are not initialized automatically. A broken nested repository causes capture to fail instead of being silently skipped.
153
+ - Workspace files and Pi JSONL are not an operating-system-level ACID transaction. Snapshots, WAL records, cursor markers, verification, and idempotent recovery are used to converge to the old or new state.
65
154
 
66
- - Git ignored 文件不进入 snapshot,也不会被 restore 创建或删除。
67
- - 空目录不属于 snapshot。
68
- - package 不恢复真实 `.git` metadata。
69
- - package 锁只能协调其他 `pi-undo` 实例,不能阻止外部编辑器、watcher 或其他进程同时写文件;无法证明安全时会 fail closed。
70
- - quarantine 不能消除任意外部进程带来的最终 `check -> unlink` TOCTOU 窗口。无法证明 original、target 或 artifact 的内容与归属时,package 会保留现场并进入 `recovery required`,不会猜测、强制删除或覆盖未知内容。
71
- - `--no-session` 模式没有可耐久验证的 session cursor,只提供进程内能力,不能承诺进程崩溃后的 undo/redo 持久化。
72
- - uninitialized gitlink 不会被自动初始化;broken nested root 会使 capture 失败,而不是静默遗漏。
73
- - package 不对工作区文件与 Pi JSONL 提供操作系统级 ACID 事务;它通过 snapshot、WAL、cursor marker 和幂等 set-state recovery 收敛到旧状态或新状态。
155
+ ## Development
74
156
 
75
- ## 开发验证
157
+ Clone the repository and install dependencies:
158
+
159
+ ```bash
160
+ git clone https://github.com/DavidEasden/pi-undo.git
161
+ cd pi-undo
162
+ npm install
163
+ ```
164
+
165
+ The development dependency for Pi points to:
166
+
167
+ ```text
168
+ resources/pi-0.80.10/packages/coding-agent
169
+ ```
170
+
171
+ For type checking and the real `AgentSession` integration tests, place the Pi `0.80.10` source tree at that path, install its workspace dependencies, and build the required workspace packages. The published package does not include `resources/`.
172
+
173
+ Useful project paths:
174
+
175
+ ```text
176
+ extensions/pi-undo.ts Pi extension entry point
177
+ src/ Snapshot, journal, restore, and runtime implementation
178
+ test/ Unit, integration, recovery, and fault-injection tests
179
+ ```
180
+
181
+ ## Testing
182
+
183
+ Run the complete test suite:
76
184
 
77
185
  ```bash
78
186
  npm test
187
+ ```
188
+
189
+ Run tests in watch mode:
190
+
191
+ ```bash
192
+ npm run test:watch
193
+ ```
194
+
195
+ Run the Pi runtime and extension integration tests:
196
+
197
+ ```bash
79
198
  npm run test:integration
199
+ ```
200
+
201
+ Run TypeScript type checking:
202
+
203
+ ```bash
80
204
  npm run typecheck
205
+ ```
206
+
207
+ Inspect the npm package contents before publishing:
208
+
209
+ ```bash
81
210
  npm run pack:dry-run
82
211
  ```
83
212
 
84
- 真实 Pi `AgentSession` 集成测试需要先安装并构建 `resources/pi-0.80.10` 的 workspace packages。
213
+ ## License
214
+
215
+ [MIT](LICENSE)
@@ -8,12 +8,15 @@ import type {
8
8
  } from "@earendil-works/pi-coding-agent";
9
9
 
10
10
  import type { UndoController } from "../src/controller.ts";
11
+ import { browseDiff } from "../src/diff-ui.ts";
12
+ import { computeCheckpointDiff, type DiffSource, formatDiffSummary, sanitizeDisplayText } from "../src/diff-view.ts";
11
13
  import { createPiUndoRuntime } from "../src/pi-runtime.ts";
12
14
  import { StatusReporter } from "../src/status-reporter.ts";
13
15
 
14
16
  export interface PiUndoRuntime {
15
17
  readonly controller: UndoController;
16
18
  readonly reporter: StatusReporter;
19
+ readonly diffSource?: DiffSource;
17
20
  readonly recovery?: { readonly files?: number; readonly opId?: string };
18
21
  setCommandContext?(context: ExtensionCommandContext | undefined): void;
19
22
  isInternalNavigation?(): boolean;
@@ -76,6 +79,45 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
76
79
  else active.reporter.setReady(history.undoCount, history.redoCount);
77
80
  };
78
81
 
82
+ const runDiff = async (args: string, context: ExtensionCommandContext): Promise<void> => {
83
+ const active = runtime;
84
+ if (active === undefined || active.diffSource === undefined) {
85
+ context.ui.notify("pi-undo session unavailable", "warning");
86
+ return;
87
+ }
88
+ const checkpoints = active.controller.listCheckpoints();
89
+ if (checkpoints.length === 0) {
90
+ context.ui.notify("No recorded Agent runs to diff", "info");
91
+ return;
92
+ }
93
+ const trimmed = args.trim();
94
+ const position = trimmed === "" ? 1 : Number(trimmed);
95
+ if (!Number.isInteger(position) || position < 1 || position > checkpoints.length) {
96
+ context.ui.notify(`Invalid run number; recorded runs: ${checkpoints.length}`, "warning");
97
+ return;
98
+ }
99
+ // 1 = 最近一次 run(栈顶)。
100
+ const checkpoint = checkpoints[checkpoints.length - position]!;
101
+ let diffs;
102
+ try {
103
+ diffs = await computeCheckpointDiff(active.diffSource, checkpoint);
104
+ } catch (error) {
105
+ context.ui.notify(`Unable to load diff: ${sanitizeDisplayText(errorMessage(error), 120)}`, "error");
106
+ return;
107
+ }
108
+ if (runtime !== active) return;
109
+ if (diffs.length === 0) {
110
+ context.ui.notify("This run changed no files", "info");
111
+ return;
112
+ }
113
+ const label = runLabel(checkpoint.rawPrompt, position);
114
+ if (context.mode !== "tui") {
115
+ context.ui.notify(`${label} — ${formatDiffSummary(diffs)}`, "info");
116
+ return;
117
+ }
118
+ await browseDiff(context, label, diffs);
119
+ };
120
+
79
121
  pi.registerCommand("undo", {
80
122
  description: "Undo the last completed Agent run",
81
123
  handler: async (_args: string, context: ExtensionCommandContext) => runCommand("undo", context),
@@ -84,6 +126,10 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
84
126
  description: "Redo the last undone Agent run",
85
127
  handler: async (_args: string, context: ExtensionCommandContext) => runCommand("redo", context),
86
128
  });
129
+ pi.registerCommand("diff", {
130
+ description: "Review files changed by an Agent run (latest, or /diff N)",
131
+ handler: async (args: string, context: ExtensionCommandContext) => runDiff(args, context),
132
+ });
87
133
 
88
134
  pi.on("session_start", async (_event: unknown, context: ExtensionContext) => initialize(context));
89
135
  pi.on("input", async (event: InputEvent) => {
@@ -130,4 +176,10 @@ function errorMessage(error: unknown): string {
130
176
  return error instanceof Error ? error.message : String(error);
131
177
  }
132
178
 
179
+ function runLabel(rawPrompt: string, position: number): string {
180
+ const singleLine = sanitizeDisplayText(rawPrompt, 1_000);
181
+ const preview = singleLine.length > 48 ? `${singleLine.slice(0, 47)}…` : singleLine;
182
+ return preview === "" ? `Run #${position}` : `Run #${position}: ${preview}`;
183
+ }
184
+
133
185
  export default createPiUndoExtension(createPiUndoRuntime);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davideasden/pi-undo",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Persistent workspace undo and redo for Pi",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -42,7 +42,8 @@
42
42
  "pack:dry-run": "npm pack --dry-run"
43
43
  },
44
44
  "peerDependencies": {
45
- "@earendil-works/pi-coding-agent": "^0.80.10"
45
+ "@earendil-works/pi-coding-agent": "^0.80.10",
46
+ "@earendil-works/pi-tui": "*"
46
47
  },
47
48
  "dependencies": {
48
49
  "proper-lockfile": "4.1.2"
package/src/controller.ts CHANGED
@@ -108,6 +108,8 @@ export interface HistoryState {
108
108
  }
109
109
 
110
110
  export interface UndoController {
111
+ /** 只读的 undo 栈视图(栈底在前);仅供 /diff 等展示使用。 */
112
+ listCheckpoints(): readonly CheckpointRecord[];
111
113
  prepareInput(text: string, context: InputContext): Promise<InputEventResult>;
112
114
  beforeAgentStart(): Promise<void>;
113
115
  agentSettled(): Promise<void>;
@@ -175,6 +177,10 @@ export class UndoControllerImpl implements UndoController {
175
177
  return { undoCount: this.undoStack.length, redoCount: this.redoStack.length, locked: this.locked };
176
178
  }
177
179
 
180
+ listCheckpoints(): readonly CheckpointRecord[] {
181
+ return [...this.undoStack];
182
+ }
183
+
178
184
  async prepareInput(text: string, context: InputContext): Promise<InputEventResult> {
179
185
  if (this.locked || this.operationInFlight) return { action: "handled" };
180
186
  if (context.streaming || text.length === 0) return { action: "continue" };
package/src/diff-ui.ts ADDED
@@ -0,0 +1,116 @@
1
+ import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
2
+ import { renderDiff } from "@earendil-works/pi-coding-agent";
3
+ import { type Component, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
4
+
5
+ import { buildFileLabel, type FileDiff, sanitizeDisplayText } from "./diff-view.ts";
6
+
7
+ const CLOSE_LABEL = "← Close";
8
+
9
+ /**
10
+ * 在 TUI 中交互浏览一次 Agent run 的逐文件 diff:
11
+ * 上层 select 列出改动文件,选中后用可滚动彩色查看器展示该文件 diff,返回后回到列表。
12
+ */
13
+ export async function browseDiff(context: ExtensionCommandContext, title: string, diffs: readonly FileDiff[]): Promise<void> {
14
+ const labels = new Map<string, FileDiff>();
15
+ const options: string[] = [];
16
+ for (const diff of diffs) {
17
+ const label = uniqueLabel(buildFileLabel(diff), labels);
18
+ labels.set(label, diff);
19
+ options.push(label);
20
+ }
21
+ options.push(CLOSE_LABEL);
22
+
23
+ for (;;) {
24
+ const choice = await context.ui.select(title, options);
25
+ if (choice === undefined || choice === CLOSE_LABEL) return;
26
+ const diff = labels.get(choice);
27
+ if (diff === undefined) return;
28
+ await showFileDiff(context, diff);
29
+ }
30
+ }
31
+
32
+ function uniqueLabel(label: string, used: ReadonlyMap<string, FileDiff>): string {
33
+ if (!used.has(label)) return label;
34
+ let index = 2;
35
+ while (used.has(`${label} (${index})`)) index += 1;
36
+ return `${label} (${index})`;
37
+ }
38
+
39
+ async function showFileDiff(context: ExtensionCommandContext, diff: FileDiff): Promise<void> {
40
+ await context.ui.custom<void>((tui, theme, _keybindings, done) => new DiffViewer(tui, theme, diff, done), {
41
+ overlay: true,
42
+ overlayOptions: { width: "90%", maxHeight: "90%" },
43
+ });
44
+ }
45
+
46
+ /** 只读、可滚动的单文件 diff 查看器;不修改任何会话或存储状态。 */
47
+ class DiffViewer implements Component {
48
+ private readonly lines: readonly string[];
49
+ private offset = 0;
50
+
51
+ constructor(
52
+ private readonly tui: { requestRender(force?: boolean): void },
53
+ private readonly theme: Theme,
54
+ private readonly diff: FileDiff,
55
+ private readonly done: (result: void) => void,
56
+ ) {
57
+ this.lines = this.buildLines();
58
+ }
59
+
60
+ invalidate(): void {}
61
+
62
+ private buildLines(): string[] {
63
+ if (this.diff.kind === "binary") return [this.theme.fg("dim", "Binary file changed; line-by-line diff unavailable.")];
64
+ if (this.diff.diff === "") return [this.theme.fg("dim", "No textual change (mode or type only).")];
65
+ return renderDiff(this.diff.diff).split("\n");
66
+ }
67
+
68
+ private viewportRows(): number {
69
+ const rows = process.stdout.rows ?? 24;
70
+ // 预留 overlay 边框、标题、footer 的空间。
71
+ return Math.max(3, Math.min(this.lines.length, rows - 8));
72
+ }
73
+
74
+ private maxOffset(): number {
75
+ return Math.max(0, this.lines.length - this.viewportRows());
76
+ }
77
+
78
+ handleInput(data: string): void {
79
+ const rows = this.viewportRows();
80
+ const previous = this.offset;
81
+ if (matchesKey(data, "escape") || matchesKey(data, "q") || matchesKey(data, "enter")) {
82
+ this.done();
83
+ return;
84
+ }
85
+ if (matchesKey(data, "up") || matchesKey(data, "k")) this.offset -= 1;
86
+ else if (matchesKey(data, "down") || matchesKey(data, "j")) this.offset += 1;
87
+ else if (matchesKey(data, "pageUp")) this.offset -= rows;
88
+ else if (matchesKey(data, "pageDown") || matchesKey(data, "space")) this.offset += rows;
89
+ else if (matchesKey(data, "home") || matchesKey(data, "g")) this.offset = 0;
90
+ else if (matchesKey(data, "end") || matchesKey(data, "shift+g")) this.offset = this.maxOffset();
91
+ this.offset = Math.max(0, Math.min(this.maxOffset(), this.offset));
92
+ if (this.offset !== previous) this.tui.requestRender();
93
+ }
94
+
95
+ render(width: number): string[] {
96
+ const rows = this.viewportRows();
97
+ // 终端 resize 会改变 viewportRows,重绘时必须重新夹紧滚动位置。
98
+ this.offset = Math.max(0, Math.min(this.maxOffset(), this.offset));
99
+ const stats = this.diff.kind === "binary"
100
+ ? "binary"
101
+ : `+${this.diff.additions} -${this.diff.deletions}`;
102
+ const safePath = sanitizeDisplayText(this.diff.path, 240);
103
+ const header = truncateToWidth(this.theme.fg("accent", `${this.diff.status.toUpperCase()} ${safePath}`) + this.theme.fg("dim", ` ${stats}`), width);
104
+ const out: string[] = [header, this.theme.fg("borderMuted", "─".repeat(Math.min(width, 80)))];
105
+ const visible = this.lines.slice(this.offset, this.offset + rows);
106
+ for (const line of visible) {
107
+ out.push(visibleWidth(line) > width ? truncateToWidth(line, width) : line);
108
+ }
109
+ for (let index = visible.length; index < rows; index += 1) out.push("");
110
+ const position = this.lines.length <= rows
111
+ ? "all"
112
+ : `${this.offset + 1}-${Math.min(this.offset + rows, this.lines.length)}/${this.lines.length}`;
113
+ out.push(this.theme.fg("dim", `↑↓/PgUp/PgDn scroll · ${position} · Esc/q close`));
114
+ return out;
115
+ }
116
+ }
@@ -0,0 +1,169 @@
1
+ import { generateDiffString } from "@earendil-works/pi-coding-agent";
2
+
3
+ import type { ManifestId, RestorePath, SnapshotManifest } from "./model.ts";
4
+
5
+ /** SnapshotStore 的只读子集;/diff 只需要读取 manifest 与 blob,不触碰任何恢复路径。 */
6
+ export interface DiffSource {
7
+ loadManifest(id: ManifestId): Promise<SnapshotManifest>;
8
+ listTree(id: ManifestId, root: string): Promise<readonly RestorePath[]>;
9
+ readBlob(id: ManifestId, root: string, blobId: string): Promise<Uint8Array>;
10
+ }
11
+
12
+ export type FileDiffStatus = "added" | "deleted" | "modified";
13
+ export type FileDiffKind = "text" | "binary" | "symlink";
14
+
15
+ export interface FileDiff {
16
+ readonly path: string;
17
+ readonly status: FileDiffStatus;
18
+ readonly kind: FileDiffKind;
19
+ readonly additions: number;
20
+ readonly deletions: number;
21
+ /** 带行号的展示 diff;二进制文件或无逐行差异时为空字符串。 */
22
+ readonly diff: string;
23
+ }
24
+
25
+ export interface CheckpointDiffRequest {
26
+ readonly beforeManifestId: ManifestId;
27
+ readonly afterManifestId: ManifestId;
28
+ readonly changedPaths: readonly string[];
29
+ }
30
+
31
+ interface LeafEntry {
32
+ readonly rootPath: string;
33
+ readonly entry: RestorePath;
34
+ }
35
+
36
+ interface LeafContent {
37
+ readonly binary: boolean;
38
+ readonly symlink: boolean;
39
+ readonly text: string;
40
+ }
41
+
42
+ /**
43
+ * 基于 checkpoint 的 before/after 快照计算逐文件 diff。
44
+ * changedPaths 中的目录项与两侧都不存在的路径会被跳过,只保留叶子文件与符号链接。
45
+ */
46
+ export async function computeCheckpointDiff(
47
+ source: DiffSource,
48
+ request: CheckpointDiffRequest,
49
+ ): Promise<FileDiff[]> {
50
+ const [before, after] = await Promise.all([
51
+ source.loadManifest(request.beforeManifestId),
52
+ source.loadManifest(request.afterManifestId),
53
+ ]);
54
+ const [beforePaths, afterPaths] = await Promise.all([
55
+ readLeafEntries(source, before),
56
+ readLeafEntries(source, after),
57
+ ]);
58
+
59
+ const result: FileDiff[] = [];
60
+ for (const path of request.changedPaths) {
61
+ const beforeLeaf = beforePaths.get(path);
62
+ const afterLeaf = afterPaths.get(path);
63
+ if (beforeLeaf === undefined && afterLeaf === undefined) continue;
64
+ const status: FileDiffStatus = beforeLeaf === undefined
65
+ ? "added"
66
+ : afterLeaf === undefined ? "deleted" : "modified";
67
+ const beforeContent = beforeLeaf === undefined
68
+ ? emptyContent()
69
+ : await readLeafContent(source, request.beforeManifestId, beforeLeaf);
70
+ const afterContent = afterLeaf === undefined
71
+ ? emptyContent()
72
+ : await readLeafContent(source, request.afterManifestId, afterLeaf);
73
+ if (beforeContent.binary || afterContent.binary) {
74
+ result.push({ path, status, kind: "binary", additions: 0, deletions: 0, diff: "" });
75
+ continue;
76
+ }
77
+ const kind: FileDiffKind = beforeContent.symlink || afterContent.symlink ? "symlink" : "text";
78
+ const { diff } = generateDiffString(beforeContent.text, afterContent.text);
79
+ result.push({ path, status, kind, ...countChanges(diff), diff });
80
+ }
81
+ return result;
82
+ }
83
+
84
+ async function readLeafEntries(source: DiffSource, manifest: SnapshotManifest): Promise<Map<string, LeafEntry>> {
85
+ const result = new Map<string, LeafEntry>();
86
+ for (const root of manifest.roots) {
87
+ if (root.state !== "active" || root.treeId === null) continue;
88
+ for (const entry of await source.listTree(manifest.manifestId, root.relativeRoot)) {
89
+ if (entry.kind === "directory") continue;
90
+ const path = root.relativeRoot === "." ? entry.relativePath : `${root.relativeRoot}/${entry.relativePath}`;
91
+ if (!result.has(path)) result.set(path, { rootPath: root.relativeRoot, entry });
92
+ }
93
+ }
94
+ return result;
95
+ }
96
+
97
+ async function readLeafContent(source: DiffSource, manifestId: ManifestId, leaf: LeafEntry): Promise<LeafContent> {
98
+ if (leaf.entry.kind === "symlink") {
99
+ return { binary: false, symlink: true, text: sanitizeDiffContent(leaf.entry.linkText ?? "") };
100
+ }
101
+ if (leaf.entry.blobId === null) {
102
+ return { binary: true, symlink: false, text: "" };
103
+ }
104
+ const bytes = await source.readBlob(manifestId, leaf.rootPath, leaf.entry.blobId);
105
+ const text = decodeLossless(bytes);
106
+ return text === null
107
+ ? { binary: true, symlink: false, text: "" }
108
+ : { binary: false, symlink: false, text: sanitizeDiffContent(text) };
109
+ }
110
+
111
+ function emptyContent(): LeafContent {
112
+ return { binary: false, symlink: false, text: "" };
113
+ }
114
+
115
+ function decodeLossless(bytes: Uint8Array): string | null {
116
+ if (bytes.includes(0)) return null;
117
+ const buffer = Buffer.from(bytes);
118
+ const text = buffer.toString("utf8");
119
+ return Buffer.from(text, "utf8").equals(buffer) ? text : null;
120
+ }
121
+
122
+ function sanitizeDiffContent(text: string): string {
123
+ return text
124
+ .replace(/\x1B/g, "[ESC]")
125
+ .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001A\u001C-\u001F\u007F]/g, "?");
126
+ }
127
+
128
+ function countChanges(diff: string): { additions: number; deletions: number } {
129
+ let additions = 0;
130
+ let deletions = 0;
131
+ for (const line of diff.split("\n")) {
132
+ if (line.startsWith("+")) additions += 1;
133
+ else if (line.startsWith("-")) deletions += 1;
134
+ }
135
+ return { additions, deletions };
136
+ }
137
+
138
+ const STATUS_CHAR: Record<FileDiffStatus, string> = { added: "A", deleted: "D", modified: "M" };
139
+
140
+ /** 单个文件的一行摘要,用于文件清单与非 TUI 降级展示。 */
141
+ export function buildFileLabel(diff: FileDiff): string {
142
+ const stats = diff.kind === "binary"
143
+ ? "(binary)"
144
+ : diff.additions === 0 && diff.deletions === 0 ? "(no textual change)" : `+${diff.additions} -${diff.deletions}`;
145
+ return `${STATUS_CHAR[diff.status]} ${sanitizeDisplayText(diff.path, 240)} ${stats}`;
146
+ }
147
+
148
+ /** 整个 checkpoint 的一行摘要,用于非 TUI 模式的 notify。 */
149
+ export function formatDiffSummary(diffs: readonly FileDiff[]): string {
150
+ let additions = 0;
151
+ let deletions = 0;
152
+ for (const diff of diffs) {
153
+ additions += diff.additions;
154
+ deletions += diff.deletions;
155
+ }
156
+ const paths = diffs.map((diff) => sanitizeDisplayText(diff.path, 160)).join(", ");
157
+ return sanitizeDisplayText(`${diffs.length} file(s), +${additions} -${deletions}: ${paths}`, 500);
158
+ }
159
+
160
+ /** 清理可能进入终端 UI 的外部文本,避免 ANSI/控制序列注入。 */
161
+ export function sanitizeDisplayText(value: string, maxLength = 200): string {
162
+ return value
163
+ .replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, "")
164
+ .replace(/\x1B\][^\u0007]*(?:\u0007|\x1B\\)/g, "")
165
+ .replace(/[\u0000-\u001F\u007F]+/g, " ")
166
+ .replace(/\s+/g, " ")
167
+ .trim()
168
+ .slice(0, maxLength);
169
+ }
package/src/pi-runtime.ts CHANGED
@@ -154,6 +154,7 @@ export async function createPiUndoRuntime(context: ExtensionContext, pi: Extensi
154
154
  return {
155
155
  controller,
156
156
  reporter: new StatusReporter(context),
157
+ diffSource: store,
157
158
  recovery: startupRecovery.kind === "locked"
158
159
  ? { files: startupRecovery.files, opId: startupRecovery.opId }
159
160
  : undefined,
@@ -22,6 +22,8 @@ import {
22
22
  } from "./quarantine.ts";
23
23
  import { SnapshotStoreError, type SnapshotStore } from "./snapshot-store.ts";
24
24
 
25
+ const PREPARED_PLAN_CACHE_LIMIT = 16;
26
+
25
27
  export interface RestorePlan {
26
28
  currentManifestId: ManifestId;
27
29
  targetManifestId: ManifestId;
@@ -69,6 +71,12 @@ interface OwnedPath {
69
71
  readonly root: SnapshotRoot;
70
72
  }
71
73
 
74
+ interface PreparedRestorePlan {
75
+ readonly plan: RestorePlan;
76
+ readonly currentPaths: ReadonlyMap<string, OwnedPath>;
77
+ readonly targetPaths: ReadonlyMap<string, OwnedPath>;
78
+ }
79
+
72
80
  interface MutationContext {
73
81
  readonly phase: RestoreMutation["phase"];
74
82
  readonly sourceManifestId: ManifestId;
@@ -88,6 +96,7 @@ export class RestoreEngine {
88
96
  private readonly store: SnapshotStore;
89
97
  private readonly discovery: RootDiscovery;
90
98
  private readonly beforeMutation: RestoreEngineOptions["beforeMutation"];
99
+ private readonly preparedPlans = new Map<string, PreparedRestorePlan>();
91
100
 
92
101
  constructor(options: RestoreEngineOptions) {
93
102
  this.requestedWorkspaceRoot = resolve(options.workspaceRoot);
@@ -174,10 +183,33 @@ export class RestoreEngine {
174
183
  writePaths,
175
184
  ...(scope === undefined ? {} : { scopePaths: [...scope] }),
176
185
  };
177
- return {
186
+ const plan = {
178
187
  ...semanticPlan,
179
188
  planDigest: checksum(canonicalJson(semanticPlan)),
180
189
  };
190
+ this.rememberPreparedPlan(plan, currentPaths, targetPaths);
191
+ return plan;
192
+ }
193
+
194
+ private rememberPreparedPlan(
195
+ plan: RestorePlan,
196
+ currentPaths: ReadonlyMap<string, OwnedPath>,
197
+ targetPaths: ReadonlyMap<string, OwnedPath>,
198
+ ): void {
199
+ const cachedPlan = cloneRestorePlan(plan);
200
+ this.preparedPlans.set(preparedPlanKey(cachedPlan), { plan: cachedPlan, currentPaths, targetPaths });
201
+ while (this.preparedPlans.size > PREPARED_PLAN_CACHE_LIMIT) {
202
+ const oldest = this.preparedPlans.keys().next().value as string | undefined;
203
+ if (oldest === undefined) break;
204
+ this.preparedPlans.delete(oldest);
205
+ }
206
+ }
207
+
208
+ private takePreparedPlan(plan: RestorePlan): PreparedRestorePlan | undefined {
209
+ const key = preparedPlanKey(plan);
210
+ const prepared = this.preparedPlans.get(key);
211
+ if (prepared !== undefined) this.preparedPlans.delete(key);
212
+ return prepared;
181
213
  }
182
214
 
183
215
  async apply(
@@ -285,8 +317,10 @@ export class RestoreEngine {
285
317
  }
286
318
  assertCompatibleManifests(current, target);
287
319
  let expectedPlan: RestorePlan;
320
+ let prepared: PreparedRestorePlan | undefined;
288
321
  try {
289
322
  expectedPlan = await this.plan(current, target, plan.scopePaths);
323
+ prepared = this.takePreparedPlan(expectedPlan);
290
324
  } catch (error) {
291
325
  if (error instanceof SnapshotStoreError && error.code === "object_missing") {
292
326
  return { code: "restore_failed_safe", verifiedPaths: 0, totalPaths: 0 };
@@ -307,10 +341,9 @@ export class RestoreEngine {
307
341
  } catch {
308
342
  return { code: "restore_failed_safe", verifiedPaths: 0, totalPaths: 0 };
309
343
  }
310
- const [currentPaths, targetPaths] = await Promise.all([
311
- this.readOwnedPaths(current),
312
- this.readOwnedPaths(target),
313
- ]);
344
+ const [currentPaths, targetPaths] = prepared === undefined
345
+ ? await Promise.all([this.readOwnedPaths(current), this.readOwnedPaths(target)])
346
+ : [prepared.currentPaths, prepared.targetPaths];
314
347
  const quarantine = new QuarantineManager({
315
348
  workspaceRoot: this.requestedWorkspaceRoot,
316
349
  journal: options.mutationJournal,
@@ -817,12 +850,11 @@ export class RestoreEngine {
817
850
  }
818
851
  }
819
852
 
820
- const live = await this.store.capture(topology, undefined, {
853
+ const livePaths = await this.store.listVisibleLeafPaths(topology, {
821
854
  excludePaths: mutationJournal === undefined ? undefined : [...await mutationJournal.activeArtifacts()],
822
855
  });
823
- await this.store.assertComplete(live.manifestId);
824
- for (const [path, owned] of await this.readOwnedPaths(live)) {
825
- if (owned.entry.kind !== "directory" && !allowedPaths.has(path)) {
856
+ for (const path of livePaths) {
857
+ if (!allowedPaths.has(path)) {
826
858
  throw new Error(`complete coverage 发现 manifest 集合外路径:${path}`);
827
859
  }
828
860
  }
@@ -1025,6 +1057,22 @@ export class RestoreEngine {
1025
1057
  }
1026
1058
  }
1027
1059
 
1060
+ function preparedPlanKey(plan: RestorePlan): string {
1061
+ return `${plan.currentManifestId}\0${plan.targetManifestId}\0${plan.planDigest}`;
1062
+ }
1063
+
1064
+ function cloneRestorePlan(plan: RestorePlan): RestorePlan {
1065
+ return {
1066
+ currentManifestId: plan.currentManifestId,
1067
+ targetManifestId: plan.targetManifestId,
1068
+ boundaryRoots: [...plan.boundaryRoots],
1069
+ deletePaths: [...plan.deletePaths],
1070
+ writePaths: [...plan.writePaths],
1071
+ ...(plan.scopePaths === undefined ? {} : { scopePaths: [...plan.scopePaths] }),
1072
+ planDigest: plan.planDigest,
1073
+ };
1074
+ }
1075
+
1028
1076
  function sameEntry(left: RestorePath, right: RestorePath): boolean {
1029
1077
  return left.kind === right.kind &&
1030
1078
  left.mode === right.mode &&
@@ -5,6 +5,8 @@ import { checksum, topologyFingerprint } from "./encoding.ts";
5
5
  import { GitRunner } from "./git-runner.ts";
6
6
  import type { DiscoveryRoot } from "./model.ts";
7
7
 
8
+ const DIRECTORY_SCAN_CONCURRENCY = 16;
9
+
8
10
  interface RepositoryInfo {
9
11
  readonly absoluteRoot: string;
10
12
  readonly commonGitDir: string;
@@ -87,22 +89,27 @@ export class RootDiscovery {
87
89
  directory: string,
88
90
  activeRoots: Map<string, DiscoveredRoot>,
89
91
  ): Promise<void> {
90
- if (!await isSafeDirectory(directory, workspaceIdentity)) {
91
- return;
92
- }
93
- const entries = await readdir(directory, { withFileTypes: true });
94
- if (!await isSafeDirectory(directory, workspaceIdentity)) {
95
- return;
96
- }
97
- for (const entry of entries) {
98
- if (entry.name === ".git" || entry.isSymbolicLink() || !entry.isDirectory()) {
99
- continue;
100
- }
101
- const candidate = join(directory, entry.name);
102
- if (!await isSafeDirectory(candidate, workspaceIdentity)) {
103
- continue;
92
+ let level: Array<{ readonly path: string; readonly inspect: boolean }> = [{ path: directory, inspect: false }];
93
+ while (level.length > 0) {
94
+ const next: Array<{ readonly path: string; readonly inspect: true }> = [];
95
+ for (let index = 0; index < level.length; index += DIRECTORY_SCAN_CONCURRENCY) {
96
+ const children = await Promise.all(level.slice(index, index + DIRECTORY_SCAN_CONCURRENCY).map(
97
+ (candidate) => this.scanDirectoryNode(workspaceIdentity, candidate, activeRoots),
98
+ ));
99
+ for (const group of children) next.push(...group);
104
100
  }
105
- const inspection = await this.inspectRepository(candidate, workspaceIdentity);
101
+ level = next;
102
+ }
103
+ }
104
+
105
+ private async scanDirectoryNode(
106
+ workspaceIdentity: string,
107
+ candidate: { readonly path: string; readonly inspect: boolean },
108
+ activeRoots: Map<string, DiscoveredRoot>,
109
+ ): Promise<Array<{ readonly path: string; readonly inspect: true }>> {
110
+ if (!await isSafeDirectory(candidate.path, workspaceIdentity)) return [];
111
+ if (candidate.inspect) {
112
+ const inspection = await this.inspectRepository(candidate.path, workspaceIdentity);
106
113
  if (inspection.kind === "active") {
107
114
  activeRoots.set(
108
115
  inspection.repository.absoluteRoot,
@@ -111,8 +118,13 @@ export class RootDiscovery {
111
118
  } else if (inspection.kind === "broken") {
112
119
  activeRoots.set(inspection.absoluteRoot, brokenRoot(workspaceIdentity, inspection.absoluteRoot));
113
120
  }
114
- await this.scanDirectory(workspaceIdentity, candidate, activeRoots);
121
+ if (!await isSafeDirectory(candidate.path, workspaceIdentity)) return [];
115
122
  }
123
+ const entries = await readdir(candidate.path, { withFileTypes: true });
124
+ if (!await isSafeDirectory(candidate.path, workspaceIdentity)) return [];
125
+ return entries
126
+ .filter((entry) => entry.name !== ".git" && !entry.isSymbolicLink() && entry.isDirectory())
127
+ .map((entry) => ({ path: join(candidate.path, entry.name), inspect: true as const }));
116
128
  }
117
129
 
118
130
  private async inspectRepository(candidate: string, workspaceIdentity: string): Promise<RepositoryInspection> {
@@ -1,3 +1,4 @@
1
+ import type { Stats } from "node:fs";
1
2
  import { lstat, mkdir, mkdtemp, readFile, readdir, readlink, realpath, rm, stat } from "node:fs/promises";
2
3
  import { tmpdir } from "node:os";
3
4
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
@@ -30,6 +31,11 @@ const GC_METADATA_FILE = "gc.json";
30
31
  const GC_RETENTION_MS = 7 * 24 * 60 * 60 * 1_000;
31
32
  const IGNORE_POLICY = "git-check-ignore-v1";
32
33
  const NULL_DEVICE = process.platform === "win32" ? "NUL" : "/dev/null";
34
+ const TREE_CACHE_LIMIT = 256;
35
+ const HASH_BATCH_MAX_PATHS = 128;
36
+ const HASH_BATCH_MAX_ARGUMENT_BYTES = 24 * 1024;
37
+ const INDEX_BATCH_MAX_ENTRIES = 4_096;
38
+ const INDEX_BATCH_MAX_BYTES = 8 * 1024 * 1024;
33
39
 
34
40
  interface PinRecord {
35
41
  readonly schemaVersion: 1;
@@ -60,6 +66,13 @@ interface CapturedRootResult {
60
66
  readonly objectClosure: string;
61
67
  }
62
68
 
69
+ interface VisibleLeaf {
70
+ readonly relativePath: string;
71
+ readonly kind: "file" | "symlink";
72
+ readonly mode: number;
73
+ readonly fingerprint: string;
74
+ }
75
+
63
76
  export interface SnapshotStoreOptions {
64
77
  readonly storeRoot?: string;
65
78
  readonly git?: GitRunner;
@@ -93,6 +106,7 @@ export class SnapshotStoreError extends Error {
93
106
 
94
107
  export interface SnapshotStore {
95
108
  capture(topology: RootTopology, scope?: readonly string[], options?: CaptureOptions): Promise<SnapshotManifest>;
109
+ listVisibleLeafPaths(topology: RootTopology, options?: CaptureOptions): Promise<readonly string[]>;
96
110
  loadManifest(id: ManifestId): Promise<SnapshotManifest>;
97
111
  assertComplete(id: ManifestId): Promise<void>;
98
112
  listTree(id: ManifestId, root: string): Promise<readonly RestorePath[]>;
@@ -110,6 +124,8 @@ export class SnapshotStore {
110
124
  private readonly lock: WorkspaceLock;
111
125
  private readonly clock: () => number;
112
126
  private readonly manifestLocations = new Map<string, string>();
127
+ // Tree ID 是内容寻址且不可变;这里只缓存解析元数据,blob bytes 与完整性仍逐次读取/校验。
128
+ private readonly treeEntriesCache = new Map<string, Promise<CapturedTreeEntry[]>>();
113
129
 
114
130
  constructor(options: SnapshotStoreOptions = {}) {
115
131
  this.storeRoot = resolve(options.storeRoot ?? join(tmpdir(), "pi-undo-snapshot-store"));
@@ -204,6 +220,72 @@ export class SnapshotStore {
204
220
  }
205
221
  }
206
222
 
223
+ async listVisibleLeafPaths(
224
+ topology: RootTopology,
225
+ options: CaptureOptions = {},
226
+ ): Promise<readonly string[]> {
227
+ await this.assertPrivateStore(topology.workspaceIdentity);
228
+ const lockIdentity = `snapshot-store:${await prospectiveCanonicalPath(this.storesRoot)}`;
229
+ return this.lock.withLock(lockIdentity, () => this.listVisibleLeafPathsLocked(topology, options));
230
+ }
231
+
232
+ private async listVisibleLeafPathsLocked(
233
+ topology: RootTopology,
234
+ options: CaptureOptions,
235
+ ): Promise<readonly string[]> {
236
+ let transactionDirectory: string | undefined;
237
+ try {
238
+ if (topology.fingerprint !== topologyFingerprint(topology.workspaceIdentity, topology.roots)) {
239
+ throw new SnapshotStoreError("capture_failed", "topology fingerprint 与 roots 不匹配");
240
+ }
241
+ const artifactExclusions = captureExclusions(topology.workspaceIdentity, options.excludePaths);
242
+ await this.assertTopology(topology, "可见路径枚举前 topology 已变化");
243
+ if (topology.roots.some((root) => root.state === "broken")) {
244
+ throw new SnapshotStoreError("capture_failed", "broken root 不能静默进入可见路径枚举");
245
+ }
246
+
247
+ const storeDirectory = this.storeDirectory(topology);
248
+ const transactionsRoot = join(storeDirectory, "transactions");
249
+ await mkdir(transactionsRoot, { recursive: true });
250
+ transactionDirectory = await mkdtemp(join(transactionsRoot, "visible-"));
251
+ const result = new Set<string>();
252
+ for (const root of topology.roots) {
253
+ if (root.state !== "active") continue;
254
+ const gitDirectory = this.rootGitDirectory(storeDirectory, root);
255
+ await this.ensurePrivateRepository(gitDirectory);
256
+ await this.assertNoAlternates(gitDirectory);
257
+ const absoluteRoot = workspaceRootPath(topology.workspaceIdentity, root.relativeRoot);
258
+ const indexPath = join(transactionDirectory, `${rootStoreId(root)}.index`);
259
+ const environment = privateGitEnvironment(gitDirectory, absoluteRoot, indexPath);
260
+ await this.runGit(["read-tree", "--empty"], { cwd: absoluteRoot, env: environment });
261
+ await this.validateIgnoreQuery(absoluteRoot, environment, root.gitBacked);
262
+ const exclusions = topology.roots
263
+ .filter((candidate) => isStrictRootAncestor(root.relativeRoot, candidate.relativeRoot))
264
+ .map((candidate) => rootRelativePath(root.relativeRoot, candidate.relativeRoot));
265
+ const exactExclusions = ownedArtifactExclusions(topology.roots, root.relativeRoot, artifactExclusions);
266
+ for (const leaf of await this.collectVisibleLeaves(
267
+ absoluteRoot,
268
+ environment,
269
+ root.gitBacked,
270
+ [],
271
+ exclusions,
272
+ exactExclusions,
273
+ )) {
274
+ result.add(workspaceRelativePath(root.relativeRoot, leaf.relativePath));
275
+ }
276
+ }
277
+ await this.assertTopology(topology, "可见路径枚举期间 topology 已变化");
278
+ return [...result].sort(comparePaths);
279
+ } catch (error) {
280
+ if (error instanceof SnapshotStoreError) throw error;
281
+ throw new SnapshotStoreError("capture_failed", errorMessage(error), { cause: error });
282
+ } finally {
283
+ if (transactionDirectory !== undefined) {
284
+ await rm(transactionDirectory, { recursive: true, force: true }).catch(() => {});
285
+ }
286
+ }
287
+ }
288
+
207
289
  async loadManifest(id: ManifestId): Promise<SnapshotManifest> {
208
290
  const manifestPath = await this.findManifestPath(id);
209
291
  try {
@@ -242,7 +324,6 @@ export class SnapshotStore {
242
324
  }
243
325
  const gitDirectory = this.rootGitDirectory(storeDirectory, root);
244
326
  await this.assertNoAlternates(gitDirectory);
245
- await this.runPrivateGit(gitDirectory, ["cat-file", "-e", `${root.treeId}^{tree}`]);
246
327
  const entries = await this.readTreeEntries(gitDirectory, root.treeId);
247
328
  if (root.ignoredPresentPaths.some((ignoredPath) => entries.some(
248
329
  (entry) => isPathAtOrBelow(ignoredPath, entry.relativePath) ||
@@ -250,9 +331,7 @@ export class SnapshotStore {
250
331
  ))) {
251
332
  throw new SnapshotStoreError("object_missing", "ignored-present proof 与 root tree 冲突");
252
333
  }
253
- for (const entry of entries) {
254
- await this.runPrivateGit(gitDirectory, ["cat-file", "-e", `${entry.objectId}^{blob}`]);
255
- }
334
+ await this.assertObjectsComplete(gitDirectory, root.treeId, entries);
256
335
  if (root.objectClosure !== treeObjectClosure(root.treeId, entries)) {
257
336
  throw new SnapshotStoreError("object_missing", "root tree 对象闭包校验失败");
258
337
  }
@@ -493,10 +572,7 @@ export class SnapshotStore {
493
572
  throw new SnapshotStoreError("capture_failed", "git write-tree 未返回有效对象 ID");
494
573
  }
495
574
  const entries = await this.readTreeEntries(gitDirectory, treeId);
496
- await this.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
- }
575
+ await this.assertObjectsComplete(gitDirectory, treeId, entries);
500
576
  return {
501
577
  treeId,
502
578
  coverage,
@@ -565,13 +641,77 @@ export class SnapshotStore {
565
641
  exclusions: readonly string[],
566
642
  exactExclusions: readonly string[],
567
643
  ): Promise<void> {
568
- if (inclusions === null) {
569
- return;
644
+ const leaves = await this.collectVisibleLeaves(
645
+ cwd,
646
+ environment,
647
+ gitBacked,
648
+ inclusions,
649
+ exclusions,
650
+ exactExclusions,
651
+ );
652
+ const objectIds = new Map<string, string>();
653
+ for (const batch of hashPathBatches(leaves.filter((leaf) => leaf.kind === "file"))) {
654
+ for (const leaf of batch) await this.assertVisibleLeafUnchanged(cwd, leaf);
655
+ const output = await this.runGit([
656
+ "hash-object",
657
+ "-w",
658
+ "--no-filters",
659
+ "--",
660
+ ...batch.map((leaf) => leaf.relativePath),
661
+ ], { cwd, env: environment });
662
+ const hashes = parseObjectIdLines(output, batch.length);
663
+ for (const leaf of batch) await this.assertVisibleLeafUnchanged(cwd, leaf);
664
+ for (let index = 0; index < batch.length; index += 1) {
665
+ objectIds.set(batch[index]!.relativePath, hashes[index]!);
666
+ }
570
667
  }
571
- const pathspecs = inclusions.length === 0 ? ["."] : inclusions.map(literalPathspec);
572
- for (const excluded of exclusions) {
573
- pathspecs.push(excludeLiteralPathspec(excluded));
668
+ for (const leaf of leaves) {
669
+ if (leaf.kind !== "symlink") continue;
670
+ await this.assertVisibleLeafUnchanged(cwd, leaf);
671
+ const linkText = await readlink(join(cwd, ...leaf.relativePath.split("/")), { encoding: "buffer" });
672
+ decodeUtf8(linkText, "symlink target 不是可无损表示的 UTF-8");
673
+ const objectId = (await this.runGit(["hash-object", "-w", "--stdin"], {
674
+ cwd,
675
+ env: environment,
676
+ stdin: linkText,
677
+ })).trim();
678
+ if (!isObjectId(objectId)) {
679
+ throw new SnapshotStoreError("capture_failed", `文件对象 materialize 失败:${leaf.relativePath}`);
680
+ }
681
+ await this.assertVisibleLeafUnchanged(cwd, leaf);
682
+ objectIds.set(leaf.relativePath, objectId);
683
+ }
684
+ for (const indexInput of indexInfoBatches(leaves, objectIds)) {
685
+ await this.runGit(["update-index", "-z", "--index-info"], {
686
+ cwd,
687
+ env: environment,
688
+ stdin: indexInput,
689
+ });
690
+ }
691
+ }
692
+
693
+ private async assertVisibleLeafUnchanged(cwd: string, leaf: VisibleLeaf): Promise<void> {
694
+ await assertNoSymlinkEscape(cwd, leaf.relativePath);
695
+ const metadata = await lstat(join(cwd, ...leaf.relativePath.split("/"))).catch((error) => {
696
+ if (hasErrorCode(error, "ENOENT")) return null;
697
+ throw error;
698
+ });
699
+ if (metadata === null || visibleLeafFingerprint(metadata) !== leaf.fingerprint) {
700
+ throw new SnapshotStoreError("capture_failed", `捕获期间工作区叶子已变化:${leaf.relativePath}`);
574
701
  }
702
+ }
703
+
704
+ private async collectVisibleLeaves(
705
+ cwd: string,
706
+ environment: Readonly<Record<string, string | undefined>>,
707
+ gitBacked: boolean,
708
+ inclusions: readonly string[] | null,
709
+ exclusions: readonly string[],
710
+ exactExclusions: readonly string[],
711
+ ): Promise<VisibleLeaf[]> {
712
+ if (inclusions === null) return [];
713
+ const pathspecs = inclusions.length === 0 ? ["."] : inclusions.map(literalPathspec);
714
+ for (const excluded of exclusions) pathspecs.push(excludeLiteralPathspec(excluded));
575
715
  const queryEnvironment = gitBacked ? sourceGitEnvironment() : environment;
576
716
  const output = await this.runGitBytes([
577
717
  ...(gitBacked ? ["-c", "core.fsmonitor=false"] : []),
@@ -583,53 +723,38 @@ export class SnapshotStore {
583
723
  "--",
584
724
  ...pathspecs,
585
725
  ], { cwd, env: queryEnvironment });
726
+ const result: VisibleLeaf[] = [];
586
727
  for (const relativePath of parseNulPaths(output)) {
587
728
  if (
588
729
  exclusions.some((excluded) => isPathAtOrBelow(excluded, relativePath)) ||
589
730
  exactExclusions.includes(relativePath)
590
- ) {
591
- continue;
592
- }
731
+ ) continue;
593
732
  relativeSafePath(cwd, relativePath);
594
733
  await assertNoSymlinkEscape(cwd, relativePath);
595
- const absolutePath = join(cwd, ...relativePath.split("/"));
596
- const metadata = await lstat(absolutePath).catch((error) => {
597
- if (hasErrorCode(error, "ENOENT")) {
598
- return null;
599
- }
734
+ const metadata = await lstat(join(cwd, ...relativePath.split("/"))).catch((error) => {
735
+ if (hasErrorCode(error, "ENOENT")) return null;
600
736
  throw error;
601
737
  });
602
- if (metadata === null) {
603
- continue;
604
- }
605
- let mode: string;
606
- let objectId: string;
738
+ if (metadata === null) continue;
607
739
  if (metadata.isSymbolicLink()) {
608
- 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();
740
+ result.push({
741
+ relativePath,
742
+ kind: "symlink",
743
+ mode: 0o120000,
744
+ fingerprint: visibleLeafFingerprint(metadata),
745
+ });
616
746
  } 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();
747
+ result.push({
748
+ relativePath,
749
+ kind: "file",
750
+ mode: (metadata.mode & 0o111) === 0 ? 0o100644 : 0o100755,
751
+ fingerprint: visibleLeafFingerprint(metadata),
752
+ });
622
753
  } else {
623
754
  throw new SnapshotStoreError("capture_failed", `不支持的工作区文件类型:${relativePath}`);
624
755
  }
625
- if (!isObjectId(objectId)) {
626
- throw new SnapshotStoreError("capture_failed", `文件对象 materialize 失败:${relativePath}`);
627
- }
628
- await this.runGit(["update-index", "--add", "--cacheinfo", mode, objectId, relativePath], {
629
- cwd,
630
- env: environment,
631
- });
632
756
  }
757
+ return result;
633
758
  }
634
759
 
635
760
  private async validateIgnoreQuery(
@@ -688,8 +813,52 @@ export class SnapshotStore {
688
813
  }
689
814
 
690
815
  private async readTreeEntries(gitDirectory: string, treeId: string): Promise<CapturedTreeEntry[]> {
691
- const output = await this.runPrivateGitBytes(gitDirectory, ["ls-tree", "-r", "-l", "-z", treeId]);
692
- return parseTreeEntries(output);
816
+ const key = `${gitDirectory}\0${treeId}`;
817
+ const cached = this.treeEntriesCache.get(key);
818
+ if (cached !== undefined) return cached;
819
+ const pending = this.runPrivateGitBytes(gitDirectory, ["ls-tree", "-r", "-l", "-z", treeId])
820
+ .then(parseTreeEntries);
821
+ this.treeEntriesCache.set(key, pending);
822
+ while (this.treeEntriesCache.size > TREE_CACHE_LIMIT) {
823
+ const oldest = this.treeEntriesCache.keys().next().value as string | undefined;
824
+ if (oldest === undefined || oldest === key) break;
825
+ this.treeEntriesCache.delete(oldest);
826
+ }
827
+ try {
828
+ return await pending;
829
+ } catch (error) {
830
+ if (this.treeEntriesCache.get(key) === pending) this.treeEntriesCache.delete(key);
831
+ throw error;
832
+ }
833
+ }
834
+
835
+ private async assertObjectsComplete(
836
+ gitDirectory: string,
837
+ treeId: string,
838
+ entries: readonly CapturedTreeEntry[],
839
+ ): Promise<void> {
840
+ const expected = [
841
+ { objectId: treeId, type: "tree" },
842
+ ...[...new Set(entries.map((entry) => entry.objectId))].map((objectId) => ({ objectId, type: "blob" })),
843
+ ];
844
+ const output = await this.runGit(["cat-file", "--batch-check"], {
845
+ env: privateObjectEnvironment(gitDirectory),
846
+ stdin: `${expected.map((object) => object.objectId).join("\n")}\n`,
847
+ });
848
+ const lines = output.endsWith("\n") ? output.slice(0, -1).split("\n") : output.split("\n");
849
+ if (lines.length !== expected.length) throw new Error("Git object batch-check 输出数量不匹配");
850
+ for (let index = 0; index < expected.length; index += 1) {
851
+ const object = expected[index]!;
852
+ const match = lines[index]!.match(/^([0-9a-f]{40,64}) (blob|tree) ([0-9]+)$/);
853
+ if (
854
+ match === null ||
855
+ match[1] !== object.objectId ||
856
+ match[2] !== object.type ||
857
+ !Number.isSafeInteger(Number(match[3]))
858
+ ) {
859
+ throw new Error(`Git object batch-check 校验失败:${object.objectId}`);
860
+ }
861
+ }
693
862
  }
694
863
 
695
864
  private async readBlobText(gitDirectory: string, objectId: string): Promise<string> {
@@ -980,6 +1149,10 @@ function workspaceRootPath(workspaceIdentity: string, rootPath: string): string
980
1149
  return safe === "." ? workspaceIdentity : join(workspaceIdentity, ...safe.split("/"));
981
1150
  }
982
1151
 
1152
+ function workspaceRelativePath(rootPath: string, relativePath: string): string {
1153
+ return rootPath === "." ? relativePath : `${rootPath}/${relativePath}`;
1154
+ }
1155
+
983
1156
  function rootStoreId(root: RootTopologyIdentity): string {
984
1157
  return checksum(canonicalJson({
985
1158
  relativeRoot: root.relativeRoot,
@@ -1061,6 +1234,76 @@ function isolatedGitConfiguration(): Readonly<Record<string, string | undefined>
1061
1234
  };
1062
1235
  }
1063
1236
 
1237
+ function indexInfoBatches(
1238
+ leaves: readonly VisibleLeaf[],
1239
+ objectIds: ReadonlyMap<string, string>,
1240
+ ): Buffer[] {
1241
+ const result: Buffer[] = [];
1242
+ let records: string[] = [];
1243
+ let bytes = 0;
1244
+ for (const leaf of leaves) {
1245
+ const objectId = objectIds.get(leaf.relativePath);
1246
+ if (objectId === undefined) {
1247
+ throw new SnapshotStoreError("capture_failed", `文件对象 materialize 结果缺失:${leaf.relativePath}`);
1248
+ }
1249
+ const record = `${leaf.mode.toString(8)} ${objectId}\t${leaf.relativePath}\0`;
1250
+ const recordBytes = Buffer.byteLength(record, "utf8");
1251
+ if (
1252
+ records.length > 0 &&
1253
+ (records.length >= INDEX_BATCH_MAX_ENTRIES || bytes + recordBytes > INDEX_BATCH_MAX_BYTES)
1254
+ ) {
1255
+ result.push(Buffer.from(records.join(""), "utf8"));
1256
+ records = [];
1257
+ bytes = 0;
1258
+ }
1259
+ records.push(record);
1260
+ bytes += recordBytes;
1261
+ }
1262
+ if (records.length > 0) result.push(Buffer.from(records.join(""), "utf8"));
1263
+ return result;
1264
+ }
1265
+
1266
+ function visibleLeafFingerprint(metadata: Stats): string {
1267
+ return checksum(canonicalJson({
1268
+ kind: metadata.isSymbolicLink() ? "symlink" : metadata.isFile() ? "file" : "other",
1269
+ dev: metadata.dev,
1270
+ ino: metadata.ino,
1271
+ mode: metadata.mode,
1272
+ size: metadata.size,
1273
+ mtimeMs: metadata.mtimeMs,
1274
+ ctimeMs: metadata.ctimeMs,
1275
+ }));
1276
+ }
1277
+
1278
+ function hashPathBatches(leaves: readonly VisibleLeaf[]): VisibleLeaf[][] {
1279
+ const result: VisibleLeaf[][] = [];
1280
+ let current: VisibleLeaf[] = [];
1281
+ let argumentBytes = 0;
1282
+ for (const leaf of leaves) {
1283
+ const leafBytes = Buffer.byteLength(leaf.relativePath, "utf8") + 1;
1284
+ if (
1285
+ current.length > 0 &&
1286
+ (current.length >= HASH_BATCH_MAX_PATHS || argumentBytes + leafBytes > HASH_BATCH_MAX_ARGUMENT_BYTES)
1287
+ ) {
1288
+ result.push(current);
1289
+ current = [];
1290
+ argumentBytes = 0;
1291
+ }
1292
+ current.push(leaf);
1293
+ argumentBytes += leafBytes;
1294
+ }
1295
+ if (current.length > 0) result.push(current);
1296
+ return result;
1297
+ }
1298
+
1299
+ function parseObjectIdLines(output: string, expectedCount: number): string[] {
1300
+ const lines = output.endsWith("\n") ? output.slice(0, -1).split("\n") : output.split("\n");
1301
+ if (lines.length !== expectedCount || lines.some((line) => !isObjectId(line))) {
1302
+ throw new SnapshotStoreError("capture_failed", "git hash-object 批量输出无效");
1303
+ }
1304
+ return lines;
1305
+ }
1306
+
1064
1307
  function parseTreeEntries(output: Uint8Array): CapturedTreeEntry[] {
1065
1308
  const entries: CapturedTreeEntry[] = [];
1066
1309
  for (const record of splitNulRecords(output)) {