1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
|
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { GitService } from './gitService.js';
import * as path from 'path';
import * as fs from 'fs/promises';
import * as os from 'os';
import type { ChildProcess } from 'node:child_process';
import { getProjectHash, GEMINI_DIR } from '../utils/paths.js';
const hoistedMockExec = vi.hoisted(() => vi.fn());
vi.mock('node:child_process', () => ({
exec: hoistedMockExec,
}));
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 hoistedMockHomedir = vi.hoisted(() => vi.fn());
vi.mock('os', async (importOriginal) => {
const actual = await importOriginal<typeof os>();
return {
...actual,
homedir: hoistedMockHomedir,
};
});
describe('GitService', () => {
let testRootDir: string;
let projectRoot: string;
let homedir: string;
let hash: string;
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 });
hash = getProjectHash(projectRoot);
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 {};
});
hoistedMockHomedir.mockReturnValue(homedir);
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(async () => {
vi.restoreAllMocks();
await fs.rm(testRootDir, { recursive: true, force: true });
});
describe('constructor', () => {
it('should successfully create an instance', () => {
expect(() => new GitService(projectRoot)).not.toThrow();
});
});
describe('verifyGitAvailability', () => {
it('should resolve true if git --version command succeeds', async () => {
const service = new GitService(projectRoot);
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(projectRoot);
await expect(service.verifyGitAvailability()).resolves.toBe(false);
});
});
describe('initialize', () => {
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(projectRoot);
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);
const setupSpy = vi
.spyOn(service, 'setupShadowGitRepository')
.mockResolvedValue(undefined);
await service.initialize();
expect(setupSpy).toHaveBeenCalled();
});
});
describe('setupShadowGitRepository', () => {
let repoDir: string;
let gitConfigPath: string;
beforeEach(() => {
repoDir = path.join(homedir, GEMINI_DIR, 'history', hash);
gitConfigPath = path.join(repoDir, '.gitconfig');
});
it('should create history and repository directories', async () => {
const service = new GitService(projectRoot);
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);
await service.setupShadowGitRepository();
const expectedConfigContent =
'[user]\n name = Gemini CLI\n email = [email protected]\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);
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(projectRoot);
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);
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);
await service.setupShadowGitRepository();
const hiddenGitIgnorePath = path.join(repoDir, '.gitignore');
// An empty string is written if the file doesn't exist.
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');
// Create a directory instead of a file to cause a read error
await fs.mkdir(visibleGitIgnorePath);
const service = new GitService(projectRoot);
// EISDIR is the expected error code on Unix-like systems
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);
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);
await service.setupShadowGitRepository();
expect(hoistedMockCommit).not.toHaveBeenCalled();
});
});
});
|