mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-14 20:10:36 -07:00
feat(worktree): add Git worktree support for isolated parallel sessions (#22973)
This commit is contained in:
@@ -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)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user