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:
Jerop Kipruto
2026-03-18 14:35:18 -04:00
parent 94ce4aba8b
commit e5f4093058
8 changed files with 328 additions and 6 deletions

View File

@@ -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

View File

@@ -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,

View File

@@ -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
? [

View File

@@ -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);

View 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',
),
}),
);
});
});

View 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)}`,
});
}
},
};

View File

@@ -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;
}

View File

@@ -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];
}