Merge branch 'mc-ui'
# Conflicts: # app/controllers/projects/merge_requests_controller.rb
This commit is contained in:
commit
095fcfc447
41 changed files with 2097 additions and 44 deletions
|
@ -88,6 +88,8 @@
|
||||||
new ZenMode();
|
new ZenMode();
|
||||||
new MergedButtons();
|
new MergedButtons();
|
||||||
break;
|
break;
|
||||||
|
case "projects:merge_requests:conflicts":
|
||||||
|
window.mcui = new MergeConflictResolver()
|
||||||
case 'projects:merge_requests:index':
|
case 'projects:merge_requests:index':
|
||||||
shortcut_handler = new ShortcutsNavigation();
|
shortcut_handler = new ShortcutsNavigation();
|
||||||
Issuable.init();
|
Issuable.init();
|
||||||
|
|
341
app/assets/javascripts/merge_conflict_data_provider.js.es6
Normal file
341
app/assets/javascripts/merge_conflict_data_provider.js.es6
Normal file
|
@ -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}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
85
app/assets/javascripts/merge_conflict_resolver.js.es6
Normal file
85
app/assets/javascripts/merge_conflict_resolver.js.es6
Normal file
|
@ -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) {
|
return function(data) {
|
||||||
var callback, urlSuffix;
|
var callback, urlSuffix;
|
||||||
if (data.state === "merged") {
|
if (data.state === "merged") {
|
||||||
urlSuffix = deleteSourceBranch ? '?delete_source=true' : '';
|
urlSuffix = deleteSourceBranch ? '?deleted_source_branch=true' : '';
|
||||||
return window.location.href = window.location.pathname + urlSuffix;
|
return window.location.href = window.location.pathname + urlSuffix;
|
||||||
} else if (data.merge_error) {
|
} else if (data.merge_error) {
|
||||||
return $('.mr-widget-body').html("<h4>" + data.merge_error + "</h4>");
|
return $('.mr-widget-body').html("<h4>" + data.merge_error + "</h4>");
|
||||||
|
|
|
@ -20,3 +20,8 @@
|
||||||
.turn-off { display: block; }
|
.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
|
// Diff line
|
||||||
.line_holder {
|
.line_holder {
|
||||||
|
&.match .line_content {
|
||||||
|
@include dark-diff-match-line;
|
||||||
|
}
|
||||||
|
|
||||||
td.diff-line-num.hll:not(.empty-cell),
|
td.diff-line-num.hll:not(.empty-cell),
|
||||||
td.line_content.hll:not(.empty-cell) {
|
td.line_content.hll:not(.empty-cell) {
|
||||||
background-color: #557;
|
background-color: #557;
|
||||||
|
@ -36,8 +40,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.line_content.match {
|
.line_content.match {
|
||||||
color: rgba(255, 255, 255, 0.3);
|
@include dark-diff-match-line;
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -21,6 +21,10 @@
|
||||||
|
|
||||||
// Diff line
|
// Diff line
|
||||||
.line_holder {
|
.line_holder {
|
||||||
|
&.match .line_content {
|
||||||
|
@include dark-diff-match-line;
|
||||||
|
}
|
||||||
|
|
||||||
td.diff-line-num.hll:not(.empty-cell),
|
td.diff-line-num.hll:not(.empty-cell),
|
||||||
td.line_content.hll:not(.empty-cell) {
|
td.line_content.hll:not(.empty-cell) {
|
||||||
background-color: #49483e;
|
background-color: #49483e;
|
||||||
|
@ -36,8 +40,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.line_content.match {
|
.line_content.match {
|
||||||
color: rgba(255, 255, 255, 0.3);
|
@include dark-diff-match-line;
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -21,6 +21,10 @@
|
||||||
|
|
||||||
// Diff line
|
// Diff line
|
||||||
.line_holder {
|
.line_holder {
|
||||||
|
&.match .line_content {
|
||||||
|
@include dark-diff-match-line;
|
||||||
|
}
|
||||||
|
|
||||||
td.diff-line-num.hll:not(.empty-cell),
|
td.diff-line-num.hll:not(.empty-cell),
|
||||||
td.line_content.hll:not(.empty-cell) {
|
td.line_content.hll:not(.empty-cell) {
|
||||||
background-color: #174652;
|
background-color: #174652;
|
||||||
|
@ -36,8 +40,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.line_content.match {
|
.line_content.match {
|
||||||
color: rgba(255, 255, 255, 0.3);
|
@include dark-diff-match-line;
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,10 @@
|
||||||
/* https://gist.github.com/qguv/7936275 */
|
/* https://gist.github.com/qguv/7936275 */
|
||||||
|
|
||||||
|
@mixin matchLine {
|
||||||
|
color: $black-transparent;
|
||||||
|
background: rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
.code.solarized-light {
|
.code.solarized-light {
|
||||||
// Line numbers
|
// Line numbers
|
||||||
.line-numbers, .diff-line-num {
|
.line-numbers, .diff-line-num {
|
||||||
|
@ -21,6 +27,10 @@
|
||||||
|
|
||||||
// Diff line
|
// Diff line
|
||||||
.line_holder {
|
.line_holder {
|
||||||
|
&.match .line_content {
|
||||||
|
@include matchLine;
|
||||||
|
}
|
||||||
|
|
||||||
td.diff-line-num.hll:not(.empty-cell),
|
td.diff-line-num.hll:not(.empty-cell),
|
||||||
td.line_content.hll:not(.empty-cell) {
|
td.line_content.hll:not(.empty-cell) {
|
||||||
background-color: #ddd8c5;
|
background-color: #ddd8c5;
|
||||||
|
@ -36,8 +46,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.line_content.match {
|
.line_content.match {
|
||||||
color: $black-transparent;
|
@include matchLine;
|
||||||
background: rgba(255, 255, 255, 0.4);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,10 @@
|
||||||
/* https://github.com/aahan/pygments-github-style */
|
/* https://github.com/aahan/pygments-github-style */
|
||||||
|
|
||||||
|
@mixin matchLine {
|
||||||
|
color: $black-transparent;
|
||||||
|
background-color: $match-line;
|
||||||
|
}
|
||||||
|
|
||||||
.code.white {
|
.code.white {
|
||||||
// Line numbers
|
// Line numbers
|
||||||
.line-numbers, .diff-line-num {
|
.line-numbers, .diff-line-num {
|
||||||
|
@ -22,6 +28,10 @@
|
||||||
// Diff line
|
// Diff line
|
||||||
.line_holder {
|
.line_holder {
|
||||||
|
|
||||||
|
&.match .line_content {
|
||||||
|
@include matchLine;
|
||||||
|
}
|
||||||
|
|
||||||
.diff-line-num {
|
.diff-line-num {
|
||||||
&.old {
|
&.old {
|
||||||
background-color: $line-number-old;
|
background-color: $line-number-old;
|
||||||
|
@ -57,8 +67,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
&.match {
|
&.match {
|
||||||
color: $black-transparent;
|
@include matchLine;
|
||||||
background-color: $match-line;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&.hll:not(.empty-cell) {
|
&.hll:not(.empty-cell) {
|
||||||
|
|
238
app/assets/stylesheets/pages/merge_conflicts.scss
Normal file
238
app/assets/stylesheets/pages/merge_conflicts.scss
Normal file
|
@ -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 :module_enabled
|
||||||
before_action :merge_request, only: [
|
before_action :merge_request, only: [
|
||||||
:edit, :update, :show, :diffs, :commits, :builds, :pipelines, :merge, :merge_check,
|
:edit, :update, :show, :diffs, :commits, :conflicts, :builds, :pipelines, :merge, :merge_check,
|
||||||
:ci_status, :toggle_subscription, :cancel_merge_when_build_succeeds, :remove_wip
|
: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 :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_widget_vars, only: [:merge, :cancel_merge_when_build_succeeds, :merge_check]
|
||||||
before_action :define_commit_vars, only: [:diffs]
|
before_action :define_commit_vars, only: [:diffs]
|
||||||
before_action :define_diff_comment_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
|
# Allow read any merge_request
|
||||||
before_action :authorize_read_merge_request!
|
before_action :authorize_read_merge_request!
|
||||||
|
@ -28,6 +28,8 @@ class Projects::MergeRequestsController < Projects::ApplicationController
|
||||||
# Allow modify merge_request
|
# Allow modify merge_request
|
||||||
before_action :authorize_update_merge_request!, only: [:close, :edit, :update, :remove_wip, :sort]
|
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
|
def index
|
||||||
terms = params['issue_search']
|
terms = params['issue_search']
|
||||||
@merge_requests = merge_requests_collection
|
@merge_requests = merge_requests_collection
|
||||||
|
@ -130,6 +132,47 @@ class Projects::MergeRequestsController < Projects::ApplicationController
|
||||||
end
|
end
|
||||||
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
|
def builds
|
||||||
respond_to do |format|
|
respond_to do |format|
|
||||||
format.html do
|
format.html do
|
||||||
|
@ -351,6 +394,10 @@ class Projects::MergeRequestsController < Projects::ApplicationController
|
||||||
return render_404 unless can?(current_user, :admin_merge_request, @merge_request)
|
return render_404 unless can?(current_user, :admin_merge_request, @merge_request)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def authorize_can_resolve_conflicts!
|
||||||
|
return render_404 unless @merge_request.conflicts_can_be_resolved_by?(current_user)
|
||||||
|
end
|
||||||
|
|
||||||
def module_enabled
|
def module_enabled
|
||||||
return render_404 unless @project.merge_requests_enabled
|
return render_404 unless @project.merge_requests_enabled
|
||||||
end
|
end
|
||||||
|
@ -425,7 +472,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController
|
||||||
noteable_id: @merge_request.id
|
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
|
@grouped_diff_discussions = @merge_request.notes.inc_author_project_award_emoji.grouped_diff_discussions
|
||||||
|
|
||||||
Banzai::NoteRenderer.render(
|
Banzai::NoteRenderer.render(
|
||||||
|
|
|
@ -24,6 +24,7 @@ module NavHelper
|
||||||
current_path?('merge_requests#diffs') ||
|
current_path?('merge_requests#diffs') ||
|
||||||
current_path?('merge_requests#commits') ||
|
current_path?('merge_requests#commits') ||
|
||||||
current_path?('merge_requests#builds') ||
|
current_path?('merge_requests#builds') ||
|
||||||
|
current_path?('merge_requests#conflicts') ||
|
||||||
current_path?('issues#show')
|
current_path?('issues#show')
|
||||||
if cookies[:collapsed_gutter] == 'true'
|
if cookies[:collapsed_gutter] == 'true'
|
||||||
"page-gutter right-sidebar-collapsed"
|
"page-gutter right-sidebar-collapsed"
|
||||||
|
|
|
@ -75,7 +75,7 @@ class DiffNote < Note
|
||||||
private
|
private
|
||||||
|
|
||||||
def supported?
|
def supported?
|
||||||
!self.for_merge_request? || self.noteable.support_new_diff_notes?
|
!self.for_merge_request? || self.noteable.has_complete_diff_refs?
|
||||||
end
|
end
|
||||||
|
|
||||||
def noteable_diff_refs
|
def noteable_diff_refs
|
||||||
|
|
|
@ -701,12 +701,12 @@ class MergeRequest < ActiveRecord::Base
|
||||||
merge_commit
|
merge_commit
|
||||||
end
|
end
|
||||||
|
|
||||||
def support_new_diff_notes?
|
def has_complete_diff_refs?
|
||||||
diff_sha_refs && diff_sha_refs.complete?
|
diff_sha_refs && diff_sha_refs.complete?
|
||||||
end
|
end
|
||||||
|
|
||||||
def update_diff_notes_positions(old_diff_refs:, new_diff_refs:)
|
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
|
return if new_diff_refs == old_diff_refs
|
||||||
|
|
||||||
active_diff_notes = self.notes.diff_notes.select do |note|
|
active_diff_notes = self.notes.diff_notes.select do |note|
|
||||||
|
@ -734,4 +734,26 @@ class MergeRequest < ActiveRecord::Base
|
||||||
def keep_around_commit
|
def keep_around_commit
|
||||||
project.repository.keep_around(self.merge_commit_sha)
|
project.repository.keep_around(self.merge_commit_sha)
|
||||||
end
|
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
|
end
|
||||||
|
|
|
@ -869,6 +869,14 @@ class Repository
|
||||||
end
|
end
|
||||||
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)
|
def check_revert_content(commit, base_branch)
|
||||||
source_sha = find_branch(base_branch).target.sha
|
source_sha = find_branch(base_branch).target.sha
|
||||||
args = [commit.id, source_sha]
|
args = [commit.id, source_sha]
|
||||||
|
|
31
app/services/merge_requests/resolve_service.rb
Normal file
31
app/services/merge_requests/resolve_service.rb
Normal file
|
@ -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
|
29
app/views/projects/merge_requests/conflicts.html.haml
Normal file
29
app/views/projects/merge_requests/conflicts.html.haml
Normal file
|
@ -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
|
- if @merge_request.merge_event
|
||||||
by #{link_to_member(@project, @merge_request.merge_event.author, avatar: true)}
|
by #{link_to_member(@project, @merge_request.merge_event.author, avatar: true)}
|
||||||
#{time_ago_with_tooltip(@merge_request.merge_event.created_at)}
|
#{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
|
%p
|
||||||
The changes were merged into
|
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"}.
|
#{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
|
.mr-state-widget
|
||||||
= render 'projects/merge_requests/widget/heading'
|
= render 'projects/merge_requests/widget/heading'
|
||||||
.mr-widget-body
|
.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?
|
- if @project.archived?
|
||||||
= render 'projects/merge_requests/widget/open/archived'
|
= render 'projects/merge_requests/widget/open/archived'
|
||||||
- elsif @merge_request.commits.blank?
|
- elsif @merge_request.commits.blank?
|
||||||
|
@ -9,7 +15,7 @@
|
||||||
= render 'projects/merge_requests/widget/open/missing_branch'
|
= render 'projects/merge_requests/widget/open/missing_branch'
|
||||||
- elsif @merge_request.unchecked?
|
- elsif @merge_request.unchecked?
|
||||||
= render 'projects/merge_requests/widget/open/check'
|
= 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'
|
= render 'projects/merge_requests/widget/open/conflicts'
|
||||||
- elsif @merge_request.work_in_progress?
|
- elsif @merge_request.work_in_progress?
|
||||||
= render 'projects/merge_requests/widget/open/wip'
|
= render 'projects/merge_requests/widget/open/wip'
|
||||||
|
@ -19,7 +25,7 @@
|
||||||
= render 'projects/merge_requests/widget/open/not_allowed'
|
= render 'projects/merge_requests/widget/open/not_allowed'
|
||||||
- elsif !@merge_request.mergeable_ci_state? && @pipeline && @pipeline.failed?
|
- elsif !@merge_request.mergeable_ci_state? && @pipeline && @pipeline.failed?
|
||||||
= render 'projects/merge_requests/widget/open/build_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'
|
= render 'projects/merge_requests/widget/open/accept'
|
||||||
|
|
||||||
- if mr_closes_issues.present?
|
- if mr_closes_issues.present?
|
||||||
|
|
|
@ -3,7 +3,18 @@
|
||||||
This merge request contains merge conflicts
|
This merge request contains merge conflicts
|
||||||
|
|
||||||
%p
|
%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)
|
- 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"}.
|
#{link_to "merge this request manually", "#modal_merge_info", class: "how_to_merge_link vlink", "data-toggle" => "modal"}.
|
||||||
- else
|
- else
|
||||||
|
|
|
@ -727,6 +727,7 @@ Rails.application.routes.draw do
|
||||||
member do
|
member do
|
||||||
get :commits
|
get :commits
|
||||||
get :diffs
|
get :diffs
|
||||||
|
get :conflicts
|
||||||
get :builds
|
get :builds
|
||||||
get :pipelines
|
get :pipelines
|
||||||
get :merge_check
|
get :merge_check
|
||||||
|
@ -737,6 +738,7 @@ Rails.application.routes.draw do
|
||||||
post :toggle_award_emoji
|
post :toggle_award_emoji
|
||||||
post :remove_wip
|
post :remove_wip
|
||||||
get :diff_for_path
|
get :diff_for_path
|
||||||
|
post :resolve_conflicts
|
||||||
end
|
end
|
||||||
|
|
||||||
collection do
|
collection do
|
||||||
|
|
BIN
doc/user/project/merge_requests/img/conflict_section.png
Normal file
BIN
doc/user/project/merge_requests/img/conflict_section.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 242 KiB |
BIN
doc/user/project/merge_requests/img/merge_request_widget.png
Normal file
BIN
doc/user/project/merge_requests/img/merge_request_widget.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 32 KiB |
41
doc/user/project/merge_requests/resolve_conflicts.md
Normal file
41
doc/user/project/merge_requests/resolve_conflicts.md
Normal file
|
@ -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
|
186
lib/gitlab/conflict/file.rb
Normal file
186
lib/gitlab/conflict/file.rb
Normal file
|
@ -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
|
57
lib/gitlab/conflict/file_collection.rb
Normal file
57
lib/gitlab/conflict/file_collection.rb
Normal file
|
@ -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
|
62
lib/gitlab/conflict/parser.rb
Normal file
62
lib/gitlab/conflict/parser.rb
Normal file
|
@ -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
|
module Diff
|
||||||
class Line
|
class Line
|
||||||
attr_reader :type, :index, :old_pos, :new_pos
|
attr_reader :type, :index, :old_pos, :new_pos
|
||||||
|
attr_writer :rich_text
|
||||||
attr_accessor :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
|
@text, @type, @index = text, type, index
|
||||||
@old_pos, @new_pos = old_pos, new_pos
|
@old_pos, @new_pos = old_pos, new_pos
|
||||||
|
@parent_file = parent_file
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.init_from_hash(hash)
|
def self.init_from_hash(hash)
|
||||||
|
@ -43,9 +45,25 @@ module Gitlab
|
||||||
type == 'old'
|
type == 'old'
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def rich_text
|
||||||
|
@parent_file.highlight_lines! if @parent_file && !@rich_text
|
||||||
|
|
||||||
|
@rich_text
|
||||||
|
end
|
||||||
|
|
||||||
def meta?
|
def meta?
|
||||||
type == 'match' || type == 'nonewline'
|
type == 'match' || type == 'nonewline'
|
||||||
end
|
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
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -4,6 +4,11 @@ describe Projects::MergeRequestsController do
|
||||||
let(:project) { create(:project) }
|
let(:project) { create(:project) }
|
||||||
let(:user) { create(:user) }
|
let(:user) { create(:user) }
|
||||||
let(:merge_request) { create(:merge_request_with_diffs, target_project: project, source_project: project) }
|
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
|
before do
|
||||||
sign_in(user)
|
sign_in(user)
|
||||||
|
@ -523,4 +528,135 @@ describe Projects::MergeRequestsController do
|
||||||
end
|
end
|
||||||
end
|
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
|
end
|
||||||
|
|
72
spec/features/merge_requests/conflicts_spec.rb
Normal file
72
spec/features/merge_requests/conflicts_spec.rb
Normal file
|
@ -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
|
24
spec/lib/gitlab/conflict/file_collection_spec.rb
Normal file
24
spec/lib/gitlab/conflict/file_collection_spec.rb
Normal file
|
@ -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
|
261
spec/lib/gitlab/conflict/file_spec.rb
Normal file
261
spec/lib/gitlab/conflict/file_spec.rb
Normal file
|
@ -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
|
188
spec/lib/gitlab/conflict/parser_spec.rb
Normal file
188
spec/lib/gitlab/conflict/parser_spec.rb
Normal file
|
@ -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
|
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
|
end
|
||||||
|
|
|
@ -23,7 +23,13 @@ module TestEnv
|
||||||
'expand-collapse-files' => '025db92',
|
'expand-collapse-files' => '025db92',
|
||||||
'expand-collapse-lines' => '238e82d',
|
'expand-collapse-lines' => '238e82d',
|
||||||
'video' => '8879059',
|
'video' => '8879059',
|
||||||
'crlf-diff' => '5938907'
|
'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
|
# gitlab-test-fork is a fork of gitlab-fork, but we don't necessarily
|
||||||
|
|
Loading…
Reference in a new issue