summaryrefslogtreecommitdiff
path: root/packages/core/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/core/src')
-rw-r--r--packages/core/src/services/gitService.test.ts105
-rw-r--r--packages/core/src/services/gitService.ts80
2 files changed, 91 insertions, 94 deletions
diff --git a/packages/core/src/services/gitService.test.ts b/packages/core/src/services/gitService.test.ts
index 67c3c091..93e2750d 100644
--- a/packages/core/src/services/gitService.test.ts
+++ b/packages/core/src/services/gitService.test.ts
@@ -5,7 +5,7 @@
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
-import { GitService, historyDirName } from './gitService.js';
+import { GitService } from './gitService.js';
import * as path from 'path';
import type * as FsPromisesModule from 'fs/promises';
import type { ChildProcess } from 'node:child_process';
@@ -29,6 +29,7 @@ vi.mock('fs/promises', async (importOriginal) => {
};
});
+const hoistedMockEnv = vi.hoisted(() => vi.fn());
const hoistedMockSimpleGit = vi.hoisted(() => vi.fn());
const hoistedMockCheckIsRepo = vi.hoisted(() => vi.fn());
const hoistedMockInit = vi.hoisted(() => vi.fn());
@@ -42,6 +43,7 @@ vi.mock('simple-git', () => ({
raw: hoistedMockRaw,
add: hoistedMockAdd,
commit: hoistedMockCommit,
+ env: hoistedMockEnv,
})),
CheckRepoActions: { IS_REPO_ROOT: 'is-repo-root' },
}));
@@ -56,8 +58,31 @@ vi.mock('../utils/errors.js', () => ({
isNodeError: hoistedMockIsNodeError,
}));
+const hoistedMockHomedir = vi.hoisted(() => vi.fn());
+vi.mock('os', () => ({
+ homedir: hoistedMockHomedir,
+}));
+
+const hoistedMockCreateHash = vi.hoisted(() => {
+ const mockUpdate = vi.fn().mockReturnThis();
+ const mockDigest = vi.fn();
+ return {
+ createHash: vi.fn(() => ({
+ update: mockUpdate,
+ digest: mockDigest,
+ })),
+ mockUpdate,
+ mockDigest,
+ };
+});
+vi.mock('crypto', () => ({
+ createHash: hoistedMockCreateHash.createHash,
+}));
+
describe('GitService', () => {
const mockProjectRoot = '/test/project';
+ const mockHomedir = '/mock/home';
+ const mockHash = 'mock-hash';
beforeEach(() => {
vi.clearAllMocks();
@@ -74,13 +99,24 @@ describe('GitService', () => {
hoistedMockReadFile.mockResolvedValue('');
hoistedMockWriteFile.mockResolvedValue(undefined);
hoistedMockIsNodeError.mockImplementation((e) => e instanceof Error);
+ hoistedMockHomedir.mockReturnValue(mockHomedir);
+ hoistedMockCreateHash.mockUpdate.mockReturnThis();
+ hoistedMockCreateHash.mockDigest.mockReturnValue(mockHash);
+ hoistedMockEnv.mockImplementation(() => ({
+ checkIsRepo: hoistedMockCheckIsRepo,
+ init: hoistedMockInit,
+ raw: hoistedMockRaw,
+ add: hoistedMockAdd,
+ commit: hoistedMockCommit,
+ }));
hoistedMockSimpleGit.mockImplementation(() => ({
checkIsRepo: hoistedMockCheckIsRepo,
init: hoistedMockInit,
raw: hoistedMockRaw,
add: hoistedMockAdd,
commit: hoistedMockCommit,
+ env: hoistedMockEnv,
}));
hoistedMockCheckIsRepo.mockResolvedValue(false);
hoistedMockInit.mockResolvedValue(undefined);
@@ -136,27 +172,26 @@ describe('GitService', () => {
'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);
+ it('should call setupShadowGitRepository if Git is available', async () => {
+ const service = new GitService(mockProjectRoot);
+ const setupSpy = vi
+ .spyOn(service, 'setupShadowGitRepository')
+ .mockResolvedValue(undefined);
- await service.initialize();
- expect(setupSpy).toHaveBeenCalled();
+ await service.initialize();
+ expect(setupSpy).toHaveBeenCalled();
+ });
});
- describe('setupHiddenGitRepository', () => {
- const historyDir = path.join(mockProjectRoot, historyDirName);
- const repoDir = path.join(historyDir, 'repository');
+ describe('setupShadowGitRepository', () => {
+ const repoDir = path.join(mockHomedir, '.gemini', 'history', mockHash);
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();
+ await service.setupShadowGitRepository();
expect(hoistedMockMkdir).toHaveBeenCalledWith(repoDir, {
recursive: true,
});
@@ -165,7 +200,7 @@ describe('GitService', () => {
it('should initialize git repo in historyDir if not already initialized', async () => {
hoistedMockCheckIsRepo.mockResolvedValue(false);
const service = new GitService(mockProjectRoot);
- await service.setupHiddenGitRepository();
+ await service.setupShadowGitRepository();
expect(hoistedMockSimpleGit).toHaveBeenCalledWith(repoDir);
expect(hoistedMockInit).toHaveBeenCalled();
});
@@ -173,7 +208,7 @@ describe('GitService', () => {
it('should not initialize git repo if already initialized', async () => {
hoistedMockCheckIsRepo.mockResolvedValue(true);
const service = new GitService(mockProjectRoot);
- await service.setupHiddenGitRepository();
+ await service.setupShadowGitRepository();
expect(hoistedMockInit).not.toHaveBeenCalled();
});
@@ -186,7 +221,7 @@ describe('GitService', () => {
return '';
});
const service = new GitService(mockProjectRoot);
- await service.setupHiddenGitRepository();
+ await service.setupShadowGitRepository();
expect(hoistedMockReadFile).toHaveBeenCalledWith(
visibleGitIgnorePath,
'utf-8',
@@ -206,48 +241,28 @@ describe('GitService', () => {
return '';
});
hoistedMockIsNodeError.mockImplementation(
- (e: unknown): e is NodeJS.ErrnoException =>
- e === readError &&
- e instanceof Error &&
- (e as NodeJS.ErrnoException).code !== 'ENOENT',
+ (e: unknown): e is NodeJS.ErrnoException => e instanceof Error,
);
const service = new GitService(mockProjectRoot);
- await expect(service.setupHiddenGitRepository()).rejects.toThrow(
+ await expect(service.setupShadowGitRepository()).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('');
+ hoistedMockCheckIsRepo.mockResolvedValue(false);
const service = new GitService(mockProjectRoot);
- await service.setupHiddenGitRepository();
- expect(hoistedMockAdd).toHaveBeenCalledWith(hiddenGitIgnorePath);
- expect(hoistedMockCommit).toHaveBeenCalledWith('Initial commit');
+ await service.setupShadowGitRepository();
+ expect(hoistedMockCommit).toHaveBeenCalledWith('Initial commit', {
+ '--allow-empty': null,
+ });
});
it('should not make an initial commit if commits already exist', async () => {
- hoistedMockRaw.mockResolvedValue('test-commit');
+ hoistedMockCheckIsRepo.mockResolvedValue(true);
const service = new GitService(mockProjectRoot);
- await service.setupHiddenGitRepository();
- expect(hoistedMockAdd).not.toHaveBeenCalled();
+ await service.setupShadowGitRepository();
expect(hoistedMockCommit).not.toHaveBeenCalled();
});
});
diff --git a/packages/core/src/services/gitService.ts b/packages/core/src/services/gitService.ts
index 8cd6b887..38c1c378 100644
--- a/packages/core/src/services/gitService.ts
+++ b/packages/core/src/services/gitService.ts
@@ -6,13 +6,13 @@
import * as fs from 'fs/promises';
import * as path from 'path';
+import * as os from 'os';
+import * as crypto from 'crypto';
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;
@@ -20,6 +20,14 @@ export class GitService {
this.projectRoot = path.resolve(projectRoot);
}
+ private getHistoryDir(): string {
+ const hash = crypto
+ .createHash('sha256')
+ .update(this.projectRoot)
+ .digest('hex');
+ return path.join(os.homedir(), '.gemini', 'history', hash);
+ }
+
async initialize(): Promise<void> {
if (!isGitRepository(this.projectRoot)) {
throw new Error('GitService requires a Git repository');
@@ -28,7 +36,7 @@ export class GitService {
if (!gitAvailable) {
throw new Error('GitService requires Git to be installed');
}
- this.setupHiddenGitRepository();
+ this.setupShadowGitRepository();
}
verifyGitAvailability(): Promise<boolean> {
@@ -47,66 +55,40 @@ export class GitService {
* 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');
+ async setupShadowGitRepository() {
+ const repoDir = this.getHistoryDir();
await fs.mkdir(repoDir, { recursive: true });
- const repoInstance: SimpleGit = simpleGit(repoDir);
- const isRepoDefined = await repoInstance.checkIsRepo(
+ const isRepoDefined = await simpleGit(repoDir).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);
- }
+ await simpleGit(repoDir).init(false, {
+ '--initial-branch': 'main',
+ });
+
+ const repo = simpleGit(repoDir);
+ await repo.commit('Initial commit', { '--allow-empty': null });
}
- const visibileGitIgnorePath = path.join(this.projectRoot, '.gitignore');
- const hiddenGitIgnorePath = path.join(repoDir, '.gitignore');
+ const userGitIgnorePath = path.join(this.projectRoot, '.gitignore');
+ const shadowGitIgnorePath = path.join(repoDir, '.gitignore');
- let visibileGitIgnoreContent = ``;
+ let userGitIgnoreContent = '';
try {
- visibileGitIgnoreContent = await fs.readFile(
- visibileGitIgnorePath,
- 'utf-8',
- );
+ userGitIgnoreContent = await fs.readFile(userGitIgnorePath, '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');
- }
+ await fs.writeFile(shadowGitIgnorePath, userGitIgnoreContent);
}
- private get hiddenGitRepository(): SimpleGit {
- const historyDir = path.join(this.projectRoot, historyDirName);
- const repoDir = path.join(historyDir, 'repository');
+ private get shadowGitRepository(): SimpleGit {
+ const repoDir = this.getHistoryDir();
return simpleGit(this.projectRoot).env({
GIT_DIR: path.join(repoDir, '.git'),
GIT_WORK_TREE: this.projectRoot,
@@ -114,19 +96,19 @@ export class GitService {
}
async getCurrentCommitHash(): Promise<string> {
- const hash = await this.hiddenGitRepository.raw('rev-parse', 'HEAD');
+ const hash = await this.shadowGitRepository.raw('rev-parse', 'HEAD');
return hash.trim();
}
async createFileSnapshot(message: string): Promise<string> {
- const repo = this.hiddenGitRepository;
+ const repo = this.shadowGitRepository;
await repo.add('.');
const commitResult = await repo.commit(message);
return commitResult.commit;
}
async restoreProjectFromSnapshot(commitHash: string): Promise<void> {
- const repo = this.hiddenGitRepository;
+ const repo = this.shadowGitRepository;
await repo.raw(['restore', '--source', commitHash, '.']);
}
}