diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts
index b2e38c94b9..73b0c45ccb 100644
--- a/packages/cli/src/config/config.test.ts
+++ b/packages/cli/src/config/config.test.ts
@@ -138,7 +138,12 @@ vi.mock('@google/gemini-cli-core', async () => {
};
});
-vi.mock('./extension-manager.js');
+vi.mock('./extension-manager.js', () => {
+ const ExtensionManager = vi.fn();
+ ExtensionManager.prototype.loadExtensions = vi.fn();
+ ExtensionManager.prototype.getExtensions = vi.fn().mockReturnValue([]);
+ return { ExtensionManager };
+});
// Global setup to ensure clean environment for all tests in this file
const originalArgv = process.argv;
@@ -146,6 +151,11 @@ const originalGeminiModel = process.env['GEMINI_MODEL'];
beforeEach(() => {
delete process.env['GEMINI_MODEL'];
+ // Restore ExtensionManager mocks by re-assigning them
+ ExtensionManager.prototype.getExtensions = vi.fn().mockReturnValue([]);
+ ExtensionManager.prototype.loadExtensions = vi
+ .fn()
+ .mockResolvedValue(undefined);
});
afterEach(() => {
@@ -698,6 +708,12 @@ describe('loadCliConfig', () => {
describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
beforeEach(() => {
vi.resetAllMocks();
+ // Restore ExtensionManager mocks that were reset
+ ExtensionManager.prototype.getExtensions = vi.fn().mockReturnValue([]);
+ ExtensionManager.prototype.loadExtensions = vi
+ .fn()
+ .mockResolvedValue(undefined);
+
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
// Other common mocks would be reset here.
});
@@ -755,6 +771,63 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
200, // maxDirs
);
});
+
+ it('should pass includeDirectories to loadServerHierarchicalMemory when loadMemoryFromIncludeDirectories is true', async () => {
+ process.argv = ['node', 'script.js'];
+ const includeDir = path.resolve(path.sep, 'path', 'to', 'include');
+ const settings = createTestMergedSettings({
+ context: {
+ includeDirectories: [includeDir],
+ loadMemoryFromIncludeDirectories: true,
+ },
+ });
+
+ const argv = await parseArguments(settings);
+ await loadCliConfig(settings, 'session-id', argv);
+
+ expect(ServerConfig.loadServerHierarchicalMemory).toHaveBeenCalledWith(
+ expect.any(String),
+ [includeDir],
+ false,
+ expect.any(Object),
+ expect.any(ExtensionManager),
+ true,
+ 'tree',
+ expect.objectContaining({
+ respectGitIgnore: true,
+ respectGeminiIgnore: true,
+ }),
+ 200,
+ );
+ });
+
+ it('should NOT pass includeDirectories to loadServerHierarchicalMemory when loadMemoryFromIncludeDirectories is false', async () => {
+ process.argv = ['node', 'script.js'];
+ const settings = createTestMergedSettings({
+ context: {
+ includeDirectories: ['/path/to/include'],
+ loadMemoryFromIncludeDirectories: false,
+ },
+ });
+
+ const argv = await parseArguments(settings);
+ await loadCliConfig(settings, 'session-id', argv);
+
+ expect(ServerConfig.loadServerHierarchicalMemory).toHaveBeenCalledWith(
+ expect.any(String),
+ [],
+ false,
+ expect.any(Object),
+ expect.any(ExtensionManager),
+ true,
+ 'tree',
+ expect.objectContaining({
+ respectGitIgnore: true,
+ respectGeminiIgnore: true,
+ }),
+ 200,
+ );
+ });
});
describe('mergeMcpServers', () => {
diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts
index 454c0b3a58..92f4d56ea4 100755
--- a/packages/cli/src/config/config.ts
+++ b/packages/cli/src/config/config.ts
@@ -476,7 +476,9 @@ export async function loadCliConfig(
// Call the (now wrapper) loadHierarchicalGeminiMemory which calls the server's version
const result = await loadServerHierarchicalMemory(
cwd,
- [],
+ settings.context?.loadMemoryFromIncludeDirectories || false
+ ? includeDirectories
+ : [],
debugMode,
fileService,
extensionManager,
diff --git a/packages/cli/src/config/sandboxConfig.test.ts b/packages/cli/src/config/sandboxConfig.test.ts
index 1b38909f3b..14080dc30b 100644
--- a/packages/cli/src/config/sandboxConfig.test.ts
+++ b/packages/cli/src/config/sandboxConfig.test.ts
@@ -25,11 +25,15 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
};
});
-vi.mock('command-exists', () => ({
- default: {
- sync: vi.fn(),
- },
-}));
+vi.mock('command-exists', () => {
+ const sync = vi.fn();
+ return {
+ sync,
+ default: {
+ sync,
+ },
+ };
+});
vi.mock('node:os', async (importOriginal) => {
const actual = await importOriginal();
@@ -49,6 +53,8 @@ describe('loadSandboxConfig', () => {
beforeEach(() => {
vi.resetAllMocks();
process.env = { ...originalEnv };
+ delete process.env['SANDBOX'];
+ delete process.env['GEMINI_SANDBOX'];
mockedGetPackageJson.mockResolvedValue({
config: { sandboxImageUri: 'default/image' },
});
diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx
index 59f59de4b1..c0fc9d7b7f 100644
--- a/packages/cli/src/gemini.test.tsx
+++ b/packages/cli/src/gemini.test.tsx
@@ -657,10 +657,48 @@ describe('gemini.tsx main function kitty protocol', () => {
refreshAuth: vi.fn(),
getRemoteAdminSettings: () => undefined,
setTerminalBackground: vi.fn(),
+ getToolRegistry: () => ({ getAllTools: () => [] }),
+ getModel: () => 'gemini-pro',
+ getEmbeddingModel: () => 'embedding-model',
+ getCoreTools: () => [],
+ getApprovalMode: () => 'default',
+ getPreviewFeatures: () => false,
+ getTargetDir: () => '/',
+ getUsageStatisticsEnabled: () => false,
+ getTelemetryEnabled: () => false,
+ getTelemetryTarget: () => 'none',
+ getTelemetryOtlpEndpoint: () => '',
+ getTelemetryOtlpProtocol: () => 'grpc',
+ getTelemetryLogPromptsEnabled: () => false,
+ getContinueOnFailedApiCall: () => false,
+ getShellToolInactivityTimeout: () => 0,
+ getTruncateToolOutputThreshold: () => 0,
+ getUseRipgrep: () => false,
+ getUseWriteTodos: () => false,
+ getHooks: () => undefined,
+ getExperiments: () => undefined,
+ getFileFilteringRespectGitIgnore: () => true,
+ getOutputFormat: () => 'text',
+ getFolderTrust: () => false,
+ getPendingIncludeDirectories: () => [],
+ getWorkspaceContext: () => ({ getDirectories: () => ['/'] }),
+ getModelAvailabilityService: () => ({
+ reset: vi.fn(),
+ resetTurn: vi.fn(),
+ }),
+ getBaseLlmClient: () => ({}),
+ getGeminiClient: () => ({}),
+ getContentGenerator: () => ({}),
+ isTrustedFolder: () => true,
+ isYoloModeDisabled: () => true,
+ isPlanEnabled: () => false,
+ isEventDrivenSchedulerEnabled: () => false,
} as unknown as Config;
vi.mocked(loadCliConfig).mockResolvedValue(mockConfig);
- vi.mocked(loadSandboxConfig).mockResolvedValue({} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
+ vi.mocked(loadSandboxConfig).mockResolvedValue({
+ command: 'docker',
+ } as any); // eslint-disable-line @typescript-eslint/no-explicit-any
vi.mocked(relaunchOnExitCode).mockImplementation(async (fn) => {
await fn();
});
diff --git a/packages/cli/src/ui/commands/directoryCommand.test.tsx b/packages/cli/src/ui/commands/directoryCommand.test.tsx
index 45ddd6fbe2..904e8498f3 100644
--- a/packages/cli/src/ui/commands/directoryCommand.test.tsx
+++ b/packages/cli/src/ui/commands/directoryCommand.test.tsx
@@ -43,6 +43,7 @@ describe('directoryCommand', () => {
beforeEach(() => {
mockWorkspaceContext = {
addDirectory: vi.fn(),
+ addDirectories: vi.fn().mockReturnValue({ added: [], failed: [] }),
getDirectories: vi
.fn()
.mockReturnValue([
@@ -125,9 +126,15 @@ describe('directoryCommand', () => {
it('should call addDirectory and show a success message for a single path', async () => {
const newPath = path.normalize('/home/user/new-project');
+ vi.mocked(mockWorkspaceContext.addDirectories).mockReturnValue({
+ added: [newPath],
+ failed: [],
+ });
if (!addCommand?.action) throw new Error('No action');
await addCommand.action(mockContext, newPath);
- expect(mockWorkspaceContext.addDirectory).toHaveBeenCalledWith(newPath);
+ expect(mockWorkspaceContext.addDirectories).toHaveBeenCalledWith([
+ newPath,
+ ]);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.INFO,
@@ -139,10 +146,16 @@ describe('directoryCommand', () => {
it('should call addDirectory for each path and show a success message for multiple paths', async () => {
const newPath1 = path.normalize('/home/user/new-project1');
const newPath2 = path.normalize('/home/user/new-project2');
+ vi.mocked(mockWorkspaceContext.addDirectories).mockReturnValue({
+ added: [newPath1, newPath2],
+ failed: [],
+ });
if (!addCommand?.action) throw new Error('No action');
await addCommand.action(mockContext, `${newPath1},${newPath2}`);
- expect(mockWorkspaceContext.addDirectory).toHaveBeenCalledWith(newPath1);
- expect(mockWorkspaceContext.addDirectory).toHaveBeenCalledWith(newPath2);
+ expect(mockWorkspaceContext.addDirectories).toHaveBeenCalledWith([
+ newPath1,
+ newPath2,
+ ]);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.INFO,
@@ -153,10 +166,11 @@ describe('directoryCommand', () => {
it('should show an error if addDirectory throws an exception', async () => {
const error = new Error('Directory does not exist');
- vi.mocked(mockWorkspaceContext.addDirectory).mockImplementation(() => {
- throw error;
- });
const newPath = path.normalize('/home/user/invalid-project');
+ vi.mocked(mockWorkspaceContext.addDirectories).mockReturnValue({
+ added: [],
+ failed: [{ path: newPath, error }],
+ });
if (!addCommand?.action) throw new Error('No action');
await addCommand.action(mockContext, newPath);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
@@ -171,10 +185,16 @@ describe('directoryCommand', () => {
if (!addCommand?.action) throw new Error('No action');
vi.spyOn(trustedFolders, 'isFolderTrustEnabled').mockReturnValue(false);
const newPath = path.normalize('/home/user/new-project');
+ vi.mocked(mockWorkspaceContext.addDirectories).mockReturnValue({
+ added: [newPath],
+ failed: [],
+ });
await addCommand.action(mockContext, newPath);
- expect(mockWorkspaceContext.addDirectory).toHaveBeenCalledWith(newPath);
+ expect(mockWorkspaceContext.addDirectories).toHaveBeenCalledWith([
+ newPath,
+ ]);
});
it('should show an info message for an already added directory', async () => {
@@ -196,13 +216,10 @@ describe('directoryCommand', () => {
const validPath = path.normalize('/home/user/valid-project');
const invalidPath = path.normalize('/home/user/invalid-project');
const error = new Error('Directory does not exist');
- vi.mocked(mockWorkspaceContext.addDirectory).mockImplementation(
- (p: string) => {
- if (p === invalidPath) {
- throw error;
- }
- },
- );
+ vi.mocked(mockWorkspaceContext.addDirectories).mockReturnValue({
+ added: [validPath],
+ failed: [{ path: invalidPath, error }],
+ });
if (!addCommand?.action) throw new Error('No action');
await addCommand.action(mockContext, `${validPath},${invalidPath}`);
@@ -290,10 +307,16 @@ describe('directoryCommand', () => {
if (!addCommand?.action) throw new Error('No action');
mockIsPathTrusted.mockReturnValue(true);
const newPath = path.normalize('/home/user/trusted-project');
+ vi.mocked(mockWorkspaceContext.addDirectories).mockReturnValue({
+ added: [newPath],
+ failed: [],
+ });
await addCommand.action(mockContext, newPath);
- expect(mockWorkspaceContext.addDirectory).toHaveBeenCalledWith(newPath);
+ expect(mockWorkspaceContext.addDirectories).toHaveBeenCalledWith([
+ newPath,
+ ]);
});
it('should show an error for an untrusted directory', async () => {
@@ -303,7 +326,7 @@ describe('directoryCommand', () => {
await addCommand.action(mockContext, newPath);
- expect(mockWorkspaceContext.addDirectory).not.toHaveBeenCalled();
+ expect(mockWorkspaceContext.addDirectories).not.toHaveBeenCalled();
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.ERROR,
diff --git a/packages/cli/src/ui/commands/directoryCommand.tsx b/packages/cli/src/ui/commands/directoryCommand.tsx
index c3c56d46f2..9116e216b9 100644
--- a/packages/cli/src/ui/commands/directoryCommand.tsx
+++ b/packages/cli/src/ui/commands/directoryCommand.tsx
@@ -17,6 +17,7 @@ import { refreshServerHierarchicalMemory } from '@google/gemini-cli-core';
import {
expandHomeDir,
getDirectorySuggestions,
+ batchAddDirectories,
} from '../utils/directoryUtils.js';
import type { Config } from '@google/gemini-cli-core';
@@ -193,14 +194,10 @@ export const directoryCommand: SlashCommand = {
);
}
- for (const pathToAdd of trustedDirs) {
- try {
- workspaceContext.addDirectory(expandHomeDir(pathToAdd));
- added.push(pathToAdd);
- } catch (e) {
- const error = e as Error;
- errors.push(`Error adding '${pathToAdd}': ${error.message}`);
- }
+ if (trustedDirs.length > 0) {
+ const result = batchAddDirectories(workspaceContext, trustedDirs);
+ added.push(...result.added);
+ errors.push(...result.errors);
}
if (undefinedTrustDirs.length > 0) {
@@ -220,17 +217,9 @@ export const directoryCommand: SlashCommand = {
};
}
} else {
- for (const pathToAdd of pathsToProcess) {
- try {
- workspaceContext.addDirectory(expandHomeDir(pathToAdd.trim()));
- added.push(pathToAdd.trim());
- } catch (e) {
- const error = e as Error;
- errors.push(
- `Error adding '${pathToAdd.trim()}': ${error.message}`,
- );
- }
- }
+ const result = batchAddDirectories(workspaceContext, pathsToProcess);
+ added.push(...result.added);
+ errors.push(...result.errors);
}
await finishAddingDirectories(config, addItem, added, errors);
diff --git a/packages/cli/src/ui/components/Footer.test.tsx b/packages/cli/src/ui/components/Footer.test.tsx
index a1d45db5cd..ed8ab8307f 100644
--- a/packages/cli/src/ui/components/Footer.test.tsx
+++ b/packages/cli/src/ui/components/Footer.test.tsx
@@ -203,6 +203,15 @@ describe('', () => {
});
describe('footer configuration filtering (golden snapshots)', () => {
+ beforeEach(() => {
+ vi.stubEnv('SANDBOX', '');
+ vi.stubEnv('SEATBELT_PROFILE', '');
+ });
+
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ });
+
it('renders complete footer with all sections visible (baseline)', () => {
const { lastFrame } = renderWithProviders(, {
width: 120,
diff --git a/packages/cli/src/ui/hooks/useIncludeDirsTrust.test.tsx b/packages/cli/src/ui/hooks/useIncludeDirsTrust.test.tsx
index 199e1b4587..87fb0cc358 100644
--- a/packages/cli/src/ui/hooks/useIncludeDirsTrust.test.tsx
+++ b/packages/cli/src/ui/hooks/useIncludeDirsTrust.test.tsx
@@ -16,10 +16,26 @@ import type { LoadedTrustedFolders } from '../../config/trustedFolders.js';
import type { MultiFolderTrustDialogProps } from '../components/MultiFolderTrustDialog.js';
-vi.mock('../utils/directoryUtils.js', () => ({
- expandHomeDir: (p: string) => p, // Simple pass-through for testing
- loadMemoryFromDirectories: vi.fn().mockResolvedValue({ fileCount: 1 }),
-}));
+vi.mock('../utils/directoryUtils.js', async (importOriginal) => {
+ const actual =
+ await importOriginal();
+ return {
+ ...actual,
+ expandHomeDir: (p: string) => p, // Simple pass-through for testing
+ batchAddDirectories: (
+ workspaceContext: WorkspaceContext,
+ paths: string[],
+ ) => {
+ const result = workspaceContext.addDirectories(paths);
+ const errors: string[] = [];
+ for (const failure of result.failed) {
+ errors.push(`Error adding '${failure.path}': ${failure.error.message}`);
+ }
+ return { added: result.added, errors };
+ },
+ loadMemoryFromDirectories: vi.fn().mockResolvedValue({ fileCount: 1 }),
+ };
+});
vi.mock('../components/MultiFolderTrustDialog.js', () => ({
MultiFolderTrustDialog: (props: MultiFolderTrustDialogProps) => (
@@ -38,6 +54,7 @@ describe('useIncludeDirsTrust', () => {
mockWorkspaceContext = {
addDirectory: vi.fn(),
+ addDirectories: vi.fn().mockReturnValue({ added: [], failed: [] }),
getDirectories: vi.fn().mockReturnValue([]),
onDirectoriesChangedListeners: new Set(),
onDirectoriesChanged: vi.fn(),
@@ -111,23 +128,18 @@ describe('useIncludeDirsTrust', () => {
'/dir1',
'/dir2',
]);
- vi.mocked(mockWorkspaceContext.addDirectory).mockImplementation(
- (path) => {
- if (path === '/dir2') {
- throw new Error('Test error');
- }
- },
- );
+ vi.mocked(mockWorkspaceContext.addDirectories).mockReturnValue({
+ added: ['/dir1'],
+ failed: [{ path: '/dir2', error: new Error('Test error') }],
+ });
renderTestHook(isTrusted);
await waitFor(() => {
- expect(mockWorkspaceContext.addDirectory).toHaveBeenCalledWith(
+ expect(mockWorkspaceContext.addDirectories).toHaveBeenCalledWith([
'/dir1',
- );
- expect(mockWorkspaceContext.addDirectory).toHaveBeenCalledWith(
'/dir2',
- );
+ ]);
expect(mockHistoryManager.addItem).toHaveBeenCalledWith(
expect.objectContaining({
text: expect.stringContaining("Error adding '/dir2': Test error"),
@@ -171,6 +183,11 @@ describe('useIncludeDirsTrust', () => {
return undefined;
});
+ vi.mocked(mockWorkspaceContext.addDirectories).mockReturnValue({
+ added: ['/trusted'],
+ failed: [],
+ });
+
renderTestHook(true);
// Opens dialog for undefined trust dir
@@ -193,15 +210,16 @@ describe('useIncludeDirsTrust', () => {
pendingDirs,
);
mockIsPathTrusted.mockReturnValue(true);
+ vi.mocked(mockWorkspaceContext.addDirectories).mockReturnValue({
+ added: pendingDirs,
+ failed: [],
+ });
renderTestHook(true);
await waitFor(() => {
- expect(mockWorkspaceContext.addDirectory).toHaveBeenCalledWith(
- '/trusted1',
- );
- expect(mockWorkspaceContext.addDirectory).toHaveBeenCalledWith(
- '/trusted2',
+ expect(mockWorkspaceContext.addDirectories).toHaveBeenCalledWith(
+ pendingDirs,
);
expect(mockSetCustomDialog).not.toHaveBeenCalled();
expect(mockConfig.clearPendingIncludeDirectories).toHaveBeenCalledTimes(
diff --git a/packages/cli/src/ui/hooks/useIncludeDirsTrust.tsx b/packages/cli/src/ui/hooks/useIncludeDirsTrust.tsx
index 90cb33cb1a..fa27d3e0ec 100644
--- a/packages/cli/src/ui/hooks/useIncludeDirsTrust.tsx
+++ b/packages/cli/src/ui/hooks/useIncludeDirsTrust.tsx
@@ -5,9 +5,9 @@
*/
import { useEffect } from 'react';
-import type { Config } from '@google/gemini-cli-core';
+import { type Config } from '@google/gemini-cli-core';
import { loadTrustedFolders } from '../../config/trustedFolders.js';
-import { expandHomeDir } from '../utils/directoryUtils.js';
+import { expandHomeDir, batchAddDirectories } from '../utils/directoryUtils.js';
import {
debugLogger,
refreshServerHierarchicalMemory,
@@ -79,15 +79,10 @@ export function useIncludeDirsTrust(
const added: string[] = [];
const errors: string[] = [];
const workspaceContext = config.getWorkspaceContext();
- for (const pathToAdd of pendingDirs) {
- try {
- workspaceContext.addDirectory(expandHomeDir(pathToAdd.trim()));
- added.push(pathToAdd.trim());
- } catch (e) {
- const error = e as Error;
- errors.push(`Error adding '${pathToAdd.trim()}': ${error.message}`);
- }
- }
+
+ const result = batchAddDirectories(workspaceContext, pendingDirs);
+ added.push(...result.added);
+ errors.push(...result.errors);
if (added.length > 0 || errors.length > 0) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
@@ -125,14 +120,10 @@ export function useIncludeDirsTrust(
}
const workspaceContext = config.getWorkspaceContext();
- for (const pathToAdd of trustedDirs) {
- try {
- workspaceContext.addDirectory(expandHomeDir(pathToAdd));
- added.push(pathToAdd);
- } catch (e) {
- const error = e as Error;
- errors.push(`Error adding '${pathToAdd}': ${error.message}`);
- }
+ if (trustedDirs.length > 0) {
+ const result = batchAddDirectories(workspaceContext, trustedDirs);
+ added.push(...result.added);
+ errors.push(...result.errors);
}
if (undefinedTrustDirs.length > 0) {
diff --git a/packages/cli/src/ui/utils/directoryUtils.ts b/packages/cli/src/ui/utils/directoryUtils.ts
index e293243989..0981a36d48 100644
--- a/packages/cli/src/ui/utils/directoryUtils.ts
+++ b/packages/cli/src/ui/utils/directoryUtils.ts
@@ -7,7 +7,7 @@
import * as path from 'node:path';
import * as fs from 'node:fs';
import { opendir } from 'node:fs/promises';
-import { homedir } from '@google/gemini-cli-core';
+import { homedir, type WorkspaceContext } from '@google/gemini-cli-core';
const MAX_SUGGESTIONS = 50;
const MATCH_BUFFER_MULTIPLIER = 3;
@@ -139,3 +139,28 @@ export async function getDirectorySuggestions(
return [];
}
}
+
+export interface BatchAddResult {
+ added: string[];
+ errors: string[];
+}
+
+/**
+ * Helper to batch add directories to the workspace context.
+ * Handles expansion and error formatting.
+ */
+export function batchAddDirectories(
+ workspaceContext: WorkspaceContext,
+ paths: string[],
+): BatchAddResult {
+ const result = workspaceContext.addDirectories(
+ paths.map((p) => expandHomeDir(p.trim())),
+ );
+
+ const errors: string[] = [];
+ for (const failure of result.failed) {
+ errors.push(`Error adding '${failure.path}': ${failure.error.message}`);
+ }
+
+ return { added: result.added, errors };
+}
diff --git a/packages/cli/src/utils/handleAutoUpdate.test.ts b/packages/cli/src/utils/handleAutoUpdate.test.ts
index 5a2ab9704f..0af2de37b1 100644
--- a/packages/cli/src/utils/handleAutoUpdate.test.ts
+++ b/packages/cli/src/utils/handleAutoUpdate.test.ts
@@ -36,6 +36,8 @@ describe('handleAutoUpdate', () => {
let mockChildProcess: ChildProcess;
beforeEach(() => {
+ vi.stubEnv('GEMINI_SANDBOX', '');
+ vi.stubEnv('SANDBOX', '');
mockSpawn = vi.fn();
vi.clearAllMocks();
vi.spyOn(updateEventEmitter, 'emit');
@@ -75,6 +77,7 @@ describe('handleAutoUpdate', () => {
});
afterEach(() => {
+ vi.unstubAllEnvs();
vi.clearAllMocks();
});
diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts
index 9d21e9c886..7441f8647d 100644
--- a/packages/core/src/config/config.ts
+++ b/packages/core/src/config/config.ts
@@ -801,6 +801,11 @@ export class Config {
}
this.initialized = true;
+ // Add pending directories to workspace context
+ for (const dir of this.pendingIncludeDirectories) {
+ this.workspaceContext.addDirectory(dir);
+ }
+
// Initialize centralized FileDiscoveryService
const discoverToolsHandle = startupProfiler.start('discover_tools');
this.getFileService();
diff --git a/packages/core/src/hooks/hookSystem.test.ts b/packages/core/src/hooks/hookSystem.test.ts
index eb71b0cc73..34bc687c5b 100644
--- a/packages/core/src/hooks/hookSystem.test.ts
+++ b/packages/core/src/hooks/hookSystem.test.ts
@@ -9,6 +9,9 @@ import { HookSystem } from './hookSystem.js';
import { Config } from '../config/config.js';
import { HookType } from './types.js';
import { spawn } from 'node:child_process';
+import * as fs from 'node:fs';
+import * as os from 'node:os';
+import * as path from 'node:path';
import type { ChildProcessWithoutNullStreams } from 'node:child_process';
import type { Readable, Writable } from 'node:stream';
@@ -58,13 +61,16 @@ describe('HookSystem Integration', () => {
beforeEach(() => {
vi.resetAllMocks();
+ const testDir = path.join(os.tmpdir(), 'test-hooks');
+ fs.mkdirSync(testDir, { recursive: true });
+
// Create a real config with simple command hook configurations for testing
config = new Config({
model: 'gemini-1.5-flash',
- targetDir: '/tmp/test-hooks',
+ targetDir: testDir,
sessionId: 'test-session',
debugMode: false,
- cwd: '/tmp/test-hooks',
+ cwd: testDir,
hooks: {
BeforeTool: [
{
@@ -141,13 +147,16 @@ describe('HookSystem Integration', () => {
});
it('should handle initialization errors gracefully', async () => {
+ const invalidDir = path.join(os.tmpdir(), 'test-hooks-invalid');
+ fs.mkdirSync(invalidDir, { recursive: true });
+
// Create a config with invalid hooks to trigger initialization errors
const invalidConfig = new Config({
model: 'gemini-1.5-flash',
- targetDir: '/tmp/test-hooks-invalid',
+ targetDir: invalidDir,
sessionId: 'test-session-invalid',
debugMode: false,
- cwd: '/tmp/test-hooks-invalid',
+ cwd: invalidDir,
hooks: {
BeforeTool: [
{
@@ -254,13 +263,16 @@ describe('HookSystem Integration', () => {
describe('hook disabling via settings', () => {
it('should not execute disabled hooks from settings', async () => {
+ const disabledDir = path.join(os.tmpdir(), 'test-hooks-disabled');
+ fs.mkdirSync(disabledDir, { recursive: true });
+
// Create config with two hooks, one enabled and one disabled via settings
const configWithDisabled = new Config({
model: 'gemini-1.5-flash',
- targetDir: '/tmp/test-hooks-disabled',
+ targetDir: disabledDir,
sessionId: 'test-session-disabled',
debugMode: false,
- cwd: '/tmp/test-hooks-disabled',
+ cwd: disabledDir,
hooks: {
BeforeTool: [
{
@@ -322,13 +334,16 @@ describe('HookSystem Integration', () => {
describe('hook disabling via command', () => {
it('should disable hook when setHookEnabled is called', async () => {
+ const setEnabledDir = path.join(os.tmpdir(), 'test-hooks-setEnabled');
+ fs.mkdirSync(setEnabledDir, { recursive: true });
+
// Create config with a hook
const configForDisabling = new Config({
model: 'gemini-1.5-flash',
- targetDir: '/tmp/test-hooks-setEnabled',
+ targetDir: setEnabledDir,
sessionId: 'test-session-setEnabled',
debugMode: false,
- cwd: '/tmp/test-hooks-setEnabled',
+ cwd: setEnabledDir,
hooks: {
BeforeTool: [
{
diff --git a/packages/core/src/utils/editor.test.ts b/packages/core/src/utils/editor.test.ts
index 78035c4cc9..b4d33c5377 100644
--- a/packages/core/src/utils/editor.test.ts
+++ b/packages/core/src/utils/editor.test.ts
@@ -480,6 +480,7 @@ describe('editor utils', () => {
});
it(`should allow ${editor} when not in sandbox mode`, () => {
+ vi.stubEnv('SANDBOX', '');
expect(allowEditorTypeInSandbox(editor)).toBe(true);
});
}
@@ -500,6 +501,7 @@ describe('editor utils', () => {
it('should return true for vscode when installed and not in sandbox mode', () => {
(execSync as Mock).mockReturnValue(Buffer.from('/usr/bin/code'));
+ vi.stubEnv('SANDBOX', '');
expect(isEditorAvailable('vscode')).toBe(true);
});
diff --git a/packages/core/src/utils/memoryImportProcessor.ts b/packages/core/src/utils/memoryImportProcessor.ts
index 9b808926c1..bf20bd6c13 100644
--- a/packages/core/src/utils/memoryImportProcessor.ts
+++ b/packages/core/src/utils/memoryImportProcessor.ts
@@ -7,7 +7,6 @@
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { isSubpath } from './paths.js';
-import { marked, type Token } from 'marked';
import { debugLogger } from './debugLogger.js';
// Simple console logger for import processing
@@ -83,7 +82,7 @@ function hasMessage(err: unknown): err is { message: string } {
);
}
-// Helper to find all code block and inline code regions using marked
+// Helper to find all code block and inline code regions using regex
/**
* Finds all import statements in content without using regex
* @returns Array of {start, _end, path} objects for each import found
@@ -154,35 +153,13 @@ function isLetter(char: string): boolean {
function findCodeRegions(content: string): Array<[number, number]> {
const regions: Array<[number, number]> = [];
- const tokens = marked.lexer(content);
- let offset = 0;
-
- function walk(token: Token, baseOffset: number) {
- if (token.type === 'code' || token.type === 'codespan') {
- regions.push([baseOffset, baseOffset + token.raw.length]);
- }
-
- if ('tokens' in token && token.tokens) {
- let childOffset = 0;
- for (const child of token.tokens) {
- const childIndexInParent = token.raw.indexOf(child.raw, childOffset);
- if (childIndexInParent === -1) {
- logger.error(
- `Could not find child token in parent raw content. Aborting parsing for this branch. Child raw: "${child.raw}"`,
- );
- break;
- }
- walk(child, baseOffset + childIndexInParent);
- childOffset = childIndexInParent + child.raw.length;
- }
- }
+ // Regex to match code blocks (inline and multiline)
+ // Matches one or more backticks, content (lazy), and same number of backticks
+ const regex = /(`+)([\s\S]*?)\1/g;
+ let match;
+ while ((match = regex.exec(content)) !== null) {
+ regions.push([match.index, match.index + match[0].length]);
}
-
- for (const token of tokens) {
- walk(token, offset);
- offset += token.raw.length;
- }
-
return regions;
}
diff --git a/packages/core/src/utils/workspaceContext.test.ts b/packages/core/src/utils/workspaceContext.test.ts
index 6c01a2ab8b..8c29819a79 100644
--- a/packages/core/src/utils/workspaceContext.test.ts
+++ b/packages/core/src/utils/workspaceContext.test.ts
@@ -374,6 +374,75 @@ describe('WorkspaceContext with real filesystem', () => {
expect(dirs1).toEqual(dirs2);
});
});
+
+ describe('addDirectories', () => {
+ it('should add multiple directories and emit one event', () => {
+ const dir3 = path.join(tempDir, 'dir3');
+ fs.mkdirSync(dir3);
+
+ const workspaceContext = new WorkspaceContext(cwd);
+ const listener = vi.fn();
+ workspaceContext.onDirectoriesChanged(listener);
+
+ const result = workspaceContext.addDirectories([otherDir, dir3]);
+
+ expect(workspaceContext.getDirectories()).toContain(otherDir);
+ expect(workspaceContext.getDirectories()).toContain(dir3);
+ expect(listener).toHaveBeenCalledOnce();
+ expect(result.added).toHaveLength(2);
+ expect(result.failed).toHaveLength(0);
+ });
+
+ it('should handle partial failures', () => {
+ const workspaceContext = new WorkspaceContext(cwd);
+ const listener = vi.fn();
+ workspaceContext.onDirectoriesChanged(listener);
+
+ const loggerSpy = vi
+ .spyOn(debugLogger, 'warn')
+ .mockImplementation(() => {});
+
+ const nonExistent = path.join(tempDir, 'does-not-exist');
+ const result = workspaceContext.addDirectories([otherDir, nonExistent]);
+
+ expect(workspaceContext.getDirectories()).toContain(otherDir);
+ expect(workspaceContext.getDirectories()).not.toContain(nonExistent);
+ expect(listener).toHaveBeenCalledOnce();
+ expect(loggerSpy).toHaveBeenCalled();
+ expect(result.added).toEqual([otherDir]);
+ expect(result.failed).toHaveLength(1);
+ expect(result.failed[0].path).toBe(nonExistent);
+ expect(result.failed[0].error).toBeDefined();
+
+ loggerSpy.mockRestore();
+ });
+
+ it('should not emit event if no directories added', () => {
+ const workspaceContext = new WorkspaceContext(cwd);
+ const listener = vi.fn();
+ workspaceContext.onDirectoriesChanged(listener);
+ const loggerSpy = vi
+ .spyOn(debugLogger, 'warn')
+ .mockImplementation(() => {});
+
+ const nonExistent = path.join(tempDir, 'does-not-exist');
+ const result = workspaceContext.addDirectories([nonExistent]);
+
+ expect(listener).not.toHaveBeenCalled();
+ expect(result.added).toHaveLength(0);
+ expect(result.failed).toHaveLength(1);
+ loggerSpy.mockRestore();
+ });
+ });
+
+ describe('addDirectory', () => {
+ it('should throw error if directory fails to add', () => {
+ const workspaceContext = new WorkspaceContext(cwd);
+ const nonExistent = path.join(tempDir, 'does-not-exist');
+
+ expect(() => workspaceContext.addDirectory(nonExistent)).toThrow();
+ });
+ });
});
describe('WorkspaceContext with optional directories', () => {
diff --git a/packages/core/src/utils/workspaceContext.ts b/packages/core/src/utils/workspaceContext.ts
index c1da8794b2..ff912083fb 100755
--- a/packages/core/src/utils/workspaceContext.ts
+++ b/packages/core/src/utils/workspaceContext.ts
@@ -11,6 +11,11 @@ import { debugLogger } from './debugLogger.js';
export type Unsubscribe = () => void;
+export interface AddDirectoriesResult {
+ added: string[];
+ failed: Array<{ path: string; error: Error }>;
+}
+
/**
* WorkspaceContext manages multiple workspace directories and validates paths
* against them. This allows the CLI to operate on files from multiple directories
@@ -31,9 +36,7 @@ export class WorkspaceContext {
additionalDirectories: string[] = [],
) {
this.addDirectory(targetDir);
- for (const additionalDirectory of additionalDirectories) {
- this.addDirectory(additionalDirectory);
- }
+ this.addDirectories(additionalDirectories);
this.initialDirectories = new Set(this.directories);
}
@@ -67,22 +70,49 @@ export class WorkspaceContext {
* Adds a directory to the workspace.
* @param directory The directory path to add (can be relative or absolute)
* @param basePath Optional base path for resolving relative paths (defaults to cwd)
+ * @throws Error if the directory cannot be added
*/
addDirectory(directory: string): void {
- try {
- const resolved = this.resolveAndValidateDir(directory);
- if (this.directories.has(resolved)) {
- return;
- }
- this.directories.add(resolved);
- this.notifyDirectoriesChanged();
- } catch (err) {
- debugLogger.warn(
- `[WARN] Skipping unreadable directory: ${directory} (${err instanceof Error ? err.message : String(err)})`,
- );
+ const result = this.addDirectories([directory]);
+ if (result.failed.length > 0) {
+ throw result.failed[0].error;
}
}
+ /**
+ * Adds multiple directories to the workspace.
+ * Emits a single change event if any directories are added.
+ * @param directories The directory paths to add
+ * @returns Object containing successfully added directories and failures
+ */
+ addDirectories(directories: string[]): AddDirectoriesResult {
+ const result: AddDirectoriesResult = { added: [], failed: [] };
+ let changed = false;
+
+ for (const directory of directories) {
+ try {
+ const resolved = this.resolveAndValidateDir(directory);
+ if (!this.directories.has(resolved)) {
+ this.directories.add(resolved);
+ changed = true;
+ }
+ result.added.push(directory);
+ } catch (err) {
+ const error = err instanceof Error ? err : new Error(String(err));
+ debugLogger.warn(
+ `[WARN] Skipping unreadable directory: ${directory} (${error.message})`,
+ );
+ result.failed.push({ path: directory, error });
+ }
+ }
+
+ if (changed) {
+ this.notifyDirectoriesChanged();
+ }
+
+ return result;
+ }
+
private resolveAndValidateDir(directory: string): string {
const absolutePath = path.resolve(this.targetDir, directory);