mirror of
https://github.com/google-gemini/gemini-cli
synced 2026-04-21 13:37:17 +00:00
Merge 8b8416055c into a38e2f0048
This commit is contained in:
commit
cb4f9a5691
8 changed files with 351 additions and 6 deletions
|
|
@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
165
packages/cli/src/config/workspace-arg.test.ts
Normal file
165
packages/cli/src/config/workspace-arg.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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', () => ({
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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', () => ({
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
]);
|
||||
|
|
|
|||
Loading…
Reference in a new issue