summaryrefslogtreecommitdiff
path: root/packages/core/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/core/src')
-rw-r--r--packages/core/src/config/config.ts25
-rw-r--r--packages/core/src/core/client.ts10
-rw-r--r--packages/core/src/core/geminiChat.ts3
-rw-r--r--packages/core/src/index.ts1
-rw-r--r--packages/core/src/services/gitService.test.ts254
-rw-r--r--packages/core/src/services/gitService.ts132
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, '.']);
+ }
+}