Skip to content

Commit e57bf3a

Browse files
y-oktallenhutchison
authored andcommitted
feat: Multi-Directory Workspace Support (part 3: configuration in settings.json) (google-gemini#5354)
Co-authored-by: Allen Hutchison <[email protected]>
1 parent 44bfb5d commit e57bf3a

File tree

17 files changed

+393
-67
lines changed

17 files changed

+393
-67
lines changed

docs/cli/configuration.md

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,26 @@ In addition to a project settings file, a project's `.gemini` directory can cont
248248
"excludedProjectEnvVars": ["DEBUG", "DEBUG_MODE", "NODE_ENV"]
249249
```
250250

251+
- **`includeDirectories`** (array of strings):
252+
- **Description:** Specifies an array of additional absolute or relative paths to include in the workspace context. This allows you to work with files across multiple directories as if they were one. Paths can use `~` to refer to the user's home directory. This setting can be combined with the `--include-directories` command-line flag.
253+
- **Default:** `[]`
254+
- **Example:**
255+
```json
256+
"includeDirectories": [
257+
"/path/to/another/project",
258+
"../shared-library",
259+
"~/common-utils"
260+
]
261+
```
262+
263+
- **`loadMemoryFromIncludeDirectories`** (boolean):
264+
- **Description:** Controls the behavior of the `/memory refresh` command. If set to `true`, `GEMINI.md` files should be loaded from all directories that are added. If set to `false`, `GEMINI.md` should only be loaded from the current directory.
265+
- **Default:** `false`
266+
- **Example:**
267+
```json
268+
"loadMemoryFromIncludeDirectories": true
269+
```
270+
251271
### Example `settings.json`:
252272

253273
```json
@@ -280,7 +300,9 @@ In addition to a project settings file, a project's `.gemini` directory can cont
280300
"tokenBudget": 100
281301
}
282302
},
283-
"excludedProjectEnvVars": ["DEBUG", "DEBUG_MODE", "NODE_ENV"]
303+
"excludedProjectEnvVars": ["DEBUG", "DEBUG_MODE", "NODE_ENV"],
304+
"includeDirectories": ["path/to/dir1", "~/path/to/dir2", "../path/to/dir3"],
305+
"loadMemoryFromIncludeDirectories": true
284306
}
285307
```
286308

packages/cli/src/config/config.test.ts

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66

77
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
88
import * as os from 'os';
9+
import * as fs from 'fs';
10+
import * as path from 'path';
911
import { loadCliConfig, parseArguments } from './config.js';
1012
import { Settings } from './settings.js';
1113
import { Extension } from './extension.js';
@@ -44,7 +46,7 @@ vi.mock('@google/gemini-cli-core', async () => {
4446
},
4547
loadEnvironment: vi.fn(),
4648
loadServerHierarchicalMemory: vi.fn(
47-
(cwd, debug, fileService, extensionPaths, _maxDirs) =>
49+
(cwd, dirs, debug, fileService, extensionPaths, _maxDirs) =>
4850
Promise.resolve({
4951
memoryContent: extensionPaths?.join(',') || '',
5052
fileCount: extensionPaths?.length || 0,
@@ -487,6 +489,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
487489
await loadCliConfig(settings, extensions, 'session-id', argv);
488490
expect(ServerConfig.loadServerHierarchicalMemory).toHaveBeenCalledWith(
489491
expect.any(String),
492+
[],
490493
false,
491494
expect.any(Object),
492495
[
@@ -1015,3 +1018,85 @@ describe('loadCliConfig ideModeFeature', () => {
10151018
expect(config.getIdeModeFeature()).toBe(false);
10161019
});
10171020
});
1021+
1022+
vi.mock('fs', async () => {
1023+
const actualFs = await vi.importActual<typeof fs>('fs');
1024+
const MOCK_CWD1 = process.cwd();
1025+
const MOCK_CWD2 = path.resolve(path.sep, 'home', 'user', 'project');
1026+
1027+
const mockPaths = new Set([
1028+
MOCK_CWD1,
1029+
MOCK_CWD2,
1030+
path.resolve(path.sep, 'cli', 'path1'),
1031+
path.resolve(path.sep, 'settings', 'path1'),
1032+
path.join(os.homedir(), 'settings', 'path2'),
1033+
path.join(MOCK_CWD2, 'cli', 'path2'),
1034+
path.join(MOCK_CWD2, 'settings', 'path3'),
1035+
]);
1036+
1037+
return {
1038+
...actualFs,
1039+
existsSync: vi.fn((p) => mockPaths.has(p.toString())),
1040+
statSync: vi.fn((p) => {
1041+
if (mockPaths.has(p.toString())) {
1042+
return { isDirectory: () => true };
1043+
}
1044+
// Fallback for other paths if needed, though the test should be specific.
1045+
return actualFs.statSync(p);
1046+
}),
1047+
realpathSync: vi.fn((p) => p),
1048+
};
1049+
});
1050+
1051+
describe('loadCliConfig with includeDirectories', () => {
1052+
const originalArgv = process.argv;
1053+
const originalEnv = { ...process.env };
1054+
1055+
beforeEach(() => {
1056+
vi.resetAllMocks();
1057+
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
1058+
process.env.GEMINI_API_KEY = 'test-api-key';
1059+
vi.spyOn(process, 'cwd').mockReturnValue(
1060+
path.resolve(path.sep, 'home', 'user', 'project'),
1061+
);
1062+
});
1063+
1064+
afterEach(() => {
1065+
process.argv = originalArgv;
1066+
process.env = originalEnv;
1067+
vi.restoreAllMocks();
1068+
});
1069+
1070+
it('should combine and resolve paths from settings and CLI arguments', async () => {
1071+
const mockCwd = path.resolve(path.sep, 'home', 'user', 'project');
1072+
process.argv = [
1073+
'node',
1074+
'script.js',
1075+
'--include-directories',
1076+
`${path.resolve(path.sep, 'cli', 'path1')},${path.join(mockCwd, 'cli', 'path2')}`,
1077+
];
1078+
const argv = await parseArguments();
1079+
const settings: Settings = {
1080+
includeDirectories: [
1081+
path.resolve(path.sep, 'settings', 'path1'),
1082+
path.join(os.homedir(), 'settings', 'path2'),
1083+
path.join(mockCwd, 'settings', 'path3'),
1084+
],
1085+
};
1086+
const config = await loadCliConfig(settings, [], 'test-session', argv);
1087+
const expected = [
1088+
mockCwd,
1089+
path.resolve(path.sep, 'cli', 'path1'),
1090+
path.join(mockCwd, 'cli', 'path2'),
1091+
path.resolve(path.sep, 'settings', 'path1'),
1092+
path.join(os.homedir(), 'settings', 'path2'),
1093+
path.join(mockCwd, 'settings', 'path3'),
1094+
];
1095+
expect(config.getWorkspaceContext().getDirectories()).toEqual(
1096+
expect.arrayContaining(expected),
1097+
);
1098+
expect(config.getWorkspaceContext().getDirectories()).toHaveLength(
1099+
expected.length,
1100+
);
1101+
});
1102+
});

packages/cli/src/config/config.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { Settings } from './settings.js';
2929
import { Extension, annotateActiveExtensions } from './extension.js';
3030
import { getCliVersion } from '../utils/version.js';
3131
import { loadSandboxConfig } from './sandboxConfig.js';
32+
import { resolvePath } from '../utils/resolvePath.js';
3233

3334
// Simple console logger for now - replace with actual logger if available
3435
const logger = {
@@ -65,6 +66,7 @@ export interface CliArgs {
6566
ideModeFeature: boolean | undefined;
6667
proxy: string | undefined;
6768
includeDirectories: string[] | undefined;
69+
loadMemoryFromIncludeDirectories: boolean | undefined;
6870
}
6971

7072
export async function parseArguments(): Promise<CliArgs> {
@@ -212,6 +214,12 @@ export async function parseArguments(): Promise<CliArgs> {
212214
// Handle comma-separated values
213215
dirs.flatMap((dir) => dir.split(',').map((d) => d.trim())),
214216
})
217+
.option('load-memory-from-include-directories', {
218+
type: 'boolean',
219+
description:
220+
'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.',
221+
default: false,
222+
})
215223
.version(await getCliVersion()) // This will enable the --version flag based on package.json
216224
.alias('v', 'version')
217225
.help()
@@ -239,6 +247,7 @@ export async function parseArguments(): Promise<CliArgs> {
239247
// TODO: Consider if App.tsx should get memory via a server call or if Config should refresh itself.
240248
export async function loadHierarchicalGeminiMemory(
241249
currentWorkingDirectory: string,
250+
includeDirectoriesToReadGemini: readonly string[] = [],
242251
debugMode: boolean,
243252
fileService: FileDiscoveryService,
244253
settings: Settings,
@@ -264,6 +273,7 @@ export async function loadHierarchicalGeminiMemory(
264273
// Directly call the server function with the corrected path.
265274
return loadServerHierarchicalMemory(
266275
effectiveCwd,
276+
includeDirectoriesToReadGemini,
267277
debugMode,
268278
fileService,
269279
extensionContextFilePaths,
@@ -325,9 +335,14 @@ export async function loadCliConfig(
325335
...settings.fileFiltering,
326336
};
327337

338+
const includeDirectories = (settings.includeDirectories || [])
339+
.map(resolvePath)
340+
.concat((argv.includeDirectories || []).map(resolvePath));
341+
328342
// Call the (now wrapper) loadHierarchicalGeminiMemory which calls the server's version
329343
const { memoryContent, fileCount } = await loadHierarchicalGeminiMemory(
330344
process.cwd(),
345+
settings.loadMemoryFromIncludeDirectories ? includeDirectories : [],
331346
debugMode,
332347
fileService,
333348
settings,
@@ -393,7 +408,11 @@ export async function loadCliConfig(
393408
embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL,
394409
sandbox: sandboxConfig,
395410
targetDir: process.cwd(),
396-
includeDirectories: argv.includeDirectories,
411+
includeDirectories,
412+
loadMemoryFromIncludeDirectories:
413+
argv.loadMemoryFromIncludeDirectories ||
414+
settings.loadMemoryFromIncludeDirectories ||
415+
false,
397416
debugMode,
398417
question: argv.promptInteractive || argv.prompt || '',
399418
fullContext: argv.allFiles || argv.all_files || false,

packages/cli/src/config/settings.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ describe('Settings Loading and Merging', () => {
112112
expect(settings.merged).toEqual({
113113
customThemes: {},
114114
mcpServers: {},
115+
includeDirectories: [],
115116
});
116117
expect(settings.errors.length).toBe(0);
117118
});
@@ -145,6 +146,7 @@ describe('Settings Loading and Merging', () => {
145146
...systemSettingsContent,
146147
customThemes: {},
147148
mcpServers: {},
149+
includeDirectories: [],
148150
});
149151
});
150152

@@ -178,6 +180,7 @@ describe('Settings Loading and Merging', () => {
178180
...userSettingsContent,
179181
customThemes: {},
180182
mcpServers: {},
183+
includeDirectories: [],
181184
});
182185
});
183186

@@ -209,6 +212,7 @@ describe('Settings Loading and Merging', () => {
209212
...workspaceSettingsContent,
210213
customThemes: {},
211214
mcpServers: {},
215+
includeDirectories: [],
212216
});
213217
});
214218

@@ -246,6 +250,7 @@ describe('Settings Loading and Merging', () => {
246250
contextFileName: 'WORKSPACE_CONTEXT.md',
247251
customThemes: {},
248252
mcpServers: {},
253+
includeDirectories: [],
249254
});
250255
});
251256

@@ -295,6 +300,7 @@ describe('Settings Loading and Merging', () => {
295300
allowMCPServers: ['server1', 'server2'],
296301
customThemes: {},
297302
mcpServers: {},
303+
includeDirectories: [],
298304
});
299305
});
300306

@@ -616,6 +622,40 @@ describe('Settings Loading and Merging', () => {
616622
expect(settings.merged.mcpServers).toEqual({});
617623
});
618624

625+
it('should merge includeDirectories from all scopes', () => {
626+
(mockFsExistsSync as Mock).mockReturnValue(true);
627+
const systemSettingsContent = {
628+
includeDirectories: ['/system/dir'],
629+
};
630+
const userSettingsContent = {
631+
includeDirectories: ['/user/dir1', '/user/dir2'],
632+
};
633+
const workspaceSettingsContent = {
634+
includeDirectories: ['/workspace/dir'],
635+
};
636+
637+
(fs.readFileSync as Mock).mockImplementation(
638+
(p: fs.PathOrFileDescriptor) => {
639+
if (p === getSystemSettingsPath())
640+
return JSON.stringify(systemSettingsContent);
641+
if (p === USER_SETTINGS_PATH)
642+
return JSON.stringify(userSettingsContent);
643+
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
644+
return JSON.stringify(workspaceSettingsContent);
645+
return '{}';
646+
},
647+
);
648+
649+
const settings = loadSettings(MOCK_WORKSPACE_DIR);
650+
651+
expect(settings.merged.includeDirectories).toEqual([
652+
'/system/dir',
653+
'/user/dir1',
654+
'/user/dir2',
655+
'/workspace/dir',
656+
]);
657+
});
658+
619659
it('should handle JSON parsing errors gracefully', () => {
620660
(mockFsExistsSync as Mock).mockReturnValue(true); // Both files "exist"
621661
const invalidJsonContent = 'invalid json';
@@ -654,6 +694,7 @@ describe('Settings Loading and Merging', () => {
654694
expect(settings.merged).toEqual({
655695
customThemes: {},
656696
mcpServers: {},
697+
includeDirectories: [],
657698
});
658699

659700
// Check that error objects are populated in settings.errors
@@ -1090,6 +1131,7 @@ describe('Settings Loading and Merging', () => {
10901131
...systemSettingsContent,
10911132
customThemes: {},
10921133
mcpServers: {},
1134+
includeDirectories: [],
10931135
});
10941136
});
10951137
});

packages/cli/src/config/settings.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,10 @@ export interface Settings {
126126
// Environment variables to exclude from project .env files
127127
excludedProjectEnvVars?: string[];
128128
dnsResolutionOrder?: DnsResolutionOrder;
129+
130+
includeDirectories?: string[];
131+
132+
loadMemoryFromIncludeDirectories?: boolean;
129133
}
130134

131135
export interface SettingsError {
@@ -181,6 +185,11 @@ export class LoadedSettings {
181185
...(workspace.mcpServers || {}),
182186
...(system.mcpServers || {}),
183187
},
188+
includeDirectories: [
189+
...(system.includeDirectories || []),
190+
...(user.includeDirectories || []),
191+
...(workspace.includeDirectories || []),
192+
],
184193
};
185194
}
186195

packages/cli/src/ui/App.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,9 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
276276
try {
277277
const { memoryContent, fileCount } = await loadHierarchicalGeminiMemory(
278278
process.cwd(),
279+
settings.merged.loadMemoryFromIncludeDirectories
280+
? config.getWorkspaceContext().getDirectories()
281+
: [],
279282
config.getDebugMode(),
280283
config.getFileService(),
281284
settings.merged,
@@ -480,6 +483,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
480483
openPrivacyNotice,
481484
toggleVimEnabled,
482485
setIsProcessing,
486+
setGeminiMdFileCount,
483487
);
484488

485489
const {
@@ -599,7 +603,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
599603
if (config) {
600604
setGeminiMdFileCount(config.getGeminiMdFileCount());
601605
}
602-
}, [config]);
606+
}, [config, config.getGeminiMdFileCount]);
603607

604608
const logger = useLogger();
605609
const [userMessages, setUserMessages] = useState<string[]>([]);

0 commit comments

Comments
 (0)