Commit 45cd1101 authored by Douwe Maan's avatar Douwe Maan Committed by Ruben Davila

Merge branch 'mc-ui'

# Conflicts:
#	app/controllers/projects/merge_requests_controller.rb
parent 10102847
......@@ -86,6 +86,8 @@
new ZenMode();
new MergedButtons();
break;
case "projects:merge_requests:conflicts":
window.mcui = new MergeConflictResolver()
case 'projects:merge_requests:index':
shortcut_handler = new ShortcutsNavigation();
Issuable.init();
......
const HEAD_HEADER_TEXT = 'HEAD//our changes';
const ORIGIN_HEADER_TEXT = 'origin//their changes';
const HEAD_BUTTON_TITLE = 'Use ours';
const ORIGIN_BUTTON_TITLE = 'Use theirs';
class MergeConflictDataProvider {
getInitialData() {
const diffViewType = $.cookie('diff_view');
return {
isLoading : true,
hasError : false,
isParallel : diffViewType === 'parallel',
diffViewType : diffViewType,
isSubmitting : false,
conflictsData : {},
resolutionData : {}
}
}
decorateData(vueInstance, data) {
this.vueInstance = vueInstance;
if (data.type === 'error') {
vueInstance.hasError = true;
data.errorMessage = data.message;
}
else {
data.shortCommitSha = data.commit_sha.slice(0, 7);
data.commitMessage = data.commit_message;
this.setParallelLines(data);
this.setInlineLines(data);
this.updateResolutionsData(data);
}
vueInstance.conflictsData = data;
vueInstance.isSubmitting = false;
const conflictsText = this.getConflictsCount() > 1 ? 'conflicts' : 'conflict';
vueInstance.conflictsData.conflictsText = conflictsText;
}
updateResolutionsData(data) {
const vi = this.vueInstance;
data.files.forEach( (file) => {
file.sections.forEach( (section) => {
if (section.conflict) {
vi.$set(`resolutionData['${section.id}']`, false);
}
});
});
}
setParallelLines(data) {
data.files.forEach( (file) => {
file.filePath = this.getFilePath(file);
file.iconClass = `fa-${file.blob_icon}`;
file.blobPath = file.blob_path;
file.parallelLines = [];
const linesObj = { left: [], right: [] };
file.sections.forEach( (section) => {
const { conflict, lines, id } = section;
if (conflict) {
linesObj.left.push(this.getOriginHeaderLine(id));
linesObj.right.push(this.getHeadHeaderLine(id));
}
lines.forEach( (line) => {
const { type } = line;
if (conflict) {
if (type === 'old') {
linesObj.left.push(this.getLineForParallelView(line, id, 'conflict'));
}
else if (type === 'new') {
linesObj.right.push(this.getLineForParallelView(line, id, 'conflict', true));
}
}
else {
const lineType = type || 'context';
linesObj.left.push (this.getLineForParallelView(line, id, lineType));
linesObj.right.push(this.getLineForParallelView(line, id, lineType, true));
}
});
this.checkLineLengths(linesObj);
});
for (let i = 0, len = linesObj.left.length; i < len; i++) {
file.parallelLines.push([
linesObj.right[i],
linesObj.left[i]
]);
}
});
}
checkLineLengths(linesObj) {
let { left, right } = linesObj;
if (left.length !== right.length) {
if (left.length > right.length) {
const diff = left.length - right.length;
for (let i = 0; i < diff; i++) {
right.push({ lineType: 'emptyLine', richText: '' });
}
}
else {
const diff = right.length - left.length;
for (let i = 0; i < diff; i++) {
left.push({ lineType: 'emptyLine', richText: '' });
}
}
}
}
setInlineLines(data) {
data.files.forEach( (file) => {
file.iconClass = `fa-${file.blob_icon}`;
file.blobPath = file.blob_path;
file.filePath = this.getFilePath(file);
file.inlineLines = []
file.sections.forEach( (section) => {
let currentLineType = 'new';
const { conflict, lines, id } = section;
if (conflict) {
file.inlineLines.push(this.getHeadHeaderLine(id));
}
lines.forEach( (line) => {
const { type } = line;
if ((type === 'new' || type === 'old') && currentLineType !== type) {
currentLineType = type;
file.inlineLines.push({ lineType: 'emptyLine', richText: '' });
}
this.decorateLineForInlineView(line, id, conflict);
file.inlineLines.push(line);
})
if (conflict) {
file.inlineLines.push(this.getOriginHeaderLine(id));
}
});
});
}
handleSelected(sectionId, selection) {
const vi = this.vueInstance;
vi.resolutionData[sectionId] = selection;
vi.conflictsData.files.forEach( (file) => {
file.inlineLines.forEach( (line) => {
if (line.id === sectionId && (line.hasConflict || line.isHeader)) {
this.markLine(line, selection);
}
});
file.parallelLines.forEach( (lines) => {
const left = lines[0];
const right = lines[1];
const hasSameId = right.id === sectionId || left.id === sectionId;
const isLeftMatch = left.hasConflict || left.isHeader;
const isRightMatch = right.hasConflict || right.isHeader;
if (hasSameId && (isLeftMatch || isRightMatch)) {
this.markLine(left, selection);
this.markLine(right, selection);
}
})
});
}
updateViewType(newType) {
const vi = this.vueInstance;
if (newType === vi.diffView || !(newType === 'parallel' || newType === 'inline')) {
return;
}
vi.diffView = newType;
vi.isParallel = newType === 'parallel';
$.cookie('diff_view', newType); // TODO: Make sure that cookie path added.
$('.content-wrapper .container-fluid').toggleClass('container-limited');
}
markLine(line, selection) {
if (selection === 'head' && line.isHead) {
line.isSelected = true;
line.isUnselected = false;
}
else if (selection === 'origin' && line.isOrigin) {
line.isSelected = true;
line.isUnselected = false;
}
else {
line.isSelected = false;
line.isUnselected = true;
}
}
getConflictsCount() {
return Object.keys(this.vueInstance.resolutionData).length;
}
getResolvedCount() {
let count = 0;
const data = this.vueInstance.resolutionData;
for (const id in data) {
const resolution = data[id];
if (resolution) {
count++;
}
}
return count;
}
isReadyToCommit() {
const { conflictsData, isSubmitting } = this.vueInstance
const allResolved = this.getConflictsCount() === this.getResolvedCount();
const hasCommitMessage = $.trim(conflictsData.commitMessage).length;
return !isSubmitting && hasCommitMessage && allResolved;
}
getCommitButtonText() {
const initial = 'Commit conflict resolution';
const inProgress = 'Committing...';
const vue = this.vueInstance;
return vue ? vue.isSubmitting ? inProgress : initial : initial;
}
decorateLineForInlineView(line, id, conflict) {
const { type } = line;
line.id = id;
line.hasConflict = conflict;
line.isHead = type === 'new';
line.isOrigin = type === 'old';
line.hasMatch = type === 'match';
line.richText = line.rich_text;
line.isSelected = false;
line.isUnselected = false;
}
getLineForParallelView(line, id, lineType, isHead) {
const { old_line, new_line, rich_text } = line;
const hasConflict = lineType === 'conflict';
return {
id,
lineType,
hasConflict,
isHead : hasConflict && isHead,
isOrigin : hasConflict && !isHead,
hasMatch : lineType === 'match',
lineNumber : isHead ? new_line : old_line,
section : isHead ? 'head' : 'origin',
richText : rich_text,
isSelected : false,
isUnselected : false
}
}
getHeadHeaderLine(id) {
return {
id : id,
richText : HEAD_HEADER_TEXT,
buttonTitle : HEAD_BUTTON_TITLE,
type : 'new',
section : 'head',
isHeader : true,
isHead : true,
isSelected : false,
isUnselected: false
}
}
getOriginHeaderLine(id) {
return {
id : id,
richText : ORIGIN_HEADER_TEXT,
buttonTitle : ORIGIN_BUTTON_TITLE,
type : 'old',
section : 'origin',
isHeader : true,
isOrigin : true,
isSelected : false,
isUnselected: false
}
}
handleFailedRequest(vueInstance, data) {
vueInstance.hasError = true;
vueInstance.conflictsData.errorMessage = 'Something went wrong!';
}
getCommitData() {
return {
commit_message: this.vueInstance.conflictsData.commitMessage,
sections: this.vueInstance.resolutionData
}
}
getFilePath(file) {
const { old_path, new_path } = file;
return old_path === new_path ? new_path : `${old_path} → ${new_path}`;
}
}
//= require vue
class MergeConflictResolver {
constructor() {
this.dataProvider = new MergeConflictDataProvider()
this.initVue()
}
initVue() {
const that = this;
this.vue = new Vue({
el : '#conflicts',
name : 'MergeConflictResolver',
data : this.dataProvider.getInitialData(),
created : this.fetchData(),
computed : this.setComputedProperties(),
methods : {
handleSelected(sectionId, selection) {
that.dataProvider.handleSelected(sectionId, selection);
},
handleViewTypeChange(newType) {
that.dataProvider.updateViewType(newType);
},
commit() {
that.commit();
}
}
})
}
setComputedProperties() {
const dp = this.dataProvider;
return {
conflictsCount() { return dp.getConflictsCount() },
resolvedCount() { return dp.getResolvedCount() },
readyToCommit() { return dp.isReadyToCommit() },
commitButtonText() { return dp.getCommitButtonText() }
}
}
fetchData() {
const dp = this.dataProvider;
$.get($('#conflicts').data('conflictsPath'))
.done((data) => {
dp.decorateData(this.vue, data);
})
.error((data) => {
dp.handleFailedRequest(this.vue, data);
})
.always(() => {
this.vue.isLoading = false;
this.vue.$nextTick(() => {
$('#conflicts .js-syntax-highlight').syntaxHighlight();
});
if (this.vue.diffViewType === 'parallel') {
$('.content-wrapper .container-fluid').removeClass('container-limited');
}
})
}
commit() {
this.vue.isSubmitting = true;
$.post($('#conflicts').data('resolveConflictsPath'), this.dataProvider.getCommitData())
.done((data) => {
window.location.href = data.redirect_to;
})
.error(() => {
new Flash('Something went wrong!');
})
.always(() => {
this.vue.isSubmitting = false;
});
}
}
......@@ -53,7 +53,7 @@
return function(data) {
var callback, urlSuffix;
if (data.state === "merged") {
urlSuffix = deleteSourceBranch ? '?delete_source=true' : '';
urlSuffix = deleteSourceBranch ? '?deleted_source_branch=true' : '';
return window.location.href = window.location.pathname + urlSuffix;
} else if (data.merge_error) {
return $('.mr-widget-body').html("<h4>" + data.merge_error + "</h4>");
......
......@@ -20,3 +20,8 @@
.turn-off { display: block; }
}
}
[v-cloak] {
display: none;
}
......@@ -123,4 +123,9 @@
}
}
}
}
\ No newline at end of file
}
@mixin dark-diff-match-line {
color: rgba(255, 255, 255, 0.3);
background: rgba(255, 255, 255, 0.1);
}
......@@ -21,6 +21,10 @@
// Diff line
.line_holder {
&.match .line_content {
@include dark-diff-match-line;
}
td.diff-line-num.hll:not(.empty-cell),
td.line_content.hll:not(.empty-cell) {
background-color: #557;
......@@ -36,8 +40,7 @@
}
.line_content.match {
color: rgba(255, 255, 255, 0.3);
background: rgba(255, 255, 255, 0.1);
@include dark-diff-match-line;
}
}
......
......@@ -21,6 +21,10 @@
// Diff line
.line_holder {
&.match .line_content {
@include dark-diff-match-line;
}
td.diff-line-num.hll:not(.empty-cell),
td.line_content.hll:not(.empty-cell) {
background-color: #49483e;
......@@ -36,8 +40,7 @@
}
.line_content.match {
color: rgba(255, 255, 255, 0.3);
background: rgba(255, 255, 255, 0.1);
@include dark-diff-match-line;
}
}
......
......@@ -21,6 +21,10 @@
// Diff line
.line_holder {
&.match .line_content {
@include dark-diff-match-line;
}
td.diff-line-num.hll:not(.empty-cell),
td.line_content.hll:not(.empty-cell) {
background-color: #174652;
......@@ -36,8 +40,7 @@
}
.line_content.match {
color: rgba(255, 255, 255, 0.3);
background: rgba(255, 255, 255, 0.1);
@include dark-diff-match-line;
}
}
......
/* https://gist.github.com/qguv/7936275 */
@mixin matchLine {
color: $black-transparent;
background: rgba(255, 255, 255, 0.4);
}
.code.solarized-light {
// Line numbers
.line-numbers, .diff-line-num {
......@@ -21,6 +27,10 @@
// Diff line
.line_holder {
&.match .line_content {
@include matchLine;
}
td.diff-line-num.hll:not(.empty-cell),
td.line_content.hll:not(.empty-cell) {
background-color: #ddd8c5;
......@@ -36,8 +46,7 @@
}
.line_content.match {
color: $black-transparent;
background: rgba(255, 255, 255, 0.4);
@include matchLine;
}
}
......
/* https://github.com/aahan/pygments-github-style */
@mixin matchLine {
color: $black-transparent;
background-color: $match-line;
}
.code.white {
// Line numbers
.line-numbers, .diff-line-num {
......@@ -22,6 +28,10 @@
// Diff line
.line_holder {
&.match .line_content {
@include matchLine;
}
.diff-line-num {
&.old {
background-color: $line-number-old;
......@@ -57,8 +67,7 @@
}
&.match {
color: $black-transparent;
background-color: $match-line;
@include matchLine;
}
&.hll:not(.empty-cell) {
......
$colors: (
white_header_head_neutral : #e1fad7,
white_line_head_neutral : #effdec,
white_button_head_neutral : #9adb84,
white_header_head_chosen : #baf0a8,
white_line_head_chosen : #e1fad7,
white_button_head_chosen : #52c22d,
white_header_origin_neutral : #e0f0ff,
white_line_origin_neutral : #f2f9ff,
white_button_origin_neutral : #87c2fa,
white_header_origin_chosen : #add8ff,
white_line_origin_chosen : #e0f0ff,
white_button_origin_chosen : #268ced,
white_header_not_chosen : #f0f0f0,
white_line_not_chosen : #f9f9f9,
dark_header_head_neutral : rgba(#3f3, .2),
dark_line_head_neutral : rgba(#3f3, .1),
dark_button_head_neutral : #40874f,
dark_header_head_chosen : rgba(#3f3, .33),
dark_line_head_chosen : rgba(#3f3, .2),
dark_button_head_chosen : #258537,
dark_header_origin_neutral : rgba(#2878c9, .4),
dark_line_origin_neutral : rgba(#2878c9, .3),
dark_button_origin_neutral : #2a5c8c,
dark_header_origin_chosen : rgba(#2878c9, .6),
dark_line_origin_chosen : rgba(#2878c9, .4),
dark_button_origin_chosen : #1d6cbf,
dark_header_not_chosen : rgba(#fff, .25),
dark_line_not_chosen : rgba(#fff, .1),
monokai_header_head_neutral : rgba(#a6e22e, .25),
monokai_line_head_neutral : rgba(#a6e22e, .1),
monokai_button_head_neutral : #376b20,
monokai_header_head_chosen : rgba(#a6e22e, .4),
monokai_line_head_chosen : rgba(#a6e22e, .25),
monokai_button_head_chosen : #39800d,
monokai_header_origin_neutral : rgba(#60d9f1, .35),
monokai_line_origin_neutral : rgba(#60d9f1, .15),
monokai_button_origin_neutral : #38848c,
monokai_header_origin_chosen : rgba(#60d9f1, .5),
monokai_line_origin_chosen : rgba(#60d9f1, .35),
monokai_button_origin_chosen : #3ea4b2,
monokai_header_not_chosen : rgba(#76715d, .24),
monokai_line_not_chosen : rgba(#76715d, .1),
solarized_light_header_head_neutral : rgba(#859900, .37),
solarized_light_line_head_neutral : rgba(#859900, .2),
solarized_light_button_head_neutral : #afb262,
solarized_light_header_head_chosen : rgba(#859900, .5),
solarized_light_line_head_chosen : rgba(#859900, .37),
solarized_light_button_head_chosen : #94993d,
solarized_light_header_origin_neutral : rgba(#2878c9, .37),
solarized_light_line_origin_neutral : rgba(#2878c9, .15),
solarized_light_button_origin_neutral : #60a1bf,
solarized_light_header_origin_chosen : rgba(#2878c9, .6),
solarized_light_line_origin_chosen : rgba(#2878c9, .37),
solarized_light_button_origin_chosen : #2482b2,
solarized_light_header_not_chosen : rgba(#839496, .37),
solarized_light_line_not_chosen : rgba(#839496, .2),
solarized_dark_header_head_neutral : rgba(#859900, .35),
solarized_dark_line_head_neutral : rgba(#859900, .15),
solarized_dark_button_head_neutral : #376b20,
solarized_dark_header_head_chosen : rgba(#859900, .5),
solarized_dark_line_head_chosen : rgba(#859900, .35),
solarized_dark_button_head_chosen : #39800d,
solarized_dark_header_origin_neutral : rgba(#2878c9, .35),
solarized_dark_line_origin_neutral : rgba(#2878c9, .15),
solarized_dark_button_origin_neutral : #086799,
solarized_dark_header_origin_chosen : rgba(#2878c9, .6),
solarized_dark_line_origin_chosen : rgba(#2878c9, .35),
solarized_dark_button_origin_chosen : #0082cc,
solarized_dark_header_not_chosen : rgba(#839496, .25),
solarized_dark_line_not_chosen : rgba(#839496, .15)
);
@mixin color-scheme($color) {
.header.line_content, .diff-line-num {
&.origin {
background-color: map-get($colors, #{$color}_header_origin_neutral);
border-color: map-get($colors, #{$color}_header_origin_neutral);
button {
background-color: map-get($colors, #{$color}_button_origin_neutral);
border-color: darken(map-get($colors, #{$color}_button_origin_neutral), 15);
}
&.selected {
background-color: map-get($colors, #{$color}_header_origin_chosen);
border-color: map-get($colors, #{$color}_header_origin_chosen);
button {
background-color: map-get($colors, #{$color}_button_origin_chosen);
border-color: darken(map-get($colors, #{$color}_button_origin_chosen), 15);
}
}
&.unselected {
background-color: map-get($colors, #{$color}_header_not_chosen);
border-color: map-get($colors, #{$color}_header_not_chosen);
button {
background-color: lighten(map-get($colors, #{$color}_button_origin_neutral), 15);
border-color: map-get($colors, #{$color}_button_origin_neutral);
}
}
}
&.head {
background-color: map-get($colors, #{$color}_header_head_neutral);
border-color: map-get($colors, #{$color}_header_head_neutral);
button {
background-color: map-get($colors, #{$color}_button_head_neutral);
border-color: darken(map-get($colors, #{$color}_button_head_neutral), 15);
}
&.selected {
background-color: map-get($colors, #{$color}_header_head_chosen);
border-color: map-get($colors, #{$color}_header_head_chosen);
button {
background-color: map-get($colors, #{$color}_button_head_chosen);
border-color: darken(map-get($colors, #{$color}_button_head_chosen), 15);
}
}
&.unselected {
background-color: map-get($colors, #{$color}_header_not_chosen);
border-color: map-get($colors, #{$color}_header_not_chosen);
button {
background-color: lighten(map-get($colors, #{$color}_button_head_neutral), 15);
border-color: map-get($colors, #{$color}_button_head_neutral);
}
}
}
}
.line_content {
&.origin {
background-color: map-get($colors, #{$color}_line_origin_neutral);
&.selected {
background-color: map-get($colors, #{$color}_line_origin_chosen);
}
&.unselected {
background-color: map-get($colors, #{$color}_line_not_chosen);
}
}
&.head {
background-color: map-get($colors, #{$color}_line_head_neutral);
&.selected {
background-color: map-get($colors, #{$color}_line_head_chosen);
}
&.unselected {
background-color: map-get($colors, #{$color}_line_not_chosen);
}
}
}
}
#conflicts {
.white {
@include color-scheme('white')
}
.dark {
@include color-scheme('dark')
}
.monokai {
@include color-scheme('monokai')
}
.solarized-light {
@include color-scheme('solarized_light')
}
.solarized-dark {
@include color-scheme('solarized_dark')
}
.diff-wrap-lines .line_content {
white-space: normal;
min-height: 19px;
}
.line_content.header {
position: relative;
button {
border-radius: 2px;
font-size: 10px;
position: absolute;
right: 10px;
padding: 0;
outline: none;
color: #fff;
width: 75px; // static width to make 2 buttons have same width
height: 19px;
}
}
.btn-success .fa-spinner {
color: #fff;
}
}
......@@ -9,15 +9,15 @@ class Projects::MergeRequestsController < Projects::ApplicationController
before_action :module_enabled
before_action :merge_request, only: [
:edit, :update, :show, :diffs, :commits, :builds, :pipelines, :merge, :merge_check,
:ci_status, :toggle_subscription, :cancel_merge_when_build_succeeds, :remove_wip
:edit, :update, :show, :diffs, :commits, :conflicts, :builds, :pipelines, :merge, :merge_check,
:ci_status, :toggle_subscription, :cancel_merge_when_build_succeeds, :remove_wip, :resolve_conflicts
]
before_action :validates_merge_request, only: [:show, :diffs, :commits, :builds, :pipelines]
before_action :define_show_vars, only: [:show, :diffs, :commits, :builds, :pipelines]
before_action :define_show_vars, only: [:show, :diffs, :commits, :conflicts, :builds, :pipelines]
before_action :define_widget_vars, only: [:merge, :cancel_merge_when_build_succeeds, :merge_check]
before_action :define_commit_vars, only: [:diffs]
before_action :define_diff_comment_vars, only: [:diffs]
before_action :ensure_ref_fetched, only: [:show, :diffs, :commits, :builds, :pipelines]
before_action :ensure_ref_fetched, only: [:show, :diffs, :commits, :builds, :conflicts, :pipelines]
# Allow read any merge_request
before_action :authorize_read_merge_request!
......@@ -28,6 +28,8 @@ class Projects::MergeRequestsController < Projects::ApplicationController
# Allow modify merge_request
before_action :authorize_update_merge_request!, only: [:close, :edit, :update, :remove_wip, :sort]
before_action :authorize_can_resolve_conflicts!, only: [:conflicts, :resolve_conflicts]
def index
terms = params['issue_search']
@merge_requests = merge_requests_collection
......@@ -130,6 +132,47 @@ class Projects::MergeRequestsController < Projects::ApplicationController
end
end
def conflicts
respond_to do |format|
format.html { define_discussion_vars }
format.json do
if @merge_request.conflicts_can_be_resolved_in_ui?
render json: @merge_request.conflicts
elsif @merge_request.can_be_merged?
render json: {
message: 'The merge conflicts for this merge request have already been resolved. Please return to the merge request.',
type: 'error'
}
else
render json: {
message: 'The merge conflicts for this merge request cannot be resolved through GitLab. Please try to resolve them locally.',
type: 'error'
}
end
end
end
end
def resolve_conflicts
return render_404 unless @merge_request.conflicts_can_be_resolved_in_ui?
if @merge_request.can_be_merged?
render status: :bad_request, json: { message: 'The merge conflicts for this merge request have already been resolved.' }
return
end
begin
MergeRequests::ResolveService.new(@merge_request.source_project, current_user, params).execute(@merge_request)
flash[:notice] = 'All merge conflicts were resolved. The merge request can now be merged.'
render json: { redirect_to: namespace_project_merge_request_url(@project.namespace, @project, @merge_request, resolved_conflicts: true) }
rescue Gitlab::Conflict::File::MissingResolution => e
render status: :bad_request, json: { message: e.message }
end
end
def builds
respond_to do |format|
format.html do
......@@ -353,6 +396,10 @@ class Projects::MergeRequestsController < Projects::ApplicationController
return render_404 unless can?(current_user, :admin_merge_request, @merge_request)
end
def authorize_can_resolve_conflicts!
return render_404 unless @merge_request.conflicts_can_be_resolved_by?(current_user)
end
def module_enabled
return render_404 unless @project.merge_requests_enabled
end
......@@ -427,7 +474,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController
noteable_id: @merge_request.id
}
@use_legacy_diff_notes = !@merge_request.support_new_diff_notes?
@use_legacy_diff_notes = !@merge_request.has_complete_diff_refs?
@grouped_diff_discussions = @merge_request.notes.inc_author_project_award_emoji.grouped_diff_discussions
Banzai::NoteRenderer.render(
......
......@@ -24,6 +24,7 @@ module NavHelper
current_path?('merge_requests#diffs') ||
current_path?('merge_requests#commits') ||
current_path?('merge_requests#builds') ||
current_path?('merge_requests#conflicts') ||
current_path?('issues#show')
if cookies[:collapsed_gutter] == 'true'
"page-gutter right-sidebar-collapsed"
......
......@@ -75,7 +75,7 @@ class DiffNote < Note
private
def supported?
!self.for_merge_request? || self.noteable.support_new_diff_notes?
!self.for_merge_request? || self.noteable.has_complete_diff_refs?
end
def noteable_diff_refs
......
......@@ -701,12 +701,12 @@ class MergeRequest < ActiveRecord::Base
merge_commit
end
def support_new_diff_notes?
def has_complete_diff_refs?
diff_sha_refs && diff_sha_refs.complete?
end
def update_diff_notes_positions(old_diff_refs:, new_diff_refs:)
return unless support_new_diff_notes?
return unless has_complete_diff_refs?
return if new_diff_refs == old_diff_refs
active_diff_notes = self.notes.diff_notes.select do |note|
......@@ -734,4 +734,26 @@ class MergeRequest < ActiveRecord::Base
def keep_around_commit
project.repository.keep_around(self.merge_commit_sha)
end
def conflicts
@conflicts ||= Gitlab::Conflict::FileCollection.new(self)
end
def conflicts_can_be_resolved_by?(user)
access = ::Gitlab::UserAccess.new(user, project: source_project)
access.can_push_to_branch?(source_branch)
end
def conflicts_can_be_resolved_in_ui?
return @conflicts_can_be_resolved_in_ui if defined?(@conflicts_can_be_resolved_in_ui)
return @conflicts_can_be_resolved_in_ui = false unless cannot_be_merged?
return @conflicts_can_be_resolved_in_ui = false unless has_complete_diff_refs?
begin
@conflicts_can_be_resolved_in_ui = conflicts.files.each(&:lines)
rescue Gitlab::Conflict::Parser::ParserError, Gitlab::Conflict::FileCollection::ConflictSideMissing
@conflicts_can_be_resolved_in_ui = false
end
end
end
......@@ -869,6 +869,14 @@ class Repository
end
end
def resolve_conflicts(user, branch, params)
commit_with_hooks(user, branch) do
committer = user_to_committer(user)
Rugged::Commit.create(rugged, params.merge(author: committer, committer: committer))
end
end
def check_revert_content(commit, base_branch)
source_sha = find_branch(base_branch).target.sha
args = [commit.id, source_sha]
......
module MergeRequests
class ResolveService < MergeRequests::BaseService
attr_accessor :conflicts, :rugged, :merge_index
def execute(merge_request)
@conflicts = merge_request.conflicts
@rugged = project.repository.rugged
@merge_index = conflicts.merge_index
conflicts.files.each do |file|
write_resolved_file_to_index(file, params[:sections])
end
commit_params = {
message: params[:commit_message] || conflicts.default_commit_message,
parents: [conflicts.our_commit, conflicts.their_commit].map(&:oid),
tree: merge_index.write_tree(rugged)
}
project.repository.resolve_conflicts(current_user, merge_request.source_branch, commit_params)
end
def write_resolved_file_to_index(file, resolutions)
new_file = file.resolve_lines(resolutions).map(&:text).join("\n")
our_path = file.our_path
merge_index.add(path: our_path, oid: rugged.write(new_file, :blob), mode: file.our_mode)
merge_index.conflict_remove(our_path)
end
end
end
- class_bindings = "{ |
'head': line.isHead, |
'origin': line.isOrigin, |
'match': line.hasMatch, |
'selected': line.isSelected, |
'unselected': line.isUnselected }"
- page_title "Merge Conflicts", "#{@merge_request.title} (#{@merge_request.to_reference}", "Merge Requests"
= render "projects/merge_requests/show/mr_title"
.merge-request-details.issuable-details
= render "projects/merge_requests/show/mr_box"
= render 'shared/issuable/sidebar', issuable: @merge_request
#conflicts{"v-cloak" => "true", data: { conflicts_path: conflicts_namespace_project_merge_request_path(@merge_request.project.namespace, @merge_request.project, @merge_request, format: :json),
resolve_conflicts_path: resolve_conflicts_namespace_project_merge_request_path(@merge_request.project.namespace, @merge_request.project, @merge_request) } }
.loading{"v-if" => "isLoading"}
%i.fa.fa-spinner.fa-spin
.nothing-here-block{"v-if" => "hasError"}
{{conflictsData.errorMessage}}
= render partial: "projects/merge_requests/conflicts/commit_stats"
.files-wrapper{"v-if" => "!isLoading && !hasError"}
= render partial: "projects/merge_requests/conflicts/parallel_view", locals: { class_bindings: class_bindings }
= render partial: "projects/merge_requests/conflicts/inline_view", locals: { class_bindings: class_bindings }
= render partial: "projects/merge_requests/conflicts/submit_form"
.content-block.oneline-block.files-changed{"v-if" => "!isLoading && !hasError"}
.inline-parallel-buttons
.btn-group
%a.btn{ |
":class" => "{'active': !isParallel}", |
"@click" => "handleViewTypeChange('inline')"}
Inline
%a.btn{ |
":class" => "{'active': isParallel}", |
"@click" => "handleViewTypeChange('parallel')"}
Side-by-side
.js-toggle-container
.commit-stat-summary
Showing
%strong.cred {{conflictsCount}} {{conflictsData.conflictsText}}
between
%strong {{conflictsData.source_branch}}
and
%strong {{conflictsData.target_branch}}
.files{"v-show" => "!isParallel"}
.diff-file.file-holder.conflict.inline-view{"v-for" => "file in conflictsData.files"}
.file-title
%i.fa.fa-fw{":class" => "file.iconClass"}
%strong {{file.filePath}}
.file-actions
%a.btn.view-file.btn-file-option{":href" => "file.blobPath"}
View file @{{conflictsData.shortCommitSha}}
.diff-content.diff-wrap-lines
.diff-wrap-lines.code.file-content.js-syntax-highlight
%table
%tr.line_holder.diff-inline{"v-for" => "line in file.inlineLines"}
%template{"v-if" => "!line.isHeader"}
%td.diff-line-num.new_line{":class" => class_bindings}
%a {{line.new_line}}
%td.diff-line-num.old_line{":class" => class_bindings}
%a {{line.old_line}}
%td.line_content{":class" => class_bindings}
{{{line.richText}}}
%template{"v-if" => "line.isHeader"}
%td.diff-line-num.header{":class" => class_bindings}
%td.diff-line-num.header{":class" => class_bindings}
%td.line_content.header{":class" => class_bindings}
%strong {{{line.richText}}}
%button.btn{"@click" => "handleSelected(line.id, line.section)"}
{{line.buttonTitle}}
.files{"v-show" => "isParallel"}
.diff-file.file-holder.conflict.parallel-view{"v-for" => "file in conflictsData.files"}
.file-title
%i.fa.fa-fw{":class" => "file.iconClass"}
%strong {{file.filePath}}
.file-actions
%a.btn.view-file.btn-file-option{":href" => "file.blobPath"}
View file @{{conflictsData.shortCommitSha}}
.diff-content.diff-wrap-lines
.diff-wrap-lines.code.file-content.js-syntax-highlight
%table
%tr.line_holder.parallel{"v-for" => "section in file.parallelLines"}
%template{"v-for" => "line in section"}
%template{"v-if" => "line.isHeader"}
%td.diff-line-num.header{":class" => class_bindings}
%td.line_content.header{":class" => class_bindings}
%strong {{line.richText}}
%button.btn{"@click" => "handleSelected(line.id, line.section)"}
{{line.buttonTitle}}
%template{"v-if" => "!line.isHeader"}
%td.diff-line-num.old_line{":class" => class_bindings}
{{line.lineNumber}}
%td.line_content.parallel{":class" => class_bindings}
{{{line.richText}}}
.content-block.oneline-block.files-changed
%strong.resolved-count {{resolvedCount}}
of
%strong.total-count {{conflictsCount}}
conflicts have been resolved
.commit-message-container.form-group
.max-width-marker
%textarea.form-control.js-commit-message{"v-model" => "conflictsData.commitMessage"}
{{{conflictsData.commitMessage}}}
%button{type: "button", class: "btn btn-success js-submit-button", ":disabled" => "!readyToCommit", "@click" => "commit()"}
%span {{commitButtonText}}
= link_to "Cancel", namespace_project_merge_request_path(@merge_request.project.namespace, @merge_request.project, @merge_request), class: "btn btn-cancel"
......@@ -6,7 +6,7 @@
- if @merge_request.merge_event
by #{link_to_member(@project, @merge_request.merge_event.author, avatar: true)}
#{time_ago_with_tooltip(@merge_request.merge_event.created_at)}
- if !@merge_request.source_branch_exists? || (params[:delete_source] == 'true')
- if !@merge_request.source_branch_exists? || params[:deleted_source_branch]
%p
The changes were merged into
#{link_to @merge_request.target_branch, namespace_project_commits_path(@project.namespace, @project, @merge_request.target_branch), class: "label-branch"}.
......
.mr-state-widget
= render 'projects/merge_requests/widget/heading'
.mr-widget-body
-# After conflicts are resolved, the user is redirected back to the MR page.
-# There is a short window before background workers run and GitLab processes
-# the new push and commits, during which it will think the conflicts still exist.
-# We send this param to get the widget to treat the MR as having no more conflicts.
- resolved_conflicts = params[:resolved_conflicts]
- if @project.archived?
= render 'projects/merge_requests/widget/open/archived'
- elsif @merge_request.commits.blank?
......@@ -9,7 +15,7 @@
= render 'projects/merge_requests/widget/open/missing_branch'
- elsif @merge_request.unchecked?
= render 'projects/merge_requests/widget/open/check'
- elsif @merge_request.cannot_be_merged?
- elsif @merge_request.cannot_be_merged? && !resolved_conflicts
= render 'projects/merge_requests/widget/open/conflicts'
- elsif @merge_request.work_in_progress?
= render 'projects/merge_requests/widget/open/wip'
......@@ -19,7 +25,7 @@
= render 'projects/merge_requests/widget/open/not_allowed'
- elsif !@merge_request.mergeable_ci_state? && @pipeline && @pipeline.failed?
= render 'projects/merge_requests/widget/open/build_failed'
- elsif @merge_request.can_be_merged?
- elsif @merge_request.can_be_merged? || resolved_conflicts
= render 'projects/merge_requests/widget/open/accept'
- if mr_closes_issues.present?
......
......@@ -3,7 +3,18 @@
This merge request contains merge conflicts
%p
Please resolve these conflicts or
Please
- if @merge_request.conflicts_can_be_resolved_by?(current_user)
- if @merge_request.conflicts_can_be_resolved_in_ui?
= link_to "resolve these conflicts", conflicts_namespace_project_merge_request_path(@project.namespace, @project, @merge_request)
- else
%span.has-tooltip{title: "These conflicts cannot be resolved through GitLab"}
resolve these conflicts locally
- else
resolve these conflicts
or
- if @merge_request.can_be_merged_via_command_line_by?(current_user)
#{link_to "merge this request manually", "#modal_merge_info", class: "how_to_merge_link vlink", "data-toggle" => "modal"}.
- else
......
......@@ -722,6 +722,7 @@ Rails.application.routes.draw do
member do
get :commits
get :diffs
get :conflicts
get :builds
get :pipelines
get :merge_check
......@@ -732,6 +733,7 @@ Rails.application.routes.draw do
post :toggle_award_emoji
post :remove_wip
get :diff_for_path
post :resolve_conflicts
end
collection do
......
# Merge conflict resolution
> [Introduced][ce-5479] in GitLab 8.11.
When a merge request has conflicts, GitLab may provide the option to resolve
those conflicts in the GitLab UI. (See
[conflicts available for resolution](#conflicts-available-for-resolution) for
more information on when this is available.) If this is an option, you will see
a **resolve these conflicts** link in the merge request widget:
![Merge request widget](img/merge_request_widget.png)
Clicking this will show a list of files with conflicts, with conflict sections
highlighted:
![Conflict section](img/conflict_section.png)
Once all conflicts have been marked as using 'ours' or 'theirs', the conflict
can be resolved. This will perform a merge of the target branch of the merge
request into the source branch, resolving the conflicts using the options
chosen. If the source branch is `feature` and the target branch is `master`,
this is similar to performing `git checkout feature; git merge master` locally.
## Conflicts available for resolution
GitLab allows resolving conflicts in a file where all of the below are true:
- The file is text, not binary
- The file does not already contain conflict markers
- The file, with conflict markers added, is not over 200 KB in size
- The file exists under the same path in both branches
If any file with conflicts in that merge request does not meet all of these
criteria, the conflicts for that merge request cannot be resolved in the UI.
Additionally, GitLab does not detect conflicts in renames away from a path. For
example, this will not create a conflict: on branch `a`, doing `git mv file1
file2`; on branch `b`, doing `git mv file1 file3`. Instead, both files will be
present in the branch after the merge request is merged.
[ce-5479]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/5479
module Gitlab
module Conflict
class File
include Gitlab::Routing.url_helpers
include IconsHelper
class MissingResolution < StandardError
end
CONTEXT_LINES = 3
attr_reader :merge_file_result, :their_path, :our_path, :our_mode, :merge_request, :repository
def initialize(merge_file_result, conflict, merge_request:)
@merge_file_result = merge_file_result
@their_path = conflict[:theirs][:path]
@our_path = conflict[:ours][:path]
@our_mode = conflict[:ours][:mode]
@merge_request = merge_request
@repository = merge_request.project.repository
@match_line_headers = {}
end
# Array of Gitlab::Diff::Line objects
def lines
@lines ||= Gitlab::Conflict::Parser.new.parse(merge_file_result[:data],
our_path: our_path,
their_path: their_path,
parent_file: self)
end
def resolve_lines(resolution)
section_id = nil
lines.map do |line|
unless line.type
section_id = nil
next line
end
section_id ||= line_code(line)
case resolution[section_id]
when 'head'
next unless line.type == 'new'
when 'origin'
next unless line.type == 'old'
else
raise MissingResolution, "Missing resolution for section ID: #{section_id}"
end
line
end.compact
end
def highlight_lines!
their_file = lines.reject { |line| line.type == 'new' }.map(&:text).join("\n")
our_file = lines.reject { |line| line.type == 'old' }.map(&:text).join("\n")
their_highlight = Gitlab::Highlight.highlight(their_path, their_file, repository: repository).lines
our_highlight = Gitlab::Highlight.highlight(our_path, our_file, repository: repository).lines
lines.each do |line|
if line.type == 'old'
line.rich_text = their_highlight[line.old_line - 1].try(:html_safe)
else
line.rich_text = our_highlight[line.new_line - 1].try(:html_safe)
end
end
end
def sections
return @sections if @sections
chunked_lines = lines.chunk { |line| line.type.nil? }.to_a
match_line = nil
sections_count = chunked_lines.size
@sections = chunked_lines.flat_map.with_index do |(no_conflict, lines), i|
section = nil
# We need to reduce context sections to CONTEXT_LINES. Conflict sections are
# always shown in full.
if no_conflict
conflict_before = i > 0
conflict_after = (sections_count - i) > 1
if conflict_before && conflict_after
# Create a gap in a long context section.
if lines.length > CONTEXT_LINES * 2
head_lines = lines.first(CONTEXT_LINES)
tail_lines = lines.last(CONTEXT_LINES)
# Ensure any existing match line has text for all lines up to the last
# line of its context.
update_match_line_text(match_line, head_lines.last)
# Insert a new match line after the created gap.
match_line = create_match_line(tail_lines.first)
section = [
{ conflict: false, lines: head_lines },
{ conflict: false, lines: tail_lines.unshift(match_line) }
]
end
elsif conflict_after
tail_lines = lines.last(CONTEXT_LINES)
# Create a gap and insert a match line at the start.
if lines.length > tail_lines.length
match_line = create_match_line(tail_lines.first)
tail_lines.unshift(match_line)
end
lines = tail_lines
elsif conflict_before
# We're at the end of the file (no conflicts after), so just remove extra
# trailing lines.
lines = lines.first(CONTEXT_LINES)
end
end
# We want to update the match line's text every time unless we've already
# created a gap and its corresponding match line.
update_match_line_text(match_line, lines.last) unless section
section ||= { conflict: !no_conflict, lines: lines }
section[:id] = line_code(lines.first) unless no_conflict
section
end
end
def line_code(line)
Gitlab::Diff::LineCode.generate(our_path, line.new_pos, line.old_pos)
end
def create_match_line(line)
Gitlab::Diff::Line.new('', 'match', line.index, line.old_pos, line.new_pos)
end
# Any line beginning with a letter, an underscore, or a dollar can be used in a
# match line header. Only context sections can contain match lines, as match lines
# have to exist in both versions of the file.
def find_match_line_header(index)
return @match_line_headers[index] if @match_line_headers.key?(index)
@match_line_headers[index] = begin
if index >= 0
line = lines[index]
if line.type.nil? && line.text.match(/\A[A-Za-z$_]/)
" #{line.text}"
else
find_match_line_header(index - 1)
end
end
end
end
# Set the match line's text for the current line. A match line takes its start
# position and context header (where present) from itself, and its end position from
# the line passed in.
def update_match_line_text(match_line, line)
return unless match_line
header = find_match_line_header(match_line.index - 1)
match_line.text = "@@ -#{match_line.old_pos},#{line.old_pos} +#{match_line.new_pos},#{line.new_pos} @@#{header}"
end
def as_json(opts = nil)
{
old_path: their_path,
new_path: our_path,
blob_icon: file_type_icon_class('file', our_mode, our_path),
blob_path: namespace_project_blob_path(merge_request.project.namespace,
merge_request.project,
::File.join(merge_request.diff_refs.head_sha, our_path)),
sections: sections
}
end
end
end
end
module Gitlab
module Conflict
class FileCollection
class ConflictSideMissing < StandardError
end
attr_reader :merge_request, :our_commit, :their_commit
def initialize(merge_request)
@merge_request = merge_request
@our_commit = merge_request.source_branch_head.raw.raw_commit
@their_commit = merge_request.target_branch_head.raw.raw_commit
end
def repository
merge_request.project.repository
end
def merge_index
@merge_index ||= repository.rugged.merge_commits(our_commit, their_commit)
end
def files
@files ||= merge_index.conflicts.map do |conflict|
raise ConflictSideMissing unless conflict[:theirs] && conflict[:ours]
Gitlab::Conflict::File.new(merge_index.merge_file(conflict[:ours][:path]),
conflict,
merge_request: merge_request)
end
end
def as_json(opts = nil)
{
target_branch: merge_request.target_branch,
source_branch: merge_request.source_branch,
commit_sha: merge_request.diff_head_sha,
commit_message: default_commit_message,
files: files
}
end
def default_commit_message
conflict_filenames = merge_index.conflicts.map do |conflict|
"# #{conflict[:ours][:path]}"
end
<<EOM.chomp
Merge branch '#{merge_request.target_branch}' into '#{merge_request.source_branch}'
# Conflicts:
#{conflict_filenames.join("\n")}
EOM
end
end
end
end
module Gitlab
module Conflict
class Parser
class ParserError < StandardError
end
class UnexpectedDelimiter < ParserError
end
class MissingEndDelimiter < ParserError
end
class UnmergeableFile < ParserError
end
def parse(text, our_path:, their_path:, parent_file: nil)
raise UnmergeableFile if text.blank? # Typically a binary file
raise UnmergeableFile if text.length > 102400
line_obj_index = 0
line_old = 1
line_new = 1
type = nil
lines = []
conflict_start = "<<<<<<< #{our_path}"
conflict_middle = '======='
conflict_end = ">>>>>>> #{their_path}"
text.each_line.map do |line|
full_line = line.delete("\n")
if full_line == conflict_start
raise UnexpectedDelimiter unless type.nil?
type = 'new'
elsif full_line == conflict_middle
raise UnexpectedDelimiter unless type == 'new'
type = 'old'
elsif full_line == conflict_end
raise UnexpectedDelimiter unless type == 'old'
type = nil
elsif line[0] == '\\'
type = 'nonewline'
lines << Gitlab::Diff::Line.new(full_line, type, line_obj_index, line_old, line_new, parent_file: parent_file)
else
lines << Gitlab::Diff::Line.new(full_line, type, line_obj_index, line_old, line_new, parent_file: parent_file)
line_old += 1 if type != 'new'
line_new += 1 if type != 'old'
line_obj_index += 1
end
end
raise MissingEndDelimiter unless type.nil?
lines
end
end
end
end
......@@ -2,11 +2,13 @@ module Gitlab
module Diff
class Line
attr_reader :type, :index, :old_pos, :new_pos
attr_writer :rich_text
attr_accessor :text
def initialize(text, type, index, old_pos, new_pos)
def initialize(text, type, index, old_pos, new_pos, parent_file: nil)
@text, @type, @index = text, type, index
@old_pos, @new_pos = old_pos, new_pos
@parent_file = parent_file
end
def self.init_from_hash(hash)
......@@ -43,9 +45,25 @@ module Gitlab
type == 'old'
end
def rich_text
@parent_file.highlight_lines! if @parent_file && !@rich_text
@rich_text
end
def meta?
type == 'match' || type == 'nonewline'
end
def as_json(opts = nil)
{
type: type,
old_line: old_line,
new_line: new_line,
text: text,
rich_text: rich_text || text
}
end
end
end
end
......@@ -4,6 +4,11 @@ describe Projects::MergeRequestsController do
let(:project) { create(:project) }
let(:user) { create(:user) }
let(:merge_request) { create(:merge_request_with_diffs, target_project: project, source_project: project) }
let(:merge_request_with_conflicts) do
create(:merge_request, source_branch: 'conflict-resolvable', target_branch: 'conflict-start', source_project: project) do |mr|
mr.mark_as_unmergeable
end
end
before do
sign_in(user)
......@@ -523,4 +528,135 @@ describe Projects::MergeRequestsController do
end
end
end
describe 'GET conflicts' do
let(:json_response) { JSON.parse(response.body) }
context 'when the conflicts cannot be resolved in the UI' do
before do
allow_any_instance_of(Gitlab::Conflict::Parser).
to receive(:parse).and_raise(Gitlab::Conflict::Parser::UnexpectedDelimiter)
get :conflicts,
namespace_id: merge_request_with_conflicts.project.namespace.to_param,
project_id: merge_request_with_conflicts.project.to_param,
id: merge_request_with_conflicts.iid,
format: 'json'
end
it 'returns a 200 status code' do
expect(response).to have_http_status(:ok)
end
it 'returns JSON with a message' do
expect(json_response.keys).to contain_exactly('message', 'type')
end
end
context 'with valid conflicts' do
before do
get :conflicts,
namespace_id: merge_request_with_conflicts.project.namespace.to_param,
project_id: merge_request_with_conflicts.project.to_param,
id: merge_request_with_conflicts.iid,
format: 'json'
end
it 'includes meta info about the MR' do
expect(json_response['commit_message']).to include('Merge branch')
expect(json_response['commit_sha']).to match(/\h{40}/)
expect(json_response['source_branch']).to eq(merge_request_with_conflicts.source_branch)
expect(json_response['target_branch']).to eq(merge_request_with_conflicts.target_branch)
end
it 'includes each file that has conflicts' do
filenames = json_response['files'].map { |file| file['new_path'] }
expect(filenames).to contain_exactly('files/ruby/popen.rb', 'files/ruby/regex.rb')
end
it 'splits files into sections with lines' do
json_response['files'].each do |file|
file['sections'].each do |section|
expect(section).to include('conflict', 'lines')
section['lines'].each do |line|
if section['conflict']
expect(line['type']).to be_in(['old', 'new'])
expect(line.values_at('old_line', 'new_line')).to contain_exactly(nil, a_kind_of(Integer))
else
if line['type'].nil?
expect(line['old_line']).not_to eq(nil)
expect(line['new_line']).not_to eq(nil)
else
expect(line['type']).to eq('match')
expect(line['old_line']).to eq(nil)
expect(line['new_line']).to eq(nil)
end
end
end
end
end
end
it 'has unique section IDs across files' do
section_ids = json_response['files'].flat_map do |file|
file['sections'].map { |section| section['id'] }.compact
end
expect(section_ids.uniq).to eq(section_ids)
end
end
end
context 'POST resolve_conflicts' do
let(:json_response) { JSON.parse(response.body) }
let!(:original_head_sha) { merge_request_with_conflicts.diff_head_sha }
def resolve_conflicts(sections)
post :resolve_conflicts,
namespace_id: merge_request_with_conflicts.project.namespace.to_param,
project_id: merge_request_with_conflicts.project.to_param,
id: merge_request_with_conflicts.iid,
format: 'json',
sections: sections,
commit_message: 'Commit message'
end
context 'with valid params' do
before do
resolve_conflicts('2f6fcd96b88b36ce98c38da085c795a27d92a3dd_14_14' => 'head',
'6eb14e00385d2fb284765eb1cd8d420d33d63fc9_9_9' => 'head',
'6eb14e00385d2fb284765eb1cd8d420d33d63fc9_21_21' => 'origin',
'6eb14e00385d2fb284765eb1cd8d420d33d63fc9_49_49' => 'origin')
end
it 'creates a new commit on the branch' do
expect(original_head_sha).not_to eq(merge_request_with_conflicts.source_branch_head.sha)
expect(merge_request_with_conflicts.source_branch_head.message).to include('Commit message')
end
it 'returns an OK response' do
expect(response).to have_http_status(:ok)
end
end
context 'when sections are missing' do
before do
resolve_conflicts('2f6fcd96b88b36ce98c38da085c795a27d92a3dd_14_14' => 'head')
end
it 'returns a 400 error' do
expect(response).to have_http_status(:bad_request)
end
it 'has a message with the name of the first missing section' do
expect(json_response['message']).to include('6eb14e00385d2fb284765eb1cd8d420d33d63fc9_9_9')
end
it 'does not create a new commit' do
expect(original_head_sha).to eq(merge_request_with_conflicts.source_branch_head.sha)
end
end
end
end
require 'spec_helper'
feature 'Merge request conflict resolution', js: true, feature: true do
include WaitForAjax
let(:user) { create(:user) }
let(:project) { create(:project) }
def create_merge_request(source_branch)
create(:merge_request, source_branch: source_branch, target_branch: 'conflict-start', source_project: project) do |mr|
mr.mark_as_unmergeable
end
end
context 'when a merge request can be resolved in the UI' do
let(:merge_request) { create_merge_request('conflict-resolvable') }
before do
project.team << [user, :developer]
login_as(user)
visit namespace_project_merge_request_path(project.namespace, project, merge_request)
end
it 'shows a link to the conflict resolution page' do
expect(page).to have_link('conflicts', href: /\/conflicts\Z/)
end
context 'visiting the conflicts resolution page' do
before { click_link('conflicts', href: /\/conflicts\Z/) }
it 'shows the conflicts' do
begin
expect(find('#conflicts')).to have_content('popen.rb')
rescue Capybara::Poltergeist::JavascriptError
retry
end
end
end
end
UNRESOLVABLE_CONFLICTS = {
'conflict-too-large' => 'when the conflicts contain a large file',
'conflict-binary-file' => 'when the conflicts contain a binary file',
'conflict-contains-conflict-markers' => 'when the conflicts contain a file with ambiguous conflict markers',
'conflict-missing-side' => 'when the conflicts contain a file edited in one branch and deleted in another'
}
UNRESOLVABLE_CONFLICTS.each do |source_branch, description|
context description do
let(:merge_request) { create_merge_request(source_branch) }
before do
project.team << [user, :developer]
login_as(user)
visit namespace_project_merge_request_path(project.namespace, project, merge_request)
end
it 'does not show a link to the conflict resolution page' do
expect(page).not_to have_link('conflicts', href: /\/conflicts\Z/)
end
it 'shows an error if the conflicts page is visited directly' do
visit current_url + '/conflicts'
wait_for_ajax
expect(find('#conflicts')).to have_content('Please try to resolve them locally.')
end
end
end
end
require 'spec_helper'
describe Gitlab::Conflict::FileCollection, lib: true do
let(:merge_request) { create(:merge_request, source_branch: 'conflict-resolvable', target_branch: 'conflict-start') }
let(:file_collection) { Gitlab::Conflict::FileCollection.new(merge_request) }
describe '#files' do
it 'returns an array of Conflict::Files' do
expect(file_collection.files).to all(be_an_instance_of(Gitlab::Conflict::File))
end
end
describe '#default_commit_message' do
it 'matches the format of the git CLI commit message' do
expect(file_collection.default_commit_message).to eq(<<EOM.chomp)
Merge branch 'conflict-start' into 'conflict-resolvable'
# Conflicts:
# files/ruby/popen.rb
# files/ruby/regex.rb
EOM
end
end
end
require 'spec_helper'
describe Gitlab::Conflict::File, lib: true do
let(:project) { create(:project) }
let(:repository) { project.repository }
let(:rugged) { repository.rugged }
let(:their_commit) { rugged.branches['conflict-start'].target }
let(:our_commit) { rugged.branches['conflict-resolvable'].target }
let(:merge_request) { create(:merge_request, source_branch: 'conflict-resolvable', target_branch: 'conflict-start', source_project: project) }
let(:index) { rugged.merge_commits(our_commit, their_commit) }
let(:conflict) { index.conflicts.last }
let(:merge_file_result) { index.merge_file('files/ruby/regex.rb') }
let(:conflict_file) { Gitlab::Conflict::File.new(merge_file_result, conflict, merge_request: merge_request) }
describe '#resolve_lines' do
let(:section_keys) { conflict_file.sections.map { |section| section[:id] }.compact }
context 'when resolving everything to the same side' do
let(:resolution_hash) { section_keys.map { |key| [key, 'head'] }.to_h }
let(:resolved_lines) { conflict_file.resolve_lines(resolution_hash) }
let(:expected_lines) { conflict_file.lines.reject { |line| line.type == 'old' } }
it 'has the correct number of lines' do
expect(resolved_lines.length).to eq(expected_lines.length)
end
it 'has content matching the chosen lines' do
expect(resolved_lines.map(&:text)).to eq(expected_lines.map(&:text))
end
end
context 'with mixed resolutions' do
let(:resolution_hash) do
section_keys.map.with_index { |key, i| [key, i.even? ? 'head' : 'origin'] }.to_h
end
let(:resolved_lines) { conflict_file.resolve_lines(resolution_hash) }
it 'has the correct number of lines' do
file_lines = conflict_file.lines.reject { |line| line.type == 'new' }
expect(resolved_lines.length).to eq(file_lines.length)
end
it 'returns a file containing only the chosen parts of the resolved sections' do
expect(resolved_lines.chunk { |line| line.type || 'both' }.map(&:first)).
to eq(['both', 'new', 'both', 'old', 'both', 'new', 'both'])
end
end
it 'raises MissingResolution when passed a hash without resolutions for all sections' do
empty_hash = section_keys.map { |key| [key, nil] }.to_h
invalid_hash = section_keys.map { |key| [key, 'invalid'] }.to_h
expect { conflict_file.resolve_lines({}) }.
to raise_error(Gitlab::Conflict::File::MissingResolution)
expect { conflict_file.resolve_lines(empty_hash) }.
to raise_error(Gitlab::Conflict::File::MissingResolution)
expect { conflict_file.resolve_lines(invalid_hash) }.
to raise_error(Gitlab::Conflict::File::MissingResolution)
end
end
describe '#highlight_lines!' do
def html_to_text(html)
CGI.unescapeHTML(ActionView::Base.full_sanitizer.sanitize(html)).delete("\n")
end
it 'modifies the existing lines' do
expect { conflict_file.highlight_lines! }.to change { conflict_file.lines.map(&:instance_variables) }
end
it 'is called implicitly when rich_text is accessed on a line' do
expect(conflict_file).to receive(:highlight_lines!).once.and_call_original
conflict_file.lines.each(&:rich_text)
end
it 'sets the rich_text of the lines matching the text content' do
conflict_file.lines.each do |line|
expect(line.text).to eq(html_to_text(line.rich_text))
end
end
end
describe '#sections' do
it 'only inserts match lines when there is a gap between sections' do
conflict_file.sections.each_with_index do |section, i|
previous_line_number = 0
current_line_number = section[:lines].map(&:old_line).compact.min
if i > 0
previous_line_number = conflict_file.sections[i - 1][:lines].map(&:old_line).compact.last
end
if current_line_number == previous_line_number + 1
expect(section[:lines].first.type).not_to eq('match')
else
expect(section[:lines].first.type).to eq('match')
expect(section[:lines].first.text).to match(/\A@@ -#{current_line_number},\d+ \+\d+,\d+ @@ module Gitlab\Z/)
end
end
end
it 'sets conflict to false for sections with only unchanged lines' do
conflict_file.sections.reject { |section| section[:conflict] }.each do |section|
without_match = section[:lines].reject { |line| line.type == 'match' }
expect(without_match).to all(have_attributes(type: nil))
end
end
it 'only includes a maximum of CONTEXT_LINES (plus an optional match line) in context sections' do
conflict_file.sections.reject { |section| section[:conflict] }.each do |section|
without_match = section[:lines].reject { |line| line.type == 'match' }
expect(without_match.length).to be <= Gitlab::Conflict::File::CONTEXT_LINES * 2
end
end
it 'sets conflict to true for sections with only changed lines' do
conflict_file.sections.select { |section| section[:conflict] }.each do |section|
section[:lines].each do |line|
expect(line.type).to be_in(['new', 'old'])
end
end
end
it 'adds unique IDs to conflict sections, and not to other sections' do
section_ids = []
conflict_file.sections.each do |section|
if section[:conflict]
expect(section).to have_key(:id)
section_ids << section[:id]
else
expect(section).not_to have_key(:id)
end
end
expect(section_ids.uniq).to eq(section_ids)
end
context 'with an example file' do
let(:file) do
<<FILE
# Ensure there is no match line header here
def username_regexp
default_regexp
end
<<<<<<< files/ruby/regex.rb
def project_name_regexp
/\A[a-zA-Z0-9][a-zA-Z0-9_\-\. ]*\z/
end
def name_regexp
/\A[a-zA-Z0-9_\-\. ]*\z/
=======
def project_name_regex
%r{\A[a-zA-Z0-9][a-zA-Z0-9_\-\. ]*\z}
end
def name_regex
%r{\A[a-zA-Z0-9_\-\. ]*\z}
>>>>>>> files/ruby/regex.rb
end
# Some extra lines
# To force a match line
# To be created
def path_regexp
default_regexp
end
<<<<<<< files/ruby/regex.rb
def archive_formats_regexp
/(zip|tar|7z|tar\.gz|tgz|gz|tar\.bz2|tbz|tbz2|tb2|bz2)/
=======
def archive_formats_regex
%r{(zip|tar|7z|tar\.gz|tgz|gz|tar\.bz2|tbz|tbz2|tb2|bz2)}
>>>>>>> files/ruby/regex.rb
end
def git_reference_regexp
# Valid git ref regexp, see:
# https://www.kernel.org/pub/software/scm/git/docs/git-check-ref-format.html
%r{
(?!
(?# doesn't begins with)
\/| (?# rule #6)
(?# doesn't contain)
.*(?:
[\/.]\.| (?# rule #1,3)
\/\/| (?# rule #6)
@\{| (?# rule #8)
\\ (?# rule #9)
)
)
[^\000-\040\177~^:?*\[]+ (?# rule #4-5)
(?# doesn't end with)
(?<!\.lock) (?# rule #1)
(?<![\/.]) (?# rule #6-7)
}x
end
protected
<<<<<<< files/ruby/regex.rb
def default_regexp
/\A[.?]?[a-zA-Z0-9][a-zA-Z0-9_\-\.]*(?<!\.git)\z/
=======
def default_regex
%r{\A[.?]?[a-zA-Z0-9][a-zA-Z0-9_\-\.]*(?<!\.git)\z}
>>>>>>> files/ruby/regex.rb
end
FILE
end
let(:conflict_file) { Gitlab::Conflict::File.new({ data: file }, conflict, merge_request: merge_request) }
let(:sections) { conflict_file.sections }
it 'sets the correct match line headers' do
expect(sections[0][:lines].first).to have_attributes(type: 'match', text: '@@ -3,14 +3,14 @@')
expect(sections[3][:lines].first).to have_attributes(type: 'match', text: '@@ -19,26 +19,26 @@ def path_regexp')
expect(sections[6][:lines].first).to have_attributes(type: 'match', text: '@@ -47,52 +47,52 @@ end')
end
it 'does not add match lines where they are not needed' do
expect(sections[1][:lines].first.type).not_to eq('match')
expect(sections[2][:lines].first.type).not_to eq('match')
expect(sections[4][:lines].first.type).not_to eq('match')
expect(sections[5][:lines].first.type).not_to eq('match')
expect(sections[7][:lines].first.type).not_to eq('match')
end
it 'creates context sections of the correct length' do
expect(sections[0][:lines].reject(&:type).length).to eq(3)
expect(sections[2][:lines].reject(&:type).length).to eq(3)
expect(sections[3][:lines].reject(&:type).length).to eq(3)
expect(sections[5][:lines].reject(&:type).length).to eq(3)
expect(sections[6][:lines].reject(&:type).length).to eq(3)
expect(sections[8][:lines].reject(&:type).length).to eq(1)
end
end
end
describe '#as_json' do
it 'includes the blob path for the file' do
expect(conflict_file.as_json[:blob_path]).
to eq("/#{project.namespace.to_param}/#{merge_request.project.to_param}/blob/#{our_commit.oid}/files/ruby/regex.rb")
end
it 'includes the blob icon for the file' do
expect(conflict_file.as_json[:blob_icon]).to eq('file-text-o')
end
end
end
require 'spec_helper'
describe Gitlab::Conflict::Parser, lib: true do
let(:parser) { Gitlab::Conflict::Parser.new }
describe '#parse' do
def parse_text(text)
parser.parse(text, our_path: 'README.md', their_path: 'README.md')
end
context 'when the file has valid conflicts' do
let(:text) do
<<CONFLICT
module Gitlab
module Regexp
extend self
def username_regexp
default_regexp
end
<<<<<<< files/ruby/regex.rb
def project_name_regexp
/\A[a-zA-Z0-9][a-zA-Z0-9_\-\. ]*\z/
end
def name_regexp
/\A[a-zA-Z0-9_\-\. ]*\z/
=======
def project_name_regex
%r{\A[a-zA-Z0-9][a-zA-Z0-9_\-\. ]*\z}
end
def name_regex
%r{\A[a-zA-Z0-9_\-\. ]*\z}
>>>>>>> files/ruby/regex.rb
end
def path_regexp
default_regexp
end
<<<<<<< files/ruby/regex.rb
def archive_formats_regexp
/(zip|tar|7z|tar\.gz|tgz|gz|tar\.bz2|tbz|tbz2|tb2|bz2)/
=======
def archive_formats_regex
%r{(zip|tar|7z|tar\.gz|tgz|gz|tar\.bz2|tbz|tbz2|tb2|bz2)}
>>>>>>> files/ruby/regex.rb
end
def git_reference_regexp
# Valid git ref regexp, see:
# https://www.kernel.org/pub/software/scm/git/docs/git-check-ref-format.html
%r{
(?!
(?# doesn't begins with)
\/| (?# rule #6)
(?# doesn't contain)
.*(?:
[\/.]\.| (?# rule #1,3)
\/\/| (?# rule #6)
@\{| (?# rule #8)
\\ (?# rule #9)
)
)
[^\000-\040\177~^:?*\[]+ (?# rule #4-5)
(?# doesn't end with)
(?<!\.lock) (?# rule #1)
(?<![\/.]) (?# rule #6-7)
}x
end
protected
<<<<<<< files/ruby/regex.rb
def default_regexp
/\A[.?]?[a-zA-Z0-9][a-zA-Z0-9_\-\.]*(?<!\.git)\z/
=======
def default_regex
%r{\A[.?]?[a-zA-Z0-9][a-zA-Z0-9_\-\.]*(?<!\.git)\z}
>>>>>>> files/ruby/regex.rb
end
end
end
CONFLICT
end
let(:lines) do
parser.parse(text, our_path: 'files/ruby/regex.rb', their_path: 'files/ruby/regex.rb')
end
it 'sets our lines as new lines' do
expect(lines[8..13]).to all(have_attributes(type: 'new'))
expect(lines[26..27]).to all(have_attributes(type: 'new'))
expect(lines[56..57]).to all(have_attributes(type: 'new'))
end
it 'sets their lines as old lines' do
expect(lines[14..19]).to all(have_attributes(type: 'old'))
expect(lines[28..29]).to all(have_attributes(type: 'old'))
expect(lines[58..59]).to all(have_attributes(type: 'old'))
end
it 'sets non-conflicted lines as both' do
expect(lines[0..7]).to all(have_attributes(type: nil))
expect(lines[20..25]).to all(have_attributes(type: nil))
expect(lines[30..55]).to all(have_attributes(type: nil))
expect(lines[60..62]).to all(have_attributes(type: nil))
end
it 'sets consecutive line numbers for index, old_pos, and new_pos' do
old_line_numbers = lines.select { |line| line.type != 'new' }.map(&:old_pos)
new_line_numbers = lines.select { |line| line.type != 'old' }.map(&:new_pos)
expect(lines.map(&:index)).to eq(0.upto(62).to_a)
expect(old_line_numbers).to eq(1.upto(53).to_a)
expect(new_line_numbers).to eq(1.upto(53).to_a)
end
end
context 'when the file contents include conflict delimiters' do
it 'raises UnexpectedDelimiter when there is a non-start delimiter first' do
expect { parse_text('=======') }.
to raise_error(Gitlab::Conflict::Parser::UnexpectedDelimiter)
expect { parse_text('>>>>>>> README.md') }.
to raise_error(Gitlab::Conflict::Parser::UnexpectedDelimiter)
expect { parse_text('>>>>>>> some-other-path.md') }.
not_to raise_error
end
it 'raises UnexpectedDelimiter when a start delimiter is followed by a non-middle delimiter' do
start_text = "<<<<<<< README.md\n"
end_text = "\n=======\n>>>>>>> README.md"
expect { parse_text(start_text + '>>>>>>> README.md' + end_text) }.
to raise_error(Gitlab::Conflict::Parser::UnexpectedDelimiter)
expect { parse_text(start_text + start_text + end_text) }.
to raise_error(Gitlab::Conflict::Parser::UnexpectedDelimiter)
expect { parse_text(start_text + '>>>>>>> some-other-path.md' + end_text) }.
not_to raise_error
end
it 'raises UnexpectedDelimiter when a middle delimiter is followed by a non-end delimiter' do
start_text = "<<<<<<< README.md\n=======\n"
end_text = "\n>>>>>>> README.md"
expect { parse_text(start_text + '=======' + end_text) }.
to raise_error(Gitlab::Conflict::Parser::UnexpectedDelimiter)
expect { parse_text(start_text + start_text + end_text) }.
to raise_error(Gitlab::Conflict::Parser::UnexpectedDelimiter)
expect { parse_text(start_text + '>>>>>>> some-other-path.md' + end_text) }.
not_to raise_error
end
it 'raises MissingEndDelimiter when there is no end delimiter at the end' do
start_text = "<<<<<<< README.md\n=======\n"
expect { parse_text(start_text) }.
to raise_error(Gitlab::Conflict::Parser::MissingEndDelimiter)
expect { parse_text(start_text + '>>>>>>> some-other-path.md') }.
to raise_error(Gitlab::Conflict::Parser::MissingEndDelimiter)
end
end
context 'other file types' do
it 'raises UnmergeableFile when lines is blank, indicating a binary file' do
expect { parse_text('') }.
to raise_error(Gitlab::Conflict::Parser::UnmergeableFile)
expect { parse_text(nil) }.
to raise_error(Gitlab::Conflict::Parser::UnmergeableFile)
end
it 'raises UnmergeableFile when the file is over 100 KB' do
expect { parse_text('a' * 102401) }.
to raise_error(Gitlab::Conflict::Parser::UnmergeableFile)
end
end
end
end
......@@ -783,4 +783,56 @@ describe MergeRequest, models: true do
end
end
end
describe '#conflicts_can_be_resolved_in_ui?' do
def create_merge_request(source_branch)
create(:merge_request, source_branch: source_branch, target_branch: 'conflict-start') do |mr|
mr.mark_as_unmergeable
end
end
it 'returns a falsey value when the MR can be merged without conflicts' do
merge_request = create_merge_request('master')
merge_request.mark_as_mergeable
expect(merge_request.conflicts_can_be_resolved_in_ui?).to be_falsey
end
it 'returns a falsey value when the MR does not support new diff notes' do
merge_request = create_merge_request('conflict-resolvable')
merge_request.merge_request_diff.update_attributes(start_commit_sha: nil)
expect(merge_request.conflicts_can_be_resolved_in_ui?).to be_falsey
end
it 'returns a falsey value when the conflicts contain a large file' do
merge_request = create_merge_request('conflict-too-large')
expect(merge_request.conflicts_can_be_resolved_in_ui?).to be_falsey
end
it 'returns a falsey value when the conflicts contain a binary file' do
merge_request = create_merge_request('conflict-binary-file')
expect(merge_request.conflicts_can_be_resolved_in_ui?).to be_falsey
end
it 'returns a falsey value when the conflicts contain a file with ambiguous conflict markers' do
merge_request = create_merge_request('conflict-contains-conflict-markers')
expect(merge_request.conflicts_can_be_resolved_in_ui?).to be_falsey
end
it 'returns a falsey value when the conflicts contain a file edited in one branch and deleted in another' do
merge_request = create_merge_request('conflict-missing-side')
expect(merge_request.conflicts_can_be_resolved_in_ui?).to be_falsey
end
it 'returns a truthy value when the conflicts are resolvable in the UI' do
merge_request = create_merge_request('conflict-resolvable')
expect(merge_request.conflicts_can_be_resolved_in_ui?).to be_truthy
end
end
end
......@@ -5,25 +5,31 @@ module TestEnv
# When developing the seed repository, comment out the branch you will modify.
BRANCH_SHA = {
'empty-branch' => '7efb185',
'ends-with.json' => '98b0d8b3',
'flatten-dir' => 'e56497b',
'feature' => '0b4bc9a',
'feature_conflict' => 'bb5206f',
'fix' => '48f0be4',
'improve/awesome' => '5937ac0',
'markdown' => '0ed8c6c',
'lfs' => 'be93687',
'master' => '5937ac0',
"'test'" => 'e56497b',
'orphaned-branch' => '45127a9',
'binary-encoding' => '7b1cf43',
'gitattributes' => '5a62481',
'expand-collapse-diffs' => '4842455',
'expand-collapse-files' => '025db92',
'expand-collapse-lines' => '238e82d',
'video' => '8879059',
'crlf-diff' => '5938907'
'empty-branch' => '7efb185',
'ends-with.json' => '98b0d8b3',
'flatten-dir' => 'e56497b',
'feature' => '0b4bc9a',
'feature_conflict' => 'bb5206f',
'fix' => '48f0be4',
'improve/awesome' => '5937ac0',
'markdown' => '0ed8c6c',
'lfs' => 'be93687',
'master' => '5937ac0',
"'test'" => 'e56497b',
'orphaned-branch' => '45127a9',
'binary-encoding' => '7b1cf43',
'gitattributes' => '5a62481',
'expand-collapse-diffs' => '4842455',
'expand-collapse-files' => '025db92',
'expand-collapse-lines' => '238e82d',
'video' => '8879059',
'crlf-diff' => '5938907',
'conflict-start' => '14fa46b',
'conflict-resolvable' => '1450cd6',
'conflict-binary-file' => '259a6fb',
'conflict-contains-conflict-markers' => '5e0964c',
'conflict-missing-side' => 'eb227b3',
'conflict-too-large' => '39fa04f',
}
# gitlab-test-fork is a fork of gitlab-fork, but we don't necessarily
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment