diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 180f461749..263c7938d9 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -999,6 +999,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => { }), 200, // maxDirs ['.git'], // boundaryMarkers + undefined, // workspaceRootOverride ); }); @@ -1029,6 +1030,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => { }), 200, ['.git'], // boundaryMarkers + undefined, // workspaceRootOverride ); }); @@ -1058,6 +1060,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => { }), 200, ['.git'], // boundaryMarkers + undefined, // workspaceRootOverride ); }); }); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 213c22120e..3cec5c54fb 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -8,6 +8,7 @@ import yargs from 'yargs'; import { hideBin } from 'yargs/helpers'; import process from 'node:process'; import * as path from 'node:path'; +import { existsSync, statSync } from 'node:fs'; import { execa } from 'execa'; import { mcpCommand } from '../commands/mcp.js'; import { extensionsCommand } from '../commands/extensions.js'; @@ -107,6 +108,7 @@ export interface CliArgs { rawOutput: boolean | undefined; acceptRawOutputRisk: boolean | undefined; isCommand: boolean | undefined; + workspace?: string; } /** @@ -124,6 +126,25 @@ const coerceCommaSeparated = (values: string[]): string[] => { ); }; +/** + * Pre-parses the command line arguments to find the workspace flag. + * Used for early setup before full argument parsing with settings, + * since settings are loaded from the workspace directory. + */ +export function getWorkspaceArg(argv: string[]): string | undefined { + const result = yargs(hideBin(argv)) + .help(false) + .version(false) + .option('workspace', { type: 'string' }) + .strict(false) + .exitProcess(false) + .parseSync(); + + return typeof result.workspace === 'string' + ? path.resolve(result.workspace.trim()) + : undefined; +} + /** * Pre-parses the command line arguments to find the worktree flag. * Used for early setup before full argument parsing with settings. @@ -305,6 +326,13 @@ export async function parseArguments( return trimmed; }, }) + .option('workspace', { + type: 'string', + nargs: 1, + description: + 'Use this directory as the workspace root. Prevents the CLI from walking upward to find .git, GEMINI.md, or .gemini directories.', + coerce: (value: string): string => path.resolve(value.trim()), + }) .option('sandbox', { alias: 's', type: 'boolean', @@ -526,7 +554,26 @@ export async function loadCliConfig( argv: CliArgs, options: LoadCliConfigOptions = {}, ): Promise { - const { cwd = process.cwd(), projectHooks } = options; + const { cwd: cwdOption = process.cwd(), projectHooks } = options; + + // --workspace takes priority over the default cwd. Resolve symlinks and + // validate it is an existing directory before using it for anything else. + if (argv.workspace) { + const resolvedWorkspace = resolveToRealPath(argv.workspace); + if (!existsSync(resolvedWorkspace)) { + throw new FatalConfigError( + `--workspace directory does not exist: ${argv.workspace}`, + ); + } + if (!statSync(resolvedWorkspace).isDirectory()) { + throw new FatalConfigError( + `--workspace path is not a directory: ${argv.workspace}`, + ); + } + argv.workspace = resolvedWorkspace; + } + + const cwd = argv.workspace ?? cwdOption; const debugMode = isDebugMode(argv); const worktreeSettings = @@ -647,6 +694,7 @@ export async function loadCliConfig( memoryFileFiltering, settings.context?.discoveryMaxDirs, settings.context?.memoryBoundaryMarkers, + argv.workspace, ); memoryContent = result.memoryContent; fileCount = result.fileCount; diff --git a/packages/cli/src/config/workspace-arg.test.ts b/packages/cli/src/config/workspace-arg.test.ts new file mode 100644 index 0000000000..9ed82d3171 --- /dev/null +++ b/packages/cli/src/config/workspace-arg.test.ts @@ -0,0 +1,165 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as path from 'node:path'; +import * as fs from 'node:fs'; +import { getWorkspaceArg, loadCliConfig, type CliArgs } from './config.js'; +import { FatalConfigError } from '@google/gemini-cli-core'; +import { createTestMergedSettings } from './settings.js'; + +vi.mock('./trustedFolders.js', () => ({ + isWorkspaceTrusted: vi.fn(() => ({ isTrusted: true, source: 'file' })), +})); + +vi.mock('./sandboxConfig.js', () => ({ + loadSandboxConfig: vi.fn(async () => undefined), +})); + +vi.mock('../commands/utils.js', () => ({ + exitCli: vi.fn(), +})); + +// --------------------------------------------------------------------------- +// getWorkspaceArg +// --------------------------------------------------------------------------- +describe('getWorkspaceArg', () => { + it('returns undefined when --workspace is not provided', () => { + expect(getWorkspaceArg(['node', 'gemini'])).toBeUndefined(); + expect( + getWorkspaceArg(['node', 'gemini', '--prompt', 'hello']), + ).toBeUndefined(); + }); + + it('returns the resolved absolute path when --workspace is provided', () => { + const result = getWorkspaceArg([ + 'node', + 'gemini', + '--workspace', + './my_dir', + ]); + expect(result).toBe(path.resolve('./my_dir')); + }); + + it('resolves relative paths to absolute', () => { + const result = getWorkspaceArg([ + 'node', + 'gemini', + '--workspace', + '../sibling', + ]); + expect(result).toBe(path.resolve('../sibling')); + }); + + it('handles an already-absolute path', () => { + const absPath = path.resolve('/tmp/workspace'); + const result = getWorkspaceArg(['node', 'gemini', '--workspace', absPath]); + expect(result).toBe(absPath); + }); + + it('trims whitespace from the path', () => { + const result = getWorkspaceArg([ + 'node', + 'gemini', + '--workspace', + ' ./my_dir ', + ]); + expect(result).toBe(path.resolve('./my_dir')); + }); + + it('ignores unrelated flags', () => { + const result = getWorkspaceArg([ + 'node', + 'gemini', + '--prompt', + 'hello', + '--workspace', + './ws', + '--yolo', + ]); + expect(result).toBe(path.resolve('./ws')); + }); +}); + +// --------------------------------------------------------------------------- +// loadCliConfig: --workspace validation +// --------------------------------------------------------------------------- +describe('loadCliConfig --workspace validation', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(path.resolve('.'), 'workspace-test-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + const baseArgv = (): CliArgs => ({ + query: undefined, + model: undefined, + sandbox: undefined, + debug: false, + prompt: undefined, + promptInteractive: undefined, + yolo: false, + approvalMode: undefined, + policy: undefined, + adminPolicy: undefined, + allowedMcpServerNames: undefined, + allowedTools: undefined, + extensions: undefined, + listExtensions: undefined, + resume: undefined, + listSessions: undefined, + deleteSession: undefined, + includeDirectories: undefined, + screenReader: undefined, + useWriteTodos: undefined, + outputFormat: undefined, + fakeResponses: undefined, + recordResponses: undefined, + rawOutput: undefined, + acceptRawOutputRisk: undefined, + isCommand: undefined, + workspace: undefined, + }); + + it('throws FatalConfigError when --workspace directory does not exist', async () => { + const argv = { + ...baseArgv(), + workspace: path.join(tmpDir, 'does-not-exist'), + }; + const settings = createTestMergedSettings(); + await expect(loadCliConfig(settings, 'session-id', argv)).rejects.toThrow( + FatalConfigError, + ); + }); + + it('throws FatalConfigError when --workspace path is a file, not a directory', async () => { + const filePath = path.join(tmpDir, 'notadir.txt'); + fs.writeFileSync(filePath, 'hello'); + const argv = { ...baseArgv(), workspace: filePath }; + const settings = createTestMergedSettings(); + await expect(loadCliConfig(settings, 'session-id', argv)).rejects.toThrow( + FatalConfigError, + ); + }); + + it('does not throw when --workspace is a valid directory', async () => { + const argv = { ...baseArgv(), workspace: tmpDir }; + const settings = createTestMergedSettings(); + // Should not throw — we just verify it reaches the point past validation. + // It may fail later for unrelated reasons (missing auth etc.) in unit tests, + // so we only assert no FatalConfigError from workspace validation. + try { + await loadCliConfig(settings, 'session-id', argv); + } catch (err) { + expect(err).not.toBeInstanceOf(FatalConfigError); + } + }); +}); diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 5b31d153fe..404d168a7e 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -202,6 +202,7 @@ vi.mock('./config/config.js', () => ({ isDebugMode: vi.fn(() => false), getRequestedWorktreeName: vi.fn(() => undefined), getWorktreeArg: vi.fn(() => undefined), + getWorkspaceArg: vi.fn(() => undefined), })); vi.mock('read-package-up', () => ({ diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 6e257270d7..41d2d324a0 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -269,8 +269,12 @@ export async function main() { slashCommandConflictHandler.start(); registerCleanup(() => slashCommandConflictHandler.stop()); + // Pre-parse --workspace before loading settings, since project settings + // (.gemini/settings.json) are loaded relative to the workspace directory. + const workspaceArg = cliConfig.getWorkspaceArg(process.argv); + const loadSettingsHandle = startupProfiler.start('load_settings'); - const settings = loadSettings(); + const settings = loadSettings(workspaceArg ?? process.cwd()); loadSettingsHandle?.end(); // If a worktree is requested and enabled, set it up early. diff --git a/packages/cli/src/gemini_cleanup.test.tsx b/packages/cli/src/gemini_cleanup.test.tsx index 2df1ab4d82..8bc9f445cf 100644 --- a/packages/cli/src/gemini_cleanup.test.tsx +++ b/packages/cli/src/gemini_cleanup.test.tsx @@ -80,6 +80,7 @@ vi.mock('./config/config.js', () => ({ isDebugMode: vi.fn(() => false), getRequestedWorktreeName: vi.fn(() => undefined), getWorktreeArg: vi.fn(() => undefined), + getWorkspaceArg: vi.fn(() => undefined), })); vi.mock('read-package-up', () => ({ diff --git a/packages/core/src/utils/memoryDiscovery.test.ts b/packages/core/src/utils/memoryDiscovery.test.ts index 9e18a41f66..1c7fdff88b 100644 --- a/packages/core/src/utils/memoryDiscovery.test.ts +++ b/packages/core/src/utils/memoryDiscovery.test.ts @@ -1361,6 +1361,112 @@ included directory memory }); }); + describe('workspaceRootOverride (--workspace flag)', () => { + it('should not traverse above the workspace root even when a parent has .git', async () => { + // Layout: + // testRootDir/ + // parent/ ← has .git, GEMINI.md (should NOT be picked up) + // workspace/ ← the --workspace dir, has GEMINI.md (should be picked up) + // src/ ← cwd + const parent = await createEmptyDir(path.join(testRootDir, 'parent')); + await createEmptyDir(path.join(parent, '.git')); + const workspace = await createEmptyDir(path.join(parent, 'workspace')); + const src = await createEmptyDir(path.join(workspace, 'src')); + + const parentMemory = await createTestFile( + path.join(parent, DEFAULT_CONTEXT_FILENAME), + 'Parent memory — should NOT appear', + ); + const workspaceMemory = await createTestFile( + path.join(workspace, DEFAULT_CONTEXT_FILENAME), + 'Workspace memory — should appear', + ); + + const result = flattenResult( + await loadServerHierarchicalMemory( + src, + [], + new FileDiscoveryService(workspace), + new SimpleExtensionLoader([]), + true, + 'tree', + undefined, + 200, + ['.git'], + workspace, // workspaceRootOverride + ), + ); + + expect(result.filePaths).toContain(normalizePath(workspaceMemory)); + expect(result.filePaths).not.toContain(normalizePath(parentMemory)); + }); + + it('should include GEMINI.md files inside the workspace (downward scan)', async () => { + // Layout: + // workspace/ + // GEMINI.md ← picked up + // src/ + // GEMINI.md ← picked up (downward scan) + const workspace = await createEmptyDir( + path.join(testRootDir, 'workspace_down'), + ); + const src = await createEmptyDir(path.join(workspace, 'src')); + + const wsMemory = await createTestFile( + path.join(workspace, DEFAULT_CONTEXT_FILENAME), + 'Workspace root memory', + ); + const srcMemory = await createTestFile( + path.join(src, DEFAULT_CONTEXT_FILENAME), + 'Src memory', + ); + + const result = flattenResult( + await loadServerHierarchicalMemory( + workspace, + [], + new FileDiscoveryService(workspace), + new SimpleExtensionLoader([]), + true, + 'tree', + undefined, + 200, + ['.git'], + workspace, // workspaceRootOverride + ), + ); + + expect(result.filePaths).toContain(normalizePath(wsMemory)); + expect(result.filePaths).toContain(normalizePath(srcMemory)); + }); + + it('should behave identically to the default when no override is given', async () => { + // Verify the new optional parameter does not alter behaviour when absent. + await createEmptyDir(path.join(projectRoot, '.git')); + const projMemory = await createTestFile( + path.join(projectRoot, DEFAULT_CONTEXT_FILENAME), + 'Project memory', + ); + const cwdMemory = await createTestFile( + path.join(cwd, DEFAULT_CONTEXT_FILENAME), + 'CWD memory', + ); + + const withoutOverride = flattenResult( + await loadServerHierarchicalMemory( + cwd, + [], + new FileDiscoveryService(projectRoot), + new SimpleExtensionLoader([]), + true, + ), + ); + + expect(withoutOverride.filePaths).toContain(normalizePath(projMemory)); + expect(withoutOverride.filePaths).toContain(normalizePath(cwdMemory)); + }); + }); + it('refreshServerHierarchicalMemory should refresh memory and update config', async () => { const extensionLoader = new SimpleExtensionLoader([]); const config = new Config({ diff --git a/packages/core/src/utils/memoryDiscovery.ts b/packages/core/src/utils/memoryDiscovery.ts index f59aed4460..6cd574a3b8 100644 --- a/packages/core/src/utils/memoryDiscovery.ts +++ b/packages/core/src/utils/memoryDiscovery.ts @@ -214,6 +214,7 @@ async function getGeminiMdFilePathsInternal( fileFilteringOptions: FileFilteringOptions, maxDirs: number, boundaryMarkers: readonly string[] = ['.git'], + workspaceRootOverride?: string, ): Promise<{ global: string[]; project: string[] }> { const dirs = new Set([ ...includeDirectoriesToReadGemini, @@ -237,6 +238,7 @@ async function getGeminiMdFilePathsInternal( fileFilteringOptions, maxDirs, boundaryMarkers, + workspaceRootOverride, ), ); @@ -269,6 +271,7 @@ async function getGeminiMdFilePathsInternalForEachDir( fileFilteringOptions: FileFilteringOptions, maxDirs: number, boundaryMarkers: readonly string[] = ['.git'], + workspaceRootOverride?: string, ): Promise<{ global: string[]; project: string[] }> { const globalPaths = new Set(); const projectPaths = new Set(); @@ -305,17 +308,29 @@ async function getGeminiMdFilePathsInternalForEachDir( resolvedCwd, ); - const projectRoot = await findProjectRoot(resolvedCwd, boundaryMarkers); + // When an explicit workspace root is provided via --workspace, use it + // directly as the project root. This prevents any upward traversal + // above the user-specified directory boundary. + const projectRoot = workspaceRootOverride + ? normalizePath(workspaceRootOverride) + : await findProjectRoot(resolvedCwd, boundaryMarkers); debugLogger.debug( '[DEBUG] [MemoryDiscovery] Determined project root:', projectRoot ?? 'None', + workspaceRootOverride ? '(from --workspace override)' : '', ); const upwardPaths: string[] = []; let currentDir = resolvedCwd; - const ultimateStopDir = projectRoot - ? normalizePath(path.dirname(projectRoot)) - : normalizePath(path.dirname(resolvedHome)); + // When a workspace root override is provided, the upward walk stops AT + // the workspace dir (it is included, but nothing above it is scanned). + // Without an override, the walk stops at the parent of the project root + // (the original behaviour). + const ultimateStopDir = workspaceRootOverride + ? normalizePath(workspaceRootOverride) + : projectRoot + ? normalizePath(path.dirname(projectRoot)) + : normalizePath(path.dirname(resolvedHome)); while ( currentDir && @@ -647,6 +662,7 @@ export async function loadServerHierarchicalMemory( fileFilteringOptions?: FileFilteringOptions, maxDirs: number = 200, boundaryMarkers: readonly string[] = ['.git'], + workspaceRootOverride?: string, ): Promise { // FIX: Use real, canonical paths for a reliable comparison to handle symlinks. const realCwd = normalizePath( @@ -680,6 +696,7 @@ export async function loadServerHierarchicalMemory( fileFilteringOptions || DEFAULT_MEMORY_FILE_FILTERING_OPTIONS, maxDirs, boundaryMarkers, + workspaceRootOverride, ), Promise.resolve(getExtensionMemoryPaths(extensionLoader)), ]);