diff --git a/src/git/git.ts b/src/git/git.ts index f10f3b4..af850e8 100644 --- a/src/git/git.ts +++ b/src/git/git.ts @@ -376,21 +376,28 @@ export namespace Git { ); } - export function branch__contains( + export function branch__containsOrPointsAt( repoPath: string, ref: string, - { name = undefined, remotes = false }: { name?: string; remotes?: boolean } = {}, + { + mode = 'contains', + name = undefined, + remotes = false, + }: { mode?: 'contains' | 'pointsAt'; name?: string; remotes?: boolean } = {}, ) { const params = ['branch']; if (remotes) { params.push('-r'); } - params.push('--contains', ref); + params.push(mode === 'pointsAt' ? `--points-at=${ref}` : `--contains=${ref}`, '--format=%(refname:short)'); if (name != null) { params.push(name); } - return git({ cwd: repoPath, configs: ['-c', 'color.branch=false'] }, ...params); + return git( + { cwd: repoPath, configs: ['-c', 'color.branch=false'], errors: GitErrorHandling.Ignore }, + ...params, + ); } export function check_ignore(repoPath: string, ...files: string[]) { diff --git a/src/git/gitService.ts b/src/git/gitService.ts index 3a3bc13..e26b0ae 100644 --- a/src/git/gitService.ts +++ b/src/git/gitService.ts @@ -50,6 +50,7 @@ import { GitLog, GitLogCommit, GitLogParser, + GitMergeStatus, GitReference, GitReflog, GitRemote, @@ -580,7 +581,7 @@ export class GitService implements Disposable { @log() async branchContainsCommit(repoPath: string, name: string, ref: string): Promise { - let data = await Git.branch__contains(repoPath, ref, { name: name }); + let data = await Git.branch__containsOrPointsAt(repoPath, ref, { mode: 'contains', name: name }); data = data?.trim(); return Boolean(data); } @@ -1348,13 +1349,17 @@ export class GitService implements Disposable { } @log() - async getCommitBranches(repoPath: string, ref: string, options?: { remotes?: boolean }): Promise { - const data = await Git.branch__contains(repoPath, ref, options); + async getCommitBranches( + repoPath: string, + ref: string, + options?: { mode?: 'contains' | 'pointsAt'; remotes?: boolean }, + ): Promise { + const data = await Git.branch__containsOrPointsAt(repoPath, ref, options); if (!data) return []; return data .split('\n') - .map(b => b.substr(2).trim()) + .map(b => b.trim()) .filter((i?: T): i is T => Boolean(i)); } @@ -2374,6 +2379,31 @@ export class GitService implements Disposable { } } + async getMergeStatus(repoPath: string): Promise; + async getMergeStatus(status: GitStatus): Promise; + @log({ args: false }) + async getMergeStatus(repoPathOrStatus: string | GitStatus): Promise { + let status; + if (typeof repoPathOrStatus === 'string') { + status = await this.getStatusForRepo(repoPathOrStatus); + } else { + status = repoPathOrStatus; + } + if (status?.hasConflicts !== true) return undefined; + + const [mergeBase, possibleSourceBranches] = await Promise.all([ + Container.git.getMergeBase(status.repoPath, 'MERGE_HEAD', 'HEAD'), + Container.git.getCommitBranches(status.repoPath, 'MERGE_HEAD', { mode: 'pointsAt' }), + ]); + return { + repoPath: status.repoPath, + mergeBase: mergeBase, + conflicts: status.files.filter(f => f.conflicted), + into: status.branch, + incoming: possibleSourceBranches?.length === 1 ? possibleSourceBranches[0] : undefined, + }; + } + @log() async getNextDiffUris( repoPath: string, diff --git a/src/git/models/merge.ts b/src/git/models/merge.ts new file mode 100644 index 0000000..feab95a --- /dev/null +++ b/src/git/models/merge.ts @@ -0,0 +1,10 @@ +'use strict'; +import { GitStatusFile } from './status'; + +export interface GitMergeStatus { + repoPath: string; + into: string; + mergeBase: string | undefined; + incoming: string | undefined; + conflicts: GitStatusFile[]; +} diff --git a/src/git/models/models.ts b/src/git/models/models.ts index 7ee3243..d10f0e6 100644 --- a/src/git/models/models.ts +++ b/src/git/models/models.ts @@ -349,6 +349,7 @@ export * from './file'; export * from './issue'; export * from './log'; export * from './logCommit'; +export * from './merge'; export * from './pullRequest'; export * from './reflog'; export * from './remote'; diff --git a/src/views/branchesView.ts b/src/views/branchesView.ts index a6b5798..2b18c49 100644 --- a/src/views/branchesView.ts +++ b/src/views/branchesView.ts @@ -49,6 +49,7 @@ export class BranchesRepositoryNode extends RepositoryFolderNode { + if (!this.commit.hasConflicts) return []; + + const mergeStatus = await Container.git.getMergeStatus(this.commit.repoPath); + if (mergeStatus == null) return []; + + return [ + new MergeConflictCurrentChangesNode(this.view, this, mergeStatus, this.file), + new MergeConflictIncomingChangesNode(this.view, this, mergeStatus, this.file), + ]; } async getTreeItem(): Promise { @@ -77,7 +86,7 @@ export class FileRevisionAsCommitNode extends ViewRefFileNode { + if (!this.commit.hasConflicts) return undefined; + + const mergeBase = await Container.git.getMergeBase(this.repoPath, 'MERGE_HEAD', 'HEAD'); + return GitUri.fromFile(this.file, this.repoPath, mergeBase ?? 'HEAD'); + } } diff --git a/src/views/nodes/mergeStatusNode.ts b/src/views/nodes/mergeStatusNode.ts new file mode 100644 index 0000000..3e80063 --- /dev/null +++ b/src/views/nodes/mergeStatusNode.ts @@ -0,0 +1,311 @@ +'use strict'; +import * as paths from 'path'; +import { Command, MarkdownString, ThemeColor, ThemeIcon, TreeItem, TreeItemCollapsibleState, Uri } from 'vscode'; +import { BranchNode } from './branchNode'; +import { Commands, DiffWithCommandArgs } from '../../commands'; +import { ViewFilesLayout } from '../../configuration'; +import { BuiltInCommands } from '../../constants'; +import { FileNode, FolderNode } from './folderNode'; +import { GitBranch, GitFile, GitMergeStatus, StatusFileFormatter } from '../../git/git'; +import { GitUri } from '../../git/gitUri'; +import { Arrays, Strings } from '../../system'; +import { View, ViewsWithCommits } from '../viewBase'; +import { ContextValues, ViewNode } from './viewNode'; + +export class MergeStatusNode extends ViewNode { + static key = ':merge'; + static getId(repoPath: string, name: string): string { + return `${BranchNode.getId(repoPath, name, true)}${this.key}`; + } + + constructor( + view: ViewsWithCommits, + parent: ViewNode, + public readonly branch: GitBranch, + public readonly status: GitMergeStatus, + ) { + super(GitUri.fromRepoPath(status.repoPath), view, parent); + } + + get id(): string { + return MergeStatusNode.getId(this.status.repoPath, this.status.into); + } + + get repoPath(): string { + return this.uri.repoPath!; + } + + getChildren(): ViewNode[] { + let children: FileNode[] = this.status.conflicts.map( + f => new MergeConflictFileNode(this.view, this, this.status, f), + ); + + if (this.view.config.files.layout !== ViewFilesLayout.List) { + const hierarchy = Arrays.makeHierarchical( + children, + n => n.uri.relativePath.split('/'), + (...parts: string[]) => Strings.normalizePath(paths.join(...parts)), + this.view.config.files.compact, + ); + + const root = new FolderNode(this.view, this, this.repoPath, '', hierarchy); + children = root.getChildren() as FileNode[]; + } else { + children.sort((a, b) => + a.label!.localeCompare(b.label!, undefined, { numeric: true, sensitivity: 'base' }), + ); + } + + return children; + } + + getTreeItem(): TreeItem { + const item = new TreeItem( + `Resolve conflicts before merging ${this.status.incoming ? `${this.status.incoming} ` : ''}into ${ + this.status.into + }`, + TreeItemCollapsibleState.Expanded, + ); + item.id = this.id; + item.contextValue = ContextValues.Merge; + item.description = Strings.pluralize('conflict', this.status.conflicts.length); + item.iconPath = new ThemeIcon('warning', new ThemeColor('list.warningForeground')); + item.tooltip = new MarkdownString( + `Merging ${this.status.incoming ? `$(git-branch) ${this.status.incoming} ` : ''} into $(git-branch) ${ + this.status.into + }\n\n${Strings.pluralize('conflicted file', this.status.conflicts.length)} + `, + true, + ); + + return item; + } +} + +export class MergeConflictFileNode extends ViewNode implements FileNode { + constructor(view: View, parent: ViewNode, private readonly status: GitMergeStatus, public readonly file: GitFile) { + super(GitUri.fromFile(file, status.repoPath, 'MERGE_HEAD'), view, parent); + } + + toClipboard(): string { + return this.fileName; + } + + get baseUri(): Uri { + return GitUri.fromFile(this.file, this.status.repoPath, this.status.mergeBase ?? 'HEAD'); + } + + get fileName(): string { + return this.file.fileName; + } + + get repoPath(): string { + return this.status.repoPath; + } + + getChildren(): ViewNode[] { + return [ + new MergeConflictCurrentChangesNode(this.view, this, this.status, this.file), + new MergeConflictIncomingChangesNode(this.view, this, this.status, this.file), + ]; + } + + getTreeItem(): TreeItem { + const item = new TreeItem(this.label, TreeItemCollapsibleState.Collapsed); + item.description = this.description; + item.contextValue = `${ContextValues.File}+conflicted`; + item.tooltip = StatusFileFormatter.fromTemplate( + // eslint-disable-next-line no-template-curly-in-string + '${file}\n${directory}/\n\n${status}${ (originalPath)} in Index (staged)', + this.file, + ); + // Use the file icon and decorations + item.resourceUri = GitUri.resolveToUri(this.file.fileName, this.repoPath); + item.iconPath = ThemeIcon.File; + item.command = this.getCommand(); + + // Only cache the label/description for a single refresh + this._label = undefined; + this._description = undefined; + + return item; + } + + private _description: string | undefined; + get description() { + if (this._description == null) { + this._description = StatusFileFormatter.fromTemplate( + this.view.config.formats.files.description, + this.file, + { + relativePath: this.relativePath, + }, + ); + } + return this._description; + } + + private _folderName: string | undefined; + get folderName() { + if (this._folderName == null) { + this._folderName = paths.dirname(this.uri.relativePath); + } + return this._folderName; + } + + private _label: string | undefined; + get label() { + if (this._label == null) { + this._label = StatusFileFormatter.fromTemplate(this.view.config.formats.files.label, this.file, { + relativePath: this.relativePath, + }); + } + return this._label; + } + + get priority(): number { + return 0; + } + + private _relativePath: string | undefined; + get relativePath(): string | undefined { + return this._relativePath; + } + set relativePath(value: string | undefined) { + this._relativePath = value; + this._label = undefined; + this._description = undefined; + } + + getCommand(): Command | undefined { + return { + title: 'Open File', + command: BuiltInCommands.Open, + arguments: [ + GitUri.resolveToUri(this.file.fileName, this.repoPath), + { + preserveFocus: true, + preview: true, + }, + ], + }; + } +} + +export class MergeConflictCurrentChangesNode extends ViewNode { + constructor(view: View, parent: ViewNode, private readonly status: GitMergeStatus, private readonly file: GitFile) { + super(GitUri.fromFile(file, status.repoPath, 'HEAD'), view, parent); + } + + getChildren(): ViewNode[] { + return []; + } + + getTreeItem(): TreeItem { + const item = new TreeItem('Current changes', TreeItemCollapsibleState.None); + item.contextValue = ContextValues.MergeCurrentChanges; + item.description = this.status.into; + item.iconPath = new ThemeIcon('diff'); + item.tooltip = new MarkdownString( + `Current changes to $(file) ${this.file.fileName} on $(git-branch) ${this.status.into}`, + true, + ); + item.command = this.getCommand(); + + return item; + } + + getCommand(): Command | undefined { + if (this.status.mergeBase == null) { + return { + title: 'Open Revision', + command: BuiltInCommands.Open, + arguments: [GitUri.toRevisionUri('HEAD', this.file.fileName, this.status.repoPath)], + }; + } + + const commandArgs: DiffWithCommandArgs = { + lhs: { + sha: this.status.mergeBase, + uri: GitUri.fromFile(this.file, this.status.repoPath, undefined, true), + title: `${this.file.fileName} (merge-base)`, + }, + rhs: { + sha: 'HEAD', + uri: GitUri.fromFile(this.file, this.status.repoPath), + title: `${this.file.fileName} (${this.status.into})`, + }, + repoPath: this.status.repoPath, + line: 0, + showOptions: { + preserveFocus: true, + preview: true, + }, + }; + return { + title: 'Open Changes', + command: Commands.DiffWith, + arguments: [commandArgs], + }; + } +} + +export class MergeConflictIncomingChangesNode extends ViewNode { + constructor(view: View, parent: ViewNode, private readonly status: GitMergeStatus, private readonly file: GitFile) { + super(GitUri.fromFile(file, status.repoPath, 'MERGE_HEAD'), view, parent); + } + + getChildren(): ViewNode[] { + return []; + } + + getTreeItem(): TreeItem { + const item = new TreeItem('Incoming changes', TreeItemCollapsibleState.None); + item.contextValue = ContextValues.MergeIncomingChanges; + item.description = this.status.incoming; + item.iconPath = new ThemeIcon('diff'); + item.tooltip = new MarkdownString( + `Incoming changes to $(file) ${this.file.fileName}${ + this.status.incoming ? ` from $(git-branch) ${this.status.incoming}` : '' + }`, + true, + ); + item.command = this.getCommand(); + + return item; + } + + getCommand(): Command | undefined { + if (this.status.mergeBase == null) { + return { + title: 'Open Revision', + command: BuiltInCommands.Open, + arguments: [GitUri.toRevisionUri('MERGE_HEAD', this.file.fileName, this.status.repoPath)], + }; + } + + const commandArgs: DiffWithCommandArgs = { + lhs: { + sha: this.status.mergeBase, + uri: GitUri.fromFile(this.file, this.status.repoPath, undefined, true), + title: `${this.file.fileName} (merge-base)`, + }, + rhs: { + sha: 'MERGE_HEAD', + uri: GitUri.fromFile(this.file, this.status.repoPath), + title: `${this.file.fileName} (${this.status.incoming ? this.status.incoming : 'incoming'})`, + }, + repoPath: this.status.repoPath, + line: 0, + showOptions: { + preserveFocus: true, + preview: true, + }, + }; + return { + title: 'Open Changes', + command: Commands.DiffWith, + arguments: [commandArgs], + }; + } +} diff --git a/src/views/nodes/repositoryNode.ts b/src/views/nodes/repositoryNode.ts index fa29eb2..b22d787 100644 --- a/src/views/nodes/repositoryNode.ts +++ b/src/views/nodes/repositoryNode.ts @@ -20,6 +20,7 @@ import { BranchNode } from './branchNode'; import { BranchTrackingStatusNode } from './branchTrackingStatusNode'; import { MessageNode } from './common'; import { ContributorsNode } from './contributorsNode'; +import { MergeStatusNode } from './mergeStatusNode'; import { ReflogNode } from './reflogNode'; import { RemotesNode } from './remotesNode'; import { StashesNode } from './stashesNode'; @@ -82,7 +83,12 @@ export class RepositoryNode extends SubscribeableViewNode { ); } - if (this.view.config.showUpstreamStatus) { + if (status.hasConflicts) { + const mergeStatus = await Container.git.getMergeStatus(status); + if (mergeStatus != null) { + children.push(new MergeStatusNode(this.view, this, branch, mergeStatus)); + } + } else if (this.view.config.showUpstreamStatus) { if (status.upstream) { if (!status.state.behind && !status.state.ahead) { children.push(new BranchTrackingStatusNode(this.view, this, branch, status, 'same', true)); diff --git a/src/views/nodes/viewNode.ts b/src/views/nodes/viewNode.ts index a09d490..8a16460 100644 --- a/src/views/nodes/viewNode.ts +++ b/src/views/nodes/viewNode.ts @@ -39,6 +39,9 @@ export enum ContextValues { FileHistory = 'gitlens:history:file', Folder = 'gitlens:folder', LineHistory = 'gitlens:history:line', + Merge = 'gitlens:merge', + MergeCurrentChanges = 'gitlens:merge:current', + MergeIncomingChanges = 'gitlens:merge:incoming', Message = 'gitlens:message', Pager = 'gitlens:pager', PullRequest = 'gitlens:pullrequest', diff --git a/src/views/viewCommands.ts b/src/views/viewCommands.ts index 7b3b49d..055cbe0 100644 --- a/src/views/viewCommands.ts +++ b/src/views/viewCommands.ts @@ -30,6 +30,7 @@ import { FileRevisionAsCommitNode, FolderNode, LineHistoryNode, + MergeConflictFileNode, nodeSupportsClearing, PageableViewNode, PagerNode, @@ -764,8 +765,35 @@ export class ViewCommands { } @debug() - private openChanges(node: ViewRefFileNode | StatusFileNode) { - if (!(node instanceof ViewRefFileNode) && !(node instanceof StatusFileNode)) return; + private openChanges(node: ViewRefFileNode | MergeConflictFileNode | StatusFileNode) { + if ( + !(node instanceof ViewRefFileNode) && + !(node instanceof MergeConflictFileNode) && + !(node instanceof StatusFileNode) + ) { + return; + } + + if (node instanceof MergeConflictFileNode) { + void executeCommand(Commands.DiffWith, { + lhs: { + sha: 'MERGE_HEAD', + uri: GitUri.fromFile(node.file, node.repoPath, undefined, true), + }, + rhs: { + sha: 'HEAD', + uri: GitUri.fromFile(node.file, node.repoPath), + }, + repoPath: node.repoPath, + line: 0, + showOptions: { + preserveFocus: false, + preview: false, + }, + }); + + return; + } const command = node.getCommand(); if (command?.arguments == null) return; @@ -860,8 +888,14 @@ export class ViewCommands { } @debug() - private openChangesWithWorking(node: ViewRefFileNode | StatusFileNode) { - if (!(node instanceof ViewRefFileNode) && !(node instanceof StatusFileNode)) return Promise.resolve(); + private async openChangesWithWorking(node: ViewRefFileNode | MergeConflictFileNode | StatusFileNode) { + if ( + !(node instanceof ViewRefFileNode) && + !(node instanceof MergeConflictFileNode) && + !(node instanceof StatusFileNode) + ) { + return Promise.resolve(); + } if (node instanceof StatusFileNode) { return executeEditorCommand(Commands.DiffWithWorking, undefined, { @@ -871,15 +905,37 @@ export class ViewCommands { preview: true, }, }); + } else if (node instanceof MergeConflictFileNode) { + return executeEditorCommand(Commands.DiffWithWorking, undefined, { + uri: node.baseUri, + showOptions: { + preserveFocus: true, + preview: true, + }, + }); + } else if (node instanceof FileRevisionAsCommitNode && node.commit.hasConflicts) { + const baseUri = await node.getConflictBaseUri(); + if (baseUri != null) { + return executeEditorCommand(Commands.DiffWithWorking, undefined, { + uri: baseUri, + showOptions: { + preserveFocus: true, + preview: true, + }, + }); + } } return GitActions.Commit.openChangesWithWorking(node.file, { repoPath: node.repoPath, ref: node.ref.ref }); } @debug() - private openFile(node: ViewRefFileNode | StatusFileNode | FileHistoryNode | LineHistoryNode) { + private openFile( + node: ViewRefFileNode | MergeConflictFileNode | StatusFileNode | FileHistoryNode | LineHistoryNode, + ) { if ( !(node instanceof ViewRefFileNode) && + !(node instanceof MergeConflictFileNode) && !(node instanceof StatusFileNode) && !(node instanceof FileHistoryNode) && !(node instanceof LineHistoryNode) @@ -911,7 +967,13 @@ export class ViewCommands { @debug() private openRevision( - node: CommitFileNode | FileRevisionAsCommitNode | ResultsFileNode | StashFileNode | StatusFileNode, + node: + | CommitFileNode + | FileRevisionAsCommitNode + | ResultsFileNode + | StashFileNode + | MergeConflictFileNode + | StatusFileNode, options?: OpenFileAtRevisionCommandArgs, ) { if ( @@ -919,7 +981,7 @@ export class ViewCommands { !(node instanceof FileRevisionAsCommitNode) && !(node instanceof ResultsFileNode) && !(node instanceof StashFileNode) && - !(node instanceof ResultsFileNode) && + !(node instanceof MergeConflictFileNode) && !(node instanceof StatusFileNode) ) { return Promise.resolve(); @@ -929,7 +991,7 @@ export class ViewCommands { let uri = options.revisionUri; if (uri == null) { - if (node instanceof ResultsFileNode) { + if (node instanceof ResultsFileNode || node instanceof MergeConflictFileNode) { uri = GitUri.toRevisionUri(node.uri); } else { uri =