diff --git a/docs/cli/git-worktrees.md b/docs/cli/git-worktrees.md index eb9ee60031..6827dcf029 100644 --- a/docs/cli/git-worktrees.md +++ b/docs/cli/git-worktrees.md @@ -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 diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index f0b1f09c6f..91385fbfc1 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -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, diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index 66806f5ef1..60951ba38c 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -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 ? [ diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index ea27b28f0e..3de92e9c0c 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -246,6 +246,19 @@ export const AppContainer = (props: AppContainerProps) => { initializationResult.themeError, ); const [isProcessing, setIsProcessing] = useState(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( @@ -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(null); diff --git a/packages/cli/src/ui/commands/worktreeCommand.test.tsx b/packages/cli/src/ui/commands/worktreeCommand.test.tsx new file mode 100644 index 0000000000..817035d778 --- /dev/null +++ b/packages/cli/src/ui/commands/worktreeCommand.test.tsx @@ -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(); + 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', + ), + }), + ); + }); +}); diff --git a/packages/cli/src/ui/commands/worktreeCommand.tsx b/packages/cli/src/ui/commands/worktreeCommand.tsx new file mode 100644 index 0000000000..86f789978a --- /dev/null +++ b/packages/cli/src/ui/commands/worktreeCommand.tsx @@ -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)}`, + }); + } + }, +}; diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index c15ab3d7b7..663bcdc008 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -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; } diff --git a/packages/core/src/utils/events.ts b/packages/core/src/utils/events.ts index 47c42c93ba..fbaf8d8bc4 100644 --- a/packages/core/src/utils/events.ts +++ b/packages/core/src/utils/events.ts @@ -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]; }