pi-rewind-hook 1.7.2 → 1.8.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/index.test.ts ADDED
@@ -0,0 +1,602 @@
1
+ import assert from "node:assert/strict";
2
+ import { execFile } from "node:child_process";
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
5
+ import test from "node:test";
6
+ import os from "node:os";
7
+ import path from "node:path";
8
+ import { promisify } from "node:util";
9
+
10
+ import rewindExtension from "./index.ts";
11
+
12
+ const execFileAsync = promisify(execFile);
13
+ const STORE_REF = "refs/pi-rewind/store";
14
+
15
+ type RewindEntry = Record<string, unknown>;
16
+ type EventHandler = (event: any, ctx: any) => Promise<any> | any;
17
+
18
+ class SessionManagerStub {
19
+ private readonly header: { type: "session"; version: number; id: string; timestamp: string; cwd: string; parentSession?: string };
20
+ private entries: RewindEntry[];
21
+ private readonly sessionFile: string;
22
+
23
+ constructor(options: {
24
+ sessionFile: string;
25
+ id: string;
26
+ cwd: string;
27
+ parentSession?: string;
28
+ entries?: RewindEntry[];
29
+ }) {
30
+ this.sessionFile = options.sessionFile;
31
+ this.header = {
32
+ type: "session",
33
+ version: 3,
34
+ id: options.id,
35
+ timestamp: new Date().toISOString(),
36
+ cwd: options.cwd,
37
+ parentSession: options.parentSession,
38
+ };
39
+ this.entries = options.entries ?? [];
40
+ this.flush();
41
+ }
42
+
43
+ flush(): void {
44
+ mkdirSync(path.dirname(this.sessionFile), { recursive: true });
45
+ const lines = [this.header, ...this.entries].map((entry) => JSON.stringify(entry)).join("\n") + "\n";
46
+ writeFileSync(this.sessionFile, lines);
47
+ }
48
+
49
+ replaceEntries(entries: RewindEntry[]): void {
50
+ this.entries = entries;
51
+ this.flush();
52
+ }
53
+
54
+ appendCustom(customType: string, data: unknown): void {
55
+ const parentId = (this.entries.at(-1)?.id as string | undefined) ?? null;
56
+ this.entries.push({
57
+ type: "custom",
58
+ customType,
59
+ data,
60
+ id: `${customType}-${this.entries.length + 1}`,
61
+ parentId,
62
+ timestamp: new Date().toISOString(),
63
+ });
64
+ this.flush();
65
+ }
66
+
67
+ getSessionId(): string {
68
+ return this.header.id;
69
+ }
70
+
71
+ getSessionFile(): string {
72
+ return this.sessionFile;
73
+ }
74
+
75
+ getHeader(): { parentSession?: string } {
76
+ return { parentSession: this.header.parentSession };
77
+ }
78
+
79
+ getCwd(): string {
80
+ return this.header.cwd;
81
+ }
82
+
83
+ getEntries(): RewindEntry[] {
84
+ return this.entries;
85
+ }
86
+
87
+ getBranch(): RewindEntry[] {
88
+ return this.entries;
89
+ }
90
+
91
+ getEntry(entryId: string): RewindEntry | undefined {
92
+ return this.entries.find((entry) => entry.id === entryId);
93
+ }
94
+ }
95
+
96
+ async function runGit(repoRoot: string, args: string[]): Promise<{ stdout: string; stderr: string; code: number }> {
97
+ try {
98
+ const { stdout, stderr } = await execFileAsync("git", args, { cwd: repoRoot });
99
+ return { stdout, stderr, code: 0 };
100
+ } catch (error: any) {
101
+ return {
102
+ stdout: error.stdout ?? "",
103
+ stderr: error.stderr ?? error.message ?? "",
104
+ code: error.code ?? 1,
105
+ };
106
+ }
107
+ }
108
+
109
+ async function runGitChecked(repoRoot: string, args: string[]): Promise<{ stdout: string; stderr: string; code: number }> {
110
+ const result = await runGit(repoRoot, args);
111
+ if (result.code !== 0) {
112
+ throw new Error(`git ${args.join(" ")} failed: ${result.stderr || `exit ${result.code}`}`);
113
+ }
114
+ return result;
115
+ }
116
+
117
+ async function gitStdout(repoRoot: string, args: string[]): Promise<string> {
118
+ return (await runGitChecked(repoRoot, args)).stdout.trim();
119
+ }
120
+
121
+ async function revParseOptional(repoRoot: string, ref: string): Promise<string | undefined> {
122
+ try {
123
+ return await gitStdout(repoRoot, ["rev-parse", ref]);
124
+ } catch {
125
+ return undefined;
126
+ }
127
+ }
128
+
129
+ async function isAncestor(repoRoot: string, ancestor: string, descendant: string): Promise<boolean> {
130
+ try {
131
+ await runGitChecked(repoRoot, ["merge-base", "--is-ancestor", ancestor, descendant]);
132
+ return true;
133
+ } catch {
134
+ return false;
135
+ }
136
+ }
137
+
138
+ async function captureSnapshot(repoRoot: string): Promise<string> {
139
+ await runGitChecked(repoRoot, ["add", "-A"]);
140
+ const treeSha = await gitStdout(repoRoot, ["write-tree"]);
141
+ return await gitStdout(repoRoot, ["commit-tree", treeSha, "-m", "rewind snapshot test"]);
142
+ }
143
+
144
+ async function createHarness(options: {
145
+ settings?: Record<string, unknown>;
146
+ failGitSubcommands?: string[];
147
+ } = {}) {
148
+ const root = await mkdtemp(path.join(os.tmpdir(), "rewind-ext-test-"));
149
+ const repoRoot = path.join(root, "repo");
150
+ const agentDir = path.join(root, "agent");
151
+ const sessionsDir = path.join(agentDir, "sessions", "--repo--");
152
+ mkdirSync(repoRoot, { recursive: true });
153
+ mkdirSync(sessionsDir, { recursive: true });
154
+ writeFileSync(path.join(agentDir, "settings.json"), JSON.stringify(options.settings ?? {}, null, 2) + "\n");
155
+
156
+ const originalAgentDir = process.env.PI_CODING_AGENT_DIR;
157
+ process.env.PI_CODING_AGENT_DIR = agentDir;
158
+
159
+ await runGitChecked(repoRoot, ["init"]);
160
+ await runGitChecked(repoRoot, ["config", "user.name", "Rewind Test"]);
161
+ await runGitChecked(repoRoot, ["config", "user.email", "rewind@example.com"]);
162
+
163
+ const handlers = new Map<string, EventHandler>();
164
+ const eventHandlers = new Map<string, (data: any) => void>();
165
+ const execCalls: string[][] = [];
166
+ const notifications: Array<{ message: string; level: string }> = [];
167
+ const statusUpdates: Array<{ key: string; value: string | undefined }> = [];
168
+ const selectCalls: Array<{ title: string; options: string[] }> = [];
169
+ const pendingSelections: string[] = [];
170
+
171
+ const currentSession = new SessionManagerStub({
172
+ sessionFile: path.join(sessionsDir, "session-1.jsonl"),
173
+ id: "session-1",
174
+ cwd: repoRoot,
175
+ });
176
+ let activeSession = currentSession;
177
+
178
+ const api = {
179
+ exec: async (cmd: string, args: string[]) => {
180
+ execCalls.push([cmd, ...args]);
181
+ if (cmd !== "git") {
182
+ throw new Error(`Unsupported command in test harness: ${cmd}`);
183
+ }
184
+
185
+ const gitSubcommand = args[0] ?? "";
186
+ if (options.failGitSubcommands?.includes(gitSubcommand)) {
187
+ return {
188
+ stdout: "",
189
+ stderr: `forced git failure for ${gitSubcommand}`,
190
+ code: 1,
191
+ };
192
+ }
193
+
194
+ return runGit(repoRoot, args);
195
+ },
196
+ appendEntry: (customType: string, data: unknown) => {
197
+ activeSession.appendCustom(customType, data);
198
+ },
199
+ on: (eventName: string, handler: EventHandler) => {
200
+ handlers.set(eventName, handler);
201
+ },
202
+ events: {
203
+ on: (eventName: string, handler: (data: any) => void) => {
204
+ eventHandlers.set(eventName, handler);
205
+ },
206
+ },
207
+ } as any;
208
+
209
+ rewindExtension(api);
210
+
211
+ function createContext(sessionManager: SessionManagerStub, hasUI = true): any {
212
+ return {
213
+ cwd: repoRoot,
214
+ hasUI,
215
+ sessionManager,
216
+ ui: {
217
+ notify: (message: string, level: string) => {
218
+ notifications.push({ message, level });
219
+ },
220
+ setStatus: (key: string, value: string | undefined) => {
221
+ statusUpdates.push({ key, value });
222
+ },
223
+ select: async (title: string, choices: string[]) => {
224
+ selectCalls.push({ title, options: choices });
225
+ return pendingSelections.shift();
226
+ },
227
+ theme: {
228
+ fg: (_color: string, text: string) => text,
229
+ },
230
+ },
231
+ };
232
+ }
233
+
234
+ return {
235
+ repoRoot,
236
+ agentDir,
237
+ currentSession,
238
+ execCalls,
239
+ notifications,
240
+ selectCalls,
241
+ statusUpdates,
242
+ enqueueSelection(choice: string) {
243
+ pendingSelections.push(choice);
244
+ },
245
+ async writeRepoFile(relativePath: string, content: string) {
246
+ const filePath = path.join(repoRoot, relativePath);
247
+ mkdirSync(path.dirname(filePath), { recursive: true });
248
+ await writeFile(filePath, content);
249
+ },
250
+ readRepoFile(relativePath: string) {
251
+ return readFileSync(path.join(repoRoot, relativePath), "utf-8");
252
+ },
253
+ createSession(options: { id: string; parentSession?: string; entries?: RewindEntry[] }) {
254
+ return new SessionManagerStub({
255
+ sessionFile: path.join(sessionsDir, `${options.id}.jsonl`),
256
+ id: options.id,
257
+ cwd: repoRoot,
258
+ parentSession: options.parentSession,
259
+ entries: options.entries,
260
+ });
261
+ },
262
+ async invoke(eventName: string, event: any, sessionManager = activeSession, hasUI = true) {
263
+ const handler = handlers.get(eventName);
264
+ assert.ok(handler, `missing handler for ${eventName}`);
265
+ activeSession = sessionManager;
266
+ return handler(event, createContext(sessionManager, hasUI));
267
+ },
268
+ async captureSnapshot() {
269
+ return captureSnapshot(repoRoot);
270
+ },
271
+ async revParseStore() {
272
+ return revParseOptional(repoRoot, STORE_REF);
273
+ },
274
+ async updateStoreRef(commitSha: string) {
275
+ await runGitChecked(repoRoot, ["update-ref", STORE_REF, commitSha]);
276
+ },
277
+ async isAncestor(ancestor: string, descendant: string) {
278
+ return isAncestor(repoRoot, ancestor, descendant);
279
+ },
280
+ eventHandlers,
281
+ async cleanup() {
282
+ if (originalAgentDir === undefined) {
283
+ delete process.env.PI_CODING_AGENT_DIR;
284
+ } else {
285
+ process.env.PI_CODING_AGENT_DIR = originalAgentDir;
286
+ }
287
+ await rm(root, { recursive: true, force: true });
288
+ },
289
+ };
290
+ }
291
+
292
+ test("/fork undo restores files into a child session instead of cancelling the fork", async () => {
293
+ const harness = await createHarness({ settings: { rewind: { silentCheckpoints: true } } });
294
+
295
+ try {
296
+ await harness.writeRepoFile("notes.txt", "current state\n");
297
+ const currentCommit = await harness.captureSnapshot();
298
+ await harness.writeRepoFile("notes.txt", "undo target\n");
299
+ const undoCommit = await harness.captureSnapshot();
300
+ await harness.writeRepoFile("notes.txt", "current state\n");
301
+
302
+ harness.currentSession.replaceEntries([
303
+ {
304
+ type: "message",
305
+ id: "user-1",
306
+ parentId: null,
307
+ timestamp: new Date().toISOString(),
308
+ message: { role: "user", content: [{ type: "text", text: "Fork from here" }] },
309
+ },
310
+ {
311
+ type: "custom",
312
+ id: "rewind-op-1",
313
+ parentId: "user-1",
314
+ timestamp: new Date().toISOString(),
315
+ customType: "rewind-op",
316
+ data: { v: 2, snapshots: [currentCommit, undoCommit], current: 0, undo: 1 },
317
+ },
318
+ ]);
319
+
320
+ await harness.invoke("session_start", {});
321
+ harness.enqueueSelection("Undo last file rewind");
322
+
323
+ const result = await harness.invoke("session_before_fork", { entryId: "user-1" });
324
+ assert.equal(result, undefined);
325
+ assert.equal(harness.readRepoFile("notes.txt"), "undo target\n");
326
+
327
+ const currentSessionRewindOps = harness.currentSession.getEntries().filter((entry) => entry.type === "custom" && entry.customType === "rewind-op");
328
+ assert.equal(currentSessionRewindOps.length, 1);
329
+
330
+ const childSession = harness.createSession({
331
+ id: "session-2",
332
+ parentSession: harness.currentSession.getSessionFile(),
333
+ });
334
+ await harness.invoke("session_fork", {}, childSession);
335
+
336
+ const childRewindOps = childSession.getEntries().filter((entry) => entry.type === "custom" && entry.customType === "rewind-op");
337
+ assert.equal(childRewindOps.length, 1);
338
+ assert.deepEqual(childRewindOps[0]?.data, {
339
+ v: 2,
340
+ snapshots: [undoCommit, currentCommit],
341
+ current: 0,
342
+ undo: 1,
343
+ });
344
+ } finally {
345
+ await harness.cleanup();
346
+ }
347
+ });
348
+
349
+ test("session_before_fork gracefully cancels when restore fails", async () => {
350
+ const harness = await createHarness({
351
+ settings: { rewind: { silentCheckpoints: true } },
352
+ failGitSubcommands: ["restore"],
353
+ });
354
+
355
+ try {
356
+ await harness.writeRepoFile("notes.txt", "target state\n");
357
+ const targetCommit = await harness.captureSnapshot();
358
+ await harness.writeRepoFile("notes.txt", "current state\n");
359
+
360
+ harness.currentSession.replaceEntries([
361
+ {
362
+ type: "message",
363
+ id: "user-1",
364
+ parentId: null,
365
+ timestamp: new Date().toISOString(),
366
+ message: { role: "user", content: [{ type: "text", text: "Restore from here" }] },
367
+ },
368
+ {
369
+ type: "custom",
370
+ id: "rewind-turn-1",
371
+ parentId: "user-1",
372
+ timestamp: new Date().toISOString(),
373
+ customType: "rewind-turn",
374
+ data: { v: 2, snapshots: [targetCommit], bindings: [["user-1", 0]] },
375
+ },
376
+ ]);
377
+
378
+ await harness.invoke("session_start", {});
379
+ harness.enqueueSelection("Restore all (files + conversation)");
380
+
381
+ const result = await harness.invoke("session_before_fork", { entryId: "user-1" });
382
+ assert.deepEqual(result, { cancel: true });
383
+ assert.equal(
384
+ harness.notifications.some((entry) => entry.level === "error" && entry.message.includes("Rewind failed before fork")),
385
+ true,
386
+ );
387
+ } finally {
388
+ await harness.cleanup();
389
+ }
390
+ });
391
+
392
+ test("session_before_tree gracefully cancels when restore fails", async () => {
393
+ const harness = await createHarness({
394
+ settings: { rewind: { silentCheckpoints: true } },
395
+ failGitSubcommands: ["restore"],
396
+ });
397
+
398
+ try {
399
+ await harness.writeRepoFile("notes.txt", "target state\n");
400
+ const targetCommit = await harness.captureSnapshot();
401
+ await harness.writeRepoFile("notes.txt", "current state\n");
402
+
403
+ harness.currentSession.replaceEntries([
404
+ {
405
+ type: "message",
406
+ id: "user-1",
407
+ parentId: null,
408
+ timestamp: new Date().toISOString(),
409
+ message: { role: "user", content: [{ type: "text", text: "Tree target" }] },
410
+ },
411
+ {
412
+ type: "custom",
413
+ id: "rewind-turn-1",
414
+ parentId: "user-1",
415
+ timestamp: new Date().toISOString(),
416
+ customType: "rewind-turn",
417
+ data: { v: 2, snapshots: [targetCommit], bindings: [["user-1", 0]] },
418
+ },
419
+ ]);
420
+
421
+ await harness.invoke("session_start", {});
422
+ harness.enqueueSelection("Restore files to that point");
423
+
424
+ const result = await harness.invoke("session_before_tree", { preparation: { targetId: "user-1" } });
425
+ assert.deepEqual(result, { cancel: true });
426
+ assert.equal(
427
+ harness.notifications.some((entry) => entry.level === "error" && entry.message.includes("Rewind failed before tree navigation")),
428
+ true,
429
+ );
430
+ } finally {
431
+ await harness.cleanup();
432
+ }
433
+ });
434
+
435
+ test("first mutating turn creates a reachable store ref even when retention is omitted", async () => {
436
+ const harness = await createHarness({
437
+ settings: { rewind: { silentCheckpoints: true } },
438
+ });
439
+
440
+ try {
441
+ const assistantTimestamp = Date.now();
442
+ harness.currentSession.replaceEntries([
443
+ {
444
+ type: "message",
445
+ id: "user-1",
446
+ parentId: null,
447
+ timestamp: new Date(assistantTimestamp - 1000).toISOString(),
448
+ message: { role: "user", content: [{ type: "text", text: "Please create the file" }] },
449
+ },
450
+ {
451
+ type: "message",
452
+ id: "assistant-1",
453
+ parentId: "user-1",
454
+ timestamp: new Date(assistantTimestamp).toISOString(),
455
+ message: {
456
+ role: "assistant",
457
+ timestamp: assistantTimestamp,
458
+ content: [{ type: "text", text: "Created the file" }],
459
+ },
460
+ },
461
+ ]);
462
+
463
+ await harness.invoke("session_start", {});
464
+ await harness.invoke("before_agent_start", { prompt: "Please create the file" });
465
+ await harness.invoke("turn_start", { turnIndex: 0 });
466
+ await harness.writeRepoFile("tests/rewind-smoke/a.txt", "smoke test\n");
467
+ await harness.invoke("turn_end", {
468
+ message: {
469
+ role: "assistant",
470
+ timestamp: assistantTimestamp,
471
+ content: [{ type: "text", text: "Created the file" }],
472
+ },
473
+ });
474
+ await harness.invoke("agent_end", {});
475
+
476
+ const rewindTurnEntries = harness.currentSession.getEntries().filter((entry) => entry.type === "custom" && entry.customType === "rewind-turn");
477
+ assert.equal(rewindTurnEntries.length, 1);
478
+ const snapshots = (rewindTurnEntries[0]?.data as { snapshots: string[] }).snapshots;
479
+ assert.equal(snapshots.length, 2);
480
+
481
+ const storeHead = await harness.revParseStore();
482
+ assert.ok(storeHead);
483
+ assert.equal(await harness.isAncestor(snapshots[0], storeHead), true);
484
+ assert.equal(await harness.isAncestor(snapshots[1], storeHead), true);
485
+
486
+ } finally {
487
+ await harness.cleanup();
488
+ }
489
+ });
490
+
491
+ test("startup does not touch the keepalive ref when rewind.retention is omitted", async () => {
492
+ const harness = await createHarness({ settings: { rewind: { silentCheckpoints: true } } });
493
+
494
+ try {
495
+ await harness.writeRepoFile("tracked.txt", "keepalive\n");
496
+ const snapshotCommit = await harness.captureSnapshot();
497
+ await harness.updateStoreRef(snapshotCommit);
498
+
499
+ await harness.invoke("session_start", {});
500
+
501
+ assert.equal(await harness.revParseStore(), snapshotCommit);
502
+ assert.equal(harness.execCalls.some((call) => call[0] === "git" && call[1] === "gc"), false);
503
+ assert.equal(harness.execCalls.some((call) => call[0] === "git" && call[1] === "update-ref" && call.includes(STORE_REF)), false);
504
+ } finally {
505
+ await harness.cleanup();
506
+ }
507
+ });
508
+
509
+ test("retention preserves the keepalive ref when discovery yields an empty live set", async () => {
510
+ const harness = await createHarness({ settings: { rewind: { retention: { maxSnapshots: 10 } } } });
511
+
512
+ try {
513
+ await harness.writeRepoFile("tracked.txt", "keepalive\n");
514
+ const snapshotCommit = await harness.captureSnapshot();
515
+ await harness.updateStoreRef(snapshotCommit);
516
+
517
+ await harness.invoke("session_start", {});
518
+
519
+ assert.equal(await harness.revParseStore(), snapshotCommit);
520
+ assert.equal(harness.execCalls.some((call) => call[0] === "git" && call[1] === "gc"), false);
521
+ } finally {
522
+ await harness.cleanup();
523
+ }
524
+ });
525
+
526
+ test("ancestor-only retention discovery ignores unrelated session trees", async () => {
527
+ const harness = await createHarness({
528
+ settings: { rewind: { retention: { maxSnapshots: 10, scanMode: "ancestor-only" } } },
529
+ });
530
+
531
+ try {
532
+ await harness.writeRepoFile("tracked.txt", "stale state\n");
533
+ const staleCommit = await harness.captureSnapshot();
534
+ await harness.writeRepoFile("tracked.txt", "unrelated live state\n");
535
+ const unrelatedLiveCommit = await harness.captureSnapshot();
536
+ await harness.updateStoreRef(staleCommit);
537
+
538
+ const unrelatedSession = harness.createSession({
539
+ id: "session-unrelated",
540
+ entries: [
541
+ {
542
+ type: "custom",
543
+ id: "rewind-op-1",
544
+ parentId: null,
545
+ timestamp: new Date().toISOString(),
546
+ customType: "rewind-op",
547
+ data: { v: 2, snapshots: [unrelatedLiveCommit], current: 0 },
548
+ },
549
+ ],
550
+ });
551
+ unrelatedSession.flush();
552
+
553
+ await harness.invoke("session_start", {});
554
+ await new Promise((resolve) => setTimeout(resolve, 250));
555
+
556
+ assert.equal(await harness.revParseStore(), staleCommit);
557
+ } finally {
558
+ await harness.cleanup();
559
+ }
560
+ });
561
+
562
+ test("retention rewrites the keepalive ref when a live snapshot exists", async () => {
563
+ const harness = await createHarness({
564
+ settings: { rewind: { retention: { maxSnapshots: 10 } } },
565
+ });
566
+
567
+ try {
568
+ await harness.writeRepoFile("tracked.txt", "stale state\n");
569
+ const staleCommit = await harness.captureSnapshot();
570
+ await harness.writeRepoFile("tracked.txt", "current live state\n");
571
+ const liveCommit = await harness.captureSnapshot();
572
+ assert.notEqual(liveCommit, staleCommit);
573
+ await harness.updateStoreRef(staleCommit);
574
+
575
+ harness.currentSession.replaceEntries([
576
+ {
577
+ type: "custom",
578
+ id: "rewind-op-1",
579
+ parentId: null,
580
+ timestamp: new Date().toISOString(),
581
+ customType: "rewind-op",
582
+ data: { v: 2, snapshots: [liveCommit], current: 0 },
583
+ },
584
+ ]);
585
+
586
+ await harness.invoke("session_start", {});
587
+
588
+ // Retention sweep runs in the background on startup; poll for completion
589
+ const deadline = Date.now() + 3000;
590
+ let storeHead: string | undefined;
591
+ while (Date.now() < deadline) {
592
+ storeHead = await harness.revParseStore();
593
+ if (storeHead && await harness.isAncestor(liveCommit, storeHead)) break;
594
+ await new Promise(r => setTimeout(r, 50));
595
+ }
596
+ assert.ok(storeHead);
597
+ assert.equal(await harness.isAncestor(liveCommit, storeHead!), true);
598
+ } finally {
599
+ await harness.cleanup();
600
+ }
601
+ });
602
+