feat(worktree): add Git worktree support for isolated parallel sessions (#22973)

This commit is contained in:
Jerop Kipruto
2026-03-20 10:10:51 -04:00
committed by GitHub
parent b9c87c14a2
commit 5a3c7154df
23 changed files with 1090 additions and 9 deletions
@@ -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