mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-03 01:40:59 -07:00
feat(worktree): add /worktree slash command for interactive switching
Implements the /worktree slash command, allowing users to switch the active session to a new isolated Git worktree without restarting the CLI. Key changes: - Added '/worktree' slash command with streamlined messaging. - Updated 'Config' class to support runtime updates to the target directory. - Introduced 'WorkingDirectoryChanged' event to notify UI and hooks of path changes. - Added comprehensive tests and updated documentation.
This commit is contained in:
@@ -38,6 +38,10 @@ Alternatively, add the following to your `settings.json`:
|
||||
|
||||
## How to use Git worktrees
|
||||
|
||||
There are two ways to use Git worktrees: at startup or mid-session.
|
||||
|
||||
### Using the startup flag
|
||||
|
||||
Use the `--worktree` (`-w`) flag to create an isolated worktree and start Gemini
|
||||
CLI in it.
|
||||
|
||||
@@ -55,6 +59,33 @@ CLI in it.
|
||||
gemini --worktree
|
||||
```
|
||||
|
||||
### Using the slash command
|
||||
|
||||
If you are already in a Gemini session, you can switch to a new isolated
|
||||
worktree without restarting using the `/worktree` command.
|
||||
|
||||
- **Switch with a specific name:**
|
||||
|
||||
```text
|
||||
/worktree feature-auth
|
||||
```
|
||||
|
||||
- **Switch with a random name:**
|
||||
```text
|
||||
/worktree
|
||||
```
|
||||
|
||||
When you use `/worktree`, Gemini:
|
||||
|
||||
1. **Checks your current environment:** If you are already in a worktree, it
|
||||
automatically cleans it up if there are no changes, or preserves it if there
|
||||
are.
|
||||
2. **Creates the new worktree:** Sets up a fresh copy of the codebase and a new
|
||||
branch.
|
||||
3. **Pivots the session:** Switches its working directory and reloads the
|
||||
project context (memory and tools) so you can immediately start working in
|
||||
isolation.
|
||||
|
||||
> **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
|
||||
|
||||
@@ -909,9 +909,9 @@ export async function loadCliConfig(
|
||||
hooks: settings.hooks || {},
|
||||
disabledHooks: settings.hooksConfig?.disabled || [],
|
||||
projectHooks: projectHooks || {},
|
||||
onModelChange: (model: string) => saveModelChange(loadSettings(cwd), model),
|
||||
onModelChange: (model: string) => saveModelChange(loadSettings(), model),
|
||||
onReload: async () => {
|
||||
const refreshedSettings = loadSettings(cwd);
|
||||
const refreshedSettings = loadSettings();
|
||||
return {
|
||||
disabledSkills: refreshedSettings.merged.skills.disabled,
|
||||
agents: refreshedSettings.merged.agents,
|
||||
|
||||
@@ -58,6 +58,7 @@ import { skillsCommand } from '../ui/commands/skillsCommand.js';
|
||||
import { settingsCommand } from '../ui/commands/settingsCommand.js';
|
||||
import { shellsCommand } from '../ui/commands/shellsCommand.js';
|
||||
import { vimCommand } from '../ui/commands/vimCommand.js';
|
||||
import { worktreeCommand } from '../ui/commands/worktreeCommand.js';
|
||||
import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js';
|
||||
import { terminalSetupCommand } from '../ui/commands/terminalSetupCommand.js';
|
||||
import { upgradeCommand } from '../ui/commands/upgradeCommand.js';
|
||||
@@ -197,6 +198,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
||||
statsCommand,
|
||||
themeCommand,
|
||||
toolsCommand,
|
||||
worktreeCommand,
|
||||
...(this.config?.isSkillsSupportEnabled()
|
||||
? this.config?.getSkillManager()?.isAdminEnabled() === false
|
||||
? [
|
||||
|
||||
@@ -246,6 +246,19 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
initializationResult.themeError,
|
||||
);
|
||||
const [isProcessing, setIsProcessing] = useState<boolean>(false);
|
||||
const [cwd, setCwd] = useState(() => config.getTargetDir());
|
||||
|
||||
useEffect(() => {
|
||||
const handleDirectoryChange = (payload: { newPath: string }) => {
|
||||
setCwd(payload.newPath);
|
||||
};
|
||||
|
||||
coreEvents.on(CoreEvent.WorkingDirectoryChanged, handleDirectoryChange);
|
||||
return () => {
|
||||
coreEvents.off(CoreEvent.WorkingDirectoryChanged, handleDirectoryChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const [embeddedShellFocused, setEmbeddedShellFocused] = useState(false);
|
||||
const [showDebugProfiler, setShowDebugProfiler] = useState(false);
|
||||
const [customDialog, setCustomDialog] = useState<React.ReactNode | null>(
|
||||
@@ -415,7 +428,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
|
||||
// Additional hooks moved from App.tsx
|
||||
const { stats: sessionStats } = useSessionStats();
|
||||
const branchName = useGitBranchName(config.getTargetDir());
|
||||
const branchName = useGitBranchName(cwd);
|
||||
|
||||
// Layout measurements
|
||||
const mainControlsRef = useRef<DOMElement>(null);
|
||||
|
||||
188
packages/cli/src/ui/commands/worktreeCommand.test.tsx
Normal file
188
packages/cli/src/ui/commands/worktreeCommand.test.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { worktreeCommand } from './worktreeCommand.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import * as core from '@google/gemini-cli-core';
|
||||
import type { CommandContext } from './types.js';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
import type { MergedSettings } from '../../config/settingsSchema.js';
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
createWorktreeService: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('worktreeCommand', () => {
|
||||
const mockConfig = {
|
||||
getWorktreeSettings: vi.fn(),
|
||||
switchToWorktree: vi.fn(),
|
||||
getTargetDir: vi.fn().mockReturnValue('/old/path'),
|
||||
};
|
||||
|
||||
const mockSettings = {
|
||||
merged: {
|
||||
experimental: {
|
||||
worktrees: true,
|
||||
},
|
||||
} as unknown as MergedSettings,
|
||||
} as LoadedSettings;
|
||||
|
||||
const mockContext = {
|
||||
services: {
|
||||
config: mockConfig as unknown as core.Config,
|
||||
settings: mockSettings,
|
||||
},
|
||||
ui: {
|
||||
addItem: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
const mockService = {
|
||||
setup: vi.fn(),
|
||||
maybeCleanup: vi.fn(),
|
||||
};
|
||||
|
||||
const originalCwd = process.cwd;
|
||||
const originalChdir = process.chdir;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.cwd = vi.fn().mockReturnValue('/old/path');
|
||||
process.chdir = vi.fn();
|
||||
vi.mocked(core.createWorktreeService).mockResolvedValue(
|
||||
mockService as unknown as core.WorktreeService,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.cwd = originalCwd;
|
||||
process.chdir = originalChdir;
|
||||
});
|
||||
|
||||
it('should switch to a new worktree', async () => {
|
||||
const newWorktreeInfo = {
|
||||
name: 'new-feature',
|
||||
path: '/new/path',
|
||||
baseSha: 'new-sha',
|
||||
};
|
||||
mockService.setup.mockResolvedValue(newWorktreeInfo);
|
||||
|
||||
await worktreeCommand.action!(
|
||||
mockContext as unknown as CommandContext,
|
||||
'new-feature',
|
||||
);
|
||||
|
||||
expect(mockService.setup).toHaveBeenCalledWith('new-feature');
|
||||
expect(process.chdir).toHaveBeenCalledWith('/new/path');
|
||||
expect(mockConfig.switchToWorktree).toHaveBeenCalledWith(newWorktreeInfo);
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledTimes(1);
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.INFO,
|
||||
text: 'Switched to worktree: new-feature',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should generate a name if none provided to the command', async () => {
|
||||
mockService.setup.mockResolvedValue({
|
||||
name: 'generated-name',
|
||||
path: '/path/generated-name',
|
||||
baseSha: 'sha',
|
||||
});
|
||||
|
||||
await worktreeCommand.action!(
|
||||
mockContext as unknown as CommandContext,
|
||||
' ',
|
||||
);
|
||||
|
||||
expect(mockService.setup).toHaveBeenCalledWith(undefined);
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: 'Switched to worktree: generated-name',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should cleanup existing worktree before switching', async () => {
|
||||
const currentWorktree = {
|
||||
name: 'old',
|
||||
path: '/old/path',
|
||||
baseSha: 'old-sha',
|
||||
};
|
||||
mockConfig.getWorktreeSettings.mockReturnValue(currentWorktree);
|
||||
mockService.setup.mockResolvedValue({
|
||||
name: 'new',
|
||||
path: '/new',
|
||||
baseSha: 'new',
|
||||
});
|
||||
|
||||
await worktreeCommand.action!(
|
||||
mockContext as unknown as CommandContext,
|
||||
'new',
|
||||
);
|
||||
|
||||
expect(mockService.maybeCleanup).toHaveBeenCalledWith(currentWorktree);
|
||||
expect(mockService.setup).toHaveBeenCalledWith('new');
|
||||
});
|
||||
|
||||
it('should show error if worktrees are disabled', async () => {
|
||||
const disabledSettings = {
|
||||
merged: {
|
||||
experimental: {
|
||||
worktrees: false,
|
||||
},
|
||||
} as unknown as MergedSettings,
|
||||
} as LoadedSettings;
|
||||
|
||||
const contextWithDisabled = {
|
||||
...mockContext,
|
||||
services: {
|
||||
...mockContext.services,
|
||||
settings: disabledSettings,
|
||||
},
|
||||
};
|
||||
|
||||
await worktreeCommand.action!(
|
||||
contextWithDisabled as unknown as CommandContext,
|
||||
'foo',
|
||||
);
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.ERROR,
|
||||
text: expect.stringContaining(
|
||||
'only available when experimental.worktrees is enabled',
|
||||
),
|
||||
}),
|
||||
);
|
||||
expect(core.createWorktreeService).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
mockService.setup.mockRejectedValue(new Error('Git failure'));
|
||||
|
||||
await worktreeCommand.action!(
|
||||
mockContext as unknown as CommandContext,
|
||||
'fail',
|
||||
);
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.ERROR,
|
||||
text: expect.stringContaining(
|
||||
'Failed to switch to worktree: Git failure',
|
||||
),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
63
packages/cli/src/ui/commands/worktreeCommand.tsx
Normal file
63
packages/cli/src/ui/commands/worktreeCommand.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { CommandKind, type SlashCommand } from './types.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import { createWorktreeService } from '@google/gemini-cli-core';
|
||||
|
||||
/**
|
||||
* Slash command to switch the active session to a new Git worktree.
|
||||
* This allows users to isolate their current task without restarting the CLI.
|
||||
*/
|
||||
export const worktreeCommand: SlashCommand = {
|
||||
name: 'worktree',
|
||||
description: 'Switch to a new isolated Git worktree',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
action: async (context, args) => {
|
||||
const { config, settings } = context.services;
|
||||
if (!config) return;
|
||||
|
||||
if (!settings.merged.experimental?.worktrees) {
|
||||
context.ui.addItem({
|
||||
type: MessageType.ERROR,
|
||||
text: 'The /worktree command is only available when experimental.worktrees is enabled in your settings.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const worktreeName = args.trim() || undefined;
|
||||
|
||||
try {
|
||||
const service = await createWorktreeService(process.cwd());
|
||||
|
||||
// 1. If already in a worktree, try to clean up if it's not dirty
|
||||
const currentWorktree = config.getWorktreeSettings();
|
||||
if (currentWorktree) {
|
||||
await service.maybeCleanup(currentWorktree);
|
||||
}
|
||||
|
||||
// 2. Create the new worktree (generates a name if undefined)
|
||||
const info = await service.setup(worktreeName);
|
||||
|
||||
// 3. Switch process directory
|
||||
process.chdir(info.path);
|
||||
|
||||
// 4. Update config (this triggers the WorkingDirectoryChanged event)
|
||||
config.switchToWorktree(info);
|
||||
|
||||
context.ui.addItem({
|
||||
type: MessageType.INFO,
|
||||
text: `Switched to worktree: ${info.name}`,
|
||||
});
|
||||
} catch (error) {
|
||||
context.ui.addItem({
|
||||
type: MessageType.ERROR,
|
||||
text: `Failed to switch to worktree: ${error instanceof Error ? error.message : String(error)}`,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -682,11 +682,11 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
readonly modelConfigService: ModelConfigService;
|
||||
private readonly embeddingModel: string;
|
||||
private readonly sandbox: SandboxConfig | undefined;
|
||||
private readonly targetDir: string;
|
||||
private targetDir: string;
|
||||
private workspaceContext: WorkspaceContext;
|
||||
private readonly debugMode: boolean;
|
||||
private readonly question: string | undefined;
|
||||
private readonly worktreeSettings: WorktreeSettings | undefined;
|
||||
private worktreeSettings: WorktreeSettings | undefined;
|
||||
readonly enableConseca: boolean;
|
||||
|
||||
private readonly coreTools: string[] | undefined;
|
||||
@@ -728,7 +728,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
private gitService: GitService | undefined = undefined;
|
||||
private readonly checkpointing: boolean;
|
||||
private readonly proxy: string | undefined;
|
||||
private readonly cwd: string;
|
||||
private cwd: string;
|
||||
private readonly bugCommand: BugCommandSettings | undefined;
|
||||
private model: string;
|
||||
private readonly disableLoopDetection: boolean;
|
||||
@@ -1535,6 +1535,22 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
return this.worktreeSettings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches the active project context to a new Git worktree.
|
||||
* This updates the target directory, reloads the workspace context,
|
||||
* and triggers a directory change event.
|
||||
*/
|
||||
switchToWorktree(settings: WorktreeSettings): void {
|
||||
this.targetDir = path.resolve(settings.path);
|
||||
this.worktreeSettings = settings;
|
||||
this.workspaceContext = new WorkspaceContext(this.targetDir, []);
|
||||
this.fileDiscoveryService = null;
|
||||
this.cwd = this.targetDir;
|
||||
coreEvents.emit(CoreEvent.WorkingDirectoryChanged, {
|
||||
newPath: this.targetDir,
|
||||
});
|
||||
}
|
||||
|
||||
getClientName(): string | undefined {
|
||||
return this.clientName;
|
||||
}
|
||||
|
||||
@@ -190,10 +190,18 @@ export enum CoreEvent {
|
||||
EditorSelected = 'editor-selected',
|
||||
SlashCommandConflicts = 'slash-command-conflicts',
|
||||
QuotaChanged = 'quota-changed',
|
||||
WorkingDirectoryChanged = 'working-directory-changed',
|
||||
TelemetryKeychainAvailability = 'telemetry-keychain-availability',
|
||||
TelemetryTokenStorageType = 'telemetry-token-storage-type',
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload for the 'working-directory-changed' event.
|
||||
*/
|
||||
export interface WorkingDirectoryChangedPayload {
|
||||
newPath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload for the 'editor-selected' event.
|
||||
*/
|
||||
@@ -223,6 +231,7 @@ export interface CoreEvents extends ExtensionEvents {
|
||||
[CoreEvent.RequestEditorSelection]: never[];
|
||||
[CoreEvent.EditorSelected]: [EditorSelectedPayload];
|
||||
[CoreEvent.SlashCommandConflicts]: [SlashCommandConflictsPayload];
|
||||
[CoreEvent.WorkingDirectoryChanged]: [WorkingDirectoryChangedPayload];
|
||||
[CoreEvent.TelemetryKeychainAvailability]: [KeychainAvailabilityEvent];
|
||||
[CoreEvent.TelemetryTokenStorageType]: [TokenStorageInitializationEvent];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user