Skip to content

Allow conflicts to be resolved on a non-checked-out PR #5858

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Apr 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 42 additions & 11 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,23 @@
},
"enabledApiProposals": [
"activeComment",
"commentingRangeHint",
"commentThreadApplicability",
"contribCommentsViewThreadMenus",
"tokenInformation",
"contribShareMenu",
"fileComments",
"codiconDecoration",
"codeActionRanges",
"commentingRangeHint",
"commentReactor",
"commentThreadApplicability",
"contribCommentEditorActionsMenu",
"contribCommentPeekContext",
"contribCommentThreadAdditionalMenu",
"codiconDecoration",
"contribCommentsViewThreadMenus",
"contribEditorContentMenu",
"contribShareMenu",
"diffCommand",
"contribCommentEditorActionsMenu",
"shareProvider",
"fileComments",
"quickDiffProvider",
"shareProvider",
"tabInputTextMerge",
"tokenInformation",
"treeViewMarkdownMessage"
],
"version": "0.86.0",
Expand Down Expand Up @@ -660,14 +661,20 @@
{
"id": "pr:github",
"name": "%view.pr.github.name%",
"when": "ReposManagerStateContext != NeedsAuthentication",
"when": "ReposManagerStateContext != NeedsAuthentication && !github:resolvingConflicts",
"icon": "$(git-pull-request)"
},
{
"id": "issues:github",
"name": "%view.issues.github.name%",
"when": "ReposManagerStateContext != NeedsAuthentication",
"when": "ReposManagerStateContext != NeedsAuthentication && !github:resolvingConflicts",
"icon": "$(issues)"
},
{
"id": "github:conflictResolution",
"name": "%view.github.conflictResolution.name%",
"when": "github:resolvingConflicts",
"icon": "$(git-merge)"
}
],
"github-pull-request": [
Expand Down Expand Up @@ -1113,6 +1120,16 @@
"title": "%command.pr.pick.title%",
"category": "%command.pull.request.category%"
},
{
"command": "pr.resolveConflict",
"title": "%command.pr.resolveConflict.title%",
"category": "%command.pull.request.category%"
},
{
"command": "pr.acceptMerge",
"title": "%command.pr.acceptMerge.title%",
"category": "%command.pull.request.category%"
},
{
"command": "review.diffWithPrHead",
"title": "%command.review.diffWithPrHead.title%",
Expand Down Expand Up @@ -1852,6 +1869,14 @@
"command": "pr.refreshComments",
"when": "gitHubOpenRepositoryCount != 0"
},
{
"command": "pr.resolveConflict",
"when": "false"
},
{
"command": "pr.acceptMerge",
"when": "isMergeResultEditor && mergeEditorBaseUri =~ /^(githubpr|gitpr):/"
},
{
"command": "issue.copyGithubPermalink",
"when": "github:hasGitHubRemotes"
Expand Down Expand Up @@ -2361,6 +2386,12 @@
"when": "(resourceScheme == pr) || (resourcePath in github:viewedFiles) || (resourcePath in github:unviewedFiles)"
}
],
"editor/content": [
{
"command": "pr.acceptMerge",
"when": "isMergeResultEditor && mergeEditorBaseUri =~ /^(githubpr|gitpr):/"
}
],
"scm/title": [
{
"command": "review.suggestDiff",
Expand Down
3 changes: 3 additions & 0 deletions package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@
"view.github.login.name": "Login",
"view.pr.github.name": "Pull Requests",
"view.issues.github.name": "Issues",
"view.github.conflictResolution.name": "Conflict Resolution",
"view.github.create.pull.request.name": "Create",
"view.github.compare.changes.name": "Files Changed",
"view.github.compare.changesCommits.name": "Commits",
Expand Down Expand Up @@ -237,6 +238,8 @@
"command.pr.createPrMenuMerge.title": "Create + Auto-Merge",
"command.pr.createPrMenuRebase.title": "Create + Auto-Rebase",
"command.pr.refreshComments.title": "Refresh Pull Request Comments",
"command.pr.resolveConflict.title": "Resolve Conflict",
"command.pr.acceptMerge.title": "Accept Merge",
"command.issue.copyGithubDevLink.title": "Copy github.dev Link",
"command.issue.copyGithubPermalink.title": "Copy GitHub Permalink",
"command.issue.copyGithubHeadLink.title": "Copy GitHub Head Link",
Expand Down
1 change: 1 addition & 0 deletions src/common/executeCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export namespace contexts {
export const LOADING_PRS_TREE = 'github:loadingPrsTree';
export const LOADING_ISSUES_TREE = 'github:loadingIssuesTree';
export const CREATE_PR_PERMISSIONS = 'github:createPrPermissions';
export const RESOLVING_CONFLICTS = 'github:resolvingConflicts';
}

export namespace commands {
Expand Down
11 changes: 10 additions & 1 deletion src/common/uri.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export function fromPRNodeUri(uri: vscode.Uri): PRNodeUriParams | undefined {
export interface GitHubUriParams {
fileName: string;
branch: string;
owner?: string;
isEmpty?: boolean;
}
export function fromGitHubURI(uri: vscode.Uri): GitHubUriParams | undefined {
Expand All @@ -67,6 +68,13 @@ export function fromGitHubURI(uri: vscode.Uri): GitHubUriParams | undefined {
} catch (e) { }
}

export function toGitHubUri(fileUri: vscode.Uri, scheme: Schemes.GithubPr | Schemes.GitPr, params: GitHubUriParams): vscode.Uri {
return fileUri.with({
scheme,
query: JSON.stringify(params)
});
}

export interface GitUriOptions {
replaceFileExtension?: boolean;
submoduleOf?: string;
Expand Down Expand Up @@ -400,7 +408,8 @@ export enum Schemes {
GithubPr = 'githubpr', // File content from GitHub in create flow
GitPr = 'gitpr', // File content from git in create flow
VscodeVfs = 'vscode-vfs', // Remote Repository
Comment = 'comment' // Comments from the VS Code comment widget
Comment = 'comment', // Comments from the VS Code comment widget
MergeOutput = 'merge-output', // Merge output
}

export function resolvePath(from: vscode.Uri, to: string) {
Expand Down
151 changes: 151 additions & 0 deletions src/github/conflictResolutionCoordinator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
/*---------------------------------------------------------------------------------------------
* 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 { commands, contexts } from '../common/executeCommands';
import { Schemes } from '../common/uri';
import { asPromise } from '../common/utils';
import { ConflictResolutionTreeView } from '../view/conflictResolution/conflictResolutionTreeView';
import { GitHubContentProvider } from '../view/gitHubContentProvider';
import { Conflict, ConflictResolutionModel } from './conflictResolutionModel';
import { GitHubRepository } from './githubRepository';

interface MergeEditorInputData { uri: vscode.Uri; title?: string; detail?: string; description?: string }

class MergeOutputProvider implements vscode.FileSystemProvider {
private _createTime: number = 0;
private _modifiedTime: number = 0;
private _mergedFiles: Map<string, Uint8Array> = new Map();
get mergeResults(): Map<string, Uint8Array> {
return this._mergedFiles;
}
onDidChangeFile: vscode.Event<vscode.FileChangeEvent[]> = new vscode.EventEmitter<vscode.FileChangeEvent[]>().event;
constructor(private readonly _conflictResolutionModel: ConflictResolutionModel) {
this._createTime = new Date().getTime();
}
watch(_uri: vscode.Uri, _options: { readonly recursive: boolean; readonly excludes: readonly string[]; }): vscode.Disposable {
// no-op because no one else can modify this file.
return {
dispose: () => { }
};
}
stat(uri: vscode.Uri): vscode.FileStat {
return {
type: vscode.FileType.File,
ctime: this._createTime,
mtime: this._modifiedTime,
size: this._mergedFiles.get(uri.path)?.length ?? 0,
};
}
readDirectory(_uri: vscode.Uri): [string, vscode.FileType][] {
throw new Error('Method not implemented.');
}
createDirectory(_uri: vscode.Uri): void {
throw new Error('Method not implemented.');
}
async readFile(uri: vscode.Uri): Promise<Uint8Array> {
if (!this._mergedFiles.has(uri.path)) {
const original = this._conflictResolutionModel.mergeBaseUri({ prHeadFilePath: uri.path });
const content = await vscode.workspace.fs.readFile(original);
this._mergedFiles.set(uri.path, content);
}
return this._mergedFiles.get(uri.path)!;
}
writeFile(uri: vscode.Uri, content: Uint8Array, _options: { readonly create: boolean; readonly overwrite: boolean; }): void {
this._modifiedTime = new Date().getTime();
this._mergedFiles.set(uri.path, content);
}
delete(_uri: vscode.Uri, _options: { readonly recursive: boolean; }): void {
throw new Error('Method not implemented.');
}
rename(_oldUri: vscode.Uri, _newUri: vscode.Uri, _options: { readonly overwrite: boolean; }): void {
throw new Error('Method not implemented.');
}
}

export class ConflictResolutionCoordinator {
private _disposables: vscode.Disposable[] = [];
private _mergeOutputProvider: MergeOutputProvider;

constructor(private readonly _conflictResolutionModel: ConflictResolutionModel, private readonly _githubRepositories: GitHubRepository[]) {
this._mergeOutputProvider = new MergeOutputProvider(this._conflictResolutionModel);
}

private register(): void {
this._disposables.push(vscode.workspace.registerFileSystemProvider(Schemes.GithubPr, new GitHubContentProvider(this._githubRepositories), { isReadonly: true }));
this._disposables.push(vscode.workspace.registerFileSystemProvider(Schemes.MergeOutput, this._mergeOutputProvider));
this._disposables.push(vscode.commands.registerCommand('pr.resolveConflict', async (conflict: Conflict) => {
const prHeadUri = this._conflictResolutionModel.prHeadUri(conflict);
const baseUri = this._conflictResolutionModel.baseUri(conflict);

const prHead: MergeEditorInputData = { uri: prHeadUri, title: vscode.l10n.t('Pull Request Head') };
const base: MergeEditorInputData = { uri: baseUri, title: vscode.l10n.t('{0} Branch', this._conflictResolutionModel.prBaseBranchName) };

const mergeBaseUri: vscode.Uri = this._conflictResolutionModel.mergeBaseUri(conflict);
const mergeOutput = this._conflictResolutionModel.mergeOutputUri(conflict);
const options = {
base: mergeBaseUri,
input1: prHead,
input2: base,
output: mergeOutput
};
await commands.executeCommand(
'_open.mergeEditor',
options
);
}));
this._disposables.push(vscode.commands.registerCommand('pr.acceptMerge', async (uri: vscode.Uri | unknown) => {
return this.acceptMerge(uri);
}));
this._disposables.push(vscode.commands.registerCommand('pr.exitConflictResolutionMode', async () => {
const exit = vscode.l10n.t('Exit and lose changes');
const result = await vscode.window.showWarningMessage(vscode.l10n.t('Are you sure you want to exit conflict resolution mode? All changes will be lost.'), { modal: true }, exit);
if (result === exit) {
return this.exitConflictResolutionMode(false);
}
}));
this._disposables.push(vscode.commands.registerCommand('pr.completeMerge', async () => {
return this.exitConflictResolutionMode(true);
}));
this._disposables.push(new ConflictResolutionTreeView(this._conflictResolutionModel));
}

private async acceptMerge(uri: vscode.Uri | unknown): Promise<void> {
if (!(uri instanceof vscode.Uri)) {
return;
}
const { activeTab } = vscode.window.tabGroups.activeTabGroup;
if (!activeTab || !(activeTab.input instanceof vscode.TabInputTextMerge)) {
return;
}

const result = await commands.executeCommand('mergeEditor.acceptMerge') as { successful: boolean };
if (result.successful) {
const contents = new TextDecoder().decode(this._mergeOutputProvider.mergeResults.get(uri.path)!);
this._conflictResolutionModel.addResolution(uri.path.substring(1), contents);
}
}

async enterConflictResolutionMode(): Promise<void> {
await commands.setContext(contexts.RESOLVING_CONFLICTS, true);
this.register();
}

private _onExitConflictResolutionMode = new vscode.EventEmitter<boolean>();
async exitConflictResolutionMode(allConflictsResolved: boolean): Promise<void> {
await commands.setContext(contexts.RESOLVING_CONFLICTS, false);
this._onExitConflictResolutionMode.fire(allConflictsResolved);
this.dispose();
}

async enterConflictResolutionAndWaitForExit(): Promise<boolean> {
await this.enterConflictResolutionMode();
return asPromise(this._onExitConflictResolutionMode.event);
}

dispose(): void {
this._disposables.forEach(d => d.dispose());
}
}
87 changes: 87 additions & 0 deletions src/github/conflictResolutionModel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/*---------------------------------------------------------------------------------------------
* 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 { Schemes, toGitHubUri } from '../common/uri';

export interface Conflict {
prHeadFilePath: string;
contentsConflict: boolean;
filePathConflict: boolean;
modeConflict: boolean;
}

export interface ResolvedConflict {
prHeadFilePath: string;
resolvedContents?: string;
// The other two fields can be added later. To begin with, we only support resolving the contents.
// resolvedFilePath: string;
// resolvedMode: string;
}

export class ConflictResolutionModel {
private _startingConflicts: Map<string, Conflict> = new Map();
private readonly _resolvedConflicts: Map<string, ResolvedConflict> = new Map();
private readonly _onAddedResolution: vscode.EventEmitter<void> = new vscode.EventEmitter<void>();
public readonly onAddedResolution: vscode.Event<void> = this._onAddedResolution.event;

constructor(public readonly startingConflicts: Conflict[], public readonly repositoryName: string, public readonly prBaseOwner: string,
public readonly latestPrBaseSha: string,
public readonly prHeadOwner: string, public readonly prHeadBranchName: string,
public readonly prBaseBranchName: string, public readonly prMergeBaseRef: string) {

for (const conflict of startingConflicts) {
this._startingConflicts.set(conflict.prHeadFilePath, conflict);
}
}

isResolvable(): boolean {
return Array.from(this._startingConflicts.values()).every(conflict => {
return !conflict.filePathConflict && !conflict.modeConflict;
});
}

addResolution(filePath: string, contents: string): void {
this._resolvedConflicts.set(filePath, { prHeadFilePath: filePath, resolvedContents: contents });
this._onAddedResolution.fire();
}

isResolved(filePath: string): boolean {
if (!this._startingConflicts.has(filePath)) {
throw new Error('Not a conflict file');
}
return this._resolvedConflicts.has(filePath);
}

get areAllConflictsResolved(): boolean {
return this._resolvedConflicts.size === this._startingConflicts.size;
}

get resolvedConflicts(): Map<string, ResolvedConflict> {
if (this._resolvedConflicts.size !== this._startingConflicts.size) {
throw new Error('Not all conflicts have been resolved');
}
return this._resolvedConflicts;
}

public mergeOutputUri(conflict: Conflict) {
return vscode.Uri.parse(`${Schemes.MergeOutput}:/${conflict.prHeadFilePath}`);
}

public mergeBaseUri(conflict: { prHeadFilePath: string }): vscode.Uri {
const fileUri = vscode.Uri.file(conflict.prHeadFilePath);
return toGitHubUri(fileUri, Schemes.GithubPr, { fileName: conflict.prHeadFilePath, branch: this.prMergeBaseRef, owner: this.prBaseOwner });
}

public baseUri(conflict: Conflict): vscode.Uri {
const fileUri = vscode.Uri.file(conflict.prHeadFilePath);
return toGitHubUri(fileUri, Schemes.GithubPr, { fileName: conflict.prHeadFilePath, branch: this.latestPrBaseSha, owner: this.prBaseOwner });
}

public prHeadUri(conflict: Conflict): vscode.Uri {
const fileUri = vscode.Uri.file(conflict.prHeadFilePath);
return toGitHubUri(fileUri, Schemes.GithubPr, { fileName: conflict.prHeadFilePath, branch: this.prHeadBranchName, owner: this.prHeadOwner });
}
}
Loading