Path: blob/main/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts
13399 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*--------------------------------------------------------------------------------------------*/45/**6* Integration tests for {@link AgentHostGitService} that spawn real `git` against7* temporary on-disk repositories. Kept out of the unit-test suite because they8* require `git` on PATH and do real filesystem and process work — same split as9* the git extension (pure parser tests in `git.test.ts`, on-disk tests in10* `smoke.test.ts`).11*12* Run via `scripts/test-integration.sh`.13*/1415import assert from 'assert';16import * as cp from 'child_process';17import { mkdtempSync, rmSync } from 'fs';18import { tmpdir } from 'os';19import { NullLogService } from '../../../log/common/log.js';20import { join } from '../../../../base/common/path.js';21import { URI } from '../../../../base/common/uri.js';22import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';23import { INativeEnvironmentService } from '../../../environment/common/environment.js';24import { FileService } from '../../../files/common/fileService.js';25import { Schemas } from '../../../../base/common/network.js';26import { DiskFileSystemProvider } from '../../../files/node/diskFileSystemProvider.js';27import { DisposableStore } from '../../../../base/common/lifecycle.js';28import { AgentHostGitService } from '../../node/agentHostGitService.js';2930function createGitService(disposables: Pick<DisposableStore, 'add'>): AgentHostGitService {31const logService = new NullLogService();32const fileService = disposables.add(new FileService(logService));33disposables.add(fileService.registerProvider(Schemas.file, disposables.add(new DiskFileSystemProvider(logService))));34const env: Partial<INativeEnvironmentService> = { tmpDir: URI.file(tmpdir()) };35return new AgentHostGitService(fileService, env as INativeEnvironmentService);36}3738suite('AgentHostGitService - getSessionGitState (real git)', () => {39const disposables = ensureNoDisposablesAreLeakedInTestSuite();4041// Skip the on-disk git tests when `git` is not on PATH (e.g. minimal CI).42const hasGit = (() => {43try { cp.execFileSync('git', ['--version'], { stdio: 'ignore' }); return true; } catch { return false; }44})();4546let tmpRoot: string | undefined;47let svc: AgentHostGitService | undefined;4849setup(() => {50tmpRoot = undefined;51svc = createGitService(disposables);52});5354teardown(() => {55if (tmpRoot) {56rmSync(tmpRoot, { recursive: true, force: true });57}58});5960function initRepo(opts?: { remote?: string; baseBranch?: string }): string {61tmpRoot = mkdtempSync(join(tmpdir(), 'agent-host-git-'));62const env = { ...process.env, GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t' };63const run = (...args: string[]) => cp.execFileSync('git', args, { cwd: tmpRoot!, env, stdio: 'pipe' });64run('init', '-q', '-b', opts?.baseBranch ?? 'main');65run('commit', '-q', '--allow-empty', '-m', 'initial');66if (opts?.remote) {67run('remote', 'add', 'origin', opts.remote);68}69return tmpRoot!;70}7172(hasGit ? test : test.skip)('returns undefined for a non-git directory', async () => {73const dir = mkdtempSync(join(tmpdir(), 'agent-host-nongit-'));74tmpRoot = dir;75const result = await svc!.getSessionGitState(URI.file(dir));76assert.strictEqual(result, undefined);77});7879(hasGit ? test : test.skip)('reports branch, github remote and clean state for a fresh repo', async () => {80const dir = initRepo({ remote: 'https://github.com/owner/repo.git' });81const result = await svc!.getSessionGitState(URI.file(dir));82assert.ok(result, 'expected git state');83assert.strictEqual(result.branchName, 'main');84assert.strictEqual(result.hasGitHubRemote, true);85assert.strictEqual(result.uncommittedChanges, 0);86// No upstream configured for the fresh local branch.87assert.strictEqual(result.upstreamBranchName, undefined);88assert.strictEqual(result.outgoingChanges, undefined);89assert.strictEqual(result.incomingChanges, undefined);90});9192(hasGit ? test : test.skip)('counts uncommitted changes', async () => {93const dir = initRepo({ remote: '[email protected]:owner/repo.git' });94const fs = await import('fs/promises');95await fs.writeFile(join(dir, 'a.txt'), 'hello');96await fs.writeFile(join(dir, 'b.txt'), 'world');97const result = await svc!.getSessionGitState(URI.file(dir));98assert.ok(result);99assert.strictEqual(result.uncommittedChanges, 2);100assert.strictEqual(result.hasGitHubRemote, false);101});102103(hasGit ? test : test.skip)('reports outgoingChanges relative to base branch when local branch has no upstream', async () => {104// Create a bare "remote" repo and set up the working repo so that105// `refs/remotes/origin/HEAD` exists (required for baseBranchName parsing).106const remoteDir = mkdtempSync(join(tmpdir(), 'agent-host-remote-'));107const env = { ...process.env, GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t' };108try {109cp.execFileSync('git', ['init', '-q', '--bare', '-b', 'main'], { cwd: remoteDir, env, stdio: 'pipe' });110tmpRoot = mkdtempSync(join(tmpdir(), 'agent-host-git-'));111const run = (...args: string[]) => cp.execFileSync('git', args, { cwd: tmpRoot!, env, stdio: 'pipe' });112run('init', '-q', '-b', 'main');113run('commit', '-q', '--allow-empty', '-m', 'initial');114run('remote', 'add', 'origin', `https://github.com/owner/repo.git`);115// Use a separate "upload" remote pointing at the bare repo to populate116// the origin/main remote-tracking ref without changing the GitHub URL117// we're testing for hasGitHubRemote detection.118run('remote', 'add', 'tmp', remoteDir);119run('push', '-q', 'tmp', 'main:main');120// Create the origin/main ref locally without any network round-trip.121run('update-ref', 'refs/remotes/origin/main', 'refs/heads/main');122run('symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main');123124// Branch off and add two commits without setting an upstream.125run('checkout', '-q', '-b', 'feature', '--no-track');126run('commit', '-q', '--allow-empty', '-m', 'one');127run('commit', '-q', '--allow-empty', '-m', 'two');128129const result = await svc!.getSessionGitState(URI.file(tmpRoot!));130assert.ok(result, 'expected git state');131assert.strictEqual(result.branchName, 'feature');132assert.strictEqual(result.baseBranchName, 'main');133assert.strictEqual(result.upstreamBranchName, undefined);134assert.strictEqual(result.outgoingChanges, 2);135assert.strictEqual(result.uncommittedChanges, 0);136} finally {137rmSync(remoteDir, { recursive: true, force: true });138}139});140});141142suite('AgentHostGitService - computeSessionFileDiffs (real git)', () => {143const disposables = ensureNoDisposablesAreLeakedInTestSuite();144145const hasGit = (() => {146try { cp.execFileSync('git', ['--version'], { stdio: 'ignore' }); return true; } catch { return false; }147})();148149let tmpRoot: string | undefined;150let svc: AgentHostGitService | undefined;151152setup(() => {153tmpRoot = undefined;154svc = createGitService(disposables);155});156157teardown(() => {158if (tmpRoot) {159rmSync(tmpRoot, { recursive: true, force: true });160}161});162163function initRepo(): { dir: string; run: (...args: string[]) => Buffer } {164tmpRoot = mkdtempSync(join(tmpdir(), 'agent-host-diff-'));165const env = { ...process.env, GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t' };166const run = (...args: string[]) => cp.execFileSync('git', args, { cwd: tmpRoot!, env, stdio: 'pipe' });167run('init', '-q', '-b', 'main');168return { dir: tmpRoot!, run };169}170171(hasGit ? test : test.skip)('returns undefined for a non-git directory', async () => {172const dir = mkdtempSync(join(tmpdir(), 'agent-host-nongit-diff-'));173tmpRoot = dir;174const result = await svc!.computeSessionFileDiffs(URI.file(dir), { sessionUri: 'copilot:/s' });175assert.strictEqual(result, undefined);176});177178(hasGit ? test : test.skip)('reports modified, added (untracked) and deleted files against HEAD', async () => {179const fs = await import('fs/promises');180const { dir, run } = initRepo();181await fs.writeFile(join(dir, 'kept.txt'), 'one\ntwo\nthree\n');182await fs.writeFile(join(dir, 'gone.txt'), 'bye\n');183run('add', '.');184run('commit', '-q', '-m', 'init');185186// Modify, add (untracked), delete.187await fs.writeFile(join(dir, 'kept.txt'), 'one\ntwo\nthree\nfour\n');188await fs.writeFile(join(dir, 'fresh.txt'), 'hello\n');189await fs.unlink(join(dir, 'gone.txt'));190191const result = await svc!.computeSessionFileDiffs(URI.file(dir), { sessionUri: 'copilot:/s' });192assert.ok(result, 'expected diffs');193const byPath = new Map(result.map(d => [d.after?.uri ?? d.before?.uri, d]));194195// Find by basename to be robust against path normalization differences (e.g. macOS /private prefix).196const findByBasename = (name: string) => result.find(d => {197const u = d.after?.uri ?? d.before?.uri;198return typeof u === 'string' && u.endsWith('/' + name);199});200201const kept = findByBasename('kept.txt');202assert.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 })))}`);203assert.deepStrictEqual(kept!.diff, { added: 1, removed: 0 });204assert.ok(kept!.before!.content.uri.startsWith('git-blob://'), 'before content should be a git-blob: URI');205206const fresh = findByBasename('fresh.txt');207assert.ok(fresh?.after && !fresh.before, 'untracked file should have only after');208209const gone = findByBasename('gone.txt');210assert.ok(gone?.before && !gone.after, 'deleted file should have only before');211void byPath;212});213214(hasGit ? test : test.skip)('anchors against the merge-base of the requested base branch', async () => {215const fs = await import('fs/promises');216const { dir, run } = initRepo();217await fs.writeFile(join(dir, 'a.txt'), 'a\n');218run('add', '.');219run('commit', '-q', '-m', 'init');220// Branch off, then advance main behind us so merge-base != HEAD.221run('checkout', '-q', '-b', 'feature');222await fs.writeFile(join(dir, 'b.txt'), 'b\n');223run('add', '.');224run('commit', '-q', '-m', 'add b on feature');225226const result = await svc!.computeSessionFileDiffs(URI.file(dir), { sessionUri: 'copilot:/s', baseBranch: 'main' });227assert.ok(result, 'expected diffs');228// `b.txt` was committed on `feature` after branching from `main`, so229// it must show up in the merge-base diff even though there are no230// uncommitted changes in the working tree.231const paths = result.map(d => (d.after?.uri ?? d.before?.uri));232assert.ok(paths.some(p => p?.endsWith('b.txt')), `expected b.txt in diff; got ${paths.join(', ')}`);233});234235(hasGit ? test : test.skip)('returns no diffs for a clean repo', async () => {236const fs = await import('fs/promises');237const { dir, run } = initRepo();238await fs.writeFile(join(dir, 'a.txt'), 'a\n');239run('add', '.');240run('commit', '-q', '-m', 'init');241242const result = await svc!.computeSessionFileDiffs(URI.file(dir), { sessionUri: 'copilot:/s' });243assert.deepStrictEqual(result, []);244});245246(hasGit ? test : test.skip)('handles an empty repo (no HEAD) by treating files as added', async () => {247const fs = await import('fs/promises');248const { dir } = initRepo();249await fs.writeFile(join(dir, 'first.txt'), 'hello\n');250251const result = await svc!.computeSessionFileDiffs(URI.file(dir), { sessionUri: 'copilot:/s' });252assert.ok(result, 'expected diffs');253assert.strictEqual(result.length, 1);254assert.ok(result[0].after && !result[0].before, 'untracked file in empty repo should be an addition');255});256257(hasGit ? test : test.skip)('showBlob retrieves committed content', async () => {258const fs = await import('fs/promises');259const { dir, run } = initRepo();260await fs.writeFile(join(dir, 'a.txt'), 'original\n');261run('add', '.');262run('commit', '-q', '-m', 'init');263const sha = cp.execFileSync('git', ['rev-parse', 'HEAD'], { cwd: dir, encoding: 'utf8' }).trim();264await fs.writeFile(join(dir, 'a.txt'), 'changed\n');265266const blob = await svc!.showBlob(URI.file(dir), sha, 'a.txt');267assert.ok(blob);268assert.strictEqual(blob.toString(), 'original\n');269});270});271272suite('AgentHostGitService - worktree helpers (real git)', () => {273const disposables = ensureNoDisposablesAreLeakedInTestSuite();274275const hasGit = (() => {276try { cp.execFileSync('git', ['--version'], { stdio: 'ignore' }); return true; } catch { return false; }277})();278279let tmpRoot: string | undefined;280let svc: AgentHostGitService | undefined;281const env = { ...process.env, GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t' };282283setup(() => {284tmpRoot = undefined;285svc = createGitService(disposables);286});287288teardown(() => {289if (tmpRoot) {290rmSync(tmpRoot, { recursive: true, force: true });291}292});293294function initRepo(): string {295tmpRoot = mkdtempSync(join(tmpdir(), 'agent-host-git-wt-'));296const run = (...args: string[]) => cp.execFileSync('git', args, { cwd: tmpRoot!, env, stdio: 'pipe' });297run('init', '-q', '-b', 'main');298run('commit', '-q', '--allow-empty', '-m', 'initial');299return tmpRoot!;300}301302(hasGit ? test : test.skip)('branchExists reports true for HEAD branch and false for missing branches', async () => {303const dir = initRepo();304assert.strictEqual(await svc!.branchExists(URI.file(dir), 'main'), true);305assert.strictEqual(await svc!.branchExists(URI.file(dir), 'does-not-exist'), false);306});307308(hasGit ? test : test.skip)('hasUncommittedChanges flips with untracked and committed work', async () => {309const dir = initRepo();310assert.strictEqual(await svc!.hasUncommittedChanges(URI.file(dir)), false);311const fs = await import('fs/promises');312await fs.writeFile(join(dir, 'a.txt'), 'hello');313assert.strictEqual(await svc!.hasUncommittedChanges(URI.file(dir)), true);314cp.execFileSync('git', ['add', 'a.txt'], { cwd: dir, env, stdio: 'pipe' });315cp.execFileSync('git', ['commit', '-q', '-m', 'add a'], { cwd: dir, env, stdio: 'pipe' });316assert.strictEqual(await svc!.hasUncommittedChanges(URI.file(dir)), false);317});318319(hasGit ? test : test.skip)('addExistingWorktree attaches a worktree for an existing branch (no -b)', async () => {320const dir = initRepo();321cp.execFileSync('git', ['branch', 'feature'], { cwd: dir, env, stdio: 'pipe' });322const wtPath = join(dir, '..', `wt-${Date.now()}`);323try {324await svc!.addExistingWorktree(URI.file(dir), URI.file(wtPath), 'feature');325const fs = await import('fs/promises');326const stat = await fs.stat(wtPath);327assert.ok(stat.isDirectory(), 'worktree directory should exist');328} finally {329rmSync(wtPath, { recursive: true, force: true });330}331});332});333334335