fix(rewind): mark per-file backup failures so rewind surfaces them

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.
This commit is contained in:
doudouOUC 2026-05-16 16:20:47 +08:00
parent 91fc811eaa
commit d598383384
12 changed files with 107 additions and 0 deletions

View file

@ -150,6 +150,8 @@ export default {
'Codi restaurat, però la conversa no sha pogut retrocedir (cap client actiu).', 'Codi restaurat, però la conversa no sha pogut retrocedir (cap client actiu).',
'Conversation rewound. Edit your prompt and press Enter to continue.': 'Conversation rewound. Edit your prompt and press Enter to continue.':
'Conversa retrocedida. Edita la teva indicació i prem Retorn per continuar.', '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}}': 'Failed to restore {{count}} file(s): {{files}}':
'Error en restaurar {{count}} fitxer(s): {{files}}', 'Error en restaurar {{count}} fitxer(s): {{files}}',
'Cannot restore files: this turn was created before file checkpointing was enabled.': 'Cannot restore files: this turn was created before file checkpointing was enabled.':

View file

@ -129,6 +129,8 @@ export default {
'Code wiederhergestellt, aber Konversation konnte nicht zurückgespult werden (kein aktiver Client).', 'Code wiederhergestellt, aber Konversation konnte nicht zurückgespult werden (kein aktiver Client).',
'Conversation rewound. Edit your prompt and press Enter to continue.': 'Conversation rewound. Edit your prompt and press Enter to continue.':
'Konversation zurückgespult. Bearbeite deinen Prompt und drücke Enter, um fortzufahren.', '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}}': 'Failed to restore {{count}} file(s): {{files}}':
'{{count}} Datei(en) konnten nicht wiederhergestellt werden: {{files}}', '{{count}} Datei(en) konnten nicht wiederhergestellt werden: {{files}}',
'Cannot restore files: this turn was created before file checkpointing was enabled.': 'Cannot restore files: this turn was created before file checkpointing was enabled.':

View file

@ -150,6 +150,8 @@ export default {
'Code restored, but conversation could not be rewound (no active client).', '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.':
'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}}':
'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.': 'Cannot restore files: this turn was created before file checkpointing was enabled.':

View file

@ -150,6 +150,8 @@ export default {
'Code restauré, mais la conversation na pas pu être ramenée en arrière (aucun client actif).', 'Code restauré, mais la conversation na pas pu être ramenée en arrière (aucun client actif).',
'Conversation rewound. Edit your prompt and press Enter to continue.': '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.', '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 naffecte pas les fichiers édités manuellement ou via des commandes shell.',
'Failed to restore {{count}} file(s): {{files}}': 'Failed to restore {{count}} file(s): {{files}}':
'Échec de la restauration de {{count}} fichier(s) : {{files}}', 'Échec de la restauration de {{count}} fichier(s) : {{files}}',
'Cannot restore files: this turn was created before file checkpointing was enabled.': 'Cannot restore files: this turn was created before file checkpointing was enabled.':

View file

@ -110,6 +110,8 @@ export default {
'コードは復元されましたが、会話は巻き戻せませんでした(モデルクライアントがアクティブではありません)。', 'コードは復元されましたが、会話は巻き戻せませんでした(モデルクライアントがアクティブではありません)。',
'Conversation rewound. Edit your prompt and press Enter to continue.': 'Conversation rewound. Edit your prompt and press Enter to continue.':
'会話を巻き戻しました。プロンプトを編集して Enter キーで続行してください。', '会話を巻き戻しました。プロンプトを編集して Enter キーで続行してください。',
'Rewinding does not affect files edited manually or via shell commands.':
'巻き戻しは、手動で編集されたファイルや shell コマンドで変更されたファイルには影響しません。',
'Failed to restore {{count}} file(s): {{files}}': 'Failed to restore {{count}} file(s): {{files}}':
'{{count}} 個のファイルの復元に失敗しました:{{files}}', '{{count}} 個のファイルの復元に失敗しました:{{files}}',
'Cannot restore files: this turn was created before file checkpointing was enabled.': 'Cannot restore files: this turn was created before file checkpointing was enabled.':

View file

@ -144,6 +144,8 @@ export default {
'Código restaurado, mas a conversa não pôde ser retrocedida (sem cliente ativo).', '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.': 'Conversation rewound. Edit your prompt and press Enter to continue.':
'Conversa retrocedida. Edite seu prompt e pressione Enter para continuar.', '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}}': 'Failed to restore {{count}} file(s): {{files}}':
'Falha ao restaurar {{count}} arquivo(s): {{files}}', 'Falha ao restaurar {{count}} arquivo(s): {{files}}',
'Cannot restore files: this turn was created before file checkpointing was enabled.': 'Cannot restore files: this turn was created before file checkpointing was enabled.':

View file

@ -153,6 +153,8 @@ export default {
'Код восстановлен, но разговор не удалось откатить (нет активного клиента).', 'Код восстановлен, но разговор не удалось откатить (нет активного клиента).',
'Conversation rewound. Edit your prompt and press Enter to continue.': 'Conversation rewound. Edit your prompt and press Enter to continue.':
'Разговор откатили. Отредактируйте подсказку и нажмите Enter, чтобы продолжить.', 'Разговор откатили. Отредактируйте подсказку и нажмите Enter, чтобы продолжить.',
'Rewinding does not affect files edited manually or via shell commands.':
'Откат не затрагивает файлы, отредактированные вручную или с помощью shell-команд.',
'Failed to restore {{count}} file(s): {{files}}': 'Failed to restore {{count}} file(s): {{files}}':
'Не удалось восстановить {{count}} файл(ов): {{files}}', 'Не удалось восстановить {{count}} файл(ов): {{files}}',
'Cannot restore files: this turn was created before file checkpointing was enabled.': 'Cannot restore files: this turn was created before file checkpointing was enabled.':

View file

@ -133,6 +133,8 @@ export default {
'程式碼已恢復,但對話無法回退(模型客戶端未啟用)。', '程式碼已恢復,但對話無法回退(模型客戶端未啟用)。',
'Conversation rewound. Edit your prompt and press Enter to continue.': 'Conversation rewound. Edit your prompt and press Enter to continue.':
'對話已回退。修改提示後按 Enter 繼續。', '對話已回退。修改提示後按 Enter 繼續。',
'Rewinding does not affect files edited manually or via shell commands.':
'回退不會影響手動編輯或透過 shell 命令修改的檔案。',
'Failed to restore {{count}} file(s): {{files}}': 'Failed to restore {{count}} file(s): {{files}}':
'恢復 {{count}} 個檔案失敗:{{files}}', '恢復 {{count}} 個檔案失敗:{{files}}',
'Cannot restore files: this turn was created before file checkpointing was enabled.': 'Cannot restore files: this turn was created before file checkpointing was enabled.':

View file

@ -144,6 +144,8 @@ export default {
'代码已恢复,但对话无法回退(模型客户端未激活)。', '代码已恢复,但对话无法回退(模型客户端未激活)。',
'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.':
'回退不会影响手工编辑或通过 shell 命令修改的文件。',
'Failed to restore {{count}} file(s): {{files}}': 'Failed to restore {{count}} file(s): {{files}}':
'恢复 {{count}} 个文件失败:{{files}}', '恢复 {{count}} 个文件失败:{{files}}',
'Cannot restore files: this turn was created before file checkpointing was enabled.': 'Cannot restore files: this turn was created before file checkpointing was enabled.':

View file

@ -420,6 +420,17 @@ export function RewindSelector({
</Box> </Box>
); );
})} })}
{restoreOptions.some(
(o) => o.key === 'code' || o.key === 'both',
) && (
<Box marginTop={1}>
<Text color={theme.text.secondary} dimColor>
{t(
'Rewinding does not affect files edited manually or via shell commands.',
)}
</Text>
</Box>
)}
</Box> </Box>
)} )}
</Box> </Box>

