Path: blob/main/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCloudGitOperationsManager.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*--------------------------------------------------------------------------------------------*/45import * as vscode from 'vscode';6import { IGitExtensionService } from '../../../platform/git/common/gitExtensionService';7import { IGitService } from '../../../platform/git/common/gitService';8import { Repository } from '../../../platform/git/vscode/git';9import { ILogService } from '../../../platform/log/common/logService';10import { getRepoId } from '../vscode/copilotCodingAgentUtils';1112export interface GitRepoInfo {13repository: Repository;14remoteName: string;15baseRef: string;16}1718export class CopilotCloudGitOperationsManager {19constructor(20private readonly logService: ILogService,21private readonly gitService: IGitService,22private readonly gitExtensionService: IGitExtensionService23) { }2425async repoInfo(): Promise<GitRepoInfo> {26// TODO: support selecting remote27// await this.promptAndUpdatePreferredGitHubRemote(true);28const repoIds = await getRepoId(this.gitService);29if (!repoIds || repoIds.length === 0) {30throw new Error(vscode.l10n.t('Repository information is not available. Open a GitHub repository to continue with cloud agent.'));31}32const repoId = repoIds[0];33const currentRepository = this.gitService.activeRepository.get();34if (!currentRepository) {35throw new Error(vscode.l10n.t('No active repository found. Open a GitHub repository to continue with cloud agent.'));36}37const git = this.gitExtensionService.getExtensionApi();38const repo = git?.getRepository(currentRepository?.rootUri);39// Checks if user has permission to access the repository40if (!repo) {41throw new Error(42vscode.l10n.t(43'Unable to access {0}. Please check your permissions and try again.',44`\`${repoId.org}/${repoId.repo}\``45)46);47}48return {49repository: repo,50remoteName: repo.state.HEAD?.upstream?.remote ?? currentRepository.upstreamRemote ?? repo.state.remotes?.[0]?.name ?? 'origin',51baseRef: currentRepository.headBranchName ?? 'main'52};53}5455/**56* Pushes the current ref to the remote57* @returns The name of the pushed branch58*/59async pushBaseRefToRemote(): Promise<string> {60try {61const { repository, remoteName, baseRef } = await this.repoInfo();62const expectedRemoteBranch = `${remoteName}/${baseRef}`;63this.logService.warn(`Base branch '${expectedRemoteBranch}' not found on remote. Pushing...`);64await repository.push(remoteName, baseRef, true);65return baseRef;66} catch (error) {67this.logService.error(`Failed to push base ref to remote: ${error instanceof Error ? error.message : String(error)}`);68throw new Error(vscode.l10n.t('Failed to push base branch to remote. Please push the branch manually and try again.'));69}70}7172async checkIfRemoteHasRef(repository: Repository, remoteName: string, baseRef: string): Promise<boolean> {73const remoteBranches =74(await repository.getBranches({ remote: true }))75.filter(b => b.remote); // Has an associated remote76const expectedRemoteBranch = `${remoteName}/${baseRef}`;77const alternateNames = new Set<string>([78expectedRemoteBranch,79`refs/remotes/${expectedRemoteBranch}`,80baseRef81]);82const hasRemoteBranch = remoteBranches.some(branch => {83if (!branch.name) {84return false;85}86if (branch.remote && branch.remote !== remoteName) {87return false;88}89const candidateName =90(branch.remote && branch.name.startsWith(branch.remote + '/'))91? branch.name92: `${branch.remote}/${branch.name}`;93return alternateNames.has(candidateName);94});95return hasRemoteBranch;96}9798async commitAndPushChanges(): Promise<string> {99const { repository, remoteName, baseRef } = await this.repoInfo();100const asyncBranch = await this.generateRandomBranchName(repository, 'copilot');101102const commitMessage = vscode.l10n.t('Checkpoint from VS Code for cloud agent session');103try {104await repository.createBranch(asyncBranch, true);105await this.performCommit(asyncBranch, repository, commitMessage);106await repository.push(remoteName, asyncBranch, true);107await this.switchBackToBaseRef(repository, baseRef, asyncBranch);108return asyncBranch;109} catch (error) {110await this.rollbackToOriginalBranch(repository, baseRef);111this.logService.error(`Failed to automatically commit and push your changes: ${error instanceof Error ? error.message : String(error)}`);112throw new Error(vscode.l10n.t('Failed to automatically commit and push your changes. Please commit or stash your changes manually and try again.'));113}114}115116private async performCommit(asyncBranch: string, repository: Repository, commitMessage: string): Promise<void> {117try {118await repository.commit(commitMessage, { all: true });119if (repository.state.HEAD?.name !== asyncBranch || repository.state.workingTreeChanges.length > 0 || repository.state.indexChanges.length > 0) {120throw new Error(vscode.l10n.t('Uncommitted changes still detected.'));121}122} catch (error) {123// TODO: stream.progress('waiting for user to manually commit changes');124const commitSuccessful = await this.handleInteractiveCommit(repository);125if (!commitSuccessful) {126throw new Error(vscode.l10n.t('Failed to commit changes. Please commit or stash your changes manually before using the cloud agent.'));127}128}129}130131private async handleInteractiveCommit(repository: Repository): Promise<boolean> {132const COMMIT_YOUR_CHANGES = vscode.l10n.t('Commit your changes to continue cloud agent session. Close integrated terminal to cancel.');133return vscode.window.withProgress({134title: COMMIT_YOUR_CHANGES,135cancellable: true,136location: vscode.ProgressLocation.Notification137}, async (_, token) => {138return new Promise<boolean>((resolve) => {139const startingCommit = repository.state.HEAD?.commit;140const terminal = vscode.window.createTerminal({141name: 'GitHub Copilot Cloud Agent',142cwd: repository.rootUri.fsPath,143message: `\x1b[1m${COMMIT_YOUR_CHANGES}\x1b[0m`144});145146terminal.show();147148let disposed = false;149let timeoutId: TimeoutHandle | undefined = undefined;150let stateListener: vscode.Disposable | undefined = undefined;151let disposalListener: vscode.Disposable | undefined = undefined;152let cancellationListener: vscode.Disposable | undefined = undefined;153const cleanup = () => {154if (disposed) {155return;156}157disposed = true;158clearTimeout(timeoutId);159stateListener?.dispose();160disposalListener?.dispose();161cancellationListener?.dispose();162terminal.dispose();163};164165if (token) {166cancellationListener = token.onCancellationRequested(() => {167cleanup();168resolve(false);169});170}171172stateListener = repository.state.onDidChange(() => {173if (repository.state.HEAD?.commit !== startingCommit) {174cleanup();175resolve(true);176}177});178179timeoutId = setTimeout(() => {180cleanup();181resolve(false);182}, 5 * 60 * 1000);183184disposalListener = vscode.window.onDidCloseTerminal((closedTerminal) => {185if (closedTerminal === terminal) {186setTimeout(() => {187if (!disposed) {188cleanup();189resolve(repository.state.HEAD?.commit !== startingCommit);190}191}, 1000);192}193});194});195});196}197198private async switchBackToBaseRef(repository: Repository, baseRef: string, newRef: string): Promise<void> {199if (repository.state.HEAD?.name !== baseRef) {200await repository.checkout(baseRef);201}202}203204private async rollbackToOriginalBranch(repository: Repository, baseRef: string): Promise<void> {205if (repository.state.HEAD?.name !== baseRef) {206try {207await repository.checkout(baseRef);208} catch (error) {209this.logService.error(`Failed to checkout back to original branch '${baseRef}': ${error instanceof Error ? error.message : String(error)}`);210}211}212}213214private async generateRandomBranchName(repository: Repository, prefix: string): Promise<string> {215for (let index = 0; index < 5; index++) {216const randomName = `${prefix}/vscode-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;217try {218const refs = await repository.getRefs({ pattern: `refs/heads/${randomName}` });219if (!refs || refs.length === 0) {220return randomName;221}222} catch (error) {223this.logService.warn(`Failed to check refs for ${randomName}: ${error instanceof Error ? error.message : String(error)}`);224return randomName;225}226}227228return `${prefix}/vscode-${Date.now().toString(36)}`;229}230}231232233