feat(worktree): add Git worktree support for isolated parallel sessions (#22973)

This commit is contained in:
Jerop Kipruto
2026-03-20 10:10:51 -04:00
committed by GitHub
parent b9c87c14a2
commit 5a3c7154df
23 changed files with 1090 additions and 9 deletions
+45
View File
@@ -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',
+105 -1
View File
@@ -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,
};
}
+4
View File
@@ -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.
+10
View File
@@ -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',
+2
View File
@@ -199,6 +199,8 @@ vi.mock('./config/config.js', () => ({
networkAccess: false,
}),
isDebugMode: vi.fn(() => false),
getRequestedWorktreeName: vi.fn(() => undefined),
getWorktreeArg: vi.fn(() => undefined),
}));
vi.mock('read-package-up', () => ({
+10
View File
@@ -9,6 +9,7 @@ import {
WarningPriority,
type Config,
type ResumedSessionData,
type WorktreeInfo,
type OutputPayload,
type ConsoleLogPayload,
type UserFeedbackPayload,
@@ -63,6 +64,7 @@ import {
registerTelemetryConfig,
setupSignalHandlers,
} from './utils/cleanup.js';
import { setupWorktree } from './utils/worktreeSetup.js';
import {
cleanupToolOutputFiles,
cleanupExpiredSessions,
@@ -210,6 +212,13 @@ export async function main() {
const settings = loadSettings();
loadSettingsHandle?.end();
// If a worktree is requested and enabled, set it up early.
const requestedWorktree = cliConfig.getRequestedWorktreeName(settings);
let worktreeInfo: WorktreeInfo | undefined;
if (requestedWorktree !== undefined) {
worktreeInfo = await setupWorktree(requestedWorktree || undefined);
}
// Report settings errors once during startup
settings.errors.forEach((error) => {
coreEvents.emitFeedback('warning', error.message);
@@ -426,6 +435,7 @@ export async function main() {
const loadConfigHandle = startupProfiler.start('load_cli_config');
const config = await loadCliConfig(settings.merged, sessionId, argv, {
projectHooks: settings.workspace.settings.hooks,
worktreeSettings: worktreeInfo,
});
loadConfigHandle?.end();
+2
View File
@@ -72,6 +72,8 @@ vi.mock('./config/config.js', () => ({
} as unknown as Config),
parseArguments: vi.fn().mockResolvedValue({}),
isDebugMode: vi.fn(() => false),
getRequestedWorktreeName: vi.fn(() => undefined),
getWorktreeArg: vi.fn(() => undefined),
}));
vi.mock('read-package-up', () => ({
@@ -8,10 +8,12 @@ import { renderWithProviders } from '../../test-utils/render.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { SessionSummaryDisplay } from './SessionSummaryDisplay.js';
import * as SessionContext from '../contexts/SessionContext.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { type SessionMetrics } from '../contexts/SessionContext.js';
import {
ToolCallDecision,
getShellConfiguration,
type WorktreeSettings,
} from '@google/gemini-cli-core';
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
@@ -24,19 +26,30 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
});
vi.mock('../contexts/SessionContext.js', async (importOriginal) => {
const actual = await importOriginal<typeof SessionContext>();
const actual =
await importOriginal<typeof import('../contexts/SessionContext.js')>();
return {
...actual,
useSessionStats: vi.fn(),
};
});
vi.mock('../contexts/ConfigContext.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../contexts/ConfigContext.js')>();
return {
...actual,
useConfig: vi.fn(),
};
});
const getShellConfigurationMock = vi.mocked(getShellConfiguration);
const useSessionStatsMock = vi.mocked(SessionContext.useSessionStats);
const renderWithMockedStats = async (
metrics: SessionMetrics,
sessionId = 'test-session',
worktreeSettings?: WorktreeSettings,
) => {
useSessionStatsMock.mockReturnValue({
stats: {
@@ -49,7 +62,11 @@ const renderWithMockedStats = async (
getPromptCount: () => 5,
startNewPrompt: vi.fn(),
});
} as unknown as ReturnType<typeof SessionContext.useSessionStats>);
vi.mocked(useConfig).mockReturnValue({
getWorktreeSettings: () => worktreeSettings,
} as never);
const result = await renderWithProviders(
<SessionSummaryDisplay duration="1h 23m 45s" />,
@@ -188,4 +205,30 @@ describe('<SessionSummaryDisplay />', () => {
unmount();
});
});
describe('Worktree status', () => {
it('renders worktree instructions when worktreeSettings are present', async () => {
const worktreeSettings: WorktreeSettings = {
name: 'foo-bar',
path: '/path/to/foo-bar',
baseSha: 'base-sha',
};
const { lastFrame, unmount } = await renderWithMockedStats(
emptyMetrics,
'test-session',
worktreeSettings,
);
const output = lastFrame();
expect(output).toContain('To resume work in this worktree:');
expect(output).toContain(
'cd /path/to/foo-bar && gemini --resume test-session',
);
expect(output).toContain(
'To remove manually: git worktree remove /path/to/foo-bar',
);
unmount();
});
});
});
@@ -7,6 +7,7 @@
import type React from 'react';
import { StatsDisplay } from './StatsDisplay.js';
import { useSessionStats } from '../contexts/SessionContext.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { escapeShellArg, getShellConfiguration } from '@google/gemini-cli-core';
interface SessionSummaryDisplayProps {
@@ -17,8 +18,19 @@ export const SessionSummaryDisplay: React.FC<SessionSummaryDisplayProps> = ({
duration,
}) => {
const { stats } = useSessionStats();
const config = useConfig();
const { shell } = getShellConfiguration();
const footer = `To resume this session: gemini --resume ${escapeShellArg(stats.sessionId, shell)}`;
const worktreeSettings = config.getWorktreeSettings();
const escapedSessionId = escapeShellArg(stats.sessionId, shell);
let footer = `To resume this session: gemini --resume ${escapedSessionId}`;
if (worktreeSettings) {
footer =
`To resume work in this worktree: cd ${escapeShellArg(worktreeSettings.path, shell)} && gemini --resume ${escapedSessionId}\n` +
`To remove manually: git worktree remove ${escapeShellArg(worktreeSettings.path, shell)}`;
}
return (
<StatsDisplay
@@ -0,0 +1,124 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { setupWorktree } from './worktreeSetup.js';
import * as coreFunctions from '@google/gemini-cli-core';
// Mock dependencies
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
getProjectRootForWorktree: vi.fn(),
createWorktreeService: vi.fn(),
debugLogger: {
log: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
writeToStdout: vi.fn(),
writeToStderr: vi.fn(),
};
});
describe('setupWorktree', () => {
const originalEnv = { ...process.env };
const originalCwd = process.cwd;
const mockService = {
setup: vi.fn(),
maybeCleanup: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
process.env = { ...originalEnv };
// Mock process.cwd and process.chdir
let currentPath = '/mock/project';
process.cwd = vi.fn().mockImplementation(() => currentPath);
process.chdir = vi.fn().mockImplementation((newPath) => {
currentPath = newPath;
});
// Mock successful execution of core utilities
vi.mocked(coreFunctions.getProjectRootForWorktree).mockResolvedValue(
'/mock/project',
);
vi.mocked(coreFunctions.createWorktreeService).mockResolvedValue(
mockService as never,
);
mockService.setup.mockResolvedValue({
name: 'my-feature',
path: '/mock/project/.gemini/worktrees/my-feature',
baseSha: 'base-sha',
});
});
afterEach(() => {
process.env = { ...originalEnv };
process.cwd = originalCwd;
delete (process as { chdir?: typeof process.chdir }).chdir;
});
it('should create and switch to a new worktree', async () => {
await setupWorktree('my-feature');
expect(coreFunctions.getProjectRootForWorktree).toHaveBeenCalledWith(
'/mock/project',
);
expect(coreFunctions.createWorktreeService).toHaveBeenCalledWith(
'/mock/project',
);
expect(mockService.setup).toHaveBeenCalledWith('my-feature');
expect(process.chdir).toHaveBeenCalledWith(
'/mock/project/.gemini/worktrees/my-feature',
);
expect(process.env['GEMINI_CLI_WORKTREE_HANDLED']).toBe('1');
});
it('should generate a name if worktreeName is undefined', async () => {
mockService.setup.mockResolvedValue({
name: 'generated-name',
path: '/mock/project/.gemini/worktrees/generated-name',
baseSha: 'base-sha',
});
await setupWorktree(undefined);
expect(mockService.setup).toHaveBeenCalledWith(undefined);
});
it('should skip worktree creation if GEMINI_CLI_WORKTREE_HANDLED is set', async () => {
process.env['GEMINI_CLI_WORKTREE_HANDLED'] = '1';
await setupWorktree('my-feature');
expect(coreFunctions.createWorktreeService).not.toHaveBeenCalled();
expect(process.chdir).not.toHaveBeenCalled();
});
it('should handle errors gracefully and exit', async () => {
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('PROCESS_EXIT');
});
mockService.setup.mockRejectedValue(new Error('Git failure'));
await expect(setupWorktree('my-feature')).rejects.toThrow('PROCESS_EXIT');
expect(coreFunctions.writeToStderr).toHaveBeenCalledWith(
expect.stringContaining(
'Failed to create or switch to worktree: Git failure',
),
);
expect(mockExit).toHaveBeenCalledWith(1);
mockExit.mockRestore();
});
});
+43
View File
@@ -0,0 +1,43 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
getProjectRootForWorktree,
createWorktreeService,
writeToStderr,
type WorktreeInfo,
} from '@google/gemini-cli-core';
/**
* Sets up a git worktree for parallel sessions.
*
* This function uses a guard (GEMINI_CLI_WORKTREE_HANDLED) to ensure that
* when the CLI relaunches itself (e.g. for memory allocation), it doesn't
* attempt to create a nested worktree.
*/
export async function setupWorktree(
worktreeName: string | undefined,
): Promise<WorktreeInfo | undefined> {
if (process.env['GEMINI_CLI_WORKTREE_HANDLED'] === '1') {
return undefined;
}
try {
const projectRoot = await getProjectRootForWorktree(process.cwd());
const service = await createWorktreeService(projectRoot);
const worktreeInfo = await service.setup(worktreeName || undefined);
process.chdir(worktreeInfo.path);
process.env['GEMINI_CLI_WORKTREE_HANDLED'] = '1';
return worktreeInfo;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
writeToStderr(`Failed to create or switch to worktree: ${errorMessage}\n`);
process.exit(1);
}
}