|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; |
|
|
import { GitService } 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 hoistedMockEnv = vi.hoisted(() => vi.fn()); |
|
|
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, |
|
|
env: hoistedMockEnv, |
|
|
})), |
|
|
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, |
|
|
})); |
|
|
|
|
|
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(); |
|
|
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); |
|
|
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); |
|
|
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 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(); |
|
|
}); |
|
|
}); |
|
|
|
|
|
describe('setupShadowGitRepository', () => { |
|
|
const repoDir = path.join(mockHomedir, '.gemini', 'history', mockHash); |
|
|
const hiddenGitIgnorePath = path.join(repoDir, '.gitignore'); |
|
|
const visibleGitIgnorePath = path.join(mockProjectRoot, '.gitignore'); |
|
|
const gitConfigPath = path.join(repoDir, '.gitconfig'); |
|
|
|
|
|
it('should create a .gitconfig file with the correct content', async () => { |
|
|
const service = new GitService(mockProjectRoot); |
|
|
await service.setupShadowGitRepository(); |
|
|
const expectedConfigContent = |
|
|
'[user]\n name = Gemini CLI\n email = gemini-cli@google.com\n[commit]\n gpgsign = false\n'; |
|
|
expect(hoistedMockWriteFile).toHaveBeenCalledWith( |
|
|
gitConfigPath, |
|
|
expectedConfigContent, |
|
|
); |
|
|
}); |
|
|
|
|
|
it('should create history and repository directories', async () => { |
|
|
const service = new GitService(mockProjectRoot); |
|
|
await service.setupShadowGitRepository(); |
|
|
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.setupShadowGitRepository(); |
|
|
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.setupShadowGitRepository(); |
|
|
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.setupShadowGitRepository(); |
|
|
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 instanceof Error, |
|
|
); |
|
|
|
|
|
const service = new GitService(mockProjectRoot); |
|
|
await expect(service.setupShadowGitRepository()).rejects.toThrow( |
|
|
'Read permission denied', |
|
|
); |
|
|
}); |
|
|
|
|
|
it('should make an initial commit if no commits exist in history repo', async () => { |
|
|
hoistedMockCheckIsRepo.mockResolvedValue(false); |
|
|
const service = new GitService(mockProjectRoot); |
|
|
await service.setupShadowGitRepository(); |
|
|
expect(hoistedMockCommit).toHaveBeenCalledWith('Initial commit', { |
|
|
'--allow-empty': null, |
|
|
}); |
|
|
}); |
|
|
|
|
|
it('should not make an initial commit if commits already exist', async () => { |
|
|
hoistedMockCheckIsRepo.mockResolvedValue(true); |
|
|
const service = new GitService(mockProjectRoot); |
|
|
await service.setupShadowGitRepository(); |
|
|
expect(hoistedMockCommit).not.toHaveBeenCalled(); |
|
|
}); |
|
|
}); |
|
|
}); |
|
|
|