{"slug": "the-rollback-in-gemini-cli-restored-an-empty-directory-and-the-test-for-it", "title": "The rollback in gemini-cli restored an empty directory, and the test for it passed", "summary": "A developer discovered that the rollback mechanism in Google's gemini-cli terminal agent restores an empty directory instead of the previous extension version, due to a missing backup step before mutation. The existing test passed because it only verified the copy call occurred, not that the source directory contained data. The developer proposed a fix that copies the extension to a temp directory before mutation and uses a flag to ensure rollback only runs when a real backup exists.", "body_md": "I was reading gemini-cli's extension update path for unrelated reasons and found that its rollback restores nothing. Not \"restores the wrong thing\" — restores an empty directory, every time, by construction.\n\ngemini-cli is Google's terminal agent, Apache-2.0. The code is in `packages/cli/src/config/extensions/update.ts`\n\n.\n\nThe shape is the one everybody writes:\n\n``` js\nconst tempDir = await ExtensionStorage.createTmpDir();\ntry {\n  // load previous config, then install the new version over extension.path\n  await extensionManager.installOrUpdateExtension(/* ... */);\n  // dispatch UPDATED or UPDATED_NEEDS_RESTART\n} catch (e) {\n  await copyExtension(tempDir, extension.path);\n} finally {\n  await fs.promises.rm(tempDir, { recursive: true, force: true });\n}\n```\n\n(trimmed; the error handling and the state dispatch have more in them than this)\n\nRead the `catch`\n\non its own and it's obviously correct: something failed, put the old extension back from the temp dir. Read it against the line above it and the temp dir is a directory that `createTmpDir`\n\nmade and nobody ever wrote to. The update mutates `extension.path`\n\nin place. Nothing copies the current install into `tempDir`\n\nfirst. So the recovery path takes an empty directory and copies it over the half-updated extension, which is worse than the failure it was catching.\n\nThere was already a test for this. It passed.\n\n```\nexpect(copyExtension).toHaveBeenCalledWith('/tmp/mock-dir', extension.path);\n```\n\nThat assertion is true. It was always true. `copyExtension`\n\nreally is called, with the temp dir as source and the extension path as destination, exactly as the rollback intends. The call happened. The test verified the call happened.\n\nWhat no assertion covered was whether the source had anything in it.\n\nThat's a specific and repeatable blind spot, and I don't think it's a mocking mistake so much as a consequence of how you write a test for a recovery path at all. You mock the filesystem because you don't want a real one. Once `copyExtension`\n\nis a mock, the only observable thing left is the call itself: name, arguments, order. The contents of a directory stop existing as a concept inside the test. So you assert on what's still visible, and it feels like coverage, because a test named \"restores on failure\" is green and there is a real assertion under it.\n\nThe missing assertion is an ordering one. Not \"was `copyExtension`\n\ncalled with these arguments\" but \"was something copied *into* `tempDir`\n\nbefore the update touched `extension.path`\n\n\". That one fails on the code as written, and it fails for the actual reason.\n\nCopy the extension into `tempDir`\n\nbefore any mutation, and carry a `backedUp`\n\nflag so the rollback only restores when there is a real backup to restore:\n\n```\nif (backedUp) {\n  await copyExtension(tempDir, extension.path);\n}\n```\n\nThe flag isn't decoration. Without it, a backup that itself failed halfway leaves you back in the original bug, restoring a partial directory over an installation that was in better shape than what replaced it. Rollback should decline to run rather than run on nothing, and a boolean is the cheapest way to say that.\n\nThe tests change from asserting one call to asserting both operations in sequence, which is what makes the regression catchable later: if someone moves the backup to after the mutation, the argument assertion still passes and the ordering assertion doesn't.\n\nThat's PR [#29166](https://github.com/google-gemini/gemini-cli/pull/29166), open at the time of writing. The before-state is readable on `main`\n\n.\n\nI went looking through my own code for the same shape afterward. The general form: a mock turns a stateful operation into a call record, and the test then asserts on the call record, which is the part that was never in doubt. Backup and restore is the easiest place to hit it, because backup is the step with no visible output. Nothing downstream reads the temp dir except the failure path, and the failure path is the thing you're mocking.\n\nThe question that surfaces it: if I delete the line I believe is doing the work, does any test go red? For this rollback, deleting the backup line changes nothing, because the backup line was never there.", "url": "https://wpnews.pro/news/the-rollback-in-gemini-cli-restored-an-empty-directory-and-the-test-for-it", "canonical_source": "https://dev.to/mahirhir/the-rollback-in-gemini-cli-restored-an-empty-directory-and-the-test-for-it-passed-1g3e", "published_at": "2026-09-03 10:06:42+00:00", "updated_at": "2026-09-03 10:53:33.997811+00:00", "lang": "en", "topics": ["developer-tools", "ai-tools"], "entities": ["Google", "gemini-cli", "PR #29166"], "alternates": {"html": "https://wpnews.pro/news/the-rollback-in-gemini-cli-restored-an-empty-directory-and-the-test-for-it", "markdown": "https://wpnews.pro/news/the-rollback-in-gemini-cli-restored-an-empty-directory-and-the-test-for-it.md", "text": "https://wpnews.pro/news/the-rollback-in-gemini-cli-restored-an-empty-directory-and-the-test-for-it.txt", "jsonld": "https://wpnews.pro/news/the-rollback-in-gemini-cli-restored-an-empty-directory-and-the-test-for-it.jsonld"}}