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