diff options
Diffstat (limited to 'packages/core/src')
| -rw-r--r-- | packages/core/src/config/config.ts | 25 | ||||
| -rw-r--r-- | packages/core/src/core/client.ts | 10 | ||||
| -rw-r--r-- | packages/core/src/core/geminiChat.ts | 3 | ||||
| -rw-r--r-- | packages/core/src/index.ts | 1 | ||||
| -rw-r--r-- | packages/core/src/services/gitService.test.ts | 254 | ||||
| -rw-r--r-- | packages/core/src/services/gitService.ts | 132 |
6 files changed, 425 insertions, 0 deletions
diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index d42fbbec..297178fd 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -25,6 +25,7 @@ import { WebSearchTool } from '../tools/web-search.js'; import { GeminiClient } from '../core/client.js'; import { GEMINI_CONFIG_DIR as GEMINI_DIR } from '../tools/memoryTool.js'; import { FileDiscoveryService } from '../services/fileDiscoveryService.js'; +import { GitService } from '../services/gitService.js'; import { initializeTelemetry } from '../telemetry/index.js'; export enum ApprovalMode { @@ -80,6 +81,7 @@ export interface ConfigParameters { fileFilteringRespectGitIgnore?: boolean; fileFilteringAllowBuildArtifacts?: boolean; enableModifyWithExternalEditors?: boolean; + checkpoint?: boolean; } export class Config { @@ -111,6 +113,8 @@ export class Config { private readonly fileFilteringAllowBuildArtifacts: boolean; private readonly enableModifyWithExternalEditors: boolean; private fileDiscoveryService: FileDiscoveryService | null = null; + private gitService: GitService | undefined = undefined; + private readonly checkpoint: boolean; constructor(params: ConfigParameters) { this.sessionId = params.sessionId; @@ -142,6 +146,7 @@ export class Config { params.fileFilteringAllowBuildArtifacts ?? false; this.enableModifyWithExternalEditors = params.enableModifyWithExternalEditors ?? false; + this.checkpoint = params.checkpoint ?? false; if (params.contextFileName) { setGeminiMdFilename(params.contextFileName); @@ -182,6 +187,10 @@ export class Config { return this.targetDir; } + getProjectRoot(): string { + return this.targetDir; + } + async getToolRegistry(): Promise<ToolRegistry> { return this.toolRegistry; } @@ -265,6 +274,10 @@ export class Config { return this.geminiClient; } + getGeminiDir(): string { + return path.join(this.targetDir, GEMINI_DIR); + } + getGeminiIgnorePatterns(): string[] { return this.geminiIgnorePatterns; } @@ -281,6 +294,10 @@ export class Config { return this.enableModifyWithExternalEditors; } + getCheckpointEnabled(): boolean { + return this.checkpoint; + } + async getFileService(): Promise<FileDiscoveryService> { if (!this.fileDiscoveryService) { this.fileDiscoveryService = new FileDiscoveryService(this.targetDir); @@ -291,6 +308,14 @@ export class Config { } return this.fileDiscoveryService; } + + async getGitService(): Promise<GitService> { + if (!this.gitService) { + this.gitService = new GitService(this.targetDir); + await this.gitService.initialize(); + } + return this.gitService; + } } function findEnvFile(startDir: string): string | null { diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 596ddcd7..4e4dc55e 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -77,6 +77,16 @@ export class GeminiClient { return this.chat; } + async getHistory(): Promise<Content[]> { + const chat = await this.chat; + return chat.getHistory(); + } + + async setHistory(history: Content[]): Promise<void> { + const chat = await this.chat; + chat.setHistory(history); + } + private async getEnvironment(): Promise<Part[]> { const cwd = process.cwd(); const today = new Date().toLocaleDateString(undefined, { diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index 2a81aca8..d15f9d1a 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -297,6 +297,9 @@ export class GeminiChat { addHistory(content: Content): void { this.history.push(content); } + setHistory(history: Content[]): void { + this.history = history; + } private async *processStreamResponse( streamResponse: AsyncGenerator<GenerateContentResponse>, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 60959281..09ad1e92 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -29,6 +29,7 @@ export * from './utils/editor.js'; // Export services export * from './services/fileDiscoveryService.js'; +export * from './services/gitService.js'; // Export base tool definitions export * from './tools/tools.js'; diff --git a/packages/core/src/services/gitService.test.ts b/packages/core/src/services/gitService.test.ts new file mode 100644 index 00000000..67c3c091 --- /dev/null +++ b/packages/core/src/services/gitService.test.ts @@ -0,0 +1,254 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { GitService, historyDirName } from './gitService.js'; +import * as path from 'path'; +import type * as FsPromisesModule from 'fs/promises'; +import type { ChildProcess } from 'node:child_process'; + +const hoistedMockExec = vi.hoisted(() => vi.fn()); +vi.mock('node:child_process', () => ({ + exec: hoistedMockExec, +})); + +const hoistedMockMkdir = vi.hoisted(() => vi.fn()); +const hoistedMockReadFile = vi.hoisted(() => vi.fn()); +const hoistedMockWriteFile = vi.hoisted(() => vi.fn()); + +vi.mock('fs/promises', async (importOriginal) => { + const actual = (await importOriginal()) as typeof FsPromisesModule; + return { + ...actual, + mkdir: hoistedMockMkdir, + readFile: hoistedMockReadFile, + writeFile: hoistedMockWriteFile, + }; +}); + +const hoistedMockSimpleGit = vi.hoisted(() => vi.fn()); +const hoistedMockCheckIsRepo = vi.hoisted(() => vi.fn()); +const hoistedMockInit = vi.hoisted(() => vi.fn()); +const hoistedMockRaw = vi.hoisted(() => vi.fn()); +const hoistedMockAdd = vi.hoisted(() => vi.fn()); +const hoistedMockCommit = vi.hoisted(() => vi.fn()); +vi.mock('simple-git', () => ({ + simpleGit: hoistedMockSimpleGit.mockImplementation(() => ({ + checkIsRepo: hoistedMockCheckIsRepo, + init: hoistedMockInit, + raw: hoistedMockRaw, + add: hoistedMockAdd, + commit: hoistedMockCommit, + })), + CheckRepoActions: { IS_REPO_ROOT: 'is-repo-root' }, +})); + +const hoistedIsGitRepositoryMock = vi.hoisted(() => vi.fn()); +vi.mock('../utils/gitUtils.js', () => ({ + isGitRepository: hoistedIsGitRepositoryMock, +})); + +const hoistedMockIsNodeError = vi.hoisted(() => vi.fn()); +vi.mock('../utils/errors.js', () => ({ + isNodeError: hoistedMockIsNodeError, +})); + +describe('GitService', () => { + const mockProjectRoot = '/test/project'; + + beforeEach(() => { + vi.clearAllMocks(); + hoistedIsGitRepositoryMock.mockReturnValue(true); + hoistedMockExec.mockImplementation((command, callback) => { + if (command === 'git --version') { + callback(null, 'git version 2.0.0'); + } else { + callback(new Error('Command not mocked')); + } + return {}; + }); + hoistedMockMkdir.mockResolvedValue(undefined); + hoistedMockReadFile.mockResolvedValue(''); + hoistedMockWriteFile.mockResolvedValue(undefined); + hoistedMockIsNodeError.mockImplementation((e) => e instanceof Error); + + hoistedMockSimpleGit.mockImplementation(() => ({ + checkIsRepo: hoistedMockCheckIsRepo, + init: hoistedMockInit, + raw: hoistedMockRaw, + add: hoistedMockAdd, + commit: hoistedMockCommit, + })); + hoistedMockCheckIsRepo.mockResolvedValue(false); + hoistedMockInit.mockResolvedValue(undefined); + hoistedMockRaw.mockResolvedValue(''); + hoistedMockAdd.mockResolvedValue(undefined); + hoistedMockCommit.mockResolvedValue({ + commit: 'initial', + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('constructor', () => { + it('should successfully create an instance if projectRoot is a Git repository', () => { + expect(() => new GitService(mockProjectRoot)).not.toThrow(); + }); + }); + + describe('verifyGitAvailability', () => { + it('should resolve true if git --version command succeeds', async () => { + const service = new GitService(mockProjectRoot); + await expect(service.verifyGitAvailability()).resolves.toBe(true); + }); + + it('should resolve false if git --version command fails', async () => { + hoistedMockExec.mockImplementation((command, callback) => { + callback(new Error('git not found')); + return {} as ChildProcess; + }); + const service = new GitService(mockProjectRoot); + await expect(service.verifyGitAvailability()).resolves.toBe(false); + }); + }); + + describe('initialize', () => { + it('should throw an error if projectRoot is not a Git repository', async () => { + hoistedIsGitRepositoryMock.mockReturnValue(false); + const service = new GitService(mockProjectRoot); + await expect(service.initialize()).rejects.toThrow( + 'GitService requires a Git repository', + ); + }); + + it('should throw an error if Git is not available', async () => { + hoistedMockExec.mockImplementation((command, callback) => { + callback(new Error('git not found')); + return {} as ChildProcess; + }); + const service = new GitService(mockProjectRoot); + await expect(service.initialize()).rejects.toThrow( + 'GitService requires Git to be installed', + ); + }); + }); + + it('should call setupHiddenGitRepository if Git is available', async () => { + const service = new GitService(mockProjectRoot); + const setupSpy = vi + .spyOn(service, 'setupHiddenGitRepository') + .mockResolvedValue(undefined); + + await service.initialize(); + expect(setupSpy).toHaveBeenCalled(); + }); + + describe('setupHiddenGitRepository', () => { + const historyDir = path.join(mockProjectRoot, historyDirName); + const repoDir = path.join(historyDir, 'repository'); + const hiddenGitIgnorePath = path.join(repoDir, '.gitignore'); + const visibleGitIgnorePath = path.join(mockProjectRoot, '.gitignore'); + + it('should create history and repository directories', async () => { + const service = new GitService(mockProjectRoot); + await service.setupHiddenGitRepository(); + expect(hoistedMockMkdir).toHaveBeenCalledWith(repoDir, { + recursive: true, + }); + }); + + it('should initialize git repo in historyDir if not already initialized', async () => { + hoistedMockCheckIsRepo.mockResolvedValue(false); + const service = new GitService(mockProjectRoot); + await service.setupHiddenGitRepository(); + expect(hoistedMockSimpleGit).toHaveBeenCalledWith(repoDir); + expect(hoistedMockInit).toHaveBeenCalled(); + }); + + it('should not initialize git repo if already initialized', async () => { + hoistedMockCheckIsRepo.mockResolvedValue(true); + const service = new GitService(mockProjectRoot); + await service.setupHiddenGitRepository(); + expect(hoistedMockInit).not.toHaveBeenCalled(); + }); + + it('should copy .gitignore from projectRoot if it exists', async () => { + const gitignoreContent = `node_modules/\n.env`; + hoistedMockReadFile.mockImplementation(async (filePath) => { + if (filePath === visibleGitIgnorePath) { + return gitignoreContent; + } + return ''; + }); + const service = new GitService(mockProjectRoot); + await service.setupHiddenGitRepository(); + expect(hoistedMockReadFile).toHaveBeenCalledWith( + visibleGitIgnorePath, + 'utf-8', + ); + expect(hoistedMockWriteFile).toHaveBeenCalledWith( + hiddenGitIgnorePath, + gitignoreContent, + ); + }); + + it('should throw an error if reading projectRoot .gitignore fails with other errors', async () => { + const readError = new Error('Read permission denied'); + hoistedMockReadFile.mockImplementation(async (filePath) => { + if (filePath === visibleGitIgnorePath) { + throw readError; + } + return ''; + }); + hoistedMockIsNodeError.mockImplementation( + (e: unknown): e is NodeJS.ErrnoException => + e === readError && + e instanceof Error && + (e as NodeJS.ErrnoException).code !== 'ENOENT', + ); + + const service = new GitService(mockProjectRoot); + await expect(service.setupHiddenGitRepository()).rejects.toThrow( + 'Read permission denied', + ); + }); + + it('should add historyDirName to projectRoot .gitignore if not present', async () => { + const initialGitignoreContent = 'node_modules/'; + hoistedMockReadFile.mockImplementation(async (filePath) => { + if (filePath === visibleGitIgnorePath) { + return initialGitignoreContent; + } + return ''; + }); + const service = new GitService(mockProjectRoot); + await service.setupHiddenGitRepository(); + const expectedContent = `${initialGitignoreContent}\n# Gemini CLI history directory\n${historyDirName}\n`; + expect(hoistedMockWriteFile).toHaveBeenCalledWith( + visibleGitIgnorePath, + expectedContent, + ); + }); + + it('should make an initial commit if no commits exist in history repo', async () => { + hoistedMockRaw.mockResolvedValue(''); + const service = new GitService(mockProjectRoot); + await service.setupHiddenGitRepository(); + expect(hoistedMockAdd).toHaveBeenCalledWith(hiddenGitIgnorePath); + expect(hoistedMockCommit).toHaveBeenCalledWith('Initial commit'); + }); + + it('should not make an initial commit if commits already exist', async () => { + hoistedMockRaw.mockResolvedValue('test-commit'); + const service = new GitService(mockProjectRoot); + await service.setupHiddenGitRepository(); + expect(hoistedMockAdd).not.toHaveBeenCalled(); + expect(hoistedMockCommit).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/core/src/services/gitService.ts b/packages/core/src/services/gitService.ts new file mode 100644 index 00000000..8cd6b887 --- /dev/null +++ b/packages/core/src/services/gitService.ts @@ -0,0 +1,132 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { isNodeError } from '../utils/errors.js'; +import { isGitRepository } from '../utils/gitUtils.js'; +import { exec } from 'node:child_process'; +import { simpleGit, SimpleGit, CheckRepoActions } from 'simple-git'; + +export const historyDirName = '.gemini_cli_history'; + +export class GitService { + private projectRoot: string; + + constructor(projectRoot: string) { + this.projectRoot = path.resolve(projectRoot); + } + + async initialize(): Promise<void> { + if (!isGitRepository(this.projectRoot)) { + throw new Error('GitService requires a Git repository'); + } + const gitAvailable = await this.verifyGitAvailability(); + if (!gitAvailable) { + throw new Error('GitService requires Git to be installed'); + } + this.setupHiddenGitRepository(); + } + + verifyGitAvailability(): Promise<boolean> { + return new Promise((resolve) => { + exec('git --version', (error) => { + if (error) { + resolve(false); + } else { + resolve(true); + } + }); + }); + } + + /** + * Creates a hidden git repository in the project root. + * The Git repository is used to support checkpointing. + */ + async setupHiddenGitRepository() { + const historyDir = path.join(this.projectRoot, historyDirName); + const repoDir = path.join(historyDir, 'repository'); + + await fs.mkdir(repoDir, { recursive: true }); + const repoInstance: SimpleGit = simpleGit(repoDir); + const isRepoDefined = await repoInstance.checkIsRepo( + CheckRepoActions.IS_REPO_ROOT, + ); + if (!isRepoDefined) { + await repoInstance.init(); + try { + await repoInstance.raw([ + 'worktree', + 'add', + this.projectRoot, + '--force', + ]); + } catch (error) { + console.log('Failed to add worktree:', error); + } + } + + const visibileGitIgnorePath = path.join(this.projectRoot, '.gitignore'); + const hiddenGitIgnorePath = path.join(repoDir, '.gitignore'); + + let visibileGitIgnoreContent = ``; + try { + visibileGitIgnoreContent = await fs.readFile( + visibileGitIgnorePath, + 'utf-8', + ); + } catch (error) { + if (isNodeError(error) && error.code !== 'ENOENT') { + throw error; + } + } + + await fs.writeFile(hiddenGitIgnorePath, visibileGitIgnoreContent); + + if (!visibileGitIgnoreContent.includes(historyDirName)) { + const updatedContent = `${visibileGitIgnoreContent}\n# Gemini CLI history directory\n${historyDirName}\n`; + await fs.writeFile(visibileGitIgnorePath, updatedContent); + } + + const commit = await repoInstance.raw([ + 'rev-list', + '--all', + '--max-count=1', + ]); + if (!commit) { + await repoInstance.add(hiddenGitIgnorePath); + + await repoInstance.commit('Initial commit'); + } + } + + private get hiddenGitRepository(): SimpleGit { + const historyDir = path.join(this.projectRoot, historyDirName); + const repoDir = path.join(historyDir, 'repository'); + return simpleGit(this.projectRoot).env({ + GIT_DIR: path.join(repoDir, '.git'), + GIT_WORK_TREE: this.projectRoot, + }); + } + + async getCurrentCommitHash(): Promise<string> { + const hash = await this.hiddenGitRepository.raw('rev-parse', 'HEAD'); + return hash.trim(); + } + + async createFileSnapshot(message: string): Promise<string> { + const repo = this.hiddenGitRepository; + await repo.add('.'); + const commitResult = await repo.commit(message); + return commitResult.commit; + } + + async restoreProjectFromSnapshot(commitHash: string): Promise<void> { + const repo = this.hiddenGitRepository; + await repo.raw(['restore', '--source', commitHash, '.']); + } +} |
