From 5a3c7154df30546dabf96330946e9139c885d13a Mon Sep 17 00:00:00 2001 From: Jerop Kipruto Date: Fri, 20 Mar 2026 10:10:51 -0400 Subject: [PATCH] feat(worktree): add Git worktree support for isolated parallel sessions (#22973) --- docs/cli/cli-reference.md | 1 + docs/cli/git-worktrees.md | 107 ++++++ docs/cli/session-management.md | 6 + docs/cli/settings.md | 1 + docs/reference/configuration.md | 5 + docs/sidebar.json | 5 + packages/cli/src/config/config.test.ts | 45 +++ packages/cli/src/config/config.ts | 106 +++++- packages/cli/src/config/settings.ts | 4 + packages/cli/src/config/settingsSchema.ts | 10 + packages/cli/src/gemini.test.tsx | 2 + packages/cli/src/gemini.tsx | 10 + packages/cli/src/gemini_cleanup.test.tsx | 2 + .../components/SessionSummaryDisplay.test.tsx | 47 ++- .../ui/components/SessionSummaryDisplay.tsx | 14 +- packages/cli/src/utils/worktreeSetup.test.ts | 124 +++++++ packages/cli/src/utils/worktreeSetup.ts | 43 +++ packages/core/src/config/config.ts | 13 + packages/core/src/index.ts | 1 + .../core/src/services/worktreeService.test.ts | 311 ++++++++++++++++++ packages/core/src/services/worktreeService.ts | 225 +++++++++++++ .../core/src/utils/memoryImportProcessor.ts | 10 +- schemas/settings.schema.json | 7 + 23 files changed, 1090 insertions(+), 9 deletions(-) create mode 100644 docs/cli/git-worktrees.md create mode 100644 packages/cli/src/utils/worktreeSetup.test.ts create mode 100644 packages/cli/src/utils/worktreeSetup.ts create mode 100644 packages/core/src/services/worktreeService.test.ts create mode 100644 packages/core/src/services/worktreeService.ts diff --git a/docs/cli/cli-reference.md b/docs/cli/cli-reference.md index 167801ca05..bc8f8b44ce 100644 --- a/docs/cli/cli-reference.md +++ b/docs/cli/cli-reference.md @@ -50,6 +50,7 @@ These commands are available within the interactive REPL. | `--model` | `-m` | string | `auto` | Model to use. See [Model Selection](#model-selection) for available values. | | `--prompt` | `-p` | string | - | Prompt text. Appended to stdin input if provided. Forces non-interactive mode. | | `--prompt-interactive` | `-i` | string | - | Execute prompt and continue in interactive mode | +| `--worktree` | `-w` | string | - | Start Gemini in a new git worktree. If no name is provided, one is generated automatically. Requires `experimental.worktrees: true` in settings. | | `--sandbox` | `-s` | boolean | `false` | Run in a sandboxed environment for safer execution | | `--approval-mode` | - | string | `default` | Approval mode for tool execution. Choices: `default`, `auto_edit`, `yolo` | | `--yolo` | `-y` | boolean | `false` | **Deprecated.** Auto-approve all actions. Use `--approval-mode=yolo` instead. | diff --git a/docs/cli/git-worktrees.md b/docs/cli/git-worktrees.md new file mode 100644 index 0000000000..5020b3fa9a --- /dev/null +++ b/docs/cli/git-worktrees.md @@ -0,0 +1,107 @@ +# Git Worktrees (experimental) + +When working on multiple tasks at once, you can use Git worktrees to give each +Gemini session its own copy of the codebase. Git worktrees create separate +working directories that each have their own files and branch while sharing the +same repository history. This prevents changes in one session from colliding +with another. + +Learn more about [session management](./session-management.md). + + +> [!NOTE] +> This is an experimental feature currently under active development. Your +> feedback is invaluable as we refine this feature. If you have ideas, +> suggestions, or encounter issues: +> +> - [Open an issue](https://github.com/google-gemini/gemini-cli/issues/new?template=bug_report.yml) on GitHub. +> - Use the **/bug** command within Gemini CLI to file an issue. + +Learn more in the official Git worktree +[documentation](https://git-scm.com/docs/git-worktree). + +## How to enable Git worktrees + +Git worktrees are an experimental feature. You must enable them in your settings +using the `/settings` command or by manually editing your `settings.json` file. + +1. Use the `/settings` command. +2. Search for and set **Enable Git Worktrees** to `true`. + +Alternatively, add the following to your `settings.json`: + +```json +{ + "experimental": { + "worktrees": true + } +} +``` + +## How to use Git worktrees + +Use the `--worktree` (`-w`) flag to create an isolated worktree and start Gemini +CLI in it. + +- **Start with a specific name:** The value you pass becomes both the directory + name (within `.gemini/worktrees/`) and the branch name. + + ```bash + gemini --worktree feature-search + ``` + +- **Start with a random name:** If you omit the name, Gemini generates a random + one automatically (for example, `worktree-a1b2c3d4`). + + ```bash + gemini --worktree + ``` + + +> [!NOTE] +> Remember to initialize your development environment in each new +> worktree according to your project's setup. Depending on your stack, this +> might include running dependency installation (`npm install`, `yarn`), setting +> up virtual environments, or following your project's standard build process. + +## How to exit a Git worktree session + +When you exit a worktree session (using `/quit` or `Ctrl+C`), Gemini leaves the +worktree intact so your work is not lost. This includes your uncommitted changes +(modified files, staged changes, or untracked files) and any new commits you +have made. + +Gemini prioritizes a fast and safe exit: it **does not automatically delete** +your worktree or branch. You are responsible for cleaning up your worktrees +manually once you are finished with them. + +When you exit, Gemini displays instructions on how to resume your work or how to +manually remove the worktree if you no longer need it. + +## Resuming work in a Git worktree + +To resume a session in a worktree, navigate to the worktree directory and start +Gemini CLI with the `--resume` flag and the session ID: + +```bash +cd .gemini/worktrees/feature-search +gemini --resume +``` + +## Managing Git worktrees manually + +For more control over worktree location and branch configuration, or to clean up +a preserved worktree, you can use Git directly: + +- **Clean up a preserved Git worktree:** + ```bash + git worktree remove .gemini/worktrees/feature-search --force + git branch -D worktree-feature-search + ``` +- **Create a Git worktree manually:** + ```bash + git worktree add ../project-feature-search -b feature-search + cd ../project-feature-search && gemini + ``` + +[Open an issue]: https://github.com/google-gemini/gemini-cli/issues diff --git a/docs/cli/session-management.md b/docs/cli/session-management.md index 8e60f61630..74bc4a4337 100644 --- a/docs/cli/session-management.md +++ b/docs/cli/session-management.md @@ -96,6 +96,12 @@ Compatibility aliases: - `/chat ...` works for the same commands. - `/resume checkpoints ...` also remains supported during migration. +## Parallel sessions with Git worktrees + +When working on multiple tasks at once, you can use +[Git worktrees](./git-worktrees.md) to give each Gemini session its own copy of +the codebase. This prevents changes in one session from colliding with another. + ## Managing sessions You can list and delete sessions to keep your history organized and manage disk diff --git a/docs/cli/settings.md b/docs/cli/settings.md index 85373f1034..ead0050fbd 100644 --- a/docs/cli/settings.md +++ b/docs/cli/settings.md @@ -151,6 +151,7 @@ they appear in the UI. | UI Label | Setting | Description | Default | | -------------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | | Enable Tool Output Masking | `experimental.toolOutputMasking.enabled` | Enables tool output masking to save tokens. | `true` | +| Enable Git Worktrees | `experimental.worktrees` | Enable automated Git worktree management for parallel work. | `false` | | Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 for pasting. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` | | Use OSC 52 Copy | `experimental.useOSC52Copy` | Use OSC 52 for copying. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` | | Plan | `experimental.plan` | Enable Plan Mode. | `true` | diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index d3b08d565a..5791bbf457 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -1527,6 +1527,11 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `true` - **Requires restart:** Yes +- **`experimental.worktrees`** (boolean): + - **Description:** Enable automated Git worktree management for parallel work. + - **Default:** `false` + - **Requires restart:** Yes + - **`experimental.extensionManagement`** (boolean): - **Description:** Enable extension management features. - **Default:** `true` diff --git a/docs/sidebar.json b/docs/sidebar.json index 6cac5ec9fd..7198a0336b 100644 --- a/docs/sidebar.json +++ b/docs/sidebar.json @@ -99,6 +99,11 @@ { "label": "Agent Skills", "slug": "docs/cli/skills" }, { "label": "Checkpointing", "slug": "docs/cli/checkpointing" }, { "label": "Headless mode", "slug": "docs/cli/headless" }, + { + "label": "Git worktrees", + "badge": "🔬", + "slug": "docs/cli/git-worktrees" + }, { "label": "Hooks", "collapsed": true, diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index c046f0c0e7..746fc14475 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -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', diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index fdcd18c086..227ad4e8ed 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -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 { @@ -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 { + 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, + }; +} diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index beecd6a017..984bdb8d60 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -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. diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index f1711f3b92..3724253e97 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -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', diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 31fec36db0..08c2cbabe8 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -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', () => ({ diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 4722bb73f3..c8cd2b3cd8 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -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(); diff --git a/packages/cli/src/gemini_cleanup.test.tsx b/packages/cli/src/gemini_cleanup.test.tsx index 9be9fc6194..382ad3f81f 100644 --- a/packages/cli/src/gemini_cleanup.test.tsx +++ b/packages/cli/src/gemini_cleanup.test.tsx @@ -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', () => ({ diff --git a/packages/cli/src/ui/components/SessionSummaryDisplay.test.tsx b/packages/cli/src/ui/components/SessionSummaryDisplay.test.tsx index 9c811fc741..f5d1ebbd5e 100644 --- a/packages/cli/src/ui/components/SessionSummaryDisplay.test.tsx +++ b/packages/cli/src/ui/components/SessionSummaryDisplay.test.tsx @@ -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(); + const actual = + await importOriginal(); return { ...actual, useSessionStats: vi.fn(), }; }); +vi.mock('../contexts/ConfigContext.js', async (importOriginal) => { + const actual = + await importOriginal(); + 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); + + vi.mocked(useConfig).mockReturnValue({ + getWorktreeSettings: () => worktreeSettings, + } as never); const result = await renderWithProviders( , @@ -188,4 +205,30 @@ describe('', () => { 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(); + }); + }); }); diff --git a/packages/cli/src/ui/components/SessionSummaryDisplay.tsx b/packages/cli/src/ui/components/SessionSummaryDisplay.tsx index 5b0a461682..7313949a9c 100644 --- a/packages/cli/src/ui/components/SessionSummaryDisplay.tsx +++ b/packages/cli/src/ui/components/SessionSummaryDisplay.tsx @@ -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 = ({ 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 ( { + const actual = + await importOriginal(); + 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(); + }); +}); diff --git a/packages/cli/src/utils/worktreeSetup.ts b/packages/cli/src/utils/worktreeSetup.ts new file mode 100644 index 0000000000..596c367d3e --- /dev/null +++ b/packages/cli/src/utils/worktreeSetup.ts @@ -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 { + 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); + } +} diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 5bac6d086c..eb2c3f90f1 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -528,6 +528,12 @@ export interface PolicyUpdateConfirmationRequest { newHash: string; } +export interface WorktreeSettings { + name: string; + path: string; + baseSha: string; +} + export interface ConfigParameters { sessionId: string; clientName?: string; @@ -651,6 +657,7 @@ export interface ConfigParameters { plan?: boolean; tracker?: boolean; planSettings?: PlanSettings; + worktreeSettings?: WorktreeSettings; modelSteering?: boolean; onModelChange?: (model: string) => void; mcpEnabled?: boolean; @@ -695,6 +702,7 @@ export class Config implements McpContext, AgentLoopContext { private workspaceContext: WorkspaceContext; private readonly debugMode: boolean; private readonly question: string | undefined; + private readonly worktreeSettings: WorktreeSettings | undefined; readonly enableConseca: boolean; private readonly coreTools: string[] | undefined; @@ -925,6 +933,7 @@ export class Config implements McpContext, AgentLoopContext { this.pendingIncludeDirectories = params.includeDirectories ?? []; this.debugMode = params.debugMode; this.question = params.question; + this.worktreeSettings = params.worktreeSettings; this.coreTools = params.coreTools; this.mainAgentTools = params.mainAgentTools; @@ -1555,6 +1564,10 @@ export class Config implements McpContext, AgentLoopContext { return this.promptId; } + getWorktreeSettings(): WorktreeSettings | undefined { + return this.worktreeSettings; + } + getClientName(): string | undefined { return this.clientName; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 32572c86a0..5729730365 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -237,6 +237,7 @@ export * from './agents/types.js'; // Export stdio utils export * from './utils/stdio.js'; export * from './utils/terminal.js'; +export * from './services/worktreeService.js'; // Export voice utilities export * from './voice/responseFormatter.js'; diff --git a/packages/core/src/services/worktreeService.test.ts b/packages/core/src/services/worktreeService.test.ts new file mode 100644 index 0000000000..b3d831e6b4 --- /dev/null +++ b/packages/core/src/services/worktreeService.test.ts @@ -0,0 +1,311 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import * as path from 'node:path'; +import * as fs from 'node:fs/promises'; +import { + getProjectRootForWorktree, + createWorktree, + isGeminiWorktree, + hasWorktreeChanges, + cleanupWorktree, + getWorktreePath, + WorktreeService, +} from './worktreeService.js'; +import { execa } from 'execa'; + +vi.mock('execa'); +vi.mock('node:fs/promises'); +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + realpathSync: vi.fn((p: string) => p), + }; +}); + +describe('worktree utilities', () => { + const projectRoot = '/mock/project'; + const worktreeName = 'test-feature'; + const expectedPath = path.join( + projectRoot, + '.gemini', + 'worktrees', + worktreeName, + ); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('getProjectRootForWorktree', () => { + it('should return the project root from git common dir', async () => { + // In main repo, git-common-dir is often just ".git" + vi.mocked(execa).mockResolvedValue({ + stdout: '.git\n', + } as never); + + const result = await getProjectRootForWorktree('/mock/project'); + expect(result).toBe('/mock/project'); + expect(execa).toHaveBeenCalledWith( + 'git', + ['rev-parse', '--git-common-dir'], + { cwd: '/mock/project' }, + ); + }); + + it('should resolve absolute git common dir paths (as seen in worktrees)', async () => { + // Inside a worktree, git-common-dir is usually an absolute path to the main .git folder + vi.mocked(execa).mockResolvedValue({ + stdout: '/mock/project/.git\n', + } as never); + + const result = await getProjectRootForWorktree( + '/mock/project/.gemini/worktrees/my-feature', + ); + expect(result).toBe('/mock/project'); + }); + + it('should fallback to cwd if git command fails', async () => { + vi.mocked(execa).mockRejectedValue(new Error('not a git repo')); + + const result = await getProjectRootForWorktree('/mock/non-git/src'); + expect(result).toBe('/mock/non-git/src'); + }); + }); + + describe('getWorktreePath', () => { + it('should return the correct path for a given name', () => { + expect(getWorktreePath(projectRoot, worktreeName)).toBe(expectedPath); + }); + }); + + describe('createWorktree', () => { + it('should execute git worktree add with correct branch and path', async () => { + vi.mocked(execa).mockResolvedValue({ stdout: '' } as never); + + const resultPath = await createWorktree(projectRoot, worktreeName); + + expect(resultPath).toBe(expectedPath); + expect(execa).toHaveBeenCalledWith( + 'git', + ['worktree', 'add', expectedPath, '-b', `worktree-${worktreeName}`], + { cwd: projectRoot }, + ); + }); + + it('should throw an error if git worktree add fails', async () => { + vi.mocked(execa).mockRejectedValue(new Error('git failed')); + + await expect(createWorktree(projectRoot, worktreeName)).rejects.toThrow( + 'git failed', + ); + }); + }); + + describe('isGeminiWorktree', () => { + it('should return true for a valid gemini worktree path', () => { + expect(isGeminiWorktree(expectedPath, projectRoot)).toBe(true); + expect( + isGeminiWorktree(path.join(expectedPath, 'src'), projectRoot), + ).toBe(true); + }); + + it('should return false for a path outside gemini worktrees', () => { + expect(isGeminiWorktree(path.join(projectRoot, 'src'), projectRoot)).toBe( + false, + ); + expect(isGeminiWorktree('/some/other/path', projectRoot)).toBe(false); + }); + }); + + describe('hasWorktreeChanges', () => { + it('should return true if git status --porcelain has output', async () => { + vi.mocked(execa).mockResolvedValue({ + stdout: ' M somefile.txt\n?? newfile.txt', + } as never); + + const hasChanges = await hasWorktreeChanges(expectedPath); + + expect(hasChanges).toBe(true); + expect(execa).toHaveBeenCalledWith('git', ['status', '--porcelain'], { + cwd: expectedPath, + }); + }); + + it('should return true if there are untracked files', async () => { + vi.mocked(execa).mockResolvedValue({ + stdout: '?? untracked-file.txt\n', + } as never); + + const hasChanges = await hasWorktreeChanges(expectedPath); + + expect(hasChanges).toBe(true); + }); + + it('should return true if HEAD differs from baseSha', async () => { + vi.mocked(execa) + .mockResolvedValueOnce({ stdout: '' } as never) // status clean + .mockResolvedValueOnce({ stdout: 'different-sha' } as never); // HEAD moved + + const hasChanges = await hasWorktreeChanges(expectedPath, 'base-sha'); + + expect(hasChanges).toBe(true); + }); + + it('should return false if status is clean and HEAD matches baseSha', async () => { + vi.mocked(execa) + .mockResolvedValueOnce({ stdout: '' } as never) // status clean + .mockResolvedValueOnce({ stdout: 'base-sha' } as never); // HEAD same + + const hasChanges = await hasWorktreeChanges(expectedPath, 'base-sha'); + + expect(hasChanges).toBe(false); + }); + + it('should return true if any git command fails', async () => { + vi.mocked(execa).mockRejectedValue(new Error('git error')); + + const hasChanges = await hasWorktreeChanges(expectedPath); + + expect(hasChanges).toBe(true); + }); + }); + + describe('cleanupWorktree', () => { + it('should remove the worktree and delete the branch', async () => { + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(execa) + .mockResolvedValueOnce({ + stdout: `worktree-${worktreeName}\n`, + } as never) // branch --show-current + .mockResolvedValueOnce({ stdout: '' } as never) // remove + .mockResolvedValueOnce({ stdout: '' } as never); // branch -D + + await cleanupWorktree(expectedPath, projectRoot); + + expect(execa).toHaveBeenCalledTimes(3); + expect(execa).toHaveBeenNthCalledWith( + 1, + 'git', + ['-C', expectedPath, 'branch', '--show-current'], + { cwd: projectRoot }, + ); + expect(execa).toHaveBeenNthCalledWith( + 2, + 'git', + ['worktree', 'remove', expectedPath, '--force'], + { cwd: projectRoot }, + ); + expect(execa).toHaveBeenNthCalledWith( + 3, + 'git', + ['branch', '-D', `worktree-${worktreeName}`], + { cwd: projectRoot }, + ); + }); + + it('should handle branch discovery failure gracefully', async () => { + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(execa) + .mockResolvedValueOnce({ stdout: '' } as never) // no branch found + .mockResolvedValueOnce({ stdout: '' } as never); // remove + + await cleanupWorktree(expectedPath, projectRoot); + + expect(execa).toHaveBeenCalledTimes(2); + expect(execa).toHaveBeenNthCalledWith( + 2, + 'git', + ['worktree', 'remove', expectedPath, '--force'], + { cwd: projectRoot }, + ); + }); + }); +}); + +describe('WorktreeService', () => { + const projectRoot = '/mock/project'; + const service = new WorktreeService(projectRoot); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('setup', () => { + it('should capture baseSha and create a worktree', async () => { + vi.mocked(execa).mockResolvedValue({ + stdout: 'current-sha\n', + } as never); + + const info = await service.setup('feature-x'); + + expect(execa).toHaveBeenCalledWith('git', ['rev-parse', 'HEAD'], { + cwd: projectRoot, + }); + expect(info.name).toBe('feature-x'); + expect(info.baseSha).toBe('current-sha'); + expect(info.path).toContain('feature-x'); + }); + + it('should generate a timestamped name if none provided', async () => { + vi.mocked(execa).mockResolvedValue({ + stdout: 'current-sha\n', + } as never); + + const info = await service.setup(); + + expect(info.name).toMatch(/^\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}-\w+/); + expect(info.path).toContain(info.name); + }); + }); + + describe('maybeCleanup', () => { + const info = { + name: 'feature-x', + path: '/mock/project/.gemini/worktrees/feature-x', + baseSha: 'base-sha', + }; + + it('should cleanup unmodified worktrees', async () => { + // Mock hasWorktreeChanges -> false (no changes) + vi.mocked(execa) + .mockResolvedValueOnce({ stdout: '' } as never) // status check + .mockResolvedValueOnce({ stdout: 'base-sha' } as never); // SHA check + + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(execa).mockResolvedValue({ stdout: '' } as never); // cleanup calls + + const cleanedUp = await service.maybeCleanup(info); + + expect(cleanedUp).toBe(true); + // Verify cleanupWorktree utilities were called (execa calls inside cleanupWorktree) + expect(execa).toHaveBeenCalledWith( + expect.anything(), + expect.arrayContaining(['worktree', 'remove', info.path, '--force']), + expect.anything(), + ); + }); + + it('should preserve modified worktrees', async () => { + // Mock hasWorktreeChanges -> true (changes detected) + vi.mocked(execa).mockResolvedValue({ + stdout: ' M modified-file.ts', + } as never); + + const cleanedUp = await service.maybeCleanup(info); + + expect(cleanedUp).toBe(false); + // Ensure cleanupWorktree was NOT called + expect(execa).not.toHaveBeenCalledWith( + expect.anything(), + expect.arrayContaining(['worktree', 'remove']), + expect.anything(), + ); + }); + }); +}); diff --git a/packages/core/src/services/worktreeService.ts b/packages/core/src/services/worktreeService.ts new file mode 100644 index 0000000000..0b6bd20648 --- /dev/null +++ b/packages/core/src/services/worktreeService.ts @@ -0,0 +1,225 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as path from 'node:path'; +import * as fs from 'node:fs/promises'; +import { realpathSync } from 'node:fs'; +import { execa } from 'execa'; +import { debugLogger } from '../utils/debugLogger.js'; + +export interface WorktreeInfo { + name: string; + path: string; + baseSha: string; +} + +/** + * Service for managing Git worktrees within Gemini CLI. + * Handles creation, cleanup, and environment setup for isolated sessions. + */ +export class WorktreeService { + constructor(private readonly projectRoot: string) {} + + /** + * Creates a new worktree and prepares the environment. + */ + async setup(name?: string): Promise { + let worktreeName = name?.trim(); + + if (!worktreeName) { + const now = new Date(); + const timestamp = now + .toISOString() + .replace(/[:.]/g, '-') + .replace('T', '-') + .replace('Z', ''); + const randomSuffix = Math.random().toString(36).substring(2, 6); + worktreeName = `${timestamp}-${randomSuffix}`; + } + + // Capture the base commit before creating the worktree + const { stdout: baseSha } = await execa('git', ['rev-parse', 'HEAD'], { + cwd: this.projectRoot, + }); + + const worktreePath = await createWorktree(this.projectRoot, worktreeName); + + return { + name: worktreeName, + path: worktreePath, + baseSha: baseSha.trim(), + }; + } + + /** + * Checks if a worktree has changes and cleans it up if it's unmodified. + */ + async maybeCleanup(info: WorktreeInfo): Promise { + const hasChanges = await hasWorktreeChanges(info.path, info.baseSha); + + if (!hasChanges) { + try { + await cleanupWorktree(info.path, this.projectRoot); + debugLogger.log( + `Automatically cleaned up unmodified worktree: ${info.path}`, + ); + return true; + } catch (error) { + debugLogger.error( + `Failed to clean up worktree ${info.path}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } else { + debugLogger.debug( + `Preserving worktree ${info.path} because it has changes.`, + ); + } + + return false; + } +} + +export async function createWorktreeService( + cwd: string, +): Promise { + const projectRoot = await getProjectRootForWorktree(cwd); + return new WorktreeService(projectRoot); +} + +// Low-level worktree utilities + +export async function getProjectRootForWorktree(cwd: string): Promise { + try { + const { stdout } = await execa('git', ['rev-parse', '--git-common-dir'], { + cwd, + }); + const gitCommonDir = stdout.trim(); + const absoluteGitDir = path.isAbsolute(gitCommonDir) + ? gitCommonDir + : path.resolve(cwd, gitCommonDir); + + // The project root is the parent of the .git directory/file + return path.dirname(absoluteGitDir); + } catch (e: unknown) { + debugLogger.debug( + `Failed to get project root for worktree at ${cwd}: ${e instanceof Error ? e.message : String(e)}`, + ); + return cwd; + } +} + +export function getWorktreePath(projectRoot: string, name: string): string { + return path.join(projectRoot, '.gemini', 'worktrees', name); +} + +export async function createWorktree( + projectRoot: string, + name: string, +): Promise { + const worktreePath = getWorktreePath(projectRoot, name); + const branchName = `worktree-${name}`; + + await execa('git', ['worktree', 'add', worktreePath, '-b', branchName], { + cwd: projectRoot, + }); + + return worktreePath; +} + +export function isGeminiWorktree( + dirPath: string, + projectRoot: string, +): boolean { + try { + const realDirPath = realpathSync(dirPath); + const realProjectRoot = realpathSync(projectRoot); + const worktreesBaseDir = path.join(realProjectRoot, '.gemini', 'worktrees'); + const relative = path.relative(worktreesBaseDir, realDirPath); + return !relative.startsWith('..') && !path.isAbsolute(relative); + } catch { + return false; + } +} + +export async function hasWorktreeChanges( + dirPath: string, + baseSha?: string, +): Promise { + try { + // 1. Check for uncommitted changes (index or working tree) + const { stdout: status } = await execa('git', ['status', '--porcelain'], { + cwd: dirPath, + }); + if (status.trim() !== '') { + return true; + } + + // 2. Check if the current commit has moved from the base + if (baseSha) { + const { stdout: currentSha } = await execa('git', ['rev-parse', 'HEAD'], { + cwd: dirPath, + }); + if (currentSha.trim() !== baseSha) { + return true; + } + } + + return false; + } catch (e: unknown) { + debugLogger.debug( + `Failed to check worktree changes at ${dirPath}: ${e instanceof Error ? e.message : String(e)}`, + ); + // If any git command fails, assume the worktree is dirty to be safe. + return true; + } +} + +export async function cleanupWorktree( + dirPath: string, + projectRoot: string, +): Promise { + try { + await fs.access(dirPath); + } catch { + return; // Worktree already gone + } + + let branchName: string | undefined; + + try { + // 1. Discover the branch name associated with this worktree path + const { stdout } = await execa( + 'git', + ['-C', dirPath, 'branch', '--show-current'], + { + cwd: projectRoot, + }, + ); + branchName = stdout.trim() || undefined; + + // 2. Remove the worktree + await execa('git', ['worktree', 'remove', dirPath, '--force'], { + cwd: projectRoot, + }); + } catch (e: unknown) { + debugLogger.debug( + `Failed to remove worktree ${dirPath}: ${e instanceof Error ? e.message : String(e)}`, + ); + } finally { + // 3. Delete the branch if we found it + if (branchName) { + try { + await execa('git', ['branch', '-D', branchName], { + cwd: projectRoot, + }); + } catch (e: unknown) { + debugLogger.debug( + `Failed to delete branch ${branchName}: ${e instanceof Error ? e.message : String(e)}`, + ); + } + } + } +} diff --git a/packages/core/src/utils/memoryImportProcessor.ts b/packages/core/src/utils/memoryImportProcessor.ts index bf20bd6c13..10bf1ad592 100644 --- a/packages/core/src/utils/memoryImportProcessor.ts +++ b/packages/core/src/utils/memoryImportProcessor.ts @@ -48,16 +48,16 @@ export interface ProcessImportsResult { importTree: MemoryFile; } -// Helper to find the project root (looks for .git directory) +// Helper to find the project root (looks for .git directory or file for worktrees) async function findProjectRoot(startDir: string): Promise { let currentDir = path.resolve(startDir); while (true) { const gitPath = path.join(currentDir, '.git'); try { - const stats = await fs.lstat(gitPath); - if (stats.isDirectory()) { - return currentDir; - } + // Check for existence only — .git can be a directory (normal repos) + // or a file (submodules / worktrees). + await fs.access(gitPath); + return currentDir; } catch { // .git not found, continue to parent } diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json index 9c790c6268..85a907e57e 100644 --- a/schemas/settings.schema.json +++ b/schemas/settings.schema.json @@ -2663,6 +2663,13 @@ "default": true, "type": "boolean" }, + "worktrees": { + "title": "Enable Git Worktrees", + "description": "Enable automated Git worktree management for parallel work.", + "markdownDescription": "Enable automated Git worktree management for parallel work.\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `false`", + "default": false, + "type": "boolean" + }, "extensionManagement": { "title": "Extension Management", "description": "Enable extension management features.",