diff --git a/extensions/git-extended/README.md b/extensions/git-extended/README.md index 0c69c97975fd5f7b89107bfedf3bd0bff608b741..23d190b0e989cdfae7377b18476c117091c2fca3 100644 --- a/extensions/git-extended/README.md +++ b/extensions/git-extended/README.md @@ -1,6 +1,3 @@ -# Git Extended +# VSCode Pull Request Support for GitHub -Experimental enhanced Git/Review/PR experience. - -## Notices -Uses code from github desktop: https://github.com/desktop/desktop \ No newline at end of file +Experimental enhanced Git/Review/PR experience. \ No newline at end of file diff --git a/extensions/git-extended/src/common/diff.ts b/extensions/git-extended/src/common/diff.ts index 998311950608372c4e43f7bb9992b5b1b5aa1cec..dfb07fe270fb71ae255a2cc2035fc2e3855686ea 100644 --- a/extensions/git-extended/src/common/diff.ts +++ b/extensions/git-extended/src/common/diff.ts @@ -8,8 +8,7 @@ import { getFileContent, writeTmpFile } from './file'; import { GitChangeType, RichFileChange } from './models/file'; import { Repository } from './models/repository'; import { Comment } from './models/comment'; -import { DiffHunk } from './models/diffHunk'; -import { getDiffChangeType, DiffLine, DiffChangeType } from './models/diffLine'; +import { DiffHunk, getDiffChangeType, DiffLine, DiffChangeType } from './models/diffHunk'; export const MODIFY_DIFF_INFO = /diff --git a\/(\S+) b\/(\S+).*\n*index.*\n*-{3}.*\n*\+{3}.*\n*((.*\n*)+)/; export const NEW_FILE_INFO = /diff --git a\/(\S+) b\/(\S+).*\n*new file mode .*\nindex.*\n*-{3}.*\n*\+{3}.*\n*((.*\n*)+)/; @@ -53,44 +52,47 @@ export function* parseDiffHunk(diffHunkPatch: string): IterableIterator= lineInPRDiff) { - positionInDiffHunk = lineInPRDiff - diffHunk.newLineNumber + diffHunk.diffLine + 1; + positionInDiffHunk = lineInPRDiff - diffHunk.newLineNumber + diffHunk.positionInHunk + 1; break; } @@ -209,13 +212,13 @@ async function parseModifiedHunkComplete(originalContent, patch, a, b) { lastCommonLine = oriStartLine + diffHunk.oldLength - 1; - for (let j = 0; j < diffHunk.Lines.length; j++) { - let diffLine = diffHunk.Lines[j]; + for (let j = 0; j < diffHunk.diffLines.length; j++) { + let diffLine = diffHunk.diffLines[j]; if (diffLine.type === DiffChangeType.Delete) { } else if (diffLine.type === DiffChangeType.Add) { - right.push(diffLine.content.substr(1)); + right.push(diffLine.text); } else { - let codeInFirstLine = diffLine.content.substr(1); + let codeInFirstLine = diffLine.text; right.push(codeInFirstLine); } } diff --git a/extensions/git-extended/src/common/log.ts b/extensions/git-extended/src/common/log.ts deleted file mode 100644 index 5a03d7a3ead6587951f02d853d055de93cd802ca..0000000000000000000000000000000000000000 --- a/extensions/git-extended/src/common/log.ts +++ /dev/null @@ -1,86 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/* -------------------------------------------------------------------------------------------- - * Includes code from github/desktop, obtained from - * https://github.com/desktop/desktop/blob/0ce0de27f4eff526c073a159ce36ac9b27407e5c/app/src/lib/git/log.ts - * ------------------------------------------------------------------------------------------ */ -import { Repository } from './models/repository'; -import { Commit } from './models/commit'; -import { GitProcess } from 'dugite'; - -export async function getParentCommit(repository: Repository, sha: string): Promise { - const result = await GitProcess.exec( - [ - 'rev-list', - '--parents', - '-n', - '1', - sha - ], - repository.path - ); - let commits = result.stdout.split(' '); - if (commits.length > 2) { - return commits[1]; - } else { - return null; - } -} - - - -export async function getCommits(repository: Repository, revisionRange: string, limit: number, additionalArgs: ReadonlyArray = []): Promise { - const delimiter = '1F'; - const delimiterString = String.fromCharCode(parseInt(delimiter, 16)); - const prettyFormat = [ - '%H', // SHA - '%s', // summary - '%P', // parent SHAs - ].join(`%x${delimiter}`); - - const result = await GitProcess.exec([ - 'log', - revisionRange, - `--max-count=${limit}`, - `--pretty=${prettyFormat}`, - '-z', - ...additionalArgs, - ], repository.path); - - const out = result.stdout; - const lines = out.split('\0'); - lines.splice(-1, 1); - - const commits = lines.map(line => { - const pieces = line.split(delimiterString); - const sha = pieces[0]; - const summary = pieces[1]; - const shaList = pieces[2]; - const parentSHAs = shaList.length ? shaList.split(' ') : []; - - return new Commit(sha, summary, parentSHAs); - }); - - return commits; -} - -export async function isWorkingTreeClean(repository: Repository): Promise { - const result = await GitProcess.exec( - [ - 'diff-index', - '--quiet', - 'HEAD', - '--' - ], - repository.path - ); - let exitCode = result.exitCode; - if (exitCode !== 0) { - return false; - } else { - return true; - } -} \ No newline at end of file diff --git a/extensions/git-extended/src/common/models/diffHunk.ts b/extensions/git-extended/src/common/models/diffHunk.ts index e2faf9537d59d2ada8b4f5f7d5d92fafe75be96f..3bc398c64929909be4e8912f8da4bb0e4f062242 100644 --- a/extensions/git-extended/src/common/models/diffHunk.ts +++ b/extensions/git-extended/src/common/models/diffHunk.ts @@ -3,16 +3,51 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { DiffLine } from './diffLine'; +export enum DiffChangeType { + Context, + Add, + Delete, + Control +} + +export class DiffLine { + public get raw(): string { + return this._raw; + } + + public get text(): string { + return this._raw.substr(1); + } + + public endwithLineBreak: boolean = true; + + constructor( + public type: DiffChangeType, + public oldLineNumber: number, /* 1 based */ + public newLineNumber: number, /* 1 based */ + public positionInHunk: number, + private _raw: string + ) { } +} + +export function getDiffChangeType(text: string) { + let c = text[0]; + switch (c) { + case ' ': return DiffChangeType.Context; + case '+': return DiffChangeType.Add; + case '-': return DiffChangeType.Delete; + case '\\': return DiffChangeType.Control; + } +} export class DiffHunk { - public Lines: DiffLine[] = []; + public diffLines: DiffLine[] = []; constructor( public oldLineNumber: number, public oldLength: number, public newLineNumber: number, public newLength: number, - public diffLine: number + public positionInHunk: number ) { } } \ No newline at end of file diff --git a/extensions/git-extended/src/common/models/diffLine.ts b/extensions/git-extended/src/common/models/diffLine.ts deleted file mode 100644 index efeb92d4e6a3698f3328034ff69a1a8459389a78..0000000000000000000000000000000000000000 --- a/extensions/git-extended/src/common/models/diffLine.ts +++ /dev/null @@ -1,51 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/* -------------------------------------------------------------------------------------------- - * Includes code from github/VisualStudio project, obtained from - * https://github.com/github/VisualStudio/blob/master/src/GitHub.Exports/Models/DiffLine.cs - * ------------------------------------------------------------------------------------------ */ - -export enum DiffChangeType { - None, - Add, - Delete, - Control -} - -export class DiffLine { - // Was the line added, deleted or unchanged. - type: DiffChangeType; - - // Gets the old 1-based line number. - oldLineNumber: number = -1; - - // Gets the new 1-based line number. - newLineNumber: number = -1; - - // Gets the unified diff line number where the first chunk header is line 0. - diffLineNumber: number = -1; - - // Gets the content of the diff line (including +, - or space). - content: String; - - constructor(type: DiffChangeType, oldLineNumber: number, newLineNumber: number, diffLineNumber: number, content: string) { - this.type = type; - this.oldLineNumber = oldLineNumber; - this.newLineNumber = newLineNumber; - this.diffLineNumber = diffLineNumber; - this.content = content; - } -} - -export function getDiffChangeType(text: string) { - let c = text[0]; - switch (c) { - case ' ': return DiffChangeType.None; - case '+': return DiffChangeType.Add; - case '-': return DiffChangeType.Delete; - case '\\': return DiffChangeType.Control; - } -} \ No newline at end of file diff --git a/extensions/git-extended/src/common/models/gitReferenceModel.ts b/extensions/git-extended/src/common/models/githubRef.ts similarity index 72% rename from extensions/git-extended/src/common/models/gitReferenceModel.ts rename to extensions/git-extended/src/common/models/githubRef.ts index f942df9367c721448d8d0208cdc8e05c2fa048d8..0dc33c6c56cff769ca30736e6b33a5eb9da9e13d 100644 --- a/extensions/git-extended/src/common/models/gitReferenceModel.ts +++ b/extensions/git-extended/src/common/models/githubRef.ts @@ -3,16 +3,16 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { UriString } from './uriString'; +import { Protocol } from './protocol'; -export class GitReferenceModel { - public repositoryCloneUrl: UriString; +export class GitHubRef { + public repositoryCloneUrl: Protocol; constructor( public ref: string, public label: string, public sha: string, repositoryCloneUrl: string ) { - this.repositoryCloneUrl = new UriString(repositoryCloneUrl); + this.repositoryCloneUrl = new Protocol(repositoryCloneUrl); } -} \ No newline at end of file +} diff --git a/extensions/git-extended/src/common/models/model.ts b/extensions/git-extended/src/common/models/model.ts deleted file mode 100644 index d69cfbe65723e782d468f7c75bdebc1be67e2dd9..0000000000000000000000000000000000000000 --- a/extensions/git-extended/src/common/models/model.ts +++ /dev/null @@ -1,11 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Repository } from './repository'; -export class Model { - constructor(repository: Repository) { - - } -} \ No newline at end of file diff --git a/extensions/git-extended/src/common/models/protocol.ts b/extensions/git-extended/src/common/models/protocol.ts new file mode 100644 index 0000000000000000000000000000000000000000..4279cb7f96d285b330effd20e54b82f75ef0b2f6 --- /dev/null +++ b/extensions/git-extended/src/common/models/protocol.ts @@ -0,0 +1,138 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; + +export enum ProtocolType { + Local, + HTTP, + SSH, + GIT, + OTHER +} + +const gitProtocolRegex = [ + new RegExp('^git@(.+):(.+)/(.+?)(?:/|.git)?$'), + new RegExp('^git:(.+)/(.+)/(.+?)(?:/|.git)?$') +]; +const sshProtocolRegex = [ + new RegExp('^ssh://git@(.+)/(.+)/(.+?)(?:/|.git)?$') +]; + +export class Protocol { + public type: ProtocolType = ProtocolType.OTHER; + public host: string = ''; + + public owner: string = ''; + + public repositoryName: string = ''; + + public get nameWithOwner(): string { + return this.owner ? `${this.owner}/${this.repositoryName}` : this.repositoryName; + } + + public isFileUri: boolean; + + public isScpUri: boolean; + + public isValidUri: boolean; + + public readonly url: vscode.Uri; + constructor( + uriString: string + ) { + try { + this.url = vscode.Uri.parse(uriString); + + if (this.url.scheme === 'file') { + this.type = ProtocolType.Local; + this.repositoryName = this.getRepositoryName(this.url.path); + return; + } + + if (this.url.scheme === 'https' || this.url.scheme === 'http') { + this.type = ProtocolType.HTTP; + this.host = this.url.authority; + this.repositoryName = this.getRepositoryName(this.url.path); + this.owner = this.getOwnerName(this.url.path); + return; + } + } catch (e) { } + + try { + for (const regex of gitProtocolRegex) { + const result = uriString.match(regex); + if (!result) { + continue; + } + + this.host = result[1]; + this.owner = result[2]; + this.repositoryName = result[3]; + this.type = ProtocolType.GIT; + return; + } + + for (const regex of sshProtocolRegex) { + const result = uriString.match(regex); + if (!result) { + continue; + } + + this.host = result[1]; + this.owner = result[2]; + this.repositoryName = result[3]; + this.type = ProtocolType.SSH; + return; + } + } catch (e) { } + } + + getRepositoryName(path: string) { + let normalized = path.replace('\\', '/'); + let lastIndex = normalized.lastIndexOf('/'); + let lastSegment = normalized.substr(lastIndex + 1); + if (lastSegment === '' || lastSegment === '/') { + return null; + } + + return lastSegment.replace(/\/$/, '').replace(/\.git$/, ''); + } + + getOwnerName(path: string) { + let normalized = path.replace('\\', '/'); + let fragments = normalized.split('/'); + if (fragments.length > 1) { + return fragments[fragments.length - 2]; + } + + return null; + } + + normalizeUri(): vscode.Uri { + if (this.type === ProtocolType.OTHER && !this.url) { + return null; + } + + if (this.isFileUri) { + return this.url; + } + + let scheme = 'https'; + if (this.url && (this.url.scheme === 'http' || this.url.scheme === 'https')) { + scheme = this.url.scheme; + } + + try { + return vscode.Uri.parse(`${scheme}://${this.host}/${this.nameWithOwner}`); + } catch (e) { + return null; + } + } + + equals(other: Protocol) { + return this.normalizeUri().toString().toLocaleLowerCase() === other.normalizeUri().toString().toLocaleLowerCase(); + } +} \ No newline at end of file diff --git a/extensions/git-extended/src/common/models/pullRequestModel.ts b/extensions/git-extended/src/common/models/pullRequestModel.ts index adc94d0fb08f3aa83257033fe6a35951d1d77e63..81d1ce55c8d5cdff28b7243679b77946d00e8abe 100644 --- a/extensions/git-extended/src/common/models/pullRequestModel.ts +++ b/extensions/git-extended/src/common/models/pullRequestModel.ts @@ -7,7 +7,7 @@ import { Remote } from './remote'; import { parseComments } from '../comment'; import { Comment } from './comment'; import { IAccount } from './account'; -import { GitReferenceModel } from './gitReferenceModel'; +import { GitHubRef } from './githubRef'; export enum PRType { RequestReview = 0, @@ -43,8 +43,8 @@ export class PullRequestModel { return this.state === PullRequestStateEnum.Merged; } - public head: GitReferenceModel; - public base: GitReferenceModel; + public head: GitHubRef; + public base: GitHubRef; constructor(public readonly otcokit: any, public readonly remote: Remote, public prItem: any) { this.prNumber = prItem.number; @@ -81,14 +81,14 @@ export class PullRequestModel { this.commentCount = prItem.comments; this.commitCount = prItem.commits; - this.head = new GitReferenceModel(prItem.head.ref, prItem.head.label, prItem.head.sha, prItem.head.repo.clone_url); - this.base = new GitReferenceModel(prItem.base.ref, prItem.base.label, prItem.base.sha, prItem.base.repo.clone_url); + this.head = new GitHubRef(prItem.head.ref, prItem.head.label, prItem.head.sha, prItem.head.repo.clone_url); + this.base = new GitHubRef(prItem.base.ref, prItem.base.label, prItem.base.sha, prItem.base.repo.clone_url); } async getFiles() { const { data } = await this.otcokit.pullRequests.getFiles({ owner: this.remote.owner, - repo: this.remote.name, + repo: this.remote.repositoryName, number: this.prItem.number }); @@ -100,7 +100,7 @@ export class PullRequestModel { // this one is from search results, which is not complete. const { data } = await this.otcokit.pullRequests.get({ owner: this.remote.owner, - repo: this.remote.name, + repo: this.remote.repositoryName, number: this.prItem.number }); this.prItem = data; @@ -112,7 +112,7 @@ export class PullRequestModel { async getComments(): Promise { const reviewData = await this.otcokit.pullRequests.getComments({ owner: this.remote.owner, - repo: this.remote.name, + repo: this.remote.repositoryName, number: this.prItem.number, per_page: 100 }); @@ -123,7 +123,7 @@ export class PullRequestModel { async createCommentReply(body: string, reply_to: string) { let ret = await this.otcokit.pullRequests.createCommentReply({ owner: this.remote.owner, - repo: this.remote.name, + repo: this.remote.repositoryName, number: this.prItem.number, body: body, in_reply_to: reply_to @@ -135,7 +135,7 @@ export class PullRequestModel { async createComment(body: string, path: string, position: number) { let ret = await this.otcokit.pullRequests.createComment({ owner: this.remote.owner, - repo: this.remote.name, + repo: this.remote.repositoryName, number: this.prItem.number, body: body, commit_id: this.prItem.head.sha, diff --git a/extensions/git-extended/src/common/models/remote.ts b/extensions/git-extended/src/common/models/remote.ts index 84a23ecfeb94a724c7e458f64a9a1ad480bcd6a5..d36d73e3e870d29fb731f82d09406a733ef93a2f 100644 --- a/extensions/git-extended/src/common/models/remote.ts +++ b/extensions/git-extended/src/common/models/remote.ts @@ -3,29 +3,35 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { Protocol } from './protocol'; export class Remote { + public get host(): string { + return this.gitProtocol.host; + } + public get owner(): string { + return this.gitProtocol.owner; + } + public get repositoryName(): string { + return this.gitProtocol.repositoryName; + } + constructor( public readonly remoteName: string, public readonly url: string, - public readonly hostname: string, - public readonly owner: string, - public readonly name: string + public readonly gitProtocol: Protocol, ) { } equals(remote: Remote): boolean { if (this.remoteName !== remote.remoteName) { return false; } - // if (this.url !== remote.url) { - // return false; - // } - if (this.hostname !== remote.hostname) { + if (this.host !== remote.host) { return false; } if (this.owner !== remote.owner) { return false; } - if (this.name !== remote.name) { + if (this.repositoryName !== remote.repositoryName) { return false; } diff --git a/extensions/git-extended/src/common/models/repository.ts b/extensions/git-extended/src/common/models/repository.ts index 78285b9b99a162704d68a479778b68c922906922..b7c7ae0ad44700dd37a89400058d8a8e6e4b8e8f 100644 --- a/extensions/git-extended/src/common/models/repository.ts +++ b/extensions/git-extended/src/common/models/repository.ts @@ -7,11 +7,11 @@ import * as vscode from 'vscode'; import { Remote } from './remote'; import { GitProcess } from 'dugite'; import { uniqBy, anyEvent, filterEvent, isDescendant } from '../util'; -import { parseRemote } from '../remote'; import { CredentialStore } from '../../credentials'; import { PullRequestModel, PRType } from './pullRequestModel'; -import { UriString } from './uriString'; +import { Protocol } from './protocol'; import { GitError, GitErrorCodes } from './gitError'; +import { PullRequestGitHelper } from '../pullRequestGitHelper'; export enum RefType { Head, @@ -43,7 +43,7 @@ export class Repository { private _onDidRunGitStatus = new vscode.EventEmitter(); readonly onDidRunGitStatus: vscode.Event = this._onDidRunGitStatus.event; - public githubRepositories?: GitHubRepository[]; + public githubRepositories?: GitHubRepository[] = []; private _HEAD: Branch | undefined; get HEAD(): Branch | undefined { @@ -61,8 +61,8 @@ export class Repository { } // todo - private _cloneUrl: UriString; - get cloneUrl(): UriString { + private _cloneUrl: Protocol; + get cloneUrl(): Protocol { return this._cloneUrl; } @@ -116,7 +116,7 @@ export class Repository { if (this._HEAD.upstream && this._HEAD.upstream.remote) { let currentRemote = this._remotes.filter(remote => remote.remoteName === this._HEAD.upstream.remote); if (currentRemote && currentRemote.length) { - this._cloneUrl = new UriString(currentRemote[0].url); + this._cloneUrl = new Protocol(currentRemote[0].url); } } @@ -126,7 +126,13 @@ export class Repository { async connectGitHub(credentialStore: CredentialStore) { let ret: GitHubRepository[] = []; await Promise.all(this.remotes.map(async remote => { + let isRemoteForPR = await PullRequestGitHelper.isRemoteCreatedForPullRequest(this, remote.remoteName); + if (isRemoteForPR) { + return; + } + let octo = await credentialStore.getOctokit(remote); + if (octo) { ret.push(new GitHubRepository(remote, octo)); } @@ -135,12 +141,12 @@ export class Repository { this.githubRepositories = ret; } - async fetch(remoteName: string, branch: string) { + async fetch(remoteName: string, branch?: string) { const result = await GitProcess.exec( [ 'fetch', remoteName, - branch + branch ? branch : '' ], this.path ); @@ -375,7 +381,7 @@ export class GitHubRepository { if (prType === PRType.All) { let result = await this.octokit.pullRequests.getAll({ owner: this.remote.owner, - repo: this.remote.name, + repo: this.remote.repositoryName, }); let ret = result.data.map(item => { if (!item.head.repo) { @@ -389,7 +395,7 @@ export class GitHubRepository { const user = await this.octokit.users.get(); const { data } = await this.octokit.search.issues({ - q: this.getPRFetchQuery(this.remote.owner, this.remote.name, user.data.login, prType) + q: this.getPRFetchQuery(this.remote.owner, this.remote.repositoryName, user.data.login, prType) }); let promises = []; @@ -397,7 +403,7 @@ export class GitHubRepository { promises.push(new Promise(async (resolve, reject) => { let prData = await this.octokit.pullRequests.get({ owner: this.remote.owner, - repo: this.remote.name, + repo: this.remote.repositoryName, number: item.number }); resolve(prData); @@ -419,7 +425,7 @@ export class GitHubRepository { async getPullRequest(id: number) { let { data } = await this.octokit.pullRequests.get({ owner: this.remote.owner, - repo: this.remote.name, + repo: this.remote.repositoryName, number: id }); if (!data.head.repo) { @@ -453,4 +459,14 @@ export class GitHubRepository { return `is:open ${filter} type:pr repo:${owner}/${repo}`; } -} \ No newline at end of file +} + +function parseRemote(remoteName: string, url: string): Remote | null { + let gitProtocol = new Protocol(url); + + if (gitProtocol.host) { + return new Remote(remoteName, url, gitProtocol); + } + + return null; +} diff --git a/extensions/git-extended/src/common/models/uriString.ts b/extensions/git-extended/src/common/models/uriString.ts deleted file mode 100644 index 73076cebfb683789eed8ffc97f341b45a32d35ee..0000000000000000000000000000000000000000 --- a/extensions/git-extended/src/common/models/uriString.ts +++ /dev/null @@ -1,128 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/* -------------------------------------------------------------------------------------------- - * Includes code from github/VisualStudio project, obtained from - * https://github.com/github/VisualStudio/blob/master/src/GitHub.Exports/Primitives/UriString.cs - * ------------------------------------------------------------------------------------------ */ - -import * as vscode from 'vscode'; - -const sshRegex = /^.+@(([.*?]|[a-z0-9-.]+?))(:(.*?))?(\/(.*)(.git)?)?$/i; -export class UriString { - public host: string; - - public owner: string; - - public repositoryName: string; - - public nameWithOwner: string; - - public isFileUri: boolean; - - public isScpUri: boolean; - - public isValidUri: boolean; - - public readonly url: vscode.Uri; - constructor( - uriString: string - ) { - let parseUriSuccess = false; - try { - this.url = vscode.Uri.parse(uriString); - - parseUriSuccess = true; - if (this.url.scheme === 'file') { - this.setFilePath(this.url); - } else { - this.setUri(this.url); - } - } catch (e) { } - - if (!parseUriSuccess) { - try { - let matches = sshRegex.exec(uriString); - - if (matches) { - this.host = matches[1]; - this.owner = matches[4]; - this.repositoryName = this.getRepositoryName(matches[6]); - this.isScpUri = true; - } else { - this.setFilePath2(uriString); - } - } catch (e) { } - } - - if (this.repositoryName) { - this.nameWithOwner = this.owner ? `${this.owner}/${this.repositoryName}` : this.repositoryName; - } - } - - setUri(uri: vscode.Uri) { - this.host = uri.authority; - this.repositoryName = this.getRepositoryName(uri.path); - this.owner = this.getOwnerName(uri.path); - } - - setFilePath(uri: vscode.Uri) { - this.host = ''; - this.owner = ''; - this.repositoryName = this.getRepositoryName(uri.path); - this.isFileUri = true; - } - - setFilePath2(path: string) { - this.host = ''; - this.owner = ''; - this.repositoryName = this.getRepositoryName(path); - this.isFileUri = true; - } - - getRepositoryName(path: string) { - let normalized = path.replace('\\', '/'); - let lastIndex = normalized.lastIndexOf('/'); - let lastSegment = normalized.substr(lastIndex + 1); - if (lastSegment === '' || lastSegment === '/') { - return null; - } - - return lastSegment.replace(/\/$/, '').replace(/\.git$/, ''); - } - - getOwnerName(path: string) { - let normalized = path.replace('\\', '/'); - let fragments = normalized.split('/'); - if (fragments.length > 1) { - return fragments[fragments.length - 2]; - } - - return null; - } - - toRepositoryUrl(owner: string = null): vscode.Uri { - if (!this.isScpUri && (!this.url || this.isFileUri)) { - return this.url; - } - - let scheme = 'https'; - if (this.url && (this.url.scheme === 'http' || this.url.scheme === 'https')) { - scheme = this.url.scheme; - } - - let nameWithOwner = this.owner ? `${this.owner}/${this.repositoryName}` : this.repositoryName; - - try { - return vscode.Uri.parse(`${scheme}://${this.host}/${nameWithOwner}`); - } catch (e) { - return null; - } - } - - equals(other: UriString) { - return this.toRepositoryUrl().toString().toLocaleLowerCase() === other.toRepositoryUrl().toString().toLocaleLowerCase(); - } -} \ No newline at end of file diff --git a/extensions/git-extended/src/common/pullRequestGitHelper.ts b/extensions/git-extended/src/common/pullRequestGitHelper.ts index a2a75f57dff6eb14b3b8d5b9034254a7dee8edae..ecd0ad971408ab338d78a5c0601c5e0dbbea5e36 100644 --- a/extensions/git-extended/src/common/pullRequestGitHelper.ts +++ b/extensions/git-extended/src/common/pullRequestGitHelper.ts @@ -3,143 +3,110 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -/* -------------------------------------------------------------------------------------------- - * Includes code from github/VisualStudio project, obtained from - * https://github.com/github/VisualStudio/blob/master/src/GitHub.App/Services/PullRequestService.cs - * ------------------------------------------------------------------------------------------ */ - import { Repository } from '../common/models/repository'; import { PullRequestModel } from '../common/models/pullRequestModel'; -import { UriString } from '../common/models/uriString'; +import { Protocol } from '../common/models/protocol'; +import { Remote } from './models/remote'; -const InvalidBranchCharsRegex = /[^0-9A-Za-z\-]/g; -const SettingCreatedByGHfVSC = 'created-by-ghfvsc'; -const SettingGHfVSCPullRequest = 'ghfvs-pr-owner-number'; -const BranchCapture = /branch\.(.+)\.ghfvsc-pr/; +const PullRequestRemoteMetadataKey = 'github-pr-remote'; +const PullRequestMetadataKey = 'github-pr-owner-number'; +const PullRequestBranchRegex = /branch\.(.+)\.github-pr-owner-number/; export class PullRequestGitHelper { - static async checkout(repository: Repository, pullRequest: PullRequestModel, localBranchName: string) { + static async createAndCheckout(repository: Repository, pullRequest: PullRequestModel) { + let localBranchName = await PullRequestGitHelper.getBranchNameForPullRequest(repository, pullRequest); + let existing = await repository.getBranch(localBranchName); if (existing) { + // already exist await repository.checkout(localBranchName); } else if (repository.cloneUrl.equals(pullRequest.head.repositoryCloneUrl)) { + // branch from the same repository await repository.fetch('origin', localBranchName); await repository.checkout(localBranchName); } else { - // nothing matches - let refSpec = `${pullRequest.head.ref}:${localBranchName}`; + // the branch is from a fork + // create remote for this fork let remoteName = await PullRequestGitHelper.createRemote(repository, pullRequest.head.repositoryCloneUrl); - - await repository.fetch(remoteName, refSpec); + // fetch the branch + let ref = `${pullRequest.head.ref}:${localBranchName}`; + await repository.fetch(remoteName, ref); await repository.checkout(localBranchName); + // set remote tracking branch for the local branch await repository.setTrackingBranch(localBranchName, `refs/remotes/${remoteName}/${pullRequest.head.ref}`); } - var prConfigKey = `branch.${localBranchName}.${SettingGHfVSCPullRequest}`; - await repository.setConfig(prConfigKey, PullRequestGitHelper.buildGHfVSConfigKeyValue(pullRequest)); + let prConfigKey = `branch.${localBranchName}.${PullRequestMetadataKey}`; + await repository.setConfig(prConfigKey, PullRequestGitHelper.buildPullRequestMetadata(pullRequest)); } - static async getLocalBranchesForPullRequest(repository: Repository, pullRequest: PullRequestModel): Promise { - if (PullRequestGitHelper.isPullRequestFromRepository(repository, pullRequest)) { - return [pullRequest.head.ref]; - } else { - let key = PullRequestGitHelper.buildGHfVSConfigKeyValue(pullRequest); + static async checkout(repository: Repository, remote: Remote, branchName: string, pullRequest: PullRequestModel): Promise { + let remoteName = remote.remoteName; + await repository.fetch(remoteName); + let branch = await repository.getBranch(branchName); - let configs = await repository.getConfigs(); - - return configs.map(config => { - let matches = BranchCapture.exec(config.key); - if (matches && matches.length) { - return { - branch: matches[1], - value: config.value - }; - } else { - return { - branch: null, - value: config.value - }; - } - }).filter(c => c.branch && c.value === key).map(c => c.value); + if (!branch) { + await PullRequestGitHelper.fetchAndCreateBranch(repository, remote, branchName, pullRequest); } - } - static async switchToBranch(repository: Repository, pullRequest: PullRequestModel): Promise { - let matchingBranches = await PullRequestGitHelper.getLocalBranchesForPullRequest(repository, pullRequest); - if (matchingBranches && matchingBranches.length) { - let branchName = matchingBranches[0]; - let remoteName = repository.HEAD.upstream.remote; + await repository.checkout(branchName); + await PullRequestGitHelper.markBranchAsPullRequest(repository, pullRequest, branchName); + } - if (!remoteName) { - return; - } + static async getBranchForPullRequestFromExistingRemotes(repository: Repository, pullRequest: PullRequestModel) { + let headRemote = PullRequestGitHelper.getHeadRemoteForPullRequest(repository, pullRequest); + if (headRemote) { + // the head of the PR is in this repository (not fork), we can just fetch + return { + remote: headRemote, + branch: pullRequest.head.ref + }; + } else { + let key = PullRequestGitHelper.buildPullRequestMetadata(pullRequest); + let configs = await repository.getConfigs(); - await repository.fetch(remoteName, branchName); - let branch = null; - try { - branch = await repository.getBranch(branchName); - } catch (e) { } - - if (!branch) { - const trackedBranchName = `refs/remotes/${remoteName}/${branchName}`; - const trackedBranch = await repository.getBranch(trackedBranchName); - - if (trackedBranch) { - // create branch - await repository.createBranch(branchName, trackedBranch.commit); - await repository.setTrackingBranch(branchName, trackedBranchName); - } else { - throw new Error(`Could not find branch '${trackedBranchName}'.`); + let branchInfos = configs.map(config => { + let matches = PullRequestBranchRegex.exec(config.key); + return { + branch: matches && matches.length ? matches[1] : null, + value: config.value + }; + }).filter(c => c.branch && c.value === key); + + if (branchInfos && branchInfos.length) { + let remoteName = await repository.getConfig(`branch.${branchInfos[0].branch}.remote`); + let headRemote = repository.remotes.filter(remote => remote.remoteName === remoteName); + if (headRemote && headRemote.length) { + return { + remote: headRemote[0], + branch: branchInfos[0].branch + }; } } - await repository.checkout(branchName); - await PullRequestGitHelper.markBranchAsPullRequest(repository, pullRequest, branchName); + return null; } } - static async getDefaultLocalBranchName(repository: Repository, pullRequestNumber: number, pullRequestTitle: string): Promise { - let initial = 'pr/' + pullRequestNumber + '-' + PullRequestGitHelper.getSafeBranchName(pullRequestTitle); - let current = initial; - let index = 2; + static async fetchAndCreateBranch(repository: Repository, remote: Remote, branchName: string, pullRequest: PullRequestModel) { + let remoteName = remote.remoteName; + const trackedBranchName = `refs/remotes/${remoteName}/${branchName}`; + const trackedBranch = await repository.getBranch(trackedBranchName); - while (true) { - let currentBranch = await repository.getBranch(current); - - if (currentBranch) { - current = initial + '-' + index++; - } else { - break; - } - } - - return current.replace(/-*$/g, ''); - } - - static async getPullRequestForCurrentBranch(repository: Repository) { - let configKey = `branch.${repository.HEAD.name}.${SettingGHfVSCPullRequest}`; - let configValue = await repository.getConfig(configKey); - return PullRequestGitHelper.parseGHfVSConfigKeyValue(configValue); - } - - static getSafeBranchName(name: string): string { - let before = name.replace(InvalidBranchCharsRegex, '-').replace(/-*$/g, ''); - - for (; ;) { - let after = before.replace('--', '-'); - - if (after === before) { - return before.toLocaleLowerCase(); - } - - before = after; + if (trackedBranch) { + // create branch + await repository.createBranch(branchName, trackedBranch.commit); + await repository.setTrackingBranch(branchName, trackedBranchName); + } else { + throw new Error(`Could not find branch '${trackedBranchName}'.`); } } - static buildGHfVSConfigKeyValue(pullRequest: PullRequestModel) { + static buildPullRequestMetadata(pullRequest: PullRequestModel) { return pullRequest.base.repositoryCloneUrl.owner + '#' + pullRequest.prNumber; } - static parseGHfVSConfigKeyValue(value: string) { + + static parsePullRequestMetadata(value: string) { if (value) { let separator = value.indexOf('#'); if (separator !== -1) { @@ -158,23 +125,29 @@ export class PullRequestGitHelper { return null; } - static async createRemote(repository: Repository, cloneUrl: UriString) { + static async getMatchingPullRequestMetadataForBranch(repository: Repository, branchName: string) { + let configKey = `branch.${branchName}.${PullRequestMetadataKey}`; + let configValue = await repository.getConfig(configKey); + return PullRequestGitHelper.parsePullRequestMetadata(configValue); + } + + static async createRemote(repository: Repository, cloneUrl: Protocol) { let remotes = repository.remotes; remotes.forEach(remote => { - if (new UriString(remote.url).equals(cloneUrl)) { - return remote.name; + if (new Protocol(remote.url).equals(cloneUrl)) { + return remote.repositoryName; } }); - var remoteName = PullRequestGitHelper.createUniqueRemoteName(repository, cloneUrl.owner); - await repository.addRemote(remoteName, cloneUrl.toRepositoryUrl().toString()); - await repository.setConfig(`remote.${remoteName}.${SettingCreatedByGHfVSC}`, 'true'); + let remoteName = PullRequestGitHelper.getUniqueRemoteName(repository, cloneUrl.owner); + await repository.addRemote(remoteName, cloneUrl.normalizeUri().toString()); + await repository.setConfig(`remote.${remoteName}.${PullRequestRemoteMetadataKey}`, 'true'); return remoteName; } static async isRemoteCreatedForPullRequest(repository: Repository, remoteName: string) { - let isForPR = await repository.getConfig(`remote.${remoteName}.${SettingCreatedByGHfVSC}`); + let isForPR = await repository.getConfig(`remote.${remoteName}.${PullRequestRemoteMetadataKey}`); if (isForPR === 'true') { return true; @@ -183,33 +156,50 @@ export class PullRequestGitHelper { } } - static createUniqueRemoteName(repository: Repository, name: string) { - { - var uniqueName = name; - var number = 1; + static async getBranchNameForPullRequest(repository: Repository, pullRequest: PullRequestModel): Promise { + let branchName = `pr/${pullRequest.author.login}/${pullRequest.prNumber}`; + let result = branchName; + let number = 1; - while (repository.remotes.find(e => e.remoteName === uniqueName)) { - uniqueName = name + number++; - } + while (true) { + let currentBranch = await repository.getBranch(result); - return uniqueName; + if (currentBranch) { + result = branchName + '-' + number++; + } else { + break; + } } + + return result; } - static isPullRequestFromRepository(repository: Repository, pullRequest: PullRequestModel): boolean { - return repository.cloneUrl && repository.cloneUrl.equals(pullRequest.head.repositoryCloneUrl); + static getUniqueRemoteName(repository: Repository, name: string) { + let uniqueName = name; + let number = 1; + + while (repository.remotes.find(e => e.remoteName === uniqueName)) { + uniqueName = name + number++; + } + + return uniqueName; } - static async getPullRequestForBranch(repository: Repository, branchName: string) { - let prConfigKey = `branch.${branchName}.${SettingGHfVSCPullRequest}`; - let info = await repository.getConfig(prConfigKey); + static getHeadRemoteForPullRequest(repository: Repository, pullRequest: PullRequestModel): Remote { + let repos = repository.githubRepositories; + for (let i = 0; i < repos.length; i++) { + let remote = repos[i].remote; + if (remote.gitProtocol && remote.gitProtocol.equals(pullRequest.head.repositoryCloneUrl)) { + return remote; + } + } - return info; + return null; } static async markBranchAsPullRequest(repository: Repository, pullRequest: PullRequestModel, branchName: string) { - let prConfigKey = `branch.${branchName}.${SettingGHfVSCPullRequest}`; - await repository.setConfig(prConfigKey, PullRequestGitHelper.buildGHfVSConfigKeyValue(pullRequest)); + let prConfigKey = `branch.${branchName}.${PullRequestMetadataKey}`; + await repository.setConfig(prConfigKey, PullRequestGitHelper.buildPullRequestMetadata(pullRequest)); } static async getLocalBranchesMarkedAsPullRequest(repository: Repository) { @@ -217,9 +207,9 @@ export class PullRequestGitHelper { let ret = []; for (let i = 0; i < branches.length; i++) { - let localInfo = await PullRequestGitHelper.getPullRequestForBranch(repository, branches[i]); - if (localInfo) { - ret.push(PullRequestGitHelper.parseGHfVSConfigKeyValue(localInfo)); + let matchingPRMetadata = await PullRequestGitHelper.getMatchingPullRequestMetadataForBranch(repository, branches[i]); + if (matchingPRMetadata) { + ret.push(matchingPRMetadata); } } diff --git a/extensions/git-extended/src/common/remote.ts b/extensions/git-extended/src/common/remote.ts deleted file mode 100644 index 35f1b9f604250765475cb46d0f93f5e0e695ad84..0000000000000000000000000000000000000000 --- a/extensions/git-extended/src/common/remote.ts +++ /dev/null @@ -1,56 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/* -------------------------------------------------------------------------------------------- - * Includes code from github/desktop, obtained from - * https://github.com/desktop/desktop/blob/master/app/src/lib/git/remote.ts - * ------------------------------------------------------------------------------------------ */ -import { GitProcess } from 'dugite'; -import { Remote } from './models/remote'; - -export async function getRemotes( - path: string -) { - const result = await GitProcess.exec(['remote', '-v'], path); - const output = result.stdout; - const lines = output.split('\n'); - const remotes = lines - .filter(x => x.endsWith('(fetch)')) - .map(x => x.split(/\s+/)) - .map(x => ({ name: x[0], url: x[1] })); - - return remotes; -} - -/** Parse the remote information from URL. */ -export function parseRemote(remoteName: string, url: string): Remote | null { - // Examples: - // https://github.com/octocat/Hello-World.git - // https://github.com/octocat/Hello-World.git/ - // git@github.com:octocat/Hello-World.git - // git:github.com/octocat/Hello-World.git - const regexes = [ - new RegExp('^https?://(?:.+@)?(.+)/(.+)/(.+?)(?:/|.git/?)?$'), - new RegExp('^git@(.+):(.+)/(.+?)(?:/|.git)?$'), - new RegExp('^git:(.+)/(.+)/(.+?)(?:/|.git)?$'), - new RegExp('^ssh://git@(.+)/(.+)/(.+?)(?:/|.git)?$') - ]; - - for (const regex of regexes) { - const result = url.match(regex); - if (!result) { - continue; - } - - const hostname = result[1]; - const owner = result[2]; - const name = result[3]; - if (hostname) { - return new Remote(remoteName, url, hostname, owner, name); - } - } - - return null; -} diff --git a/extensions/git-extended/src/credentials.ts b/extensions/git-extended/src/credentials.ts index 937d75870a8a2f673c8327628d0cd3ba75afb0e8..6962bf8154b7383b6f1fa1d9e22a2179986ccbf7 100644 --- a/extensions/git-extended/src/credentials.ts +++ b/extensions/git-extended/src/credentials.ts @@ -21,7 +21,7 @@ export class CredentialStore { return this.octokits[remote.url]; } - if (this.configuration.host === remote.hostname && this.configuration.accessToken) { + if (this.configuration.host === remote.host && this.configuration.accessToken) { this.octokits[remote.url] = Octokit({}); this.octokits[remote.url].authenticate({ type: 'token', diff --git a/extensions/git-extended/src/prView/prProvider.ts b/extensions/git-extended/src/prView/prProvider.ts index dd0839a372e35a1858b0b5b7c9c5c39991539ea9..5aa87a246f6fae64811a34353f01d85027f4d59a 100644 --- a/extensions/git-extended/src/prView/prProvider.ts +++ b/extensions/git-extended/src/prView/prProvider.ts @@ -285,7 +285,7 @@ export class PRProvider implements vscode.TreeDataProvider { const reviewData = await element.otcokit.pullRequests.getComments({ owner: element.remote.owner, - repo: element.remote.name, + repo: element.remote.repositoryName, number: element.prItem.number, per_page: 100, }); diff --git a/extensions/git-extended/src/review/reviewManager.ts b/extensions/git-extended/src/review/reviewManager.ts index 513a20f018d63005d1ee9898c4aac3e038ff8e64..251c123db4e93ee76c452d2721a2f0790d9ad024 100644 --- a/extensions/git-extended/src/review/reviewManager.ts +++ b/extensions/git-extended/src/review/reviewManager.ts @@ -113,14 +113,14 @@ export class ReviewManager implements vscode.DecorationProvider { } private async validateState() { - let localInfo = await PullRequestGitHelper.getPullRequestForCurrentBranch(this._repository); + let matchingPullRequestMetadata = await PullRequestGitHelper.getMatchingPullRequestMetadataForBranch(this._repository, this._repository.HEAD.name); - if (!localInfo) { + if (!matchingPullRequestMetadata) { this.clear(true); return; } - if (this._prNumber === localInfo.prNumber) { + if (this._prNumber === matchingPullRequestMetadata.prNumber) { return; } @@ -138,9 +138,11 @@ export class ReviewManager implements vscode.DecorationProvider { // we switch to another PR, let's clean up first. this.clear(false); - this._prNumber = localInfo.prNumber; + this._prNumber = matchingPullRequestMetadata.prNumber; this._lastCommitSha = null; - let githubRepo = this._repository.githubRepositories.find(repo => repo.remote.owner.toLocaleLowerCase() === localInfo.owner.toLocaleLowerCase()); + let githubRepo = this._repository.githubRepositories.find(repo => + repo.remote.owner.toLocaleLowerCase() === matchingPullRequestMetadata.owner.toLocaleLowerCase() + ); if (!githubRepo) { return; // todo, should show warning @@ -487,14 +489,12 @@ export class ReviewManager implements vscode.DecorationProvider { this.statusBarItem.show(); try { - // todo, check if HEAD is dirty - let localBranches = await PullRequestGitHelper.getLocalBranchesForPullRequest(this._repository, pr); + let localBranchInfo = await PullRequestGitHelper.getBranchForPullRequestFromExistingRemotes(this._repository, pr); - if (localBranches.length > 0) { - await PullRequestGitHelper.switchToBranch(this._repository, pr); + if (localBranchInfo) { + await PullRequestGitHelper.checkout(this._repository, localBranchInfo.remote, localBranchInfo.branch, pr); } else { - let branchName = await PullRequestGitHelper.getDefaultLocalBranchName(this._repository, pr.prNumber, pr.title); - await PullRequestGitHelper.checkout(this._repository, pr, branchName); + await PullRequestGitHelper.createAndCheckout(this._repository, pr); } } catch (e) { if (e.gitErrorCode) {