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).',
'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.':

View file

@ -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.':

View file

@ -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.':

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).',
'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 naffecte 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.':

View file

@ -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.':

View file

@ -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.':

View file

@ -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.':

View file

@ -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.':

View file

@ -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.':

View file

@ -420,6 +420,17 @@ export function RewindSelector({
</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>

View file

@ -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', () => {

View file

@ -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);