| |
| |
| |
| |
| |
|
|
| import { |
| describe, |
| it, |
| expect, |
| vi, |
| beforeEach, |
| afterEach, |
| type Mock, |
| } from 'vitest'; |
| import { |
| GitService, |
| SHADOW_REPO_AUTHOR_NAME, |
| SHADOW_REPO_AUTHOR_EMAIL, |
| } from './gitService.js'; |
| import { Storage } from '../config/storage.js'; |
| import * as path from 'node:path'; |
| import * as fs from 'node:fs/promises'; |
| import * as os from 'node:os'; |
| import { homedir as pathsHomedir } from '../utils/paths.js'; |
| import { spawnAsync } from '../utils/shell-utils.js'; |
|
|
| vi.mock('../utils/shell-utils.js', () => ({ |
| spawnAsync: vi.fn(), |
| })); |
|
|
| 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()); |
| const hoistedMockStatus = vi.hoisted(() => vi.fn()); |
| vi.mock('simple-git', () => ({ |
| simpleGit: hoistedMockSimpleGit.mockImplementation(() => ({ |
| checkIsRepo: hoistedMockCheckIsRepo, |
| init: hoistedMockInit, |
| raw: hoistedMockRaw, |
| add: hoistedMockAdd, |
| commit: hoistedMockCommit, |
| status: hoistedMockStatus, |
| env: hoistedMockEnv, |
| })), |
| CheckRepoActions: { IS_REPO_ROOT: 'is-repo-root' }, |
| })); |
|
|
| const hoistedIsGitRepositoryMock = vi.hoisted(() => vi.fn()); |
| vi.mock('../utils/gitUtils.js', async (importOriginal) => { |
| const actual = await importOriginal<typeof import('../utils/gitUtils.js')>(); |
| return { |
| ...actual, |
| isGitRepository: hoistedIsGitRepositoryMock, |
| }; |
| }); |
|
|
| const hoistedMockHomedir = vi.hoisted(() => vi.fn()); |
| vi.mock('node:os', async (importOriginal) => { |
| const actual = await importOriginal<typeof os>(); |
| return { |
| ...actual, |
| homedir: hoistedMockHomedir, |
| }; |
| }); |
|
|
| vi.mock('../utils/paths.js', async (importOriginal) => { |
| const actual = await importOriginal<typeof import('../utils/paths.js')>(); |
| return { |
| ...actual, |
| homedir: vi.fn(), |
| }; |
| }); |
|
|
| const hoistedMockDebugLogger = vi.hoisted(() => ({ |
| debug: vi.fn(), |
| warn: vi.fn(), |
| error: vi.fn(), |
| })); |
| vi.mock('../utils/debugLogger.js', () => ({ |
| debugLogger: hoistedMockDebugLogger, |
| })); |
|
|
| describe('GitService', () => { |
| let testRootDir: string; |
| let projectRoot: string; |
| let homedir: string; |
| let storage: Storage; |
|
|
| beforeEach(async () => { |
| testRootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'git-service-test-')); |
| projectRoot = path.join(testRootDir, 'project'); |
| homedir = path.join(testRootDir, 'home'); |
| await fs.mkdir(projectRoot, { recursive: true }); |
| await fs.mkdir(homedir, { recursive: true }); |
|
|
| vi.clearAllMocks(); |
| hoistedIsGitRepositoryMock.mockReturnValue(true); |
| (spawnAsync as Mock).mockResolvedValue({ |
| stdout: 'git version 2.0.0', |
| stderr: '', |
| }); |
|
|
| hoistedMockHomedir.mockReturnValue(homedir); |
| (pathsHomedir as Mock).mockReturnValue(homedir); |
|
|
| hoistedMockEnv.mockImplementation(() => ({ |
| checkIsRepo: hoistedMockCheckIsRepo, |
| init: hoistedMockInit, |
| raw: hoistedMockRaw, |
| add: hoistedMockAdd, |
| commit: hoistedMockCommit, |
| status: hoistedMockStatus, |
| })); |
| hoistedMockSimpleGit.mockImplementation(() => ({ |
| checkIsRepo: hoistedMockCheckIsRepo, |
| init: hoistedMockInit, |
| raw: hoistedMockRaw, |
| add: hoistedMockAdd, |
| commit: hoistedMockCommit, |
| status: hoistedMockStatus, |
| env: hoistedMockEnv, |
| })); |
| hoistedMockCheckIsRepo.mockResolvedValue(false); |
| hoistedMockInit.mockResolvedValue(undefined); |
| hoistedMockRaw.mockResolvedValue(''); |
| hoistedMockAdd.mockResolvedValue(undefined); |
| hoistedMockCommit.mockResolvedValue({ |
| commit: 'initial', |
| }); |
| storage = new Storage(projectRoot); |
| await storage.initialize(); |
| }); |
|
|
| afterEach(async () => { |
| if (testRootDir) { |
| await fs.rm(testRootDir, { recursive: true, force: true }); |
| } |
| }); |
|
|
| describe('constructor', () => { |
| it('should successfully create an instance', () => { |
| expect(() => new GitService(projectRoot, storage)).not.toThrow(); |
| }); |
| }); |
|
|
| describe('verifyGitAvailability', () => { |
| it('should resolve true if git --version command succeeds', async () => { |
| await expect(GitService.verifyGitAvailability()).resolves.toBe(true); |
| expect(spawnAsync).toHaveBeenCalledWith('git', ['--version'], { |
| env: expect.anything(), |
| }); |
| }); |
|
|
| it('should resolve false if git --version command fails', async () => { |
| (spawnAsync as Mock).mockRejectedValue(new Error('git not found')); |
| await expect(GitService.verifyGitAvailability()).resolves.toBe(false); |
| }); |
| }); |
|
|
| describe('initialize', () => { |
| it('should throw an error if Git is not available', async () => { |
| (spawnAsync as Mock).mockRejectedValue(new Error('git not found')); |
| const service = new GitService(projectRoot, storage); |
| await expect(service.initialize()).rejects.toThrow( |
| 'Checkpointing is enabled, but Git is not installed. Please install Git or disable checkpointing to continue.', |
| ); |
| }); |
|
|
| it('should call setupShadowGitRepository if Git is available', async () => { |
| const service = new GitService(projectRoot, storage); |
| const setupSpy = vi |
| .spyOn(service, 'setupShadowGitRepository') |
| .mockResolvedValue(undefined); |
|
|
| await service.initialize(); |
| expect(setupSpy).toHaveBeenCalled(); |
| }); |
| }); |
|
|
| describe('setupShadowGitRepository', () => { |
| let repoDir: string; |
| let gitConfigPath: string; |
|
|
| beforeEach(async () => { |
| repoDir = storage.getHistoryDir(); |
| gitConfigPath = path.join(repoDir, '.gitconfig'); |
| }); |
|
|
| it('should create history and repository directories', async () => { |
| const service = new GitService(projectRoot, storage); |
| await service.setupShadowGitRepository(); |
| const stats = await fs.stat(repoDir); |
| expect(stats.isDirectory()).toBe(true); |
| }); |
|
|
| it('should create a .gitconfig file with the correct content', async () => { |
| const service = new GitService(projectRoot, storage); |
| await service.setupShadowGitRepository(); |
|
|
| const expectedConfigContent = `[user]\n name = ${SHADOW_REPO_AUTHOR_NAME}\n email = ${SHADOW_REPO_AUTHOR_EMAIL}\n[commit]\n gpgsign = false\n`; |
| const actualConfigContent = await fs.readFile(gitConfigPath, 'utf-8'); |
| expect(actualConfigContent).toBe(expectedConfigContent); |
| }); |
|
|
| it('should initialize git repo in historyDir if not already initialized', async () => { |
| hoistedMockCheckIsRepo.mockResolvedValue(false); |
| const service = new GitService(projectRoot, storage); |
| await service.setupShadowGitRepository(); |
| expect(hoistedMockSimpleGit).toHaveBeenCalledWith( |
| repoDir, |
| expect.anything(), |
| ); |
| expect(hoistedMockInit).toHaveBeenCalled(); |
| }); |
|
|
| it('should not initialize git repo if already initialized', async () => { |
| hoistedMockCheckIsRepo.mockResolvedValue(true); |
| const service = new GitService(projectRoot, storage); |
| await service.setupShadowGitRepository(); |
| expect(hoistedMockInit).not.toHaveBeenCalled(); |
| }); |
|
|
| it('should copy .gitignore from projectRoot if it exists', async () => { |
| const gitignoreContent = 'node_modules/\n.env'; |
| const visibleGitIgnorePath = path.join(projectRoot, '.gitignore'); |
| await fs.writeFile(visibleGitIgnorePath, gitignoreContent); |
|
|
| const service = new GitService(projectRoot, storage); |
| await service.setupShadowGitRepository(); |
|
|
| const hiddenGitIgnorePath = path.join(repoDir, '.gitignore'); |
| const copiedContent = await fs.readFile(hiddenGitIgnorePath, 'utf-8'); |
| expect(copiedContent).toBe(gitignoreContent); |
| }); |
|
|
| it('should not create a .gitignore in shadow repo if project .gitignore does not exist', async () => { |
| const service = new GitService(projectRoot, storage); |
| await service.setupShadowGitRepository(); |
|
|
| const hiddenGitIgnorePath = path.join(repoDir, '.gitignore'); |
| |
| const content = await fs.readFile(hiddenGitIgnorePath, 'utf-8'); |
| expect(content).toBe(''); |
| }); |
|
|
| it('should throw an error if reading projectRoot .gitignore fails with other errors', async () => { |
| const visibleGitIgnorePath = path.join(projectRoot, '.gitignore'); |
| |
| await fs.mkdir(visibleGitIgnorePath); |
|
|
| const service = new GitService(projectRoot, storage); |
| |
| await expect(service.setupShadowGitRepository()).rejects.toThrow( |
| /EISDIR: illegal operation on a directory, read|EBUSY: resource busy or locked, read/, |
| ); |
| }); |
|
|
| it('should make an initial commit if no commits exist in history repo', async () => { |
| hoistedMockCheckIsRepo.mockResolvedValue(false); |
| const service = new GitService(projectRoot, storage); |
| 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(projectRoot, storage); |
| await service.setupShadowGitRepository(); |
| expect(hoistedMockCommit).not.toHaveBeenCalled(); |
| }); |
|
|
| it('should handle checkIsRepo failure gracefully and initialize repo', async () => { |
| |
| hoistedMockCheckIsRepo.mockRejectedValue( |
| new Error('git rev-parse --is-inside-work-tree failed'), |
| ); |
| const service = new GitService(projectRoot, storage); |
| await service.setupShadowGitRepository(); |
| |
| expect(hoistedMockInit).toHaveBeenCalled(); |
| |
| expect(hoistedMockDebugLogger.debug).toHaveBeenCalledWith( |
| expect.stringContaining('checkIsRepo failed'), |
| ); |
| }); |
|
|
| it('should configure git environment to use local gitconfig', async () => { |
| hoistedMockCheckIsRepo.mockResolvedValue(false); |
| const service = new GitService(projectRoot, storage); |
| await service.setupShadowGitRepository(); |
|
|
| expect(hoistedMockEnv).toHaveBeenCalledWith( |
| expect.objectContaining({ |
| GIT_CONFIG_GLOBAL: gitConfigPath, |
| GIT_CONFIG_SYSTEM: path.join(repoDir, '.gitconfig_system_empty'), |
| GIT_AUTHOR_NAME: SHADOW_REPO_AUTHOR_NAME, |
| GIT_AUTHOR_EMAIL: SHADOW_REPO_AUTHOR_EMAIL, |
| GIT_COMMITTER_NAME: SHADOW_REPO_AUTHOR_NAME, |
| GIT_COMMITTER_EMAIL: SHADOW_REPO_AUTHOR_EMAIL, |
| }), |
| ); |
|
|
| const systemConfigContent = await fs.readFile( |
| path.join(repoDir, '.gitconfig_system_empty'), |
| 'utf-8', |
| ); |
| expect(systemConfigContent).toBe(''); |
| }); |
|
|
| describe('environment variable preservation', () => { |
| const customPath = '/custom/bin'; |
| const safeHome = '/home/user'; |
| const sensitiveKey = 'sk-123456789'; |
|
|
| beforeEach(() => { |
| vi.stubEnv('PATH', customPath); |
| vi.stubEnv('HOME', safeHome); |
| vi.stubEnv('API_KEY', sensitiveKey); |
| vi.stubEnv('UNRELATED_VAR', 'some-value'); |
| |
| |
| vi.stubEnv('GITHUB_SHA', ''); |
| vi.stubEnv('SURFACE', ''); |
| hoistedMockCheckIsRepo.mockResolvedValue(false); |
| }); |
|
|
| afterEach(() => { |
| vi.unstubAllEnvs(); |
| }); |
|
|
| it('should preserve system PATH in the Git environment', async () => { |
| const service = new GitService(projectRoot, storage); |
| await service.setupShadowGitRepository(); |
|
|
| expect(hoistedMockEnv).toHaveBeenCalledWith( |
| expect.objectContaining({ |
| PATH: customPath, |
| GIT_CONFIG_GLOBAL: expect.any(String), |
| GIT_AUTHOR_NAME: SHADOW_REPO_AUTHOR_NAME, |
| }), |
| ); |
| }); |
|
|
| it('should preserve safe environment variables like HOME', async () => { |
| const service = new GitService(projectRoot, storage); |
| await service.setupShadowGitRepository(); |
|
|
| expect(hoistedMockEnv).toHaveBeenCalledWith( |
| expect.objectContaining({ |
| HOME: safeHome, |
| }), |
| ); |
| }); |
|
|
| it('should NOT include sensitive environment variables like API_KEY', async () => { |
| const service = new GitService(projectRoot, storage); |
| await service.setupShadowGitRepository(); |
|
|
| const callArgs = hoistedMockEnv.mock.calls[0][0]; |
| expect(callArgs.API_KEY).toBeUndefined(); |
| }); |
|
|
| it('should preserve unrelated environment variables in non-strict mode', async () => { |
| const service = new GitService(projectRoot, storage); |
| await service.setupShadowGitRepository(); |
|
|
| const callArgs = hoistedMockEnv.mock.calls[0][0]; |
| expect(callArgs.UNRELATED_VAR).toBe('some-value'); |
| }); |
|
|
| it('should explicitly unset GIT_DIR and GIT_WORK_TREE to maintain isolation', async () => { |
| const service = new GitService(projectRoot, storage); |
| await service.setupShadowGitRepository(); |
|
|
| const callArgs = hoistedMockEnv.mock.calls[0][0]; |
| expect(callArgs.GIT_DIR).toBeUndefined(); |
| expect(callArgs.GIT_WORK_TREE).toBeUndefined(); |
| }); |
| }); |
|
|
| describe('GIT_CONFIG isolation', () => { |
| beforeEach(() => { |
| vi.stubEnv('GIT_CONFIG_GLOBAL', '/user/global/config'); |
| vi.stubEnv('GIT_CONFIG_SYSTEM', '/user/system/config'); |
| hoistedMockCheckIsRepo.mockResolvedValue(false); |
| }); |
|
|
| afterEach(() => { |
| vi.unstubAllEnvs(); |
| }); |
|
|
| it('should override GIT_CONFIG environment variables from process.env', async () => { |
| const service = new GitService(projectRoot, storage); |
| await service.setupShadowGitRepository(); |
|
|
| const expectedConfigPath = path.join(repoDir, '.gitconfig'); |
| const expectedSystemPath = path.join( |
| repoDir, |
| '.gitconfig_system_empty', |
| ); |
|
|
| expect(hoistedMockEnv).toHaveBeenCalledWith( |
| expect.objectContaining({ |
| GIT_CONFIG_GLOBAL: expectedConfigPath, |
| GIT_CONFIG_SYSTEM: expectedSystemPath, |
| }), |
| ); |
|
|
| |
| const callArgs = hoistedMockEnv.mock.calls[0][0]; |
| expect(callArgs.GIT_CONFIG_GLOBAL).not.toBe('/user/global/config'); |
| expect(callArgs.GIT_CONFIG_SYSTEM).not.toBe('/user/system/config'); |
| }); |
| }); |
|
|
| describe('shadowGitRepository prioritization', () => { |
| beforeEach(() => { |
| vi.stubEnv('GIT_DIR', '/user/fake/.git'); |
| vi.stubEnv('GIT_WORK_TREE', '/user/fake/worktree'); |
| hoistedMockCheckIsRepo.mockResolvedValue(true); |
| }); |
|
|
| afterEach(() => { |
| vi.unstubAllEnvs(); |
| }); |
|
|
| it('should prioritize internal GIT_DIR and GIT_WORK_TREE over process.env', async () => { |
| const service = new GitService(projectRoot, storage); |
| |
| hoistedMockRaw.mockResolvedValue('hash'); |
| await service.getCurrentCommitHash(); |
|
|
| const expectedRepoDir = storage.getHistoryDir(); |
| const expectedGitDir = path.join(expectedRepoDir, '.git'); |
|
|
| expect(hoistedMockEnv).toHaveBeenCalledWith( |
| expect.objectContaining({ |
| GIT_DIR: expectedGitDir, |
| GIT_WORK_TREE: projectRoot, |
| }), |
| ); |
|
|
| |
| const callArgs = hoistedMockEnv.mock.calls[0][0]; |
| expect(callArgs.GIT_DIR).not.toBe('/user/fake/.git'); |
| expect(callArgs.GIT_WORK_TREE).not.toBe('/user/fake/worktree'); |
| }); |
| }); |
| }); |
|
|
| describe('createFileSnapshot', () => { |
| it('should commit with --no-verify flag', async () => { |
| hoistedMockStatus.mockResolvedValue({ isClean: () => false }); |
| const service = new GitService(projectRoot, storage); |
| await service.initialize(); |
| await service.createFileSnapshot('test commit'); |
| expect(hoistedMockCommit).toHaveBeenCalledWith('test commit', { |
| '--no-verify': null, |
| }); |
| }); |
|
|
| it('should create a new commit if there are staged changes', async () => { |
| hoistedMockStatus.mockResolvedValue({ isClean: () => false }); |
| hoistedMockCommit.mockResolvedValue({ commit: 'new-commit-hash' }); |
| const service = new GitService(projectRoot, storage); |
| const commitHash = await service.createFileSnapshot('test message'); |
| expect(hoistedMockAdd).toHaveBeenCalledWith('.'); |
| expect(hoistedMockStatus).toHaveBeenCalled(); |
| expect(hoistedMockCommit).toHaveBeenCalledWith('test message', { |
| '--no-verify': null, |
| }); |
| expect(commitHash).toBe('new-commit-hash'); |
| }); |
|
|
| it('should return the current HEAD commit hash if there are no staged changes', async () => { |
| hoistedMockStatus.mockResolvedValue({ isClean: () => true }); |
| hoistedMockRaw.mockResolvedValue('current-head-hash'); |
| const service = new GitService(projectRoot, storage); |
| const commitHash = await service.createFileSnapshot('test message'); |
| expect(hoistedMockAdd).toHaveBeenCalledWith('.'); |
| expect(hoistedMockStatus).toHaveBeenCalled(); |
| expect(hoistedMockCommit).not.toHaveBeenCalled(); |
| expect(hoistedMockRaw).toHaveBeenCalledWith('rev-parse', 'HEAD'); |
| expect(commitHash).toBe('current-head-hash'); |
| }); |
| }); |
| }); |
|
|