diff --git a/packages/cli/src/i18n/locales/ca.js b/packages/cli/src/i18n/locales/ca.js index 2ac1b5414..75ac273d6 100644 --- a/packages/cli/src/i18n/locales/ca.js +++ b/packages/cli/src/i18n/locales/ca.js @@ -150,6 +150,8 @@ export default { 'Codi restaurat, però la conversa no s’ha pogut retrocedir (cap client actiu).', 'Conversation rewound. Edit your prompt and press Enter to continue.': 'Conversa retrocedida. Edita la teva indicació i prem Retorn per continuar.', + 'Rewinding does not affect files edited manually or via shell commands.': + 'El retrocés no afecta els fitxers editats manualment o mitjançant comandes de shell.', 'Failed to restore {{count}} file(s): {{files}}': 'Error en restaurar {{count}} fitxer(s): {{files}}', 'Cannot restore files: this turn was created before file checkpointing was enabled.': diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index 581061c16..a6079d20e 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -129,6 +129,8 @@ export default { 'Code wiederhergestellt, aber Konversation konnte nicht zurückgespult werden (kein aktiver Client).', 'Conversation rewound. Edit your prompt and press Enter to continue.': 'Konversation zurückgespult. Bearbeite deinen Prompt und drücke Enter, um fortzufahren.', + 'Rewinding does not affect files edited manually or via shell commands.': + 'Das Zurückspulen wirkt sich nicht auf Dateien aus, die manuell oder per Shell-Befehl geändert wurden.', 'Failed to restore {{count}} file(s): {{files}}': '{{count}} Datei(en) konnten nicht wiederhergestellt werden: {{files}}', 'Cannot restore files: this turn was created before file checkpointing was enabled.': diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index df21b8209..ab19d1f1b 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -150,6 +150,8 @@ export default { 'Code restored, but conversation could not be rewound (no active client).', 'Conversation rewound. Edit your prompt and press Enter to continue.': 'Conversation rewound. Edit your prompt and press Enter to continue.', + 'Rewinding does not affect files edited manually or via shell commands.': + 'Rewinding does not affect files edited manually or via shell commands.', 'Failed to restore {{count}} file(s): {{files}}': 'Failed to restore {{count}} file(s): {{files}}', 'Cannot restore files: this turn was created before file checkpointing was enabled.': diff --git a/packages/cli/src/i18n/locales/fr.js b/packages/cli/src/i18n/locales/fr.js index 0424ed7ce..0292418a8 100644 --- a/packages/cli/src/i18n/locales/fr.js +++ b/packages/cli/src/i18n/locales/fr.js @@ -150,6 +150,8 @@ export default { 'Code restauré, mais la conversation n’a pas pu être ramenée en arrière (aucun client actif).', 'Conversation rewound. Edit your prompt and press Enter to continue.': 'Conversation ramenée en arrière. Modifiez votre invite et appuyez sur Entrée pour continuer.', + 'Rewinding does not affect files edited manually or via shell commands.': + 'Le retour en arrière n’affecte pas les fichiers édités manuellement ou via des commandes shell.', 'Failed to restore {{count}} file(s): {{files}}': 'Échec de la restauration de {{count}} fichier(s) : {{files}}', 'Cannot restore files: this turn was created before file checkpointing was enabled.': diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index fa89cb3f2..52acec9bd 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -110,6 +110,8 @@ export default { 'コードは復元されましたが、会話は巻き戻せませんでした(モデルクライアントがアクティブではありません)。', 'Conversation rewound. Edit your prompt and press Enter to continue.': '会話を巻き戻しました。プロンプトを編集して Enter キーで続行してください。', + 'Rewinding does not affect files edited manually or via shell commands.': + '巻き戻しは、手動で編集されたファイルや shell コマンドで変更されたファイルには影響しません。', 'Failed to restore {{count}} file(s): {{files}}': '{{count}} 個のファイルの復元に失敗しました:{{files}}', 'Cannot restore files: this turn was created before file checkpointing was enabled.': diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index c3459c918..411ade85d 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -144,6 +144,8 @@ export default { 'Código restaurado, mas a conversa não pôde ser retrocedida (sem cliente ativo).', 'Conversation rewound. Edit your prompt and press Enter to continue.': 'Conversa retrocedida. Edite seu prompt e pressione Enter para continuar.', + 'Rewinding does not affect files edited manually or via shell commands.': + 'O retrocesso não afeta arquivos editados manualmente ou por meio de comandos shell.', 'Failed to restore {{count}} file(s): {{files}}': 'Falha ao restaurar {{count}} arquivo(s): {{files}}', 'Cannot restore files: this turn was created before file checkpointing was enabled.': diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index f683e0455..43c63430e 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -153,6 +153,8 @@ export default { 'Код восстановлен, но разговор не удалось откатить (нет активного клиента).', 'Conversation rewound. Edit your prompt and press Enter to continue.': 'Разговор откатили. Отредактируйте подсказку и нажмите Enter, чтобы продолжить.', + 'Rewinding does not affect files edited manually or via shell commands.': + 'Откат не затрагивает файлы, отредактированные вручную или с помощью shell-команд.', 'Failed to restore {{count}} file(s): {{files}}': 'Не удалось восстановить {{count}} файл(ов): {{files}}', 'Cannot restore files: this turn was created before file checkpointing was enabled.': diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js index f919ae2ef..a95131d4b 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -133,6 +133,8 @@ export default { '程式碼已恢復,但對話無法回退(模型客戶端未啟用)。', 'Conversation rewound. Edit your prompt and press Enter to continue.': '對話已回退。修改提示後按 Enter 繼續。', + 'Rewinding does not affect files edited manually or via shell commands.': + '回退不會影響手動編輯或透過 shell 命令修改的檔案。', 'Failed to restore {{count}} file(s): {{files}}': '恢復 {{count}} 個檔案失敗:{{files}}', 'Cannot restore files: this turn was created before file checkpointing was enabled.': diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index d08e9695a..98b6c8c5a 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -144,6 +144,8 @@ export default { '代码已恢复,但对话无法回退(模型客户端未激活)。', 'Conversation rewound. Edit your prompt and press Enter to continue.': '对话已回退。修改你的提示后按回车继续。', + 'Rewinding does not affect files edited manually or via shell commands.': + '回退不会影响手工编辑或通过 shell 命令修改的文件。', 'Failed to restore {{count}} file(s): {{files}}': '恢复 {{count}} 个文件失败:{{files}}', 'Cannot restore files: this turn was created before file checkpointing was enabled.': diff --git a/packages/cli/src/ui/components/RewindSelector.tsx b/packages/cli/src/ui/components/RewindSelector.tsx index d22a3a196..0bd829d88 100644 --- a/packages/cli/src/ui/components/RewindSelector.tsx +++ b/packages/cli/src/ui/components/RewindSelector.tsx @@ -420,6 +420,17 @@ export function RewindSelector({ ); })} + {restoreOptions.some( + (o) => o.key === 'code' || o.key === 'both', + ) && ( + + + {t( + 'Rewinding does not affect files edited manually or via shell commands.', + )} + + + )} )} diff --git a/packages/core/src/services/fileHistoryService.test.ts b/packages/core/src/services/fileHistoryService.test.ts index 667373228..6ae47d07b 100644 --- a/packages/core/src/services/fileHistoryService.test.ts +++ b/packages/core/src/services/fileHistoryService.test.ts @@ -158,6 +158,40 @@ describe('FileHistoryService', () => { snapshots[0].trackedFileBackups[p1Key].backupFileName, ); }); + + // When a per-file backup attempt throws inside makeSnapshot, the new + // snapshot must NOT silently inherit the previous snapshot's backup + // and present it as the captured state of this turn — that would + // make a later rewind restore older content while reporting success. + // Instead the snapshot records a `failed: true` marker so rewind + // surfaces the file via filesFailed and getDiffStats omits it. + it('marks per-file backup failures and does not silently inherit', async () => { + const file = join(projectDir, 'a.txt'); + await writeFile(file, 'p1-content'); + + await service.makeSnapshot('p1'); + await service.trackEdit(file); + + // Modify the file and break the backup target (replace storageDir + // with a regular file → ENOTDIR inside `safeCopyFile`'s recursive + // mkdir). The next makeSnapshot's per-file backup attempt throws. + await writeFile(file, 'p2-content'); + await rm(storageDir, { recursive: true, force: true }); + await writeFile(storageDir, ''); + + await service.makeSnapshot('p2'); + + const p2Backups = service.getSnapshots()[1].trackedFileBackups; + const p2Backup = p2Backups['a.txt']; + expect(p2Backup).toBeDefined(); + expect(p2Backup.failed).toBe(true); + + // Rewind to p2 must report the file as failed, not silently + // restore p1-content as if it were the captured state of p2. + const result = await service.rewind('p2'); + expect(result.filesChanged).toEqual([]); + expect(result.filesFailed).toContain(file); + }); }); describe('rewind', () => { diff --git a/packages/core/src/services/fileHistoryService.ts b/packages/core/src/services/fileHistoryService.ts index 89901ca27..afb80b016 100644 --- a/packages/core/src/services/fileHistoryService.ts +++ b/packages/core/src/services/fileHistoryService.ts @@ -27,6 +27,13 @@ export interface FileHistoryBackup { backupFileName: BackupFileName; version: number; backupTime: Date; + // Set when makeSnapshot's per-file backup attempt threw. Distinguishes + // "we have a confirmed backup of this file at this snapshot" from + // "we tried to capture this file at this snapshot but failed (so the + // attached backup, if any, is older than this turn)". Rewind / diff + // surface failed paths via filesFailed instead of silently restoring + // stale content as if it were current. + failed?: boolean; } export interface FileHistorySnapshot { @@ -265,6 +272,17 @@ async function computeDiffStatsForFile( return { filesChanged, insertions, deletions }; } +/** + * Tracks file edits made through the assistant's `edit` and `write_file` + * tools so `/rewind` can roll the workspace back to the state at a chosen + * turn boundary. + * + * Scope (intentional, mirrors upstream claude-code): only files touched + * via `edit` and `write_file` are tracked. Changes made via + * `run_shell_command` (`sed -i`, `cp`, `mv`, `rm`, `npm` scripts, `git` + * apply, etc.) and any out-of-tool manual edits are NOT captured, and + * `/rewind` cannot restore them. + */ export class FileHistoryService { private state: FileHistoryState = { snapshots: [], @@ -395,6 +413,18 @@ export class FileHistoryService { debugLogger.error( `FileHistory: Failed to backup file ${trackingPath}: ${error}`, ); + // Record the failure rather than letting the inheritance loop + // silently copy the previous snapshot's backup — that would + // make a rewind to this snapshot restore the file to its + // pre-failure content as if it were the captured state of + // this turn. + const previous = mostRecent?.trackedFileBackups[trackingPath]; + trackedFileBackups[trackingPath] = { + backupFileName: previous?.backupFileName ?? null, + version: this.getMaxVersion(trackingPath) + 1, + backupTime: new Date(), + failed: true, + }; } }), ); @@ -469,6 +499,12 @@ export class FileHistoryService { const filePath = this.maybeExpandFilePath(trackingPath); const targetBackup = targetSnapshot.trackedFileBackups[trackingPath]; + // The backup attempt failed at the target snapshot; we cannot + // produce a meaningful diff against a content we never captured, + // so omit this file from the preview rather than show a diff + // versus an older inherited backup. + if (targetBackup?.failed) return null; + const backupFileName: BackupFileName | undefined = targetBackup ? targetBackup.backupFileName : this.getBackupFileNameFirstVersion(trackingPath); @@ -527,6 +563,14 @@ export class FileHistoryService { const filePath = this.maybeExpandFilePath(trackingPath); const targetBackup = targetSnapshot.trackedFileBackups[trackingPath]; + // makeSnapshot couldn't capture this file at the target turn. + // Surface it as failed instead of restoring the carried-over + // (older) backup as if it were the captured state. + if (targetBackup?.failed) { + filesFailed.push(filePath); + continue; + } + const backupFileName: BackupFileName | undefined = targetBackup ? targetBackup.backupFileName : this.getBackupFileNameFirstVersion(trackingPath);