Skip to content

Commit

Permalink
Allow a PR to be brought up to date with main and resolve conflicts (#…
Browse files Browse the repository at this point in the history
…5618)

* Allow a PR to be brought up to date with main and resolve conflicts
Fixes #1562, #200

* Use viewerCanUpdate instead of viewerCanUpdateBranch

* Refactor notification into separate class

* Use git to check if PR branch is up to date with base

* Clean up

* Move proposal
  • Loading branch information
alexr00 authored Jan 17, 2024
1 parent c4845b3 commit 5fffcbe
Show file tree
Hide file tree
Showing 20 changed files with 323 additions and 9 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"contribCommentEditorActionsMenu",
"shareProvider",
"quickDiffProvider",
"tabInputTextMerge",
"treeViewMarkdownMessage"
],
"version": "0.78.1",
Expand Down
2 changes: 2 additions & 0 deletions src/@types/git.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,8 @@ export interface Repository {
log(options?: LogOptions): Promise<Commit[]>;

commit(message: string, opts?: CommitOptions): Promise<void>;
merge(ref: string): Promise<void>;
mergeAbort(): Promise<void>;
}

export interface RemoteSource {
Expand Down
25 changes: 25 additions & 0 deletions src/@types/vscode.proposed.tabInputTextMerge.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

// https://github.com/microsoft/vscode/issues/153213

declare module 'vscode' {

export class TabInputTextMerge {

readonly base: Uri;
readonly input1: Uri;
readonly input2: Uri;
readonly result: Uri;

constructor(base: Uri, input1: Uri, input2: Uri, result: Uri);
}

export interface Tab {

readonly input: TabInputText | TabInputTextDiff | TabInputTextMerge | TabInputCustom | TabInputWebview | TabInputNotebook | TabInputNotebookDiff | TabInputTerminal | unknown;

}
}
2 changes: 2 additions & 0 deletions src/api/api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,8 @@ export interface Repository {

commit(message: string, opts?: CommitOptions): Promise<void>;
add(paths: string[]): Promise<void>;
merge(ref: string): Promise<void>;
mergeAbort(): Promise<void>;
}

/**
Expand Down
165 changes: 165 additions & 0 deletions src/github/conflictGuide.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
/*---------------------------------------------------------------------------------------------
* 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';
import { Change, Repository } from '../api/api';
import { commands } from '../common/executeCommands';
import { asPromise, dispose } from '../common/utils';

export class ConflictModel implements vscode.Disposable {
public readonly startingConflictsCount: number;
private _lastReportedRemainingCount: number;
private _disposables: vscode.Disposable[] = [];
private _onConflictCountChanged: vscode.EventEmitter<number> = new vscode.EventEmitter();
public readonly onConflictCountChanged: vscode.Event<number> = this._onConflictCountChanged.event; // reports difference in number of conflicts
private _finishedCommit: vscode.EventEmitter<boolean> = new vscode.EventEmitter();
public readonly message: string;

constructor(private readonly _repository: Repository, private readonly _upstream: string, private readonly _into: string) {
this.startingConflictsCount = this.remainingConflicts.length;
this._lastReportedRemainingCount = this.startingConflictsCount;
this._repository.inputBox.value = this.message = `Merge branch '${this._upstream}' into ${this._into}`;
this._watchForRemainingConflictsChange();
}

private _watchForRemainingConflictsChange() {
this._disposables.push(vscode.window.tabGroups.onDidChangeTabs(async (e) => {
if (e.closed.length > 0) {
await this._repository.status();
this._reportProgress();
}
}));
}

private _reportProgress() {
const remainingCount = this.remainingConflicts.length;
if (this._lastReportedRemainingCount !== remainingCount) {
this._onConflictCountChanged.fire(this._lastReportedRemainingCount - remainingCount);
this._lastReportedRemainingCount = remainingCount;
}
if (remainingCount === 0) {
this.listenForCommit();
}
}

private async listenForCommit() {
let localDisposable: vscode.Disposable | undefined;
const result = await new Promise<boolean>(resolve => {
const startingCommit = this._repository.state.HEAD?.commit;
localDisposable = this._repository.state.onDidChange(() => {
if (this._repository.state.HEAD?.commit !== startingCommit && this._repository.state.indexChanges.length === 0 && this._repository.state.mergeChanges.length === 0) {
resolve(true);
}
});
this._disposables.push(localDisposable);
});

localDisposable?.dispose();
this._finishedCommit.fire(result);
}

get remainingConflicts(): Change[] {
return this._repository.state.mergeChanges;
}

private async closeMergeEditors(): Promise<void> {
for (const group of vscode.window.tabGroups.all) {
for (const tab of group.tabs) {
if (tab.input instanceof vscode.TabInputTextMerge) {
vscode.window.tabGroups.close(tab);
}
}
}
}

public async abort(): Promise<void> {
this._repository.inputBox.value = '';
// set up an event to listen for when we are all out of merge changes before closing the merge editors.
// Just waiting for the merge doesn't cut it
// Even with this, we still need to wait 1 second, and then it still might say there are conflicts. Why is this?
const disposable = this._repository.state.onDidChange(async () => {
if (this._repository.state.mergeChanges.length === 0) {
await new Promise<void>(resolve => setTimeout(resolve, 1000));
this.closeMergeEditors();
disposable.dispose();
}
});
this._disposables.push(disposable);
await this._repository.mergeAbort();
this._finishedCommit.fire(false);
}

private async first(): Promise<void> {
if (this.remainingConflicts.length === 0) {
return;
}
await commands.focusView('workbench.scm');
this._reportProgress();
await Promise.all(this.remainingConflicts.map(conflict => commands.executeCommand('git.openMergeEditor', conflict.uri)));
}

public static async begin(repository: Repository, upstream: string, into: string): Promise<ConflictModel | undefined> {
const model = new ConflictModel(repository, upstream, into);
if (model.remainingConflicts.length === 0) {
return undefined;
}
const notification = new ConflictNotification(model, repository);
model._disposables.push(notification);
model.first();
return model;
}

public finished(): Promise<boolean> {
return asPromise(this._finishedCommit.event);
}

dispose() {
dispose(this._disposables);
}
}

class ConflictNotification implements vscode.Disposable {
private _disposables: vscode.Disposable[] = [];

constructor(private readonly _conflictModel: ConflictModel, private readonly _repository: Repository) {
vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, cancellable: true }, async (progress, token) => {
const report = (increment: number) => {
progress.report({ message: vscode.l10n.t('Use the Source Control view to resolve conflicts, {0} of {0} remaining', this._conflictModel.remainingConflicts.length, this._conflictModel.startingConflictsCount), increment });
};
report(0);
return new Promise<boolean>((resolve) => {
this._disposables.push(this._conflictModel.onConflictCountChanged((conflictsChangedBy) => {
const increment = conflictsChangedBy * (100 / this._conflictModel.startingConflictsCount);
report(increment);
if (this._conflictModel.remainingConflicts.length === 0) {
resolve(true);
}
}));
this._disposables.push(token.onCancellationRequested(() => {
this._conflictModel.abort();
resolve(false);
}));
});
}).then(async (result) => {
if (result) {
const commit = vscode.l10n.t('Commit');
const cancel = vscode.l10n.t('Abort Merge');
const result = await vscode.window.showInformationMessage(vscode.l10n.t('All conflicts resolved. Commit and push the resolution to continue.'), commit, cancel);
if (result === commit) {
await this._repository.commit(this._conflictModel.message);
await this._repository.push();
return true;
} else {
await this._conflictModel.abort();
return false;
}
}
});
}

dispose() {
dispose(this._disposables);
}
}
34 changes: 34 additions & 0 deletions src/github/folderRepositoryManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2132,6 +2132,40 @@ export class FolderRepositoryManager implements vscode.Disposable {
return this.repository.checkout(branchName);
}

async isHeadUpToDateWithBase(pullRequestModel: PullRequestModel): Promise<boolean> {
if (!pullRequestModel.head) {
return false;
}
let baseRemote: string | undefined;
let headRemote: string | undefined;
const targetRepo = pullRequestModel.base.repositoryCloneUrl.repositoryName.toLowerCase();
const targetBaseOwner = pullRequestModel.base?.owner.toLowerCase();
const targetHeadOwner = pullRequestModel.head?.owner.toLowerCase();
for (const remote of this.repository.state.remotes) {
if (baseRemote && headRemote) {
break;
}
const url = remote.fetchUrl ?? remote.pushUrl;
if (!url) {
return false;
}
const parsedRemote = parseRemote(remote.name, url);
const parsedOwner = parsedRemote?.owner.toLowerCase();
const parsedRepo = parsedRemote?.repositoryName.toLowerCase();
if (!baseRemote && parsedOwner === targetBaseOwner && parsedRepo === targetRepo) {
baseRemote = remote.name;
}
if (!headRemote && parsedOwner === targetHeadOwner && parsedRepo === targetRepo) {
headRemote = remote.name;
}
}
if (!baseRemote || !headRemote) {
return false;
}
const log = await this.repository.log({ range: `${headRemote}/${pullRequestModel.head.ref}..${baseRemote}/${pullRequestModel.base.ref}` });
return log.length === 0;
}

async fetchById(githubRepo: GitHubRepository, id: number): Promise<PullRequestModel | undefined> {
const pullRequest = await githubRepo.getPullRequest(id);
if (pullRequest) {
Expand Down
1 change: 1 addition & 0 deletions src/github/graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -603,6 +603,7 @@ export interface PullRequest {
};
viewerCanEnableAutoMerge: boolean;
viewerCanDisableAutoMerge: boolean;
viewerCanUpdate: boolean;
isDraft?: boolean;
suggestedReviewers: SuggestedReviewerResponse[];
projectItems?: {
Expand Down
1 change: 1 addition & 0 deletions src/github/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ export interface PullRequest extends Issue {
merged?: boolean;
mergeable?: PullRequestMergeability;
mergeQueueEntry?: MergeQueueEntry | null;
viewerCanUpdate: boolean;
autoMerge?: boolean;
autoMergeMethod?: MergeMethod;
allowAutoMerge?: boolean;
Expand Down
42 changes: 40 additions & 2 deletions src/github/pullRequestOverview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@
'use strict';

import * as vscode from 'vscode';
import { GitErrorCodes } from '../api/api1';
import { onDidUpdatePR, openPullRequestOnGitHub } from '../commands';
import { IComment } from '../common/comment';
import Logger from '../common/logger';
import { DEFAULT_MERGE_METHOD, PR_SETTINGS_NAMESPACE } from '../common/settingKeys';
import { ReviewEvent as CommonReviewEvent } from '../common/timelineEvent';
import { asPromise, dispose, formatError } from '../common/utils';
import { IRequestMessage, PULL_REQUEST_OVERVIEW_VIEW_TYPE } from '../common/webview';
import { ConflictModel } from './conflictGuide';
import { FolderRepositoryManager } from './folderRepositoryManager';
import {
GithubItemStateEnum,
Expand All @@ -23,6 +25,7 @@ import {
ITeam,
MergeMethod,
MergeMethodsAvailability,
PullRequestMergeability,
reviewerId,
ReviewEvent,
ReviewState,
Expand Down Expand Up @@ -190,7 +193,8 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel<PullRequestMode
this._folderRepositoryManager.getCurrentUser(pullRequestModel.githubRepository),
pullRequestModel.canEdit(),
this._folderRepositoryManager.getOrgTeamsCount(pullRequestModel.githubRepository),
this._folderRepositoryManager.mergeQueueMethodForBranch(pullRequestModel.base.ref, pullRequestModel.remote.owner, pullRequestModel.remote.repositoryName)])
this._folderRepositoryManager.mergeQueueMethodForBranch(pullRequestModel.base.ref, pullRequestModel.remote.owner, pullRequestModel.remote.repositoryName),
this._folderRepositoryManager.isHeadUpToDateWithBase(pullRequestModel)])
.then(result => {
const [
pullRequest,
Expand All @@ -203,7 +207,8 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel<PullRequestMode
currentUser,
viewerCanEdit,
orgTeamsCount,
mergeQueueMethod
mergeQueueMethod,
isBranchUpToDateWithBase
] = result;
if (!pullRequest) {
throw new Error(
Expand Down Expand Up @@ -262,6 +267,7 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel<PullRequestMode
hasWritePermission,
status: status[0],
reviewRequirement: status[1],
canUpdateBranch: pullRequest.item.viewerCanUpdate && !isBranchUpToDateWithBase,
mergeable: pullRequest.item.mergeable,
reviewers: this._existingReviewers,
isDraft: pullRequest.isDraft,
Expand Down Expand Up @@ -374,6 +380,8 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel<PullRequestMode
return this.dequeue(message);
case 'pr.enqueue':
return this.enqueue(message);
case 'pr.update-branch':
return this.updateBranch(message);
case 'pr.gotoChangesSinceReview':
this.gotoChangesSinceReview();
break;
Expand Down Expand Up @@ -814,6 +822,36 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel<PullRequestMode
this._replyMessage(message, { mergeQueueEntry: result });
}

private async updateBranch(message: IRequestMessage<string>): Promise<void> {
if (this._folderRepositoryManager.repository.state.workingTreeChanges.length > 0 || this._folderRepositoryManager.repository.state.indexChanges.length > 0) {
await vscode.window.showErrorMessage(vscode.l10n.t('The pull request branch cannot be updated when the there changed files in the working tree or index. Stash or commit all change and then try again.'), { modal: true });
return this._replyMessage(message, {});
}
const qualifiedUpstream = `${this._item.remote.remoteName}/${this._item.base.ref}`;
await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification }, async (progress) => {
progress.report({ message: vscode.l10n.t('Fetching branch') });
await this._folderRepositoryManager.repository.fetch({ ref: this._item.base.ref, remote: this._item.remote.remoteName });
progress.report({ message: vscode.l10n.t('Merging branch') });
try {
await this._folderRepositoryManager.repository.merge(qualifiedUpstream);
} catch (e) {
if (e.gitErrorCode !== GitErrorCodes.Conflict) {
throw e;
}
}
});

if (this._item.item.mergeable === PullRequestMergeability.Conflict) {
const wizard = await ConflictModel.begin(this._folderRepositoryManager.repository, this._item.base.ref, this._folderRepositoryManager.repository.state.HEAD!.name!);
await wizard?.finished();
wizard?.dispose();
} else {
await this._folderRepositoryManager.repository.push();
}

this._replyMessage(message, {});
}

protected editCommentPromise(comment: IComment, text: string): Promise<IComment> {
return this._item.editReviewComment(comment, text);
}
Expand Down
1 change: 1 addition & 0 deletions src/github/queries.gql
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ fragment PullRequestFragment on PullRequest {
}
viewerCanEnableAutoMerge
viewerCanDisableAutoMerge
viewerCanUpdate
id
databaseId
isDraft
Expand Down
1 change: 1 addition & 0 deletions src/github/queriesExtra.gql
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ fragment PullRequestFragment on PullRequest {
}
viewerCanEnableAutoMerge
viewerCanDisableAutoMerge
viewerCanUpdate
id
databaseId
isDraft
Expand Down
1 change: 1 addition & 0 deletions src/github/queriesLimited.gql
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ fragment PullRequestFragment on PullRequest {
}
viewerCanEnableAutoMerge
viewerCanDisableAutoMerge
viewerCanUpdate
id
databaseId
isDraft
Expand Down
Loading

0 comments on commit 5fffcbe

Please sign in to comment.