/ extensions / rewind / index.test.ts
index.test.ts
  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  } = {}) {
147    const root = await mkdtemp(path.join(os.tmpdir(), "rewind-ext-test-"));
148    const repoRoot = path.join(root, "repo");
149    const agentDir = path.join(root, "agent");
150    const sessionsDir = path.join(agentDir, "sessions", "--repo--");
151    mkdirSync(repoRoot, { recursive: true });
152    mkdirSync(sessionsDir, { recursive: true });
153    writeFileSync(path.join(agentDir, "settings.json"), JSON.stringify(options.settings ?? {}, null, 2) + "\n");
154  
155    const originalAgentDir = process.env.PI_CODING_AGENT_DIR;
156    process.env.PI_CODING_AGENT_DIR = agentDir;
157  
158    await runGitChecked(repoRoot, ["init"]);
159    await runGitChecked(repoRoot, ["config", "user.name", "Rewind Test"]);
160    await runGitChecked(repoRoot, ["config", "user.email", "rewind@example.com"]);
161  
162    const handlers = new Map<string, EventHandler>();
163    const eventHandlers = new Map<string, (data: any) => void>();
164    const execCalls: string[][] = [];
165    const notifications: Array<{ message: string; level: string }> = [];
166    const statusUpdates: Array<{ key: string; value: string | undefined }> = [];
167    const selectCalls: Array<{ title: string; options: string[] }> = [];
168    const pendingSelections: string[] = [];
169  
170    const currentSession = new SessionManagerStub({
171      sessionFile: path.join(sessionsDir, "session-1.jsonl"),
172      id: "session-1",
173      cwd: repoRoot,
174    });
175    let activeSession = currentSession;
176  
177    const api = {
178      exec: async (cmd: string, args: string[]) => {
179        execCalls.push([cmd, ...args]);
180        if (cmd !== "git") {
181          throw new Error(`Unsupported command in test harness: ${cmd}`);
182        }
183        return runGit(repoRoot, args);
184      },
185      appendEntry: (customType: string, data: unknown) => {
186        activeSession.appendCustom(customType, data);
187      },
188      on: (eventName: string, handler: EventHandler) => {
189        handlers.set(eventName, handler);
190      },
191      events: {
192        on: (eventName: string, handler: (data: any) => void) => {
193          eventHandlers.set(eventName, handler);
194        },
195      },
196    } as any;
197  
198    rewindExtension(api);
199  
200    function createContext(sessionManager: SessionManagerStub, hasUI = true): any {
201      return {
202        cwd: repoRoot,
203        hasUI,
204        sessionManager,
205        ui: {
206          notify: (message: string, level: string) => {
207            notifications.push({ message, level });
208          },
209          setStatus: (key: string, value: string | undefined) => {
210            statusUpdates.push({ key, value });
211          },
212          select: async (title: string, choices: string[]) => {
213            selectCalls.push({ title, options: choices });
214            return pendingSelections.shift();
215          },
216          theme: {
217            fg: (_color: string, text: string) => text,
218          },
219        },
220      };
221    }
222  
223    return {
224      repoRoot,
225      agentDir,
226      currentSession,
227      execCalls,
228      notifications,
229      selectCalls,
230      statusUpdates,
231      enqueueSelection(choice: string) {
232        pendingSelections.push(choice);
233      },
234      async writeRepoFile(relativePath: string, content: string) {
235        const filePath = path.join(repoRoot, relativePath);
236        mkdirSync(path.dirname(filePath), { recursive: true });
237        await writeFile(filePath, content);
238      },
239      readRepoFile(relativePath: string) {
240        return readFileSync(path.join(repoRoot, relativePath), "utf-8");
241      },
242      createSession(options: { id: string; parentSession?: string; entries?: RewindEntry[] }) {
243        return new SessionManagerStub({
244          sessionFile: path.join(sessionsDir, `${options.id}.jsonl`),
245          id: options.id,
246          cwd: repoRoot,
247          parentSession: options.parentSession,
248          entries: options.entries,
249        });
250      },
251      async invoke(eventName: string, event: any, sessionManager = activeSession, hasUI = true) {
252        const handler = handlers.get(eventName);
253        assert.ok(handler, `missing handler for ${eventName}`);
254        activeSession = sessionManager;
255        return handler(event, createContext(sessionManager, hasUI));
256      },
257      async captureSnapshot() {
258        return captureSnapshot(repoRoot);
259      },
260      async revParseStore() {
261        return revParseOptional(repoRoot, STORE_REF);
262      },
263      async updateStoreRef(commitSha: string) {
264        await runGitChecked(repoRoot, ["update-ref", STORE_REF, commitSha]);
265      },
266      async isAncestor(ancestor: string, descendant: string) {
267        return isAncestor(repoRoot, ancestor, descendant);
268      },
269      eventHandlers,
270      async cleanup() {
271        if (originalAgentDir === undefined) {
272          delete process.env.PI_CODING_AGENT_DIR;
273        } else {
274          process.env.PI_CODING_AGENT_DIR = originalAgentDir;
275        }
276        await rm(root, { recursive: true, force: true });
277      },
278    };
279  }
280  
281  test("/fork undo restores files into a child session instead of cancelling the fork", async () => {
282    const harness = await createHarness({ settings: { rewind: { silentCheckpoints: true } } });
283  
284    try {
285      await harness.writeRepoFile("notes.txt", "current state\n");
286      const currentCommit = await harness.captureSnapshot();
287      await harness.writeRepoFile("notes.txt", "undo target\n");
288      const undoCommit = await harness.captureSnapshot();
289      await harness.writeRepoFile("notes.txt", "current state\n");
290  
291      harness.currentSession.replaceEntries([
292        {
293          type: "message",
294          id: "user-1",
295          parentId: null,
296          timestamp: new Date().toISOString(),
297          message: { role: "user", content: [{ type: "text", text: "Fork from here" }] },
298        },
299        {
300          type: "custom",
301          id: "rewind-op-1",
302          parentId: "user-1",
303          timestamp: new Date().toISOString(),
304          customType: "rewind-op",
305          data: { v: 2, snapshots: [currentCommit, undoCommit], current: 0, undo: 1 },
306        },
307      ]);
308  
309      await harness.invoke("session_start", {});
310      harness.enqueueSelection("Undo last file rewind");
311  
312      const result = await harness.invoke("session_before_fork", { entryId: "user-1" });
313      assert.equal(result, undefined);
314      assert.equal(harness.readRepoFile("notes.txt"), "undo target\n");
315  
316      const currentSessionRewindOps = harness.currentSession.getEntries().filter((entry) => entry.type === "custom" && entry.customType === "rewind-op");
317      assert.equal(currentSessionRewindOps.length, 1);
318  
319      const childSession = harness.createSession({
320        id: "session-2",
321        parentSession: harness.currentSession.getSessionFile(),
322      });
323      await harness.invoke("session_fork", {}, childSession);
324  
325      const childRewindOps = childSession.getEntries().filter((entry) => entry.type === "custom" && entry.customType === "rewind-op");
326      assert.equal(childRewindOps.length, 1);
327      assert.deepEqual(childRewindOps[0]?.data, {
328        v: 2,
329        snapshots: [undoCommit, currentCommit],
330        current: 0,
331        undo: 1,
332      });
333    } finally {
334      await harness.cleanup();
335    }
336  });
337  
338  test("first mutating turn creates a reachable store ref even when retention is omitted", async () => {
339    const harness = await createHarness({
340      settings: { rewind: { silentCheckpoints: true } },
341    });
342  
343    try {
344      const assistantTimestamp = Date.now();
345      harness.currentSession.replaceEntries([
346        {
347          type: "message",
348          id: "user-1",
349          parentId: null,
350          timestamp: new Date(assistantTimestamp - 1000).toISOString(),
351          message: { role: "user", content: [{ type: "text", text: "Please create the file" }] },
352        },
353        {
354          type: "message",
355          id: "assistant-1",
356          parentId: "user-1",
357          timestamp: new Date(assistantTimestamp).toISOString(),
358          message: {
359            role: "assistant",
360            timestamp: assistantTimestamp,
361            content: [{ type: "text", text: "Created the file" }],
362          },
363        },
364      ]);
365  
366      await harness.invoke("session_start", {});
367      await harness.invoke("before_agent_start", { prompt: "Please create the file" });
368      await harness.invoke("turn_start", { turnIndex: 0 });
369      await harness.writeRepoFile("tests/rewind-smoke/a.txt", "smoke test\n");
370      await harness.invoke("turn_end", {
371        message: {
372          role: "assistant",
373          timestamp: assistantTimestamp,
374          content: [{ type: "text", text: "Created the file" }],
375        },
376      });
377      await harness.invoke("agent_end", {});
378  
379      const rewindTurnEntries = harness.currentSession.getEntries().filter((entry) => entry.type === "custom" && entry.customType === "rewind-turn");
380      assert.equal(rewindTurnEntries.length, 1);
381      const snapshots = (rewindTurnEntries[0]?.data as { snapshots: string[] }).snapshots;
382      assert.equal(snapshots.length, 2);
383  
384      const storeHead = await harness.revParseStore();
385      assert.ok(storeHead);
386      assert.equal(await harness.isAncestor(snapshots[0], storeHead), true);
387      assert.equal(await harness.isAncestor(snapshots[1], storeHead), true);
388  
389    } finally {
390      await harness.cleanup();
391    }
392  });
393  
394  test("startup does not touch the keepalive ref when rewind.retention is omitted", async () => {
395    const harness = await createHarness({ settings: { rewind: { silentCheckpoints: true } } });
396  
397    try {
398      await harness.writeRepoFile("tracked.txt", "keepalive\n");
399      const snapshotCommit = await harness.captureSnapshot();
400      await harness.updateStoreRef(snapshotCommit);
401  
402      await harness.invoke("session_start", {});
403  
404      assert.equal(await harness.revParseStore(), snapshotCommit);
405      assert.equal(harness.execCalls.some((call) => call[0] === "git" && call[1] === "gc"), false);
406      assert.equal(harness.execCalls.some((call) => call[0] === "git" && call[1] === "update-ref" && call.includes(STORE_REF)), false);
407    } finally {
408      await harness.cleanup();
409    }
410  });
411  
412  test("retention preserves the keepalive ref when discovery yields an empty live set", async () => {
413    const harness = await createHarness({ settings: { rewind: { retention: { maxSnapshots: 10 } } } });
414  
415    try {
416      await harness.writeRepoFile("tracked.txt", "keepalive\n");
417      const snapshotCommit = await harness.captureSnapshot();
418      await harness.updateStoreRef(snapshotCommit);
419  
420      await harness.invoke("session_start", {});
421  
422      assert.equal(await harness.revParseStore(), snapshotCommit);
423      assert.equal(harness.execCalls.some((call) => call[0] === "git" && call[1] === "gc"), false);
424    } finally {
425      await harness.cleanup();
426    }
427  });
428  
429  test("retention rewrites the keepalive ref when a live snapshot exists", async () => {
430    const harness = await createHarness({
431      settings: { rewind: { retention: { maxSnapshots: 10 } } },
432    });
433  
434    try {
435      await harness.writeRepoFile("tracked.txt", "stale state\n");
436      const staleCommit = await harness.captureSnapshot();
437      await harness.writeRepoFile("tracked.txt", "current live state\n");
438      const liveCommit = await harness.captureSnapshot();
439      assert.notEqual(liveCommit, staleCommit);
440      await harness.updateStoreRef(staleCommit);
441  
442      harness.currentSession.replaceEntries([
443        {
444          type: "custom",
445          id: "rewind-op-1",
446          parentId: null,
447          timestamp: new Date().toISOString(),
448          customType: "rewind-op",
449          data: { v: 2, snapshots: [liveCommit], current: 0 },
450        },
451      ]);
452  
453      await harness.invoke("session_start", {});
454  
455      // Retention sweep runs in the background on startup; poll for completion
456      const deadline = Date.now() + 3000;
457      let storeHead: string | undefined;
458      while (Date.now() < deadline) {
459        storeHead = await harness.revParseStore();
460        if (storeHead && await harness.isAncestor(liveCommit, storeHead)) break;
461        await new Promise(r => setTimeout(r, 50));
462      }
463      assert.ok(storeHead);
464      assert.equal(await harness.isAncestor(liveCommit, storeHead!), true);
465    } finally {
466      await harness.cleanup();
467    }
468  });
469