mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
Restore Checkpoint Feature (#934)
This commit is contained in:
@@ -36,6 +36,7 @@
|
||||
"ignore": "^7.0.0",
|
||||
"open": "^10.1.2",
|
||||
"shell-quote": "^1.8.2",
|
||||
"simple-git": "^3.27.0",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"undici": "^7.10.0"
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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';
|
||||
|
||||
254
packages/core/src/services/gitService.test.ts
Normal file
254
packages/core/src/services/gitService.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
132
packages/core/src/services/gitService.ts
Normal file
132
packages/core/src/services/gitService.ts
Normal file
@@ -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, '.']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user