mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-14 03:50:49 -07:00
feat(worktree): add Git worktree support for isolated parallel sessions (#22973)
This commit is contained in:
@@ -226,6 +226,51 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe('parseArguments', () => {
|
||||
describe('worktree', () => {
|
||||
it('should parse --worktree flag when provided with a name', async () => {
|
||||
process.argv = ['node', 'script.js', '--worktree', 'my-feature'];
|
||||
const settings = createTestMergedSettings();
|
||||
settings.experimental.worktrees = true;
|
||||
const argv = await parseArguments(settings);
|
||||
expect(argv.worktree).toBe('my-feature');
|
||||
});
|
||||
|
||||
it('should generate a random name when --worktree is provided without a name', async () => {
|
||||
process.argv = ['node', 'script.js', '--worktree'];
|
||||
const settings = createTestMergedSettings();
|
||||
settings.experimental.worktrees = true;
|
||||
const argv = await parseArguments(settings);
|
||||
expect(argv.worktree).toBeDefined();
|
||||
expect(argv.worktree).not.toBe('');
|
||||
expect(typeof argv.worktree).toBe('string');
|
||||
});
|
||||
|
||||
it('should throw an error when --worktree is used but experimental.worktrees is not enabled', async () => {
|
||||
process.argv = ['node', 'script.js', '--worktree', 'feature'];
|
||||
const settings = createTestMergedSettings();
|
||||
settings.experimental.worktrees = false;
|
||||
|
||||
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||
throw new Error('process.exit called');
|
||||
});
|
||||
const mockConsoleError = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
await expect(parseArguments(settings)).rejects.toThrow(
|
||||
'process.exit called',
|
||||
);
|
||||
expect(mockConsoleError).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'The --worktree flag is only available when experimental.worktrees is enabled in your settings.',
|
||||
),
|
||||
);
|
||||
|
||||
mockExit.mockRestore();
|
||||
mockConsoleError.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
description: 'long flags',
|
||||
|
||||
@@ -4,10 +4,11 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import yargs from 'yargs/yargs';
|
||||
import yargs from 'yargs';
|
||||
import { hideBin } from 'yargs/helpers';
|
||||
import process from 'node:process';
|
||||
import * as path from 'node:path';
|
||||
import { execa } from 'execa';
|
||||
import { mcpCommand } from '../commands/mcp.js';
|
||||
import { extensionsCommand } from '../commands/extensions.js';
|
||||
import { skillsCommand } from '../commands/skills.js';
|
||||
@@ -38,6 +39,9 @@ import {
|
||||
applyAdminAllowlist,
|
||||
applyRequiredServers,
|
||||
getAdminBlockedMcpServersMessage,
|
||||
getProjectRootForWorktree,
|
||||
isGeminiWorktree,
|
||||
type WorktreeSettings,
|
||||
type HookDefinition,
|
||||
type HookEventName,
|
||||
type OutputFormat,
|
||||
@@ -48,6 +52,8 @@ import {
|
||||
type MergedSettings,
|
||||
saveModelChange,
|
||||
loadSettings,
|
||||
isWorktreeEnabled,
|
||||
type LoadedSettings,
|
||||
} from './settings.js';
|
||||
|
||||
import { loadSandboxConfig } from './sandboxConfig.js';
|
||||
@@ -74,6 +80,7 @@ export interface CliArgs {
|
||||
debug: boolean | undefined;
|
||||
prompt: string | undefined;
|
||||
promptInteractive: string | undefined;
|
||||
worktree?: string;
|
||||
|
||||
yolo: boolean | undefined;
|
||||
approvalMode: string | undefined;
|
||||
@@ -115,6 +122,36 @@ const coerceCommaSeparated = (values: string[]): string[] => {
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Pre-parses the command line arguments to find the worktree flag.
|
||||
* Used for early setup before full argument parsing with settings.
|
||||
*/
|
||||
export function getWorktreeArg(argv: string[]): string | undefined {
|
||||
const result = yargs(hideBin(argv))
|
||||
.help(false)
|
||||
.version(false)
|
||||
.option('worktree', { alias: 'w', type: 'string' })
|
||||
.strict(false)
|
||||
.exitProcess(false)
|
||||
.parseSync();
|
||||
|
||||
if (result.worktree === undefined) return undefined;
|
||||
return typeof result.worktree === 'string' ? result.worktree.trim() : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a worktree is requested via CLI and enabled in settings.
|
||||
* Returns the requested name (can be empty string for auto-generated) or undefined.
|
||||
*/
|
||||
export function getRequestedWorktreeName(
|
||||
settings: LoadedSettings,
|
||||
): string | undefined {
|
||||
if (!isWorktreeEnabled(settings)) {
|
||||
return undefined;
|
||||
}
|
||||
return getWorktreeArg(process.argv);
|
||||
}
|
||||
|
||||
export async function parseArguments(
|
||||
settings: MergedSettings,
|
||||
): Promise<CliArgs> {
|
||||
@@ -158,6 +195,20 @@ export async function parseArguments(
|
||||
description:
|
||||
'Execute the provided prompt and continue in interactive mode',
|
||||
})
|
||||
.option('worktree', {
|
||||
alias: 'w',
|
||||
type: 'string',
|
||||
skipValidation: true,
|
||||
description:
|
||||
'Start Gemini in a new git worktree. If no name is provided, one is generated automatically.',
|
||||
coerce: (value: unknown): string => {
|
||||
const trimmed = typeof value === 'string' ? value.trim() : '';
|
||||
if (trimmed === '') {
|
||||
return Math.random().toString(36).substring(2, 10);
|
||||
}
|
||||
return trimmed;
|
||||
},
|
||||
})
|
||||
.option('sandbox', {
|
||||
alias: 's',
|
||||
type: 'boolean',
|
||||
@@ -335,6 +386,9 @@ export async function parseArguments(
|
||||
) {
|
||||
return `Invalid values:\n Argument: output-format, Given: "${argv['outputFormat']}", Choices: "text", "json", "stream-json"`;
|
||||
}
|
||||
if (argv['worktree'] && !settings.experimental?.worktrees) {
|
||||
return 'The --worktree flag is only available when experimental.worktrees is enabled in your settings.';
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -420,6 +474,7 @@ export interface LoadCliConfigOptions {
|
||||
projectHooks?: { [K in HookEventName]?: HookDefinition[] } & {
|
||||
disabled?: string[];
|
||||
};
|
||||
worktreeSettings?: WorktreeSettings;
|
||||
}
|
||||
|
||||
export async function loadCliConfig(
|
||||
@@ -431,6 +486,9 @@ export async function loadCliConfig(
|
||||
const { cwd = process.cwd(), projectHooks } = options;
|
||||
const debugMode = isDebugMode(argv);
|
||||
|
||||
const worktreeSettings =
|
||||
options.worktreeSettings ?? (await resolveWorktreeSettings(cwd));
|
||||
|
||||
if (argv.sandbox) {
|
||||
process.env['GEMINI_SANDBOX'] = 'true';
|
||||
}
|
||||
@@ -802,6 +860,7 @@ export async function loadCliConfig(
|
||||
importFormat: settings.context?.importFormat,
|
||||
debugMode,
|
||||
question,
|
||||
worktreeSettings,
|
||||
|
||||
coreTools: settings.tools?.core || undefined,
|
||||
allowedTools: allowedTools.length > 0 ? allowedTools : undefined,
|
||||
@@ -943,3 +1002,48 @@ function mergeExcludeTools(
|
||||
]);
|
||||
return Array.from(allExcludeTools);
|
||||
}
|
||||
|
||||
async function resolveWorktreeSettings(
|
||||
cwd: string,
|
||||
): Promise<WorktreeSettings | undefined> {
|
||||
let worktreePath: string | undefined;
|
||||
try {
|
||||
const { stdout } = await execa('git', ['rev-parse', '--show-toplevel'], {
|
||||
cwd,
|
||||
});
|
||||
const toplevel = stdout.trim();
|
||||
const projectRoot = await getProjectRootForWorktree(toplevel);
|
||||
|
||||
if (isGeminiWorktree(toplevel, projectRoot)) {
|
||||
worktreePath = toplevel;
|
||||
}
|
||||
} catch (_e) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!worktreePath) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let worktreeBaseSha: string | undefined;
|
||||
try {
|
||||
const { stdout } = await execa('git', ['rev-parse', 'HEAD'], {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
worktreeBaseSha = stdout.trim();
|
||||
} catch (e: unknown) {
|
||||
debugLogger.debug(
|
||||
`Failed to resolve worktree base SHA at ${worktreePath}: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!worktreeBaseSha) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
name: path.basename(worktreePath),
|
||||
path: worktreePath,
|
||||
baseSha: worktreeBaseSha,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -632,6 +632,10 @@ export function resetSettingsCacheForTesting() {
|
||||
settingsCache.clear();
|
||||
}
|
||||
|
||||
export function isWorktreeEnabled(settings: LoadedSettings): boolean {
|
||||
return settings.merged.experimental.worktrees;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads settings from user and workspace directories.
|
||||
* Project settings override user settings.
|
||||
|
||||
@@ -1906,6 +1906,16 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Enable local and remote subagents.',
|
||||
showInDialog: false,
|
||||
},
|
||||
worktrees: {
|
||||
type: 'boolean',
|
||||
label: 'Enable Git Worktrees',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description:
|
||||
'Enable automated Git worktree management for parallel work.',
|
||||
showInDialog: true,
|
||||
},
|
||||
extensionManagement: {
|
||||
type: 'boolean',
|
||||
label: 'Extension Management',
|
||||
|
||||
Reference in New Issue
Block a user