From 44c6de5f24f714ea19cb0d1cc9509a68f5cf2e8b Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Thu, 10 Sep 2026 09:12:35 +0200 Subject: [PATCH 1/2] fix(cli): an interactive restore that ticks nothing restores nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `RestoreAllUseCase` forwarded an empty checkbox answer as `files: []`, which the restore reads as "no selection made", the same as a non-interactive run, and every drifted file was restored. An empty selection now ends the run with nothing restored; a run where nothing drifted, so no checkbox was shown, still restores the plugin files the picker never offers. Red first: the new test failed with `expected '{ "respectGitignore": false, …' to be 'EDITED OUTSIDE THE CLI'`. Closes #805 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011x4ms5qcGuZgYhCxfdHMUb AIDD-Session-Id: 4acc9a1c-19bc-4468-b8b6-e86644bcba60 --- .../global/restore-all-use-case.ts | 14 ++++++++-- .../restore-all-use-case.unit.test.ts | 28 +++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/cli/src/contexts/framework/application/global/restore-all-use-case.ts b/cli/src/contexts/framework/application/global/restore-all-use-case.ts index b64833e77..87f48d1a4 100644 --- a/cli/src/contexts/framework/application/global/restore-all-use-case.ts +++ b/cli/src/contexts/framework/application/global/restore-all-use-case.ts @@ -36,6 +36,16 @@ export class RestoreAllUseCase { if (manifest === null) throw new NoManifestError(); const effectiveFiles = interactive ? await this.promptForFiles(projectRoot) : undefined; + if (effectiveFiles !== undefined && effectiveFiles.length === 0) { + return { + totalRestored: 0, + totalKept: 0, + pluginNamesRestored: [], + errors, + unrestorable: [], + nativeOnlyToolIds: [], + }; + } const version = this.resolveVersion(manifest); const restoreResult = await this.runConfigRestore( projectRoot, @@ -73,12 +83,12 @@ export class RestoreAllUseCase { .filter((d) => d.status === "modified" || d.status === "deleted") .map((d) => d.relativePath) ); - if (driftedFiles.length === 0) return []; + if (driftedFiles.length === 0) return undefined; const selected = await this.prompter.checkbox( "Select files to restore:", driftedFiles.map((f) => ({ name: f, value: f })) ); - return selected.length === 0 ? [] : selected; + return selected; } private async runConfigRestore( diff --git a/cli/tests/contexts/framework/application/restore-all-use-case.unit.test.ts b/cli/tests/contexts/framework/application/restore-all-use-case.unit.test.ts index 56bdbe5cf..3ac9015d6 100644 --- a/cli/tests/contexts/framework/application/restore-all-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/restore-all-use-case.unit.test.ts @@ -136,6 +136,34 @@ describe("RestoreAllUseCase — the --force flag", () => { }); }); +describe("RestoreAllUseCase — an interactive run that ticks nothing", () => { + it("restores nothing: an empty selection is a decision, not the absence of one", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude"); + const manifest = await deps.manifestRepo.load(); + const tracked = manifest?.getToolFiles("claude") ?? []; + const trackedPath = join(PROJECT_ROOT, tracked[0].relativePath); + await deps.fs.writeFile(trackedPath, "EDITED OUTSIDE THE CLI"); + const prompter = new ScriptedPrompter([ScriptedPrompter.answer.checkbox([])]); + + const result = await makeRestoreAllUseCase( + deps, + new PluginDistributionReaderAdapter(deps.fs), + prompter + ).execute(PROJECT_ROOT, false, true); + + expect(deps.fs.getFile(trackedPath)).toBe("EDITED OUTSIDE THE CLI"); + expect(result).toStrictEqual({ + totalRestored: 0, + totalKept: 0, + pluginNamesRestored: [], + errors: [], + unrestorable: [], + nativeOnlyToolIds: [], + }); + }); +}); + describe("RestoreAllUseCase — plugin materialization", () => { it("restores a corrupted plugin file with exactly one materialization call (translate-mode: claude)", async () => { const deps = await buildUnitDeps(PROJECT_ROOT); From 908a85851307ccdfc6e03822efb9d64c89e0e42e Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Thu, 10 Sep 2026 09:17:00 +0200 Subject: [PATCH 2/2] test(cli): the picker tests say what an empty selection now means Two tests from #804 pinned the behaviour #805 fixes: an empty selection forwarded as `files: []`. They now assert that the run never delegates when nothing was ticked, and that a run with no drifted entry delegates with no selection at all. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011x4ms5qcGuZgYhCxfdHMUb AIDD-Session-Id: 4acc9a1c-19bc-4468-b8b6-e86644bcba60 --- .../application/restore-all-use-case.unit.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cli/tests/contexts/framework/application/restore-all-use-case.unit.test.ts b/cli/tests/contexts/framework/application/restore-all-use-case.unit.test.ts index 41ec6a80e..8baf4011e 100644 --- a/cli/tests/contexts/framework/application/restore-all-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/restore-all-use-case.unit.test.ts @@ -580,7 +580,7 @@ describe("RestoreAllUseCase — the interactive file picker", () => { expect(asked.map((o) => o.files)).toStrictEqual([[KEYBINDINGS]]); }); - it("forwards an empty selection as no file at all", async () => { + it("never delegates when the user ticked nothing: an empty selection is a decision", async () => { const deps = await vscodeProject(); await deps.fs.writeFile(join(PROJECT_ROOT, KEYBINDINGS), "[]"); const { asked, delegate } = recordingDelegate(); @@ -591,10 +591,10 @@ describe("RestoreAllUseCase — the interactive file picker", () => { true ); - expect(asked.map((o) => o.files)).toStrictEqual([[]]); + expect(asked).toStrictEqual([]); }); - it("asks nothing and selects nothing when no tracked entry drifted", async () => { + it("asks nothing and delegates with no selection when no tracked entry drifted", async () => { const deps = await vscodeProject(); const prompter = new CheckboxRecordingPrompter([KEYBINDINGS]); const { asked, delegate } = recordingDelegate(); @@ -602,6 +602,6 @@ describe("RestoreAllUseCase — the interactive file picker", () => { await restoreAllDelegatingTo(deps, prompter, delegate).execute(PROJECT_ROOT, false, true); expect(prompter.asks).toStrictEqual([]); - expect(asked.map((o) => o.files)).toStrictEqual([[]]); + expect(asked.map((o) => o.files)).toStrictEqual([undefined]); }); });