Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/prompt/vscode-node/test/gitDiffService.spec.ts
13405 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { MockInstance, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
7
import * as vscode from 'vscode';
8
import { IGitExtensionService } from '../../../../platform/git/common/gitExtensionService';
9
import { API, Change, Repository } from '../../../../platform/git/vscode/git';
10
import { IIgnoreService, NullIgnoreService } from '../../../../platform/ignore/common/ignoreService';
11
import { ITestingServicesAccessor } from '../../../../platform/test/node/services';
12
import { CancellationTokenSource } from '../../../../util/vs/base/common/cancellation';
13
import { CancellationError } from '../../../../util/vs/base/common/errors';
14
import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation';
15
import { Uri } from '../../../../vscodeTypes';
16
import { createExtensionUnitTestingServices } from '../../../test/node/services';
17
import { GitDiffService } from '../gitDiffService';
18
19
class TestIgnoreService extends NullIgnoreService {
20
private readonly _ignoredUris = new Set<string>();
21
22
setIgnoredUris(uris: Uri[]): void {
23
this._ignoredUris.clear();
24
for (const uri of uris) {
25
this._ignoredUris.add(uri.toString());
26
}
27
}
28
29
override async isCopilotIgnored(file: Uri): Promise<boolean> {
30
return this._ignoredUris.has(file.toString());
31
}
32
}
33
34
describe('GitDiffService', () => {
35
let readFileSpy: MockInstance<typeof vscode.workspace.fs.readFile>;
36
let statSpy: MockInstance<typeof vscode.workspace.fs.stat>;
37
let accessor: ITestingServicesAccessor;
38
let gitDiffService: GitDiffService;
39
let mockRepository: Partial<Repository>;
40
let testIgnoreService: TestIgnoreService;
41
42
beforeEach(() => {
43
// Create mock workspace.fs.readFile if it doesn't exist
44
if (!vscode.workspace?.fs?.readFile) {
45
const workspaceWithFs = vscode as unknown as { workspace: typeof vscode.workspace };
46
workspaceWithFs.workspace = {
47
...vscode.workspace,
48
fs: {
49
...vscode.workspace?.fs,
50
readFile: vi.fn(),
51
stat: vi.fn()
52
}
53
};
54
}
55
56
// Spy on workspace.fs.readFile
57
readFileSpy = vi.spyOn(vscode.workspace.fs, 'readFile').mockImplementation(() => Promise.resolve(new Uint8Array()));
58
// Spy on workspace.fs.stat - default to a small file
59
statSpy = vi.spyOn(vscode.workspace.fs, 'stat').mockImplementation(() => Promise.resolve({ size: 100, type: 1 /* File */, ctime: 0, mtime: 0 } as vscode.FileStat));
60
61
mockRepository = {
62
rootUri: Uri.file('/repo'),
63
diffWith: vi.fn().mockResolvedValue(''),
64
diffIndexWithHEAD: vi.fn().mockResolvedValue(''),
65
diffWithHEAD: vi.fn().mockResolvedValue('')
66
};
67
68
const services = createExtensionUnitTestingServices();
69
70
const mockGitExtensionService = {
71
getExtensionApi: vi.fn().mockReturnValue({
72
getRepository: vi.fn().mockReturnValue(mockRepository),
73
openRepository: vi.fn(),
74
repositories: [mockRepository as Repository]
75
} as unknown as API)
76
} as unknown as IGitExtensionService;
77
services.set(IGitExtensionService, mockGitExtensionService);
78
79
testIgnoreService = new TestIgnoreService();
80
services.set(IIgnoreService, testIgnoreService);
81
82
accessor = services.createTestingAccessor();
83
gitDiffService = accessor.get(IInstantiationService).createInstance(GitDiffService);
84
});
85
86
afterEach(() => {
87
readFileSpy.mockRestore();
88
statSpy.mockRestore();
89
});
90
91
describe('getChangeDiffs', () => {
92
it('should use diffIndexWithHEAD for index changes', async () => {
93
const fileUri = Uri.file('/repo/staged.txt');
94
(mockRepository.diffIndexWithHEAD as ReturnType<typeof vi.fn>).mockResolvedValue('index diff');
95
96
const changes: Change[] = [{
97
uri: fileUri,
98
originalUri: fileUri,
99
renameUri: undefined,
100
status: 0 /* INDEX_MODIFIED */
101
}];
102
103
const diffs = await gitDiffService.getChangeDiffs(mockRepository as Repository, changes);
104
105
expect(diffs).toHaveLength(1);
106
expect(diffs[0].diff).toBe('index diff');
107
expect(mockRepository.diffIndexWithHEAD).toHaveBeenCalledWith(fileUri.fsPath);
108
expect(mockRepository.diffWithHEAD).not.toHaveBeenCalled();
109
});
110
111
it('should use diffWithHEAD for working tree changes', async () => {
112
const fileUri = Uri.file('/repo/modified.txt');
113
(mockRepository.diffWithHEAD as ReturnType<typeof vi.fn>).mockResolvedValue('working tree diff');
114
115
const changes: Change[] = [{
116
uri: fileUri,
117
originalUri: fileUri,
118
renameUri: undefined,
119
status: 5 /* MODIFIED */
120
}];
121
122
const diffs = await gitDiffService.getChangeDiffs(mockRepository as Repository, changes);
123
124
expect(diffs).toHaveLength(1);
125
expect(diffs[0].diff).toBe('working tree diff');
126
expect(mockRepository.diffWithHEAD).toHaveBeenCalledWith(fileUri.fsPath);
127
expect(mockRepository.diffIndexWithHEAD).not.toHaveBeenCalled();
128
});
129
130
it('should skip copilot-ignored files', async () => {
131
const ignoredUri = Uri.file('/repo/secret.txt');
132
const normalUri = Uri.file('/repo/normal.txt');
133
134
testIgnoreService.setIgnoredUris([ignoredUri]);
135
(mockRepository.diffWithHEAD as ReturnType<typeof vi.fn>).mockResolvedValue('normal diff');
136
137
const changes: Change[] = [
138
{ uri: ignoredUri, originalUri: ignoredUri, renameUri: undefined, status: 5 /* MODIFIED */ },
139
{ uri: normalUri, originalUri: normalUri, renameUri: undefined, status: 5 /* MODIFIED */ }
140
];
141
142
const diffs = await gitDiffService.getChangeDiffs(mockRepository as Repository, changes);
143
144
expect(diffs).toHaveLength(1);
145
expect(diffs[0].uri.toString()).toBe(normalUri.toString());
146
});
147
148
it('should throw CancellationError when token is cancelled', async () => {
149
const cts = new CancellationTokenSource();
150
cts.cancel();
151
152
const changes: Change[] = [{
153
uri: Uri.file('/repo/file.txt'),
154
originalUri: Uri.file('/repo/file.txt'),
155
renameUri: undefined,
156
status: 5 /* MODIFIED */
157
}];
158
159
await expect(gitDiffService.getChangeDiffs(mockRepository as Repository, changes, cts.token))
160
.rejects.toThrow(CancellationError);
161
});
162
163
it('should return empty array when repository is not found', async () => {
164
const services = createExtensionUnitTestingServices();
165
166
const mockGitExtensionService = {
167
getExtensionApi: vi.fn().mockReturnValue({
168
getRepository: vi.fn().mockReturnValue(null),
169
openRepository: vi.fn().mockResolvedValue(null),
170
repositories: []
171
} as unknown as API)
172
} as unknown as IGitExtensionService;
173
services.set(IGitExtensionService, mockGitExtensionService);
174
services.set(IIgnoreService, testIgnoreService);
175
176
const service = services.createTestingAccessor().get(IInstantiationService).createInstance(GitDiffService);
177
const changes: Change[] = [{
178
uri: Uri.file('/nonexistent/file.txt'),
179
originalUri: Uri.file('/nonexistent/file.txt'),
180
renameUri: undefined,
181
status: 5
182
}];
183
184
const diffs = await service.getChangeDiffs(Uri.file('/nonexistent'), changes);
185
expect(diffs).toEqual([]);
186
});
187
});
188
189
describe('getWorkingTreeDiffsFromRef', () => {
190
it('should use diffWith for tracked changes', async () => {
191
const fileUri = Uri.file('/repo/file.txt');
192
(mockRepository.diffWith as ReturnType<typeof vi.fn>).mockResolvedValue('ref diff');
193
194
const changes: Change[] = [{
195
uri: fileUri,
196
originalUri: fileUri,
197
renameUri: undefined,
198
status: 5 /* MODIFIED */
199
}];
200
201
const diffs = await gitDiffService.getWorkingTreeDiffsFromRef(mockRepository as Repository, changes, 'main');
202
203
expect(diffs).toHaveLength(1);
204
expect(diffs[0].diff).toBe('ref diff');
205
expect(mockRepository.diffWith).toHaveBeenCalledWith('main', fileUri.fsPath);
206
});
207
208
it('should generate patch for untracked files instead of diffWith', async () => {
209
const fileUri = Uri.file('/repo/new.txt');
210
readFileSpy.mockResolvedValue(Buffer.from('new content\n'));
211
212
const changes: Change[] = [{
213
uri: fileUri,
214
originalUri: fileUri,
215
renameUri: undefined,
216
status: 7 /* UNTRACKED */
217
}];
218
219
const diffs = await gitDiffService.getWorkingTreeDiffsFromRef(mockRepository as Repository, changes, 'main');
220
221
expect(diffs).toHaveLength(1);
222
expect(diffs[0].diff).toContain('--- /dev/null');
223
expect(diffs[0].diff).toContain('+new content');
224
expect(mockRepository.diffWith).not.toHaveBeenCalled();
225
});
226
227
it('should skip copilot-ignored files', async () => {
228
const ignoredUri = Uri.file('/repo/secret.txt');
229
testIgnoreService.setIgnoredUris([ignoredUri]);
230
231
const changes: Change[] = [{
232
uri: ignoredUri,
233
originalUri: ignoredUri,
234
renameUri: undefined,
235
status: 5 /* MODIFIED */
236
}];
237
238
const diffs = await gitDiffService.getWorkingTreeDiffsFromRef(mockRepository as Repository, changes, 'main');
239
expect(diffs).toHaveLength(0);
240
});
241
});
242
243
describe('diff truncation', () => {
244
it('should truncate diffs exceeding MAX_DIFF_SIZE', async () => {
245
const fileUri = Uri.file('/repo/large.txt');
246
const largeDiff = 'x'.repeat(200_000);
247
(mockRepository.diffWithHEAD as ReturnType<typeof vi.fn>).mockResolvedValue(largeDiff);
248
249
const changes: Change[] = [{
250
uri: fileUri,
251
originalUri: fileUri,
252
renameUri: undefined,
253
status: 5 /* MODIFIED */
254
}];
255
256
const diffs = await gitDiffService.getChangeDiffs(mockRepository as Repository, changes);
257
258
expect(diffs).toHaveLength(1);
259
expect(diffs[0].diff.length).toBeLessThan(largeDiff.length);
260
expect(diffs[0].diff).toContain('[diff truncated]');
261
});
262
263
it('should not truncate diffs within MAX_DIFF_SIZE', async () => {
264
const fileUri = Uri.file('/repo/small.txt');
265
const smallDiff = 'x'.repeat(1000);
266
(mockRepository.diffWithHEAD as ReturnType<typeof vi.fn>).mockResolvedValue(smallDiff);
267
268
const changes: Change[] = [{
269
uri: fileUri,
270
originalUri: fileUri,
271
renameUri: undefined,
272
status: 5 /* MODIFIED */
273
}];
274
275
const diffs = await gitDiffService.getChangeDiffs(mockRepository as Repository, changes);
276
277
expect(diffs).toHaveLength(1);
278
expect(diffs[0].diff).toBe(smallDiff);
279
});
280
});
281
282
describe('large untracked files', () => {
283
it('should return a minimal patch for files exceeding MAX_UNTRACKED_FILE_SIZE', async () => {
284
const fileUri = Uri.file('/repo/huge.bin');
285
const largeSize = 2 * 1024 * 1024; // 2 MB
286
statSpy.mockResolvedValue({ size: largeSize, type: 1, ctime: 0, mtime: 0 } as vscode.FileStat);
287
288
const changes: Change[] = [{
289
uri: fileUri,
290
originalUri: fileUri,
291
renameUri: undefined,
292
status: 7 /* UNTRACKED */
293
}];
294
295
const diffs = await gitDiffService.getChangeDiffs(mockRepository as Repository, changes);
296
297
expect(diffs).toHaveLength(1);
298
expect(diffs[0].diff).toContain('File too large to diff');
299
expect(diffs[0].diff).toContain('--- /dev/null');
300
// readFile should not have been called
301
expect(readFileSpy).not.toHaveBeenCalled();
302
});
303
304
it('should proceed to read file if stat fails', async () => {
305
const fileUri = Uri.file('/repo/nostat.txt');
306
statSpy.mockRejectedValue(new Error('stat failed'));
307
readFileSpy.mockResolvedValue(Buffer.from('content\n'));
308
309
const changes: Change[] = [{
310
uri: fileUri,
311
originalUri: fileUri,
312
renameUri: undefined,
313
status: 7 /* UNTRACKED */
314
}];
315
316
const diffs = await gitDiffService.getChangeDiffs(mockRepository as Repository, changes);
317
318
expect(diffs).toHaveLength(1);
319
expect(diffs[0].diff).toContain('+content');
320
expect(diffs[0].diff).not.toContain('File too large');
321
});
322
});
323
324
describe('_getUntrackedChangePatch', () => {
325
it('should generate correct patch for untracked file', async () => {
326
const fileUri = Uri.file('/repo/newfile.txt');
327
const fileContent = 'line1\nline2\n';
328
329
readFileSpy.mockResolvedValue(Buffer.from(fileContent));
330
331
const changes: Change[] = [{
332
uri: fileUri,
333
originalUri: fileUri,
334
renameUri: undefined,
335
status: 7 /* UNTRACKED */
336
}];
337
338
const diffs = await gitDiffService.getChangeDiffs(mockRepository as Repository, changes);
339
340
expect(diffs).toHaveLength(1);
341
const patch = diffs[0].diff;
342
343
// Verify standard git patch headers
344
expect(patch).toContain('diff --git a/newfile.txt b/newfile.txt');
345
expect(patch).toContain('new file mode 100644');
346
expect(patch).toContain('--- /dev/null');
347
expect(patch).toContain('+++ b/newfile.txt');
348
349
// Verify range header uses line count (2 lines), not byte length
350
expect(patch).toContain('@@ -0,0 +1,2 @@');
351
352
// Verify content
353
expect(patch).toContain('+line1');
354
expect(patch).toContain('+line2');
355
356
// Verify final newline
357
expect(patch.endsWith('\n')).toBe(true);
358
359
// Verify no "No newline at end of file" warning since file ends with \n
360
expect(patch).not.toContain('\\ No newline at end of file');
361
});
362
363
it('should handle file without trailing newline', async () => {
364
const fileUri = Uri.file('/repo/no-newline.txt');
365
const fileContent = 'line1'; // No trailing \n
366
367
readFileSpy.mockResolvedValue(Buffer.from(fileContent));
368
369
const changes: Change[] = [{
370
uri: fileUri,
371
originalUri: fileUri,
372
renameUri: undefined,
373
status: 7 /* UNTRACKED */
374
}];
375
376
const diffs = await gitDiffService.getChangeDiffs(mockRepository as Repository, changes);
377
const patch = diffs[0].diff;
378
379
expect(patch).toContain('@@ -0,0 +1,1 @@');
380
expect(patch).toContain('+line1');
381
expect(patch).toContain('\\ No newline at end of file');
382
expect(patch.endsWith('\n')).toBe(true);
383
});
384
385
it('should handle empty file', async () => {
386
const fileUri = Uri.file('/repo/empty.txt');
387
const fileContent = '';
388
389
// Mock readFile to return an empty buffer
390
readFileSpy.mockResolvedValue(Buffer.from(fileContent));
391
392
const changes: Change[] = [{
393
uri: fileUri,
394
originalUri: fileUri,
395
renameUri: undefined,
396
status: 7 /* UNTRACKED */
397
}];
398
399
const diffs = await gitDiffService.getChangeDiffs(mockRepository as Repository, changes);
400
401
// Empty file case: git omits range header and content for totally empty files
402
const patch = diffs[0].diff;
403
expect(patch).toContain('diff --git a/empty.txt b/empty.txt');
404
expect(patch).toContain('new file mode 100644');
405
expect(patch).toContain('--- /dev/null');
406
expect(patch).toContain('+++ b/empty.txt');
407
// No range header for empty files
408
expect(patch).not.toContain('@@');
409
// No content lines
410
expect(patch).not.toMatch(/^\+[^+]/m);
411
});
412
413
it('should handle file with single blank line', async () => {
414
const fileUri = Uri.file('/repo/blank-line.txt');
415
const fileContent = '\n'; // Single newline
416
417
readFileSpy.mockResolvedValue(Buffer.from(fileContent));
418
419
const changes: Change[] = [{
420
uri: fileUri,
421
originalUri: fileUri,
422
renameUri: undefined,
423
status: 7 /* UNTRACKED */
424
}];
425
426
const diffs = await gitDiffService.getChangeDiffs(mockRepository as Repository, changes);
427
428
// Single blank line: should have range header and one empty line addition
429
const patch = diffs[0].diff;
430
expect(patch).toContain('diff --git a/blank-line.txt b/blank-line.txt');
431
expect(patch).toContain('new file mode 100644');
432
expect(patch).toContain('--- /dev/null');
433
expect(patch).toContain('+++ b/blank-line.txt');
434
expect(patch).toContain('@@ -0,0 +1,1 @@');
435
expect(patch).toContain('+'); // One empty line
436
expect(patch.endsWith('\n')).toBe(true);
437
});
438
});
439
});
440
441