This commit is contained in:
Arya Kaushal 2026-04-21 09:35:37 +00:00 committed by GitHub
commit cb4f9a5691
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 351 additions and 6 deletions

View file

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

View file

@ -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<Config> {
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;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<string>([
...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<string>();
const projectPaths = new Set<string>();
@ -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<LoadServerHierarchicalMemoryResponse> {
// 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)),
]);