diff options
| author | Yuki Okita <[email protected]> | 2025-08-20 10:55:47 +0900 |
|---|---|---|
| committer | GitHub <[email protected]> | 2025-08-20 01:55:47 +0000 |
| commit | 21c6480b65528a98ac0e1e3855f3c78c1f9b7cbe (patch) | |
| tree | 5555ec429209e87e0c21483c9e5fddd53ac01dbc /packages/cli/src | |
| parent | 1049d388451120587a8643a401fd71430a8cd5fe (diff) | |
Refac: Centralize storage file management (#4078)
Co-authored-by: Taylor Mullen <[email protected]>
Diffstat (limited to 'packages/cli/src')
21 files changed, 226 insertions, 142 deletions
diff --git a/packages/cli/src/commands/mcp/list.test.ts b/packages/cli/src/commands/mcp/list.test.ts index daf2e3d7..f3eb72ea 100644 --- a/packages/cli/src/commands/mcp/list.test.ts +++ b/packages/cli/src/commands/mcp/list.test.ts @@ -11,9 +11,27 @@ import { loadExtensions } from '../../config/extension.js'; import { createTransport } from '@google/gemini-cli-core'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; -vi.mock('../../config/settings.js'); -vi.mock('../../config/extension.js'); -vi.mock('@google/gemini-cli-core'); +vi.mock('../../config/settings.js', () => ({ + loadSettings: vi.fn(), +})); +vi.mock('../../config/extension.js', () => ({ + loadExtensions: vi.fn(), +})); +vi.mock('@google/gemini-cli-core', () => ({ + createTransport: vi.fn(), + MCPServerStatus: { + CONNECTED: 'CONNECTED', + CONNECTING: 'CONNECTING', + DISCONNECTED: 'DISCONNECTED', + }, + Storage: vi.fn().mockImplementation((_cwd: string) => ({ + getGlobalSettingsPath: () => '/tmp/gemini/settings.json', + getWorkspaceSettingsPath: () => '/tmp/gemini/workspace-settings.json', + getProjectTempDir: () => '/test/home/.gemini/tmp/mocked_hash', + })), + GEMINI_CONFIG_DIR: '.gemini', + getErrorMessage: (e: unknown) => (e instanceof Error ? e.message : String(e)), +})); vi.mock('@modelcontextprotocol/sdk/client/index.js'); const mockedLoadSettings = loadSettings as vi.Mock; diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 727f6fe1..c9426379 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -6,7 +6,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import * as os from 'os'; -import * as fs from 'fs'; import * as path from 'path'; import { ShellTool, EditTool, WriteFileTool } from '@google/gemini-cli-core'; import { loadCliConfig, parseArguments } from './config.js'; @@ -19,6 +18,38 @@ vi.mock('./trustedFolders.js', () => ({ isWorkspaceTrusted: vi.fn(), })); +vi.mock('fs', async (importOriginal) => { + const actualFs = await importOriginal<typeof import('fs')>(); + const pathMod = await import('path'); + const mockHome = '/mock/home/user'; + const MOCK_CWD1 = process.cwd(); + const MOCK_CWD2 = pathMod.resolve(pathMod.sep, 'home', 'user', 'project'); + + const mockPaths = new Set([ + MOCK_CWD1, + MOCK_CWD2, + pathMod.resolve(pathMod.sep, 'cli', 'path1'), + pathMod.resolve(pathMod.sep, 'settings', 'path1'), + pathMod.join(mockHome, 'settings', 'path2'), + pathMod.join(MOCK_CWD2, 'cli', 'path2'), + pathMod.join(MOCK_CWD2, 'settings', 'path3'), + ]); + + return { + ...actualFs, + mkdirSync: vi.fn(), + writeFileSync: vi.fn(), + existsSync: vi.fn((p) => mockPaths.has(p.toString())), + statSync: vi.fn((p) => { + if (mockPaths.has(p.toString())) { + return { isDirectory: () => true } as unknown as import('fs').Stats; + } + return (actualFs as typeof import('fs')).statSync(p as unknown as string); + }), + realpathSync: vi.fn((p) => p), + }; +}); + vi.mock('os', async (importOriginal) => { const actualOs = await importOriginal<typeof os>(); return { @@ -1441,35 +1472,6 @@ describe('loadCliConfig folderTrust', () => { }); }); -vi.mock('fs', async () => { - const actualFs = await vi.importActual<typeof fs>('fs'); - const MOCK_CWD1 = process.cwd(); - const MOCK_CWD2 = path.resolve(path.sep, 'home', 'user', 'project'); - - const mockPaths = new Set([ - MOCK_CWD1, - MOCK_CWD2, - path.resolve(path.sep, 'cli', 'path1'), - path.resolve(path.sep, 'settings', 'path1'), - path.join(os.homedir(), 'settings', 'path2'), - path.join(MOCK_CWD2, 'cli', 'path2'), - path.join(MOCK_CWD2, 'settings', 'path3'), - ]); - - return { - ...actualFs, - existsSync: vi.fn((p) => mockPaths.has(p.toString())), - statSync: vi.fn((p) => { - if (mockPaths.has(p.toString())) { - return { isDirectory: () => true }; - } - // Fallback for other paths if needed, though the test should be specific. - return actualFs.statSync(p); - }), - realpathSync: vi.fn((p) => p), - }; -}); - describe('loadCliConfig with includeDirectories', () => { const originalArgv = process.argv; diff --git a/packages/cli/src/config/extension.test.ts b/packages/cli/src/config/extension.test.ts index 85852bd7..ece36916 100644 --- a/packages/cli/src/config/extension.test.ts +++ b/packages/cli/src/config/extension.test.ts @@ -10,7 +10,6 @@ import * as os from 'os'; import * as path from 'path'; import { EXTENSIONS_CONFIG_FILENAME, - EXTENSIONS_DIRECTORY_NAME, annotateActiveExtensions, loadExtensions, } from './extension.js'; @@ -23,6 +22,8 @@ vi.mock('os', async (importOriginal) => { }; }); +const EXTENSIONS_DIRECTORY_NAME = path.join('.gemini', 'extensions'); + describe('loadExtensions', () => { let tempWorkspaceDir: string; let tempHomeDir: string; diff --git a/packages/cli/src/config/extension.ts b/packages/cli/src/config/extension.ts index 1922f55a..46fd32fc 100644 --- a/packages/cli/src/config/extension.ts +++ b/packages/cli/src/config/extension.ts @@ -4,12 +4,15 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { MCPServerConfig, GeminiCLIExtension } from '@google/gemini-cli-core'; +import { + MCPServerConfig, + GeminiCLIExtension, + Storage, +} from '@google/gemini-cli-core'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; -export const EXTENSIONS_DIRECTORY_NAME = path.join('.gemini', 'extensions'); export const EXTENSIONS_CONFIG_FILENAME = 'gemini-extension.json'; export interface Extension { @@ -43,7 +46,8 @@ export function loadExtensions(workspaceDir: string): Extension[] { } function loadExtensionsFromDir(dir: string): Extension[] { - const extensionsDir = path.join(dir, EXTENSIONS_DIRECTORY_NAME); + const storage = new Storage(dir); + const extensionsDir = storage.getExtensionsDir(); if (!fs.existsSync(extensionsDir)) { return []; } diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 414caf11..3df98d95 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -11,6 +11,7 @@ import * as dotenv from 'dotenv'; import { GEMINI_CONFIG_DIR as GEMINI_DIR, getErrorMessage, + Storage, } from '@google/gemini-cli-core'; import stripJsonComments from 'strip-json-comments'; import { DefaultLight } from '../ui/themes/default-light.js'; @@ -20,8 +21,9 @@ import { Settings, MemoryImportFormat } from './settingsSchema.js'; export type { Settings, MemoryImportFormat }; export const SETTINGS_DIRECTORY_NAME = '.gemini'; -export const USER_SETTINGS_DIR = path.join(homedir(), SETTINGS_DIRECTORY_NAME); -export const USER_SETTINGS_PATH = path.join(USER_SETTINGS_DIR, 'settings.json'); + +export const USER_SETTINGS_PATH = Storage.getGlobalSettingsPath(); +export const USER_SETTINGS_DIR = path.dirname(USER_SETTINGS_PATH); export const DEFAULT_EXCLUDED_ENV_VARS = ['DEBUG', 'DEBUG_MODE']; export function getSystemSettingsPath(): string { @@ -37,10 +39,6 @@ export function getSystemSettingsPath(): string { } } -export function getWorkspaceSettingsPath(workspaceDir: string): string { - return path.join(workspaceDir, SETTINGS_DIRECTORY_NAME, 'settings.json'); -} - export type { DnsResolutionOrder } from './settingsSchema.js'; export enum SettingScope { @@ -269,7 +267,9 @@ export function loadEnvironment(settings?: Settings): void { // If no settings provided, try to load workspace settings for exclusions let resolvedSettings = settings; if (!resolvedSettings) { - const workspaceSettingsPath = getWorkspaceSettingsPath(process.cwd()); + const workspaceSettingsPath = new Storage( + process.cwd(), + ).getWorkspaceSettingsPath(); try { if (fs.existsSync(workspaceSettingsPath)) { const workspaceContent = fs.readFileSync( @@ -342,7 +342,9 @@ export function loadSettings(workspaceDir: string): LoadedSettings { // We expect homedir to always exist and be resolvable. const realHomeDir = fs.realpathSync(resolvedHomeDir); - const workspaceSettingsPath = getWorkspaceSettingsPath(workspaceDir); + const workspaceSettingsPath = new Storage( + workspaceDir, + ).getWorkspaceSettingsPath(); // Load system settings try { diff --git a/packages/cli/src/services/FileCommandLoader.test.ts b/packages/cli/src/services/FileCommandLoader.test.ts index 42d93074..9960a632 100644 --- a/packages/cli/src/services/FileCommandLoader.test.ts +++ b/packages/cli/src/services/FileCommandLoader.test.ts @@ -5,11 +5,7 @@ */ import * as path from 'node:path'; -import { - Config, - getProjectCommandsDir, - getUserCommandsDir, -} from '@google/gemini-cli-core'; +import { Config, Storage } from '@google/gemini-cli-core'; import mock from 'mock-fs'; import { FileCommandLoader } from './FileCommandLoader.js'; import { assert, vi } from 'vitest'; @@ -57,6 +53,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { await importOriginal<typeof import('@google/gemini-cli-core')>(); return { ...original, + Storage: original.Storage, isCommandAllowed: vi.fn(), ShellExecutionService: { execute: vi.fn(), @@ -86,7 +83,7 @@ describe('FileCommandLoader', () => { }); it('loads a single command from a file', async () => { - const userCommandsDir = getUserCommandsDir(); + const userCommandsDir = Storage.getUserCommandsDir(); mock({ [userCommandsDir]: { 'test.toml': 'prompt = "This is a test prompt"', @@ -127,7 +124,7 @@ describe('FileCommandLoader', () => { itif(process.platform !== 'win32')( 'loads commands from a symlinked directory', async () => { - const userCommandsDir = getUserCommandsDir(); + const userCommandsDir = Storage.getUserCommandsDir(); const realCommandsDir = '/real/commands'; mock({ [realCommandsDir]: { @@ -152,7 +149,7 @@ describe('FileCommandLoader', () => { itif(process.platform !== 'win32')( 'loads commands from a symlinked subdirectory', async () => { - const userCommandsDir = getUserCommandsDir(); + const userCommandsDir = Storage.getUserCommandsDir(); const realNamespacedDir = '/real/namespaced-commands'; mock({ [userCommandsDir]: { @@ -176,7 +173,7 @@ describe('FileCommandLoader', () => { ); it('loads multiple commands', async () => { - const userCommandsDir = getUserCommandsDir(); + const userCommandsDir = Storage.getUserCommandsDir(); mock({ [userCommandsDir]: { 'test1.toml': 'prompt = "Prompt 1"', @@ -191,7 +188,7 @@ describe('FileCommandLoader', () => { }); it('creates deeply nested namespaces correctly', async () => { - const userCommandsDir = getUserCommandsDir(); + const userCommandsDir = Storage.getUserCommandsDir(); mock({ [userCommandsDir]: { @@ -205,7 +202,7 @@ describe('FileCommandLoader', () => { const mockConfig = { getProjectRoot: vi.fn(() => '/path/to/project'), getExtensions: vi.fn(() => []), - } as unknown as Config; + } as Config; const loader = new FileCommandLoader(mockConfig); const commands = await loader.loadCommands(signal); expect(commands).toHaveLength(1); @@ -213,7 +210,7 @@ describe('FileCommandLoader', () => { }); it('creates namespaces from nested directories', async () => { - const userCommandsDir = getUserCommandsDir(); + const userCommandsDir = Storage.getUserCommandsDir(); mock({ [userCommandsDir]: { git: { @@ -232,8 +229,10 @@ describe('FileCommandLoader', () => { }); it('returns both user and project commands in order', async () => { - const userCommandsDir = getUserCommandsDir(); - const projectCommandsDir = getProjectCommandsDir(process.cwd()); + const userCommandsDir = Storage.getUserCommandsDir(); + const projectCommandsDir = new Storage( + process.cwd(), + ).getProjectCommandsDir(); mock({ [userCommandsDir]: { 'test.toml': 'prompt = "User prompt"', @@ -246,7 +245,7 @@ describe('FileCommandLoader', () => { const mockConfig = { getProjectRoot: vi.fn(() => process.cwd()), getExtensions: vi.fn(() => []), - } as unknown as Config; + } as Config; const loader = new FileCommandLoader(mockConfig); const commands = await loader.loadCommands(signal); @@ -284,7 +283,7 @@ describe('FileCommandLoader', () => { }); it('ignores files with TOML syntax errors', async () => { - const userCommandsDir = getUserCommandsDir(); + const userCommandsDir = Storage.getUserCommandsDir(); mock({ [userCommandsDir]: { 'invalid.toml': 'this is not valid toml', @@ -300,7 +299,7 @@ describe('FileCommandLoader', () => { }); it('ignores files that are semantically invalid (missing prompt)', async () => { - const userCommandsDir = getUserCommandsDir(); + const userCommandsDir = Storage.getUserCommandsDir(); mock({ [userCommandsDir]: { 'no_prompt.toml': 'description = "This file is missing a prompt"', @@ -316,7 +315,7 @@ describe('FileCommandLoader', () => { }); it('handles filename edge cases correctly', async () => { - const userCommandsDir = getUserCommandsDir(); + const userCommandsDir = Storage.getUserCommandsDir(); mock({ [userCommandsDir]: { 'test.v1.toml': 'prompt = "Test prompt"', @@ -338,7 +337,7 @@ describe('FileCommandLoader', () => { }); it('uses a default description if not provided', async () => { - const userCommandsDir = getUserCommandsDir(); + const userCommandsDir = Storage.getUserCommandsDir(); mock({ [userCommandsDir]: { 'test.toml': 'prompt = "Test prompt"', @@ -353,7 +352,7 @@ describe('FileCommandLoader', () => { }); it('uses the provided description', async () => { - const userCommandsDir = getUserCommandsDir(); + const userCommandsDir = Storage.getUserCommandsDir(); mock({ [userCommandsDir]: { 'test.toml': 'prompt = "Test prompt"\ndescription = "My test command"', @@ -368,7 +367,7 @@ describe('FileCommandLoader', () => { }); it('should sanitize colons in filenames to prevent namespace conflicts', async () => { - const userCommandsDir = getUserCommandsDir(); + const userCommandsDir = Storage.getUserCommandsDir(); mock({ [userCommandsDir]: { 'legacy:command.toml': 'prompt = "This is a legacy command"', @@ -388,7 +387,7 @@ describe('FileCommandLoader', () => { describe('Processor Instantiation Logic', () => { it('instantiates only DefaultArgumentProcessor if no {{args}} or !{} are present', async () => { - const userCommandsDir = getUserCommandsDir(); + const userCommandsDir = Storage.getUserCommandsDir(); mock({ [userCommandsDir]: { 'simple.toml': `prompt = "Just a regular prompt"`, @@ -403,7 +402,7 @@ describe('FileCommandLoader', () => { }); it('instantiates only ShellProcessor if {{args}} is present (but not !{})', async () => { - const userCommandsDir = getUserCommandsDir(); + const userCommandsDir = Storage.getUserCommandsDir(); mock({ [userCommandsDir]: { 'args.toml': `prompt = "Prompt with {{args}}"`, @@ -418,7 +417,7 @@ describe('FileCommandLoader', () => { }); it('instantiates ShellProcessor and DefaultArgumentProcessor if !{} is present (but not {{args}})', async () => { - const userCommandsDir = getUserCommandsDir(); + const userCommandsDir = Storage.getUserCommandsDir(); mock({ [userCommandsDir]: { 'shell.toml': `prompt = "Prompt with !{cmd}"`, @@ -433,7 +432,7 @@ describe('FileCommandLoader', () => { }); it('instantiates only ShellProcessor if both {{args}} and !{} are present', async () => { - const userCommandsDir = getUserCommandsDir(); + const userCommandsDir = Storage.getUserCommandsDir(); mock({ [userCommandsDir]: { 'both.toml': `prompt = "Prompt with {{args}} and !{cmd}"`, @@ -450,8 +449,10 @@ describe('FileCommandLoader', () => { describe('Extension Command Loading', () => { it('loads commands from active extensions', async () => { - const userCommandsDir = getUserCommandsDir(); - const projectCommandsDir = getProjectCommandsDir(process.cwd()); + const userCommandsDir = Storage.getUserCommandsDir(); + const projectCommandsDir = new Storage( + process.cwd(), + ).getProjectCommandsDir(); const extensionDir = path.join( process.cwd(), '.gemini/extensions/test-ext', @@ -485,7 +486,7 @@ describe('FileCommandLoader', () => { path: extensionDir, }, ]), - } as unknown as Config; + } as Config; const loader = new FileCommandLoader(mockConfig); const commands = await loader.loadCommands(signal); @@ -499,8 +500,10 @@ describe('FileCommandLoader', () => { }); it('extension commands have extensionName metadata for conflict resolution', async () => { - const userCommandsDir = getUserCommandsDir(); - const projectCommandsDir = getProjectCommandsDir(process.cwd()); + const userCommandsDir = Storage.getUserCommandsDir(); + const projectCommandsDir = new Storage( + process.cwd(), + ).getProjectCommandsDir(); const extensionDir = path.join( process.cwd(), '.gemini/extensions/test-ext', @@ -534,7 +537,7 @@ describe('FileCommandLoader', () => { path: extensionDir, }, ]), - } as unknown as Config; + } as Config; const loader = new FileCommandLoader(mockConfig); const commands = await loader.loadCommands(signal); @@ -641,7 +644,7 @@ describe('FileCommandLoader', () => { path: extensionDir2, }, ]), - } as unknown as Config; + } as Config; const loader = new FileCommandLoader(mockConfig); const commands = await loader.loadCommands(signal); @@ -677,7 +680,7 @@ describe('FileCommandLoader', () => { path: extensionDir, }, ]), - } as unknown as Config; + } as Config; const loader = new FileCommandLoader(mockConfig); const commands = await loader.loadCommands(signal); expect(commands).toHaveLength(0); @@ -709,7 +712,7 @@ describe('FileCommandLoader', () => { getExtensions: vi.fn(() => [ { name: 'a', version: '1.0.0', isActive: true, path: extensionDir }, ]), - } as unknown as Config; + } as Config; const loader = new FileCommandLoader(mockConfig); const commands = await loader.loadCommands(signal); @@ -742,7 +745,7 @@ describe('FileCommandLoader', () => { describe('Argument Handling Integration (via ShellProcessor)', () => { it('correctly processes a command with {{args}}', async () => { - const userCommandsDir = getUserCommandsDir(); + const userCommandsDir = Storage.getUserCommandsDir(); mock({ [userCommandsDir]: { 'shorthand.toml': @@ -774,7 +777,7 @@ describe('FileCommandLoader', () => { describe('Default Argument Processor Integration', () => { it('correctly processes a command without {{args}}', async () => { - const userCommandsDir = getUserCommandsDir(); + const userCommandsDir = Storage.getUserCommandsDir(); mock({ [userCommandsDir]: { 'model_led.toml': @@ -808,7 +811,7 @@ describe('FileCommandLoader', () => { describe('Shell Processor Integration', () => { it('instantiates ShellProcessor if {{args}} is present (even without shell trigger)', async () => { - const userCommandsDir = getUserCommandsDir(); + const userCommandsDir = Storage.getUserCommandsDir(); mock({ [userCommandsDir]: { 'args_only.toml': `prompt = "Hello {{args}}"`, @@ -821,7 +824,7 @@ describe('FileCommandLoader', () => { expect(ShellProcessor).toHaveBeenCalledWith('args_only'); }); it('instantiates ShellProcessor if the trigger is present', async () => { - const userCommandsDir = getUserCommandsDir(); + const userCommandsDir = Storage.getUserCommandsDir(); mock({ [userCommandsDir]: { 'shell.toml': `prompt = "Run this: ${SHELL_INJECTION_TRIGGER}echo hello}"`, @@ -835,7 +838,7 @@ describe('FileCommandLoader', () => { }); it('does not instantiate ShellProcessor if no triggers ({{args}} or !{}) are present', async () => { - const userCommandsDir = getUserCommandsDir(); + const userCommandsDir = Storage.getUserCommandsDir(); mock({ [userCommandsDir]: { 'regular.toml': `prompt = "Just a regular prompt"`, @@ -849,7 +852,7 @@ describe('FileCommandLoader', () => { }); it('returns a "submit_prompt" action if shell processing succeeds', async () => { - const userCommandsDir = getUserCommandsDir(); + const userCommandsDir = Storage.getUserCommandsDir(); mock({ [userCommandsDir]: { 'shell.toml': `prompt = "Run !{echo 'hello'}"`, @@ -876,7 +879,7 @@ describe('FileCommandLoader', () => { }); it('returns a "confirm_shell_commands" action if shell processing requires it', async () => { - const userCommandsDir = getUserCommandsDir(); + const userCommandsDir = Storage.getUserCommandsDir(); const rawInvocation = '/shell rm -rf /'; mock({ [userCommandsDir]: { @@ -910,7 +913,7 @@ describe('FileCommandLoader', () => { }); it('re-throws other errors from the processor', async () => { - const userCommandsDir = getUserCommandsDir(); + const userCommandsDir = Storage.getUserCommandsDir(); mock({ [userCommandsDir]: { 'shell.toml': `prompt = "Run !{something}"`, @@ -935,7 +938,7 @@ describe('FileCommandLoader', () => { ).rejects.toThrow('Something else went wrong'); }); it('assembles the processor pipeline in the correct order (Shell -> Default)', async () => { - const userCommandsDir = getUserCommandsDir(); + const userCommandsDir = Storage.getUserCommandsDir(); mock({ [userCommandsDir]: { // This prompt uses !{} but NOT {{args}}, so both processors should be active. diff --git a/packages/cli/src/services/FileCommandLoader.ts b/packages/cli/src/services/FileCommandLoader.ts index 2942a8b9..a405655a 100644 --- a/packages/cli/src/services/FileCommandLoader.ts +++ b/packages/cli/src/services/FileCommandLoader.ts @@ -9,11 +9,7 @@ import path from 'path'; import toml from '@iarna/toml'; import { glob } from 'glob'; import { z } from 'zod'; -import { - Config, - getProjectCommandsDir, - getUserCommandsDir, -} from '@google/gemini-cli-core'; +import { Config, Storage } from '@google/gemini-cli-core'; import { ICommandLoader } from './types.js'; import { CommandContext, @@ -130,11 +126,13 @@ export class FileCommandLoader implements ICommandLoader { private getCommandDirectories(): CommandDirectory[] { const dirs: CommandDirectory[] = []; + const storage = this.config?.storage ?? new Storage(this.projectRoot); + // 1. User commands - dirs.push({ path: getUserCommandsDir() }); + dirs.push({ path: Storage.getUserCommandsDir() }); // 2. Project commands (override user commands) - dirs.push({ path: getProjectCommandsDir(this.projectRoot) }); + dirs.push({ path: storage.getProjectCommandsDir() }); // 3. Extension commands (processed last to detect all conflicts) if (this.config) { diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx index d71d8371..01c6581c 100644 --- a/packages/cli/src/ui/App.tsx +++ b/packages/cli/src/ui/App.tsx @@ -742,7 +742,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { } }, [config, config.getGeminiMdFileCount]); - const logger = useLogger(); + const logger = useLogger(config.storage); useEffect(() => { const fetchUserMessages = async () => { diff --git a/packages/cli/src/ui/commands/chatCommand.test.ts b/packages/cli/src/ui/commands/chatCommand.test.ts index c7299883..88808c92 100644 --- a/packages/cli/src/ui/commands/chatCommand.test.ts +++ b/packages/cli/src/ui/commands/chatCommand.test.ts @@ -67,11 +67,14 @@ describe('chatCommand', () => { mockContext = createMockCommandContext({ services: { config: { - getProjectTempDir: () => '/tmp/gemini', + getProjectRoot: () => '/project/root', getGeminiClient: () => ({ getChat: mockGetChat, }) as unknown as GeminiClient, + storage: { + getProjectTempDir: () => '/project/root/.gemini/tmp/mockhash', + }, }, logger: { saveCheckpoint: mockSaveCheckpoint, diff --git a/packages/cli/src/ui/commands/chatCommand.ts b/packages/cli/src/ui/commands/chatCommand.ts index 1c9029a9..fdd174ea 100644 --- a/packages/cli/src/ui/commands/chatCommand.ts +++ b/packages/cli/src/ui/commands/chatCommand.ts @@ -28,7 +28,8 @@ const getSavedChatTags = async ( context: CommandContext, mtSortDesc: boolean, ): Promise<ChatDetail[]> => { - const geminiDir = context.services.config?.getProjectTempDir(); + const cfg = context.services.config; + const geminiDir = cfg?.storage?.getProjectTempDir(); if (!geminiDir) { return []; } diff --git a/packages/cli/src/ui/commands/ideCommand.test.ts b/packages/cli/src/ui/commands/ideCommand.test.ts index f61df287..104eafb3 100644 --- a/packages/cli/src/ui/commands/ideCommand.test.ts +++ b/packages/cli/src/ui/commands/ideCommand.test.ts @@ -20,7 +20,14 @@ import * as core from '@google/gemini-cli-core'; vi.mock('child_process'); vi.mock('glob'); -vi.mock('@google/gemini-cli-core'); +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const original = await importOriginal<typeof core>(); + return { + ...original, + getOauthClient: vi.fn(original.getOauthClient), + getIdeInstaller: vi.fn(original.getIdeInstaller), + }; +}); describe('ideCommand', () => { let mockContext: CommandContext; diff --git a/packages/cli/src/ui/commands/restoreCommand.test.ts b/packages/cli/src/ui/commands/restoreCommand.test.ts index 23f71499..b9ecc139 100644 --- a/packages/cli/src/ui/commands/restoreCommand.test.ts +++ b/packages/cli/src/ui/commands/restoreCommand.test.ts @@ -39,7 +39,10 @@ describe('restoreCommand', () => { mockConfig = { getCheckpointingEnabled: vi.fn().mockReturnValue(true), - getProjectTempDir: vi.fn().mockReturnValue(geminiTempDir), + storage: { + getProjectTempCheckpointsDir: vi.fn().mockReturnValue(checkpointsDir), + getProjectTempDir: vi.fn().mockReturnValue(geminiTempDir), + }, getGeminiClient: vi.fn().mockReturnValue({ setHistory: mockSetHistory, }), @@ -77,7 +80,9 @@ describe('restoreCommand', () => { describe('action', () => { it('should return an error if temp dir is not found', async () => { - vi.mocked(mockConfig.getProjectTempDir).mockReturnValue(''); + vi.mocked( + mockConfig.storage.getProjectTempCheckpointsDir, + ).mockReturnValue(''); expect( await restoreCommand(mockConfig)?.action?.(mockContext, ''), @@ -219,7 +224,7 @@ describe('restoreCommand', () => { describe('completion', () => { it('should return an empty array if temp dir is not found', async () => { - vi.mocked(mockConfig.getProjectTempDir).mockReturnValue(''); + vi.mocked(mockConfig.storage.getProjectTempDir).mockReturnValue(''); const command = restoreCommand(mockConfig); expect(await command?.completion?.(mockContext, '')).toEqual([]); diff --git a/packages/cli/src/ui/commands/restoreCommand.ts b/packages/cli/src/ui/commands/restoreCommand.ts index 84259288..f34cdf25 100644 --- a/packages/cli/src/ui/commands/restoreCommand.ts +++ b/packages/cli/src/ui/commands/restoreCommand.ts @@ -22,9 +22,7 @@ async function restoreAction( const { config, git: gitService } = services; const { addItem, loadHistory } = ui; - const checkpointDir = config?.getProjectTempDir() - ? path.join(config.getProjectTempDir(), 'checkpoints') - : undefined; + const checkpointDir = config?.storage.getProjectTempCheckpointsDir(); if (!checkpointDir) { return { @@ -125,9 +123,7 @@ async function completion( ): Promise<string[]> { const { services } = context; const { config } = services; - const checkpointDir = config?.getProjectTempDir() - ? path.join(config.getProjectTempDir(), 'checkpoints') - : undefined; + const checkpointDir = config?.storage.getProjectTempCheckpointsDir(); if (!checkpointDir) { return []; } diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 99a59c34..02c25bd8 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -81,7 +81,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({ const [cursorPosition, setCursorPosition] = useState<[number, number]>([ 0, 0, ]); - const shellHistory = useShellHistory(config.getProjectRoot()); + const shellHistory = useShellHistory(config.getProjectRoot(), config.storage); const historyData = shellHistory.history; const completion = useCommandCompletion( diff --git a/packages/cli/src/ui/hooks/shellCommandProcessor.test.ts b/packages/cli/src/ui/hooks/shellCommandProcessor.test.ts index 9c13c8ec..8a37dde0 100644 --- a/packages/cli/src/ui/hooks/shellCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/shellCommandProcessor.test.ts @@ -17,15 +17,10 @@ import { const mockIsBinary = vi.hoisted(() => vi.fn()); const mockShellExecutionService = vi.hoisted(() => vi.fn()); -vi.mock('@google/gemini-cli-core', async (importOriginal) => { - const original = - await importOriginal<typeof import('@google/gemini-cli-core')>(); - return { - ...original, - ShellExecutionService: { execute: mockShellExecutionService }, - isBinary: mockIsBinary, - }; -}); +vi.mock('@google/gemini-cli-core', () => ({ + ShellExecutionService: { execute: mockShellExecutionService }, + isBinary: mockIsBinary, +})); vi.mock('fs'); vi.mock('os'); vi.mock('crypto'); diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 4e70eab7..44a3c2fa 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -16,6 +16,7 @@ import { makeSlashCommandEvent, SlashCommandStatus, ToolConfirmationOutcome, + Storage, } from '@google/gemini-cli-core'; import { useSessionStats } from '../contexts/SessionContext.js'; import { runExitCleanup } from '../../utils/cleanup.js'; @@ -82,11 +83,14 @@ export const useSlashCommandProcessor = ( if (!config?.getProjectRoot()) { return; } - return new GitService(config.getProjectRoot()); + return new GitService(config.getProjectRoot(), config.storage); }, [config]); const logger = useMemo(() => { - const l = new Logger(config?.getSessionId() || ''); + const l = new Logger( + config?.getSessionId() || '', + config?.storage ?? new Storage(process.cwd()), + ); // The logger's initialize is async, but we can create the instance // synchronously. Commands that use it will await its initialization. return l; diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 99b727b6..abfe28c7 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -105,13 +105,14 @@ export const useGeminiStream = ( useStateAndRef<HistoryItemWithoutId | null>(null); const processedMemoryToolsRef = useRef<Set<string>>(new Set()); const { startNewPrompt, getPromptCount } = useSessionStats(); - const logger = useLogger(); + const storage = config.storage; + const logger = useLogger(storage); const gitService = useMemo(() => { if (!config.getProjectRoot()) { return; } - return new GitService(config.getProjectRoot()); - }, [config]); + return new GitService(config.getProjectRoot(), storage); + }, [config, storage]); const [toolCalls, scheduleToolCalls, markToolsAsSubmitted] = useReactToolScheduler( @@ -877,9 +878,7 @@ export const useGeminiStream = ( ); if (restorableToolCalls.length > 0) { - const checkpointDir = config.getProjectTempDir() - ? path.join(config.getProjectTempDir(), 'checkpoints') - : undefined; + const checkpointDir = storage.getProjectTempCheckpointsDir(); if (!checkpointDir) { return; @@ -962,7 +961,15 @@ export const useGeminiStream = ( } }; saveRestorableToolCalls(); - }, [toolCalls, config, onDebugMessage, gitService, history, geminiClient]); + }, [ + toolCalls, + config, + onDebugMessage, + gitService, + history, + geminiClient, + storage, + ]); return { streamingState, diff --git a/packages/cli/src/ui/hooks/useLogger.ts b/packages/cli/src/ui/hooks/useLogger.ts index 879e9dd7..8833b642 100644 --- a/packages/cli/src/ui/hooks/useLogger.ts +++ b/packages/cli/src/ui/hooks/useLogger.ts @@ -5,16 +5,16 @@ */ import { useState, useEffect } from 'react'; -import { sessionId, Logger } from '@google/gemini-cli-core'; +import { sessionId, Logger, Storage } from '@google/gemini-cli-core'; /** * Hook to manage the logger instance. */ -export const useLogger = () => { +export const useLogger = (storage: Storage) => { const [logger, setLogger] = useState<Logger | null>(null); useEffect(() => { - const newLogger = new Logger(sessionId); + const newLogger = new Logger(sessionId, storage); /** * Start async initialization, no need to await. Using await slows down the * time from launch to see the gemini-cli prompt and it's better to not save @@ -26,7 +26,7 @@ export const useLogger = () => { setLogger(newLogger); }) .catch(() => {}); - }, []); + }, [storage]); return logger; }; diff --git a/packages/cli/src/ui/hooks/useShellHistory.test.ts b/packages/cli/src/ui/hooks/useShellHistory.test.ts index 3e2c2dd8..f0d8586c 100644 --- a/packages/cli/src/ui/hooks/useShellHistory.test.ts +++ b/packages/cli/src/ui/hooks/useShellHistory.test.ts @@ -11,9 +11,41 @@ import * as path from 'path'; import * as os from 'os'; import * as crypto from 'crypto'; -vi.mock('fs/promises'); +vi.mock('fs/promises', () => ({ + readFile: vi.fn(), + writeFile: vi.fn(), + mkdir: vi.fn(), +})); vi.mock('os'); vi.mock('crypto'); +vi.mock('fs', async (importOriginal) => { + const actualFs = await importOriginal<typeof import('fs')>(); + return { + ...actualFs, + mkdirSync: vi.fn(), + }; +}); +vi.mock('@google/gemini-cli-core', () => { + class Storage { + getProjectTempDir(): string { + return path.join('/test/home/', '.gemini', 'tmp', 'mocked_hash'); + } + getHistoryFilePath(): string { + return path.join( + '/test/home/', + '.gemini', + 'tmp', + 'mocked_hash', + 'shell_history', + ); + } + } + return { + isNodeError: (err: unknown): err is NodeJS.ErrnoException => + typeof err === 'object' && err !== null && 'code' in err, + Storage, + }; +}); const MOCKED_PROJECT_ROOT = '/test/project'; const MOCKED_HOME_DIR = '/test/home'; diff --git a/packages/cli/src/ui/hooks/useShellHistory.ts b/packages/cli/src/ui/hooks/useShellHistory.ts index 2e18dfbd..a0812f5b 100644 --- a/packages/cli/src/ui/hooks/useShellHistory.ts +++ b/packages/cli/src/ui/hooks/useShellHistory.ts @@ -7,9 +7,8 @@ import { useState, useEffect, useCallback } from 'react'; import * as fs from 'fs/promises'; import * as path from 'path'; -import { isNodeError, getProjectTempDir } from '@google/gemini-cli-core'; +import { isNodeError, Storage } from '@google/gemini-cli-core'; -const HISTORY_FILE = 'shell_history'; const MAX_HISTORY_LENGTH = 100; export interface UseShellHistoryReturn { @@ -20,9 +19,12 @@ export interface UseShellHistoryReturn { resetHistoryPosition: () => void; } -async function getHistoryFilePath(projectRoot: string): Promise<string> { - const historyDir = getProjectTempDir(projectRoot); - return path.join(historyDir, HISTORY_FILE); +async function getHistoryFilePath( + projectRoot: string, + configStorage?: Storage, +): Promise<string> { + const storage = configStorage ?? new Storage(projectRoot); + return storage.getHistoryFilePath(); } // Handle multiline commands @@ -67,20 +69,23 @@ async function writeHistoryFile( } } -export function useShellHistory(projectRoot: string): UseShellHistoryReturn { +export function useShellHistory( + projectRoot: string, + storage?: Storage, +): UseShellHistoryReturn { const [history, setHistory] = useState<string[]>([]); const [historyIndex, setHistoryIndex] = useState(-1); const [historyFilePath, setHistoryFilePath] = useState<string | null>(null); useEffect(() => { async function loadHistory() { - const filePath = await getHistoryFilePath(projectRoot); + const filePath = await getHistoryFilePath(projectRoot, storage); setHistoryFilePath(filePath); const loadedHistory = await readHistoryFile(filePath); setHistory(loadedHistory.reverse()); // Newest first } loadHistory(); - }, [projectRoot]); + }, [projectRoot, storage]); const addCommandToHistory = useCallback( (command: string) => { diff --git a/packages/cli/src/utils/cleanup.ts b/packages/cli/src/utils/cleanup.ts index 1200b6da..33ca9ddb 100644 --- a/packages/cli/src/utils/cleanup.ts +++ b/packages/cli/src/utils/cleanup.ts @@ -6,7 +6,7 @@ import { promises as fs } from 'fs'; import { join } from 'path'; -import { getProjectTempDir } from '@google/gemini-cli-core'; +import { Storage } from '@google/gemini-cli-core'; const cleanupFunctions: Array<(() => void) | (() => Promise<void>)> = []; @@ -26,7 +26,8 @@ export async function runExitCleanup() { } export async function cleanupCheckpoints() { - const tempDir = getProjectTempDir(process.cwd()); + const storage = new Storage(process.cwd()); + const tempDir = storage.getProjectTempDir(); const checkpointsDir = join(tempDir, 'checkpoints'); try { await fs.rm(checkpointsDir, { recursive: true, force: true }); |
