diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js
index 7160fa71ce5638d40c2cc7a14140567ddde2b7f9..1163edd8547cc7ed0d689f5114020842e60f5158 100644
--- a/app/assets/javascripts/dispatcher.js
+++ b/app/assets/javascripts/dispatcher.js
@@ -88,6 +88,8 @@
           new ZenMode();
           new MergedButtons();
           break;
+        case "projects:merge_requests:conflicts":
+          window.mcui = new MergeConflictResolver()
         case 'projects:merge_requests:index':
           shortcut_handler = new ShortcutsNavigation();
           Issuable.init();
diff --git a/app/assets/javascripts/merge_conflict_data_provider.js.es6 b/app/assets/javascripts/merge_conflict_data_provider.js.es6
new file mode 100644
index 0000000000000000000000000000000000000000..cd92df8ddc5de268e043ea4d9f7c9410696a5ebd
--- /dev/null
+++ b/app/assets/javascripts/merge_conflict_data_provider.js.es6
@@ -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}`;
+  }
+
+}
diff --git a/app/assets/javascripts/merge_conflict_resolver.js.es6 b/app/assets/javascripts/merge_conflict_resolver.js.es6
new file mode 100644
index 0000000000000000000000000000000000000000..77bffbcb403be6e4835ad33121ae1b81241f3084
--- /dev/null
+++ b/app/assets/javascripts/merge_conflict_resolver.js.es6
@@ -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;
+      });
+  }
+
+}
diff --git a/app/assets/javascripts/merge_request_widget.js b/app/assets/javascripts/merge_request_widget.js
index 659bd37c388ff3ca0caeb2494343377feb090ab9..bd35b6f679d18db622510b9a3c354b261e6b1c6d 100644
--- a/app/assets/javascripts/merge_request_widget.js
+++ b/app/assets/javascripts/merge_request_widget.js
@@ -53,7 +53,7 @@
           return function(data) {
             var callback, urlSuffix;
             if (data.state === "merged") {
-              urlSuffix = deleteSourceBranch ? '?delete_source=true' : '';
+              urlSuffix = deleteSourceBranch ? '?deleted_source_branch=true' : '';
               return window.location.href = window.location.pathname + urlSuffix;
             } else if (data.merge_error) {
               return $('.mr-widget-body').html("<h4>" + data.merge_error + "</h4>");
diff --git a/app/assets/stylesheets/behaviors.scss b/app/assets/stylesheets/behaviors.scss
index 542a53f0377f924f06dbc104f99251f0d56797e9..a6b9efc49c9a2ca6dce1c15cdb242167f16b8fb5 100644
--- a/app/assets/stylesheets/behaviors.scss
+++ b/app/assets/stylesheets/behaviors.scss
@@ -20,3 +20,8 @@
     .turn-off { display: block; }
   }
 }
+
+
+[v-cloak] {
+  display: none;
+}
diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss
index 5ec5a96a597d508767fc65fcd5b36ab4dc96577e..d2d60ed71967e11d667c72f61c76518c052bf8ce 100644
--- a/app/assets/stylesheets/framework/mixins.scss
+++ b/app/assets/stylesheets/framework/mixins.scss
@@ -123,4 +123,9 @@
       }
     }
   }
-}
\ No newline at end of file
+}
+
+@mixin dark-diff-match-line {
+  color: rgba(255, 255, 255, 0.3);
+  background: rgba(255, 255, 255, 0.1);
+}
diff --git a/app/assets/stylesheets/highlight/dark.scss b/app/assets/stylesheets/highlight/dark.scss
index 77a73dc379b43b253781c7b2232c5c8b193c2ba0..16ffbe57a99fbe93f6587700468f0e9ab983ea33 100644
--- a/app/assets/stylesheets/highlight/dark.scss
+++ b/app/assets/stylesheets/highlight/dark.scss
@@ -21,6 +21,10 @@
 
   // Diff line
   .line_holder {
+    &.match .line_content {
+      @include dark-diff-match-line;
+    }
+
     td.diff-line-num.hll:not(.empty-cell),
     td.line_content.hll:not(.empty-cell) {
       background-color: #557;
@@ -36,8 +40,7 @@
     }
 
     .line_content.match {
-      color: rgba(255, 255, 255, 0.3);
-      background: rgba(255, 255, 255, 0.1);
+      @include dark-diff-match-line;
     }
   }
 
diff --git a/app/assets/stylesheets/highlight/monokai.scss b/app/assets/stylesheets/highlight/monokai.scss
index 80a509a7c1ac9fa3b949bf8269228d7aa4bc7b9a..7de920e074b37a01aeaa519ae3a3e3701167ec37 100644
--- a/app/assets/stylesheets/highlight/monokai.scss
+++ b/app/assets/stylesheets/highlight/monokai.scss
@@ -21,6 +21,10 @@
 
   // Diff line
   .line_holder {
+    &.match .line_content {
+      @include dark-diff-match-line;
+    }
+
     td.diff-line-num.hll:not(.empty-cell),
     td.line_content.hll:not(.empty-cell) {
       background-color: #49483e;
@@ -36,8 +40,7 @@
     }
 
     .line_content.match {
-      color: rgba(255, 255, 255, 0.3);
-      background: rgba(255, 255, 255, 0.1);
+      @include dark-diff-match-line;
     }
   }
 
diff --git a/app/assets/stylesheets/highlight/solarized_dark.scss b/app/assets/stylesheets/highlight/solarized_dark.scss
index c62bd021aefda15de6fe2baea4d02b965b97e294..b11499c71eec8e065e899011e0143143c704cfbf 100644
--- a/app/assets/stylesheets/highlight/solarized_dark.scss
+++ b/app/assets/stylesheets/highlight/solarized_dark.scss
@@ -21,6 +21,10 @@
 
   // Diff line
   .line_holder {
+    &.match .line_content {
+      @include dark-diff-match-line;
+    }
+
     td.diff-line-num.hll:not(.empty-cell),
     td.line_content.hll:not(.empty-cell) {
       background-color: #174652;
@@ -36,8 +40,7 @@
     }
 
     .line_content.match {
-      color: rgba(255, 255, 255, 0.3);
-      background: rgba(255, 255, 255, 0.1);
+      @include dark-diff-match-line;
     }
   }
 
diff --git a/app/assets/stylesheets/highlight/solarized_light.scss b/app/assets/stylesheets/highlight/solarized_light.scss
index 524cfaf90c309c43d0aac388acbaf80fc87984b3..657bb5e3cd964109a49e58ccdb12b59ef355bf06 100644
--- a/app/assets/stylesheets/highlight/solarized_light.scss
+++ b/app/assets/stylesheets/highlight/solarized_light.scss
@@ -1,4 +1,10 @@
 /* https://gist.github.com/qguv/7936275 */
+
+@mixin matchLine {
+  color: $black-transparent;
+  background: rgba(255, 255, 255, 0.4);
+}
+
 .code.solarized-light {
   // Line numbers
   .line-numbers, .diff-line-num {
@@ -21,6 +27,10 @@
 
   // Diff line
   .line_holder {
+    &.match .line_content {
+      @include matchLine;
+    }
+
     td.diff-line-num.hll:not(.empty-cell),
     td.line_content.hll:not(.empty-cell) {
       background-color: #ddd8c5;
@@ -36,8 +46,7 @@
     }
 
     .line_content.match {
-      color: $black-transparent;
-      background: rgba(255, 255, 255, 0.4);
+      @include matchLine;
     }
   }
 
diff --git a/app/assets/stylesheets/highlight/white.scss b/app/assets/stylesheets/highlight/white.scss
index 31a4e3deaac866c8446a9ef2b55c72c547a03418..36a80a916b2df218d3a9420b20528712fa4db5ce 100644
--- a/app/assets/stylesheets/highlight/white.scss
+++ b/app/assets/stylesheets/highlight/white.scss
@@ -1,4 +1,10 @@
 /* https://github.com/aahan/pygments-github-style */
+
+@mixin matchLine {
+  color: $black-transparent;
+  background-color: $match-line;
+}
+
 .code.white {
   // Line numbers
   .line-numbers, .diff-line-num {
@@ -22,6 +28,10 @@
   // Diff line
   .line_holder {
 
+    &.match .line_content {
+      @include matchLine;
+    }
+
     .diff-line-num {
       &.old {
         background-color: $line-number-old;
@@ -57,8 +67,7 @@
       }
 
       &.match {
-        color: $black-transparent;
-        background-color: $match-line;
+        @include matchLine;
       }
 
       &.hll:not(.empty-cell) {
diff --git a/app/assets/stylesheets/pages/merge_conflicts.scss b/app/assets/stylesheets/pages/merge_conflicts.scss
new file mode 100644
index 0000000000000000000000000000000000000000..1f499897c165bee80aeb1af77ea45f3d23c9e742
--- /dev/null
+++ b/app/assets/stylesheets/pages/merge_conflicts.scss
@@ -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;
+  }
+}
diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb
index 6805cfba8507ab016868d0ef30a361603e677111..bb793cce223517f04ba7c9a70ea05b7e190e1814 100644
--- a/app/controllers/projects/merge_requests_controller.rb
+++ b/app/controllers/projects/merge_requests_controller.rb
@@ -9,15 +9,15 @@ class Projects::MergeRequestsController < Projects::ApplicationController
 
   before_action :module_enabled
   before_action :merge_request, only: [
-    :edit, :update, :show, :diffs, :commits, :builds, :pipelines, :merge, :merge_check,
-    :ci_status, :toggle_subscription, :cancel_merge_when_build_succeeds, :remove_wip
+    :edit, :update, :show, :diffs, :commits, :conflicts, :builds, :pipelines, :merge, :merge_check,
+    :ci_status, :toggle_subscription, :cancel_merge_when_build_succeeds, :remove_wip, :resolve_conflicts
   ]
   before_action :validates_merge_request, only: [:show, :diffs, :commits, :builds, :pipelines]
-  before_action :define_show_vars, only: [:show, :diffs, :commits, :builds, :pipelines]
+  before_action :define_show_vars, only: [:show, :diffs, :commits, :conflicts, :builds, :pipelines]
   before_action :define_widget_vars, only: [:merge, :cancel_merge_when_build_succeeds, :merge_check]
   before_action :define_commit_vars, only: [:diffs]
   before_action :define_diff_comment_vars, only: [:diffs]
-  before_action :ensure_ref_fetched, only: [:show, :diffs, :commits, :builds, :pipelines]
+  before_action :ensure_ref_fetched, only: [:show, :diffs, :commits, :builds, :conflicts, :pipelines]
 
   # Allow read any merge_request
   before_action :authorize_read_merge_request!
@@ -28,6 +28,8 @@ class Projects::MergeRequestsController < Projects::ApplicationController
   # Allow modify merge_request
   before_action :authorize_update_merge_request!, only: [:close, :edit, :update, :remove_wip, :sort]
 
+  before_action :authorize_can_resolve_conflicts!, only: [:conflicts, :resolve_conflicts]
+
   def index
     terms = params['issue_search']
     @merge_requests = merge_requests_collection
@@ -130,6 +132,47 @@ class Projects::MergeRequestsController < Projects::ApplicationController
     end
   end
 
+  def conflicts
+    respond_to do |format|
+      format.html { define_discussion_vars }
+
+      format.json do
+        if @merge_request.conflicts_can_be_resolved_in_ui?
+          render json: @merge_request.conflicts
+        elsif @merge_request.can_be_merged?
+          render json: {
+            message: 'The merge conflicts for this merge request have already been resolved. Please return to the merge request.',
+            type: 'error'
+          }
+        else
+          render json: {
+            message: 'The merge conflicts for this merge request cannot be resolved through GitLab. Please try to resolve them locally.',
+            type: 'error'
+          }
+        end
+      end
+    end
+  end
+
+  def resolve_conflicts
+    return render_404 unless @merge_request.conflicts_can_be_resolved_in_ui?
+
+    if @merge_request.can_be_merged?
+      render status: :bad_request, json: { message: 'The merge conflicts for this merge request have already been resolved.' }
+      return
+    end
+
+    begin
+      MergeRequests::ResolveService.new(@merge_request.source_project, current_user, params).execute(@merge_request)
+
+      flash[:notice] = 'All merge conflicts were resolved. The merge request can now be merged.'
+
+      render json: { redirect_to: namespace_project_merge_request_url(@project.namespace, @project, @merge_request, resolved_conflicts: true) }
+    rescue Gitlab::Conflict::File::MissingResolution => e
+      render status: :bad_request, json: { message: e.message }
+    end
+  end
+
   def builds
     respond_to do |format|
       format.html do
@@ -351,6 +394,10 @@ class Projects::MergeRequestsController < Projects::ApplicationController
     return render_404 unless can?(current_user, :admin_merge_request, @merge_request)
   end
 
+  def authorize_can_resolve_conflicts!
+    return render_404 unless @merge_request.conflicts_can_be_resolved_by?(current_user)
+  end
+
   def module_enabled
     return render_404 unless @project.merge_requests_enabled
   end
@@ -425,7 +472,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController
       noteable_id: @merge_request.id
     }
 
-    @use_legacy_diff_notes = !@merge_request.support_new_diff_notes?
+    @use_legacy_diff_notes = !@merge_request.has_complete_diff_refs?
     @grouped_diff_discussions = @merge_request.notes.inc_author_project_award_emoji.grouped_diff_discussions
 
     Banzai::NoteRenderer.render(
diff --git a/app/helpers/nav_helper.rb b/app/helpers/nav_helper.rb
index 3ff8be5e284cbe4126a16e309d46be7ce071973b..6c1cc6ef072df5a99f6e156ab2dd350db1b1e0e4 100644
--- a/app/helpers/nav_helper.rb
+++ b/app/helpers/nav_helper.rb
@@ -24,6 +24,7 @@ module NavHelper
       current_path?('merge_requests#diffs') ||
       current_path?('merge_requests#commits') ||
       current_path?('merge_requests#builds') ||
+      current_path?('merge_requests#conflicts') ||
       current_path?('issues#show')
       if cookies[:collapsed_gutter] == 'true'
         "page-gutter right-sidebar-collapsed"
diff --git a/app/models/diff_note.rb b/app/models/diff_note.rb
index c816deb4e0cb65d2b4f2a19c24dfa78f55381856..e02a3d54c36b5168ebeb19f44e5478c7e390fa41 100644
--- a/app/models/diff_note.rb
+++ b/app/models/diff_note.rb
@@ -75,7 +75,7 @@ class DiffNote < Note
   private
 
   def supported?
-    !self.for_merge_request? || self.noteable.support_new_diff_notes?
+    !self.for_merge_request? || self.noteable.has_complete_diff_refs?
   end
 
   def noteable_diff_refs
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index d6a6a9a11ae6056ad3b1ba9109c2b33f6ff4f2af..4304ef0476722d1a05b1c48bea9d90e1724730a1 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -701,12 +701,12 @@ class MergeRequest < ActiveRecord::Base
     merge_commit
   end
 
-  def support_new_diff_notes?
+  def has_complete_diff_refs?
     diff_sha_refs && diff_sha_refs.complete?
   end
 
   def update_diff_notes_positions(old_diff_refs:, new_diff_refs:)
-    return unless support_new_diff_notes?
+    return unless has_complete_diff_refs?
     return if new_diff_refs == old_diff_refs
 
     active_diff_notes = self.notes.diff_notes.select do |note|
@@ -734,4 +734,26 @@ class MergeRequest < ActiveRecord::Base
   def keep_around_commit
     project.repository.keep_around(self.merge_commit_sha)
   end
+
+  def conflicts
+    @conflicts ||= Gitlab::Conflict::FileCollection.new(self)
+  end
+
+  def conflicts_can_be_resolved_by?(user)
+    access = ::Gitlab::UserAccess.new(user, project: source_project)
+    access.can_push_to_branch?(source_branch)
+  end
+
+  def conflicts_can_be_resolved_in_ui?
+    return @conflicts_can_be_resolved_in_ui if defined?(@conflicts_can_be_resolved_in_ui)
+
+    return @conflicts_can_be_resolved_in_ui = false unless cannot_be_merged?
+    return @conflicts_can_be_resolved_in_ui = false unless has_complete_diff_refs?
+
+    begin
+      @conflicts_can_be_resolved_in_ui = conflicts.files.each(&:lines)
+    rescue Gitlab::Conflict::Parser::ParserError, Gitlab::Conflict::FileCollection::ConflictSideMissing
+      @conflicts_can_be_resolved_in_ui = false
+    end
+  end
 end
diff --git a/app/models/repository.rb b/app/models/repository.rb
index e56bac509a4ed1e774778c68b998565b8b1218b9..01b02ccc0dcad4a6a4945a51aced46dca0fc6000 100644
--- a/app/models/repository.rb
+++ b/app/models/repository.rb
@@ -869,6 +869,14 @@ class Repository
     end
   end
 
+  def resolve_conflicts(user, branch, params)
+    commit_with_hooks(user, branch) do
+      committer = user_to_committer(user)
+
+      Rugged::Commit.create(rugged, params.merge(author: committer, committer: committer))
+    end
+  end
+
   def check_revert_content(commit, base_branch)
     source_sha = find_branch(base_branch).target.sha
     args       = [commit.id, source_sha]
diff --git a/app/services/merge_requests/resolve_service.rb b/app/services/merge_requests/resolve_service.rb
new file mode 100644
index 0000000000000000000000000000000000000000..adc71b0c2bcfb47ff7e5cda058af6197a06e2ace
--- /dev/null
+++ b/app/services/merge_requests/resolve_service.rb
@@ -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
diff --git a/app/views/projects/merge_requests/conflicts.html.haml b/app/views/projects/merge_requests/conflicts.html.haml
new file mode 100644
index 0000000000000000000000000000000000000000..a524936f73cb3179a97516ef01dfb006e7d7991e
--- /dev/null
+++ b/app/views/projects/merge_requests/conflicts.html.haml
@@ -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"
diff --git a/app/views/projects/merge_requests/conflicts/_commit_stats.html.haml b/app/views/projects/merge_requests/conflicts/_commit_stats.html.haml
new file mode 100644
index 0000000000000000000000000000000000000000..457c467fba9825e161cccbad4fd94686b241eb7c
--- /dev/null
+++ b/app/views/projects/merge_requests/conflicts/_commit_stats.html.haml
@@ -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}}
diff --git a/app/views/projects/merge_requests/conflicts/_inline_view.html.haml b/app/views/projects/merge_requests/conflicts/_inline_view.html.haml
new file mode 100644
index 0000000000000000000000000000000000000000..19c7da4b5e39a6a9c1ccbfd2151b76e88ef5e286
--- /dev/null
+++ b/app/views/projects/merge_requests/conflicts/_inline_view.html.haml
@@ -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}}
diff --git a/app/views/projects/merge_requests/conflicts/_parallel_view.html.haml b/app/views/projects/merge_requests/conflicts/_parallel_view.html.haml
new file mode 100644
index 0000000000000000000000000000000000000000..2e6f67c2eaf867c0d5aa94e255fd0dfb5458c1e1
--- /dev/null
+++ b/app/views/projects/merge_requests/conflicts/_parallel_view.html.haml
@@ -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}}}
diff --git a/app/views/projects/merge_requests/conflicts/_submit_form.html.haml b/app/views/projects/merge_requests/conflicts/_submit_form.html.haml
new file mode 100644
index 0000000000000000000000000000000000000000..78bd4133ea292416bf3b683b3bce89220be47b28
--- /dev/null
+++ b/app/views/projects/merge_requests/conflicts/_submit_form.html.haml
@@ -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"
diff --git a/app/views/projects/merge_requests/widget/_merged.html.haml b/app/views/projects/merge_requests/widget/_merged.html.haml
index 19b5d0ff0664f5b64a8acb04e372fe0ecf725e4b..7794d6d7df2ffadd052c924af87fed2626663b9d 100644
--- a/app/views/projects/merge_requests/widget/_merged.html.haml
+++ b/app/views/projects/merge_requests/widget/_merged.html.haml
@@ -6,7 +6,7 @@
       - if @merge_request.merge_event
         by #{link_to_member(@project, @merge_request.merge_event.author, avatar: true)}
         #{time_ago_with_tooltip(@merge_request.merge_event.created_at)}
-    - if !@merge_request.source_branch_exists? || (params[:delete_source] == 'true')
+    - if !@merge_request.source_branch_exists? || params[:deleted_source_branch]
       %p
         The changes were merged into
         #{link_to @merge_request.target_branch, namespace_project_commits_path(@project.namespace, @project, @merge_request.target_branch), class: "label-branch"}.
diff --git a/app/views/projects/merge_requests/widget/_open.html.haml b/app/views/projects/merge_requests/widget/_open.html.haml
index dc18f715f25b25b0b6c9d9d07be1e859262c345c..6f5ee5f16c5a5e70921bd18759853873c4e0e9e5 100644
--- a/app/views/projects/merge_requests/widget/_open.html.haml
+++ b/app/views/projects/merge_requests/widget/_open.html.haml
@@ -1,6 +1,12 @@
 .mr-state-widget
   = render 'projects/merge_requests/widget/heading'
   .mr-widget-body
+    -# After conflicts are resolved, the user is redirected back to the MR page.
+    -# There is a short window before background workers run and GitLab processes
+    -# the new push and commits, during which it will think the conflicts still exist.
+    -# We send this param to get the widget to treat the MR as having no more conflicts.
+    - resolved_conflicts = params[:resolved_conflicts]
+
     - if @project.archived?
       = render 'projects/merge_requests/widget/open/archived'
     - elsif @merge_request.commits.blank?
@@ -9,7 +15,7 @@
       = render 'projects/merge_requests/widget/open/missing_branch'
     - elsif @merge_request.unchecked?
       = render 'projects/merge_requests/widget/open/check'
-    - elsif @merge_request.cannot_be_merged?
+    - elsif @merge_request.cannot_be_merged? && !resolved_conflicts
       = render 'projects/merge_requests/widget/open/conflicts'
     - elsif @merge_request.work_in_progress?
       = render 'projects/merge_requests/widget/open/wip'
@@ -19,7 +25,7 @@
       = render 'projects/merge_requests/widget/open/not_allowed'
     - elsif !@merge_request.mergeable_ci_state? && @pipeline && @pipeline.failed?
       = render 'projects/merge_requests/widget/open/build_failed'
-    - elsif @merge_request.can_be_merged?
+    - elsif @merge_request.can_be_merged? || resolved_conflicts
       = render 'projects/merge_requests/widget/open/accept'
 
   - if mr_closes_issues.present?
diff --git a/app/views/projects/merge_requests/widget/open/_conflicts.html.haml b/app/views/projects/merge_requests/widget/open/_conflicts.html.haml
index f000cc38a653a3b34dfa875f42e0fcc27926eac4..af3096f04d97d4c43d93d09b7d437bdc811a87fd 100644
--- a/app/views/projects/merge_requests/widget/open/_conflicts.html.haml
+++ b/app/views/projects/merge_requests/widget/open/_conflicts.html.haml
@@ -3,7 +3,18 @@
   This merge request contains merge conflicts
 
 %p
-  Please resolve these conflicts or
+  Please
+  - if @merge_request.conflicts_can_be_resolved_by?(current_user)
+    - if @merge_request.conflicts_can_be_resolved_in_ui?
+      = link_to "resolve these conflicts", conflicts_namespace_project_merge_request_path(@project.namespace, @project, @merge_request)
+    - else
+      %span.has-tooltip{title: "These conflicts cannot be resolved through GitLab"}
+        resolve these conflicts locally
+  - else
+    resolve these conflicts
+
+  or
+
   - if @merge_request.can_be_merged_via_command_line_by?(current_user)
     #{link_to "merge this request manually", "#modal_merge_info", class: "how_to_merge_link vlink", "data-toggle" => "modal"}.
   - else
diff --git a/config/routes.rb b/config/routes.rb
index b57d42ee84659965afac832582af95a4ef437cc2..58e824c047654ced6f4c02dd38f2e151c81accec 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -727,6 +727,7 @@ Rails.application.routes.draw do
           member do
             get :commits
             get :diffs
+            get :conflicts
             get :builds
             get :pipelines
             get :merge_check
@@ -737,6 +738,7 @@ Rails.application.routes.draw do
             post :toggle_award_emoji
             post :remove_wip
             get :diff_for_path
+            post :resolve_conflicts
           end
 
           collection do
diff --git a/doc/user/project/merge_requests/img/conflict_section.png b/doc/user/project/merge_requests/img/conflict_section.png
new file mode 100644
index 0000000000000000000000000000000000000000..842e50b14b2c6646139e7d92ccb68b39a7610787
Binary files /dev/null and b/doc/user/project/merge_requests/img/conflict_section.png differ
diff --git a/doc/user/project/merge_requests/img/merge_request_widget.png b/doc/user/project/merge_requests/img/merge_request_widget.png
new file mode 100644
index 0000000000000000000000000000000000000000..ffb96b17b0740373838cea9d446435977d443147
Binary files /dev/null and b/doc/user/project/merge_requests/img/merge_request_widget.png differ
diff --git a/doc/user/project/merge_requests/resolve_conflicts.md b/doc/user/project/merge_requests/resolve_conflicts.md
new file mode 100644
index 0000000000000000000000000000000000000000..44b76ffc8e689e867b2be786f8e8fb8bd6536f97
--- /dev/null
+++ b/doc/user/project/merge_requests/resolve_conflicts.md
@@ -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
diff --git a/lib/gitlab/conflict/file.rb b/lib/gitlab/conflict/file.rb
new file mode 100644
index 0000000000000000000000000000000000000000..0a1fd27ced542e1e8c8848217f6d96eb97187075
--- /dev/null
+++ b/lib/gitlab/conflict/file.rb
@@ -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
diff --git a/lib/gitlab/conflict/file_collection.rb b/lib/gitlab/conflict/file_collection.rb
new file mode 100644
index 0000000000000000000000000000000000000000..bbd0427a2c82766707c6079447915148c065930e
--- /dev/null
+++ b/lib/gitlab/conflict/file_collection.rb
@@ -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
diff --git a/lib/gitlab/conflict/parser.rb b/lib/gitlab/conflict/parser.rb
new file mode 100644
index 0000000000000000000000000000000000000000..6eccded7872791b5c6fab5ec2da4a1dc5e8f01fb
--- /dev/null
+++ b/lib/gitlab/conflict/parser.rb
@@ -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
diff --git a/lib/gitlab/diff/line.rb b/lib/gitlab/diff/line.rb
index cf097e0d0dec32f6f782efeca98be06e30328638..80a146b4a5a96f490b0252b5404232486ad8a4aa 100644
--- a/lib/gitlab/diff/line.rb
+++ b/lib/gitlab/diff/line.rb
@@ -2,11 +2,13 @@ module Gitlab
   module Diff
     class Line
       attr_reader :type, :index, :old_pos, :new_pos
+      attr_writer :rich_text
       attr_accessor :text
 
-      def initialize(text, type, index, old_pos, new_pos)
+      def initialize(text, type, index, old_pos, new_pos, parent_file: nil)
         @text, @type, @index = text, type, index
         @old_pos, @new_pos = old_pos, new_pos
+        @parent_file = parent_file
       end
 
       def self.init_from_hash(hash)
@@ -43,9 +45,25 @@ module Gitlab
         type == 'old'
       end
 
+      def rich_text
+        @parent_file.highlight_lines! if @parent_file && !@rich_text
+
+        @rich_text
+      end
+
       def meta?
         type == 'match' || type == 'nonewline'
       end
+
+      def as_json(opts = nil)
+        {
+          type: type,
+          old_line: old_line,
+          new_line: new_line,
+          text: text,
+          rich_text: rich_text || text
+        }
+      end
     end
   end
 end
diff --git a/spec/controllers/projects/merge_requests_controller_spec.rb b/spec/controllers/projects/merge_requests_controller_spec.rb
index 69758494543085e94dd493877311407b40b3fec1..c64c2b075c53c81adac2617da089badcb47953ca 100644
--- a/spec/controllers/projects/merge_requests_controller_spec.rb
+++ b/spec/controllers/projects/merge_requests_controller_spec.rb
@@ -4,6 +4,11 @@ describe Projects::MergeRequestsController do
   let(:project) { create(:project) }
   let(:user)    { create(:user) }
   let(:merge_request) { create(:merge_request_with_diffs, target_project: project, source_project: project) }
+  let(:merge_request_with_conflicts) do
+    create(:merge_request, source_branch: 'conflict-resolvable', target_branch: 'conflict-start', source_project: project) do |mr|
+      mr.mark_as_unmergeable
+    end
+  end
 
   before do
     sign_in(user)
@@ -523,4 +528,135 @@ describe Projects::MergeRequestsController do
       end
     end
   end
+
+  describe 'GET conflicts' do
+    let(:json_response) { JSON.parse(response.body) }
+
+    context 'when the conflicts cannot be resolved in the UI' do
+      before do
+        allow_any_instance_of(Gitlab::Conflict::Parser).
+          to receive(:parse).and_raise(Gitlab::Conflict::Parser::UnexpectedDelimiter)
+
+        get :conflicts,
+            namespace_id: merge_request_with_conflicts.project.namespace.to_param,
+            project_id: merge_request_with_conflicts.project.to_param,
+            id: merge_request_with_conflicts.iid,
+            format: 'json'
+      end
+
+      it 'returns a 200 status code' do
+        expect(response).to have_http_status(:ok)
+      end
+
+      it 'returns JSON with a message' do
+        expect(json_response.keys).to contain_exactly('message', 'type')
+      end
+    end
+
+    context 'with valid conflicts' do
+      before do
+        get :conflicts,
+            namespace_id: merge_request_with_conflicts.project.namespace.to_param,
+            project_id: merge_request_with_conflicts.project.to_param,
+            id: merge_request_with_conflicts.iid,
+            format: 'json'
+      end
+
+      it 'includes meta info about the MR' do
+        expect(json_response['commit_message']).to include('Merge branch')
+        expect(json_response['commit_sha']).to match(/\h{40}/)
+        expect(json_response['source_branch']).to eq(merge_request_with_conflicts.source_branch)
+        expect(json_response['target_branch']).to eq(merge_request_with_conflicts.target_branch)
+      end
+
+      it 'includes each file that has conflicts' do
+        filenames = json_response['files'].map { |file| file['new_path'] }
+
+        expect(filenames).to contain_exactly('files/ruby/popen.rb', 'files/ruby/regex.rb')
+      end
+
+      it 'splits files into sections with lines' do
+        json_response['files'].each do |file|
+          file['sections'].each do |section|
+            expect(section).to include('conflict', 'lines')
+
+            section['lines'].each do |line|
+              if section['conflict']
+                expect(line['type']).to be_in(['old', 'new'])
+                expect(line.values_at('old_line', 'new_line')).to contain_exactly(nil, a_kind_of(Integer))
+              else
+                if line['type'].nil?
+                  expect(line['old_line']).not_to eq(nil)
+                  expect(line['new_line']).not_to eq(nil)
+                else
+                  expect(line['type']).to eq('match')
+                  expect(line['old_line']).to eq(nil)
+                  expect(line['new_line']).to eq(nil)
+                end
+              end
+            end
+          end
+        end
+      end
+
+      it 'has unique section IDs across files' do
+        section_ids = json_response['files'].flat_map do |file|
+          file['sections'].map { |section| section['id'] }.compact
+        end
+
+        expect(section_ids.uniq).to eq(section_ids)
+      end
+    end
+  end
+
+  context 'POST resolve_conflicts' do
+    let(:json_response) { JSON.parse(response.body) }
+    let!(:original_head_sha) { merge_request_with_conflicts.diff_head_sha }
+
+    def resolve_conflicts(sections)
+      post :resolve_conflicts,
+           namespace_id: merge_request_with_conflicts.project.namespace.to_param,
+           project_id: merge_request_with_conflicts.project.to_param,
+           id: merge_request_with_conflicts.iid,
+           format: 'json',
+           sections: sections,
+           commit_message: 'Commit message'
+    end
+
+    context 'with valid params' do
+      before do
+        resolve_conflicts('2f6fcd96b88b36ce98c38da085c795a27d92a3dd_14_14' => 'head',
+                          '6eb14e00385d2fb284765eb1cd8d420d33d63fc9_9_9' => 'head',
+                          '6eb14e00385d2fb284765eb1cd8d420d33d63fc9_21_21' => 'origin',
+                          '6eb14e00385d2fb284765eb1cd8d420d33d63fc9_49_49' => 'origin')
+      end
+
+      it 'creates a new commit on the branch' do
+        expect(original_head_sha).not_to eq(merge_request_with_conflicts.source_branch_head.sha)
+        expect(merge_request_with_conflicts.source_branch_head.message).to include('Commit message')
+      end
+
+      it 'returns an OK response' do
+        expect(response).to have_http_status(:ok)
+      end
+    end
+
+    context 'when sections are missing' do
+      before do
+        resolve_conflicts('2f6fcd96b88b36ce98c38da085c795a27d92a3dd_14_14' => 'head')
+      end
+
+      it 'returns a 400 error' do
+        expect(response).to have_http_status(:bad_request)
+      end
+
+      it 'has a message with the name of the first missing section' do
+        expect(json_response['message']).to include('6eb14e00385d2fb284765eb1cd8d420d33d63fc9_9_9')
+      end
+
+      it 'does not create a new commit' do
+        expect(original_head_sha).to eq(merge_request_with_conflicts.source_branch_head.sha)
+      end
+    end
+  end
 end
diff --git a/spec/features/merge_requests/conflicts_spec.rb b/spec/features/merge_requests/conflicts_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..930c36ade2bf48ef77e589545c477a70f24be605
--- /dev/null
+++ b/spec/features/merge_requests/conflicts_spec.rb
@@ -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
diff --git a/spec/lib/gitlab/conflict/file_collection_spec.rb b/spec/lib/gitlab/conflict/file_collection_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..39d892c18c033812e90e892973d6fb6d9f77bea3
--- /dev/null
+++ b/spec/lib/gitlab/conflict/file_collection_spec.rb
@@ -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
diff --git a/spec/lib/gitlab/conflict/file_spec.rb b/spec/lib/gitlab/conflict/file_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..60020487061b849dcda0dd40dea6ca0c783eea37
--- /dev/null
+++ b/spec/lib/gitlab/conflict/file_spec.rb
@@ -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
diff --git a/spec/lib/gitlab/conflict/parser_spec.rb b/spec/lib/gitlab/conflict/parser_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..65a828accdec47c9d32567c9a2a1dcab8a8ad4d1
--- /dev/null
+++ b/spec/lib/gitlab/conflict/parser_spec.rb
@@ -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
diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb
index acb75ec21a9e3ca98e4240c0a5e36b6946365861..f83dbefedc0c076fc2d07696248639131eea5211 100644
--- a/spec/models/merge_request_spec.rb
+++ b/spec/models/merge_request_spec.rb
@@ -783,4 +783,56 @@ describe MergeRequest, models: true do
       end
     end
   end
+
+  describe '#conflicts_can_be_resolved_in_ui?' do
+    def create_merge_request(source_branch)
+      create(:merge_request, source_branch: source_branch, target_branch: 'conflict-start') do |mr|
+        mr.mark_as_unmergeable
+      end
+    end
+
+    it 'returns a falsey value when the MR can be merged without conflicts' do
+      merge_request = create_merge_request('master')
+      merge_request.mark_as_mergeable
+
+      expect(merge_request.conflicts_can_be_resolved_in_ui?).to be_falsey
+    end
+
+    it 'returns a falsey value when the MR does not support new diff notes' do
+      merge_request = create_merge_request('conflict-resolvable')
+      merge_request.merge_request_diff.update_attributes(start_commit_sha: nil)
+
+      expect(merge_request.conflicts_can_be_resolved_in_ui?).to be_falsey
+    end
+
+    it 'returns a falsey value when the conflicts contain a large file' do
+      merge_request = create_merge_request('conflict-too-large')
+
+      expect(merge_request.conflicts_can_be_resolved_in_ui?).to be_falsey
+    end
+
+    it 'returns a falsey value when the conflicts contain a binary file' do
+      merge_request = create_merge_request('conflict-binary-file')
+
+      expect(merge_request.conflicts_can_be_resolved_in_ui?).to be_falsey
+    end
+
+    it 'returns a falsey value when the conflicts contain a file with ambiguous conflict markers' do
+      merge_request = create_merge_request('conflict-contains-conflict-markers')
+
+      expect(merge_request.conflicts_can_be_resolved_in_ui?).to be_falsey
+    end
+
+    it 'returns a falsey value when the conflicts contain a file edited in one branch and deleted in another' do
+      merge_request = create_merge_request('conflict-missing-side')
+
+      expect(merge_request.conflicts_can_be_resolved_in_ui?).to be_falsey
+    end
+
+    it 'returns a truthy value when the conflicts are resolvable in the UI' do
+      merge_request = create_merge_request('conflict-resolvable')
+
+      expect(merge_request.conflicts_can_be_resolved_in_ui?).to be_truthy
+    end
+  end
 end
diff --git a/spec/support/test_env.rb b/spec/support/test_env.rb
index 1c0c66969e3bece8bd82183029bc4857a449c60f..edbbfc3c9e5729674525103443a8a382c5262f9c 100644
--- a/spec/support/test_env.rb
+++ b/spec/support/test_env.rb
@@ -5,25 +5,31 @@ module TestEnv
 
   # When developing the seed repository, comment out the branch you will modify.
   BRANCH_SHA = {
-    'empty-branch'          => '7efb185',
-    'ends-with.json'        => '98b0d8b3',
-    'flatten-dir'           => 'e56497b',
-    'feature'               => '0b4bc9a',
-    'feature_conflict'      => 'bb5206f',
-    'fix'                   => '48f0be4',
-    'improve/awesome'       => '5937ac0',
-    'markdown'              => '0ed8c6c',
-    'lfs'                   => 'be93687',
-    'master'                => '5937ac0',
-    "'test'"                => 'e56497b',
-    'orphaned-branch'       => '45127a9',
-    'binary-encoding'       => '7b1cf43',
-    'gitattributes'         => '5a62481',
-    'expand-collapse-diffs' => '4842455',
-    'expand-collapse-files' => '025db92',
-    'expand-collapse-lines' => '238e82d',
-    'video'                 => '8879059',
-    'crlf-diff'             => '5938907'
+    'empty-branch'                       => '7efb185',
+    'ends-with.json'                     => '98b0d8b3',
+    'flatten-dir'                        => 'e56497b',
+    'feature'                            => '0b4bc9a',
+    'feature_conflict'                   => 'bb5206f',
+    'fix'                                => '48f0be4',
+    'improve/awesome'                    => '5937ac0',
+    'markdown'                           => '0ed8c6c',
+    'lfs'                                => 'be93687',
+    'master'                             => '5937ac0',
+    "'test'"                             => 'e56497b',
+    'orphaned-branch'                    => '45127a9',
+    'binary-encoding'                    => '7b1cf43',
+    'gitattributes'                      => '5a62481',
+    'expand-collapse-diffs'              => '4842455',
+    'expand-collapse-files'              => '025db92',
+    'expand-collapse-lines'              => '238e82d',
+    'video'                              => '8879059',
+    'crlf-diff'                          => '5938907',
+    'conflict-start'                     => '14fa46b',
+    'conflict-resolvable'                => '1450cd6',
+    'conflict-binary-file'               => '259a6fb',
+    'conflict-contains-conflict-markers' => '5e0964c',
+    'conflict-missing-side'              => 'eb227b3',
+    'conflict-too-large'                 => '39fa04f',
   }
 
   # gitlab-test-fork is a fork of gitlab-fork, but we don't necessarily