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 1f650a602f
commit 894190b0ca
23 changed files with 1090 additions and 9 deletions
@@ -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)}`,
);
}
}
}
}