View file

@ -158,6 +158,40 @@ describe('FileHistoryService', () => {
snapshots[0].trackedFileBackups[p1Key].backupFileName, 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', () => { describe('rewind', () => {

View file

@ -27,6 +27,13 @@ export interface FileHistoryBackup {
backupFileName: BackupFileName; backupFileName: BackupFileName;
version: number; version: number;
backupTime: Date; 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 { export interface FileHistorySnapshot {
@ -265,6 +272,17 @@ async function computeDiffStatsForFile(
return { filesChanged, insertions, deletions }; 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 { export class FileHistoryService {
private state: FileHistoryState = { private state: FileHistoryState = {
snapshots: [], snapshots: [],
@ -395,6 +413,18 @@ export class FileHistoryService {
debugLogger.error( debugLogger.error(
`FileHistory: Failed to backup file ${trackingPath}: ${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 filePath = this.maybeExpandFilePath(trackingPath);
const targetBackup = targetSnapshot.trackedFileBackups[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 const backupFileName: BackupFileName | undefined = targetBackup
? targetBackup.backupFileName ? targetBackup.backupFileName
: this.getBackupFileNameFirstVersion(trackingPath); : this.getBackupFileNameFirstVersion(trackingPath);
@ -527,6 +563,14 @@ export class FileHistoryService {
const filePath = this.maybeExpandFilePath(trackingPath); const filePath = this.maybeExpandFilePath(trackingPath);
const targetBackup = targetSnapshot.trackedFileBackups[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 const backupFileName: BackupFileName | undefined = targetBackup
? targetBackup.backupFileName ? targetBackup.backupFileName
: this.getBackupFileNameFirstVersion(trackingPath); : this.getBackupFileNameFirstVersion(trackingPath);