fix(core,cli): enable recursive directory access for (#17094)

This commit is contained in:
Gal Zahavi
2026-01-21 09:58:23 -08:00
committed by GitHub
parent acbef4cd31
commit 45d554ae2f
17 changed files with 410 additions and 135 deletions

View File

@@ -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', () => {

View File

@@ -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,

View File

@@ -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' },
});

View File

@@ -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();
});

View File

@@ -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,

View File

@@ -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);

View File

@@ -203,6 +203,15 @@ describe('<Footer />', () => {
});
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(<Footer />, {
width: 120,

View File

@@ -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<typeof import('../utils/directoryUtils.js')>();
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(

View File

@@ -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) {

View File

@@ -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 };
}

View File

@@ -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();
});