Merge branch 'mc-ui'
# Conflicts: # app/controllers/projects/merge_requests_controller.rb
This commit is contained in:
commit
095fcfc447
|
@ -88,6 +88,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();
|
||||
|
|
|
@ -0,0 +1,341 @@
|
|||
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}`;
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,85 @@
|
|||
//= 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;
|
||||
}
|
||||
|
|
|
@ -124,3 +124,8 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,4 +1,10 @@
|
|||
/* 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,4 +1,10 @@
|
|||
/* 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) {
|
||||
|
|
|
@ -0,0 +1,238 @@
|
|||
$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
|
||||
|
@ -351,6 +394,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
|
||||
|
@ -425,7 +472,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]
|
||||
|
|
|
@ -0,0 +1,31 @@
|
|||
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
|
|
@ -0,0 +1,29 @@
|
|||
- 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"
|
|
@ -0,0 +1,20 @@
|
|||
.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}}
|
|
@ -0,0 +1,28 @@
|
|||
.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}}
|
|
@ -0,0 +1,27 @@
|
|||
.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}}}
|
|
@ -0,0 +1,15 @@
|
|||
.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"}.
|
||||
|
|
|
@ -1,6 +1,12 @@
|
|||
.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
|
||||
|
|
|
@ -727,6 +727,7 @@ Rails.application.routes.draw do
|
|||
member do
|
||||
get :commits
|
||||
get :diffs
|
||||
get :conflicts
|
||||
get :builds
|
||||
get :pipelines
|
||||
get :merge_check
|
||||
|
@ -737,6 +738,7 @@ Rails.application.routes.draw do
|
|||
post :toggle_award_emoji
|
||||
post :remove_wip
|
||||
get :diff_for_path
|
||||
post :resolve_conflicts
|
||||
end
|
||||
|
||||
collection do
|
||||
|
|
Binary file not shown.
After Width: | Height: | Size: 242 KiB |
Binary file not shown.
After Width: | Height: | Size: 32 KiB |
|
@ -0,0 +1,41 @@
|
|||
# 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
|
|
@ -0,0 +1,186 @@
|
|||
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
|
|
@ -0,0 +1,57 @@
|
|||
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
|
|
@ -0,0 +1,62 @@
|
|||
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
|
||||
|
|
|
@ -0,0 +1,72 @@
|
|||
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
|
|
@ -0,0 +1,24 @@
|
|||
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
|
|
@ -0,0 +1,261 @@
|
|||
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
|
|
@ -0,0 +1,188 @@
|
|||
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
|
||||
|
|
Loading…
Reference in New Issue