feat(rewind): add file restoration support to /rewind command#4064
feat(rewind): add file restoration support to /rewind command#4064doudouOUC wants to merge 6 commits into
Conversation
Previously /rewind only truncated conversation history — files modified by the assistant remained on disk. This adds a file-copy-based backup system (ported from claude-code's fileHistory) so users can optionally roll back file changes when rewinding. Core changes: - New FileHistoryService with snapshot/backup/restore lifecycle - trackEdit() called before each file write in edit and write-file tools - makeSnapshot() at each user turn boundary in client.ts - Three-phase RewindSelector UI: pick turn → choose restore option → execute - RestoreOption type: 'both' | 'conversation' | 'code' | 'cancel' Closes #3697 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)
There was a problem hiding this comment.
Pull request overview
Adds file checkpointing and restoration so /rewind can optionally roll back workspace file changes in addition to truncating conversation history. This is implemented via a new core FileHistoryService that snapshots tool-initiated edits per user turn and is surfaced in the CLI RewindSelector as a multi-step “what to restore” flow.
Changes:
- Introduce
FileHistoryService(backup/snapshot/restore + diff stats) and wire it intoConfigand core client turn boundaries. - Track edits before
edit/write_filetool writes so rewinds can restore/delete files to a chosen snapshot. - Extend CLI
/rewindUI to offer restore options (conversation/code/both) with async diff stats + new i18n strings.
Reviewed changes
Copilot reviewed 14 out of 14 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/core/src/services/fileHistoryService.ts | New snapshot/backup/restore service used to roll back files on rewind and compute diff stats. |
| packages/core/src/config/config.ts | Adds fileCheckpointingEnabled and lazy getFileHistoryService() singleton. |
| packages/core/src/core/client.ts | Creates snapshots at UserQuery boundaries (best-effort). |
| packages/core/src/tools/edit.ts | Calls trackEdit() before applying edits. |
| packages/core/src/tools/write-file.ts | Calls trackEdit() before writing file content. |
| packages/core/src/index.ts | Exports the new FileHistoryService from core entrypoint. |
| packages/cli/src/ui/types.ts | Adds promptId to user history items for snapshot lookup. |
| packages/cli/src/ui/hooks/useGeminiStream.ts | Stores prompt_id on user history items. |
| packages/cli/src/ui/contexts/UIActionsContext.tsx | Updates rewind confirm signature to include restore option. |
| packages/cli/src/ui/components/RewindSelector.tsx | Adds restore-option phase with async diff stats loading. |
| packages/cli/src/ui/components/DialogManager.tsx | Passes file checkpointing flags/service into RewindSelector. |
| packages/cli/src/ui/AppContainer.tsx | Implements restore option handling (restore files and/or truncate conversation). |
| packages/cli/src/i18n/locales/en.js | Adds new strings for restore options and restore result/errors. |
| packages/cli/src/i18n/locales/zh.js | Adds new strings for restore options and restore result/errors. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Add type assertion for promptId in addItem call — Omit<HistoryItem, 'id'> does not distribute over the union, so TypeScript rejects promptId. Uses the same `as HistoryItemWithoutId` pattern used elsewhere in the file. - Wrap makeSnapshot() in try/catch so file history never breaks chat flow. - Use i18n t() for file restore messages and add missing locale keys. 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)
vscode-ide-companion targets ES2022 which lacks Array.findLast. 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)
Code Coverage Summary
CLI Package - Full Text ReportCore Package - Full Text ReportFor detailed HTML reports, please see the 'coverage-reports-22.x-ubuntu-latest' artifact from the main CI run. |
- Add file restore i18n keys to all 8 locale files (zh-TW, ca, de, fr, ja, pt, ru were missing) - Update useGeminiStream test to expect promptId in user history item 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)
edit.test.ts and write-file.test.ts mock configs lacked the new getFileHistoryService method, causing trackEdit calls to throw. 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)
…r strings Allow users to press Esc/Ctrl+C to cancel during diff stats loading phase. Add three missing footer navigation strings to all 9 locale files. 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)
| if (isENOENT(e)) { | ||
| debugLogger.error(`FileHistory: Backup file not found: ${backupPath}`); | ||
| return; | ||
| } |
There was a problem hiding this comment.
[Critical] restoreBackup silently returns on ENOENT (backup file missing from disk), but the return type is Promise<void> — callers cannot distinguish success from "backup not found." applySnapshot (line 561) unconditionally pushes to filesChanged after this call, so the user sees "Restored N file(s)" when the file was actually unchanged.
| } | |
| return false; |
Also change the return type to Promise<boolean> and update the normal path to return true. Then in applySnapshot, only push to filesChanged when the return value is true.
— DeepSeek/deepseek-v4-pro via Qwen Code /review
| let fileRestoreError: string | undefined; | ||
| if (option === 'code' || option === 'both') { | ||
| const promptId = (userItem as HistoryItemUser).promptId; | ||
| if (promptId) { |
There was a problem hiding this comment.
[Critical] promptId is optional on HistoryItemUser (promptId?: string). When it is undefined (e.g., sessions created before this PR that are later resumed), the if (promptId) guard silently skips file restoration. The user selected "Restore code and conversation" but only the conversation was restored — no error, no warning.
| if (promptId) { | |
| const promptId = (userItem as HistoryItemUser).promptId; | |
| if (promptId) { | |
| // ...existing restore logic... | |
| } else if (option === 'code') { | |
| fileRestoreError = t('Cannot restore files: this turn was created before file checkpointing was available.'); | |
| } else { | |
| fileRestoreError = t('Cannot restore files: this turn was created before file checkpointing was available. Only the conversation will be restored.'); | |
| } |
— DeepSeek/deepseek-v4-pro via Qwen Code /review
| try { | ||
| backup = await createBackup(filePath, 1, this.sessionId); | ||
| } catch (error) { | ||
| debugLogger.error(`FileHistory: trackEdit failed: ${error}`); |
There was a problem hiding this comment.
[Critical] trackEdit catches createBackup failures and only logs to debugLogger — the user has no way to know backup creation failed. On subsequent /rewind, that file won't be restorable. Same pattern in makeSnapshot (line 379).
Consider emitting a user-visible warning via the session history so the user knows file checkpointing is broken for a specific file. At minimum, propagate to the tool result display.
— DeepSeek/deepseek-v4-pro via Qwen Code /review
| trackingPath: string, | ||
| ): BackupFileName | undefined { | ||
| for (const snapshot of this.state.snapshots) { | ||
| const backup = snapshot.trackedFileBackups[trackingPath]; |
There was a problem hiding this comment.
[Critical] getBackupFileNameFirstVersion returns undefined when a file's version-1 backup ref was in an evicted snapshot (MAX_SNAPSHOTS=100 evicts old snapshots at line 404). In getDiffStats (line 448), this causes the file to be silently omitted from results — the UI shows fewer changed files than actually exist. In hasAnyChanges, the file is silently skipped. In applySnapshot (line 540), it logs an error but skips restoration.
Track or reconstruct version-1 references even after eviction, or surface an explicit message when a file's full history has been evicted.
— DeepSeek/deepseek-v4-pro via Qwen Code /review
| debugLogger.debug(`FileHistory: Deleted ${filePath}`); | ||
| filesChanged.push(filePath); | ||
| } catch (e: unknown) { | ||
| if (!isENOENT(e)) throw e; |
There was a problem hiding this comment.
[Critical] Non-ENOENT errors from unlink (e.g., EACCES) are re-thrown and caught by the outer catch (line 567), which only logs to debugLogger. The file is not deleted, not added to filesChanged, and the caller has no way to know. The user sees an incomplete "Restored N file(s)" message with no indication a deletion failed.
Return an error-level result from applySnapshot so rewind() can surface failed deletions to the caller.
— DeepSeek/deepseek-v4-pro via Qwen Code /review
|
|
||
| // If conversation truncation failed but we're in 'both' mode, | ||
| // still fall through to show file restore messages below. | ||
| if (!canTruncate && option === 'conversation') return; |
There was a problem hiding this comment.
[Suggestion] When option === 'both' and canTruncate is false (target turn was compressed), file restoration has already succeeded but conversation truncation silently fails. The user sees the file restore success message without any indication that the conversation was NOT restored. Only option === 'conversation' has the early-return guard — 'both' falls through with no partial-success warning.
Add explicit handling: if !canTruncate && option === 'both', still show file restore results but also add a warning that the conversation could not be rewound.
— DeepSeek/deepseek-v4-pro via Qwen Code /review
| }; | ||
|
|
||
| this.state.snapshots.push(newSnapshot); | ||
| if (this.state.snapshots.length > MAX_SNAPSHOTS) { |
There was a problem hiding this comment.
[Suggestion] When old snapshots are evicted (slice(-MAX_SNAPSHOTS)), their backup files on disk are never cleaned up. Over long sessions this accumulates unbounded data in ~/.qwen/file-history/. Sensitive file contents (API keys, tokens) persist as plaintext backups indefinitely.
Unlink backup files from evicted snapshots during the eviction step.
— DeepSeek/deepseek-v4-pro via Qwen Code /review
| async (userItem: HistoryItem, option: RestoreOption) => { | ||
| // Close the selector immediately to prevent double submission | ||
| // while the async file restore is in progress. | ||
| setIsRewindSelectorOpen(false); |
There was a problem hiding this comment.
[Suggestion] setIsRewindSelectorOpen(false) is called synchronously at the top, before the async await config.getFileHistoryService().rewind(promptId). During the await, React re-renders and the user can type and send new messages, which triggers makeSnapshot() — racing with the in-progress rewind() and potentially corrupting snapshot state.
Move setIsRewindSelectorOpen(false) to after the async work completes and all messages are shown, or add a reentrancy guard (isRewinding ref) that blocks input during file restoration.
— DeepSeek/deepseek-v4-pro via Qwen Code /review
| export interface FileHistoryState { | ||
| snapshots: FileHistorySnapshot[]; | ||
| trackedFiles: Set<string>; | ||
| snapshotSequence: number; |
There was a problem hiding this comment.
[Suggestion] snapshotSequence field (also lines 248, 293, 407) is written in 3 places but never read anywhere. hasAnyChanges (line 484) and canRestore (line 428) are public methods with no callers in the entire codebase. These are dead code inherited from upstream — remove unused methods and the unused field to reduce API surface and maintenance burden.
— DeepSeek/deepseek-v4-pro via Qwen Code /review
|
|
||
| if (originalStats.mtimeMs < backupStats.mtimeMs) return false; | ||
|
|
||
| try { |
There was a problem hiding this comment.
[Suggestion] checkOriginFileChanged has an mtime-based short-circuit: if (originalStats.mtimeMs < backupStats.mtimeMs) return false. If a file's mtime is manually set back (e.g., git checkout of an old version preserving old mtime), the content may differ but this early return skips the content comparison. Consider removing the mtime short-circuit or replacing it with a weaker equality check, since mtime is not a reliable indicator of content identity.
— DeepSeek/deepseek-v4-pro via Qwen Code /review
Summary
Closes #3697
Previously
/rewindonly truncated conversation history — files modified by the assistant remained on disk, requiring manualgit checkoutto undo. This PR adds a file-copy-based backup system (ported from claude-code'sfileHistory) so users can optionally roll back file changes when rewinding.Core Service (
fileHistoryService.ts)trackEdit()saves a copy of each file before the first tool-initiated write;makeSnapshot()captures per-turn file state at each user turn boundaryrewind(promptId)compares current files to the target snapshot and restores/deletes as needed+insertions -deletionsfor the RewindSelector UI viadiff.diffLines~/.qwen/file-history/{sessionId}/{sha256hash}@v{version}Tool Integration
edit.tsandwrite-file.tscalltrackEdit()before each file write (3-4 lines each)client.tscallsmakeSnapshot()at eachUserQueryturn boundary (wrapped in try/catch so file history never breaks the core chat flow)Config
fileCheckpointingEnabledparameter (default:truefor interactive,falsefor SDK mode)FileHistoryServicesingleton onConfigRewindSelector UI (3-phase flow)
+N -N in M filesdetail)fileCheckpointingEnabledis falseOther changes
HistoryItemUser.promptIdfor snapshot lookup during rewindhandleRewindConfirmacceptsRestoreOption, handles file restore before conversation truncationTest plan
/rewind→ should see restore options with diff stats🤖 Generated with Qwen Code