@davideasden/pi-undo 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +170 -39
- package/extensions/pi-undo.ts +219 -14
- package/package.json +3 -2
- package/src/controller.ts +14 -3
- 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/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> {
|