diff options
Diffstat (limited to 'packages/cli')
| -rw-r--r-- | packages/cli/src/config/config.test.ts | 87 | ||||
| -rw-r--r-- | packages/cli/src/config/config.ts | 21 | ||||
| -rw-r--r-- | packages/cli/src/config/settings.test.ts | 42 | ||||
| -rw-r--r-- | packages/cli/src/config/settings.ts | 9 | ||||
| -rw-r--r-- | packages/cli/src/ui/App.tsx | 6 | ||||
| -rw-r--r-- | packages/cli/src/ui/commands/directoryCommand.test.tsx | 13 | ||||
| -rw-r--r-- | packages/cli/src/ui/commands/directoryCommand.tsx | 34 | ||||
| -rw-r--r-- | packages/cli/src/ui/commands/memoryCommand.test.ts | 4 | ||||
| -rw-r--r-- | packages/cli/src/ui/commands/memoryCommand.ts | 3 | ||||
| -rw-r--r-- | packages/cli/src/ui/commands/types.ts | 1 | ||||
| -rw-r--r-- | packages/cli/src/ui/hooks/slashCommandProcessor.ts | 3 | ||||
| -rw-r--r-- | packages/cli/src/utils/resolvePath.ts | 21 |
12 files changed, 240 insertions, 4 deletions
diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 431b1375..f5d0ddf8 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -6,6 +6,8 @@ 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 { loadCliConfig, parseArguments } from './config.js'; import { Settings } from './settings.js'; import { Extension } from './extension.js'; @@ -44,7 +46,7 @@ vi.mock('@google/gemini-cli-core', async () => { }, loadEnvironment: vi.fn(), loadServerHierarchicalMemory: vi.fn( - (cwd, debug, fileService, extensionPaths, _maxDirs) => + (cwd, dirs, debug, fileService, extensionPaths, _maxDirs) => Promise.resolve({ memoryContent: extensionPaths?.join(',') || '', fileCount: extensionPaths?.length || 0, @@ -487,6 +489,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => { await loadCliConfig(settings, extensions, 'session-id', argv); expect(ServerConfig.loadServerHierarchicalMemory).toHaveBeenCalledWith( expect.any(String), + [], false, expect.any(Object), [ @@ -1015,3 +1018,85 @@ describe('loadCliConfig ideModeFeature', () => { expect(config.getIdeModeFeature()).toBe(false); }); }); + +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; + const originalEnv = { ...process.env }; + + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); + process.env.GEMINI_API_KEY = 'test-api-key'; + vi.spyOn(process, 'cwd').mockReturnValue( + path.resolve(path.sep, 'home', 'user', 'project'), + ); + }); + + afterEach(() => { + process.argv = originalArgv; + process.env = originalEnv; + vi.restoreAllMocks(); + }); + + it('should combine and resolve paths from settings and CLI arguments', async () => { + const mockCwd = path.resolve(path.sep, 'home', 'user', 'project'); + process.argv = [ + 'node', + 'script.js', + '--include-directories', + `${path.resolve(path.sep, 'cli', 'path1')},${path.join(mockCwd, 'cli', 'path2')}`, + ]; + const argv = await parseArguments(); + const settings: Settings = { + includeDirectories: [ + path.resolve(path.sep, 'settings', 'path1'), + path.join(os.homedir(), 'settings', 'path2'), + path.join(mockCwd, 'settings', 'path3'), + ], + }; + const config = await loadCliConfig(settings, [], 'test-session', argv); + const expected = [ + mockCwd, + path.resolve(path.sep, 'cli', 'path1'), + path.join(mockCwd, 'cli', 'path2'), + path.resolve(path.sep, 'settings', 'path1'), + path.join(os.homedir(), 'settings', 'path2'), + path.join(mockCwd, 'settings', 'path3'), + ]; + expect(config.getWorkspaceContext().getDirectories()).toEqual( + expect.arrayContaining(expected), + ); + expect(config.getWorkspaceContext().getDirectories()).toHaveLength( + expected.length, + ); + }); +}); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 0395ac0f..d3d37c6a 100644 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -29,6 +29,7 @@ import { Settings } from './settings.js'; import { Extension, annotateActiveExtensions } from './extension.js'; import { getCliVersion } from '../utils/version.js'; import { loadSandboxConfig } from './sandboxConfig.js'; +import { resolvePath } from '../utils/resolvePath.js'; // Simple console logger for now - replace with actual logger if available const logger = { @@ -65,6 +66,7 @@ export interface CliArgs { ideModeFeature: boolean | undefined; proxy: string | undefined; includeDirectories: string[] | undefined; + loadMemoryFromIncludeDirectories: boolean | undefined; } export async function parseArguments(): Promise<CliArgs> { @@ -212,6 +214,12 @@ export async function parseArguments(): Promise<CliArgs> { // Handle comma-separated values dirs.flatMap((dir) => dir.split(',').map((d) => d.trim())), }) + .option('load-memory-from-include-directories', { + type: 'boolean', + description: + 'If true, when refreshing memory, GEMINI.md files should be loaded from all directories that are added. If false, GEMINI.md files should only be loaded from the primary working directory.', + default: false, + }) .version(await getCliVersion()) // This will enable the --version flag based on package.json .alias('v', 'version') .help() @@ -239,6 +247,7 @@ export async function parseArguments(): Promise<CliArgs> { // TODO: Consider if App.tsx should get memory via a server call or if Config should refresh itself. export async function loadHierarchicalGeminiMemory( currentWorkingDirectory: string, + includeDirectoriesToReadGemini: readonly string[] = [], debugMode: boolean, fileService: FileDiscoveryService, settings: Settings, @@ -264,6 +273,7 @@ export async function loadHierarchicalGeminiMemory( // Directly call the server function with the corrected path. return loadServerHierarchicalMemory( effectiveCwd, + includeDirectoriesToReadGemini, debugMode, fileService, extensionContextFilePaths, @@ -325,9 +335,14 @@ export async function loadCliConfig( ...settings.fileFiltering, }; + const includeDirectories = (settings.includeDirectories || []) + .map(resolvePath) + .concat((argv.includeDirectories || []).map(resolvePath)); + // Call the (now wrapper) loadHierarchicalGeminiMemory which calls the server's version const { memoryContent, fileCount } = await loadHierarchicalGeminiMemory( process.cwd(), + settings.loadMemoryFromIncludeDirectories ? includeDirectories : [], debugMode, fileService, settings, @@ -393,7 +408,11 @@ export async function loadCliConfig( embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL, sandbox: sandboxConfig, targetDir: process.cwd(), - includeDirectories: argv.includeDirectories, + includeDirectories, + loadMemoryFromIncludeDirectories: + argv.loadMemoryFromIncludeDirectories || + settings.loadMemoryFromIncludeDirectories || + false, debugMode, question: argv.promptInteractive || argv.prompt || '', fullContext: argv.allFiles || argv.all_files || false, diff --git a/packages/cli/src/config/settings.test.ts b/packages/cli/src/config/settings.test.ts index 4099e778..d0266720 100644 --- a/packages/cli/src/config/settings.test.ts +++ b/packages/cli/src/config/settings.test.ts @@ -112,6 +112,7 @@ describe('Settings Loading and Merging', () => { expect(settings.merged).toEqual({ customThemes: {}, mcpServers: {}, + includeDirectories: [], }); expect(settings.errors.length).toBe(0); }); @@ -145,6 +146,7 @@ describe('Settings Loading and Merging', () => { ...systemSettingsContent, customThemes: {}, mcpServers: {}, + includeDirectories: [], }); }); @@ -178,6 +180,7 @@ describe('Settings Loading and Merging', () => { ...userSettingsContent, customThemes: {}, mcpServers: {}, + includeDirectories: [], }); }); @@ -209,6 +212,7 @@ describe('Settings Loading and Merging', () => { ...workspaceSettingsContent, customThemes: {}, mcpServers: {}, + includeDirectories: [], }); }); @@ -246,6 +250,7 @@ describe('Settings Loading and Merging', () => { contextFileName: 'WORKSPACE_CONTEXT.md', customThemes: {}, mcpServers: {}, + includeDirectories: [], }); }); @@ -295,6 +300,7 @@ describe('Settings Loading and Merging', () => { allowMCPServers: ['server1', 'server2'], customThemes: {}, mcpServers: {}, + includeDirectories: [], }); }); @@ -616,6 +622,40 @@ describe('Settings Loading and Merging', () => { expect(settings.merged.mcpServers).toEqual({}); }); + it('should merge includeDirectories from all scopes', () => { + (mockFsExistsSync as Mock).mockReturnValue(true); + const systemSettingsContent = { + includeDirectories: ['/system/dir'], + }; + const userSettingsContent = { + includeDirectories: ['/user/dir1', '/user/dir2'], + }; + const workspaceSettingsContent = { + includeDirectories: ['/workspace/dir'], + }; + + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === getSystemSettingsPath()) + return JSON.stringify(systemSettingsContent); + if (p === USER_SETTINGS_PATH) + return JSON.stringify(userSettingsContent); + if (p === MOCK_WORKSPACE_SETTINGS_PATH) + return JSON.stringify(workspaceSettingsContent); + return '{}'; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + + expect(settings.merged.includeDirectories).toEqual([ + '/system/dir', + '/user/dir1', + '/user/dir2', + '/workspace/dir', + ]); + }); + it('should handle JSON parsing errors gracefully', () => { (mockFsExistsSync as Mock).mockReturnValue(true); // Both files "exist" const invalidJsonContent = 'invalid json'; @@ -654,6 +694,7 @@ describe('Settings Loading and Merging', () => { expect(settings.merged).toEqual({ customThemes: {}, mcpServers: {}, + includeDirectories: [], }); // Check that error objects are populated in settings.errors @@ -1090,6 +1131,7 @@ describe('Settings Loading and Merging', () => { ...systemSettingsContent, customThemes: {}, mcpServers: {}, + includeDirectories: [], }); }); }); diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 20a7b14a..722af628 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -126,6 +126,10 @@ export interface Settings { // Environment variables to exclude from project .env files excludedProjectEnvVars?: string[]; dnsResolutionOrder?: DnsResolutionOrder; + + includeDirectories?: string[]; + + loadMemoryFromIncludeDirectories?: boolean; } export interface SettingsError { @@ -181,6 +185,11 @@ export class LoadedSettings { ...(workspace.mcpServers || {}), ...(system.mcpServers || {}), }, + includeDirectories: [ + ...(system.includeDirectories || []), + ...(user.includeDirectories || []), + ...(workspace.includeDirectories || []), + ], }; } diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx index 3b695111..f07a5386 100644 --- a/packages/cli/src/ui/App.tsx +++ b/packages/cli/src/ui/App.tsx @@ -276,6 +276,9 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { try { const { memoryContent, fileCount } = await loadHierarchicalGeminiMemory( process.cwd(), + settings.merged.loadMemoryFromIncludeDirectories + ? config.getWorkspaceContext().getDirectories() + : [], config.getDebugMode(), config.getFileService(), settings.merged, @@ -480,6 +483,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { openPrivacyNotice, toggleVimEnabled, setIsProcessing, + setGeminiMdFileCount, ); const { @@ -599,7 +603,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { if (config) { setGeminiMdFileCount(config.getGeminiMdFileCount()); } - }, [config]); + }, [config, config.getGeminiMdFileCount]); const logger = useLogger(); const [userMessages, setUserMessages] = useState<string[]>([]); diff --git a/packages/cli/src/ui/commands/directoryCommand.test.tsx b/packages/cli/src/ui/commands/directoryCommand.test.tsx index 081083d3..fee8ae40 100644 --- a/packages/cli/src/ui/commands/directoryCommand.test.tsx +++ b/packages/cli/src/ui/commands/directoryCommand.test.tsx @@ -40,11 +40,24 @@ describe('directoryCommand', () => { getGeminiClient: vi.fn().mockReturnValue({ addDirectoryContext: vi.fn(), }), + getWorkingDir: () => '/test/dir', + shouldLoadMemoryFromIncludeDirectories: () => false, + getDebugMode: () => false, + getFileService: () => ({}), + getExtensionContextFilePaths: () => [], + getFileFilteringOptions: () => ({ ignore: [], include: [] }), + setUserMemory: vi.fn(), + setGeminiMdFileCount: vi.fn(), } as unknown as Config; mockContext = { services: { config: mockConfig, + settings: { + merged: { + memoryDiscoveryMaxDirs: 1000, + }, + }, }, ui: { addItem: vi.fn(), diff --git a/packages/cli/src/ui/commands/directoryCommand.tsx b/packages/cli/src/ui/commands/directoryCommand.tsx index 18f7e78f..6c667f44 100644 --- a/packages/cli/src/ui/commands/directoryCommand.tsx +++ b/packages/cli/src/ui/commands/directoryCommand.tsx @@ -8,6 +8,7 @@ import { SlashCommand, CommandContext, CommandKind } from './types.js'; import { MessageType } from '../types.js'; import * as os from 'os'; import * as path from 'path'; +import { loadServerHierarchicalMemory } from '@google/gemini-cli-core'; export function expandHomeDir(p: string): string { if (!p) { @@ -16,7 +17,7 @@ export function expandHomeDir(p: string): string { let expandedPath = p; if (p.toLowerCase().startsWith('%userprofile%')) { expandedPath = os.homedir() + p.substring('%userprofile%'.length); - } else if (p.startsWith('~')) { + } else if (p === '~' || p.startsWith('~/')) { expandedPath = os.homedir() + p.substring(1); } return path.normalize(expandedPath); @@ -90,6 +91,37 @@ export const directoryCommand: SlashCommand = { } } + try { + if (config.shouldLoadMemoryFromIncludeDirectories()) { + const { memoryContent, fileCount } = + await loadServerHierarchicalMemory( + config.getWorkingDir(), + [ + ...config.getWorkspaceContext().getDirectories(), + ...pathsToAdd, + ], + config.getDebugMode(), + config.getFileService(), + config.getExtensionContextFilePaths(), + context.services.settings.merged.memoryImportFormat || 'tree', // Use setting or default to 'tree' + config.getFileFilteringOptions(), + context.services.settings.merged.memoryDiscoveryMaxDirs, + ); + config.setUserMemory(memoryContent); + config.setGeminiMdFileCount(fileCount); + context.ui.setGeminiMdFileCount(fileCount); + } + addItem( + { + type: MessageType.INFO, + text: `Successfully added GEMINI.md files from the following directories if there are:\n- ${added.join('\n- ')}`, + }, + Date.now(), + ); + } catch (error) { + errors.push(`Error refreshing memory: ${(error as Error).message}`); + } + if (added.length > 0) { const gemini = config.getGeminiClient(); if (gemini) { diff --git a/packages/cli/src/ui/commands/memoryCommand.test.ts b/packages/cli/src/ui/commands/memoryCommand.test.ts index 74614fa7..670ca796 100644 --- a/packages/cli/src/ui/commands/memoryCommand.test.ts +++ b/packages/cli/src/ui/commands/memoryCommand.test.ts @@ -161,6 +161,10 @@ describe('memoryCommand', () => { getDebugMode: () => false, getFileService: () => ({}) as FileDiscoveryService, getExtensionContextFilePaths: () => [], + shouldLoadMemoryFromIncludeDirectories: () => false, + getWorkspaceContext: () => ({ + getDirectories: () => [], + }), getFileFilteringOptions: () => ({ ignore: [], include: [], diff --git a/packages/cli/src/ui/commands/memoryCommand.ts b/packages/cli/src/ui/commands/memoryCommand.ts index 370bb1fb..b046e7f8 100644 --- a/packages/cli/src/ui/commands/memoryCommand.ts +++ b/packages/cli/src/ui/commands/memoryCommand.ts @@ -89,6 +89,9 @@ export const memoryCommand: SlashCommand = { const { memoryContent, fileCount } = await loadServerHierarchicalMemory( config.getWorkingDir(), + config.shouldLoadMemoryFromIncludeDirectories() + ? config.getWorkspaceContext().getDirectories() + : [], config.getDebugMode(), config.getFileService(), config.getExtensionContextFilePaths(), diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 2de221f0..09d79e9d 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -59,6 +59,7 @@ export interface CommandContext { /** Toggles a special display mode. */ toggleCorgiMode: () => void; toggleVimEnabled: () => Promise<boolean>; + setGeminiMdFileCount: (count: number) => void; }; // Session-specific data session: { diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 6d9f4643..cfe4b385 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -51,6 +51,7 @@ export const useSlashCommandProcessor = ( openPrivacyNotice: () => void, toggleVimEnabled: () => Promise<boolean>, setIsProcessing: (isProcessing: boolean) => void, + setGeminiMdFileCount: (count: number) => void, ) => { const session = useSessionStats(); const [commands, setCommands] = useState<readonly SlashCommand[]>([]); @@ -163,6 +164,7 @@ export const useSlashCommandProcessor = ( setPendingItem: setPendingCompressionItem, toggleCorgiMode, toggleVimEnabled, + setGeminiMdFileCount, }, session: { stats: session.stats, @@ -185,6 +187,7 @@ export const useSlashCommandProcessor = ( toggleCorgiMode, toggleVimEnabled, sessionShellAllowlist, + setGeminiMdFileCount, ], ); diff --git a/packages/cli/src/utils/resolvePath.ts b/packages/cli/src/utils/resolvePath.ts new file mode 100644 index 00000000..b26ed8fc --- /dev/null +++ b/packages/cli/src/utils/resolvePath.ts @@ -0,0 +1,21 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as os from 'os'; +import * as path from 'path'; + +export function resolvePath(p: string): string { + if (!p) { + return ''; + } + let expandedPath = p; + if (p.toLowerCase().startsWith('%userprofile%')) { + expandedPath = os.homedir() + p.substring('%userprofile%'.length); + } else if (p === '~' || p.startsWith('~/')) { + expandedPath = os.homedir() + p.substring(1); + } + return path.normalize(expandedPath); +} |
