Path: blob/main/extensions/copilot/src/platform/git/common/gitService.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 { IDisposable } from 'monaco-editor';6import { createServiceIdentifier } from '../../../util/common/services';7import { CancellationToken } from '../../../util/vs/base/common/cancellation';8import { Event } from '../../../util/vs/base/common/event';9import { IObservable } from '../../../util/vs/base/common/observableInternal';10import { equalsIgnoreCase } from '../../../util/vs/base/common/strings';11import { URI } from '../../../util/vs/base/common/uri';12import { Branch, Change, CommitOptions, CommitShortStat, DiffChange, Ref, RefQuery, Repository, RepositoryAccessDetails, RepositoryKind, Worktree } from '../vscode/git';1314export interface RepoContext {15readonly rootUri: URI;16readonly kind: RepositoryKind;17readonly isUsingVirtualFileSystem: boolean;18readonly headIncomingChanges: number | undefined;19readonly headOutgoingChanges: number | undefined;20readonly headBranchName: string | undefined;21readonly headCommitHash: string | undefined;22readonly upstreamBranchName: string | undefined;23readonly upstreamRemote: string | undefined;24readonly isRebasing: boolean;25// TODO: merge these into one object. The only reason they're separate is to not have26// to change every test.27readonly remoteFetchUrls?: Array<string | undefined>;28readonly remotes: string[];29readonly worktrees: Worktree[];30readonly changes: { mergeChanges: Change[]; indexChanges: Change[]; workingTree: Change[]; untrackedChanges: Change[] } | undefined;3132readonly headBranchNameObs: IObservable<string | undefined>;33readonly headCommitHashObs: IObservable<string | undefined>;34readonly upstreamBranchNameObs: IObservable<string | undefined>;35readonly upstreamRemoteObs: IObservable<string | undefined>;36readonly isRebasingObs: IObservable<boolean>;3738isIgnored(uri: URI): Promise<boolean>;39}4041export const IGitService = createServiceIdentifier<IGitService>('IGitService');4243export interface IGitService extends IDisposable {4445readonly _serviceBrand: undefined;4647readonly onDidOpenRepository: Event<RepoContext>;48readonly onDidCloseRepository: Event<RepoContext>;49readonly onDidFinishInitialization: Event<void>;5051readonly activeRepository: IObservable<RepoContext | undefined>;5253readonly repositories: Array<RepoContext>;54readonly isInitialized: boolean;5556initRepository(uri: URI): Promise<Repository | undefined>;57getRecentRepositories(): Iterable<RepositoryAccessDetails>;58getRepository(uri: URI, forceOpen?: boolean): Promise<RepoContext | undefined>;59getRepository2(uri: URI): Promise<Repository | undefined>;60openRepository(uri: URI): Promise<Repository | undefined>;61getRepositoryFetchUrls(uri: URI): Promise<Pick<RepoContext, 'rootUri' | 'remoteFetchUrls'> | undefined>;62initialize(): Promise<void>;63add(uri: URI, paths: string[]): Promise<void>;64diffBetweenPatch(uri: URI, ref1: string, ref2: string, path?: string): Promise<string | undefined>;65diffBetweenWithStats(uri: URI, ref1: string, ref2: string, path?: string): Promise<DiffChange[] | undefined>;66diffWith(uri: URI, ref: string): Promise<Change[] | undefined>;67diffIndexWithHEADShortStats(uri: URI): Promise<CommitShortStat | undefined>;68getMergeBase(uri: URI, ref1: string, ref2: string): Promise<string | undefined>;69restore(uri: URI, paths: string[], options?: { staged?: boolean; ref?: string }): Promise<void>;7071createWorktree(uri: URI, options?: { path?: string; commitish?: string; branch?: string; noTrack?: boolean }): Promise<string | undefined>;72deleteWorktree(uri: URI, path: string, options?: { force?: boolean }): Promise<void>;7374migrateChanges(uri: URI, sourceRepositoryUri: URI, options?: { confirmation?: boolean; deleteFromSource?: boolean; untracked?: boolean }): Promise<void>;7576applyPatch(uri: URI, patch: string): Promise<void>;77commit(uri: URI, message: string | undefined, opts?: CommitOptions): Promise<void>;7879getBranch(uri: URI, name: string): Promise<Branch | undefined>;80getBranchBase(uri: URI, name: string): Promise<Branch | undefined>;81getRefs(uri: URI, query: RefQuery, cancellationToken?: CancellationToken): Promise<Ref[]>;82isBranchProtected(uri: URI, branch?: string | Branch): Promise<boolean | undefined>;8384generateRandomBranchName(uri: URI): Promise<string | undefined>;8586exec(uri: URI, args: string[], env?: Record<string, string>): Promise<string>;87}8889/**90* Gets the best repo github repo id from the repo context.91*/92export function getGitHubRepoInfoFromContext(repoContext: RepoContext): { id: GithubRepoId; remoteUrl: string } | undefined {93for (const remoteUrl of getOrderedRemoteUrlsFromContext(repoContext)) {94if (remoteUrl) {95const id = getGithubRepoIdFromFetchUrl(remoteUrl);96if (id) {97return { id, remoteUrl };98}99}100}101return undefined;102}103104export interface ResolvedRepoRemoteInfo {105readonly fetchUrl: string | undefined;106readonly repoId: ResolvedRepoId;107}108109export type ResolvedRepoId = GithubRepoId | AdoRepoId;110111/**112* Gets the repo info for any type of repo from the repo context.113*/114export function* getOrderedRepoInfosFromContext(repoContext: RepoContext): Iterable<ResolvedRepoRemoteInfo> {115for (const remoteUrl of getOrderedRemoteUrlsFromContext(repoContext)) {116const repoId = getGithubRepoIdFromFetchUrl(remoteUrl) ?? getAdoRepoIdFromFetchUrl(remoteUrl);117if (repoId) {118yield { repoId, fetchUrl: remoteUrl };119}120}121}122123/**124* Returns the remote URLs from repo context, starting with the best first.125*/126export function getOrderedRemoteUrlsFromContext(repoContext: RepoContext): Iterable<string> {127const out = new Set<string>();128129// Strategy 1: If there's only one remote, use that130if (repoContext.remoteFetchUrls?.length === 1) {131out.add(repoContext.remoteFetchUrls[0]!);132return out;133}134135136// Strategy 2: If there's an upstream remote, use that137const remoteIndex = repoContext.remotes.findIndex(r => r === repoContext.upstreamRemote);138if (remoteIndex !== -1) {139const fetchUrl = repoContext.remoteFetchUrls?.[remoteIndex];140if (fetchUrl) {141out.add(fetchUrl);142}143}144145// Strategy 3: If there's a remote named "origin", use that146const originIndex = repoContext.remotes.findIndex(r => r === 'origin');147if (originIndex !== -1) {148const fetchUrl = repoContext.remoteFetchUrls?.[originIndex];149if (fetchUrl) {150out.add(fetchUrl);151}152}153154// Return everything else155for (const remote of repoContext.remoteFetchUrls ?? []) {156if (remote) {157out.add(remote);158}159}160161return out;162}163164export function parseRemoteUrl(fetchUrl: string): { host: string; rawHost: string; path: string } | undefined {165fetchUrl = fetchUrl.trim();166try {167// Normalize git shorthand syntax ([email protected]:user/repo.git) into an explicit ssh:// url168// See https://git-scm.com/docs/git-clone/2.35.0#_git_urls169if (/^[\w\d\-]+@/i.test(fetchUrl)) {170const parts = fetchUrl.split(':');171if (parts.length !== 2) {172return undefined;173}174fetchUrl = 'ssh://' + parts[0] + '/' + parts[1];175}176177const repoUrl = URI.parse(fetchUrl);178const authority = repoUrl.authority;179const path = repoUrl.path;180if (!(equalsIgnoreCase(repoUrl.scheme, 'ssh') || equalsIgnoreCase(repoUrl.scheme, 'https') || equalsIgnoreCase(repoUrl.scheme, 'http'))) {181return;182}183184const splitAuthority = authority.split('@');185if (splitAuthority.length > 2) { // Invalid, too many @ symbols186return undefined;187}188189const extractedHost = splitAuthority.at(-1);190if (!extractedHost) {191return;192}193194const rawHost = extractedHost195.toLowerCase()196.replace(/:\d+$/, ''); // Remove optional port197198const normalizedHost = rawHost199.replace(/^[\w\-]+-/, '') // Remove common ssh syntax: abc-github.com200.replace(/-[\w\-]+$/, '');// Remove common ssh syntax: github.com-abc201202return { host: normalizedHost, rawHost, path: path };203} catch (err) {204return undefined;205}206}207208export class GithubRepoId {209readonly type = 'github';210211static parse(nwo: string): GithubRepoId | undefined {212const parts = nwo.split('/');213if (parts.length !== 2) {214return undefined;215}216return new GithubRepoId(parts[0], parts[1]);217}218219constructor(220public readonly org: string,221public readonly repo: string,222public readonly host: string = 'github.com',223) { }224225toString(): string {226return toGithubNwo(this);227}228}229230export function toGithubNwo(id: GithubRepoId): string {231return `${id.org}/${id.repo}`.toLowerCase();232}233234export function toGithubWebUrl(id: GithubRepoId): string {235return `https://${id.host}/${id.org}/${id.repo}`;236}237238/**239* Extracts the GitHub repository name from a git fetch URL.240* @param fetchUrl The git fetch URL to extract the repository name from.241* @returns The repository name if the fetch URL is a valid GitHub URL, otherwise undefined.242*/243export function getGithubRepoIdFromFetchUrl(fetchUrl: string): GithubRepoId | undefined {244const parsed = parseRemoteUrl(fetchUrl);245if (!parsed) {246return undefined;247}248249const topLevelUrls = ['github.com', 'ghe.com'];250const matchedHost = topLevelUrls.find(topLevelUrl => parsed.host === topLevelUrl || parsed.host.endsWith('.' + topLevelUrl));251if (!matchedHost) {252return;253}254255// Determine the actual web-accessible hostname256// For ghe.com subdomains, use the raw host (e.g., 'myco.ghe.com')257// For github.com, always use 'github.com' (SSH aliases like 'alias-github.com' should map to github.com)258const webHost = matchedHost === 'ghe.com'259? parsed.rawHost260: 'github.com';261262const pathMatch = parsed.path.match(/^\/?([^/]+)\/([^/]+?)(\/|\.git\/?)?$/i);263return pathMatch ? new GithubRepoId(pathMatch[1], pathMatch[2], webHost) : undefined;264}265266export class AdoRepoId {267268readonly type = 'ado';269270constructor(271public readonly org: string,272public readonly project: string,273public readonly repo: string,274) { }275276toString(): string {277return `${this.org}/${this.project}/${this.repo}`.toLowerCase();278}279}280281/**282* Extracts the ADO repository name from a git fetch URL.283* @param fetchUrl The Git fetch URL to extract the repository name from.284* @returns The repository name if the fetch URL is a valid ADO URL, otherwise undefined.285*/286export function getAdoRepoIdFromFetchUrl(fetchUrl: string): AdoRepoId | undefined {287const parsed = parseRemoteUrl(fetchUrl);288if (!parsed) {289return undefined;290}291292// Http: https://dev.azure.com/organization/project/_git/repository293// Http: https://dev.azure.com/organization/project/_git/_optimized/repository294// Http: https://dev.azure.com/organization/project/_git/_full/repository295if (parsed.host === 'dev.azure.com') {296const partsMatch = parsed.path.match(/^\/?(?<org>[^/]+)\/(?<project>[^/]+?)\/_git\/(?:_(?:optimized|full)\/)?(?<repo>[^/]+?)(\.git|\/)?$/i);297if (partsMatch?.groups) {298return new AdoRepoId(partsMatch.groups.org, partsMatch.groups.project, partsMatch.groups.repo);299}300return undefined;301}302303// Ssh: [email protected]:v3/organization/project/repository304// Ssh: [email protected]:v3/organization/project/_optimized/repository305// Ssh: [email protected]:v3/organization/project/_full/repository306if (parsed.host === 'ssh.dev.azure.com') {307const partsMatch = parsed.path.match(/^\/?v3\/(?<org>[^/]+)\/(?<project>[^/]+?)\/(?:_(?:optimized|full)\/)?(?<repo>[^/]+?)(\.git|\/)?$/i);308if (partsMatch?.groups) {309return new AdoRepoId(partsMatch.groups.org, partsMatch.groups.project, partsMatch.groups.repo);310}311return undefined;312}313314// legacy https: https://organization.visualstudio.com/project/_git/repository315// Legacy ssh: [email protected]:v3/organization/project/repository316if (parsed.host.endsWith('.visualstudio.com')) {317const hostMatch = parsed.host.match(/^(?<org>[^\.]+)\.visualstudio\.com$/i);318if (!hostMatch?.groups) {319return undefined;320}321322const partsMatch =323// Legacy ssh: [email protected]:v3/organization/project/repository324// Legacy ssh: [email protected]:v3/organization/project/_optimized/repository325// Legacy ssh: [email protected]:v3/organization/project/_full/repository326parsed.path.match(/^\/(v3\/)(?<org>[^/]+?)\/(?<project>[^/]+?)\/(?:_(?:optimized|full)\/)?(?<repo>[^/]+?)(\.git|\/)?$/i)327328// legacy https: https://organization.visualstudio.com/project/_git/repository329// legacy https: https://organization.visualstudio.com/project/_git/_optimized/repository330// legacy https: https://organization.visualstudio.com/project/_git/_full/repository331// or legacy https: https://organization.visualstudio.com/collection/project/_git/repository332// or legacy https: https://organization.visualstudio.com/collection/project/_git/_optimized/repository333// or legacy https: https://organization.visualstudio.com/collection/project/_git/_full/repository334?? parsed.path.match(/^\/?((?<collection>[^/]+?)\/)?(?<project>[^/]+?)\/_git\/(?:_(?:optimized|full)\/)?(?<repo>[^/]+?)(\.git|\/)?$/i);335if (partsMatch?.groups) {336return new AdoRepoId(hostMatch.groups.org, partsMatch.groups.project, partsMatch.groups.repo);337}338339return undefined;340}341342return undefined;343}344345/**346* Normalizes a remote repo fetch url into a standardized format347* @param fetchUrl A remote repo fetch url in the form of http, https, or ssh.348* @returns The normalized fetch url. Sanitized of any credentials, stripped of query params, and using https349*/350export function normalizeFetchUrl(fetchUrl: string): string {351// Handle SSH shorthand (git@host:project/repo.git)352if (/^[\w\d\-]+@[\w\d\.\-]+:/.test(fetchUrl)) {353fetchUrl = fetchUrl.replace(/([\w\d\-]+)@([\w\d\.\-]+):(.+)/, 'https://$2/$3');354return fetchUrl;355}356357let url: URL;358try {359url = new URL(fetchUrl);360} catch {361return fetchUrl;362}363364// Special handling for the scm/scm.git case365const scmScmMatch = url.pathname.match(/^\/scm\/scm\.git/);366367// Create new URL with HTTPS protocol368const newUrl = new URL('https://' + url.hostname + url.pathname);369370// Only remove /scm/ if it is followed by another segment (not if repo is named 'scm')371if (!scmScmMatch && /^\/scm\/[^/]/.test(newUrl.pathname)) {372newUrl.pathname = newUrl.pathname.replace(/^\/scm\//, '/');373}374375return newUrl.toString();376}377378379