index.ts
1 /** 2 * Rewind Extension - session-ledger based exact file restoration for pi branching 3 * 4 * Rewind v2 stores exact rewind metadata in hidden session custom entries and keeps 5 * snapshot commits reachable through a single repo-local store ref. 6 */ 7 8 import { getAgentDir, type ExtensionAPI, type ExtensionContext } from "@mariozechner/pi-coding-agent"; 9 import { exec as execCb } from "child_process"; 10 import { existsSync, readFileSync, realpathSync } from "fs"; 11 import { mkdtemp, readdir, readFile, rm, stat } from "fs/promises"; 12 import { tmpdir } from "os"; 13 import { dirname, isAbsolute, join, relative, resolve } from "path"; 14 import { promisify } from "util"; 15 16 const execAsync = promisify(execCb); 17 18 const LEGACY_REF_PREFIX = "refs/pi-checkpoints/"; 19 const STORE_REF = "refs/pi-rewind/store"; 20 const STATUS_KEY = "rewind"; 21 const FORK_PREFERENCE_SOURCE_ALLOWLIST = new Set(["fork-from-first"]); 22 const LEGACY_ZERO_SHA = "0000000000000000000000000000000000000000"; 23 const RETENTION_SWEEP_THRESHOLD = 50; 24 const RETENTION_VERSION = 2; 25 const EMPTY_TREE_SHA = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"; 26 27 type ExecFn = (cmd: string, args: string[]) => Promise<{ stdout: string; stderr: string; code: number }>; 28 29 type GitExecResult = Awaited<ReturnType<ExecFn>>; 30 31 type BindingTuple = [entryId: string, snapshotIndex: number]; 32 33 interface RewindSettings { 34 rewind?: { 35 silentCheckpoints?: boolean; 36 retention?: { 37 maxSnapshots?: number; 38 maxAgeDays?: number; 39 pinLabeledEntries?: boolean; 40 }; 41 }; 42 } 43 44 interface RewindTurnData { 45 v: 2; 46 snapshots: string[]; 47 bindings: BindingTuple[]; 48 } 49 50 interface RewindOpData { 51 v: 2; 52 snapshots: string[]; 53 bindings?: BindingTuple[]; 54 current?: number; 55 undo?: number; 56 } 57 58 interface ActivePromptCollector { 59 snapshots: string[]; 60 bindings: BindingTuple[]; 61 promptText?: string; 62 pendingUserCommitSha?: string; 63 } 64 65 interface ExactState { 66 commitSha: string; 67 treeSha: string; 68 } 69 70 interface ActiveBranchState { 71 currentCommitSha?: string; 72 currentTreeSha?: string; 73 undoCommitSha?: string; 74 } 75 76 interface PendingResultingState { 77 currentCommitSha: string; 78 undoCommitSha?: string; 79 } 80 81 interface ParsedLedgerReference { 82 commitSha: string; 83 entryId?: string; 84 timestamp: number; 85 kind: "binding" | "current" | "undo"; 86 } 87 88 interface ParsedSessionLedger { 89 sessionFile: string; 90 sessionId?: string; 91 cwd?: string; 92 parentSession?: string; 93 entryToCommit: Map<string, string>; 94 labeledEntryIds: Set<string>; 95 references: ParsedLedgerReference[]; 96 latestCurrentCommitSha?: string; 97 latestUndoCommitSha?: string; 98 } 99 100 interface LegacyRef { 101 refName: string; 102 commitSha: string; 103 sessionId?: string; 104 entryId: string; 105 scoped: boolean; 106 } 107 108 interface SessionLikeMessageEntry { 109 type: "message"; 110 id: string; 111 parentId: string | null; 112 timestamp: string; 113 message: { 114 role: string; 115 timestamp?: number; 116 content?: unknown; 117 }; 118 } 119 120 interface SessionLikeCustomEntry { 121 type: "custom"; 122 id: string; 123 parentId: string | null; 124 timestamp: string; 125 customType: string; 126 data?: unknown; 127 } 128 129 interface SessionLikeLabelEntry { 130 type: "label"; 131 targetId: string; 132 label?: string; 133 } 134 135 interface SessionLikeBranchSummaryEntry { 136 type: "branch_summary"; 137 id: string; 138 } 139 140 interface SessionLikeGenericEntry { 141 type: string; 142 id?: string; 143 parentId?: string | null; 144 timestamp?: string; 145 message?: { 146 role?: string; 147 timestamp?: number; 148 content?: unknown; 149 }; 150 customType?: string; 151 data?: unknown; 152 targetId?: string; 153 label?: string; 154 } 155 156 type SessionLikeEntry = 157 | SessionLikeMessageEntry 158 | SessionLikeCustomEntry 159 | SessionLikeLabelEntry 160 | SessionLikeBranchSummaryEntry 161 | SessionLikeGenericEntry; 162 163 let cachedSettings: RewindSettings | null = null; 164 165 function getSettingsFilePath(): string { 166 return join(getAgentDir(), "settings.json"); 167 } 168 169 function getDefaultSessionsDir(): string { 170 return join(getAgentDir(), "sessions"); 171 } 172 173 function getSettings(): RewindSettings { 174 if (cachedSettings) { 175 return cachedSettings; 176 } 177 178 try { 179 cachedSettings = JSON.parse(readFileSync(getSettingsFilePath(), "utf-8")) as RewindSettings; 180 } catch { 181 cachedSettings = {}; 182 } 183 184 return cachedSettings; 185 } 186 187 function getSilentCheckpointsSetting(): boolean { 188 return getSettings().rewind?.silentCheckpoints === true; 189 } 190 191 function getRetentionSettings(): NonNullable<NonNullable<RewindSettings["rewind"]>["retention"]> | undefined { 192 return getSettings().rewind?.retention; 193 } 194 195 function isRewindTurnData(value: unknown): value is RewindTurnData { 196 if (!value || typeof value !== "object") return false; 197 const data = value as Partial<RewindTurnData>; 198 return data.v === 2 && Array.isArray(data.snapshots) && Array.isArray(data.bindings); 199 } 200 201 function isRewindOpData(value: unknown): value is RewindOpData { 202 if (!value || typeof value !== "object") return false; 203 const data = value as Partial<RewindOpData>; 204 return data.v === 2 && Array.isArray(data.snapshots); 205 } 206 207 function canonicalizePath(value: string): string { 208 const resolvedValue = resolve(value); 209 try { 210 return realpathSync.native(resolvedValue); 211 } catch { 212 return resolvedValue; 213 } 214 } 215 216 function isInsidePath(targetPath: string, parentPath: string): boolean { 217 const resolvedTarget = canonicalizePath(targetPath); 218 const resolvedParent = canonicalizePath(parentPath); 219 const rel = relative(resolvedParent, resolvedTarget); 220 return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); 221 } 222 223 function toTimestamp(value: string | undefined): number { 224 if (!value) return 0; 225 const parsed = Date.parse(value); 226 return Number.isFinite(parsed) ? parsed : 0; 227 } 228 229 function getTextContent(content: unknown): string { 230 if (typeof content === "string") return content; 231 if (!Array.isArray(content)) return ""; 232 return content 233 .filter((block): block is { type: string; text?: string } => !!block && typeof block === "object") 234 .filter((block) => block.type === "text") 235 .map((block) => block.text ?? "") 236 .join("\n"); 237 } 238 239 function updateLabelSet(labelIds: Set<string>, entry: SessionLikeLabelEntry) { 240 if (!entry.targetId) return; 241 if (entry.label && entry.label.trim()) { 242 labelIds.add(entry.targetId); 243 return; 244 } 245 labelIds.delete(entry.targetId); 246 } 247 248 function applyBindings(target: Map<string, string>, snapshots: string[], bindings?: BindingTuple[]) { 249 if (!bindings) return; 250 for (const [entryId, snapshotIndex] of bindings) { 251 const commitSha = snapshots[snapshotIndex]; 252 if (entryId && commitSha) { 253 target.set(entryId, commitSha); 254 } 255 } 256 } 257 258 function addReferences(target: ParsedLedgerReference[], snapshots: string[], timestamp: number, data: RewindTurnData | RewindOpData) { 259 if ("bindings" in data && data.bindings) { 260 for (const [entryId, snapshotIndex] of data.bindings) { 261 const commitSha = snapshots[snapshotIndex]; 262 if (!commitSha) continue; 263 target.push({ commitSha, entryId, timestamp, kind: "binding" }); 264 } 265 } 266 267 if ("current" in data && typeof data.current === "number") { 268 const commitSha = snapshots[data.current]; 269 if (commitSha) { 270 target.push({ commitSha, timestamp, kind: "current" }); 271 } 272 } 273 274 if ("undo" in data && typeof data.undo === "number") { 275 const commitSha = snapshots[data.undo]; 276 if (commitSha) { 277 target.push({ commitSha, timestamp, kind: "undo" }); 278 } 279 } 280 } 281 282 function resolveBindingSnapshotIndex(snapshots: string[], commitSha: string): number { 283 const existingIndex = snapshots.indexOf(commitSha); 284 if (existingIndex >= 0) return existingIndex; 285 snapshots.push(commitSha); 286 return snapshots.length - 1; 287 } 288 289 function addBindingToCollector(collector: ActivePromptCollector, entryId: string, commitSha: string) { 290 const snapshotIndex = resolveBindingSnapshotIndex(collector.snapshots, commitSha); 291 collector.bindings.push([entryId, snapshotIndex]); 292 } 293 294 function getCommitFromData(data: RewindOpData, indexKey: "current" | "undo"): string | undefined { 295 const snapshotIndex = data[indexKey]; 296 return typeof snapshotIndex === "number" ? data.snapshots[snapshotIndex] : undefined; 297 } 298 299 function isRestorableTreeEntry(entry: SessionLikeEntry | undefined): boolean { 300 if (!entry) return false; 301 if (entry.type === "message") { 302 return entry.message.role === "user" || entry.message.role === "assistant"; 303 } 304 return entry.type === "branch_summary" || entry.type === "compaction"; 305 } 306 307 function isAssistantMessageEntry(entry: SessionLikeEntry): entry is SessionLikeMessageEntry { 308 return entry.type === "message" && entry.message.role === "assistant"; 309 } 310 311 function findLatestUserMessageEntry(entries: SessionLikeEntry[]): SessionLikeMessageEntry | null { 312 for (let index = entries.length - 1; index >= 0; index -= 1) { 313 const entry = entries[index]; 314 if (entry.type === "message" && entry.message.role === "user") { 315 return entry; 316 } 317 } 318 return null; 319 } 320 321 function findLatestMatchingUserMessageEntry( 322 entries: SessionLikeEntry[], 323 promptText: string | null | undefined, 324 ): SessionLikeMessageEntry | null { 325 if (!promptText) return null; 326 327 for (let index = entries.length - 1; index >= 0; index -= 1) { 328 const entry = entries[index]; 329 if (entry.type !== "message" || entry.message.role !== "user") continue; 330 if (getTextContent(entry.message.content) === promptText) { 331 return entry; 332 } 333 } 334 335 return null; 336 } 337 338 function findAssistantEntryForTurn(entries: SessionLikeEntry[], message: { timestamp?: number; content?: unknown }): SessionLikeMessageEntry | null { 339 const targetTimestamp = message.timestamp; 340 const targetText = getTextContent(message.content); 341 342 for (let index = entries.length - 1; index >= 0; index -= 1) { 343 const entry = entries[index]; 344 if (!isAssistantMessageEntry(entry)) continue; 345 346 if (targetTimestamp !== undefined && entry.message.timestamp === targetTimestamp) { 347 return entry; 348 } 349 350 if (targetText && getTextContent(entry.message.content) === targetText) { 351 return entry; 352 } 353 } 354 355 return null; 356 } 357 358 export default function rewindExtension(pi: ExtensionAPI) { 359 const entryToCommit = new Map<string, string>(); 360 const parsedSessionCache = new Map<string, { mtimeMs: number; ledger: ParsedSessionLedger }>(); 361 362 let repoRoot: string | null = null; 363 let sessionId: string | null = null; 364 let currentSessionFile: string | undefined; 365 let currentParentSession: string | undefined; 366 let currentSessionCwd: string | undefined; 367 let isGitRepo = false; 368 let lastExact: ExactState | null = null; 369 let activeBranchState: ActiveBranchState = {}; 370 let promptCollector: ActivePromptCollector | null = null; 371 let pendingForkState: PendingResultingState | null = null; 372 let pendingTreeState: PendingResultingState | null = null; 373 let activePromptText: string | null = null; 374 let newSnapshotsSinceSweep = 0; 375 let sweepRunning = false; 376 let sweepCompletedThisSession = false; 377 let forceConversationOnlyOnNextFork = false; 378 let forceConversationOnlySource: string | null = null; 379 380 function notify(ctx: ExtensionContext, message: string, level: "info" | "warning" | "error" = "info") { 381 if (!ctx.hasUI) return; 382 if (level === "info" && getSilentCheckpointsSetting()) return; 383 ctx.ui.notify(message, level); 384 } 385 386 function updateStatus(ctx: ExtensionContext) { 387 if (!ctx.hasUI) return; 388 if (!isGitRepo || getSilentCheckpointsSetting()) { 389 ctx.ui.setStatus(STATUS_KEY, undefined); 390 return; 391 } 392 393 const uniqueSnapshots = new Set(entryToCommit.values()).size; 394 const theme = ctx.ui.theme; 395 ctx.ui.setStatus( 396 STATUS_KEY, 397 theme.fg("dim", "◆ ") + theme.fg("muted", `${entryToCommit.size} points / ${uniqueSnapshots} snapshots`), 398 ); 399 } 400 401 function resetState() { 402 entryToCommit.clear(); 403 parsedSessionCache.clear(); 404 repoRoot = null; 405 sessionId = null; 406 currentSessionFile = undefined; 407 currentParentSession = undefined; 408 currentSessionCwd = undefined; 409 isGitRepo = false; 410 lastExact = null; 411 activeBranchState = {}; 412 promptCollector = null; 413 pendingForkState = null; 414 pendingTreeState = null; 415 activePromptText = null; 416 newSnapshotsSinceSweep = 0; 417 sweepCompletedThisSession = false; 418 forceConversationOnlyOnNextFork = false; 419 forceConversationOnlySource = null; 420 cachedSettings = null; 421 } 422 423 function syncSessionIdentity(ctx: ExtensionContext) { 424 sessionId = ctx.sessionManager.getSessionId(); 425 currentSessionFile = ctx.sessionManager.getSessionFile(); 426 currentParentSession = ctx.sessionManager.getHeader()?.parentSession; 427 currentSessionCwd = ctx.sessionManager.getCwd(); 428 } 429 430 async function execGitChecked(args: string[]): Promise<GitExecResult> { 431 const result = await pi.exec("git", args); 432 if (result.code !== 0) { 433 const stderr = result.stderr.trim(); 434 throw new Error(stderr || `git ${args.join(" ")} failed with code ${result.code}`); 435 } 436 return result; 437 } 438 439 async function getRepoRoot(exec: ExecFn): Promise<string> { 440 if (repoRoot) return repoRoot; 441 const result = await exec("git", ["rev-parse", "--show-toplevel"]); 442 if (result.code !== 0) { 443 const stderr = result.stderr.trim(); 444 throw new Error(stderr || `git rev-parse --show-toplevel failed with code ${result.code}`); 445 } 446 repoRoot = result.stdout.trim(); 447 return repoRoot; 448 } 449 450 async function captureWorktreeTree(): Promise<{ treeSha: string }> { 451 const root = await getRepoRoot(pi.exec); 452 const tempDir = await mkdtemp(join(tmpdir(), "pi-rewind-")); 453 const tempIndex = join(tempDir, "index"); 454 455 try { 456 const env = { ...process.env, GIT_INDEX_FILE: tempIndex }; 457 await execAsync("git add -A", { cwd: root, env }); 458 const { stdout } = await execAsync("git write-tree", { cwd: root, env }); 459 return { treeSha: stdout.trim() }; 460 } finally { 461 await rm(tempDir, { recursive: true, force: true }).catch(() => undefined); 462 } 463 } 464 465 async function getCommitTreeSha(commitSha: string): Promise<string> { 466 const result = await execGitChecked(["show", "-s", "--format=%T", commitSha]); 467 return result.stdout.trim(); 468 } 469 470 async function commitExists(commitSha: string): Promise<boolean> { 471 const result = await pi.exec("git", ["cat-file", "-e", `${commitSha}^{commit}`]); 472 return result.code === 0; 473 } 474 475 async function getStoreHead(): Promise<string | undefined> { 476 const result = await pi.exec("git", ["rev-parse", "--verify", STORE_REF]); 477 if (result.code !== 0) { 478 return undefined; 479 } 480 const value = result.stdout.trim(); 481 return value || undefined; 482 } 483 484 async function createStoreKeepaliveCommit(snapshotCommitSha: string, previousStoreHead?: string): Promise<string> { 485 const args = ["commit-tree", EMPTY_TREE_SHA]; 486 487 if (previousStoreHead) { 488 args.push("-p", previousStoreHead); 489 } 490 491 args.push("-p", snapshotCommitSha, "-m", "pi rewind store"); 492 const result = await execGitChecked(args); 493 return result.stdout.trim(); 494 } 495 496 async function appendSnapshotToStore(commitSha: string): Promise<void> { 497 let attempts = 0; 498 499 while (attempts < 5) { 500 attempts += 1; 501 const oldHead = await getStoreHead(); 502 const keepaliveCommit = await createStoreKeepaliveCommit(commitSha, oldHead); 503 504 try { 505 if (oldHead) { 506 await execGitChecked(["update-ref", STORE_REF, keepaliveCommit, oldHead]); 507 } else { 508 await execGitChecked(["update-ref", STORE_REF, keepaliveCommit, LEGACY_ZERO_SHA]); 509 } 510 return; 511 } catch { 512 // Retry if another process updated the store ref concurrently 513 } 514 } 515 516 throw new Error("failed to update rewind store ref"); 517 } 518 519 async function rewriteStoreToLiveSet(liveCommitShas: string[]): Promise<"rewritten" | "preserved-empty"> { 520 const uniqueLiveCommits = [...new Set(liveCommitShas.filter(Boolean))]; 521 if (uniqueLiveCommits.length === 0) { 522 return "preserved-empty"; 523 } 524 525 let head: string | undefined; 526 for (const commitSha of uniqueLiveCommits) { 527 head = await createStoreKeepaliveCommit(commitSha, head); 528 } 529 530 const oldHead = await getStoreHead(); 531 if (oldHead) { 532 await execGitChecked(["update-ref", STORE_REF, head!, oldHead]); 533 return "rewritten"; 534 } 535 536 await execGitChecked(["update-ref", STORE_REF, head!, LEGACY_ZERO_SHA]); 537 return "rewritten"; 538 } 539 540 async function ensureSnapshotForTree(treeSha: string): Promise<string> { 541 if (lastExact && lastExact.treeSha === treeSha) { 542 return lastExact.commitSha; 543 } 544 545 const result = await execGitChecked(["commit-tree", treeSha, "-m", "pi rewind snapshot"]); 546 const commitSha = result.stdout.trim(); 547 await appendSnapshotToStore(commitSha); 548 lastExact = { commitSha, treeSha }; 549 newSnapshotsSinceSweep += 1; 550 return commitSha; 551 } 552 553 async function ensureSnapshotForCurrentWorktree(): Promise<string> { 554 const { treeSha } = await captureWorktreeTree(); 555 return ensureSnapshotForTree(treeSha); 556 } 557 558 async function deletePathsFromWorkingTree(paths: string[]) { 559 if (paths.length === 0) return; 560 const root = await getRepoRoot(pi.exec); 561 562 for (const repoRelativePath of paths) { 563 const absolutePath = resolve(root, repoRelativePath); 564 if (!isInsidePath(absolutePath, root)) { 565 throw new Error(`refusing to delete path outside repo root: ${repoRelativePath}`); 566 } 567 await rm(absolutePath, { recursive: true, force: true }); 568 } 569 } 570 571 async function getDeletedPaths(currentTreeSha: string, targetTreeSha: string): Promise<string[]> { 572 const result = await execGitChecked([ 573 "diff", 574 "--name-only", 575 "--diff-filter=D", 576 "-z", 577 currentTreeSha, 578 targetTreeSha, 579 "--", 580 ]); 581 582 return result.stdout.split("\0").filter(Boolean); 583 } 584 585 async function restoreCommitExactly(targetCommitSha: string): Promise<{ changed: boolean; undoCommitSha?: string; targetTreeSha: string }> { 586 const { treeSha: currentTreeSha } = await captureWorktreeTree(); 587 const targetTreeSha = await getCommitTreeSha(targetCommitSha); 588 589 if (currentTreeSha === targetTreeSha) { 590 lastExact = { commitSha: targetCommitSha, treeSha: targetTreeSha }; 591 return { changed: false, targetTreeSha }; 592 } 593 594 const undoCommitSha = await ensureSnapshotForTree(currentTreeSha); 595 const pathsToDelete = await getDeletedPaths(currentTreeSha, targetTreeSha); 596 await deletePathsFromWorkingTree(pathsToDelete); 597 await execGitChecked(["restore", `--source=${targetCommitSha}`, "--worktree", "--", "."]); 598 lastExact = { commitSha: targetCommitSha, treeSha: targetTreeSha }; 599 return { changed: true, undoCommitSha, targetTreeSha }; 600 } 601 602 function bindPendingPromptUser(entries: SessionLikeEntry[], collector: ActivePromptCollector) { 603 if (!collector.pendingUserCommitSha) return; 604 605 const userEntry = findLatestMatchingUserMessageEntry(entries, collector.promptText) ?? findLatestUserMessageEntry(entries); 606 if (!userEntry) return; 607 if (collector.bindings.some(([entryId]) => entryId === userEntry.id)) { 608 collector.pendingUserCommitSha = undefined; 609 return; 610 } 611 612 addBindingToCollector(collector, userEntry.id, collector.pendingUserCommitSha); 613 collector.pendingUserCommitSha = undefined; 614 } 615 616 function appendRewindTurn(ctx: ExtensionContext, collector: ActivePromptCollector) { 617 if (collector.bindings.length === 0) return; 618 619 const data: RewindTurnData = { 620 v: RETENTION_VERSION, 621 snapshots: collector.snapshots, 622 bindings: collector.bindings, 623 }; 624 625 pi.appendEntry("rewind-turn", data); 626 applyBindings(entryToCommit, data.snapshots, data.bindings); 627 628 const latestBinding = data.bindings[data.bindings.length - 1]; 629 if (latestBinding) { 630 activeBranchState.currentCommitSha = data.snapshots[latestBinding[1]]; 631 activeBranchState.currentTreeSha = lastExact?.commitSha === activeBranchState.currentCommitSha ? lastExact.treeSha : undefined; 632 } 633 634 updateStatus(ctx); 635 } 636 637 function appendRewindOp(ctx: ExtensionContext, data: RewindOpData) { 638 const hasBindings = Boolean(data.bindings?.length); 639 const hasCurrent = typeof data.current === "number"; 640 const hasUndo = typeof data.undo === "number"; 641 if (!hasBindings && !hasCurrent && !hasUndo) return; 642 643 pi.appendEntry("rewind-op", data); 644 applyBindings(entryToCommit, data.snapshots, data.bindings); 645 646 const currentCommitSha = getCommitFromData(data, "current"); 647 if (currentCommitSha) { 648 activeBranchState.currentCommitSha = currentCommitSha; 649 activeBranchState.currentTreeSha = lastExact?.commitSha === currentCommitSha ? lastExact.treeSha : undefined; 650 } 651 652 const undoCommitSha = getCommitFromData(data, "undo"); 653 if (undoCommitSha) { 654 activeBranchState.undoCommitSha = undoCommitSha; 655 } 656 657 updateStatus(ctx); 658 } 659 660 function buildCurrentSessionLedger(ctx: ExtensionContext): ParsedSessionLedger { 661 const ledger: ParsedSessionLedger = { 662 sessionFile: currentSessionFile ?? "", 663 sessionId: ctx.sessionManager.getSessionId(), 664 cwd: ctx.sessionManager.getCwd(), 665 parentSession: ctx.sessionManager.getHeader()?.parentSession, 666 entryToCommit: new Map<string, string>(), 667 labeledEntryIds: new Set<string>(), 668 references: [], 669 }; 670 671 for (const rawEntry of ctx.sessionManager.getEntries() as SessionLikeEntry[]) { 672 if (rawEntry.type === "custom" && rawEntry.customType === "rewind-turn" && isRewindTurnData(rawEntry.data)) { 673 applyBindings(ledger.entryToCommit, rawEntry.data.snapshots, rawEntry.data.bindings); 674 addReferences(ledger.references, rawEntry.data.snapshots, toTimestamp(rawEntry.timestamp), rawEntry.data); 675 continue; 676 } 677 678 if (rawEntry.type === "custom" && rawEntry.customType === "rewind-op" && isRewindOpData(rawEntry.data)) { 679 applyBindings(ledger.entryToCommit, rawEntry.data.snapshots, rawEntry.data.bindings); 680 addReferences(ledger.references, rawEntry.data.snapshots, toTimestamp(rawEntry.timestamp), rawEntry.data); 681 const currentCommitSha = getCommitFromData(rawEntry.data, "current"); 682 if (currentCommitSha) ledger.latestCurrentCommitSha = currentCommitSha; 683 const undoCommitSha = getCommitFromData(rawEntry.data, "undo"); 684 if (undoCommitSha) ledger.latestUndoCommitSha = undoCommitSha; 685 continue; 686 } 687 688 if (rawEntry.type === "label") { 689 updateLabelSet(ledger.labeledEntryIds, rawEntry); 690 } 691 } 692 693 return ledger; 694 } 695 696 async function parseSessionLedgerFile(sessionFile: string): Promise<ParsedSessionLedger | null> { 697 try { 698 const fileStat = await stat(sessionFile); 699 const cached = parsedSessionCache.get(sessionFile); 700 if (cached && cached.mtimeMs === fileStat.mtimeMs) { 701 return cached.ledger; 702 } 703 704 const content = await readFile(sessionFile, "utf-8"); 705 const ledger: ParsedSessionLedger = { 706 sessionFile, 707 entryToCommit: new Map<string, string>(), 708 labeledEntryIds: new Set<string>(), 709 references: [], 710 }; 711 712 const hasRewindEntries = content.includes('"rewind-'); 713 714 if (!hasRewindEntries) { 715 // Fast path: extract session header only, skip line-by-line JSON parsing 716 let pos = 0; 717 for (let i = 0; i < 5 && pos < content.length; i++) { 718 const nextNewline = content.indexOf("\n", pos); 719 const line = nextNewline >= 0 ? content.substring(pos, nextNewline) : content.substring(pos); 720 pos = nextNewline >= 0 ? nextNewline + 1 : content.length; 721 if (!line) continue; 722 try { 723 const entry = JSON.parse(line); 724 if (entry?.type === "session") { 725 ledger.sessionId = entry.id; 726 ledger.cwd = entry.cwd; 727 ledger.parentSession = entry.parentSession; 728 break; 729 } 730 } catch { continue; } 731 } 732 parsedSessionCache.set(sessionFile, { mtimeMs: fileStat.mtimeMs, ledger }); 733 return ledger; 734 } 735 736 const lines = content.split("\n").filter(Boolean); 737 738 for (const line of lines) { 739 let entry: any; 740 try { 741 entry = JSON.parse(line); 742 } catch { 743 continue; 744 } 745 746 if (entry?.type === "session") { 747 ledger.sessionId = entry.id; 748 ledger.cwd = entry.cwd; 749 ledger.parentSession = entry.parentSession; 750 continue; 751 } 752 753 if (entry?.type === "custom" && entry?.customType === "rewind-turn" && isRewindTurnData(entry.data)) { 754 applyBindings(ledger.entryToCommit, entry.data.snapshots, entry.data.bindings); 755 addReferences(ledger.references, entry.data.snapshots, toTimestamp(entry.timestamp), entry.data); 756 continue; 757 } 758 759 if (entry?.type === "custom" && entry?.customType === "rewind-op" && isRewindOpData(entry.data)) { 760 applyBindings(ledger.entryToCommit, entry.data.snapshots, entry.data.bindings); 761 addReferences(ledger.references, entry.data.snapshots, toTimestamp(entry.timestamp), entry.data); 762 const currentCommitSha = getCommitFromData(entry.data, "current"); 763 if (currentCommitSha) ledger.latestCurrentCommitSha = currentCommitSha; 764 const undoCommitSha = getCommitFromData(entry.data, "undo"); 765 if (undoCommitSha) ledger.latestUndoCommitSha = undoCommitSha; 766 continue; 767 } 768 769 if (entry?.type === "label") { 770 updateLabelSet(ledger.labeledEntryIds, entry); 771 } 772 } 773 774 parsedSessionCache.set(sessionFile, { mtimeMs: fileStat.mtimeMs, ledger }); 775 return ledger; 776 } catch { 777 return null; 778 } 779 } 780 781 async function listLegacyRefs(): Promise<LegacyRef[]> { 782 try { 783 const result = await execGitChecked([ 784 "for-each-ref", 785 "--format=%(refname) %(objectname)", 786 LEGACY_REF_PREFIX, 787 ]); 788 789 return result.stdout 790 .split("\n") 791 .map((line) => line.trim()) 792 .filter(Boolean) 793 .map((line) => { 794 const [refName, commitSha] = line.split(/\s+/, 2); 795 const shortRef = refName.replace(LEGACY_REF_PREFIX, ""); 796 if (shortRef.startsWith("checkpoint-resume-") || shortRef.startsWith("before-restore-")) { 797 return null; 798 } 799 800 const scoped = shortRef.match(/^checkpoint-([a-f0-9-]{36})-(\d+)-(.+)$/); 801 if (scoped) { 802 return { 803 refName, 804 commitSha, 805 sessionId: scoped[1], 806 entryId: scoped[3], 807 scoped: true, 808 } satisfies LegacyRef; 809 } 810 811 const unscoped = shortRef.match(/^checkpoint-(\d+)-(.+)$/); 812 if (unscoped) { 813 return { 814 refName, 815 commitSha, 816 entryId: unscoped[2], 817 scoped: false, 818 } satisfies LegacyRef; 819 } 820 821 return null; 822 }) 823 .filter((value): value is LegacyRef => value !== null); 824 } catch { 825 return []; 826 } 827 } 828 829 async function importLegacyRefsIfNeeded(ctx: ExtensionContext) { 830 if (!sessionId) return; 831 832 const legacyRefs = await listLegacyRefs(); 833 if (legacyRefs.length === 0) return; 834 835 const snapshots: string[] = []; 836 const bindings: BindingTuple[] = []; 837 const refsToDelete: string[] = []; 838 const entries = ctx.sessionManager.getEntries() as SessionLikeEntry[]; 839 840 for (const entry of entries) { 841 if (entry.type !== "message" || entry.message.role !== "user") continue; 842 if (entryToCommit.has(entry.id)) continue; 843 844 const legacyRef = legacyRefs.find((candidate) => { 845 if (candidate.entryId !== entry.id) return false; 846 if (candidate.scoped) return candidate.sessionId === sessionId; 847 return true; 848 }); 849 850 if (!legacyRef) continue; 851 if (!(await commitExists(legacyRef.commitSha))) continue; 852 853 await appendSnapshotToStore(legacyRef.commitSha); 854 const snapshotIndex = resolveBindingSnapshotIndex(snapshots, legacyRef.commitSha); 855 bindings.push([entry.id, snapshotIndex]); 856 if (legacyRef.scoped) { 857 refsToDelete.push(legacyRef.refName); 858 } 859 } 860 861 if (bindings.length === 0) return; 862 863 appendRewindOp(ctx, { 864 v: RETENTION_VERSION, 865 snapshots, 866 bindings, 867 }); 868 869 for (const refName of refsToDelete) { 870 try { 871 const result = await pi.exec("git", ["update-ref", "-d", refName]); 872 if (result.code !== 0) { 873 throw new Error(result.stderr.trim() || `git update-ref -d ${refName} failed with code ${result.code}`); 874 } 875 } catch { 876 // Ignore legacy cleanup failures 877 } 878 } 879 } 880 881 async function resolveLegacyAncestorCommit(entryId: string, targetSessionId: string | undefined): Promise<string | undefined> { 882 if (!targetSessionId) return undefined; 883 const legacyRefs = await listLegacyRefs(); 884 const match = legacyRefs.find((ref) => ref.scoped && ref.sessionId === targetSessionId && ref.entryId === entryId); 885 if (!match) return undefined; 886 return (await commitExists(match.commitSha)) ? match.commitSha : undefined; 887 } 888 889 async function resolveEntrySnapshotWithLineage(entryId: string, sessionFile = currentSessionFile): Promise<string | undefined> { 890 let cursor = sessionFile; 891 892 while (cursor) { 893 const ledger = cursor === currentSessionFile ? buildCurrentSessionLedgerFromMemory() : await parseSessionLedgerFile(cursor); 894 if (!ledger) break; 895 896 const commitSha = ledger.entryToCommit.get(entryId); 897 if (commitSha && (await commitExists(commitSha))) { 898 return commitSha; 899 } 900 901 const legacyCommitSha = await resolveLegacyAncestorCommit(entryId, ledger.sessionId); 902 if (legacyCommitSha) { 903 return legacyCommitSha; 904 } 905 906 cursor = ledger.parentSession; 907 } 908 909 return undefined; 910 } 911 912 function buildCurrentSessionLedgerFromMemory(): ParsedSessionLedger { 913 return { 914 sessionFile: currentSessionFile ?? "", 915 sessionId: sessionId ?? undefined, 916 cwd: currentSessionCwd, 917 parentSession: currentParentSession, 918 entryToCommit: new Map(entryToCommit), 919 labeledEntryIds: new Set(), 920 references: [], 921 latestCurrentCommitSha: activeBranchState.currentCommitSha, 922 latestUndoCommitSha: activeBranchState.undoCommitSha, 923 }; 924 } 925 926 async function reconstructState(ctx: ExtensionContext) { 927 entryToCommit.clear(); 928 activeBranchState = {}; 929 lastExact = null; 930 931 const currentLedger = buildCurrentSessionLedger(ctx); 932 for (const [entryId, commitSha] of currentLedger.entryToCommit.entries()) { 933 entryToCommit.set(entryId, commitSha); 934 } 935 936 let latestVisibleBindingCommitSha: string | undefined; 937 for (const entry of ctx.sessionManager.getBranch() as SessionLikeEntry[]) { 938 const boundCommitSha = entry.id ? entryToCommit.get(entry.id) : undefined; 939 if (boundCommitSha && isRestorableTreeEntry(entry)) { 940 latestVisibleBindingCommitSha = boundCommitSha; 941 } 942 943 if (entry.type === "custom" && entry.customType === "rewind-op" && isRewindOpData(entry.data)) { 944 const currentCommitSha = getCommitFromData(entry.data, "current"); 945 if (currentCommitSha) { 946 activeBranchState.currentCommitSha = currentCommitSha; 947 } 948 const undoCommitSha = getCommitFromData(entry.data, "undo"); 949 if (undoCommitSha) { 950 activeBranchState.undoCommitSha = undoCommitSha; 951 } 952 } 953 } 954 955 if (!activeBranchState.currentCommitSha) { 956 activeBranchState.currentCommitSha = latestVisibleBindingCommitSha; 957 } 958 959 if (activeBranchState.currentCommitSha && (await commitExists(activeBranchState.currentCommitSha))) { 960 activeBranchState.currentTreeSha = await getCommitTreeSha(activeBranchState.currentCommitSha); 961 const { treeSha: worktreeTreeSha } = await captureWorktreeTree(); 962 963 if (activeBranchState.currentTreeSha === worktreeTreeSha) { 964 lastExact = { 965 commitSha: activeBranchState.currentCommitSha, 966 treeSha: activeBranchState.currentTreeSha, 967 }; 968 } 969 } 970 } 971 972 async function discoverSessionFiles(): Promise<string[]> { 973 const roots = new Set<string>(); 974 const defaultSessionsDir = getDefaultSessionsDir(); 975 if (existsSync(defaultSessionsDir)) { 976 roots.add(defaultSessionsDir); 977 } 978 if (currentSessionFile) { 979 roots.add(dirname(currentSessionFile)); 980 } 981 982 const discovered = new Set<string>(); 983 const stack = [...roots]; 984 while (stack.length > 0) { 985 const dir = stack.pop(); 986 if (!dir) continue; 987 988 let entries: Awaited<ReturnType<typeof readdir>>; 989 try { 990 entries = await readdir(dir, { withFileTypes: true }); 991 } catch { 992 continue; 993 } 994 995 for (const entry of entries) { 996 const fullPath = join(dir, entry.name); 997 if (entry.isDirectory()) { 998 stack.push(fullPath); 999 continue; 1000 } 1001 if (entry.isFile() && entry.name.endsWith(".jsonl")) { 1002 discovered.add(fullPath); 1003 } 1004 } 1005 } 1006 1007 let ancestorCursor = currentSessionFile; 1008 while (ancestorCursor) { 1009 discovered.add(ancestorCursor); 1010 const ledger = ancestorCursor === currentSessionFile ? buildCurrentSessionLedgerFromMemory() : await parseSessionLedgerFile(ancestorCursor); 1011 ancestorCursor = ledger?.parentSession; 1012 } 1013 1014 return [...discovered]; 1015 } 1016 1017 async function maybeSweepRetention(ctx: ExtensionContext, reason: "startup" | "new-snapshots" | "shutdown") { 1018 const retention = getRetentionSettings(); 1019 if (!retention) return; 1020 if (reason === "new-snapshots" && newSnapshotsSinceSweep < RETENTION_SWEEP_THRESHOLD) return; 1021 if (reason === "shutdown" && sweepCompletedThisSession && newSnapshotsSinceSweep < RETENTION_SWEEP_THRESHOLD) return; 1022 if (!repoRoot) return; 1023 if (sweepRunning) return; 1024 sweepRunning = true; 1025 try { 1026 await runRetentionSweep(ctx, reason); 1027 } finally { 1028 sweepRunning = false; 1029 } 1030 } 1031 1032 async function runRetentionSweep(ctx: ExtensionContext, reason: "startup" | "new-snapshots" | "shutdown") { 1033 const retention = getRetentionSettings(); 1034 if (!retention) return; 1035 1036 const sessionFiles = await discoverSessionFiles(); 1037 const ledgers: ParsedSessionLedger[] = []; 1038 1039 for (const sessionFile of sessionFiles) { 1040 const ledger = sessionFile === currentSessionFile ? buildCurrentSessionLedger(ctx) : await parseSessionLedgerFile(sessionFile); 1041 if (!ledger?.cwd) continue; 1042 if (!isInsidePath(ledger.cwd, repoRoot)) continue; 1043 ledgers.push(ledger); 1044 } 1045 1046 const latestReferenceByCommit = new Map<string, number>(); 1047 const pinnedCommits = new Set<string>(); 1048 const currentCommits = new Set<string>(); 1049 const undoCommits = new Set<string>(); 1050 1051 for (const ledger of ledgers) { 1052 for (const reference of ledger.references) { 1053 const prev = latestReferenceByCommit.get(reference.commitSha) ?? 0; 1054 if (reference.timestamp > prev) { 1055 latestReferenceByCommit.set(reference.commitSha, reference.timestamp); 1056 } 1057 if (reference.kind === "binding" && retention.pinLabeledEntries && reference.entryId && ledger.labeledEntryIds.has(reference.entryId)) { 1058 pinnedCommits.add(reference.commitSha); 1059 } 1060 } 1061 1062 if (ledger.latestCurrentCommitSha) { 1063 currentCommits.add(ledger.latestCurrentCommitSha); 1064 } 1065 if (ledger.latestUndoCommitSha) { 1066 undoCommits.add(ledger.latestUndoCommitSha); 1067 } 1068 } 1069 1070 for (const commitSha of [...currentCommits, ...undoCommits]) { 1071 if (await commitExists(commitSha)) { 1072 pinnedCommits.add(commitSha); 1073 } 1074 } 1075 1076 let candidates = [...latestReferenceByCommit.entries()] 1077 .filter(([commitSha]) => !pinnedCommits.has(commitSha)) 1078 .sort((left, right) => right[1] - left[1]); 1079 1080 if (typeof retention.maxAgeDays === "number" && retention.maxAgeDays >= 0) { 1081 const cutoff = Date.now() - retention.maxAgeDays * 24 * 60 * 60 * 1000; 1082 candidates = candidates.filter(([, timestamp]) => timestamp >= cutoff); 1083 } 1084 1085 if (typeof retention.maxSnapshots === "number" && retention.maxSnapshots >= 0 && candidates.length > retention.maxSnapshots) { 1086 candidates = candidates.slice(0, retention.maxSnapshots); 1087 } 1088 1089 const liveSet = [...new Set([...pinnedCommits, ...candidates.map(([commitSha]) => commitSha)])]; 1090 const existingLiveSet: string[] = []; 1091 for (const commitSha of liveSet) { 1092 if (await commitExists(commitSha)) { 1093 existingLiveSet.push(commitSha); 1094 } 1095 } 1096 1097 const rewriteResult = await rewriteStoreToLiveSet(existingLiveSet); 1098 if (rewriteResult === "preserved-empty") { 1099 return; 1100 } 1101 1102 // Skip gc on background startup sweeps to avoid racing with concurrent snapshot creation 1103 if (reason !== "startup") { 1104 try { 1105 const result = await pi.exec("git", ["gc", "--auto"]); 1106 if (result.code !== 0) { 1107 throw new Error(result.stderr.trim() || `git gc --auto failed with code ${result.code}`); 1108 } 1109 } catch { 1110 // Best effort only 1111 } 1112 } 1113 1114 newSnapshotsSinceSweep = 0; 1115 sweepCompletedThisSession = true; 1116 updateStatus(ctx); 1117 } 1118 1119 async function initializeForSession(ctx: ExtensionContext) { 1120 resetState(); 1121 syncSessionIdentity(ctx); 1122 1123 try { 1124 const result = await pi.exec("git", ["rev-parse", "--is-inside-work-tree"]); 1125 isGitRepo = result.code === 0 && result.stdout.trim() === "true"; 1126 } catch { 1127 isGitRepo = false; 1128 } 1129 1130 if (!isGitRepo) { 1131 updateStatus(ctx); 1132 return; 1133 } 1134 1135 await getRepoRoot(pi.exec); 1136 await reconstructState(ctx); 1137 await importLegacyRefsIfNeeded(ctx); 1138 await reconstructState(ctx); 1139 updateStatus(ctx); 1140 maybeSweepRetention(ctx, "startup").catch(() => {}); 1141 } 1142 1143 pi.events.on("rewind:fork-preference", (data: any) => { 1144 if (data?.mode !== "conversation-only") return; 1145 if (typeof data?.source !== "string") return; 1146 if (!FORK_PREFERENCE_SOURCE_ALLOWLIST.has(data.source)) return; 1147 forceConversationOnlyOnNextFork = true; 1148 forceConversationOnlySource = data.source; 1149 }); 1150 1151 pi.on("before_agent_start", async (event) => { 1152 activePromptText = event.prompt; 1153 }); 1154 1155 pi.on("session_start", async (_event, ctx) => { 1156 await initializeForSession(ctx); 1157 }); 1158 1159 pi.on("session_switch", async (_event, ctx) => { 1160 await initializeForSession(ctx); 1161 }); 1162 1163 pi.on("session_fork", async (_event, ctx) => { 1164 syncSessionIdentity(ctx); 1165 if (!isGitRepo || !pendingForkState) { 1166 await reconstructState(ctx); 1167 updateStatus(ctx); 1168 return; 1169 } 1170 1171 const snapshots = [pendingForkState.currentCommitSha]; 1172 const data: RewindOpData = { v: RETENTION_VERSION, snapshots, current: 0 }; 1173 if (pendingForkState.undoCommitSha) { 1174 data.snapshots.push(pendingForkState.undoCommitSha); 1175 data.undo = 1; 1176 } 1177 1178 appendRewindOp(ctx, data); 1179 pendingForkState = null; 1180 await reconstructState(ctx); 1181 updateStatus(ctx); 1182 }); 1183 1184 pi.on("session_tree", async (event, ctx) => { 1185 syncSessionIdentity(ctx); 1186 if (!isGitRepo || !pendingTreeState) { 1187 await reconstructState(ctx); 1188 updateStatus(ctx); 1189 return; 1190 } 1191 1192 const snapshots = [pendingTreeState.currentCommitSha]; 1193 const data: RewindOpData = { v: RETENTION_VERSION, snapshots, current: 0 }; 1194 if (pendingTreeState.undoCommitSha) { 1195 data.snapshots.push(pendingTreeState.undoCommitSha); 1196 data.undo = 1; 1197 } 1198 if (event.summaryEntry?.id) { 1199 data.bindings = [[event.summaryEntry.id, 0]]; 1200 } 1201 1202 appendRewindOp(ctx, data); 1203 pendingTreeState = null; 1204 await reconstructState(ctx); 1205 updateStatus(ctx); 1206 }); 1207 1208 pi.on("session_compact", async (event, ctx) => { 1209 syncSessionIdentity(ctx); 1210 if (!isGitRepo) return; 1211 1212 let currentCommitSha = activeBranchState.currentCommitSha; 1213 if (!currentCommitSha) { 1214 currentCommitSha = await ensureSnapshotForCurrentWorktree(); 1215 } 1216 1217 appendRewindOp(ctx, { 1218 v: RETENTION_VERSION, 1219 snapshots: [currentCommitSha], 1220 bindings: [[event.compactionEntry.id, 0]], 1221 }); 1222 await reconstructState(ctx); 1223 updateStatus(ctx); 1224 }); 1225 1226 pi.on("session_shutdown", async (_event, ctx) => { 1227 syncSessionIdentity(ctx); 1228 if (!isGitRepo) return; 1229 await maybeSweepRetention(ctx, "shutdown"); 1230 }); 1231 1232 pi.on("turn_start", async (event, ctx) => { 1233 if (!isGitRepo) return; 1234 if (event.turnIndex !== 0) return; 1235 1236 const { treeSha } = await captureWorktreeTree(); 1237 const commitSha = await ensureSnapshotForTree(treeSha); 1238 promptCollector = { 1239 snapshots: [], 1240 bindings: [], 1241 promptText: activePromptText ?? undefined, 1242 pendingUserCommitSha: commitSha, 1243 }; 1244 1245 bindPendingPromptUser(ctx.sessionManager.getBranch() as SessionLikeEntry[], promptCollector); 1246 }); 1247 1248 pi.on("turn_end", async (event, ctx) => { 1249 if (!isGitRepo || !promptCollector) return; 1250 1251 const branchEntries = ctx.sessionManager.getBranch() as SessionLikeEntry[]; 1252 bindPendingPromptUser(branchEntries, promptCollector); 1253 1254 if (event.message.role !== "assistant") return; 1255 1256 const assistantEntry = findAssistantEntryForTurn(branchEntries, event.message); 1257 if (!assistantEntry) return; 1258 1259 const { treeSha } = await captureWorktreeTree(); 1260 const commitSha = await ensureSnapshotForTree(treeSha); 1261 addBindingToCollector(promptCollector, assistantEntry.id, commitSha); 1262 }); 1263 1264 pi.on("agent_end", async (_event, ctx) => { 1265 if (!isGitRepo || !promptCollector) return; 1266 bindPendingPromptUser(ctx.sessionManager.getBranch() as SessionLikeEntry[], promptCollector); 1267 appendRewindTurn(ctx, promptCollector); 1268 promptCollector = null; 1269 activePromptText = null; 1270 await reconstructState(ctx); 1271 updateStatus(ctx); 1272 await maybeSweepRetention(ctx, "new-snapshots"); 1273 }); 1274 1275 pi.on("session_before_fork", async (event, ctx) => { 1276 const shouldForceConversationOnly = forceConversationOnlyOnNextFork; 1277 const forcedBySource = forceConversationOnlySource; 1278 forceConversationOnlyOnNextFork = false; 1279 forceConversationOnlySource = null; 1280 1281 if (!isGitRepo) return; 1282 if (!ctx.hasUI) { 1283 pendingForkState = { currentCommitSha: await ensureSnapshotForCurrentWorktree() }; 1284 return; 1285 } 1286 1287 const targetCommitSha = await resolveEntrySnapshotWithLineage(event.entryId); 1288 const hasUndo = Boolean(activeBranchState.undoCommitSha && (await commitExists(activeBranchState.undoCommitSha))); 1289 1290 if (shouldForceConversationOnly) { 1291 pendingForkState = { currentCommitSha: await ensureSnapshotForCurrentWorktree() }; 1292 notify(ctx, `Rewind: using conversation-only fork (keep current files)${forcedBySource ? ` (${forcedBySource})` : ""}`); 1293 return; 1294 } 1295 1296 const options = ["Conversation only (keep current files)"]; 1297 if (targetCommitSha) { 1298 options.push("Restore all (files + conversation)", "Code only (restore files, keep conversation)"); 1299 } 1300 if (hasUndo) { 1301 options.push("Undo last file rewind"); 1302 } 1303 1304 const choice = await ctx.ui.select("Restore Options", options); 1305 if (!choice) { 1306 notify(ctx, "Rewind cancelled"); 1307 return { cancel: true }; 1308 } 1309 1310 if (choice === "Undo last file rewind") { 1311 const restore = await restoreCommitExactly(activeBranchState.undoCommitSha!); 1312 pendingForkState = { 1313 currentCommitSha: activeBranchState.undoCommitSha!, 1314 undoCommitSha: restore.undoCommitSha, 1315 }; 1316 notify(ctx, "Files restored to before last rewind"); 1317 return; 1318 } 1319 1320 if (choice === "Conversation only (keep current files)") { 1321 pendingForkState = { currentCommitSha: await ensureSnapshotForCurrentWorktree() }; 1322 return; 1323 } 1324 1325 if (!targetCommitSha) { 1326 notify(ctx, "No exact rewind point available for that entry", "error"); 1327 return { cancel: true }; 1328 } 1329 1330 const restore = await restoreCommitExactly(targetCommitSha); 1331 pendingForkState = { 1332 currentCommitSha: targetCommitSha, 1333 undoCommitSha: restore.undoCommitSha, 1334 }; 1335 notify(ctx, "Files restored from rewind point"); 1336 1337 if (choice === "Code only (restore files, keep conversation)") { 1338 return { skipConversationRestore: true }; 1339 } 1340 }); 1341 1342 pi.on("session_before_tree", async (event, ctx) => { 1343 if (!isGitRepo || !ctx.hasUI) return; 1344 1345 const targetEntry = ctx.sessionManager.getEntry(event.preparation.targetId) as SessionLikeEntry | undefined; 1346 const targetCommitSha = isRestorableTreeEntry(targetEntry) 1347 ? await resolveEntrySnapshotWithLineage(event.preparation.targetId, currentSessionFile) 1348 : undefined; 1349 const hasUndo = Boolean(activeBranchState.undoCommitSha && (await commitExists(activeBranchState.undoCommitSha))); 1350 1351 const options = ["Keep current files"]; 1352 if (targetCommitSha) { 1353 options.push("Restore files to that point"); 1354 } 1355 if (hasUndo) { 1356 options.push("Undo last file rewind"); 1357 } 1358 options.push("Cancel navigation"); 1359 1360 const choice = await ctx.ui.select("Restore Options", options); 1361 if (!choice || choice === "Cancel navigation") { 1362 notify(ctx, "Navigation cancelled"); 1363 return { cancel: true }; 1364 } 1365 1366 if (choice === "Undo last file rewind") { 1367 const restore = await restoreCommitExactly(activeBranchState.undoCommitSha!); 1368 const snapshots = [activeBranchState.undoCommitSha!]; 1369 const data: RewindOpData = { v: RETENTION_VERSION, snapshots, current: 0 }; 1370 if (restore.undoCommitSha) { 1371 data.snapshots.push(restore.undoCommitSha); 1372 data.undo = 1; 1373 } 1374 appendRewindOp(ctx, data); 1375 notify(ctx, "Files restored to before last rewind"); 1376 await reconstructState(ctx); 1377 return { cancel: true }; 1378 } 1379 1380 if (choice === "Keep current files") { 1381 pendingTreeState = { currentCommitSha: await ensureSnapshotForCurrentWorktree() }; 1382 return; 1383 } 1384 1385 if (!targetCommitSha) { 1386 notify(ctx, "Exact file rewind is only available for user, assistant, compaction, and summary nodes", "error"); 1387 return { cancel: true }; 1388 } 1389 1390 const restore = await restoreCommitExactly(targetCommitSha); 1391 pendingTreeState = { 1392 currentCommitSha: targetCommitSha, 1393 undoCommitSha: restore.undoCommitSha, 1394 }; 1395 notify(ctx, "Files restored to rewind point"); 1396 }); 1397 }