pi-undo-redo 0.1.1
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 +67 -0
- package/package.json +56 -0
- package/scripts/publish.mjs +34 -0
- package/src/extension.ts +1376 -0
package/src/extension.ts
ADDED
|
@@ -0,0 +1,1376 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { lstat, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
type ExtensionAPI = {
|
|
8
|
+
on(event: string, handler: (...args: any[]) => unknown): unknown;
|
|
9
|
+
registerCommand(name: string, definition: { description?: string; handler: (args: unknown, ctx: ExtensionCommandContext) => unknown }): unknown;
|
|
10
|
+
appendEntry(customType: string, data: unknown): unknown;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type ExtensionCommandContext = {
|
|
14
|
+
cwd: string;
|
|
15
|
+
waitForIdle(): Promise<void>;
|
|
16
|
+
navigateTree(targetId: string, options?: { summarize?: boolean; label?: string }): Promise<{ cancelled: boolean }>;
|
|
17
|
+
sessionManager: {
|
|
18
|
+
getSessionId(): string;
|
|
19
|
+
getEntries(): SessionEntry[];
|
|
20
|
+
getEntry(id: string): SessionEntry | undefined;
|
|
21
|
+
getBranch(leafId?: string | null): SessionEntry[];
|
|
22
|
+
getLeafId(): string | null;
|
|
23
|
+
};
|
|
24
|
+
ui: {
|
|
25
|
+
notify(message: string, level?: "warning" | "info" | "error"): void;
|
|
26
|
+
setEditorText(text: string): void;
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
type SessionEntry = {
|
|
31
|
+
type: string;
|
|
32
|
+
id: string;
|
|
33
|
+
parentId: string | null;
|
|
34
|
+
customType?: string;
|
|
35
|
+
data?: unknown;
|
|
36
|
+
message?: { role: string; content?: unknown };
|
|
37
|
+
[key: string]: unknown;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const EXT = "pi-undo-redo";
|
|
41
|
+
const REDO_EXT = "pi-undo-redo-redo";
|
|
42
|
+
const DEFAULT_LARGE_FILE_LIMIT = 2 * 1024 * 1024;
|
|
43
|
+
const DEFAULT_GIT_TIMEOUT = 30_000;
|
|
44
|
+
|
|
45
|
+
type Settings = {
|
|
46
|
+
enabled: boolean;
|
|
47
|
+
storageDir: string;
|
|
48
|
+
largeFileLimitBytes: number;
|
|
49
|
+
gitTimeoutMs: number;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
type Checkpoint = {
|
|
53
|
+
userEntryId: string;
|
|
54
|
+
beforeLeafId: string | null;
|
|
55
|
+
finalLeafId: string;
|
|
56
|
+
prompt: string;
|
|
57
|
+
beforeSnapshot: string;
|
|
58
|
+
afterSnapshot: string;
|
|
59
|
+
files: string[];
|
|
60
|
+
createdAt: number;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
type ActiveCheckpoint = Omit<Checkpoint, "finalLeafId" | "afterSnapshot" | "files" | "createdAt"> & {
|
|
64
|
+
snapshotFiles: Set<string>;
|
|
65
|
+
touchedFiles: Set<string>;
|
|
66
|
+
warnedNonGitShell: boolean;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
type PendingBeforeSnapshot = {
|
|
70
|
+
prompt: string;
|
|
71
|
+
beforeSnapshot: string;
|
|
72
|
+
snapshotFiles?: string[];
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
type RedoItem = {
|
|
76
|
+
checkpoint?: Checkpoint;
|
|
77
|
+
leafId: string | null;
|
|
78
|
+
prompt?: string;
|
|
79
|
+
snapshot?: string;
|
|
80
|
+
snapshotFiles?: string[];
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
type SnapshotRestoreGroup = {
|
|
84
|
+
snapshot: string;
|
|
85
|
+
files: string[];
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
type SnapshotTarget = {
|
|
89
|
+
snapshot: string;
|
|
90
|
+
files?: string[];
|
|
91
|
+
restoreGroups?: SnapshotRestoreGroup[];
|
|
92
|
+
score: number;
|
|
93
|
+
reason: string;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
type GitResult = { code: number; stdout: string; stderr: string };
|
|
97
|
+
|
|
98
|
+
export default function undoRedo(pi: ExtensionAPI) {
|
|
99
|
+
const checkpoints = new Map<string, Checkpoint>();
|
|
100
|
+
const redoStack: RedoItem[] = [];
|
|
101
|
+
let active: ActiveCheckpoint | undefined;
|
|
102
|
+
let pendingBeforeSnapshot: PendingBeforeSnapshot | undefined;
|
|
103
|
+
let snapshotter: ShadowGit | undefined;
|
|
104
|
+
let suppressTreeRestore = 0;
|
|
105
|
+
|
|
106
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
107
|
+
const settings = await loadSettings(ctx.cwd);
|
|
108
|
+
snapshotter = new ShadowGit(ctx.cwd, ctx.sessionManager.getSessionId(), settings);
|
|
109
|
+
checkpoints.clear();
|
|
110
|
+
redoStack.length = 0;
|
|
111
|
+
active = undefined;
|
|
112
|
+
pendingBeforeSnapshot = undefined;
|
|
113
|
+
|
|
114
|
+
for (const entry of ctx.sessionManager.getEntries()) {
|
|
115
|
+
if (entry.type !== "custom") continue;
|
|
116
|
+
if (entry.customType === EXT) {
|
|
117
|
+
const checkpoint = repairCheckpoint(entry.data as Partial<Checkpoint> | undefined, ctx.sessionManager);
|
|
118
|
+
if (checkpoint) checkpoints.set(checkpoint.userEntryId, checkpoint);
|
|
119
|
+
}
|
|
120
|
+
if (entry.customType === REDO_EXT) {
|
|
121
|
+
redoStack.length = 0;
|
|
122
|
+
redoStack.push(...parseRedoStack(entry.data));
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
pi.on("before_agent_start", async (event, ctx) => {
|
|
128
|
+
redoStack.length = 0;
|
|
129
|
+
pi.appendEntry(REDO_EXT, []);
|
|
130
|
+
active = undefined;
|
|
131
|
+
pendingBeforeSnapshot = undefined;
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
const snap = await getSnapshotter(ctx.cwd, ctx.sessionManager.getSessionId());
|
|
135
|
+
if (!(await snap.available())) return;
|
|
136
|
+
const gitMode = await snap.isGitMode();
|
|
137
|
+
const snapshotFiles = gitMode ? undefined : cumulativeExplicitFiles();
|
|
138
|
+
pendingBeforeSnapshot = {
|
|
139
|
+
prompt: event.prompt,
|
|
140
|
+
beforeSnapshot: await snap.track(snapshotFiles),
|
|
141
|
+
snapshotFiles,
|
|
142
|
+
};
|
|
143
|
+
} catch (error) {
|
|
144
|
+
pendingBeforeSnapshot = undefined;
|
|
145
|
+
ctx.ui.notify(`Pi undo/redo checkpoint skipped: ${message(error)}`, "warning");
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
pi.on("message_start", async (event, ctx) => {
|
|
150
|
+
if (event.message.role !== "assistant" || active || pendingBeforeSnapshot === undefined) return;
|
|
151
|
+
const pending = pendingBeforeSnapshot;
|
|
152
|
+
|
|
153
|
+
const branch = ctx.sessionManager.getBranch();
|
|
154
|
+
const userEntry = findLatestUserByText(branch, pending.prompt);
|
|
155
|
+
if (!userEntry) {
|
|
156
|
+
pendingBeforeSnapshot = undefined;
|
|
157
|
+
ctx.ui.notify("Pi undo/redo checkpoint skipped: submitted user message was not found in the session branch", "warning");
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
pendingBeforeSnapshot = undefined;
|
|
162
|
+
active = {
|
|
163
|
+
userEntryId: userEntry.id,
|
|
164
|
+
beforeLeafId: userEntry.parentId,
|
|
165
|
+
prompt: userText(userEntry.message!),
|
|
166
|
+
beforeSnapshot: pending.beforeSnapshot,
|
|
167
|
+
snapshotFiles: new Set(pending.snapshotFiles ?? []),
|
|
168
|
+
touchedFiles: new Set(),
|
|
169
|
+
warnedNonGitShell: false,
|
|
170
|
+
};
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
174
|
+
if (!active && !pendingBeforeSnapshot) return;
|
|
175
|
+
const snap = await getSnapshotter(ctx.cwd, ctx.sessionManager.getSessionId());
|
|
176
|
+
if (await snap.isGitMode()) return;
|
|
177
|
+
if (event.toolName === "write" || event.toolName === "edit") {
|
|
178
|
+
try {
|
|
179
|
+
await captureExplicitToolPath(snap, event.input?.path, event.toolName === "write" ? event.input?.content : undefined, ctx);
|
|
180
|
+
} catch (error) {
|
|
181
|
+
const reason = `Pi undo/redo non-git capture blocked ${event.toolName}: ${message(error)}`;
|
|
182
|
+
ctx.ui.notify(reason, "warning");
|
|
183
|
+
return { block: true, reason };
|
|
184
|
+
}
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (event.toolName === "bash" && active && !active.warnedNonGitShell) {
|
|
188
|
+
active.warnedNonGitShell = true;
|
|
189
|
+
ctx.ui.notify(
|
|
190
|
+
"Pi undo/redo non-git mode only restores files explicitly touched by Pi write/edit tools; shell-created paths are not undo-restorable.",
|
|
191
|
+
"warning",
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
pi.on("session_shutdown", async () => {
|
|
197
|
+
active = undefined;
|
|
198
|
+
pendingBeforeSnapshot = undefined;
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
pi.on("session_before_tree", async (event, ctx) => {
|
|
202
|
+
if (suppressTreeRestore > 0) return;
|
|
203
|
+
if (active || pendingBeforeSnapshot) {
|
|
204
|
+
active = undefined;
|
|
205
|
+
pendingBeforeSnapshot = undefined;
|
|
206
|
+
}
|
|
207
|
+
try {
|
|
208
|
+
const snap = await getSnapshotter(ctx.cwd, ctx.sessionManager.getSessionId());
|
|
209
|
+
if (!(await snap.available())) return;
|
|
210
|
+
const gitMode = await snap.isGitMode();
|
|
211
|
+
const target = resolveSnapshotForLeaf(ctx.sessionManager.getBranch(event.preparation.targetId), event.preparation.targetId, gitMode);
|
|
212
|
+
if (!target) return;
|
|
213
|
+
const dirty = await snap.dirtyFiles(target.files);
|
|
214
|
+
if (dirty.length === 0) return;
|
|
215
|
+
const affected = await changedFilesForTarget(snap, target, gitMode);
|
|
216
|
+
const risky = intersectRiskyPaths(dirty, affected);
|
|
217
|
+
if (risky.length === 0) return;
|
|
218
|
+
ctx.ui.notify(formatDirtyGuardMessage("/tree", risky), "warning");
|
|
219
|
+
return { cancel: true };
|
|
220
|
+
} catch (error) {
|
|
221
|
+
ctx.ui.notify(`Dirty guard failed: ${message(error)}`, "error");
|
|
222
|
+
return { cancel: true };
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
pi.on("session_tree", async (event, ctx) => {
|
|
227
|
+
if (suppressTreeRestore > 0) return;
|
|
228
|
+
try {
|
|
229
|
+
const snap = await getSnapshotter(ctx.cwd, ctx.sessionManager.getSessionId());
|
|
230
|
+
if (!(await snap.available())) return;
|
|
231
|
+
const gitMode = await snap.isGitMode();
|
|
232
|
+
const target = resolveSnapshotForLeaf(ctx.sessionManager.getBranch(event.newLeafId ?? undefined), event.newLeafId, gitMode);
|
|
233
|
+
if (!target) return;
|
|
234
|
+
if (event.oldLeafId && event.oldLeafId !== event.newLeafId) {
|
|
235
|
+
const previous = resolveSnapshotForLeaf(ctx.sessionManager.getBranch(event.oldLeafId), event.oldLeafId, gitMode);
|
|
236
|
+
redoStack.length = 0;
|
|
237
|
+
redoStack.push({ leafId: event.oldLeafId, snapshot: previous?.snapshot, snapshotFiles: previous?.files });
|
|
238
|
+
pi.appendEntry(REDO_EXT, redoStack);
|
|
239
|
+
}
|
|
240
|
+
if (!gitMode && target.restoreGroups) {
|
|
241
|
+
for (const group of target.restoreGroups) await snap.restoreSnapshot(group.snapshot, group.files);
|
|
242
|
+
} else {
|
|
243
|
+
await snap.restoreSnapshot(target.snapshot, target.files);
|
|
244
|
+
}
|
|
245
|
+
pi.appendEntry(REDO_EXT, redoStack);
|
|
246
|
+
const modeNote = gitMode ? "" : ` (${target.files?.length ?? 0} explicit file(s))`;
|
|
247
|
+
ctx.ui.notify(`Restored workspace for /tree (${target.reason})${modeNote}`, "info");
|
|
248
|
+
} catch (error) {
|
|
249
|
+
ctx.ui.notify(`Tree workspace restore failed: ${message(error)}`, "error");
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
pi.on("agent_end", async (_event, ctx) => {
|
|
254
|
+
await finalizeActiveCheckpoint(ctx);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
registerUndo("undo", "Undo the last user message and restore its changed files");
|
|
258
|
+
registerRedo("redo", "Redo the last undone message and changed files");
|
|
259
|
+
pi.registerCommand("undo-cleanup", {
|
|
260
|
+
description: "Conservatively prune old protected pi-undo-redo shadow-git snapshot refs",
|
|
261
|
+
handler: async (_args, ctx) => cleanup(ctx),
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
function registerUndo(name: string, description: string) {
|
|
265
|
+
pi.registerCommand(name, { description, handler: async (_args, ctx) => undo(ctx) });
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function registerRedo(name: string, description: string) {
|
|
269
|
+
pi.registerCommand(name, { description, handler: async (_args, ctx) => redo(ctx) });
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function undo(ctx: ExtensionCommandContext) {
|
|
273
|
+
active = undefined;
|
|
274
|
+
pendingBeforeSnapshot = undefined;
|
|
275
|
+
await ctx.waitForIdle();
|
|
276
|
+
const snap = await getSnapshotter(ctx.cwd, ctx.sessionManager.getSessionId());
|
|
277
|
+
if (!(await snap.available())) {
|
|
278
|
+
ctx.ui.notify("Pi undo/redo is disabled for the current workspace", "warning");
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const branch = ctx.sessionManager.getBranch();
|
|
283
|
+
const target = [...branch].reverse().find(isUserMessageEntry);
|
|
284
|
+
|
|
285
|
+
if (!target) {
|
|
286
|
+
ctx.ui.notify("No user message to undo", "warning");
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const checkpoint = checkpoints.get(target.id);
|
|
291
|
+
const beforeLeafId = checkpoint?.beforeLeafId ?? target.parentId;
|
|
292
|
+
if (!beforeLeafId) {
|
|
293
|
+
ctx.ui.notify("Cannot undo the first session message in-place; fork before it instead.", "warning");
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
let pushedRedo = false;
|
|
298
|
+
let restoredCheckpoint = false;
|
|
299
|
+
let navigationCompleted = false;
|
|
300
|
+
const popPushedRedo = () => {
|
|
301
|
+
if (!pushedRedo) return;
|
|
302
|
+
redoStack.pop();
|
|
303
|
+
pushedRedo = false;
|
|
304
|
+
pi.appendEntry(REDO_EXT, redoStack);
|
|
305
|
+
};
|
|
306
|
+
const restoreAfterSnapshotBestEffort = async () => {
|
|
307
|
+
if (!checkpoint || !restoredCheckpoint) return undefined;
|
|
308
|
+
try {
|
|
309
|
+
await snap.revertFiles(checkpoint.afterSnapshot, checkpoint.files);
|
|
310
|
+
restoredCheckpoint = false;
|
|
311
|
+
return undefined;
|
|
312
|
+
} catch (rollbackError) {
|
|
313
|
+
return message(rollbackError);
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
try {
|
|
317
|
+
if (checkpoint) {
|
|
318
|
+
const dirty = await snap.dirtyFiles(checkpoint.files);
|
|
319
|
+
const risky = intersectRiskyPaths(dirty, new Set(checkpoint.files));
|
|
320
|
+
if (risky.length > 0) {
|
|
321
|
+
ctx.ui.notify(formatDirtyGuardMessage("/undo", risky), "warning");
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
await snap.revertFiles(checkpoint.beforeSnapshot, checkpoint.files);
|
|
325
|
+
restoredCheckpoint = true;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
redoStack.push({ checkpoint, leafId: ctx.sessionManager.getLeafId(), prompt: userText(target.message!) });
|
|
329
|
+
pushedRedo = true;
|
|
330
|
+
pi.appendEntry(REDO_EXT, redoStack);
|
|
331
|
+
suppressTreeRestore++;
|
|
332
|
+
let result: { cancelled: boolean };
|
|
333
|
+
try {
|
|
334
|
+
result = await ctx.navigateTree(beforeLeafId, { summarize: false, label: "undo" });
|
|
335
|
+
} finally {
|
|
336
|
+
suppressTreeRestore--;
|
|
337
|
+
}
|
|
338
|
+
if (result.cancelled) {
|
|
339
|
+
popPushedRedo();
|
|
340
|
+
const rollbackError = await restoreAfterSnapshotBestEffort();
|
|
341
|
+
if (rollbackError) ctx.ui.notify(`Undo navigation cancelled; workspace rollback failed: ${rollbackError}`, "error");
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
navigationCompleted = true;
|
|
345
|
+
ctx.ui.setEditorText(checkpoint?.prompt ?? userText(target.message!));
|
|
346
|
+
const fileCount = checkpoint?.files.length ?? 0;
|
|
347
|
+
ctx.ui.notify(fileCount > 0 ? `Undid message; restored ${fileCount} file(s)` : "Undid message", "info");
|
|
348
|
+
} catch (error) {
|
|
349
|
+
if (!navigationCompleted) popPushedRedo();
|
|
350
|
+
const rollbackError = !navigationCompleted ? await restoreAfterSnapshotBestEffort() : undefined;
|
|
351
|
+
const rollbackNote = rollbackError ? `; workspace rollback failed: ${rollbackError}` : "";
|
|
352
|
+
ctx.ui.notify(`Undo failed: ${message(error)}${rollbackNote}`, "error");
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async function cleanup(ctx: ExtensionCommandContext) {
|
|
357
|
+
await ctx.waitForIdle();
|
|
358
|
+
try {
|
|
359
|
+
const snap = await getSnapshotter(ctx.cwd, ctx.sessionManager.getSessionId());
|
|
360
|
+
if (!(await snap.available())) {
|
|
361
|
+
ctx.ui.notify("Pi undo/redo cleanup skipped: current workspace is not a git repository", "warning");
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
const removed = await snap.cleanupSnapshots(liveSnapshotIds());
|
|
365
|
+
ctx.ui.notify(
|
|
366
|
+
removed > 0
|
|
367
|
+
? `Pi undo/redo cleanup pruned ${removed} old protected snapshot ref(s)`
|
|
368
|
+
: "Pi undo/redo cleanup found no old snapshot refs to prune",
|
|
369
|
+
"info",
|
|
370
|
+
);
|
|
371
|
+
} catch (error) {
|
|
372
|
+
ctx.ui.notify(`Pi undo/redo cleanup failed: ${message(error)}`, "error");
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async function redo(ctx: ExtensionCommandContext) {
|
|
377
|
+
active = undefined;
|
|
378
|
+
pendingBeforeSnapshot = undefined;
|
|
379
|
+
await ctx.waitForIdle();
|
|
380
|
+
const item = redoStack.pop();
|
|
381
|
+
if (!item) {
|
|
382
|
+
ctx.ui.notify("Nothing to redo", "warning");
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
try {
|
|
387
|
+
const snap = await getSnapshotter(ctx.cwd, ctx.sessionManager.getSessionId());
|
|
388
|
+
if (item.checkpoint) {
|
|
389
|
+
const dirty = await snap.dirtyFiles(item.checkpoint.files);
|
|
390
|
+
const risky = intersectRiskyPaths(dirty, new Set(item.checkpoint.files));
|
|
391
|
+
if (risky.length > 0) {
|
|
392
|
+
redoStack.push(item);
|
|
393
|
+
ctx.ui.notify(formatDirtyGuardMessage("/redo", risky), "warning");
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
await snap.revertFiles(item.checkpoint.afterSnapshot, item.checkpoint.files);
|
|
397
|
+
} else if (item.snapshot) {
|
|
398
|
+
const pathLimit = (await snap.isGitMode()) ? undefined : item.snapshotFiles;
|
|
399
|
+
const dirty = await snap.dirtyFiles(pathLimit);
|
|
400
|
+
const affected = await snap.changedFilesForSnapshot(item.snapshot, pathLimit);
|
|
401
|
+
const risky = intersectRiskyPaths(dirty, affected);
|
|
402
|
+
if (risky.length > 0) {
|
|
403
|
+
redoStack.push(item);
|
|
404
|
+
ctx.ui.notify(formatDirtyGuardMessage("/redo", risky), "warning");
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
await snap.restoreSnapshot(item.snapshot, pathLimit);
|
|
408
|
+
}
|
|
409
|
+
if (item.leafId) {
|
|
410
|
+
suppressTreeRestore++;
|
|
411
|
+
let result: { cancelled: boolean };
|
|
412
|
+
try {
|
|
413
|
+
result = await ctx.navigateTree(item.leafId, { summarize: false, label: "redo" });
|
|
414
|
+
} finally {
|
|
415
|
+
suppressTreeRestore--;
|
|
416
|
+
}
|
|
417
|
+
if (result.cancelled) {
|
|
418
|
+
redoStack.push(item);
|
|
419
|
+
pi.appendEntry(REDO_EXT, redoStack);
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
pi.appendEntry(REDO_EXT, redoStack);
|
|
424
|
+
ctx.ui.setEditorText("");
|
|
425
|
+
const fileCount = item.checkpoint?.files.length ?? 0;
|
|
426
|
+
ctx.ui.notify(fileCount > 0 ? `Redid message; restored ${fileCount} file(s)` : "Redid message", "info");
|
|
427
|
+
} catch (error) {
|
|
428
|
+
redoStack.push(item);
|
|
429
|
+
pi.appendEntry(REDO_EXT, redoStack);
|
|
430
|
+
ctx.ui.notify(`Redo failed: ${message(error)}`, "error");
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function liveSnapshotIds() {
|
|
435
|
+
const ids = new Set<string>();
|
|
436
|
+
for (const checkpoint of checkpoints.values()) {
|
|
437
|
+
ids.add(checkpoint.beforeSnapshot);
|
|
438
|
+
ids.add(checkpoint.afterSnapshot);
|
|
439
|
+
}
|
|
440
|
+
for (const item of redoStack) {
|
|
441
|
+
if (item.snapshot) ids.add(item.snapshot);
|
|
442
|
+
if (item.checkpoint) {
|
|
443
|
+
ids.add(item.checkpoint.beforeSnapshot);
|
|
444
|
+
ids.add(item.checkpoint.afterSnapshot);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
if (active) ids.add(active.beforeSnapshot);
|
|
448
|
+
if (pendingBeforeSnapshot) ids.add(pendingBeforeSnapshot.beforeSnapshot);
|
|
449
|
+
return ids;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
async function finalizeActiveCheckpoint(ctx: {
|
|
453
|
+
cwd: string;
|
|
454
|
+
sessionManager: { getSessionId(): string; getLeafId(): string | null };
|
|
455
|
+
ui: { notify(message: string, level?: "warning" | "info" | "error"): void };
|
|
456
|
+
}) {
|
|
457
|
+
if (!active) return;
|
|
458
|
+
const current = active;
|
|
459
|
+
active = undefined;
|
|
460
|
+
pendingBeforeSnapshot = undefined;
|
|
461
|
+
|
|
462
|
+
try {
|
|
463
|
+
const snap = await getSnapshotter(ctx.cwd, ctx.sessionManager.getSessionId());
|
|
464
|
+
if (!(await snap.available())) return;
|
|
465
|
+
const touchedFiles = [...current.touchedFiles];
|
|
466
|
+
const snapshotFiles = unique([...current.snapshotFiles, ...touchedFiles]);
|
|
467
|
+
const gitMode = await snap.isGitMode();
|
|
468
|
+
const afterSnapshot = await snap.track(gitMode ? touchedFiles : snapshotFiles);
|
|
469
|
+
const files = await snap.patchFiles(current.beforeSnapshot, touchedFiles);
|
|
470
|
+
const checkpoint: Checkpoint = {
|
|
471
|
+
userEntryId: current.userEntryId,
|
|
472
|
+
beforeLeafId: current.beforeLeafId,
|
|
473
|
+
prompt: current.prompt,
|
|
474
|
+
beforeSnapshot: current.beforeSnapshot,
|
|
475
|
+
finalLeafId: ctx.sessionManager.getLeafId() ?? current.userEntryId,
|
|
476
|
+
afterSnapshot,
|
|
477
|
+
files,
|
|
478
|
+
createdAt: Date.now(),
|
|
479
|
+
};
|
|
480
|
+
checkpoints.set(checkpoint.userEntryId, checkpoint);
|
|
481
|
+
pi.appendEntry(EXT, checkpoint);
|
|
482
|
+
} catch (error) {
|
|
483
|
+
ctx.ui.notify(`Pi undo/redo checkpoint finalization failed: ${message(error)}`, "warning");
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function cumulativeExplicitFiles() {
|
|
488
|
+
const files = new Set<string>();
|
|
489
|
+
for (const checkpoint of checkpoints.values()) {
|
|
490
|
+
for (const file of checkpoint.files) files.add(file);
|
|
491
|
+
}
|
|
492
|
+
return [...files];
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
async function changedFilesForTarget(snap: ShadowGit, target: SnapshotTarget, gitMode: boolean) {
|
|
496
|
+
if (gitMode || !target.restoreGroups) return snap.changedFilesForSnapshot(target.snapshot, target.files);
|
|
497
|
+
const affected = new Set<string>();
|
|
498
|
+
for (const group of target.restoreGroups) {
|
|
499
|
+
for (const file of await snap.changedFilesForSnapshot(group.snapshot, group.files)) affected.add(file);
|
|
500
|
+
}
|
|
501
|
+
return affected;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function resolveSnapshotForLeaf(branch: SessionEntry[], leafId: string | null, gitMode: boolean) {
|
|
505
|
+
if (checkpoints.size === 0) return;
|
|
506
|
+
const index = new Map(branch.map((entry, i) => [entry.id, i] as const));
|
|
507
|
+
let best: SnapshotTarget | undefined;
|
|
508
|
+
let fallback: SnapshotTarget | undefined;
|
|
509
|
+
|
|
510
|
+
const addRestoreGroup = (groups: Map<string, Set<string>>, snapshot: string, files: Iterable<string>) => {
|
|
511
|
+
let group = groups.get(snapshot);
|
|
512
|
+
if (!group) {
|
|
513
|
+
group = new Set<string>();
|
|
514
|
+
groups.set(snapshot, group);
|
|
515
|
+
}
|
|
516
|
+
for (const file of files) group.add(file);
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
const firstTouchBeforeRestoreGroups = (files: Iterable<string>) => {
|
|
520
|
+
const pending = new Set(files);
|
|
521
|
+
const groups = new Map<string, Set<string>>();
|
|
522
|
+
if (pending.size === 0) return [];
|
|
523
|
+
for (const checkpoint of checkpoints.values()) {
|
|
524
|
+
const firstTouchedHere = checkpoint.files.filter((file) => pending.has(file));
|
|
525
|
+
if (firstTouchedHere.length === 0) continue;
|
|
526
|
+
addRestoreGroup(groups, checkpoint.beforeSnapshot, firstTouchedHere);
|
|
527
|
+
for (const file of firstTouchedHere) pending.delete(file);
|
|
528
|
+
if (pending.size === 0) break;
|
|
529
|
+
}
|
|
530
|
+
return [...groups].map(([snapshot, groupFiles]) => ({ snapshot, files: [...groupFiles] }));
|
|
531
|
+
};
|
|
532
|
+
|
|
533
|
+
const rootRestoreTarget = () => {
|
|
534
|
+
if (gitMode || leafId) return undefined;
|
|
535
|
+
const files = cumulativeExplicitFiles();
|
|
536
|
+
if (files.length === 0) return undefined;
|
|
537
|
+
const restoreGroups = firstTouchBeforeRestoreGroups(files);
|
|
538
|
+
return {
|
|
539
|
+
snapshot: emptySnapshot(),
|
|
540
|
+
files: filesForRestoreGroups(restoreGroups),
|
|
541
|
+
restoreGroups,
|
|
542
|
+
score: -1,
|
|
543
|
+
reason: "before first checkpoint",
|
|
544
|
+
};
|
|
545
|
+
};
|
|
546
|
+
|
|
547
|
+
const nonGitRestoreGroups = (targetSnapshot: string, score: number, includeFiles: string[] = []) => {
|
|
548
|
+
if (gitMode) return undefined;
|
|
549
|
+
const targetFiles = new Set<string>(includeFiles);
|
|
550
|
+
const rootFiles = new Set<string>();
|
|
551
|
+
for (const checkpoint of checkpoints.values()) {
|
|
552
|
+
for (const file of checkpoint.files) rootFiles.add(file);
|
|
553
|
+
const after = index.get(checkpoint.finalLeafId);
|
|
554
|
+
if (after !== undefined && after <= score) {
|
|
555
|
+
for (const file of checkpoint.files) targetFiles.add(file);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
const groups = new Map<string, Set<string>>();
|
|
559
|
+
const existingAtTarget = [...targetFiles];
|
|
560
|
+
if (existingAtTarget.length > 0) addRestoreGroup(groups, targetSnapshot, existingAtTarget);
|
|
561
|
+
const absentAtTarget = [...rootFiles].filter((file) => !targetFiles.has(file));
|
|
562
|
+
for (const group of firstTouchBeforeRestoreGroups(absentAtTarget)) addRestoreGroup(groups, group.snapshot, group.files);
|
|
563
|
+
return [...groups].map(([snapshot, groupFiles]) => ({ snapshot, files: [...groupFiles] }));
|
|
564
|
+
};
|
|
565
|
+
|
|
566
|
+
const filesForRestoreGroups = (restoreGroups?: SnapshotRestoreGroup[]) => {
|
|
567
|
+
if (gitMode) return undefined;
|
|
568
|
+
const files = new Set<string>();
|
|
569
|
+
for (const group of restoreGroups ?? []) {
|
|
570
|
+
for (const file of group.files) files.add(file);
|
|
571
|
+
}
|
|
572
|
+
return [...files];
|
|
573
|
+
};
|
|
574
|
+
if (!leafId) return rootRestoreTarget();
|
|
575
|
+
const keepBest = (candidate: SnapshotTarget) => {
|
|
576
|
+
if (!best || candidate.score > best.score) best = candidate;
|
|
577
|
+
};
|
|
578
|
+
const keepFallback = (candidate: SnapshotTarget) => {
|
|
579
|
+
if (!fallback || candidate.score > fallback.score) fallback = candidate;
|
|
580
|
+
};
|
|
581
|
+
|
|
582
|
+
for (const checkpoint of checkpoints.values()) {
|
|
583
|
+
const after = index.get(checkpoint.finalLeafId);
|
|
584
|
+
if (after !== undefined) {
|
|
585
|
+
const restoreGroups = nonGitRestoreGroups(checkpoint.afterSnapshot, after);
|
|
586
|
+
keepBest({
|
|
587
|
+
snapshot: checkpoint.afterSnapshot,
|
|
588
|
+
files: filesForRestoreGroups(restoreGroups),
|
|
589
|
+
restoreGroups,
|
|
590
|
+
score: after,
|
|
591
|
+
reason: "after checkpoint",
|
|
592
|
+
});
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const atUser = index.get(checkpoint.userEntryId);
|
|
597
|
+
if (atUser !== undefined) {
|
|
598
|
+
const restoreGroups = nonGitRestoreGroups(checkpoint.beforeSnapshot, atUser, checkpoint.files);
|
|
599
|
+
keepBest({
|
|
600
|
+
snapshot: checkpoint.beforeSnapshot,
|
|
601
|
+
files: filesForRestoreGroups(restoreGroups),
|
|
602
|
+
restoreGroups,
|
|
603
|
+
score: atUser,
|
|
604
|
+
reason: "before checkpoint",
|
|
605
|
+
});
|
|
606
|
+
continue;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
if (checkpoint.beforeLeafId) {
|
|
610
|
+
const before = index.get(checkpoint.beforeLeafId);
|
|
611
|
+
if (before !== undefined) {
|
|
612
|
+
const restoreGroups = nonGitRestoreGroups(checkpoint.beforeSnapshot, before, checkpoint.files);
|
|
613
|
+
keepFallback({
|
|
614
|
+
snapshot: checkpoint.beforeSnapshot,
|
|
615
|
+
files: filesForRestoreGroups(restoreGroups),
|
|
616
|
+
restoreGroups,
|
|
617
|
+
score: before,
|
|
618
|
+
reason: "before next checkpoint",
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
return best ?? fallback ?? rootRestoreTarget();
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
async function getSnapshotter(cwd: string, sessionId: string) {
|
|
628
|
+
if (!snapshotter) snapshotter = new ShadowGit(cwd, sessionId, await loadSettings(cwd));
|
|
629
|
+
return snapshotter;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
async function captureExplicitToolPath(
|
|
633
|
+
snap: ShadowGit,
|
|
634
|
+
rawPath: unknown,
|
|
635
|
+
rawContent: unknown,
|
|
636
|
+
ctx: { cwd: string; ui: { notify(message: string, level?: "warning" | "info" | "error"): void } },
|
|
637
|
+
) {
|
|
638
|
+
if (typeof rawPath !== "string") throw new Error("tool input has no string path");
|
|
639
|
+
const rel = await snap.resolveWorkspacePath(rawPath, ctx.cwd);
|
|
640
|
+
await snap.assertExplicitPathSupported(rel, rawContent);
|
|
641
|
+
if (!active) return;
|
|
642
|
+
if (!active.snapshotFiles.has(rel)) {
|
|
643
|
+
active.beforeSnapshot = await snap.capturePathBefore(rel, active.beforeSnapshot);
|
|
644
|
+
active.snapshotFiles.add(rel);
|
|
645
|
+
}
|
|
646
|
+
active.touchedFiles.add(rel);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
class AsyncMutex {
|
|
651
|
+
private tail: Promise<void> = Promise.resolve();
|
|
652
|
+
|
|
653
|
+
async run<T>(fn: () => Promise<T>): Promise<T> {
|
|
654
|
+
let release!: () => void;
|
|
655
|
+
const previous = this.tail;
|
|
656
|
+
this.tail = new Promise<void>((resolve) => {
|
|
657
|
+
release = resolve;
|
|
658
|
+
});
|
|
659
|
+
await previous;
|
|
660
|
+
try {
|
|
661
|
+
return await fn();
|
|
662
|
+
} finally {
|
|
663
|
+
release();
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
export class ShadowGit {
|
|
669
|
+
private initialized = false;
|
|
670
|
+
private repoRoot: string | undefined;
|
|
671
|
+
private sourceGitDir: string | undefined;
|
|
672
|
+
private gitdir: string | undefined;
|
|
673
|
+
private readonly cwd: string;
|
|
674
|
+
private readonly sessionId: string;
|
|
675
|
+
private readonly settings: Settings;
|
|
676
|
+
private readonly mutex = new AsyncMutex();
|
|
677
|
+
|
|
678
|
+
constructor(cwd: string, sessionId: string, settings: Settings) {
|
|
679
|
+
this.cwd = cwd;
|
|
680
|
+
this.sessionId = sessionId;
|
|
681
|
+
this.settings = settings;
|
|
682
|
+
// Match OpenCode's storage model: one shadow git repository per resolved
|
|
683
|
+
// git worktree, not per chat session or launch cwd. The key is set after
|
|
684
|
+
// rev-parse resolves the source repository root so subdirectory launches
|
|
685
|
+
// share the same shadow repo.
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
async available(): Promise<boolean> {
|
|
689
|
+
if (!this.settings.enabled) return false;
|
|
690
|
+
try {
|
|
691
|
+
return await this.mutex.run(async () => {
|
|
692
|
+
await this.ensureRepoInfo();
|
|
693
|
+
return Boolean(this.repoRoot && this.gitdir);
|
|
694
|
+
});
|
|
695
|
+
} catch {
|
|
696
|
+
return false;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
async changedFilesForSnapshot(snapshot: string, files?: string[]): Promise<Set<string>> {
|
|
701
|
+
return this.mutex.run(() => this.changedFilesForSnapshotImpl(snapshot, files));
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
async dirtyFiles(files?: string[]): Promise<string[]> {
|
|
705
|
+
return this.mutex.run(() => this.dirtyFilesImpl(files));
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
async track(files?: string[]): Promise<string> {
|
|
709
|
+
return this.mutex.run(() => this.trackImpl(files));
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
async patchFiles(from: string, files?: string[]): Promise<string[]> {
|
|
713
|
+
return this.mutex.run(() => this.patchFilesImpl(from, files));
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
async restoreSnapshot(snapshot: string, files?: string[]): Promise<void> {
|
|
717
|
+
return this.mutex.run(() => this.restoreSnapshotImpl(snapshot, files));
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
async revertFiles(snapshot: string, files: string[]): Promise<void> {
|
|
721
|
+
return this.mutex.run(() => this.revertFilesImpl(snapshot, files));
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
async cleanupSnapshots(liveSnapshots: Iterable<string> = [], maxRefs = 256): Promise<number> {
|
|
725
|
+
return this.mutex.run(() => this.cleanupSnapshotsImpl(maxRefs, new Set(liveSnapshots)));
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
getStorageKey(): string | undefined {
|
|
729
|
+
return this.gitdir ? path.basename(path.dirname(this.gitdir)) : undefined;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
getShadowGitDir(): string | undefined {
|
|
733
|
+
return this.gitdir;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
async isGitMode(): Promise<boolean> {
|
|
737
|
+
await this.ensureRepoInfo();
|
|
738
|
+
return Boolean(this.sourceGitDir);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
async resolveWorkspacePath(rawPath: string, cwd: string): Promise<string> {
|
|
742
|
+
await this.ensureRepoInfo();
|
|
743
|
+
if (this.sourceGitDir) {
|
|
744
|
+
const normalized = normalizeRelPath(rawPath);
|
|
745
|
+
if (!normalized) throw new Error(`unsafe path: ${rawPath}`);
|
|
746
|
+
return normalized;
|
|
747
|
+
}
|
|
748
|
+
return resolveSafeWorkspacePath(rawPath, cwd, this.root());
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
async capturePathBefore(rel: string, baseSnapshot: string): Promise<string> {
|
|
752
|
+
return this.mutex.run(async () => {
|
|
753
|
+
await this.ensure();
|
|
754
|
+
if (this.sourceGitDir) return baseSnapshot;
|
|
755
|
+
await this.readTree(baseSnapshot);
|
|
756
|
+
await this.addChanged([rel]);
|
|
757
|
+
return this.writeProtectedTree();
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
async assertExplicitPathSupported(rel: string, content?: unknown): Promise<void> {
|
|
762
|
+
await this.ensureRepoInfo();
|
|
763
|
+
if (this.sourceGitDir) return;
|
|
764
|
+
if (typeof content === "string" && Buffer.byteLength(content, "utf8") > this.settings.largeFileLimitBytes) {
|
|
765
|
+
throw new Error(`explicit file exceeds largeFileLimitBytes: ${rel}`);
|
|
766
|
+
}
|
|
767
|
+
const info = await stat(path.join(this.root(), rel)).catch(() => undefined);
|
|
768
|
+
if (info?.isDirectory()) throw new Error(`refusing to snapshot directory path: ${rel}`);
|
|
769
|
+
if (info?.isFile() && info.size > this.settings.largeFileLimitBytes) throw new Error(`explicit file exceeds largeFileLimitBytes: ${rel}`);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
private async changedFilesForSnapshotImpl(snapshot: string, files?: string[]): Promise<Set<string>> {
|
|
773
|
+
await this.ensure();
|
|
774
|
+
const paths = this.pathspecOrDot(files);
|
|
775
|
+
if (!this.sourceGitDir && paths.length === 0) return new Set();
|
|
776
|
+
const result = await this.git(this.shadowArgs(["diff", "--cached", "--name-only", "-z", "--no-renames", snapshot, "--", ...paths]));
|
|
777
|
+
if (result.code !== 0) throw new Error(result.stderr || result.stdout || "git diff failed");
|
|
778
|
+
return new Set(splitNul(result.stdout).map(normalizeRelPath).filter(isString));
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
private async dirtyFilesImpl(files?: string[]): Promise<string[]> {
|
|
782
|
+
await this.ensure();
|
|
783
|
+
const paths = this.pathspecOrDot(files);
|
|
784
|
+
if (!this.sourceGitDir) {
|
|
785
|
+
if (paths.length === 0) return [];
|
|
786
|
+
const [managedDiff, managedTracked] = await Promise.all([
|
|
787
|
+
this.git(this.shadowArgs(["diff-files", "--name-only", "-z", "--", ...paths])),
|
|
788
|
+
this.git(this.shadowArgs(["ls-files", "-z", "--", ...paths])),
|
|
789
|
+
]);
|
|
790
|
+
if (managedDiff.code !== 0 || managedTracked.code !== 0) {
|
|
791
|
+
throw new Error(managedDiff.stderr || managedTracked.stderr || "failed to list dirty files");
|
|
792
|
+
}
|
|
793
|
+
const trackedByShadow = new Set(splitNul(managedTracked.stdout).map(normalizeRelPath).filter(isString));
|
|
794
|
+
const untracked = await this.untrackedExistingPaths(paths, trackedByShadow);
|
|
795
|
+
return unique([...splitNul(managedDiff.stdout).map(normalizeRelPath).filter(isString), ...untracked]);
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
const [managedDiff, managedTracked, sourceOther] = await Promise.all([
|
|
799
|
+
this.git(this.shadowArgs(["diff-files", "--name-only", "-z", "--", ...paths])),
|
|
800
|
+
this.git(this.shadowArgs(["ls-files", "-z", "--", ...paths])),
|
|
801
|
+
this.git(this.sourceArgs(["ls-files", "--others", "--exclude-standard", "-z", "--", ...paths])),
|
|
802
|
+
]);
|
|
803
|
+
if (managedDiff.code !== 0 || managedTracked.code !== 0 || sourceOther.code !== 0) {
|
|
804
|
+
throw new Error(managedDiff.stderr || managedTracked.stderr || sourceOther.stderr || "failed to list dirty files");
|
|
805
|
+
}
|
|
806
|
+
const trackedByShadow = new Set(splitNul(managedTracked.stdout).map(normalizeRelPath).filter(isString));
|
|
807
|
+
return unique([
|
|
808
|
+
...splitNul(managedDiff.stdout).map(normalizeRelPath).filter(isString),
|
|
809
|
+
...splitNul(sourceOther.stdout)
|
|
810
|
+
.map(normalizeRelPath)
|
|
811
|
+
.filter(isString)
|
|
812
|
+
.filter((file) => !trackedByShadow.has(file)),
|
|
813
|
+
]);
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
private async trackImpl(files?: string[]): Promise<string> {
|
|
817
|
+
await this.ensure();
|
|
818
|
+
if (!this.sourceGitDir && files === undefined) {
|
|
819
|
+
await this.clearIndex();
|
|
820
|
+
return this.writeProtectedTree();
|
|
821
|
+
}
|
|
822
|
+
await this.addChanged(files);
|
|
823
|
+
return this.writeProtectedTree();
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
private async patchFilesImpl(from: string, files?: string[]): Promise<string[]> {
|
|
827
|
+
await this.ensure();
|
|
828
|
+
await this.addChanged(files);
|
|
829
|
+
const paths = this.pathspecOrDot(files);
|
|
830
|
+
if (!this.sourceGitDir && paths.length === 0) return [];
|
|
831
|
+
const result = await this.git(this.shadowArgs(["diff", "--cached", "--no-ext-diff", "--name-only", from, "--", ...paths]));
|
|
832
|
+
if (result.code !== 0) throw new Error(result.stderr || result.stdout || "git diff failed");
|
|
833
|
+
return unique(result.stdout.split("\n").map((line) => normalizeRelPath(line)).filter(isString));
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
private async restoreSnapshotImpl(snapshot: string, files?: string[]): Promise<void> {
|
|
837
|
+
await this.ensure();
|
|
838
|
+
const changed = [...(await this.changedFilesForSnapshotImpl(snapshot, files))];
|
|
839
|
+
const safeFiles = this.sourceGitDir ? changed : this.limitToKnownFiles(changed, files);
|
|
840
|
+
await this.revertFilesImpl(snapshot, safeFiles);
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
private async revertFilesImpl(snapshot: string, files: string[]): Promise<void> {
|
|
844
|
+
await this.ensure();
|
|
845
|
+
const reverted: string[] = [];
|
|
846
|
+
for (const file of unique(files)) {
|
|
847
|
+
const rel = normalizeRelPath(file);
|
|
848
|
+
if (!rel) continue;
|
|
849
|
+
const tree = await this.git(this.shadowArgs(["ls-tree", snapshot, "--", rel]));
|
|
850
|
+
if (tree.code === 0 && tree.stdout.trim()) {
|
|
851
|
+
const checkout = await this.git(this.shadowArgs(["checkout", snapshot, "--", rel]));
|
|
852
|
+
if (checkout.code !== 0) throw new Error(checkout.stderr || checkout.stdout || `git checkout ${rel} failed`);
|
|
853
|
+
} else {
|
|
854
|
+
await this.safeRemovePath(rel);
|
|
855
|
+
}
|
|
856
|
+
reverted.push(rel);
|
|
857
|
+
}
|
|
858
|
+
await this.addChanged(this.sourceGitDir ? undefined : reverted);
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
private async cleanupSnapshotsImpl(maxRefs: number, liveSnapshots: Set<string>): Promise<number> {
|
|
862
|
+
await this.ensure();
|
|
863
|
+
const limit = Math.max(0, Math.floor(maxRefs));
|
|
864
|
+
const list = await this.git(this.shadowArgs(["for-each-ref", "--sort=-creatordate", "--format=%(refname)", "refs/pi-undo-redo/snapshots"]), {
|
|
865
|
+
allowFailure: true,
|
|
866
|
+
});
|
|
867
|
+
if (list.code !== 0) throw new Error(list.stderr || list.stdout || "git for-each-ref failed");
|
|
868
|
+
const refs = list.stdout.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
869
|
+
let removed = 0;
|
|
870
|
+
for (const ref of refs.slice(limit)) {
|
|
871
|
+
if (!ref.startsWith("refs/pi-undo-redo/snapshots/")) continue;
|
|
872
|
+
const snapshot = ref.slice("refs/pi-undo-redo/snapshots/".length);
|
|
873
|
+
if (liveSnapshots.has(snapshot)) continue;
|
|
874
|
+
const update = await this.git(this.shadowArgs(["update-ref", "-d", ref]));
|
|
875
|
+
if (update.code !== 0) throw new Error(update.stderr || update.stdout || `git update-ref -d ${ref} failed`);
|
|
876
|
+
removed++;
|
|
877
|
+
}
|
|
878
|
+
return removed;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
private async safeRemovePath(rel: string): Promise<void> {
|
|
882
|
+
const currentShadow = await this.pathKind(rel);
|
|
883
|
+
if (currentShadow === "directory") {
|
|
884
|
+
throw new Error(`Refusing to restore file over shadow-tracked directory: ${rel}`);
|
|
885
|
+
}
|
|
886
|
+
const target = path.join(this.root(), rel);
|
|
887
|
+
const info = await lstat(target).catch(() => undefined);
|
|
888
|
+
if (!info) return;
|
|
889
|
+
if (info.isDirectory()) {
|
|
890
|
+
const tracked = await this.git(this.shadowArgs(["ls-files", "-z", "--", rel]));
|
|
891
|
+
const trackedFiles = splitNul(tracked.stdout).map(normalizeRelPath).filter(isString);
|
|
892
|
+
if (trackedFiles.length > 0) {
|
|
893
|
+
throw new Error(`Refusing to recursively remove directory with tracked contents: ${rel}`);
|
|
894
|
+
}
|
|
895
|
+
throw new Error(`Refusing to recursively remove directory for file restore: ${rel}`);
|
|
896
|
+
}
|
|
897
|
+
await rm(target, { force: true });
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
private async pathKind(rel: string): Promise<"file" | "directory" | undefined> {
|
|
901
|
+
const result = await this.git(this.shadowArgs(["ls-tree", "-z", "HEAD", "--", rel]), { allowFailure: true });
|
|
902
|
+
if (result.code !== 0 || !result.stdout) return undefined;
|
|
903
|
+
const first = splitNul(result.stdout)[0] ?? "";
|
|
904
|
+
const meta = first.split("\t", 1)[0] ?? "";
|
|
905
|
+
const modeType = meta.split(/\s+/);
|
|
906
|
+
const kind = modeType[1];
|
|
907
|
+
if (kind === "tree") return "directory";
|
|
908
|
+
if (kind === "blob" || kind === "commit") return "file";
|
|
909
|
+
return undefined;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
private async protectSnapshot(snapshot: string): Promise<void> {
|
|
913
|
+
const ref = `refs/pi-undo-redo/snapshots/${snapshot}`;
|
|
914
|
+
const commit = await this.git(this.shadowArgs(["commit-tree", snapshot, "-m", `[pi-undo-redo] ${snapshot}`]));
|
|
915
|
+
if (commit.code !== 0) throw new Error(commit.stderr || commit.stdout || "git commit-tree failed");
|
|
916
|
+
const update = await this.git(this.shadowArgs(["update-ref", ref, commit.stdout.trim()]));
|
|
917
|
+
if (update.code !== 0) throw new Error(update.stderr || update.stdout || "git update-ref failed");
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
private async ensure(): Promise<void> {
|
|
921
|
+
await this.ensureRepoInfo();
|
|
922
|
+
const gitdir = this.shadow();
|
|
923
|
+
await mkdir(gitdir, { recursive: true });
|
|
924
|
+
if (!this.initialized && !existsSync(path.join(gitdir, "HEAD"))) {
|
|
925
|
+
const init = await this.git(["init"], { env: { GIT_DIR: gitdir, GIT_WORK_TREE: this.root() } });
|
|
926
|
+
if (init.code !== 0) throw new Error(init.stderr || init.stdout || "git init failed");
|
|
927
|
+
await this.git(["--git-dir", gitdir, "config", "core.autocrlf", "false"]);
|
|
928
|
+
await this.git(["--git-dir", gitdir, "config", "core.longpaths", "true"]);
|
|
929
|
+
await this.git(["--git-dir", gitdir, "config", "core.symlinks", "true"]);
|
|
930
|
+
await this.git(["--git-dir", gitdir, "config", "core.fsmonitor", "false"]);
|
|
931
|
+
}
|
|
932
|
+
const name = await this.git(["--git-dir", gitdir, "config", "user.name", "pi-undo-redo"]);
|
|
933
|
+
if (name.code !== 0) throw new Error(name.stderr || name.stdout || "git config user.name failed");
|
|
934
|
+
const email = await this.git(["--git-dir", gitdir, "config", "user.email", "pi-undo-redo@local"]);
|
|
935
|
+
if (email.code !== 0) throw new Error(email.stderr || email.stdout || "git config user.email failed");
|
|
936
|
+
this.initialized = true;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
private async ensureRepoInfo(): Promise<void> {
|
|
940
|
+
if (this.repoRoot && this.gitdir) return;
|
|
941
|
+
const root = await this.git(["-C", this.cwd, "rev-parse", "--show-toplevel"], { allowFailure: true });
|
|
942
|
+
if (root.code === 0 && root.stdout.trim()) {
|
|
943
|
+
this.repoRoot = path.resolve(root.stdout.trim());
|
|
944
|
+
const gitDir = await this.git(["-C", this.repoRoot, "rev-parse", "--absolute-git-dir"]);
|
|
945
|
+
if (gitDir.code !== 0) throw new Error("cannot resolve source git dir");
|
|
946
|
+
this.sourceGitDir = path.resolve(gitDir.stdout.trim());
|
|
947
|
+
const key = createHash("sha256").update(`git:${this.repoRoot}`).digest("hex").slice(0, 24);
|
|
948
|
+
this.gitdir = path.join(this.settings.storageDir, "worktrees", key, "repo.git");
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
this.repoRoot = path.resolve(this.cwd);
|
|
953
|
+
this.sourceGitDir = undefined;
|
|
954
|
+
const key = createHash("sha256").update(`fs:${this.repoRoot}`).digest("hex").slice(0, 24);
|
|
955
|
+
this.gitdir = path.join(this.settings.storageDir, "worktrees", key, "repo.git");
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
private async addChanged(files?: string[]): Promise<void> {
|
|
959
|
+
await this.syncExclude();
|
|
960
|
+
const paths = this.pathspecOrDot(files);
|
|
961
|
+
if (!this.sourceGitDir && paths.length === 0) return;
|
|
962
|
+
|
|
963
|
+
const trackedArgs = this.sourceGitDir ? this.sourceArgs(["ls-files", "-z", "--", ...paths]) : this.shadowArgs(["ls-files", "-z", "--", ...paths]);
|
|
964
|
+
const [shadowModified, shadowOthers, sourceTracked] = await Promise.all([
|
|
965
|
+
this.git(this.shadowArgs(["diff-files", "--name-only", "-z", "--", ...paths])),
|
|
966
|
+
this.git(this.shadowArgs(["ls-files", "--others", "-z", "--", ...paths])),
|
|
967
|
+
this.git(trackedArgs),
|
|
968
|
+
]);
|
|
969
|
+
if (shadowModified.code !== 0 || shadowOthers.code !== 0 || sourceTracked.code !== 0) {
|
|
970
|
+
throw new Error(shadowModified.stderr || shadowOthers.stderr || sourceTracked.stderr || "failed to list snapshot files");
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
const sourceTrackedSet = new Set(splitNul(sourceTracked.stdout).map(normalizeRelPath).filter(isString));
|
|
974
|
+
const candidates = unique([
|
|
975
|
+
...splitNul(shadowModified.stdout).map(normalizeRelPath).filter(isString),
|
|
976
|
+
...splitNul(shadowOthers.stdout).map(normalizeRelPath).filter(isString),
|
|
977
|
+
...(!this.sourceGitDir ? await this.untrackedExistingPaths(paths, new Set()) : []),
|
|
978
|
+
]);
|
|
979
|
+
if (candidates.length === 0) return;
|
|
980
|
+
|
|
981
|
+
const sourceUntracked = candidates.filter((file) => !sourceTrackedSet.has(file));
|
|
982
|
+
const sourceUntrackedSet = new Set(sourceUntracked);
|
|
983
|
+
const ignored = await this.checkIgnored(sourceUntracked);
|
|
984
|
+
if (ignored.size > 0) await this.drop([...ignored]);
|
|
985
|
+
|
|
986
|
+
const large = new Set<string>();
|
|
987
|
+
await Promise.all(
|
|
988
|
+
sourceUntracked.map(async (file) => {
|
|
989
|
+
if (ignored.has(file)) return;
|
|
990
|
+
try {
|
|
991
|
+
const s = await stat(path.join(this.root(), file));
|
|
992
|
+
if (s.isFile() && s.size > this.settings.largeFileLimitBytes) large.add(file);
|
|
993
|
+
} catch {
|
|
994
|
+
// ignore races with deleted files
|
|
995
|
+
}
|
|
996
|
+
}),
|
|
997
|
+
);
|
|
998
|
+
|
|
999
|
+
if (large.size > 0) await this.syncExclude([...large]);
|
|
1000
|
+
const stage = candidates.filter((file) => !sourceUntrackedSet.has(file) || (!ignored.has(file) && !large.has(file)));
|
|
1001
|
+
if (stage.length === 0) return;
|
|
1002
|
+
|
|
1003
|
+
const result = await this.git(this.shadowArgs(["add", "--all", "--sparse", "-f", "--pathspec-from-file=-", "--pathspec-file-nul"]), {
|
|
1004
|
+
stdin: feed(stage),
|
|
1005
|
+
});
|
|
1006
|
+
if (result.code !== 0) throw new Error(result.stderr || result.stdout || "git add failed");
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
private async readTree(snapshot: string): Promise<void> {
|
|
1010
|
+
const result = await this.git(this.shadowArgs(["read-tree", snapshot]));
|
|
1011
|
+
if (result.code !== 0) throw new Error(result.stderr || result.stdout || "git read-tree failed");
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
private async clearIndex(): Promise<void> {
|
|
1015
|
+
const result = await this.git(this.shadowArgs(["read-tree", "--empty"]));
|
|
1016
|
+
if (result.code !== 0) throw new Error(result.stderr || result.stdout || "git read-tree --empty failed");
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
private async writeProtectedTree(): Promise<string> {
|
|
1020
|
+
const result = await this.git(this.shadowArgs(["write-tree"]));
|
|
1021
|
+
if (result.code !== 0) throw new Error(result.stderr || result.stdout || "git write-tree failed");
|
|
1022
|
+
const snapshot = result.stdout.trim();
|
|
1023
|
+
await this.protectSnapshot(snapshot);
|
|
1024
|
+
return snapshot;
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
private pathspecOrDot(files?: string[]): string[] {
|
|
1028
|
+
if (!files) return this.sourceGitDir ? ["."] : [];
|
|
1029
|
+
return unique(files.map(normalizeRelPath).filter(isString));
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
private limitToKnownFiles(files: string[], known?: string[]): string[] {
|
|
1033
|
+
if (!known) return files;
|
|
1034
|
+
const knownSet = new Set(known.map(normalizeRelPath).filter(isString));
|
|
1035
|
+
return files.filter((file) => {
|
|
1036
|
+
const normalized = normalizeRelPath(file);
|
|
1037
|
+
return Boolean(normalized && knownSet.has(normalized));
|
|
1038
|
+
});
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
private async untrackedExistingPaths(paths: string[], trackedByShadow: Set<string>): Promise<string[]> {
|
|
1042
|
+
const files = await Promise.all(
|
|
1043
|
+
paths.map(async (rel) => {
|
|
1044
|
+
const normalized = normalizeRelPath(rel);
|
|
1045
|
+
if (!normalized || trackedByShadow.has(normalized)) return undefined;
|
|
1046
|
+
try {
|
|
1047
|
+
const info = await stat(path.join(this.root(), normalized));
|
|
1048
|
+
if (info.isFile()) return normalized;
|
|
1049
|
+
} catch {
|
|
1050
|
+
// deleted paths are handled by git add --all for tracked paths
|
|
1051
|
+
}
|
|
1052
|
+
return undefined;
|
|
1053
|
+
}),
|
|
1054
|
+
);
|
|
1055
|
+
return unique(files.filter(isString));
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
private async checkIgnored(files: string[]): Promise<Set<string>> {
|
|
1059
|
+
if (!files.length) return new Set();
|
|
1060
|
+
const args = this.sourceGitDir
|
|
1061
|
+
? ["--git-dir", this.source(), "--work-tree", this.root(), "check-ignore", "--no-index", "--stdin", "-z"]
|
|
1062
|
+
: this.shadowArgs(["check-ignore", "--no-index", "--stdin", "-z"]);
|
|
1063
|
+
const result = await this.git(args, { cwd: this.root(), stdin: feed(files), allowFailure: true });
|
|
1064
|
+
if (result.code !== 0 && result.code !== 1) return new Set();
|
|
1065
|
+
return new Set(splitNul(result.stdout).map(normalizeRelPath).filter(isString));
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
private async drop(files: string[]): Promise<void> {
|
|
1069
|
+
if (!files.length) return;
|
|
1070
|
+
await this.git(this.shadowArgs(["rm", "--cached", "-f", "--ignore-unmatch", "--pathspec-from-file=-", "--pathspec-file-nul"]), {
|
|
1071
|
+
stdin: feed(files),
|
|
1072
|
+
allowFailure: true,
|
|
1073
|
+
});
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
private async syncExclude(extra: string[] = []): Promise<void> {
|
|
1077
|
+
const sourceExclude = this.sourceGitDir ? path.join(this.source(), "info", "exclude") : undefined;
|
|
1078
|
+
const source = sourceExclude ? await readFile(sourceExclude, "utf8").catch(() => "") : "";
|
|
1079
|
+
const lines = [source.trimEnd(), ...extra.map((item) => `/${item.replaceAll("\\", "/")}`)].filter(Boolean);
|
|
1080
|
+
const excludePath = path.join(this.shadow(), "info", "exclude");
|
|
1081
|
+
await mkdir(path.dirname(excludePath), { recursive: true });
|
|
1082
|
+
await writeFile(excludePath, lines.length ? `${lines.join("\n")}\n` : "", "utf8");
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
private sourceArgs(args: string[]): string[] {
|
|
1086
|
+
return [
|
|
1087
|
+
"-c",
|
|
1088
|
+
"core.longpaths=true",
|
|
1089
|
+
"-c",
|
|
1090
|
+
"core.symlinks=true",
|
|
1091
|
+
"-c",
|
|
1092
|
+
"core.autocrlf=false",
|
|
1093
|
+
"-c",
|
|
1094
|
+
"core.quotepath=false",
|
|
1095
|
+
"--git-dir",
|
|
1096
|
+
this.source(),
|
|
1097
|
+
"--work-tree",
|
|
1098
|
+
this.root(),
|
|
1099
|
+
...args,
|
|
1100
|
+
];
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
private shadowArgs(args: string[]): string[] {
|
|
1104
|
+
return [
|
|
1105
|
+
"-c",
|
|
1106
|
+
"core.longpaths=true",
|
|
1107
|
+
"-c",
|
|
1108
|
+
"core.symlinks=true",
|
|
1109
|
+
"-c",
|
|
1110
|
+
"core.autocrlf=false",
|
|
1111
|
+
"-c",
|
|
1112
|
+
"core.quotepath=false",
|
|
1113
|
+
"--git-dir",
|
|
1114
|
+
this.shadow(),
|
|
1115
|
+
"--work-tree",
|
|
1116
|
+
this.root(),
|
|
1117
|
+
...args,
|
|
1118
|
+
];
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
private root(): string {
|
|
1122
|
+
if (!this.repoRoot) throw new Error("repo root not initialized");
|
|
1123
|
+
return this.repoRoot;
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
private source(): string {
|
|
1127
|
+
if (!this.sourceGitDir) throw new Error("source git dir not initialized");
|
|
1128
|
+
return this.sourceGitDir;
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
private shadow(): string {
|
|
1132
|
+
if (!this.gitdir) throw new Error("shadow git dir not initialized");
|
|
1133
|
+
return this.gitdir;
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
private git(args: string[], opts: { cwd?: string; env?: Record<string, string>; stdin?: string; allowFailure?: boolean } = {}) {
|
|
1137
|
+
return execFile("git", args, {
|
|
1138
|
+
cwd: opts.cwd ?? this.rootOrCwd(),
|
|
1139
|
+
env: opts.env,
|
|
1140
|
+
stdin: opts.stdin,
|
|
1141
|
+
timeoutMs: this.settings.gitTimeoutMs,
|
|
1142
|
+
allowFailure: opts.allowFailure,
|
|
1143
|
+
});
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
private rootOrCwd() {
|
|
1147
|
+
return this.repoRoot ?? this.cwd;
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
async function execFile(
|
|
1152
|
+
command: string,
|
|
1153
|
+
args: string[],
|
|
1154
|
+
opts: { cwd: string; env?: Record<string, string>; stdin?: string; timeoutMs: number; allowFailure?: boolean },
|
|
1155
|
+
): Promise<GitResult> {
|
|
1156
|
+
return new Promise((resolve, reject) => {
|
|
1157
|
+
const child = spawn(command, args, {
|
|
1158
|
+
cwd: opts.cwd,
|
|
1159
|
+
env: { ...process.env, ...opts.env },
|
|
1160
|
+
windowsHide: true,
|
|
1161
|
+
});
|
|
1162
|
+
const timer = setTimeout(() => {
|
|
1163
|
+
child.kill("SIGTERM");
|
|
1164
|
+
reject(new Error(`${command} ${args.join(" ")} timed out after ${opts.timeoutMs}ms`));
|
|
1165
|
+
}, opts.timeoutMs);
|
|
1166
|
+
const stdout: Buffer[] = [];
|
|
1167
|
+
const stderr: Buffer[] = [];
|
|
1168
|
+
child.stdout.on("data", (chunk) => stdout.push(Buffer.from(chunk)));
|
|
1169
|
+
child.stderr.on("data", (chunk) => stderr.push(Buffer.from(chunk)));
|
|
1170
|
+
child.on("error", (error) => {
|
|
1171
|
+
clearTimeout(timer);
|
|
1172
|
+
reject(error);
|
|
1173
|
+
});
|
|
1174
|
+
child.on("close", (code) => {
|
|
1175
|
+
clearTimeout(timer);
|
|
1176
|
+
const result = {
|
|
1177
|
+
code: code ?? 1,
|
|
1178
|
+
stdout: Buffer.concat(stdout).toString("utf8"),
|
|
1179
|
+
stderr: Buffer.concat(stderr).toString("utf8"),
|
|
1180
|
+
};
|
|
1181
|
+
if (!opts.allowFailure && result.code !== 0) reject(new Error(result.stderr || result.stdout || `${command} exited ${result.code}`));
|
|
1182
|
+
else resolve(result);
|
|
1183
|
+
});
|
|
1184
|
+
if (opts.stdin !== undefined) child.stdin.end(opts.stdin);
|
|
1185
|
+
else child.stdin.end();
|
|
1186
|
+
});
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
async function loadSettings(cwd: string): Promise<Settings> {
|
|
1190
|
+
const globalSettings = await readJson(path.join(getAgentDir(), "settings.json"));
|
|
1191
|
+
const projectSettings = await readJson(path.join(cwd, ".pi", "settings.json"));
|
|
1192
|
+
const raw = { ...(globalSettings.opencodeUndo ?? {}), ...(globalSettings.undoRedo ?? {}), ...(projectSettings.opencodeUndo ?? {}), ...(projectSettings.undoRedo ?? {}) } as Record<string, unknown>;
|
|
1193
|
+
return {
|
|
1194
|
+
enabled: raw.enabled !== false,
|
|
1195
|
+
storageDir: path.resolve(expandHome(typeof raw.storageDir === "string" ? raw.storageDir : path.join(getAgentDir(), "state", EXT))),
|
|
1196
|
+
largeFileLimitBytes: positiveInt(raw.largeFileLimitBytes, DEFAULT_LARGE_FILE_LIMIT),
|
|
1197
|
+
gitTimeoutMs: positiveInt(raw.gitTimeoutMs, DEFAULT_GIT_TIMEOUT),
|
|
1198
|
+
};
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
async function readJson(file: string): Promise<Record<string, any>> {
|
|
1202
|
+
try {
|
|
1203
|
+
return JSON.parse(await readFile(file, "utf8"));
|
|
1204
|
+
} catch {
|
|
1205
|
+
return {};
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
function getAgentDir() {
|
|
1210
|
+
return process.env.PI_CODING_AGENT_DIR || path.join(homedir(), ".pi", "agent");
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
function expandHome(value: string) {
|
|
1214
|
+
if (value === "~") return homedir();
|
|
1215
|
+
if (value.startsWith("~/") || value.startsWith("~\\")) return path.join(homedir(), value.slice(2));
|
|
1216
|
+
return value;
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
function positiveInt(value: unknown, fallback: number) {
|
|
1220
|
+
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : fallback;
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
function feed(values: string[]) {
|
|
1224
|
+
return values.join("\0") + "\0";
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
function splitNul(value: string) {
|
|
1228
|
+
return value.split("\0").filter(Boolean);
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
function emptySnapshot() {
|
|
1232
|
+
return "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
function normalizeRelPath(file: string): string | undefined {
|
|
1236
|
+
if (!file || file.includes("\0") || path.isAbsolute(file) || looksLikeWindowsAbsolute(file)) return;
|
|
1237
|
+
const normalized = file.replaceAll("\\", "/").replace(/^\.\//, "");
|
|
1238
|
+
if (!normalized || normalized === "." || normalized.startsWith("../") || normalized.includes("/../")) return;
|
|
1239
|
+
return normalized;
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
function looksLikeWindowsAbsolute(file: string) {
|
|
1243
|
+
return /^[A-Za-z]:[\\/]/.test(file) || file.startsWith("\\\\") || file.startsWith("//");
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
function resolveSafeWorkspacePath(rawPath: string, cwd: string, root: string): string {
|
|
1247
|
+
if (!rawPath || rawPath.includes("\0")) throw new Error("path is empty or contains NUL");
|
|
1248
|
+
if (isDangerousRootPath(rawPath)) throw new Error(`dangerous path is not restorable: ${rawPath}`);
|
|
1249
|
+
|
|
1250
|
+
const rootAbs = path.resolve(root);
|
|
1251
|
+
const cwdAbs = path.resolve(cwd);
|
|
1252
|
+
if (!isInsideOrEqual(cwdAbs, rootAbs)) throw new Error(`cwd is outside workspace root: ${cwd}`);
|
|
1253
|
+
|
|
1254
|
+
const inputPath = rawPath.replaceAll("\\", path.sep);
|
|
1255
|
+
const target = path.isAbsolute(inputPath) || looksLikeWindowsAbsolute(rawPath) ? path.resolve(inputPath) : path.resolve(cwdAbs, inputPath);
|
|
1256
|
+
if (!isInsideOrEqual(target, rootAbs)) throw new Error(`path escapes workspace root: ${rawPath}`);
|
|
1257
|
+
|
|
1258
|
+
const rel = path.relative(rootAbs, target).replaceAll("\\", "/");
|
|
1259
|
+
const normalized = normalizeRelPath(rel);
|
|
1260
|
+
if (!normalized) throw new Error(`unsafe path: ${rawPath}`);
|
|
1261
|
+
if (isDangerousWorkspaceRel(normalized)) throw new Error(`dangerous path is not restorable: ${normalized}`);
|
|
1262
|
+
return normalized;
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
function isInsideOrEqual(candidate: string, root: string) {
|
|
1266
|
+
const rel = path.relative(root, candidate);
|
|
1267
|
+
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
function isDangerousRootPath(rawPath: string) {
|
|
1271
|
+
const normalized = rawPath.replaceAll("\\", "/").replace(/\/+$/, "");
|
|
1272
|
+
return normalized === "/" || /^[A-Za-z]:$/.test(normalized) || /^[A-Za-z]:\/$/.test(normalized);
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
function isDangerousWorkspaceRel(rel: string) {
|
|
1276
|
+
const first = rel.split("/", 1)[0]?.toLowerCase();
|
|
1277
|
+
return first === ".git" || first === ".pi" || first === "node_modules";
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
function intersectRiskyPaths(dirty: string[], affected: Set<string>) {
|
|
1281
|
+
return dirty.filter((file) => {
|
|
1282
|
+
const normalized = normalizeRelPath(file);
|
|
1283
|
+
if (!normalized) return false;
|
|
1284
|
+
for (const item of affected) {
|
|
1285
|
+
if (pathsClash(normalized, item)) return true;
|
|
1286
|
+
}
|
|
1287
|
+
return false;
|
|
1288
|
+
});
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
function pathsClash(a: string, b: string) {
|
|
1292
|
+
return a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`);
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
function formatDirtyGuardMessage(action: string, files: string[]) {
|
|
1296
|
+
const shown = files.slice(0, 12).map((file) => ` - ${file}`).join("\n");
|
|
1297
|
+
const more = files.length > 12 ? `\n ... and ${files.length - 12} more` : "";
|
|
1298
|
+
return `Dirty guard blocked ${action}: ${files.length} unsnapshotted file(s) would be overwritten.\n\n${shown}${more}\n\nCommit/stash/revert those manual changes before retrying.`;
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
function unique<T>(values: T[]) {
|
|
1302
|
+
return [...new Set(values)];
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
function isString(value: string | undefined): value is string {
|
|
1306
|
+
return typeof value === "string";
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
function findLatestUserByText(branch: SessionEntry[], text: string) {
|
|
1310
|
+
return [...branch].reverse().find((entry) => isUserMessageEntry(entry) && userText(entry.message!) === text);
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
type UserMessageEntry = SessionEntry & { type: "message"; message: { role: "user"; content: unknown } };
|
|
1314
|
+
|
|
1315
|
+
function isUserMessageEntry(entry: SessionEntry): entry is UserMessageEntry {
|
|
1316
|
+
return entry.type === "message" && typeof entry.message === "object" && entry.message !== null && "role" in entry.message && entry.message.role === "user";
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
function userText(message: { content?: unknown }) {
|
|
1320
|
+
const content = message.content;
|
|
1321
|
+
if (typeof content === "string") return content;
|
|
1322
|
+
if (!Array.isArray(content)) return "";
|
|
1323
|
+
return content
|
|
1324
|
+
.filter((block): block is { type: "text"; text: string } =>
|
|
1325
|
+
Boolean(block && typeof block === "object" && (block as { type?: unknown }).type === "text"),
|
|
1326
|
+
)
|
|
1327
|
+
.map((block) => block.text)
|
|
1328
|
+
.join("");
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
type ReadonlySessionLookup = {
|
|
1332
|
+
getEntry(id: string): SessionEntry | undefined;
|
|
1333
|
+
getBranch(fromId?: string): SessionEntry[];
|
|
1334
|
+
};
|
|
1335
|
+
|
|
1336
|
+
function repairCheckpoint(value: Partial<Checkpoint> | undefined, sessions: ReadonlySessionLookup): Checkpoint | undefined {
|
|
1337
|
+
if (!isCheckpoint(value)) return;
|
|
1338
|
+
const original = sessions.getEntry(value.userEntryId);
|
|
1339
|
+
if (original && isUserMessageEntry(original) && userText(original.message!) === value.prompt) {
|
|
1340
|
+
return value;
|
|
1341
|
+
}
|
|
1342
|
+
const repairedUser = findLatestUserByText(sessions.getBranch(value.finalLeafId), value.prompt);
|
|
1343
|
+
if (!repairedUser) return;
|
|
1344
|
+
return { ...value, userEntryId: repairedUser.id, beforeLeafId: repairedUser.parentId };
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
function isCheckpoint(value: Partial<Checkpoint> | undefined): value is Checkpoint {
|
|
1348
|
+
return Boolean(
|
|
1349
|
+
value &&
|
|
1350
|
+
typeof value.userEntryId === "string" &&
|
|
1351
|
+
typeof value.finalLeafId === "string" &&
|
|
1352
|
+
typeof value.beforeSnapshot === "string" &&
|
|
1353
|
+
typeof value.afterSnapshot === "string" &&
|
|
1354
|
+
Array.isArray(value.files),
|
|
1355
|
+
);
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
function parseRedoStack(value: unknown): RedoItem[] {
|
|
1359
|
+
if (!Array.isArray(value)) return [];
|
|
1360
|
+
return value.flatMap((item): RedoItem[] => {
|
|
1361
|
+
if (!item || typeof item !== "object") return [];
|
|
1362
|
+
const raw = item as { checkpoint?: Partial<Checkpoint>; leafId?: unknown; prompt?: unknown; snapshot?: unknown; snapshotFiles?: unknown };
|
|
1363
|
+
const redo: RedoItem = {
|
|
1364
|
+
leafId: typeof raw.leafId === "string" ? raw.leafId : null,
|
|
1365
|
+
prompt: typeof raw.prompt === "string" ? raw.prompt : undefined,
|
|
1366
|
+
snapshot: typeof raw.snapshot === "string" ? raw.snapshot : undefined,
|
|
1367
|
+
snapshotFiles: Array.isArray(raw.snapshotFiles) ? raw.snapshotFiles.map((file) => normalizeRelPath(String(file))).filter(isString) : undefined,
|
|
1368
|
+
};
|
|
1369
|
+
if (isCheckpoint(raw.checkpoint)) redo.checkpoint = raw.checkpoint;
|
|
1370
|
+
return redo.leafId || redo.checkpoint ? [redo] : [];
|
|
1371
|
+
});
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
function message(error: unknown) {
|
|
1375
|
+
return error instanceof Error ? error.message : String(error);
|
|
1376
|
+
}
|