Path: blob/main/extensions/copilot/src/extension/prompt/vscode-node/test/gitDiffService.spec.ts
13405 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { MockInstance, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';6import * as vscode from 'vscode';7import { IGitExtensionService } from '../../../../platform/git/common/gitExtensionService';8import { API, Change, Repository } from '../../../../platform/git/vscode/git';9import { IIgnoreService, NullIgnoreService } from '../../../../platform/ignore/common/ignoreService';10import { ITestingServicesAccessor } from '../../../../platform/test/node/services';11import { CancellationTokenSource } from '../../../../util/vs/base/common/cancellation';12import { CancellationError } from '../../../../util/vs/base/common/errors';13import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation';14import { Uri } from '../../../../vscodeTypes';15import { createExtensionUnitTestingServices } from '../../../test/node/services';16import { GitDiffService } from '../gitDiffService';1718class TestIgnoreService extends NullIgnoreService {19private readonly _ignoredUris = new Set<string>();2021setIgnoredUris(uris: Uri[]): void {22this._ignoredUris.clear();23for (const uri of uris) {24this._ignoredUris.add(uri.toString());25}26}2728override async isCopilotIgnored(file: Uri): Promise<boolean> {29return this._ignoredUris.has(file.toString());30}31}3233describe('GitDiffService', () => {34let readFileSpy: MockInstance<typeof vscode.workspace.fs.readFile>;35let statSpy: MockInstance<typeof vscode.workspace.fs.stat>;36let accessor: ITestingServicesAccessor;37let gitDiffService: GitDiffService;38let mockRepository: Partial<Repository>;39let testIgnoreService: TestIgnoreService;4041beforeEach(() => {42// Create mock workspace.fs.readFile if it doesn't exist43if (!vscode.workspace?.fs?.readFile) {44const workspaceWithFs = vscode as unknown as { workspace: typeof vscode.workspace };45workspaceWithFs.workspace = {46...vscode.workspace,47fs: {48...vscode.workspace?.fs,49readFile: vi.fn(),50stat: vi.fn()51}52};53}5455// Spy on workspace.fs.readFile56readFileSpy = vi.spyOn(vscode.workspace.fs, 'readFile').mockImplementation(() => Promise.resolve(new Uint8Array()));57// Spy on workspace.fs.stat - default to a small file58statSpy = vi.spyOn(vscode.workspace.fs, 'stat').mockImplementation(() => Promise.resolve({ size: 100, type: 1 /* File */, ctime: 0, mtime: 0 } as vscode.FileStat));5960mockRepository = {61rootUri: Uri.file('/repo'),62diffWith: vi.fn().mockResolvedValue(''),63diffIndexWithHEAD: vi.fn().mockResolvedValue(''),64diffWithHEAD: vi.fn().mockResolvedValue('')65};6667const services = createExtensionUnitTestingServices();6869const mockGitExtensionService = {70getExtensionApi: vi.fn().mockReturnValue({71getRepository: vi.fn().mockReturnValue(mockRepository),72openRepository: vi.fn(),73repositories: [mockRepository as Repository]74} as unknown as API)75} as unknown as IGitExtensionService;76services.set(IGitExtensionService, mockGitExtensionService);7778testIgnoreService = new TestIgnoreService();79services.set(IIgnoreService, testIgnoreService);8081accessor = services.createTestingAccessor();82gitDiffService = accessor.get(IInstantiationService).createInstance(GitDiffService);83});8485afterEach(() => {86readFileSpy.mockRestore();87statSpy.mockRestore();88});8990describe('getChangeDiffs', () => {91it('should use diffIndexWithHEAD for index changes', async () => {92const fileUri = Uri.file('/repo/staged.txt');93(mockRepository.diffIndexWithHEAD as ReturnType<typeof vi.fn>).mockResolvedValue('index diff');9495const changes: Change[] = [{96uri: fileUri,97originalUri: fileUri,98renameUri: undefined,99status: 0 /* INDEX_MODIFIED */100}];101102const diffs = await gitDiffService.getChangeDiffs(mockRepository as Repository, changes);103104expect(diffs).toHaveLength(1);105expect(diffs[0].diff).toBe('index diff');106expect(mockRepository.diffIndexWithHEAD).toHaveBeenCalledWith(fileUri.fsPath);107expect(mockRepository.diffWithHEAD).not.toHaveBeenCalled();108});109110it('should use diffWithHEAD for working tree changes', async () => {111const fileUri = Uri.file('/repo/modified.txt');112(mockRepository.diffWithHEAD as ReturnType<typeof vi.fn>).mockResolvedValue('working tree diff');113114const changes: Change[] = [{115uri: fileUri,116originalUri: fileUri,117renameUri: undefined,118status: 5 /* MODIFIED */119}];120121const diffs = await gitDiffService.getChangeDiffs(mockRepository as Repository, changes);122123expect(diffs).toHaveLength(1);124expect(diffs[0].diff).toBe('working tree diff');125expect(mockRepository.diffWithHEAD).toHaveBeenCalledWith(fileUri.fsPath);126expect(mockRepository.diffIndexWithHEAD).not.toHaveBeenCalled();127});128129it('should skip copilot-ignored files', async () => {130const ignoredUri = Uri.file('/repo/secret.txt');131const normalUri = Uri.file('/repo/normal.txt');132133testIgnoreService.setIgnoredUris([ignoredUri]);134(mockRepository.diffWithHEAD as ReturnType<typeof vi.fn>).mockResolvedValue('normal diff');135136const changes: Change[] = [137{ uri: ignoredUri, originalUri: ignoredUri, renameUri: undefined, status: 5 /* MODIFIED */ },138{ uri: normalUri, originalUri: normalUri, renameUri: undefined, status: 5 /* MODIFIED */ }139];140141const diffs = await gitDiffService.getChangeDiffs(mockRepository as Repository, changes);142143expect(diffs).toHaveLength(1);144expect(diffs[0].uri.toString()).toBe(normalUri.toString());145});146147it('should throw CancellationError when token is cancelled', async () => {148const cts = new CancellationTokenSource();149cts.cancel();150151const changes: Change[] = [{152uri: Uri.file('/repo/file.txt'),153originalUri: Uri.file('/repo/file.txt'),154renameUri: undefined,155status: 5 /* MODIFIED */156}];157158await expect(gitDiffService.getChangeDiffs(mockRepository as Repository, changes, cts.token))159.rejects.toThrow(CancellationError);160});161162it('should return empty array when repository is not found', async () => {163const services = createExtensionUnitTestingServices();164165const mockGitExtensionService = {166getExtensionApi: vi.fn().mockReturnValue({167getRepository: vi.fn().mockReturnValue(null),168openRepository: vi.fn().mockResolvedValue(null),169repositories: []170} as unknown as API)171} as unknown as IGitExtensionService;172services.set(IGitExtensionService, mockGitExtensionService);173services.set(IIgnoreService, testIgnoreService);174175const service = services.createTestingAccessor().get(IInstantiationService).createInstance(GitDiffService);176const changes: Change[] = [{177uri: Uri.file('/nonexistent/file.txt'),178originalUri: Uri.file('/nonexistent/file.txt'),179renameUri: undefined,180status: 5181}];182183const diffs = await service.getChangeDiffs(Uri.file('/nonexistent'), changes);184expect(diffs).toEqual([]);185});186});187188describe('getWorkingTreeDiffsFromRef', () => {189it('should use diffWith for tracked changes', async () => {190const fileUri = Uri.file('/repo/file.txt');191(mockRepository.diffWith as ReturnType<typeof vi.fn>).mockResolvedValue('ref diff');192193const changes: Change[] = [{194uri: fileUri,195originalUri: fileUri,196renameUri: undefined,197status: 5 /* MODIFIED */198}];199200const diffs = await gitDiffService.getWorkingTreeDiffsFromRef(mockRepository as Repository, changes, 'main');201202expect(diffs).toHaveLength(1);203expect(diffs[0].diff).toBe('ref diff');204expect(mockRepository.diffWith).toHaveBeenCalledWith('main', fileUri.fsPath);205});206207it('should generate patch for untracked files instead of diffWith', async () => {208const fileUri = Uri.file('/repo/new.txt');209readFileSpy.mockResolvedValue(Buffer.from('new content\n'));210211const changes: Change[] = [{212uri: fileUri,213originalUri: fileUri,214renameUri: undefined,215status: 7 /* UNTRACKED */216}];217218const diffs = await gitDiffService.getWorkingTreeDiffsFromRef(mockRepository as Repository, changes, 'main');219220expect(diffs).toHaveLength(1);221expect(diffs[0].diff).toContain('--- /dev/null');222expect(diffs[0].diff).toContain('+new content');223expect(mockRepository.diffWith).not.toHaveBeenCalled();224});225226it('should skip copilot-ignored files', async () => {227const ignoredUri = Uri.file('/repo/secret.txt');228testIgnoreService.setIgnoredUris([ignoredUri]);229230const changes: Change[] = [{231uri: ignoredUri,232originalUri: ignoredUri,233renameUri: undefined,234status: 5 /* MODIFIED */235}];236237const diffs = await gitDiffService.getWorkingTreeDiffsFromRef(mockRepository as Repository, changes, 'main');238expect(diffs).toHaveLength(0);239});240});241242describe('diff truncation', () => {243it('should truncate diffs exceeding MAX_DIFF_SIZE', async () => {244const fileUri = Uri.file('/repo/large.txt');245const largeDiff = 'x'.repeat(200_000);246(mockRepository.diffWithHEAD as ReturnType<typeof vi.fn>).mockResolvedValue(largeDiff);247248const changes: Change[] = [{249uri: fileUri,250originalUri: fileUri,251renameUri: undefined,252status: 5 /* MODIFIED */253}];254255const diffs = await gitDiffService.getChangeDiffs(mockRepository as Repository, changes);256257expect(diffs).toHaveLength(1);258expect(diffs[0].diff.length).toBeLessThan(largeDiff.length);259expect(diffs[0].diff).toContain('[diff truncated]');260});261262it('should not truncate diffs within MAX_DIFF_SIZE', async () => {263const fileUri = Uri.file('/repo/small.txt');264const smallDiff = 'x'.repeat(1000);265(mockRepository.diffWithHEAD as ReturnType<typeof vi.fn>).mockResolvedValue(smallDiff);266267const changes: Change[] = [{268uri: fileUri,269originalUri: fileUri,270renameUri: undefined,271status: 5 /* MODIFIED */272}];273274const diffs = await gitDiffService.getChangeDiffs(mockRepository as Repository, changes);275276expect(diffs).toHaveLength(1);277expect(diffs[0].diff).toBe(smallDiff);278});279});280281describe('large untracked files', () => {282it('should return a minimal patch for files exceeding MAX_UNTRACKED_FILE_SIZE', async () => {283const fileUri = Uri.file('/repo/huge.bin');284const largeSize = 2 * 1024 * 1024; // 2 MB285statSpy.mockResolvedValue({ size: largeSize, type: 1, ctime: 0, mtime: 0 } as vscode.FileStat);286287const changes: Change[] = [{288uri: fileUri,289originalUri: fileUri,290renameUri: undefined,291status: 7 /* UNTRACKED */292}];293294const diffs = await gitDiffService.getChangeDiffs(mockRepository as Repository, changes);295296expect(diffs).toHaveLength(1);297expect(diffs[0].diff).toContain('File too large to diff');298expect(diffs[0].diff).toContain('--- /dev/null');299// readFile should not have been called300expect(readFileSpy).not.toHaveBeenCalled();301});302303it('should proceed to read file if stat fails', async () => {304const fileUri = Uri.file('/repo/nostat.txt');305statSpy.mockRejectedValue(new Error('stat failed'));306readFileSpy.mockResolvedValue(Buffer.from('content\n'));307308const changes: Change[] = [{309uri: fileUri,310originalUri: fileUri,311renameUri: undefined,312status: 7 /* UNTRACKED */313}];314315const diffs = await gitDiffService.getChangeDiffs(mockRepository as Repository, changes);316317expect(diffs).toHaveLength(1);318expect(diffs[0].diff).toContain('+content');319expect(diffs[0].diff).not.toContain('File too large');320});321});322323describe('_getUntrackedChangePatch', () => {324it('should generate correct patch for untracked file', async () => {325const fileUri = Uri.file('/repo/newfile.txt');326const fileContent = 'line1\nline2\n';327328readFileSpy.mockResolvedValue(Buffer.from(fileContent));329330const changes: Change[] = [{331uri: fileUri,332originalUri: fileUri,333renameUri: undefined,334status: 7 /* UNTRACKED */335}];336337const diffs = await gitDiffService.getChangeDiffs(mockRepository as Repository, changes);338339expect(diffs).toHaveLength(1);340const patch = diffs[0].diff;341342// Verify standard git patch headers343expect(patch).toContain('diff --git a/newfile.txt b/newfile.txt');344expect(patch).toContain('new file mode 100644');345expect(patch).toContain('--- /dev/null');346expect(patch).toContain('+++ b/newfile.txt');347348// Verify range header uses line count (2 lines), not byte length349expect(patch).toContain('@@ -0,0 +1,2 @@');350351// Verify content352expect(patch).toContain('+line1');353expect(patch).toContain('+line2');354355// Verify final newline356expect(patch.endsWith('\n')).toBe(true);357358// Verify no "No newline at end of file" warning since file ends with \n359expect(patch).not.toContain('\\ No newline at end of file');360});361362it('should handle file without trailing newline', async () => {363const fileUri = Uri.file('/repo/no-newline.txt');364const fileContent = 'line1'; // No trailing \n365366readFileSpy.mockResolvedValue(Buffer.from(fileContent));367368const changes: Change[] = [{369uri: fileUri,370originalUri: fileUri,371renameUri: undefined,372status: 7 /* UNTRACKED */373}];374375const diffs = await gitDiffService.getChangeDiffs(mockRepository as Repository, changes);376const patch = diffs[0].diff;377378expect(patch).toContain('@@ -0,0 +1,1 @@');379expect(patch).toContain('+line1');380expect(patch).toContain('\\ No newline at end of file');381expect(patch.endsWith('\n')).toBe(true);382});383384it('should handle empty file', async () => {385const fileUri = Uri.file('/repo/empty.txt');386const fileContent = '';387388// Mock readFile to return an empty buffer389readFileSpy.mockResolvedValue(Buffer.from(fileContent));390391const changes: Change[] = [{392uri: fileUri,393originalUri: fileUri,394renameUri: undefined,395status: 7 /* UNTRACKED */396}];397398const diffs = await gitDiffService.getChangeDiffs(mockRepository as Repository, changes);399400// Empty file case: git omits range header and content for totally empty files401const patch = diffs[0].diff;402expect(patch).toContain('diff --git a/empty.txt b/empty.txt');403expect(patch).toContain('new file mode 100644');404expect(patch).toContain('--- /dev/null');405expect(patch).toContain('+++ b/empty.txt');406// No range header for empty files407expect(patch).not.toContain('@@');408// No content lines409expect(patch).not.toMatch(/^\+[^+]/m);410});411412it('should handle file with single blank line', async () => {413const fileUri = Uri.file('/repo/blank-line.txt');414const fileContent = '\n'; // Single newline415416readFileSpy.mockResolvedValue(Buffer.from(fileContent));417418const changes: Change[] = [{419uri: fileUri,420originalUri: fileUri,421renameUri: undefined,422status: 7 /* UNTRACKED */423}];424425const diffs = await gitDiffService.getChangeDiffs(mockRepository as Repository, changes);426427// Single blank line: should have range header and one empty line addition428const patch = diffs[0].diff;429expect(patch).toContain('diff --git a/blank-line.txt b/blank-line.txt');430expect(patch).toContain('new file mode 100644');431expect(patch).toContain('--- /dev/null');432expect(patch).toContain('+++ b/blank-line.txt');433expect(patch).toContain('@@ -0,0 +1,1 @@');434expect(patch).toContain('+'); // One empty line435expect(patch.endsWith('\n')).toBe(true);436});437});438});439440441