Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts
13399 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
/**
7
* Integration tests for {@link AgentHostGitService} that spawn real `git` against
8
* temporary on-disk repositories. Kept out of the unit-test suite because they
9
* require `git` on PATH and do real filesystem and process work — same split as
10
* the git extension (pure parser tests in `git.test.ts`, on-disk tests in
11
* `smoke.test.ts`).
12
*
13
* Run via `scripts/test-integration.sh`.
14
*/
15
16
import assert from 'assert';
17
import * as cp from 'child_process';
18
import { mkdtempSync, rmSync } from 'fs';
19
import { tmpdir } from 'os';
20
import { NullLogService } from '../../../log/common/log.js';
21
import { join } from '../../../../base/common/path.js';
22
import { URI } from '../../../../base/common/uri.js';
23
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
24
import { INativeEnvironmentService } from '../../../environment/common/environment.js';
25
import { FileService } from '../../../files/common/fileService.js';
26
import { Schemas } from '../../../../base/common/network.js';
27
import { DiskFileSystemProvider } from '../../../files/node/diskFileSystemProvider.js';
28
import { DisposableStore } from '../../../../base/common/lifecycle.js';
29
import { AgentHostGitService } from '../../node/agentHostGitService.js';
30
31
function createGitService(disposables: Pick<DisposableStore, 'add'>): AgentHostGitService {
32
const logService = new NullLogService();
33
const fileService = disposables.add(new FileService(logService));
34
disposables.add(fileService.registerProvider(Schemas.file, disposables.add(new DiskFileSystemProvider(logService))));
35
const env: Partial<INativeEnvironmentService> = { tmpDir: URI.file(tmpdir()) };
36
return new AgentHostGitService(fileService, env as INativeEnvironmentService);
37
}
38
39
suite('AgentHostGitService - getSessionGitState (real git)', () => {
40
const disposables = ensureNoDisposablesAreLeakedInTestSuite();
41
42
// Skip the on-disk git tests when `git` is not on PATH (e.g. minimal CI).
43
const hasGit = (() => {
44
try { cp.execFileSync('git', ['--version'], { stdio: 'ignore' }); return true; } catch { return false; }
45
})();
46
47
let tmpRoot: string | undefined;
48
let svc: AgentHostGitService | undefined;
49
50
setup(() => {
51
tmpRoot = undefined;
52
svc = createGitService(disposables);
53
});
54
55
teardown(() => {
56
if (tmpRoot) {
57
rmSync(tmpRoot, { recursive: true, force: true });
58
}
59
});
60
61
function initRepo(opts?: { remote?: string; baseBranch?: string }): string {
62
tmpRoot = mkdtempSync(join(tmpdir(), 'agent-host-git-'));
63
const env = { ...process.env, GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t' };
64
const run = (...args: string[]) => cp.execFileSync('git', args, { cwd: tmpRoot!, env, stdio: 'pipe' });
65
run('init', '-q', '-b', opts?.baseBranch ?? 'main');
66
run('commit', '-q', '--allow-empty', '-m', 'initial');
67
if (opts?.remote) {
68
run('remote', 'add', 'origin', opts.remote);
69
}
70
return tmpRoot!;
71
}
72
73
(hasGit ? test : test.skip)('returns undefined for a non-git directory', async () => {
74
const dir = mkdtempSync(join(tmpdir(), 'agent-host-nongit-'));
75
tmpRoot = dir;
76
const result = await svc!.getSessionGitState(URI.file(dir));
77
assert.strictEqual(result, undefined);
78
});
79
80
(hasGit ? test : test.skip)('reports branch, github remote and clean state for a fresh repo', async () => {
81
const dir = initRepo({ remote: 'https://github.com/owner/repo.git' });
82
const result = await svc!.getSessionGitState(URI.file(dir));
83
assert.ok(result, 'expected git state');
84
assert.strictEqual(result.branchName, 'main');
85
assert.strictEqual(result.hasGitHubRemote, true);
86
assert.strictEqual(result.uncommittedChanges, 0);
87
// No upstream configured for the fresh local branch.
88
assert.strictEqual(result.upstreamBranchName, undefined);
89
assert.strictEqual(result.outgoingChanges, undefined);
90
assert.strictEqual(result.incomingChanges, undefined);
91
});
92
93
(hasGit ? test : test.skip)('counts uncommitted changes', async () => {
94
const dir = initRepo({ remote: '[email protected]:owner/repo.git' });
95
const fs = await import('fs/promises');
96
await fs.writeFile(join(dir, 'a.txt'), 'hello');
97
await fs.writeFile(join(dir, 'b.txt'), 'world');
98
const result = await svc!.getSessionGitState(URI.file(dir));
99
assert.ok(result);
100
assert.strictEqual(result.uncommittedChanges, 2);
101
assert.strictEqual(result.hasGitHubRemote, false);
102
});
103
104
(hasGit ? test : test.skip)('reports outgoingChanges relative to base branch when local branch has no upstream', async () => {
105
// Create a bare "remote" repo and set up the working repo so that
106
// `refs/remotes/origin/HEAD` exists (required for baseBranchName parsing).
107
const remoteDir = mkdtempSync(join(tmpdir(), 'agent-host-remote-'));
108
const env = { ...process.env, GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t' };
109
try {
110
cp.execFileSync('git', ['init', '-q', '--bare', '-b', 'main'], { cwd: remoteDir, env, stdio: 'pipe' });
111
tmpRoot = mkdtempSync(join(tmpdir(), 'agent-host-git-'));
112
const run = (...args: string[]) => cp.execFileSync('git', args, { cwd: tmpRoot!, env, stdio: 'pipe' });
113
run('init', '-q', '-b', 'main');
114
run('commit', '-q', '--allow-empty', '-m', 'initial');
115
run('remote', 'add', 'origin', `https://github.com/owner/repo.git`);
116
// Use a separate "upload" remote pointing at the bare repo to populate
117
// the origin/main remote-tracking ref without changing the GitHub URL
118
// we're testing for hasGitHubRemote detection.
119
run('remote', 'add', 'tmp', remoteDir);
120
run('push', '-q', 'tmp', 'main:main');
121
// Create the origin/main ref locally without any network round-trip.
122
run('update-ref', 'refs/remotes/origin/main', 'refs/heads/main');
123
run('symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main');
124
125
// Branch off and add two commits without setting an upstream.
126
run('checkout', '-q', '-b', 'feature', '--no-track');
127
run('commit', '-q', '--allow-empty', '-m', 'one');
128
run('commit', '-q', '--allow-empty', '-m', 'two');
129
130
const result = await svc!.getSessionGitState(URI.file(tmpRoot!));
131
assert.ok(result, 'expected git state');
132
assert.strictEqual(result.branchName, 'feature');
133
assert.strictEqual(result.baseBranchName, 'main');
134
assert.strictEqual(result.upstreamBranchName, undefined);
135
assert.strictEqual(result.outgoingChanges, 2);
136
assert.strictEqual(result.uncommittedChanges, 0);
137
} finally {
138
rmSync(remoteDir, { recursive: true, force: true });
139
}
140
});
141
});
142
143
suite('AgentHostGitService - computeSessionFileDiffs (real git)', () => {
144
const disposables = ensureNoDisposablesAreLeakedInTestSuite();
145
146
const hasGit = (() => {
147
try { cp.execFileSync('git', ['--version'], { stdio: 'ignore' }); return true; } catch { return false; }
148
})();
149
150
let tmpRoot: string | undefined;
151
let svc: AgentHostGitService | undefined;
152
153
setup(() => {
154
tmpRoot = undefined;
155
svc = createGitService(disposables);
156
});
157
158
teardown(() => {
159
if (tmpRoot) {
160
rmSync(tmpRoot, { recursive: true, force: true });
161
}
162
});
163
164
function initRepo(): { dir: string; run: (...args: string[]) => Buffer } {
165
tmpRoot = mkdtempSync(join(tmpdir(), 'agent-host-diff-'));
166
const env = { ...process.env, GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t' };
167
const run = (...args: string[]) => cp.execFileSync('git', args, { cwd: tmpRoot!, env, stdio: 'pipe' });
168
run('init', '-q', '-b', 'main');
169
return { dir: tmpRoot!, run };
170
}
171
172
(hasGit ? test : test.skip)('returns undefined for a non-git directory', async () => {
173
const dir = mkdtempSync(join(tmpdir(), 'agent-host-nongit-diff-'));
174
tmpRoot = dir;
175
const result = await svc!.computeSessionFileDiffs(URI.file(dir), { sessionUri: 'copilot:/s' });
176
assert.strictEqual(result, undefined);
177
});
178
179
(hasGit ? test : test.skip)('reports modified, added (untracked) and deleted files against HEAD', async () => {
180
const fs = await import('fs/promises');
181
const { dir, run } = initRepo();
182
await fs.writeFile(join(dir, 'kept.txt'), 'one\ntwo\nthree\n');
183
await fs.writeFile(join(dir, 'gone.txt'), 'bye\n');
184
run('add', '.');
185
run('commit', '-q', '-m', 'init');
186
187
// Modify, add (untracked), delete.
188
await fs.writeFile(join(dir, 'kept.txt'), 'one\ntwo\nthree\nfour\n');
189
await fs.writeFile(join(dir, 'fresh.txt'), 'hello\n');
190
await fs.unlink(join(dir, 'gone.txt'));
191
192
const result = await svc!.computeSessionFileDiffs(URI.file(dir), { sessionUri: 'copilot:/s' });
193
assert.ok(result, 'expected diffs');
194
const byPath = new Map(result.map(d => [d.after?.uri ?? d.before?.uri, d]));
195
196
// Find by basename to be robust against path normalization differences (e.g. macOS /private prefix).
197
const findByBasename = (name: string) => result.find(d => {
198
const u = d.after?.uri ?? d.before?.uri;
199
return typeof u === 'string' && u.endsWith('/' + name);
200
});
201
202
const kept = findByBasename('kept.txt');
203
assert.ok(kept?.before && kept.after, `modified file should have before+after; result=${JSON.stringify(result.map(d => ({ a: d.after?.uri, b: d.before?.uri })))}`);
204
assert.deepStrictEqual(kept!.diff, { added: 1, removed: 0 });
205
assert.ok(kept!.before!.content.uri.startsWith('git-blob://'), 'before content should be a git-blob: URI');
206
207
const fresh = findByBasename('fresh.txt');
208
assert.ok(fresh?.after && !fresh.before, 'untracked file should have only after');
209
210
const gone = findByBasename('gone.txt');
211
assert.ok(gone?.before && !gone.after, 'deleted file should have only before');
212
void byPath;
213
});
214
215
(hasGit ? test : test.skip)('anchors against the merge-base of the requested base branch', async () => {
216
const fs = await import('fs/promises');
217
const { dir, run } = initRepo();
218
await fs.writeFile(join(dir, 'a.txt'), 'a\n');
219
run('add', '.');
220
run('commit', '-q', '-m', 'init');
221
// Branch off, then advance main behind us so merge-base != HEAD.
222
run('checkout', '-q', '-b', 'feature');
223
await fs.writeFile(join(dir, 'b.txt'), 'b\n');
224
run('add', '.');
225
run('commit', '-q', '-m', 'add b on feature');
226
227
const result = await svc!.computeSessionFileDiffs(URI.file(dir), { sessionUri: 'copilot:/s', baseBranch: 'main' });
228
assert.ok(result, 'expected diffs');
229
// `b.txt` was committed on `feature` after branching from `main`, so
230
// it must show up in the merge-base diff even though there are no
231
// uncommitted changes in the working tree.
232
const paths = result.map(d => (d.after?.uri ?? d.before?.uri));
233
assert.ok(paths.some(p => p?.endsWith('b.txt')), `expected b.txt in diff; got ${paths.join(', ')}`);
234
});
235
236
(hasGit ? test : test.skip)('returns no diffs for a clean repo', async () => {
237
const fs = await import('fs/promises');
238
const { dir, run } = initRepo();
239
await fs.writeFile(join(dir, 'a.txt'), 'a\n');
240
run('add', '.');
241
run('commit', '-q', '-m', 'init');
242
243
const result = await svc!.computeSessionFileDiffs(URI.file(dir), { sessionUri: 'copilot:/s' });
244
assert.deepStrictEqual(result, []);
245
});
246
247
(hasGit ? test : test.skip)('handles an empty repo (no HEAD) by treating files as added', async () => {
248
const fs = await import('fs/promises');
249
const { dir } = initRepo();
250
await fs.writeFile(join(dir, 'first.txt'), 'hello\n');
251
252
const result = await svc!.computeSessionFileDiffs(URI.file(dir), { sessionUri: 'copilot:/s' });
253
assert.ok(result, 'expected diffs');
254
assert.strictEqual(result.length, 1);
255
assert.ok(result[0].after && !result[0].before, 'untracked file in empty repo should be an addition');
256
});
257
258
(hasGit ? test : test.skip)('showBlob retrieves committed content', async () => {
259
const fs = await import('fs/promises');
260
const { dir, run } = initRepo();
261
await fs.writeFile(join(dir, 'a.txt'), 'original\n');
262
run('add', '.');
263
run('commit', '-q', '-m', 'init');
264
const sha = cp.execFileSync('git', ['rev-parse', 'HEAD'], { cwd: dir, encoding: 'utf8' }).trim();
265
await fs.writeFile(join(dir, 'a.txt'), 'changed\n');
266
267
const blob = await svc!.showBlob(URI.file(dir), sha, 'a.txt');
268
assert.ok(blob);
269
assert.strictEqual(blob.toString(), 'original\n');
270
});
271
});
272
273
suite('AgentHostGitService - worktree helpers (real git)', () => {
274
const disposables = ensureNoDisposablesAreLeakedInTestSuite();
275
276
const hasGit = (() => {
277
try { cp.execFileSync('git', ['--version'], { stdio: 'ignore' }); return true; } catch { return false; }
278
})();
279
280
let tmpRoot: string | undefined;
281
let svc: AgentHostGitService | undefined;
282
const env = { ...process.env, GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t' };
283
284
setup(() => {
285
tmpRoot = undefined;
286
svc = createGitService(disposables);
287
});
288
289
teardown(() => {
290
if (tmpRoot) {
291
rmSync(tmpRoot, { recursive: true, force: true });
292
}
293
});
294
295
function initRepo(): string {
296
tmpRoot = mkdtempSync(join(tmpdir(), 'agent-host-git-wt-'));
297
const run = (...args: string[]) => cp.execFileSync('git', args, { cwd: tmpRoot!, env, stdio: 'pipe' });
298
run('init', '-q', '-b', 'main');
299
run('commit', '-q', '--allow-empty', '-m', 'initial');
300
return tmpRoot!;
301
}
302
303
(hasGit ? test : test.skip)('branchExists reports true for HEAD branch and false for missing branches', async () => {
304
const dir = initRepo();
305
assert.strictEqual(await svc!.branchExists(URI.file(dir), 'main'), true);
306
assert.strictEqual(await svc!.branchExists(URI.file(dir), 'does-not-exist'), false);
307
});
308
309
(hasGit ? test : test.skip)('hasUncommittedChanges flips with untracked and committed work', async () => {
310
const dir = initRepo();
311
assert.strictEqual(await svc!.hasUncommittedChanges(URI.file(dir)), false);
312
const fs = await import('fs/promises');
313
await fs.writeFile(join(dir, 'a.txt'), 'hello');
314
assert.strictEqual(await svc!.hasUncommittedChanges(URI.file(dir)), true);
315
cp.execFileSync('git', ['add', 'a.txt'], { cwd: dir, env, stdio: 'pipe' });
316
cp.execFileSync('git', ['commit', '-q', '-m', 'add a'], { cwd: dir, env, stdio: 'pipe' });
317
assert.strictEqual(await svc!.hasUncommittedChanges(URI.file(dir)), false);
318
});
319
320
(hasGit ? test : test.skip)('addExistingWorktree attaches a worktree for an existing branch (no -b)', async () => {
321
const dir = initRepo();
322
cp.execFileSync('git', ['branch', 'feature'], { cwd: dir, env, stdio: 'pipe' });
323
const wtPath = join(dir, '..', `wt-${Date.now()}`);
324
try {
325
await svc!.addExistingWorktree(URI.file(dir), URI.file(wtPath), 'feature');
326
const fs = await import('fs/promises');
327
const stat = await fs.stat(wtPath);
328
assert.ok(stat.isDirectory(), 'worktree directory should exist');
329
} finally {
330
rmSync(wtPath, { recursive: true, force: true });
331
}
332
});
333
});
334
335