Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/platform/git/common/gitService.ts
13401 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 { IDisposable } from 'monaco-editor';
7
import { createServiceIdentifier } from '../../../util/common/services';
8
import { CancellationToken } from '../../../util/vs/base/common/cancellation';
9
import { Event } from '../../../util/vs/base/common/event';
10
import { IObservable } from '../../../util/vs/base/common/observableInternal';
11
import { equalsIgnoreCase } from '../../../util/vs/base/common/strings';
12
import { URI } from '../../../util/vs/base/common/uri';
13
import { Branch, Change, CommitOptions, CommitShortStat, DiffChange, Ref, RefQuery, Repository, RepositoryAccessDetails, RepositoryKind, Worktree } from '../vscode/git';
14
15
export interface RepoContext {
16
readonly rootUri: URI;
17
readonly kind: RepositoryKind;
18
readonly isUsingVirtualFileSystem: boolean;
19
readonly headIncomingChanges: number | undefined;
20
readonly headOutgoingChanges: number | undefined;
21
readonly headBranchName: string | undefined;
22
readonly headCommitHash: string | undefined;
23
readonly upstreamBranchName: string | undefined;
24
readonly upstreamRemote: string | undefined;
25
readonly isRebasing: boolean;
26
// TODO: merge these into one object. The only reason they're separate is to not have
27
// to change every test.
28
readonly remoteFetchUrls?: Array<string | undefined>;
29
readonly remotes: string[];
30
readonly worktrees: Worktree[];
31
readonly changes: { mergeChanges: Change[]; indexChanges: Change[]; workingTree: Change[]; untrackedChanges: Change[] } | undefined;
32
33
readonly headBranchNameObs: IObservable<string | undefined>;
34
readonly headCommitHashObs: IObservable<string | undefined>;
35
readonly upstreamBranchNameObs: IObservable<string | undefined>;
36
readonly upstreamRemoteObs: IObservable<string | undefined>;
37
readonly isRebasingObs: IObservable<boolean>;
38
39
isIgnored(uri: URI): Promise<boolean>;
40
}
41
42
export const IGitService = createServiceIdentifier<IGitService>('IGitService');
43
44
export interface IGitService extends IDisposable {
45
46
readonly _serviceBrand: undefined;
47
48
readonly onDidOpenRepository: Event<RepoContext>;
49
readonly onDidCloseRepository: Event<RepoContext>;
50
readonly onDidFinishInitialization: Event<void>;
51
52
readonly activeRepository: IObservable<RepoContext | undefined>;
53
54
readonly repositories: Array<RepoContext>;
55
readonly isInitialized: boolean;
56
57
initRepository(uri: URI): Promise<Repository | undefined>;
58
getRecentRepositories(): Iterable<RepositoryAccessDetails>;
59
getRepository(uri: URI, forceOpen?: boolean): Promise<RepoContext | undefined>;
60
getRepository2(uri: URI): Promise<Repository | undefined>;
61
openRepository(uri: URI): Promise<Repository | undefined>;
62
getRepositoryFetchUrls(uri: URI): Promise<Pick<RepoContext, 'rootUri' | 'remoteFetchUrls'> | undefined>;
63
initialize(): Promise<void>;
64
add(uri: URI, paths: string[]): Promise<void>;
65
diffBetweenPatch(uri: URI, ref1: string, ref2: string, path?: string): Promise<string | undefined>;
66
diffBetweenWithStats(uri: URI, ref1: string, ref2: string, path?: string): Promise<DiffChange[] | undefined>;
67
diffWith(uri: URI, ref: string): Promise<Change[] | undefined>;
68
diffIndexWithHEADShortStats(uri: URI): Promise<CommitShortStat | undefined>;
69
getMergeBase(uri: URI, ref1: string, ref2: string): Promise<string | undefined>;
70
restore(uri: URI, paths: string[], options?: { staged?: boolean; ref?: string }): Promise<void>;
71
72
createWorktree(uri: URI, options?: { path?: string; commitish?: string; branch?: string; noTrack?: boolean }): Promise<string | undefined>;
73
deleteWorktree(uri: URI, path: string, options?: { force?: boolean }): Promise<void>;
74
75
migrateChanges(uri: URI, sourceRepositoryUri: URI, options?: { confirmation?: boolean; deleteFromSource?: boolean; untracked?: boolean }): Promise<void>;
76
77
applyPatch(uri: URI, patch: string): Promise<void>;
78
commit(uri: URI, message: string | undefined, opts?: CommitOptions): Promise<void>;
79
80
getBranch(uri: URI, name: string): Promise<Branch | undefined>;
81
getBranchBase(uri: URI, name: string): Promise<Branch | undefined>;
82
getRefs(uri: URI, query: RefQuery, cancellationToken?: CancellationToken): Promise<Ref[]>;
83
isBranchProtected(uri: URI, branch?: string | Branch): Promise<boolean | undefined>;
84
85
generateRandomBranchName(uri: URI): Promise<string | undefined>;
86
87
exec(uri: URI, args: string[], env?: Record<string, string>): Promise<string>;
88
}
89
90
/**
91
* Gets the best repo github repo id from the repo context.
92
*/
93
export function getGitHubRepoInfoFromContext(repoContext: RepoContext): { id: GithubRepoId; remoteUrl: string } | undefined {
94
for (const remoteUrl of getOrderedRemoteUrlsFromContext(repoContext)) {
95
if (remoteUrl) {
96
const id = getGithubRepoIdFromFetchUrl(remoteUrl);
97
if (id) {
98
return { id, remoteUrl };
99
}
100
}
101
}
102
return undefined;
103
}
104
105
export interface ResolvedRepoRemoteInfo {
106
readonly fetchUrl: string | undefined;
107
readonly repoId: ResolvedRepoId;
108
}
109
110
export type ResolvedRepoId = GithubRepoId | AdoRepoId;
111
112
/**
113
* Gets the repo info for any type of repo from the repo context.
114
*/
115
export function* getOrderedRepoInfosFromContext(repoContext: RepoContext): Iterable<ResolvedRepoRemoteInfo> {
116
for (const remoteUrl of getOrderedRemoteUrlsFromContext(repoContext)) {
117
const repoId = getGithubRepoIdFromFetchUrl(remoteUrl) ?? getAdoRepoIdFromFetchUrl(remoteUrl);
118
if (repoId) {
119
yield { repoId, fetchUrl: remoteUrl };
120
}
121
}
122
}
123
124
/**
125
* Returns the remote URLs from repo context, starting with the best first.
126
*/
127
export function getOrderedRemoteUrlsFromContext(repoContext: RepoContext): Iterable<string> {
128
const out = new Set<string>();
129
130
// Strategy 1: If there's only one remote, use that
131
if (repoContext.remoteFetchUrls?.length === 1) {
132
out.add(repoContext.remoteFetchUrls[0]!);
133
return out;
134
}
135
136
137
// Strategy 2: If there's an upstream remote, use that
138
const remoteIndex = repoContext.remotes.findIndex(r => r === repoContext.upstreamRemote);
139
if (remoteIndex !== -1) {
140
const fetchUrl = repoContext.remoteFetchUrls?.[remoteIndex];
141
if (fetchUrl) {
142
out.add(fetchUrl);
143
}
144
}
145
146
// Strategy 3: If there's a remote named "origin", use that
147
const originIndex = repoContext.remotes.findIndex(r => r === 'origin');
148
if (originIndex !== -1) {
149
const fetchUrl = repoContext.remoteFetchUrls?.[originIndex];
150
if (fetchUrl) {
151
out.add(fetchUrl);
152
}
153
}
154
155
// Return everything else
156
for (const remote of repoContext.remoteFetchUrls ?? []) {
157
if (remote) {
158
out.add(remote);
159
}
160
}
161
162
return out;
163
}
164
165
export function parseRemoteUrl(fetchUrl: string): { host: string; rawHost: string; path: string } | undefined {
166
fetchUrl = fetchUrl.trim();
167
try {
168
// Normalize git shorthand syntax ([email protected]:user/repo.git) into an explicit ssh:// url
169
// See https://git-scm.com/docs/git-clone/2.35.0#_git_urls
170
if (/^[\w\d\-]+@/i.test(fetchUrl)) {
171
const parts = fetchUrl.split(':');
172
if (parts.length !== 2) {
173
return undefined;
174
}
175
fetchUrl = 'ssh://' + parts[0] + '/' + parts[1];
176
}
177
178
const repoUrl = URI.parse(fetchUrl);
179
const authority = repoUrl.authority;
180
const path = repoUrl.path;
181
if (!(equalsIgnoreCase(repoUrl.scheme, 'ssh') || equalsIgnoreCase(repoUrl.scheme, 'https') || equalsIgnoreCase(repoUrl.scheme, 'http'))) {
182
return;
183
}
184
185
const splitAuthority = authority.split('@');
186
if (splitAuthority.length > 2) { // Invalid, too many @ symbols
187
return undefined;
188
}
189
190
const extractedHost = splitAuthority.at(-1);
191
if (!extractedHost) {
192
return;
193
}
194
195
const rawHost = extractedHost
196
.toLowerCase()
197
.replace(/:\d+$/, ''); // Remove optional port
198
199
const normalizedHost = rawHost
200
.replace(/^[\w\-]+-/, '') // Remove common ssh syntax: abc-github.com
201
.replace(/-[\w\-]+$/, '');// Remove common ssh syntax: github.com-abc
202
203
return { host: normalizedHost, rawHost, path: path };
204
} catch (err) {
205
return undefined;
206
}
207
}
208
209
export class GithubRepoId {
210
readonly type = 'github';
211
212
static parse(nwo: string): GithubRepoId | undefined {
213
const parts = nwo.split('/');
214
if (parts.length !== 2) {
215
return undefined;
216
}
217
return new GithubRepoId(parts[0], parts[1]);
218
}
219
220
constructor(
221
public readonly org: string,
222
public readonly repo: string,
223
public readonly host: string = 'github.com',
224
) { }
225
226
toString(): string {
227
return toGithubNwo(this);
228
}
229
}
230
231
export function toGithubNwo(id: GithubRepoId): string {
232
return `${id.org}/${id.repo}`.toLowerCase();
233
}
234
235
export function toGithubWebUrl(id: GithubRepoId): string {
236
return `https://${id.host}/${id.org}/${id.repo}`;
237
}
238
239
/**
240
* Extracts the GitHub repository name from a git fetch URL.
241
* @param fetchUrl The git fetch URL to extract the repository name from.
242
* @returns The repository name if the fetch URL is a valid GitHub URL, otherwise undefined.
243
*/
244
export function getGithubRepoIdFromFetchUrl(fetchUrl: string): GithubRepoId | undefined {
245
const parsed = parseRemoteUrl(fetchUrl);
246
if (!parsed) {
247
return undefined;
248
}
249
250
const topLevelUrls = ['github.com', 'ghe.com'];
251
const matchedHost = topLevelUrls.find(topLevelUrl => parsed.host === topLevelUrl || parsed.host.endsWith('.' + topLevelUrl));
252
if (!matchedHost) {
253
return;
254
}
255
256
// Determine the actual web-accessible hostname
257
// For ghe.com subdomains, use the raw host (e.g., 'myco.ghe.com')
258
// For github.com, always use 'github.com' (SSH aliases like 'alias-github.com' should map to github.com)
259
const webHost = matchedHost === 'ghe.com'
260
? parsed.rawHost
261
: 'github.com';
262
263
const pathMatch = parsed.path.match(/^\/?([^/]+)\/([^/]+?)(\/|\.git\/?)?$/i);
264
return pathMatch ? new GithubRepoId(pathMatch[1], pathMatch[2], webHost) : undefined;
265
}
266
267
export class AdoRepoId {
268
269
readonly type = 'ado';
270
271
constructor(
272
public readonly org: string,
273
public readonly project: string,
274
public readonly repo: string,
275
) { }
276
277
toString(): string {
278
return `${this.org}/${this.project}/${this.repo}`.toLowerCase();
279
}
280
}
281
282
/**
283
* Extracts the ADO repository name from a git fetch URL.
284
* @param fetchUrl The Git fetch URL to extract the repository name from.
285
* @returns The repository name if the fetch URL is a valid ADO URL, otherwise undefined.
286
*/
287
export function getAdoRepoIdFromFetchUrl(fetchUrl: string): AdoRepoId | undefined {
288
const parsed = parseRemoteUrl(fetchUrl);
289
if (!parsed) {
290
return undefined;
291
}
292
293
// Http: https://dev.azure.com/organization/project/_git/repository
294
// Http: https://dev.azure.com/organization/project/_git/_optimized/repository
295
// Http: https://dev.azure.com/organization/project/_git/_full/repository
296
if (parsed.host === 'dev.azure.com') {
297
const partsMatch = parsed.path.match(/^\/?(?<org>[^/]+)\/(?<project>[^/]+?)\/_git\/(?:_(?:optimized|full)\/)?(?<repo>[^/]+?)(\.git|\/)?$/i);
298
if (partsMatch?.groups) {
299
return new AdoRepoId(partsMatch.groups.org, partsMatch.groups.project, partsMatch.groups.repo);
300
}
301
return undefined;
302
}
303
304
// Ssh: [email protected]:v3/organization/project/repository
305
// Ssh: [email protected]:v3/organization/project/_optimized/repository
306
// Ssh: [email protected]:v3/organization/project/_full/repository
307
if (parsed.host === 'ssh.dev.azure.com') {
308
const partsMatch = parsed.path.match(/^\/?v3\/(?<org>[^/]+)\/(?<project>[^/]+?)\/(?:_(?:optimized|full)\/)?(?<repo>[^/]+?)(\.git|\/)?$/i);
309
if (partsMatch?.groups) {
310
return new AdoRepoId(partsMatch.groups.org, partsMatch.groups.project, partsMatch.groups.repo);
311
}
312
return undefined;
313
}
314
315
// legacy https: https://organization.visualstudio.com/project/_git/repository
316
// Legacy ssh: [email protected]:v3/organization/project/repository
317
if (parsed.host.endsWith('.visualstudio.com')) {
318
const hostMatch = parsed.host.match(/^(?<org>[^\.]+)\.visualstudio\.com$/i);
319
if (!hostMatch?.groups) {
320
return undefined;
321
}
322
323
const partsMatch =
324
// Legacy ssh: [email protected]:v3/organization/project/repository
325
// Legacy ssh: [email protected]:v3/organization/project/_optimized/repository
326
// Legacy ssh: [email protected]:v3/organization/project/_full/repository
327
parsed.path.match(/^\/(v3\/)(?<org>[^/]+?)\/(?<project>[^/]+?)\/(?:_(?:optimized|full)\/)?(?<repo>[^/]+?)(\.git|\/)?$/i)
328
329
// legacy https: https://organization.visualstudio.com/project/_git/repository
330
// legacy https: https://organization.visualstudio.com/project/_git/_optimized/repository
331
// legacy https: https://organization.visualstudio.com/project/_git/_full/repository
332
// or legacy https: https://organization.visualstudio.com/collection/project/_git/repository
333
// or legacy https: https://organization.visualstudio.com/collection/project/_git/_optimized/repository
334
// or legacy https: https://organization.visualstudio.com/collection/project/_git/_full/repository
335
?? parsed.path.match(/^\/?((?<collection>[^/]+?)\/)?(?<project>[^/]+?)\/_git\/(?:_(?:optimized|full)\/)?(?<repo>[^/]+?)(\.git|\/)?$/i);
336
if (partsMatch?.groups) {
337
return new AdoRepoId(hostMatch.groups.org, partsMatch.groups.project, partsMatch.groups.repo);
338
}
339
340
return undefined;
341
}
342
343
return undefined;
344
}
345
346
/**
347
* Normalizes a remote repo fetch url into a standardized format
348
* @param fetchUrl A remote repo fetch url in the form of http, https, or ssh.
349
* @returns The normalized fetch url. Sanitized of any credentials, stripped of query params, and using https
350
*/
351
export function normalizeFetchUrl(fetchUrl: string): string {
352
// Handle SSH shorthand (git@host:project/repo.git)
353
if (/^[\w\d\-]+@[\w\d\.\-]+:/.test(fetchUrl)) {
354
fetchUrl = fetchUrl.replace(/([\w\d\-]+)@([\w\d\.\-]+):(.+)/, 'https://$2/$3');
355
return fetchUrl;
356
}
357
358
let url: URL;
359
try {
360
url = new URL(fetchUrl);
361
} catch {
362
return fetchUrl;
363
}
364
365
// Special handling for the scm/scm.git case
366
const scmScmMatch = url.pathname.match(/^\/scm\/scm\.git/);
367
368
// Create new URL with HTTPS protocol
369
const newUrl = new URL('https://' + url.hostname + url.pathname);
370
371
// Only remove /scm/ if it is followed by another segment (not if repo is named 'scm')
372
if (!scmScmMatch && /^\/scm\/[^/]/.test(newUrl.pathname)) {
373
newUrl.pathname = newUrl.pathname.replace(/^\/scm\//, '/');
374
}
375
376
return newUrl.toString();
377
}
378
379