Skip to content

Commit 6662a74

Browse files
IanMatthewHuffIan HuffIan Huff
authored
Fix up for the GitDiffService (#2116)
* initial pass at untracked file changes, testing next * add final trailing \n * unit tests for function * fix test execution * tweak some comments and formattting * another comment tweak after testing * small change to handling fully empty files * copilot PR feedback --------- Co-authored-by: Ian Huff <ianhuff@Ians-MacBook-Pro-2.local> Co-authored-by: Ian Huff <ianhuff@Mac.home>
1 parent e690809 commit 6662a74

File tree

2 files changed

+203
-8
lines changed

2 files changed

+203
-8
lines changed

src/extension/prompt/vscode-node/gitDiffService.ts

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -114,22 +114,38 @@ export class GitDiffService implements IGitDiffService {
114114
try {
115115
const buffer = await workspace.fs.readFile(resource);
116116
const relativePath = path.relative(repository.rootUri.fsPath, resource.fsPath);
117+
const content = buffer.toString();
117118

118119
// Header
119120
patch.push(`diff --git a/${relativePath} b/${relativePath}`);
120-
121-
// Add original/modified file paths
121+
// 100644 is standard file mode for new git files. Saves us from trying to check file permissions and handling
122+
// UNIX vs Windows permission differences. Skipping calculating the SHA1 hashes as well since they are not strictly necessary
123+
// to apply the patch.
124+
patch.push('new file mode 100644');
122125
patch.push('--- /dev/null', `+++ b/${relativePath}`);
123126

124-
// Add range header
125-
patch.push(`@@ -0,0 +1,${buffer.length} @@`);
126-
127-
// Add content
128-
patch.push(...buffer.toString().split('\n').map(line => `+${line}`));
127+
// For non-empty files, add range header and content (empty files omit this)
128+
if (content.length > 0) {
129+
const lines = content.split('\n');
130+
if (content.endsWith('\n')) {
131+
// Prevent an extra empty line at the end
132+
lines.pop();
133+
}
134+
135+
// Range header and content
136+
patch.push(`@@ -0,0 +1,${lines.length} @@`);
137+
patch.push(...lines.map(line => `+${line}`));
138+
139+
// Git standard to add this comment if the file does not end with a newline
140+
if (!content.endsWith('\n')) {
141+
patch.push('\\ No newline at end of file');
142+
}
143+
}
129144
} catch (err) {
130145
console.error(err, `Failed to generate patch file for untracked file: ${resource.toString()}`);
131146
}
132147

133-
return patch.join('\n');
148+
// The patch itself should always end with a newline per git patch standards
149+
return patch.join('\n') + '\n';
134150
}
135151
}
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
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 { ITestingServicesAccessor } from '../../../../platform/test/node/services';
11+
import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation';
12+
import { Uri } from '../../../../vscodeTypes';
13+
import { createExtensionUnitTestingServices } from '../../../test/node/services';
14+
import { GitDiffService } from '../gitDiffService';
15+
16+
describe('GitDiffService', () => {
17+
let readFileSpy: MockInstance<typeof vscode.workspace.fs.readFile>;
18+
let accessor: ITestingServicesAccessor;
19+
let gitDiffService: GitDiffService;
20+
let mockRepository: Partial<Repository>;
21+
22+
beforeEach(() => {
23+
// Create mock workspace.fs.readFile if it doesn't exist
24+
if (!vscode.workspace?.fs?.readFile) {
25+
const workspaceWithFs = vscode as unknown as { workspace: typeof vscode.workspace };
26+
workspaceWithFs.workspace = {
27+
...vscode.workspace,
28+
fs: {
29+
...vscode.workspace?.fs,
30+
readFile: vi.fn()
31+
}
32+
};
33+
}
34+
35+
// Spy on workspace.fs.readFile
36+
readFileSpy = vi.spyOn(vscode.workspace.fs, 'readFile').mockImplementation(() => Promise.resolve(new Uint8Array()));
37+
38+
mockRepository = {
39+
rootUri: Uri.file('/repo'),
40+
diffWith: vi.fn(),
41+
diffIndexWithHEAD: vi.fn(),
42+
diffWithHEAD: vi.fn()
43+
};
44+
45+
const services = createExtensionUnitTestingServices();
46+
47+
const mockGitExtensionService = {
48+
getExtensionApi: vi.fn().mockReturnValue({
49+
getRepository: vi.fn().mockReturnValue(mockRepository),
50+
openRepository: vi.fn(),
51+
repositories: [mockRepository as Repository]
52+
} as unknown as API)
53+
} as unknown as IGitExtensionService;
54+
services.set(IGitExtensionService, mockGitExtensionService);
55+
56+
accessor = services.createTestingAccessor();
57+
gitDiffService = accessor.get(IInstantiationService).createInstance(GitDiffService);
58+
});
59+
60+
afterEach(() => {
61+
readFileSpy.mockRestore();
62+
});
63+
64+
describe('_getUntrackedChangePatch', () => {
65+
it('should generate correct patch for untracked file', async () => {
66+
const fileUri = Uri.file('/repo/newfile.txt');
67+
const fileContent = 'line1\nline2\n';
68+
69+
readFileSpy.mockResolvedValue(Buffer.from(fileContent));
70+
71+
const changes: Change[] = [{
72+
uri: fileUri,
73+
originalUri: fileUri,
74+
renameUri: undefined,
75+
status: 7 /* UNTRACKED */
76+
}];
77+
78+
const diffs = await gitDiffService.getChangeDiffs(mockRepository as Repository, changes);
79+
80+
expect(diffs).toHaveLength(1);
81+
const patch = diffs[0].diff;
82+
83+
// Verify standard git patch headers
84+
expect(patch).toContain('diff --git a/newfile.txt b/newfile.txt');
85+
expect(patch).toContain('new file mode 100644');
86+
expect(patch).toContain('--- /dev/null');
87+
expect(patch).toContain('+++ b/newfile.txt');
88+
89+
// Verify range header uses line count (2 lines), not byte length
90+
expect(patch).toContain('@@ -0,0 +1,2 @@');
91+
92+
// Verify content
93+
expect(patch).toContain('+line1');
94+
expect(patch).toContain('+line2');
95+
96+
// Verify final newline
97+
expect(patch.endsWith('\n')).toBe(true);
98+
99+
// Verify no "No newline at end of file" warning since file ends with \n
100+
expect(patch).not.toContain('\\ No newline at end of file');
101+
});
102+
103+
it('should handle file without trailing newline', async () => {
104+
const fileUri = Uri.file('/repo/no-newline.txt');
105+
const fileContent = 'line1'; // No trailing \n
106+
107+
readFileSpy.mockResolvedValue(Buffer.from(fileContent));
108+
109+
const changes: Change[] = [{
110+
uri: fileUri,
111+
originalUri: fileUri,
112+
renameUri: undefined,
113+
status: 7 /* UNTRACKED */
114+
}];
115+
116+
const diffs = await gitDiffService.getChangeDiffs(mockRepository as Repository, changes);
117+
const patch = diffs[0].diff;
118+
119+
expect(patch).toContain('@@ -0,0 +1,1 @@');
120+
expect(patch).toContain('+line1');
121+
expect(patch).toContain('\\ No newline at end of file');
122+
expect(patch.endsWith('\n')).toBe(true);
123+
});
124+
125+
it('should handle empty file', async () => {
126+
const fileUri = Uri.file('/repo/empty.txt');
127+
const fileContent = '';
128+
129+
// Mock readFile to return an empty buffer
130+
readFileSpy.mockResolvedValue(Buffer.from(fileContent));
131+
132+
const changes: Change[] = [{
133+
uri: fileUri,
134+
originalUri: fileUri,
135+
renameUri: undefined,
136+
status: 7 /* UNTRACKED */
137+
}];
138+
139+
const diffs = await gitDiffService.getChangeDiffs(mockRepository as Repository, changes);
140+
141+
// Empty file case: git omits range header and content for totally empty files
142+
const patch = diffs[0].diff;
143+
expect(patch).toContain('diff --git a/empty.txt b/empty.txt');
144+
expect(patch).toContain('new file mode 100644');
145+
expect(patch).toContain('--- /dev/null');
146+
expect(patch).toContain('+++ b/empty.txt');
147+
// No range header for empty files
148+
expect(patch).not.toContain('@@');
149+
// No content lines
150+
expect(patch).not.toMatch(/^\+[^+]/m);
151+
});
152+
153+
it('should handle file with single blank line', async () => {
154+
const fileUri = Uri.file('/repo/blank-line.txt');
155+
const fileContent = '\n'; // Single newline
156+
157+
readFileSpy.mockResolvedValue(Buffer.from(fileContent));
158+
159+
const changes: Change[] = [{
160+
uri: fileUri,
161+
originalUri: fileUri,
162+
renameUri: undefined,
163+
status: 7 /* UNTRACKED */
164+
}];
165+
166+
const diffs = await gitDiffService.getChangeDiffs(mockRepository as Repository, changes);
167+
168+
// Single blank line: should have range header and one empty line addition
169+
const patch = diffs[0].diff;
170+
expect(patch).toContain('diff --git a/blank-line.txt b/blank-line.txt');
171+
expect(patch).toContain('new file mode 100644');
172+
expect(patch).toContain('--- /dev/null');
173+
expect(patch).toContain('+++ b/blank-line.txt');
174+
expect(patch).toContain('@@ -0,0 +1,1 @@');
175+
expect(patch).toContain('+'); // One empty line
176+
expect(patch.endsWith('\n')).toBe(true);
177+
});
178+
});
179+
});

0 commit comments

Comments
 (0)