@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 +170 -39
- package/extensions/pi-undo.ts +52 -0
- package/package.json +3 -2
- package/src/controller.ts +6 -0
- package/src/diff-ui.ts +116 -0
- package/src/diff-view.ts +169 -0
- package/src/pi-runtime.ts +1 -0
- package/src/restore-engine.ts +57 -9
- package/src/root-discovery.ts +28 -16
- package/src/snapshot-store.ts +291 -48
package/README.md
CHANGED
|
@@ -1,84 +1,215 @@
|
|
|
1
1
|
# pi-undo
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
26
|
-
- `/redo`:恢复最近一次 undo 前保存的工作区状态,而不是简单覆盖成 checkpoint 的原始 after snapshot。
|
|
27
|
-
- `/tree`:仍使用 Pi 原生命令。package 在导航前预检目标 boundary 并保存 rescue snapshot,导航完成后恢复该 boundary 对应的文件状态。
|
|
78
|
+
### Redo
|
|
28
79
|
|
|
29
|
-
|
|
80
|
+
```text
|
|
81
|
+
/redo
|
|
82
|
+
```
|
|
30
83
|
|
|
31
|
-
undo
|
|
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
|
-
|
|
86
|
+
### Tree Navigation
|
|
34
87
|
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
125
|
+
## Safety and Recovery
|
|
46
126
|
|
|
47
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
213
|
+
## License
|
|
214
|
+
|
|
215
|
+
[MIT](LICENSE)
|
package/extensions/pi-undo.ts
CHANGED
|
@@ -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.
|
|
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
|
+
}
|
package/src/diff-view.ts
ADDED
|
@@ -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,
|
package/src/restore-engine.ts
CHANGED
|
@@ -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
|
-
|
|
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] =
|
|
311
|
-
this.readOwnedPaths(current),
|
|
312
|
-
|
|
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
|
|
853
|
+
const livePaths = await this.store.listVisibleLeafPaths(topology, {
|
|
821
854
|
excludePaths: mutationJournal === undefined ? undefined : [...await mutationJournal.activeArtifacts()],
|
|
822
855
|
});
|
|
823
|
-
|
|
824
|
-
|
|
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 &&
|
package/src/root-discovery.ts
CHANGED
|
@@ -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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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
|
|
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> {
|
package/src/snapshot-store.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Stats } from "node:fs";
|
|
1
2
|
import { lstat, mkdir, mkdtemp, readFile, readdir, readlink, realpath, rm, stat } from "node:fs/promises";
|
|
2
3
|
import { tmpdir } from "node:os";
|
|
3
4
|
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
@@ -30,6 +31,11 @@ const GC_METADATA_FILE = "gc.json";
|
|
|
30
31
|
const GC_RETENTION_MS = 7 * 24 * 60 * 60 * 1_000;
|
|
31
32
|
const IGNORE_POLICY = "git-check-ignore-v1";
|
|
32
33
|
const NULL_DEVICE = process.platform === "win32" ? "NUL" : "/dev/null";
|
|
34
|
+
const TREE_CACHE_LIMIT = 256;
|
|
35
|
+
const HASH_BATCH_MAX_PATHS = 128;
|
|
36
|
+
const HASH_BATCH_MAX_ARGUMENT_BYTES = 24 * 1024;
|
|
37
|
+
const INDEX_BATCH_MAX_ENTRIES = 4_096;
|
|
38
|
+
const INDEX_BATCH_MAX_BYTES = 8 * 1024 * 1024;
|
|
33
39
|
|
|
34
40
|
interface PinRecord {
|
|
35
41
|
readonly schemaVersion: 1;
|
|
@@ -60,6 +66,13 @@ interface CapturedRootResult {
|
|
|
60
66
|
readonly objectClosure: string;
|
|
61
67
|
}
|
|
62
68
|
|
|
69
|
+
interface VisibleLeaf {
|
|
70
|
+
readonly relativePath: string;
|
|
71
|
+
readonly kind: "file" | "symlink";
|
|
72
|
+
readonly mode: number;
|
|
73
|
+
readonly fingerprint: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
63
76
|
export interface SnapshotStoreOptions {
|
|
64
77
|
readonly storeRoot?: string;
|
|
65
78
|
readonly git?: GitRunner;
|
|
@@ -93,6 +106,7 @@ export class SnapshotStoreError extends Error {
|
|
|
93
106
|
|
|
94
107
|
export interface SnapshotStore {
|
|
95
108
|
capture(topology: RootTopology, scope?: readonly string[], options?: CaptureOptions): Promise<SnapshotManifest>;
|
|
109
|
+
listVisibleLeafPaths(topology: RootTopology, options?: CaptureOptions): Promise<readonly string[]>;
|
|
96
110
|
loadManifest(id: ManifestId): Promise<SnapshotManifest>;
|
|
97
111
|
assertComplete(id: ManifestId): Promise<void>;
|
|
98
112
|
listTree(id: ManifestId, root: string): Promise<readonly RestorePath[]>;
|
|
@@ -110,6 +124,8 @@ export class SnapshotStore {
|
|
|
110
124
|
private readonly lock: WorkspaceLock;
|
|
111
125
|
private readonly clock: () => number;
|
|
112
126
|
private readonly manifestLocations = new Map<string, string>();
|
|
127
|
+
// Tree ID 是内容寻址且不可变;这里只缓存解析元数据,blob bytes 与完整性仍逐次读取/校验。
|
|
128
|
+
private readonly treeEntriesCache = new Map<string, Promise<CapturedTreeEntry[]>>();
|
|
113
129
|
|
|
114
130
|
constructor(options: SnapshotStoreOptions = {}) {
|
|
115
131
|
this.storeRoot = resolve(options.storeRoot ?? join(tmpdir(), "pi-undo-snapshot-store"));
|
|
@@ -204,6 +220,72 @@ export class SnapshotStore {
|
|
|
204
220
|
}
|
|
205
221
|
}
|
|
206
222
|
|
|
223
|
+
async listVisibleLeafPaths(
|
|
224
|
+
topology: RootTopology,
|
|
225
|
+
options: CaptureOptions = {},
|
|
226
|
+
): Promise<readonly string[]> {
|
|
227
|
+
await this.assertPrivateStore(topology.workspaceIdentity);
|
|
228
|
+
const lockIdentity = `snapshot-store:${await prospectiveCanonicalPath(this.storesRoot)}`;
|
|
229
|
+
return this.lock.withLock(lockIdentity, () => this.listVisibleLeafPathsLocked(topology, options));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private async listVisibleLeafPathsLocked(
|
|
233
|
+
topology: RootTopology,
|
|
234
|
+
options: CaptureOptions,
|
|
235
|
+
): Promise<readonly string[]> {
|
|
236
|
+
let transactionDirectory: string | undefined;
|
|
237
|
+
try {
|
|
238
|
+
if (topology.fingerprint !== topologyFingerprint(topology.workspaceIdentity, topology.roots)) {
|
|
239
|
+
throw new SnapshotStoreError("capture_failed", "topology fingerprint 与 roots 不匹配");
|
|
240
|
+
}
|
|
241
|
+
const artifactExclusions = captureExclusions(topology.workspaceIdentity, options.excludePaths);
|
|
242
|
+
await this.assertTopology(topology, "可见路径枚举前 topology 已变化");
|
|
243
|
+
if (topology.roots.some((root) => root.state === "broken")) {
|
|
244
|
+
throw new SnapshotStoreError("capture_failed", "broken root 不能静默进入可见路径枚举");
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const storeDirectory = this.storeDirectory(topology);
|
|
248
|
+
const transactionsRoot = join(storeDirectory, "transactions");
|
|
249
|
+
await mkdir(transactionsRoot, { recursive: true });
|
|
250
|
+
transactionDirectory = await mkdtemp(join(transactionsRoot, "visible-"));
|
|
251
|
+
const result = new Set<string>();
|
|
252
|
+
for (const root of topology.roots) {
|
|
253
|
+
if (root.state !== "active") continue;
|
|
254
|
+
const gitDirectory = this.rootGitDirectory(storeDirectory, root);
|
|
255
|
+
await this.ensurePrivateRepository(gitDirectory);
|
|
256
|
+
await this.assertNoAlternates(gitDirectory);
|
|
257
|
+
const absoluteRoot = workspaceRootPath(topology.workspaceIdentity, root.relativeRoot);
|
|
258
|
+
const indexPath = join(transactionDirectory, `${rootStoreId(root)}.index`);
|
|
259
|
+
const environment = privateGitEnvironment(gitDirectory, absoluteRoot, indexPath);
|
|
260
|
+
await this.runGit(["read-tree", "--empty"], { cwd: absoluteRoot, env: environment });
|
|
261
|
+
await this.validateIgnoreQuery(absoluteRoot, environment, root.gitBacked);
|
|
262
|
+
const exclusions = topology.roots
|
|
263
|
+
.filter((candidate) => isStrictRootAncestor(root.relativeRoot, candidate.relativeRoot))
|
|
264
|
+
.map((candidate) => rootRelativePath(root.relativeRoot, candidate.relativeRoot));
|
|
265
|
+
const exactExclusions = ownedArtifactExclusions(topology.roots, root.relativeRoot, artifactExclusions);
|
|
266
|
+
for (const leaf of await this.collectVisibleLeaves(
|
|
267
|
+
absoluteRoot,
|
|
268
|
+
environment,
|
|
269
|
+
root.gitBacked,
|
|
270
|
+
[],
|
|
271
|
+
exclusions,
|
|
272
|
+
exactExclusions,
|
|
273
|
+
)) {
|
|
274
|
+
result.add(workspaceRelativePath(root.relativeRoot, leaf.relativePath));
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
await this.assertTopology(topology, "可见路径枚举期间 topology 已变化");
|
|
278
|
+
return [...result].sort(comparePaths);
|
|
279
|
+
} catch (error) {
|
|
280
|
+
if (error instanceof SnapshotStoreError) throw error;
|
|
281
|
+
throw new SnapshotStoreError("capture_failed", errorMessage(error), { cause: error });
|
|
282
|
+
} finally {
|
|
283
|
+
if (transactionDirectory !== undefined) {
|
|
284
|
+
await rm(transactionDirectory, { recursive: true, force: true }).catch(() => {});
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
207
289
|
async loadManifest(id: ManifestId): Promise<SnapshotManifest> {
|
|
208
290
|
const manifestPath = await this.findManifestPath(id);
|
|
209
291
|
try {
|
|
@@ -242,7 +324,6 @@ export class SnapshotStore {
|
|
|
242
324
|
}
|
|
243
325
|
const gitDirectory = this.rootGitDirectory(storeDirectory, root);
|
|
244
326
|
await this.assertNoAlternates(gitDirectory);
|
|
245
|
-
await this.runPrivateGit(gitDirectory, ["cat-file", "-e", `${root.treeId}^{tree}`]);
|
|
246
327
|
const entries = await this.readTreeEntries(gitDirectory, root.treeId);
|
|
247
328
|
if (root.ignoredPresentPaths.some((ignoredPath) => entries.some(
|
|
248
329
|
(entry) => isPathAtOrBelow(ignoredPath, entry.relativePath) ||
|
|
@@ -250,9 +331,7 @@ export class SnapshotStore {
|
|
|
250
331
|
))) {
|
|
251
332
|
throw new SnapshotStoreError("object_missing", "ignored-present proof 与 root tree 冲突");
|
|
252
333
|
}
|
|
253
|
-
|
|
254
|
-
await this.runPrivateGit(gitDirectory, ["cat-file", "-e", `${entry.objectId}^{blob}`]);
|
|
255
|
-
}
|
|
334
|
+
await this.assertObjectsComplete(gitDirectory, root.treeId, entries);
|
|
256
335
|
if (root.objectClosure !== treeObjectClosure(root.treeId, entries)) {
|
|
257
336
|
throw new SnapshotStoreError("object_missing", "root tree 对象闭包校验失败");
|
|
258
337
|
}
|
|
@@ -493,10 +572,7 @@ export class SnapshotStore {
|
|
|
493
572
|
throw new SnapshotStoreError("capture_failed", "git write-tree 未返回有效对象 ID");
|
|
494
573
|
}
|
|
495
574
|
const entries = await this.readTreeEntries(gitDirectory, treeId);
|
|
496
|
-
await this.
|
|
497
|
-
for (const entry of entries) {
|
|
498
|
-
await this.runPrivateGit(gitDirectory, ["cat-file", "-e", `${entry.objectId}^{blob}`]);
|
|
499
|
-
}
|
|
575
|
+
await this.assertObjectsComplete(gitDirectory, treeId, entries);
|
|
500
576
|
return {
|
|
501
577
|
treeId,
|
|
502
578
|
coverage,
|
|
@@ -565,13 +641,77 @@ export class SnapshotStore {
|
|
|
565
641
|
exclusions: readonly string[],
|
|
566
642
|
exactExclusions: readonly string[],
|
|
567
643
|
): Promise<void> {
|
|
568
|
-
|
|
569
|
-
|
|
644
|
+
const leaves = await this.collectVisibleLeaves(
|
|
645
|
+
cwd,
|
|
646
|
+
environment,
|
|
647
|
+
gitBacked,
|
|
648
|
+
inclusions,
|
|
649
|
+
exclusions,
|
|
650
|
+
exactExclusions,
|
|
651
|
+
);
|
|
652
|
+
const objectIds = new Map<string, string>();
|
|
653
|
+
for (const batch of hashPathBatches(leaves.filter((leaf) => leaf.kind === "file"))) {
|
|
654
|
+
for (const leaf of batch) await this.assertVisibleLeafUnchanged(cwd, leaf);
|
|
655
|
+
const output = await this.runGit([
|
|
656
|
+
"hash-object",
|
|
657
|
+
"-w",
|
|
658
|
+
"--no-filters",
|
|
659
|
+
"--",
|
|
660
|
+
...batch.map((leaf) => leaf.relativePath),
|
|
661
|
+
], { cwd, env: environment });
|
|
662
|
+
const hashes = parseObjectIdLines(output, batch.length);
|
|
663
|
+
for (const leaf of batch) await this.assertVisibleLeafUnchanged(cwd, leaf);
|
|
664
|
+
for (let index = 0; index < batch.length; index += 1) {
|
|
665
|
+
objectIds.set(batch[index]!.relativePath, hashes[index]!);
|
|
666
|
+
}
|
|
570
667
|
}
|
|
571
|
-
const
|
|
572
|
-
|
|
573
|
-
|
|
668
|
+
for (const leaf of leaves) {
|
|
669
|
+
if (leaf.kind !== "symlink") continue;
|
|
670
|
+
await this.assertVisibleLeafUnchanged(cwd, leaf);
|
|
671
|
+
const linkText = await readlink(join(cwd, ...leaf.relativePath.split("/")), { encoding: "buffer" });
|
|
672
|
+
decodeUtf8(linkText, "symlink target 不是可无损表示的 UTF-8");
|
|
673
|
+
const objectId = (await this.runGit(["hash-object", "-w", "--stdin"], {
|
|
674
|
+
cwd,
|
|
675
|
+
env: environment,
|
|
676
|
+
stdin: linkText,
|
|
677
|
+
})).trim();
|
|
678
|
+
if (!isObjectId(objectId)) {
|
|
679
|
+
throw new SnapshotStoreError("capture_failed", `文件对象 materialize 失败:${leaf.relativePath}`);
|
|
680
|
+
}
|
|
681
|
+
await this.assertVisibleLeafUnchanged(cwd, leaf);
|
|
682
|
+
objectIds.set(leaf.relativePath, objectId);
|
|
683
|
+
}
|
|
684
|
+
for (const indexInput of indexInfoBatches(leaves, objectIds)) {
|
|
685
|
+
await this.runGit(["update-index", "-z", "--index-info"], {
|
|
686
|
+
cwd,
|
|
687
|
+
env: environment,
|
|
688
|
+
stdin: indexInput,
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
private async assertVisibleLeafUnchanged(cwd: string, leaf: VisibleLeaf): Promise<void> {
|
|
694
|
+
await assertNoSymlinkEscape(cwd, leaf.relativePath);
|
|
695
|
+
const metadata = await lstat(join(cwd, ...leaf.relativePath.split("/"))).catch((error) => {
|
|
696
|
+
if (hasErrorCode(error, "ENOENT")) return null;
|
|
697
|
+
throw error;
|
|
698
|
+
});
|
|
699
|
+
if (metadata === null || visibleLeafFingerprint(metadata) !== leaf.fingerprint) {
|
|
700
|
+
throw new SnapshotStoreError("capture_failed", `捕获期间工作区叶子已变化:${leaf.relativePath}`);
|
|
574
701
|
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
private async collectVisibleLeaves(
|
|
705
|
+
cwd: string,
|
|
706
|
+
environment: Readonly<Record<string, string | undefined>>,
|
|
707
|
+
gitBacked: boolean,
|
|
708
|
+
inclusions: readonly string[] | null,
|
|
709
|
+
exclusions: readonly string[],
|
|
710
|
+
exactExclusions: readonly string[],
|
|
711
|
+
): Promise<VisibleLeaf[]> {
|
|
712
|
+
if (inclusions === null) return [];
|
|
713
|
+
const pathspecs = inclusions.length === 0 ? ["."] : inclusions.map(literalPathspec);
|
|
714
|
+
for (const excluded of exclusions) pathspecs.push(excludeLiteralPathspec(excluded));
|
|
575
715
|
const queryEnvironment = gitBacked ? sourceGitEnvironment() : environment;
|
|
576
716
|
const output = await this.runGitBytes([
|
|
577
717
|
...(gitBacked ? ["-c", "core.fsmonitor=false"] : []),
|
|
@@ -583,53 +723,38 @@ export class SnapshotStore {
|
|
|
583
723
|
"--",
|
|
584
724
|
...pathspecs,
|
|
585
725
|
], { cwd, env: queryEnvironment });
|
|
726
|
+
const result: VisibleLeaf[] = [];
|
|
586
727
|
for (const relativePath of parseNulPaths(output)) {
|
|
587
728
|
if (
|
|
588
729
|
exclusions.some((excluded) => isPathAtOrBelow(excluded, relativePath)) ||
|
|
589
730
|
exactExclusions.includes(relativePath)
|
|
590
|
-
)
|
|
591
|
-
continue;
|
|
592
|
-
}
|
|
731
|
+
) continue;
|
|
593
732
|
relativeSafePath(cwd, relativePath);
|
|
594
733
|
await assertNoSymlinkEscape(cwd, relativePath);
|
|
595
|
-
const
|
|
596
|
-
|
|
597
|
-
if (hasErrorCode(error, "ENOENT")) {
|
|
598
|
-
return null;
|
|
599
|
-
}
|
|
734
|
+
const metadata = await lstat(join(cwd, ...relativePath.split("/"))).catch((error) => {
|
|
735
|
+
if (hasErrorCode(error, "ENOENT")) return null;
|
|
600
736
|
throw error;
|
|
601
737
|
});
|
|
602
|
-
if (metadata === null)
|
|
603
|
-
continue;
|
|
604
|
-
}
|
|
605
|
-
let mode: string;
|
|
606
|
-
let objectId: string;
|
|
738
|
+
if (metadata === null) continue;
|
|
607
739
|
if (metadata.isSymbolicLink()) {
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
stdin: linkText,
|
|
615
|
-
})).trim();
|
|
740
|
+
result.push({
|
|
741
|
+
relativePath,
|
|
742
|
+
kind: "symlink",
|
|
743
|
+
mode: 0o120000,
|
|
744
|
+
fingerprint: visibleLeafFingerprint(metadata),
|
|
745
|
+
});
|
|
616
746
|
} else if (metadata.isFile()) {
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
747
|
+
result.push({
|
|
748
|
+
relativePath,
|
|
749
|
+
kind: "file",
|
|
750
|
+
mode: (metadata.mode & 0o111) === 0 ? 0o100644 : 0o100755,
|
|
751
|
+
fingerprint: visibleLeafFingerprint(metadata),
|
|
752
|
+
});
|
|
622
753
|
} else {
|
|
623
754
|
throw new SnapshotStoreError("capture_failed", `不支持的工作区文件类型:${relativePath}`);
|
|
624
755
|
}
|
|
625
|
-
if (!isObjectId(objectId)) {
|
|
626
|
-
throw new SnapshotStoreError("capture_failed", `文件对象 materialize 失败:${relativePath}`);
|
|
627
|
-
}
|
|
628
|
-
await this.runGit(["update-index", "--add", "--cacheinfo", mode, objectId, relativePath], {
|
|
629
|
-
cwd,
|
|
630
|
-
env: environment,
|
|
631
|
-
});
|
|
632
756
|
}
|
|
757
|
+
return result;
|
|
633
758
|
}
|
|
634
759
|
|
|
635
760
|
private async validateIgnoreQuery(
|
|
@@ -688,8 +813,52 @@ export class SnapshotStore {
|
|
|
688
813
|
}
|
|
689
814
|
|
|
690
815
|
private async readTreeEntries(gitDirectory: string, treeId: string): Promise<CapturedTreeEntry[]> {
|
|
691
|
-
const
|
|
692
|
-
|
|
816
|
+
const key = `${gitDirectory}\0${treeId}`;
|
|
817
|
+
const cached = this.treeEntriesCache.get(key);
|
|
818
|
+
if (cached !== undefined) return cached;
|
|
819
|
+
const pending = this.runPrivateGitBytes(gitDirectory, ["ls-tree", "-r", "-l", "-z", treeId])
|
|
820
|
+
.then(parseTreeEntries);
|
|
821
|
+
this.treeEntriesCache.set(key, pending);
|
|
822
|
+
while (this.treeEntriesCache.size > TREE_CACHE_LIMIT) {
|
|
823
|
+
const oldest = this.treeEntriesCache.keys().next().value as string | undefined;
|
|
824
|
+
if (oldest === undefined || oldest === key) break;
|
|
825
|
+
this.treeEntriesCache.delete(oldest);
|
|
826
|
+
}
|
|
827
|
+
try {
|
|
828
|
+
return await pending;
|
|
829
|
+
} catch (error) {
|
|
830
|
+
if (this.treeEntriesCache.get(key) === pending) this.treeEntriesCache.delete(key);
|
|
831
|
+
throw error;
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
private async assertObjectsComplete(
|
|
836
|
+
gitDirectory: string,
|
|
837
|
+
treeId: string,
|
|
838
|
+
entries: readonly CapturedTreeEntry[],
|
|
839
|
+
): Promise<void> {
|
|
840
|
+
const expected = [
|
|
841
|
+
{ objectId: treeId, type: "tree" },
|
|
842
|
+
...[...new Set(entries.map((entry) => entry.objectId))].map((objectId) => ({ objectId, type: "blob" })),
|
|
843
|
+
];
|
|
844
|
+
const output = await this.runGit(["cat-file", "--batch-check"], {
|
|
845
|
+
env: privateObjectEnvironment(gitDirectory),
|
|
846
|
+
stdin: `${expected.map((object) => object.objectId).join("\n")}\n`,
|
|
847
|
+
});
|
|
848
|
+
const lines = output.endsWith("\n") ? output.slice(0, -1).split("\n") : output.split("\n");
|
|
849
|
+
if (lines.length !== expected.length) throw new Error("Git object batch-check 输出数量不匹配");
|
|
850
|
+
for (let index = 0; index < expected.length; index += 1) {
|
|
851
|
+
const object = expected[index]!;
|
|
852
|
+
const match = lines[index]!.match(/^([0-9a-f]{40,64}) (blob|tree) ([0-9]+)$/);
|
|
853
|
+
if (
|
|
854
|
+
match === null ||
|
|
855
|
+
match[1] !== object.objectId ||
|
|
856
|
+
match[2] !== object.type ||
|
|
857
|
+
!Number.isSafeInteger(Number(match[3]))
|
|
858
|
+
) {
|
|
859
|
+
throw new Error(`Git object batch-check 校验失败:${object.objectId}`);
|
|
860
|
+
}
|
|
861
|
+
}
|
|
693
862
|
}
|
|
694
863
|
|
|
695
864
|
private async readBlobText(gitDirectory: string, objectId: string): Promise<string> {
|
|
@@ -980,6 +1149,10 @@ function workspaceRootPath(workspaceIdentity: string, rootPath: string): string
|
|
|
980
1149
|
return safe === "." ? workspaceIdentity : join(workspaceIdentity, ...safe.split("/"));
|
|
981
1150
|
}
|
|
982
1151
|
|
|
1152
|
+
function workspaceRelativePath(rootPath: string, relativePath: string): string {
|
|
1153
|
+
return rootPath === "." ? relativePath : `${rootPath}/${relativePath}`;
|
|
1154
|
+
}
|
|
1155
|
+
|
|
983
1156
|
function rootStoreId(root: RootTopologyIdentity): string {
|
|
984
1157
|
return checksum(canonicalJson({
|
|
985
1158
|
relativeRoot: root.relativeRoot,
|
|
@@ -1061,6 +1234,76 @@ function isolatedGitConfiguration(): Readonly<Record<string, string | undefined>
|
|
|
1061
1234
|
};
|
|
1062
1235
|
}
|
|
1063
1236
|
|
|
1237
|
+
function indexInfoBatches(
|
|
1238
|
+
leaves: readonly VisibleLeaf[],
|
|
1239
|
+
objectIds: ReadonlyMap<string, string>,
|
|
1240
|
+
): Buffer[] {
|
|
1241
|
+
const result: Buffer[] = [];
|
|
1242
|
+
let records: string[] = [];
|
|
1243
|
+
let bytes = 0;
|
|
1244
|
+
for (const leaf of leaves) {
|
|
1245
|
+
const objectId = objectIds.get(leaf.relativePath);
|
|
1246
|
+
if (objectId === undefined) {
|
|
1247
|
+
throw new SnapshotStoreError("capture_failed", `文件对象 materialize 结果缺失:${leaf.relativePath}`);
|
|
1248
|
+
}
|
|
1249
|
+
const record = `${leaf.mode.toString(8)} ${objectId}\t${leaf.relativePath}\0`;
|
|
1250
|
+
const recordBytes = Buffer.byteLength(record, "utf8");
|
|
1251
|
+
if (
|
|
1252
|
+
records.length > 0 &&
|
|
1253
|
+
(records.length >= INDEX_BATCH_MAX_ENTRIES || bytes + recordBytes > INDEX_BATCH_MAX_BYTES)
|
|
1254
|
+
) {
|
|
1255
|
+
result.push(Buffer.from(records.join(""), "utf8"));
|
|
1256
|
+
records = [];
|
|
1257
|
+
bytes = 0;
|
|
1258
|
+
}
|
|
1259
|
+
records.push(record);
|
|
1260
|
+
bytes += recordBytes;
|
|
1261
|
+
}
|
|
1262
|
+
if (records.length > 0) result.push(Buffer.from(records.join(""), "utf8"));
|
|
1263
|
+
return result;
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
function visibleLeafFingerprint(metadata: Stats): string {
|
|
1267
|
+
return checksum(canonicalJson({
|
|
1268
|
+
kind: metadata.isSymbolicLink() ? "symlink" : metadata.isFile() ? "file" : "other",
|
|
1269
|
+
dev: metadata.dev,
|
|
1270
|
+
ino: metadata.ino,
|
|
1271
|
+
mode: metadata.mode,
|
|
1272
|
+
size: metadata.size,
|
|
1273
|
+
mtimeMs: metadata.mtimeMs,
|
|
1274
|
+
ctimeMs: metadata.ctimeMs,
|
|
1275
|
+
}));
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
function hashPathBatches(leaves: readonly VisibleLeaf[]): VisibleLeaf[][] {
|
|
1279
|
+
const result: VisibleLeaf[][] = [];
|
|
1280
|
+
let current: VisibleLeaf[] = [];
|
|
1281
|
+
let argumentBytes = 0;
|
|
1282
|
+
for (const leaf of leaves) {
|
|
1283
|
+
const leafBytes = Buffer.byteLength(leaf.relativePath, "utf8") + 1;
|
|
1284
|
+
if (
|
|
1285
|
+
current.length > 0 &&
|
|
1286
|
+
(current.length >= HASH_BATCH_MAX_PATHS || argumentBytes + leafBytes > HASH_BATCH_MAX_ARGUMENT_BYTES)
|
|
1287
|
+
) {
|
|
1288
|
+
result.push(current);
|
|
1289
|
+
current = [];
|
|
1290
|
+
argumentBytes = 0;
|
|
1291
|
+
}
|
|
1292
|
+
current.push(leaf);
|
|
1293
|
+
argumentBytes += leafBytes;
|
|
1294
|
+
}
|
|
1295
|
+
if (current.length > 0) result.push(current);
|
|
1296
|
+
return result;
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
function parseObjectIdLines(output: string, expectedCount: number): string[] {
|
|
1300
|
+
const lines = output.endsWith("\n") ? output.slice(0, -1).split("\n") : output.split("\n");
|
|
1301
|
+
if (lines.length !== expectedCount || lines.some((line) => !isObjectId(line))) {
|
|
1302
|
+
throw new SnapshotStoreError("capture_failed", "git hash-object 批量输出无效");
|
|
1303
|
+
}
|
|
1304
|
+
return lines;
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1064
1307
|
function parseTreeEntries(output: Uint8Array): CapturedTreeEntry[] {
|
|
1065
1308
|
const entries: CapturedTreeEntry[] = [];
|
|
1066
1309
|
for (const record of splitNulRecords(output)) {
|