Path: blob/main/extensions/copilot/src/platform/git/vscode-node/gitServiceImpl.ts
13401 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 * as vscode from 'vscode';67import { execFile } from 'child_process';8import { promisify } from 'util';9import { Uri } from 'vscode';10import { BatchedProcessor } from '../../../util/common/async';11import { coalesce } from '../../../util/vs/base/common/arrays';12import { Sequencer } from '../../../util/vs/base/common/async';13import { CachedFunction } from '../../../util/vs/base/common/cache';14import { CancellationToken, cancelOnDispose } from '../../../util/vs/base/common/cancellation';15import { Emitter, Event } from '../../../util/vs/base/common/event';16import { Disposable } from '../../../util/vs/base/common/lifecycle';17import { autorun, IObservable, observableFromEvent, observableSignalFromEvent, observableValue, waitForState } from '../../../util/vs/base/common/observableInternal';18import * as path from '../../../util/vs/base/common/path';19import { isEqual } from '../../../util/vs/base/common/resources';20import { URI } from '../../../util/vs/base/common/uri';21import { ILogService } from '../../log/common/logService';22import { IGitExtensionService } from '../common/gitExtensionService';23import { IGitService, RepoContext } from '../common/gitService';24import { parseGitRemotes } from '../common/utils';25import { API, APIState, Branch, Change, CommitOptions, CommitShortStat, DiffChange, Ref, RefQuery, Repository, RepositoryAccessDetails } from '../vscode/git';2627const execFileAsync = promisify(execFile);2829export class GitServiceImpl extends Disposable implements IGitService {3031declare readonly _serviceBrand: undefined;3233readonly activeRepository = observableValue<RepoContext | undefined>(this, undefined);3435private readonly _getRepositorySequencer = new Sequencer();3637private _onDidOpenRepository = new Emitter<RepoContext>();38readonly onDidOpenRepository: Event<RepoContext> = this._onDidOpenRepository.event;39private _onDidCloseRepository = new Emitter<RepoContext>();40readonly onDidCloseRepository: Event<RepoContext> = this._onDidCloseRepository.event;41private _onDidFinishInitialRepositoryDiscovery = new Emitter<void>();42readonly onDidFinishInitialization: Event<void> = this._onDidFinishInitialRepositoryDiscovery.event;43private _isInitialized = observableValue(this, false);44constructor(45@IGitExtensionService private readonly gitExtensionService: IGitExtensionService,46@ILogService private readonly logService: ILogService47) {48super();4950this._register(this._onDidOpenRepository);51this._register(this._onDidCloseRepository);52this._register(this._onDidFinishInitialRepositoryDiscovery);5354const gitAPI = this.gitExtensionService.getExtensionApi();55if (gitAPI) {56this.registerGitAPIListeners(gitAPI);57} else {58this._register(this.gitExtensionService.onDidChange((status) => {59if (status.enabled) {60const gitAPI = this.gitExtensionService.getExtensionApi();61if (gitAPI) {62this.registerGitAPIListeners(gitAPI);63return;64}65}6667// Extension is disabled / git is not available so we say all repositories are discovered68this._onDidFinishInitialRepositoryDiscovery.fire();69this._isInitialized.set(true, undefined);70}));71}72}7374private registerGitAPIListeners(gitAPI: API) {75this._register(gitAPI.onDidOpenRepository(repository => this.doOpenRepository(repository)));76this._register(gitAPI.onDidCloseRepository(repository => this.doCloseRepository(repository)));7778for (const repository of gitAPI.repositories) {79this.doOpenRepository(repository);80}8182// Initial repository discovery83const stateObs = observableFromEvent(this,84gitAPI.onDidChangeState as Event<APIState>, () => gitAPI.state);8586this._register(autorun(async reader => {87const state = stateObs.read(reader);88if (state !== 'initialized') {89return;90}9192// Wait for all discovered repositories to be initialized93await Promise.all(gitAPI.repositories.map(repository => {94const HEAD = observableFromEvent(this, repository.state.onDidChange as Event<void>, () => repository.state.HEAD);95return waitForState(HEAD, state => state !== undefined, undefined, cancelOnDispose(this._store));96}));9798this._isInitialized.set(true, undefined);99this._onDidFinishInitialRepositoryDiscovery.fire();100101this.logService.trace(`[GitServiceImpl] Initial repository discovery finished: ${this.repositories.length} repositories found.`);102}));103}104105get isInitialized(): boolean {106return this._isInitialized.get();107}108109public getRecentRepositories(): Iterable<RepositoryAccessDetails> {110const gitAPI = this.gitExtensionService.getExtensionApi();111if (!gitAPI) {112return [];113}114return gitAPI.recentRepositories;115}116117async initRepository(uri: URI): Promise<Repository | undefined> {118const gitAPI = this.gitExtensionService.getExtensionApi();119const repository = await gitAPI?.init(uri);120if (!repository) {121return undefined;122}123124await this.waitForRepositoryState(repository);125return repository;126}127128async openRepository(uri: URI): Promise<Repository | undefined> {129const repository = await this._getRepository(uri, true);130if (!repository) {131return undefined;132}133134await this.waitForRepositoryState(repository);135return repository;136}137138async getRepository2(uri: URI): Promise<Repository | undefined> {139const repository = await this._getRepository(uri, false);140return repository;141}142143async getRepository(uri: URI, forceOpen = true): Promise<RepoContext | undefined> {144const repository = await this._getRepository(uri, forceOpen);145if (!repository) {146return undefined;147}148149await this.waitForRepositoryState(repository);150return GitServiceImpl.repoToRepoContext(repository);151}152153private async _getRepository(uri: URI, forceOpen = true): Promise<Repository | undefined> {154return this._getRepositorySequencer.queue(async () => {155const gitAPI = this.gitExtensionService.getExtensionApi();156if (!gitAPI) {157return undefined;158}159160if (!(uri instanceof vscode.Uri)) {161// The git extension API expects a vscode.Uri, so we convert it if necessary162uri = vscode.Uri.parse(uri.toString());163}164165// Ensure that the initial166// repository discovery is167// finished168await this.initialize();169170// Query opened repositories171let repository = gitAPI.getRepository(uri);172if (repository) {173return repository;174}175176if (!forceOpen) {177return undefined;178}179180// Open repository181repository = await gitAPI.openRepository(uri);182if (!repository) {183return undefined;184}185186return repository;187});188}189190async getRepositoryFetchUrls(uri: URI): Promise<Pick<RepoContext, 'rootUri' | 'remoteFetchUrls'> | undefined> {191this.logService.trace(`[GitServiceImpl][getRepositoryFetchUrls] URI: ${uri.toString()}`);192193const gitAPI = this.gitExtensionService.getExtensionApi();194if (!gitAPI) {195return undefined;196}197198// Query opened repositories199const repository = gitAPI.getRepository(uri);200if (repository) {201await this.waitForRepositoryState(repository);202203const remotes = {204rootUri: repository.rootUri,205remoteFetchUrls: repository.state.remotes.map(r => r.fetchUrl),206};207208this.logService.trace(`[GitServiceImpl][getRepositoryFetchUrls] Remotes (open repository): ${JSON.stringify(remotes)}`);209return remotes;210}211212try {213const uriStat = await vscode.workspace.fs.stat(uri);214if (uriStat.type !== vscode.FileType.Directory) {215uri = URI.file(path.dirname(uri.fsPath));216}217218// Get repository root219const repositoryRoot = await gitAPI.getRepositoryRoot(uri);220if (!repositoryRoot) {221this.logService.trace(`[GitServiceImpl][getRepositoryFetchUrls] No repository root found`);222return undefined;223}224225this.logService.trace(`[GitServiceImpl][getRepositoryFetchUrls] Repository root: ${repositoryRoot.toString()}`);226const buffer = await vscode.workspace.fs.readFile(URI.file(path.join(repositoryRoot.fsPath, '.git', 'config')));227228const remotes = {229rootUri: repositoryRoot,230remoteFetchUrls: parseGitRemotes(buffer.toString()).map(remote => remote.fetchUrl)231};232233this.logService.trace(`[GitServiceImpl][getRepositoryFetchUrls] Remotes (.git/config): ${JSON.stringify(remotes)}`);234return remotes;235} catch (error) {236this.logService.error(`[GitServiceImpl][getRepositoryFetchUrls] Failed to read remotes from .git/config: ${error.message}`);237return undefined;238}239}240241async add(uri: URI, paths: string[]): Promise<void> {242const gitAPI = this.gitExtensionService.getExtensionApi();243const repository = gitAPI?.getRepository(uri);244await repository?.add(paths);245}246247async restore(uri: URI, paths: string[], options?: { staged?: boolean; ref?: string }): Promise<void> {248const gitAPI = this.gitExtensionService.getExtensionApi();249const repository = gitAPI?.getRepository(uri);250await repository?.restore(paths, options);251}252253async diffBetweenPatch(uri: vscode.Uri, ref1: string, ref2: string, path?: string): Promise<string | undefined> {254const gitAPI = this.gitExtensionService.getExtensionApi();255const repository = gitAPI?.getRepository(uri);256return repository?.diffBetweenPatch(ref1, ref2, path);257}258259async diffBetweenWithStats(uri: vscode.Uri, ref1: string, ref2: string, path?: string): Promise<DiffChange[] | undefined> {260const gitAPI = this.gitExtensionService.getExtensionApi();261const repository = gitAPI?.getRepository(uri);262return await repository?.diffBetweenWithStats(ref1, ref2, path);263}264265async diffWith(uri: vscode.Uri, ref: string): Promise<Change[] | undefined> {266const gitAPI = this.gitExtensionService.getExtensionApi();267const repository = gitAPI?.getRepository(uri);268return repository?.diffWith(ref);269}270271async diffIndexWithHEADShortStats(uri: URI): Promise<CommitShortStat | undefined> {272const gitAPI = this.gitExtensionService.getExtensionApi();273const repository = gitAPI?.getRepository(uri);274if (!repository?.diffIndexWithHEADShortStats) {275return undefined;276}277return await repository?.diffIndexWithHEADShortStats(uri.fsPath);278}279280async getMergeBase(uri: URI, ref1: string, ref2: string): Promise<string | undefined> {281const gitAPI = this.gitExtensionService.getExtensionApi();282const repository = gitAPI?.getRepository(uri);283return repository?.getMergeBase(ref1, ref2);284}285286async commit(uri: URI, message: string, opts?: CommitOptions): Promise<void> {287const gitAPI = this.gitExtensionService.getExtensionApi();288const repository = gitAPI?.getRepository(uri);289if (!repository) {290return;291}292293await repository.commit(message, opts);294}295296async applyPatch(uri: URI, patch: string): Promise<void> {297const gitAPI = this.gitExtensionService.getExtensionApi();298const repository = gitAPI?.getRepository(uri);299return await repository?.apply(patch, false);300}301302async rebase(uri: URI, branch: string): Promise<void> {303try {304const gitAPI = this.gitExtensionService.getExtensionApi();305const repository = gitAPI?.getRepository(uri);306await repository?.rebase(branch);307} catch (error) {308this.logService.error(`[GitServiceImpl][rebase] Failed to rebase ${uri.toString()} on ${branch}: ${error.message}`);309}310}311312async createWorktree(uri: URI, options?: { path?: string; commitish?: string; branch?: string; noTrack?: boolean }): Promise<string | undefined> {313const gitAPI = this.gitExtensionService.getExtensionApi();314const repository = gitAPI?.getRepository(uri);315return await repository?.createWorktree(options);316}317318async deleteWorktree(uri: URI, path: string, options?: { force?: boolean }): Promise<void> {319const gitAPI = this.gitExtensionService.getExtensionApi();320const repository = gitAPI?.getRepository(uri);321return await repository?.deleteWorktree(path, options);322}323324async migrateChanges(uri: URI, sourceRepositoryUri: URI, options?: { confirmation?: boolean; deleteFromSource?: boolean; untracked?: boolean }): Promise<void> {325const gitAPI = this.gitExtensionService.getExtensionApi();326const repository = gitAPI?.getRepository(uri);327return await repository?.migrateChanges(sourceRepositoryUri.fsPath, options);328}329330async getBranch(uri: URI, name: string): Promise<Branch | undefined> {331const gitAPI = this.gitExtensionService.getExtensionApi();332const repository = gitAPI?.getRepository(uri);333return await repository?.getBranch(name);334}335336async getBranchBase(uri: URI, name: string): Promise<Branch | undefined> {337const gitAPI = this.gitExtensionService.getExtensionApi();338const repository = gitAPI?.getRepository(uri);339return await repository?.getBranchBase(name);340}341342async getRefs(uri: URI, query: RefQuery, cancellationToken?: CancellationToken): Promise<Ref[]> {343const gitAPI = this.gitExtensionService.getExtensionApi();344const repository = gitAPI?.getRepository(uri);345return await repository?.getRefs(query, cancellationToken) ?? [];346}347348async isBranchProtected(uri: URI, branch?: string | Branch): Promise<boolean | undefined> {349try {350const gitAPI = this.gitExtensionService.getExtensionApi();351const repository = gitAPI?.getRepository(uri);352if (!repository) {353return undefined;354}355356const branchToCheck = typeof branch === 'string'357? await repository.getBranch(branch)358: branch;359return repository.isBranchProtected(branchToCheck);360} catch (error) {361const branchLabel = typeof branch === 'string' ? branch : branch?.name;362this.logService.error(`[GitServiceImpl][isBranchProtected] Failed to check branch protection for ${uri.toString()}${branchLabel ? ` (${branchLabel})` : ''}: ${error instanceof Error ? error.message : String(error)}`);363return undefined;364}365}366367async generateRandomBranchName(uri: URI): Promise<string | undefined> {368try {369const gitAPI = this.gitExtensionService.getExtensionApi();370const repository = gitAPI?.getRepository(uri);371372const branchName = await repository?.generateRandomBranchName();373return branchName;374} catch (error) {375this.logService.error(`[GitServiceImpl][generateRandomBranchName] Failed to generate random branch name: ${error instanceof Error ? error.message : String(error)}`);376return undefined;377}378}379380async exec(cwd: URI, args: string[], env?: Record<string, string>): Promise<string> {381const gitAPI = this.gitExtensionService.getExtensionApi();382const gitPath = gitAPI?.git.path ?? 'git';383const gitEnv = Object.assign({}, process.env, env, {384GIT_AUTHOR_NAME: 'VS Code',385GIT_AUTHOR_EMAIL: '[email protected]',386GIT_COMMITTER_NAME: 'VS Code',387GIT_COMMITTER_EMAIL: '[email protected]',388LANG: 'en_US.UTF-8',389LANGUAGE: 'en',390LC_ALL: 'en_US.UTF-8'391} satisfies Record<string, string>);392393const timer = performance.now();394395try {396const result = await execFileAsync(gitPath, args, {397cwd: cwd.fsPath,398encoding: 'utf8',399env: gitEnv400});401402if (result.stderr) {403this.logService.error(`[GitServiceImpl][exec] git ${args.join(' ')} [${Math.round(performance.now() - timer)}ms] Error: ${result.stderr}`);404throw new Error(`Failed to execute git command (git ${args.join(' ')}). Error: ${result.stderr}`);405}406407this.logService.trace(`[GitServiceImpl][exec] git ${args.join(' ')} [${Math.round(performance.now() - timer)}ms]`);408return result.stdout.trim();409} catch (error) {410const errorMessage = error instanceof Error ? error.message : String(error);411this.logService.error(`[GitServiceImpl][exec] git ${args.join(' ')} [${Math.round(performance.now() - timer)}ms] Error: ${errorMessage}`);412413throw new Error(`Failed to execute git command (git ${args.join(' ')}). Error: ${errorMessage}`);414}415}416417async initialize(): Promise<void> {418if (this._isInitialized.get()) {419return;420}421422await waitForState(this._isInitialized, state => state, undefined, cancelOnDispose(this._store));423424if (this.repositories.length > 0) {425await waitForState(this.activeRepository, state => state !== undefined, undefined, cancelOnDispose(this._store));426}427}428429private async doOpenRepository(repository: Repository): Promise<void> {430this.logService.trace(`[GitServiceImpl][doOpenRepository] Repository: ${repository.rootUri.toString()}`);431432// The `gitAPI.onDidOpenRepository` event is fired before `git status` completes and the repository433// state is initialized. `IGitService.onDidOpenRepository` will only fire after the repository state434// is initialized.435const HEAD = observableFromEvent(this, repository.state.onDidChange as Event<void>, () => repository.state.HEAD);436await waitForState(HEAD, state => state !== undefined, undefined, cancelOnDispose(this._store));437438this.logService.trace(`[GitServiceImpl][doOpenRepository] Repository initialized: ${JSON.stringify(HEAD.get())}`);439440// Active repository441const selectedObs = observableFromEvent(this,442repository.ui.onDidChange as Event<void>, () => repository.ui.selected);443444const onDidChangeStateSignal = observableSignalFromEvent(this, repository.state.onDidChange as Event<void>);445446this._register(autorun(reader => {447onDidChangeStateSignal.read(reader);448const selected = selectedObs.read(reader);449450// eslint-disable-next-line local/code-no-observable-get-in-reactive-context451const activeRepository = this.activeRepository.get();452if (activeRepository && !selected && !isEqual(activeRepository.rootUri, repository.rootUri)) {453return;454}455456const repositoryContext = GitServiceImpl.repoToRepoContext(repository);457this.logService.trace(`[GitServiceImpl][doOpenRepository] Active repository: ${JSON.stringify(repositoryContext)}`);458this.activeRepository.set(repositoryContext, undefined);459}));460461// Open repository event462const repositoryContext = GitServiceImpl.repoToRepoContext(repository);463if (repositoryContext) {464this._onDidOpenRepository.fire(repositoryContext);465}466}467468private doCloseRepository(repository: Repository): void {469this.logService.trace(`[GitServiceImpl][doCloseRepository] Repository: ${repository.rootUri.toString()}`);470471const repositoryContext = GitServiceImpl.repoToRepoContext(repository);472if (repositoryContext) {473this._onDidCloseRepository.fire(repositoryContext);474}475}476477private async waitForRepositoryState(repository: Repository): Promise<void> {478if (repository.state.HEAD) {479return;480}481482const HEAD = observableFromEvent(this, repository.state.onDidChange as Event<void>, () => repository.state.HEAD);483await waitForState(HEAD, state => state !== undefined, undefined, cancelOnDispose(this._store));484}485486private static repoToRepoContext(repo: Repository): RepoContext;487private static repoToRepoContext(repo: Repository | undefined | null): RepoContext | undefined;488private static repoToRepoContext(repo: Repository | undefined | null): RepoContext | undefined {489if (!repo) {490return undefined;491}492493return new RepoContextImpl(repo);494}495496get repositories(): RepoContext[] {497const gitAPI = this.gitExtensionService.getExtensionApi();498if (!gitAPI) {499return [];500}501502return coalesce(gitAPI.repositories503.filter(repository => repository.state.HEAD !== undefined)504.map(repository => GitServiceImpl.repoToRepoContext(repository)));505}506}507508export class RepoContextImpl implements RepoContext {509public readonly rootUri = this._repo.rootUri;510public readonly kind = this._repo.kind;511public readonly isUsingVirtualFileSystem = this._repo.isUsingVirtualFileSystem;512public readonly headBranchName = this._repo.state.HEAD?.name;513public readonly headCommitHash = this._repo.state.HEAD?.commit;514public readonly headIncomingChanges = this._repo.state.HEAD?.behind;515public readonly headOutgoingChanges = this._repo.state.HEAD?.ahead;516public readonly upstreamBranchName = this._repo.state.HEAD?.upstream?.name;517public readonly upstreamRemote = this._repo.state.HEAD?.upstream?.remote;518public readonly isRebasing = this._repo.state.rebaseCommit !== null;519public readonly remotes = this._repo.state.remotes.map(r => r.name);520public readonly remoteFetchUrls = this._repo.state.remotes.map(r => r.fetchUrl);521public readonly worktrees = this._repo.state.worktrees;522523public readonly changes = {524mergeChanges: this._repo.state.mergeChanges,525indexChanges: this._repo.state.indexChanges,526workingTree: this._repo.state.workingTreeChanges,527untrackedChanges: this._repo.state.untrackedChanges528};529530private readonly _onDidChangeSignal = observableSignalFromEvent(this, this._repo.state.onDidChange as Event<void>);531532public readonly headBranchNameObs: IObservable<string | undefined> = this._onDidChangeSignal.map(() => this._repo.state.HEAD?.name);533public readonly headCommitHashObs: IObservable<string | undefined> = this._onDidChangeSignal.map(() => this._repo.state.HEAD?.commit);534public readonly upstreamBranchNameObs: IObservable<string | undefined> = this._onDidChangeSignal.map(() => this._repo.state.HEAD?.upstream?.name);535public readonly upstreamRemoteObs: IObservable<string | undefined> = this._onDidChangeSignal.map(() => this._repo.state.HEAD?.upstream?.remote);536public readonly isRebasingObs: IObservable<boolean> = this._onDidChangeSignal.map(() => this._repo.state.rebaseCommit !== null);537538private readonly _checkIsIgnored = new BatchedProcessor<string, boolean>(async (paths) => {539const result = await this._repo.checkIgnore(paths);540return paths.map(p => result.has(p));541}, 1000);542private readonly _isIgnored = new CachedFunction(async (documentUri: string) => {543const path = Uri.parse(documentUri).fsPath;544const result = await this._checkIsIgnored.request(path);545return result;546});547548public isIgnored(uri: URI): Promise<boolean> {549return this._isIgnored.get(uri.toString());550}551552constructor(553private readonly _repo: Repository554) {555}556}557558559