mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-17 03:57:18 +00:00
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:
parent
91fc811eaa
commit
d598383384
12 changed files with 107 additions and 0 deletions
|
|
@ -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.':
|
||||
|
|
|
|||
|
|
@ -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.':
|
||||
|
|
|
|||
|
|
@ -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.':
|
||||
|
|
|
|||
|
|
@ -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.':
|
||||
|
|
|
|||
|
|
@ -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.':
|
||||
|
|
|
|||
|
|
@ -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.':
|
||||
|
|
|
|||
|
|
@ -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.':
|
||||
|
|
|
|||
|
|
@ -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.':
|
||||
|
|
|
|||
|
|
@ -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.':
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue