feat(rewind): add file restoration support to /rewind command - #4064
Conversation
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.
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. |
LaZzyMan
left a comment
There was a problem hiding this comment.
Review
This PR ports Claude Code's fileHistory backup system to qwen-code and adds a clean 3-phase UI to /rewind. The core algorithm is faithfully ported and the overall approach is sound. Two medium-severity correctness issues and a lack of unit tests for the new core service need attention before merging.
1. Race condition in concurrent file tracking (severity: medium · confidence: high)
When two tool calls edit different files at the same time (which can happen under maxConcurrency > 1), trackEdit may interleave writes to the same snapshot's backup map because it mutates the snapshot object in-place. The re-check guard before the write only protects against a duplicate write for the same file, not against a concurrent write for a different file arriving between the read and the write. The reference implementation avoids this by creating a new snapshot object via spread and committing it atomically. A class-based fix would be to spread the existing trackedFileBackups into a new object, write the new entry, then replace the snapshot reference.
2. Silent partial restore on "Restore code and conversation" when the turn was compressed (severity: medium · confidence: high)
If the user picks "Restore code and conversation" but the target turn was absorbed by context compression, the file restore runs first and succeeds, then the conversation truncation fails. At that point the function returns early — before the success/error messages are displayed. The user's files are silently reverted while the conversation stays intact and the rewind selector is already closed, leaving no feedback about what happened.
3. No unit tests for the new core service (severity: medium · confidence: very high)
FileHistoryService is 596 lines covering async I/O, version inheritance, ENOENT handling, and an mtime shortcut — all without a single test. The existing tool tests only stub trackEdit with a mock. Key paths worth covering: concurrent trackEdit for different files, makeSnapshot when a file is unchanged (version reuse), rewind when a backup file is missing from disk, and the option=both + compressed-turn edge case from issue 2 above.
Verdict
COMMENT — the two medium issues are fixable without redesign, but the file-restore / partial-rewind edge case in particular could leave users confused about what state their files are in.
wenshao
left a comment
There was a problem hiding this comment.
Type errors: 4 tsc compilation errors in InputPrompt.test.tsx — missing midInputGhostText, vimModeEnabled not on InputPromptProps, unsafe cast to UIState.
State persistence: FileHistoryService state (snapshots, trackedFiles) is purely in-memory. getSnapshots() / restoreFromSnapshots() exist but are never called. On session resume, all checkpoint data is lost — UI shows restore options but nothing restores.
Test coverage: The 564-line FileHistoryService has no test file. The new RewindSelector UI component has no test. handleRewindConfirm orchestration and makeSnapshot call in client.ts are untested.
Side effects: /summary loses thinking capability (no thinkingConfig passed to runSideQuery). nextSpeakerChecker defaults to fast model for conversation-termination decisions.
— DeepSeek/deepseek-v4-pro via Qwen Code /review
|
Thanks for the thorough review! Responses below: 1. Race condition in concurrent
|
6a13f67 to
c94e7ef
Compare
c94e7ef to
dd0eb50
Compare
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)
tanzhenxin
left a comment
There was a problem hiding this comment.
Review
Re-review after the ~15 fix/test commits since the prior approve. The headless-leak gate fix, the checkOriginFileChanged false-success fix, orphaned-backup cleanup, the loading state, and the new best-effort tests are all genuine improvements. However, re-checking the feature end-to-end against the current head surfaced two correctness defects that the earlier approve missed, flagged below for resolution before merge.
1. Code restore is permanently broken after /resume or restart (severity: high · confidence: very high)
Snapshots are only ever held in memory. The snapshot-restore entry points exist but have no production callers, and nothing serializes snapshots to the session — the service is recreated empty whenever the session changes. The upstream implementation this was ported from explicitly persists snapshots for resume support; that step was dropped in the port.
The consequence: after /resume or any restart, the rewind UI still lists prior turns, but selecting "restore code" looks the snapshot up in a fresh empty service, fails to find it, and reports "the selected snapshot was not found". In the combined code+conversation mode this also blocks the conversation truncation, so the user gets a hard failure for the headline action on every resumed session. This is a regression versus the feature being ported, not a hardening nicety.
2. Shell-command file changes are silently omitted from rewind (severity: high · confidence: very high)
File tracking is wired only into the edit and write-file tools. The shell tool has no integration, so anything run_shell_command mutates — rm, mv, sed -i, output redirection, git apply, npm run format, codegen scripts — is never backed up. /rewind then leaves those changes on disk while reporting success and without showing them in the diff summary. The feature is presented as rolling back the files the assistant modified, with no scoping to the edit/write tools, so this is a silent correctness gap rather than a documented limitation.
3. A failed backup during snapshotting can later restore stale content (severity: medium · confidence: high)
When the per-turn snapshot captures a tracked file but the backup copy fails (transient I/O error, permission, quota), the failure is swallowed and the new snapshot inherits the previous turn's backup. The snapshot then claims the file is at this turn's state when it is actually one turn newer. A later rewind to that point silently rolls the file back to older content, with no failure surfaced to the user (the failure happened at snapshot time, not restore time). This pattern is inherited from upstream, so it is not a port defect — but it is a genuine latent data-loss path and belongs in the PR's known limitations rather than being treated as out-of-scope hardening.
The orchestration that drives the three rewind modes also has no test coverage; the recent test commit only covered the lower-level service and tool hooks.
Verdict
COMMENT — not formally blocking, but the resume-persistence gap and the untracked shell-mutation gap are correctness/data-consistency defects that should be fixed, or at minimum explicitly scoped and documented, before this merges.
Two related issues from a /review pass:
1. Silent data loss in makeSnapshot inheritance: when the per-file
backup attempt threw inside makeSnapshot, the catch block left the
path missing from `trackedFileBackups`, and the inheritance loop
then copied the previous snapshot's backup into the new snapshot.
A later rewind to that snapshot would restore older content while
reporting success.
Now the catch records `{ failed: true, ... }` for the path. The
inheritance loop skips paths already present in trackedFileBackups,
so failed paths are no longer paved over by stale carryover. Both
applySnapshot and getDiffStats honor `failed` — rewind pushes the
path to filesFailed and the diff preview omits it.
2. Marketing/scope mismatch: the rewind UI offers "Restore code" but
the feature only tracks edits made via the `edit` and `write_file`
tools — shell-mediated changes (`sed -i`, `cp`, `rm`, `mv`,
`npm`, etc.) and out-of-tool manual edits are not captured.
Added a class-level JSDoc on FileHistoryService spelling out the
scope, and an inline footer in the restore-options panel:
"Rewinding does not affect files edited manually or via shell
commands." (matching the upstream claude-code MessageSelector
wording). New i18n key in all 9 locales.
Test added: trackEdit/makeSnapshot per-file failure path. Asserts
the new snapshot has `failed: true`, and that rewind to that snapshot
reports the file as filesFailed instead of silently restoring the
inherited stale backup.
Several small wins from the latest /review pass plus a UX mitigation for turns whose file-history snapshot is not present in memory (most often because the conversation came from a resumed session, but also when a turn has no captured edits): - AppContainer: wrap the "Cannot rewind to a turn that was compressed" error in t(); add the new key to all 9 locales. - RewindSelector: replace the inline `(+N -M in K file/files)` template literal with t() using two plural-aware keys; add to all 9 locales. - DiffStats.filesChanged: tighten from optional to required to match reality (every code path that returns a DiffStats sets it). Drops the `!.filesChanged!` non-null cascade in RewindSelector. - RewindSelector phase 2: when the option list does not contain code/both (i.e. no file-restore is actionable for this turn), show an explicit hint instead of leaving the user to guess why those options are missing. Same i18n key in all 9 locales. The mitigation hint covers the resumed-session case Tan raised (snapshots are not rehydrated by `/resume` today) without changing behavior — `getRestoreOptions` already gracefully degrades to conversation-only when `getDiffStats` returns undefined for a snapshot that is not in memory; we just surface the "why" to the user.
|
@tanzhenxin Thanks for the re-review. Quick status on the three points: 1. Code restore broken after
|
wenshao
left a comment
There was a problem hiding this comment.
No issues found. LGTM! ✅
The new file-copy-based backup system (FileHistoryService) is well-structured with careful error handling throughout (try/catch in tool integrations, per-file failure markers, partial failure reporting in RewindResult). The 3-phase RewindSelector UI is thoughtfully designed with proper loading states and key navigation. All new user-facing strings are properly internationalized across 9 locales. The test suite includes 27 tests covering the core service with good edge case coverage (disk full, inheritance, eviction, disabled service).
— DeepSeek/deepseek-v4-pro via Qwen Code /review
The `failed: true` marker added in d598383 was sticky: once set, the no-change optimization in `makeSnapshot` would copy the failed entry forward into every subsequent snapshot for as long as the file stayed unchanged. A single transient I/O error therefore poisoned `/rewind` for that file until the user happened to modify the content again. Add `!latestBackup.failed` to the no-change reuse guard so a failed entry is never copied forward — the next snapshot retries the backup, which either heals (when the underlying I/O has recovered) or honestly records another failed entry. New regression test (`does not carry a failed marker forward when the file is unchanged`): - Snapshot p1 with file content X - Sabotage the storage dir → p2's per-file backup throws → p2 records failed: true - Restore the storage dir; file still equals X - p3 must NOT copy p2's failed entry; it must retry createBackup and produce a fresh non-failed entry that allows rewind to p3 to succeed
本地真实测试报告Head: 10:27Z Critical(sticky
|
| 测试套件 | 结果 |
|---|---|
fileHistoryService.test.ts |
28/28 pass — 含 disk full / inheritance / eviction / disabled service / trackEdit best-effort / 新回归用例 |
tools/edit.test.ts |
76/76 pass — trackEdit 钩子 try/catch 集成 |
tools/write-file.test.ts |
46/46 pass — 同上 |
ui/components/MainContent.test.tsx |
7/7 pass — RewindSelector 3-phase 流程 |
ui/hooks/slashCommandProcessor.test.ts |
45/45 pass — handleRewindConfirm try/finally |
tsc --noEmit (core + cli) |
0 错 |
| GitHub CI | 全绿(Lint, Test ubuntu/macos/windows, CodeQL, Coverage) |
新回归测试单跑:
$ npx vitest run src/services/fileHistoryService.test.ts -t "does not carry a failed marker forward"
✓ Tests 1 passed | 27 skipped (28)
真实 E2E 备注
/rewind 是 3-phase 交互 UI(pick list → restore options → loading),且 fileCheckpointingEnabled 在 -p 非交互模式默认 false——无法用 --prompt 驱动。真实可执行的端到端验证就是 fileHistoryService.test.ts:用真实 fs/promises 跑 write/read/rm/mkdir/snapshot/rewind 全流程,sabotage storage dir 触发真实 I/O 错误,不是 mock。28 条全过即覆盖完毕。
仍开着的 Suggestion
10:27Z 那条 L422 的 Suggestion(把 error reason 附在 FileHistoryBackup 上,让 rewind 失败时能告诉用户为什么失败)没采纳——grep failedReason 在 service 文件里返回 0 行。这是观测性改进,不阻塞合入,可以 follow-up issue 跟。
LGTM ✅
wenshao
left a comment
There was a problem hiding this comment.
已发布详细测试报告(见 issue comment)。Critical(sticky failed marker)已修,回归测试 28/28 过,CI 全绿,tsc 0 错。LGTM ✅
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)returnsRewindResult { filesChanged, filesFailed }— partial failures are surfaced to the caller instead of being silently swallowed+insertions -deletionsfor the RewindSelector UI viadiff.diffLinestrackEditandmakeSnapshotuse global max-version scan to prevent backup filename collisions after rewind or snapshot eviction~/.qwen/file-history/{sessionId}/{sha256hash}@v{version}Tool Integration
edit.tsandwrite-file.tscalltrackEdit()before each file write, wrapped in try/catch so file history never breaks core tool operationsclient.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 and non-interactive-pmode)!params.sdkMode && params.interactive !== falseFileHistoryServicesingleton onConfigRewindSelector UI (3-phase flow)
+N -N in M filesdetail)fileCheckpointingEnabledis falseError handling
rewind()returnsRewindResult { filesChanged, filesFailed }— partial failures are reported with file nameshandleRewindConfirmusestry/finallyto ensure the selector closes on all exit paths (including early returns for compressed turns)Other changes
HistoryItemUser.promptIdfor snapshot lookup during rewindhandleRewindConfirmacceptsRestoreOption, handles file restore before conversation truncationTest plan
Unit tests
17 tests covering: trackEdit, makeSnapshot, version inheritance, rewind (success/deletion/partial failure/truncateHistory), snapshot eviction, getDiffStats, and disabled service.
Manual verification
1. Build and start
2. File restoration (golden path)
Add a comment to the top of README.mdAdd a space to the description in package.jsonEsc Escor type/rewind(+N -M in K files)git diffshould show no changes3. Test each restore option
4. Edge cases
create a file test-temp.txt, rewind → file should be deletedfileCheckpointingEnabled: false), rewind should only show Y/N confirm with no file restore options/clear, ask AI to edit a file, rewind → should work correctly (session reset)Automated verification results (tmux)
The following scenarios were verified programmatically via
tmux send-keysagainstnpm run devwith glm-5 model:Scenario 1: Restore code and conversation
Add a comment '// rewind test' to the top of README.mdEsc EscEnterEnter(on first option)head README.mdScenario 2: Restore conversation only
Enter(pre-filled prompt)Esc Esc→Enter→Down→EnterScenario 3: Restore code only
Remove the first line from README.mdEsc Esc→Enter→Down Down→EnterScenario 4: Esc navigation
Esc Esc→Enter→EscEsc人工验证视频:
Restore code and conversation:
https://github.com/user-attachments/assets/9425fa1f-5b86-445f-aebd-b5d9dd1cae10
Restore conversation only:
https://github.com/user-attachments/assets/07454200-60e8-4f75-857c-cda34bfe2d7f
Restore code only:
https://github.com/user-attachments/assets/ee6d2a96-dc75-4a8f-aa3d-577d340cb151
🤖 Generated with Qwen Code