From 94ce4aba8b1d2205a7ebc43d083e53960b55ad47 Mon Sep 17 00:00:00 2001 From: Jerop Kipruto Date: Wed, 18 Mar 2026 13:48:50 -0400 Subject: [PATCH] feat(worktree): add Git worktree support for isolated parallel sessions Introduces a central Git worktree management system to enable isolated, parallel agent orchestration. This allows running multiple Gemini sessions on different branches within the same repository without file contention. Key Features: - Added 'WorktreeService' in @google/gemini-cli-core to manage creation, lifecycle, and cleanup of Git worktrees. - Implemented smart cleanup logic that detects untracked files and new commits, automatically preserving worktrees with uncommitted work. - Added '--worktree' (-w) flag to the CLI to launch sessions in fresh, isolated environments. - Enhanced the session exit experience with detailed status messages and instructions for resuming work in preserved worktrees. --- docs/cli/cli-reference.md | 1 + docs/cli/git-worktrees.md | 112 +++++++ docs/cli/session-management.md | 8 + 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 | 60 ++++ .../cli/src/config/settingsSchema.test.ts | 11 + packages/cli/src/config/settingsSchema.ts | 9 + packages/cli/src/gemini.tsx | 7 +- packages/cli/src/ui/AppContainer.tsx | 2 +- .../components/SessionSummaryDisplay.test.tsx | 95 +++++- .../ui/components/SessionSummaryDisplay.tsx | 39 ++- packages/cli/src/utils/relaunch.test.ts | 8 + packages/cli/src/utils/relaunch.ts | 3 + packages/cli/src/utils/worktreeSetup.test.ts | 151 +++++++++ packages/cli/src/utils/worktreeSetup.ts | 57 ++++ packages/core/src/config/config.ts | 13 + packages/core/src/index.ts | 1 + .../core/src/services/worktreeService.test.ts | 304 ++++++++++++++++++ packages/core/src/services/worktreeService.ts | 217 +++++++++++++ packages/core/src/utils/memoryDiscovery.ts | 2 +- .../core/src/utils/memoryImportProcessor.ts | 4 +- schemas/settings.schema.json | 7 + 25 files changed, 1159 insertions(+), 8 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..eb9ee60031 --- /dev/null +++ b/docs/cli/git-worktrees.md @@ -0,0 +1,112 @@ +# Parallel sessions with Git worktrees + +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 a preview 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] 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-auth + ``` + +- **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 worktree session + +When you exit a worktree session (using `/quit` or `Ctrl+C`), Gemini +automatically determines whether to clean up or preserve the worktree based on +the presence of changes. + +- **Automatic removal:** If the worktree is completely clean—meaning it has no + uncommitted changes and no new commits have been made—Gemini automatically + removes the worktree directory and deletes the temporary branch. +- **Safe preservation:** If Gemini detects any changes, it leaves the worktree + intact so your work is not lost. Preservation occurs if: + - You have **uncommitted changes** (modified files, staged changes, or new + untracked files). + - You have made **new commits** on the worktree branch since the session + started. + +Gemini prioritizes a fast and safe exit: it **does not display an interactive +prompt** to ask whether to keep the worktree. Instead, it ensures your work is +safely preserved by default if any modifications are detected. + +## Resuming work in a worktree + +If a worktree was preserved because it contained changes, Gemini displays +instructions on how to resume your work when you exit. + +To resume a session in a preserved worktree, navigate to the worktree directory +and start Gemini CLI with the `--resume` flag: + +```bash +cd .gemini/worktrees/feature-auth +gemini --resume latest +``` + +## Managing 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 worktree:** + ```bash + git worktree remove .gemini/worktrees/feature-auth --force + git branch -D worktree-feature-auth + ``` +- **Create a worktree manually:** + ```bash + git worktree add ../project-feature-a -b feature-a + cd ../project-feature-a && 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..4156d84b36 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 @@ -206,3 +212,5 @@ becoming too large and expensive. across sessions. - Learn how to [Checkpoint](./checkpointing.md) your session state. - Check out the [CLI reference](./cli-reference.md) for all command-line flags. + +[Open an issue]: https://github.com/google-gemini/gemini-cli/issues diff --git a/docs/cli/settings.md b/docs/cli/settings.md index eb9ba4158e..eed74277da 100644 --- a/docs/cli/settings.md +++ b/docs/cli/settings.md @@ -147,6 +147,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 git worktrees. | `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 7df1de61f1..ed136771b9 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -1345,6 +1345,11 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `true` - **Requires restart:** Yes +- **`experimental.worktrees`** (boolean): + - **Description:** Enable git worktrees. + - **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 a94d1f0a28..16750b6261 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 80c1e19443..f0b1f09c6f 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -8,6 +8,7 @@ import yargs from 'yargs/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'; @@ -37,6 +38,9 @@ import { resolveToRealPath, applyAdminAllowlist, getAdminBlockedMcpServersMessage, + getProjectRootForWorktree, + isGeminiWorktree, + type WorktreeSettings, type HookDefinition, type HookEventName, type OutputFormat, @@ -73,6 +77,7 @@ export interface CliArgs { debug: boolean | undefined; prompt: string | undefined; promptInteractive: string | undefined; + worktree?: string; yolo: boolean | undefined; approvalMode: string | undefined; @@ -157,6 +162,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', @@ -334,6 +353,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; }); @@ -430,6 +452,8 @@ export async function loadCliConfig( const { cwd = process.cwd(), projectHooks } = options; const debugMode = isDebugMode(argv); + const worktreeSettings = await resolveWorktreeSettings(cwd); + if (argv.sandbox) { process.env['GEMINI_SANDBOX'] = 'true'; } @@ -766,6 +790,7 @@ export async function loadCliConfig( importFormat: settings.context?.importFormat, debugMode, question, + worktreeSettings, coreTools: settings.tools?.core || undefined, allowedTools: allowedTools.length > 0 ? allowedTools : undefined, @@ -906,3 +931,38 @@ function mergeExcludeTools( ]); return Array.from(allExcludeTools); } + +async function resolveWorktreeSettings( + cwd: string, +): Promise { + const projectRoot = await getProjectRootForWorktree(cwd); + const worktreePath = isGeminiWorktree(cwd, projectRoot) ? cwd : undefined; + + if (!worktreePath) { + return undefined; + } + + let worktreeBaseSha = process.env['GEMINI_CLI_WORKTREE_BASE_SHA']; + if (!worktreeBaseSha) { + 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/settingsSchema.test.ts b/packages/cli/src/config/settingsSchema.test.ts index 37ddf87642..7cc45939d0 100644 --- a/packages/cli/src/config/settingsSchema.test.ts +++ b/packages/cli/src/config/settingsSchema.test.ts @@ -428,6 +428,17 @@ describe('SettingsSchema', () => { expect(setting.description).toBe('Enable Plan Mode.'); }); + it('should have worktrees setting in schema', () => { + const setting = getSettingsSchema().experimental.properties.worktrees; + expect(setting).toBeDefined(); + expect(setting.type).toBe('boolean'); + expect(setting.category).toBe('Experimental'); + expect(setting.default).toBe(false); + expect(setting.requiresRestart).toBe(true); + expect(setting.showInDialog).toBe(true); + expect(setting.description).toBe('Enable git worktrees.'); + }); + it('should have hooksConfig.notifications setting in schema', () => { const setting = getSettingsSchema().hooksConfig?.properties.notifications; expect(setting).toBeDefined(); diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 8a107c4d47..2bf9f950a4 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1870,6 +1870,15 @@ 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 git worktrees.', + showInDialog: true, + }, extensionManagement: { type: 'boolean', label: 'Extension Management', diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 4722bb73f3..139dcb6ab0 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -63,6 +63,7 @@ import { registerTelemetryConfig, setupSignalHandlers, } from './utils/cleanup.js'; +import { setupWorktree } from './utils/worktreeSetup.js'; import { cleanupToolOutputFiles, cleanupExpiredSessions, @@ -207,7 +208,7 @@ export async function main() { registerCleanup(() => slashCommandConflictHandler.stop()); const loadSettingsHandle = startupProfiler.start('load_settings'); - const settings = loadSettings(); + let settings = loadSettings(); loadSettingsHandle?.end(); // Report settings errors once during startup @@ -233,6 +234,10 @@ export async function main() { const argv = await parseArguments(settings.merged); parseArgsHandle?.end(); + if (argv.worktree) { + settings = await setupWorktree(argv.worktree, settings); + } + if ( (argv.allowedTools && argv.allowedTools.length > 0) || (settings.merged.tools?.allowed && settings.merged.tools.allowed.length > 0) diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index b2402f9fe9..ea27b28f0e 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -913,7 +913,7 @@ Logging in with Google... Restarting Gemini CLI to continue. setTimeout(async () => { await runExitCleanup(); process.exit(0); - }, 100); + }, 1000); }, setDebugMessage, toggleCorgiMode: () => setCorgiMode((prev) => !prev), diff --git a/packages/cli/src/ui/components/SessionSummaryDisplay.test.tsx b/packages/cli/src/ui/components/SessionSummaryDisplay.test.tsx index 3be3cb09f5..9221cb49e9 100644 --- a/packages/cli/src/ui/components/SessionSummaryDisplay.test.tsx +++ b/packages/cli/src/ui/components/SessionSummaryDisplay.test.tsx @@ -6,12 +6,15 @@ import { renderWithProviders } from '../../test-utils/render.js'; import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { act } from 'react'; import { SessionSummaryDisplay } from './SessionSummaryDisplay.js'; import * as SessionContext from '../contexts/SessionContext.js'; import { type SessionMetrics } from '../contexts/SessionContext.js'; +import * as ConfigContext from '../contexts/ConfigContext.js'; import { ToolCallDecision, getShellConfiguration, + hasWorktreeChanges, } from '@google/gemini-cli-core'; vi.mock('@google/gemini-cli-core', async (importOriginal) => { @@ -20,6 +23,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { return { ...actual, getShellConfiguration: vi.fn(), + hasWorktreeChanges: vi.fn(), }; }); @@ -31,12 +35,23 @@ vi.mock('../contexts/SessionContext.js', async (importOriginal) => { }; }); +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 useConfigMock = vi.mocked(ConfigContext.useConfig); +const hasWorktreeChangesMock = vi.mocked(hasWorktreeChanges); const renderWithMockedStats = async ( metrics: SessionMetrics, sessionId = 'test-session', + worktreeSettings?: object, ) => { useSessionStatsMock.mockReturnValue({ stats: { @@ -49,7 +64,11 @@ const renderWithMockedStats = async ( getPromptCount: () => 5, startNewPrompt: vi.fn(), - }); + } as unknown as ReturnType); + + useConfigMock.mockReturnValue({ + getWorktreeSettings: () => worktreeSettings, + } as unknown as ReturnType); const result = renderWithProviders( , @@ -89,6 +108,7 @@ describe('', () => { argsPrefix: ['-c'], shell: 'bash', }); + hasWorktreeChangesMock.mockReset(); }); it('renders the summary display with a title', async () => { @@ -188,4 +208,77 @@ describe('', () => { unmount(); }); }); + + describe('Worktree status', () => { + const worktreeSettings = { + name: 'foo-bar', + path: '/path/to/foo-bar', + baseSha: 'base-sha', + }; + + interface RenderResult { + lastFrame: () => string; + unmount: () => void; + } + + it('renders an additive cleanup message when worktree is clean', async () => { + hasWorktreeChangesMock.mockResolvedValue(false); + + let renderResult: RenderResult | null = null; + await act(async () => { + const result = await renderWithMockedStats( + emptyMetrics, + 'test-session', + worktreeSettings, + ); + renderResult = result as unknown as RenderResult; + }); + + // Wait for re-render triggered by useEffect + await vi.waitFor( + () => { + const output = renderResult!.lastFrame(); + expect(output).toContain( + 'To resume this session: gemini --resume test-session', + ); + expect(output).toContain( + 'Worktree foo-bar has no changes and will be automatically removed.', + ); + }, + { timeout: 3000 }, + ); + + renderResult!.unmount(); + }); + + it('renders resumption instructions when worktree is dirty', async () => { + hasWorktreeChangesMock.mockResolvedValue(true); + + let renderResult: RenderResult | null = null; + await act(async () => { + const result = await renderWithMockedStats( + emptyMetrics, + 'test-session', + worktreeSettings, + ); + renderResult = result as unknown as RenderResult; + }); + + await vi.waitFor( + () => { + const output = renderResult!.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', + ); + }, + { timeout: 3000 }, + ); + + renderResult!.unmount(); + }); + }); }); diff --git a/packages/cli/src/ui/components/SessionSummaryDisplay.tsx b/packages/cli/src/ui/components/SessionSummaryDisplay.tsx index 5b0a461682..9524809416 100644 --- a/packages/cli/src/ui/components/SessionSummaryDisplay.tsx +++ b/packages/cli/src/ui/components/SessionSummaryDisplay.tsx @@ -5,9 +5,15 @@ */ import type React from 'react'; +import { useEffect, useState } from 'react'; import { StatsDisplay } from './StatsDisplay.js'; import { useSessionStats } from '../contexts/SessionContext.js'; -import { escapeShellArg, getShellConfiguration } from '@google/gemini-cli-core'; +import { useConfig } from '../contexts/ConfigContext.js'; +import { + escapeShellArg, + getShellConfiguration, + hasWorktreeChanges, +} from '@google/gemini-cli-core'; interface SessionSummaryDisplayProps { duration: string; @@ -17,11 +23,40 @@ 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 [isDirty, setIsDirty] = useState(undefined); + + const worktreeSettings = config.getWorktreeSettings(); + + useEffect(() => { + if (worktreeSettings?.path) { + hasWorktreeChanges(worktreeSettings.path, worktreeSettings.baseSha) + .then(setIsDirty) + .catch(() => { + // Fallback to dirty if check fails + setIsDirty(true); + }); + } + }, [worktreeSettings]); + + const escapedSessionId = escapeShellArg(stats.sessionId, shell); + let footer = `To resume this session: gemini --resume ${escapedSessionId}`; + + if (worktreeSettings) { + if (isDirty === true) { + 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)}`; + } else if (isDirty === false) { + footer += `\nWorktree ${worktreeSettings.name} has no changes and will be automatically removed.`; + } + } return ( ({ writeToStderr: vi.fn(), + runExitCleanup: vi.fn(), })); vi.mock('@google/gemini-cli-core', async (importOriginal) => { @@ -30,6 +31,10 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { }; }); +vi.mock('./cleanup.js', () => ({ + runExitCleanup: mocks.runExitCleanup, +})); + vi.mock('node:child_process', async (importOriginal) => { const actual = await importOriginal(); return { @@ -71,6 +76,7 @@ describe('relaunchOnExitCode', () => { ); expect(runner).toHaveBeenCalledTimes(1); + expect(mocks.runExitCleanup).toHaveBeenCalled(); expect(processExitSpy).toHaveBeenCalledWith(0); }); @@ -88,6 +94,7 @@ describe('relaunchOnExitCode', () => { ); expect(runner).toHaveBeenCalledTimes(3); + expect(mocks.runExitCleanup).toHaveBeenCalled(); expect(processExitSpy).toHaveBeenCalledWith(0); }); @@ -106,6 +113,7 @@ describe('relaunchOnExitCode', () => { ), ); expect(stdinResumeSpy).toHaveBeenCalled(); + expect(mocks.runExitCleanup).toHaveBeenCalled(); expect(processExitSpy).toHaveBeenCalledWith(1); }); }); diff --git a/packages/cli/src/utils/relaunch.ts b/packages/cli/src/utils/relaunch.ts index 7e287e4565..adde187828 100644 --- a/packages/cli/src/utils/relaunch.ts +++ b/packages/cli/src/utils/relaunch.ts @@ -6,6 +6,7 @@ import { spawn } from 'node:child_process'; import { RELAUNCH_EXIT_CODE } from './processUtils.js'; +import { runExitCleanup } from './cleanup.js'; import { writeToStderr, type AdminControlsSettings, @@ -17,6 +18,7 @@ export async function relaunchOnExitCode(runner: () => Promise) { const exitCode = await runner(); if (exitCode !== RELAUNCH_EXIT_CODE) { + await runExitCleanup(); process.exit(exitCode); } } catch (error) { @@ -26,6 +28,7 @@ export async function relaunchOnExitCode(runner: () => Promise) { writeToStderr( `Fatal error: Failed to relaunch the CLI process.\n${errorMessage}\n`, ); + await runExitCleanup(); process.exit(1); } } diff --git a/packages/cli/src/utils/worktreeSetup.test.ts b/packages/cli/src/utils/worktreeSetup.test.ts new file mode 100644 index 0000000000..4dd7e81f48 --- /dev/null +++ b/packages/cli/src/utils/worktreeSetup.test.ts @@ -0,0 +1,151 @@ +/** + * @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'; +import * as settingsFunctions from '../config/settings.js'; +import { registerCleanup } from './cleanup.js'; + +// Mock dependencies +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + 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(), + }; +}); + +vi.mock('../config/settings.js', () => ({ + loadSettings: vi.fn(), +})); + +vi.mock('./cleanup.js', () => ({ + registerCleanup: 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', + }); + + vi.mocked(settingsFunctions.loadSettings).mockReturnValue({ + merged: {}, + } as never); + }); + + 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 () => { + const initialSettings = { merged: { foo: 'bar' } } as never; + + const result = await setupWorktree('my-feature', initialSettings); + + 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(settingsFunctions.loadSettings).toHaveBeenCalledWith( + '/mock/project/.gemini/worktrees/my-feature', + ); + expect(registerCleanup).toHaveBeenCalled(); + expect(process.env['GEMINI_CLI_WORKTREE_HANDLED']).toBe('1'); + expect(result).not.toBe(initialSettings); + }); + + it('should generate a name if worktreeName is undefined', async () => { + const initialSettings = { merged: { foo: 'bar' } } as never; + mockService.setup.mockResolvedValue({ + name: 'generated-name', + path: '/mock/project/.gemini/worktrees/generated-name', + baseSha: 'base-sha', + }); + + await setupWorktree(undefined, initialSettings); + + 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'; + const initialSettings = { merged: { foo: 'bar' } } as never; + + const result = await setupWorktree('my-feature', initialSettings); + + expect(coreFunctions.createWorktreeService).not.toHaveBeenCalled(); + expect(process.chdir).not.toHaveBeenCalled(); + expect(result).toBe(initialSettings); + }); + + 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')); + + const initialSettings = { merged: {} } as never; + await expect(setupWorktree('my-feature', initialSettings)).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..6c50c0e6e7 --- /dev/null +++ b/packages/cli/src/utils/worktreeSetup.ts @@ -0,0 +1,57 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + getProjectRootForWorktree, + createWorktreeService, + writeToStderr, +} from '@google/gemini-cli-core'; +import { loadSettings, type LoadedSettings } from '../config/settings.js'; +import { registerCleanup } from './cleanup.js'; + +/** + * Sets up a git worktree for parallel sessions. + * Returns the reloaded settings for the new worktree directory. + * + * 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, + currentSettings: LoadedSettings, +): Promise { + if (process.env['GEMINI_CLI_WORKTREE_HANDLED']) { + return currentSettings; + } + + try { + const projectRoot = await getProjectRootForWorktree(process.cwd()); + const service = await createWorktreeService(process.cwd()); + + const worktreeInfo = await service.setup(worktreeName || undefined); + + process.chdir(worktreeInfo.path); + process.env['GEMINI_CLI_WORKTREE_HANDLED'] = '1'; + process.env['GEMINI_CLI_WORKTREE_BASE_SHA'] = worktreeInfo.baseSha; + + // Reload settings for the new worktree to pick up any local GEMINI.md + const newSettings = loadSettings(process.cwd()); + + registerCleanup(async () => { + const cleanedUp = await service.maybeCleanup(worktreeInfo); + if (cleanedUp) { + process.chdir(projectRoot); + } + }); + + return newSettings; + } 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 aa3e9aa5b6..c15ab3d7b7 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -513,6 +513,12 @@ export interface PolicyUpdateConfirmationRequest { newHash: string; } +export interface WorktreeSettings { + name: string; + path: string; + baseSha: string; +} + export interface ConfigParameters { sessionId: string; clientName?: string; @@ -635,6 +641,7 @@ export interface ConfigParameters { plan?: boolean; tracker?: boolean; planSettings?: PlanSettings; + worktreeSettings?: WorktreeSettings; modelSteering?: boolean; onModelChange?: (model: string) => void; mcpEnabled?: boolean; @@ -679,6 +686,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; @@ -894,6 +902,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; @@ -1522,6 +1531,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 47412dd73c..8a2e875b5b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -235,6 +235,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..21dcf2dd11 --- /dev/null +++ b/packages/core/src/services/worktreeService.test.ts @@ -0,0 +1,304 @@ +/** + * @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'); + +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..39a4b252b5 --- /dev/null +++ b/packages/core/src/services/worktreeService.ts @@ -0,0 +1,217 @@ +/** + * @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 { 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 { + const worktreesBaseDir = path.join(projectRoot, '.gemini', 'worktrees'); + return dirPath.startsWith(worktreesBaseDir); +} + +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/memoryDiscovery.ts b/packages/core/src/utils/memoryDiscovery.ts index f772394d79..5e0912de26 100644 --- a/packages/core/src/utils/memoryDiscovery.ts +++ b/packages/core/src/utils/memoryDiscovery.ts @@ -152,7 +152,7 @@ async function findProjectRoot(startDir: string): Promise { const gitPath = path.join(currentDir, '.git'); try { const stats = await fs.lstat(gitPath); - if (stats.isDirectory()) { + if (stats.isDirectory() || stats.isFile()) { return currentDir; } } catch (error: unknown) { diff --git a/packages/core/src/utils/memoryImportProcessor.ts b/packages/core/src/utils/memoryImportProcessor.ts index bf20bd6c13..d62df14778 100644 --- a/packages/core/src/utils/memoryImportProcessor.ts +++ b/packages/core/src/utils/memoryImportProcessor.ts @@ -48,14 +48,14 @@ 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()) { + if (stats.isDirectory() || stats.isFile()) { return currentDir; } } catch { diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json index f85a39bb35..cf569adf91 100644 --- a/schemas/settings.schema.json +++ b/schemas/settings.schema.json @@ -2319,6 +2319,13 @@ "default": true, "type": "boolean" }, + "worktrees": { + "title": "Enable Git Worktrees", + "description": "Enable git worktrees.", + "markdownDescription": "Enable git worktrees.\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `false`", + "default": false, + "type": "boolean" + }, "extensionManagement": { "title": "Extension Management", "description": "Enable extension management features.",