mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-15 06:12:50 -07:00
feat(worktree): add Git worktree support for isolated parallel sessions (#22973)
This commit is contained in:
@@ -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. |
|
||||
|
||||
@@ -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).
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
> [!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
|
||||
```
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
> [!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 <session_id>
|
||||
```
|
||||
|
||||
## 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
|
||||
@@ -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
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -226,6 +226,51 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe('parseArguments', () => {
|
||||
describe('worktree', () => {
|
||||
it('should parse --worktree flag when provided with a name', async () => {
|
||||
process.argv = ['node', 'script.js', '--worktree', 'my-feature'];
|
||||
const settings = createTestMergedSettings();
|
||||
settings.experimental.worktrees = true;
|
||||
const argv = await parseArguments(settings);
|
||||
expect(argv.worktree).toBe('my-feature');
|
||||
});
|
||||
|
||||
it('should generate a random name when --worktree is provided without a name', async () => {
|
||||
process.argv = ['node', 'script.js', '--worktree'];
|
||||
const settings = createTestMergedSettings();
|
||||
settings.experimental.worktrees = true;
|
||||
const argv = await parseArguments(settings);
|
||||
expect(argv.worktree).toBeDefined();
|
||||
expect(argv.worktree).not.toBe('');
|
||||
expect(typeof argv.worktree).toBe('string');
|
||||
});
|
||||
|
||||
it('should throw an error when --worktree is used but experimental.worktrees is not enabled', async () => {
|
||||
process.argv = ['node', 'script.js', '--worktree', 'feature'];
|
||||
const settings = createTestMergedSettings();
|
||||
settings.experimental.worktrees = false;
|
||||
|
||||
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||
throw new Error('process.exit called');
|
||||
});
|
||||
const mockConsoleError = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
await expect(parseArguments(settings)).rejects.toThrow(
|
||||
'process.exit called',
|
||||
);
|
||||
expect(mockConsoleError).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'The --worktree flag is only available when experimental.worktrees is enabled in your settings.',
|
||||
),
|
||||
);
|
||||
|
||||
mockExit.mockRestore();
|
||||
mockConsoleError.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
description: 'long flags',
|
||||
|
||||
@@ -4,10 +4,11 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import yargs from 'yargs/yargs';
|
||||
import yargs from 'yargs';
|
||||
import { hideBin } from 'yargs/helpers';
|
||||
import process from 'node:process';
|
||||
import * as path from 'node:path';
|
||||
import { execa } from 'execa';
|
||||
import { mcpCommand } from '../commands/mcp.js';
|
||||
import { extensionsCommand } from '../commands/extensions.js';
|
||||
import { skillsCommand } from '../commands/skills.js';
|
||||
@@ -38,6 +39,9 @@ import {
|
||||
applyAdminAllowlist,
|
||||
applyRequiredServers,
|
||||
getAdminBlockedMcpServersMessage,
|
||||
getProjectRootForWorktree,
|
||||
isGeminiWorktree,
|
||||
type WorktreeSettings,
|
||||
type HookDefinition,
|
||||
type HookEventName,
|
||||
type OutputFormat,
|
||||
@@ -48,6 +52,8 @@ import {
|
||||
type MergedSettings,
|
||||
saveModelChange,
|
||||
loadSettings,
|
||||
isWorktreeEnabled,
|
||||
type LoadedSettings,
|
||||
} from './settings.js';
|
||||
|
||||
import { loadSandboxConfig } from './sandboxConfig.js';
|
||||
@@ -74,6 +80,7 @@ export interface CliArgs {
|
||||
debug: boolean | undefined;
|
||||
prompt: string | undefined;
|
||||
promptInteractive: string | undefined;
|
||||
worktree?: string;
|
||||
|
||||
yolo: boolean | undefined;
|
||||
approvalMode: string | undefined;
|
||||
@@ -115,6 +122,36 @@ const coerceCommaSeparated = (values: string[]): string[] => {
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Pre-parses the command line arguments to find the worktree flag.
|
||||
* Used for early setup before full argument parsing with settings.
|
||||
*/
|
||||
export function getWorktreeArg(argv: string[]): string | undefined {
|
||||
const result = yargs(hideBin(argv))
|
||||
.help(false)
|
||||
.version(false)
|
||||
.option('worktree', { alias: 'w', type: 'string' })
|
||||
.strict(false)
|
||||
.exitProcess(false)
|
||||
.parseSync();
|
||||
|
||||
if (result.worktree === undefined) return undefined;
|
||||
return typeof result.worktree === 'string' ? result.worktree.trim() : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a worktree is requested via CLI and enabled in settings.
|
||||
* Returns the requested name (can be empty string for auto-generated) or undefined.
|
||||
*/
|
||||
export function getRequestedWorktreeName(
|
||||
settings: LoadedSettings,
|
||||
): string | undefined {
|
||||
if (!isWorktreeEnabled(settings)) {
|
||||
return undefined;
|
||||
}
|
||||
return getWorktreeArg(process.argv);
|
||||
}
|
||||
|
||||
export async function parseArguments(
|
||||
settings: MergedSettings,
|
||||
): Promise<CliArgs> {
|
||||
@@ -158,6 +195,20 @@ export async function parseArguments(
|
||||
description:
|
||||
'Execute the provided prompt and continue in interactive mode',
|
||||
})
|
||||
.option('worktree', {
|
||||
alias: 'w',
|
||||
type: 'string',
|
||||
skipValidation: true,
|
||||
description:
|
||||
'Start Gemini in a new git worktree. If no name is provided, one is generated automatically.',
|
||||
coerce: (value: unknown): string => {
|
||||
const trimmed = typeof value === 'string' ? value.trim() : '';
|
||||
if (trimmed === '') {
|
||||
return Math.random().toString(36).substring(2, 10);
|
||||
}
|
||||
return trimmed;
|
||||
},
|
||||
})
|
||||
.option('sandbox', {
|
||||
alias: 's',
|
||||
type: 'boolean',
|
||||
@@ -335,6 +386,9 @@ export async function parseArguments(
|
||||
) {
|
||||
return `Invalid values:\n Argument: output-format, Given: "${argv['outputFormat']}", Choices: "text", "json", "stream-json"`;
|
||||
}
|
||||
if (argv['worktree'] && !settings.experimental?.worktrees) {
|
||||
return 'The --worktree flag is only available when experimental.worktrees is enabled in your settings.';
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -420,6 +474,7 @@ export interface LoadCliConfigOptions {
|
||||
projectHooks?: { [K in HookEventName]?: HookDefinition[] } & {
|
||||
disabled?: string[];
|
||||
};
|
||||
worktreeSettings?: WorktreeSettings;
|
||||
}
|
||||
|
||||
export async function loadCliConfig(
|
||||
@@ -431,6 +486,9 @@ export async function loadCliConfig(
|
||||
const { cwd = process.cwd(), projectHooks } = options;
|
||||
const debugMode = isDebugMode(argv);
|
||||
|
||||
const worktreeSettings =
|
||||
options.worktreeSettings ?? (await resolveWorktreeSettings(cwd));
|
||||
|
||||
if (argv.sandbox) {
|
||||
process.env['GEMINI_SANDBOX'] = 'true';
|
||||
}
|
||||
@@ -802,6 +860,7 @@ export async function loadCliConfig(
|
||||
importFormat: settings.context?.importFormat,
|
||||
debugMode,
|
||||
question,
|
||||
worktreeSettings,
|
||||
|
||||
coreTools: settings.tools?.core || undefined,
|
||||
allowedTools: allowedTools.length > 0 ? allowedTools : undefined,
|
||||
@@ -943,3 +1002,48 @@ function mergeExcludeTools(
|
||||
]);
|
||||
return Array.from(allExcludeTools);
|
||||
}
|
||||
|
||||
async function resolveWorktreeSettings(
|
||||
cwd: string,
|
||||
): Promise<WorktreeSettings | undefined> {
|
||||
let worktreePath: string | undefined;
|
||||
try {
|
||||
const { stdout } = await execa('git', ['rev-parse', '--show-toplevel'], {
|
||||
cwd,
|
||||
});
|
||||
const toplevel = stdout.trim();
|
||||
const projectRoot = await getProjectRootForWorktree(toplevel);
|
||||
|
||||
if (isGeminiWorktree(toplevel, projectRoot)) {
|
||||
worktreePath = toplevel;
|
||||
}
|
||||
} catch (_e) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!worktreePath) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let worktreeBaseSha: string | undefined;
|
||||
try {
|
||||
const { stdout } = await execa('git', ['rev-parse', 'HEAD'], {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
worktreeBaseSha = stdout.trim();
|
||||
} catch (e: unknown) {
|
||||
debugLogger.debug(
|
||||
`Failed to resolve worktree base SHA at ${worktreePath}: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!worktreeBaseSha) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
name: path.basename(worktreePath),
|
||||
path: worktreePath,
|
||||
baseSha: worktreeBaseSha,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -632,6 +632,10 @@ export function resetSettingsCacheForTesting() {
|
||||
settingsCache.clear();
|
||||
}
|
||||
|
||||
export function isWorktreeEnabled(settings: LoadedSettings): boolean {
|
||||
return settings.merged.experimental.worktrees;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads settings from user and workspace directories.
|
||||
* Project settings override user settings.
|
||||
|
||||
@@ -1906,6 +1906,16 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Enable local and remote subagents.',
|
||||
showInDialog: false,
|
||||
},
|
||||
worktrees: {
|
||||
type: 'boolean',
|
||||
label: 'Enable Git Worktrees',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description:
|
||||
'Enable automated Git worktree management for parallel work.',
|
||||
showInDialog: true,
|
||||
},
|
||||
extensionManagement: {
|
||||
type: 'boolean',
|
||||
label: 'Extension Management',
|
||||
|
||||
@@ -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', () => ({
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -72,6 +72,8 @@ vi.mock('./config/config.js', () => ({
|
||||
} as unknown as Config),
|
||||
parseArguments: vi.fn().mockResolvedValue({}),
|
||||
isDebugMode: vi.fn(() => false),
|
||||
getRequestedWorktreeName: vi.fn(() => undefined),
|
||||
getWorktreeArg: vi.fn(() => undefined),
|
||||
}));
|
||||
|
||||
vi.mock('read-package-up', () => ({
|
||||
|
||||
@@ -8,10 +8,12 @@ import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { SessionSummaryDisplay } from './SessionSummaryDisplay.js';
|
||||
import * as SessionContext from '../contexts/SessionContext.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import { type SessionMetrics } from '../contexts/SessionContext.js';
|
||||
import {
|
||||
ToolCallDecision,
|
||||
getShellConfiguration,
|
||||
type WorktreeSettings,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
@@ -24,19 +26,30 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
});
|
||||
|
||||
vi.mock('../contexts/SessionContext.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof SessionContext>();
|
||||
const actual =
|
||||
await importOriginal<typeof import('../contexts/SessionContext.js')>();
|
||||
return {
|
||||
...actual,
|
||||
useSessionStats: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../contexts/ConfigContext.js', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('../contexts/ConfigContext.js')>();
|
||||
return {
|
||||
...actual,
|
||||
useConfig: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const getShellConfigurationMock = vi.mocked(getShellConfiguration);
|
||||
const useSessionStatsMock = vi.mocked(SessionContext.useSessionStats);
|
||||
|
||||
const renderWithMockedStats = async (
|
||||
metrics: SessionMetrics,
|
||||
sessionId = 'test-session',
|
||||
worktreeSettings?: WorktreeSettings,
|
||||
) => {
|
||||
useSessionStatsMock.mockReturnValue({
|
||||
stats: {
|
||||
@@ -49,7 +62,11 @@ const renderWithMockedStats = async (
|
||||
|
||||
getPromptCount: () => 5,
|
||||
startNewPrompt: vi.fn(),
|
||||
});
|
||||
} as unknown as ReturnType<typeof SessionContext.useSessionStats>);
|
||||
|
||||
vi.mocked(useConfig).mockReturnValue({
|
||||
getWorktreeSettings: () => worktreeSettings,
|
||||
} as never);
|
||||
|
||||
const result = await renderWithProviders(
|
||||
<SessionSummaryDisplay duration="1h 23m 45s" />,
|
||||
@@ -188,4 +205,30 @@ describe('<SessionSummaryDisplay />', () => {
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Worktree status', () => {
|
||||
it('renders worktree instructions when worktreeSettings are present', async () => {
|
||||
const worktreeSettings: WorktreeSettings = {
|
||||
name: 'foo-bar',
|
||||
path: '/path/to/foo-bar',
|
||||
baseSha: 'base-sha',
|
||||
};
|
||||
|
||||
const { lastFrame, unmount } = await renderWithMockedStats(
|
||||
emptyMetrics,
|
||||
'test-session',
|
||||
worktreeSettings,
|
||||
);
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).toContain('To resume work in this worktree:');
|
||||
expect(output).toContain(
|
||||
'cd /path/to/foo-bar && gemini --resume test-session',
|
||||
);
|
||||
expect(output).toContain(
|
||||
'To remove manually: git worktree remove /path/to/foo-bar',
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import type React from 'react';
|
||||
import { StatsDisplay } from './StatsDisplay.js';
|
||||
import { useSessionStats } from '../contexts/SessionContext.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import { escapeShellArg, getShellConfiguration } from '@google/gemini-cli-core';
|
||||
|
||||
interface SessionSummaryDisplayProps {
|
||||
@@ -17,8 +18,19 @@ export const SessionSummaryDisplay: React.FC<SessionSummaryDisplayProps> = ({
|
||||
duration,
|
||||
}) => {
|
||||
const { stats } = useSessionStats();
|
||||
const config = useConfig();
|
||||
const { shell } = getShellConfiguration();
|
||||
const footer = `To resume this session: gemini --resume ${escapeShellArg(stats.sessionId, shell)}`;
|
||||
|
||||
const worktreeSettings = config.getWorktreeSettings();
|
||||
|
||||
const escapedSessionId = escapeShellArg(stats.sessionId, shell);
|
||||
let footer = `To resume this session: gemini --resume ${escapedSessionId}`;
|
||||
|
||||
if (worktreeSettings) {
|
||||
footer =
|
||||
`To resume work in this worktree: cd ${escapeShellArg(worktreeSettings.path, shell)} && gemini --resume ${escapedSessionId}\n` +
|
||||
`To remove manually: git worktree remove ${escapeShellArg(worktreeSettings.path, shell)}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<StatsDisplay
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { setupWorktree } from './worktreeSetup.js';
|
||||
import * as coreFunctions from '@google/gemini-cli-core';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
getProjectRootForWorktree: vi.fn(),
|
||||
createWorktreeService: vi.fn(),
|
||||
debugLogger: {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
writeToStdout: vi.fn(),
|
||||
writeToStderr: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('setupWorktree', () => {
|
||||
const originalEnv = { ...process.env };
|
||||
const originalCwd = process.cwd;
|
||||
|
||||
const mockService = {
|
||||
setup: vi.fn(),
|
||||
maybeCleanup: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.env = { ...originalEnv };
|
||||
|
||||
// Mock process.cwd and process.chdir
|
||||
let currentPath = '/mock/project';
|
||||
process.cwd = vi.fn().mockImplementation(() => currentPath);
|
||||
process.chdir = vi.fn().mockImplementation((newPath) => {
|
||||
currentPath = newPath;
|
||||
});
|
||||
|
||||
// Mock successful execution of core utilities
|
||||
vi.mocked(coreFunctions.getProjectRootForWorktree).mockResolvedValue(
|
||||
'/mock/project',
|
||||
);
|
||||
vi.mocked(coreFunctions.createWorktreeService).mockResolvedValue(
|
||||
mockService as never,
|
||||
);
|
||||
mockService.setup.mockResolvedValue({
|
||||
name: 'my-feature',
|
||||
path: '/mock/project/.gemini/worktrees/my-feature',
|
||||
baseSha: 'base-sha',
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
process.cwd = originalCwd;
|
||||
delete (process as { chdir?: typeof process.chdir }).chdir;
|
||||
});
|
||||
|
||||
it('should create and switch to a new worktree', async () => {
|
||||
await setupWorktree('my-feature');
|
||||
|
||||
expect(coreFunctions.getProjectRootForWorktree).toHaveBeenCalledWith(
|
||||
'/mock/project',
|
||||
);
|
||||
expect(coreFunctions.createWorktreeService).toHaveBeenCalledWith(
|
||||
'/mock/project',
|
||||
);
|
||||
expect(mockService.setup).toHaveBeenCalledWith('my-feature');
|
||||
expect(process.chdir).toHaveBeenCalledWith(
|
||||
'/mock/project/.gemini/worktrees/my-feature',
|
||||
);
|
||||
expect(process.env['GEMINI_CLI_WORKTREE_HANDLED']).toBe('1');
|
||||
});
|
||||
|
||||
it('should generate a name if worktreeName is undefined', async () => {
|
||||
mockService.setup.mockResolvedValue({
|
||||
name: 'generated-name',
|
||||
path: '/mock/project/.gemini/worktrees/generated-name',
|
||||
baseSha: 'base-sha',
|
||||
});
|
||||
|
||||
await setupWorktree(undefined);
|
||||
|
||||
expect(mockService.setup).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
|
||||
it('should skip worktree creation if GEMINI_CLI_WORKTREE_HANDLED is set', async () => {
|
||||
process.env['GEMINI_CLI_WORKTREE_HANDLED'] = '1';
|
||||
|
||||
await setupWorktree('my-feature');
|
||||
|
||||
expect(coreFunctions.createWorktreeService).not.toHaveBeenCalled();
|
||||
expect(process.chdir).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle errors gracefully and exit', async () => {
|
||||
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||
throw new Error('PROCESS_EXIT');
|
||||
});
|
||||
|
||||
mockService.setup.mockRejectedValue(new Error('Git failure'));
|
||||
|
||||
await expect(setupWorktree('my-feature')).rejects.toThrow('PROCESS_EXIT');
|
||||
|
||||
expect(coreFunctions.writeToStderr).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'Failed to create or switch to worktree: Git failure',
|
||||
),
|
||||
);
|
||||
expect(mockExit).toHaveBeenCalledWith(1);
|
||||
|
||||
mockExit.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
getProjectRootForWorktree,
|
||||
createWorktreeService,
|
||||
writeToStderr,
|
||||
type WorktreeInfo,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
/**
|
||||
* Sets up a git worktree for parallel sessions.
|
||||
*
|
||||
* This function uses a guard (GEMINI_CLI_WORKTREE_HANDLED) to ensure that
|
||||
* when the CLI relaunches itself (e.g. for memory allocation), it doesn't
|
||||
* attempt to create a nested worktree.
|
||||
*/
|
||||
export async function setupWorktree(
|
||||
worktreeName: string | undefined,
|
||||
): Promise<WorktreeInfo | undefined> {
|
||||
if (process.env['GEMINI_CLI_WORKTREE_HANDLED'] === '1') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const projectRoot = await getProjectRootForWorktree(process.cwd());
|
||||
const service = await createWorktreeService(projectRoot);
|
||||
|
||||
const worktreeInfo = await service.setup(worktreeName || undefined);
|
||||
|
||||
process.chdir(worktreeInfo.path);
|
||||
process.env['GEMINI_CLI_WORKTREE_HANDLED'] = '1';
|
||||
|
||||
return worktreeInfo;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
writeToStderr(`Failed to create or switch to worktree: ${errorMessage}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<typeof import('node:fs')>();
|
||||
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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<WorktreeInfo> {
|
||||
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<boolean> {
|
||||
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<WorktreeService> {
|
||||
const projectRoot = await getProjectRootForWorktree(cwd);
|
||||
return new WorktreeService(projectRoot);
|
||||
}
|
||||
|
||||
// Low-level worktree utilities
|
||||
|
||||
export async function getProjectRootForWorktree(cwd: string): Promise<string> {
|
||||
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<string> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string> {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user