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 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(); + }); }); - 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'); + 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 { 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 { @@ -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 { - 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 { - 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 { - const repo = this.hiddenGitRepository; + const repo = this.shadowGitRepository; await repo.raw(['restore', '--source', commitHash, '.']); } }