Skip to content

Commit

Permalink
Implement patch/3way merge
Browse files Browse the repository at this point in the history
Fixes #54
  • Loading branch information
kpdecker committed Aug 26, 2015
1 parent da522e3 commit f94da90
Show file tree
Hide file tree
Showing 3 changed files with 1,582 additions and 0 deletions.
349 changes: 349 additions & 0 deletions src/patch/merge.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,349 @@
import {structuredPatch} from './create';
import {parsePatch} from './parse';

import {arrayEqual, arrayStartsWith} from '../util/array';

export function calcLineCount(hunk) {
let conflicted = false;

hunk.oldLines = 0;
hunk.newLines = 0;

hunk.lines.forEach(function(line) {
if (typeof line !== 'string') {
conflicted = true;
return;
}

if (line[0] === '+' || line[0] === ' ') {
hunk.newLines++;
}
if (line[0] === '-' || line[0] === ' ') {
hunk.oldLines++;
}
});

if (conflicted) {
delete hunk.oldLines;
delete hunk.newLines;
}
}

export function merge(mine, theirs, base) {
mine = loadPatch(mine, base);
theirs = loadPatch(theirs, base);

let ret = {};

// For index we just let it pass through as it doesn't have any necessary meaning.
// Leaving sanity checks on this to the API consumer that may know more about the
// meaning in their own context.
if (mine.index || theirs.index) {
ret.index = mine.index || theirs.index;
}

if (mine.newFileName || theirs.newFileName) {
if (!fileNameChanged(mine)) {
// No header or no change in ours, use theirs (and ours if theirs does not exist)
ret.oldFileName = theirs.oldFileName || mine.oldFileName;
ret.newFileName = theirs.newFileName || mine.newFileName;
ret.oldHeader = theirs.oldHeader || mine.oldHeader;
ret.newHeader = theirs.newHeader || mine.newHeader;
} else if (!fileNameChanged(theirs)) {
// No header or no change in theirs, use ours
ret.oldFileName = mine.oldFileName;
ret.newFileName = mine.newFileName;
ret.oldHeader = mine.oldHeader;
ret.newHeader = mine.newHeader;
} else {
// Both changed... figure it out
ret.oldFileName = selectField(ret, mine.oldFileName, theirs.oldFileName);
ret.newFileName = selectField(ret, mine.newFileName, theirs.newFileName);
ret.oldHeader = selectField(ret, mine.oldHeader, theirs.oldHeader);
ret.newHeader = selectField(ret, mine.newHeader, theirs.newHeader);
}
}

ret.hunks = [];

let mineIndex = 0,
theirsIndex = 0,
mineOffset = 0,
theirsOffset = 0;

while (mineIndex < mine.hunks.length || theirsIndex < theirs.hunks.length) {
let mineCurrent = mine.hunks[mineIndex] || {oldStart: Infinity},
theirsCurrent = theirs.hunks[theirsIndex] || {oldStart: Infinity};

if (hunkBefore(mineCurrent, theirsCurrent)) {
// This patch does not overlap with any of the others, yay.
ret.hunks.push(cloneHunk(mineCurrent, mineOffset));
mineIndex++;
theirsOffset += mineCurrent.newLines - mineCurrent.oldLines;
} else if (hunkBefore(theirsCurrent, mineCurrent)) {
// This patch does not overlap with any of the others, yay.
ret.hunks.push(cloneHunk(theirsCurrent, theirsOffset));
theirsIndex++;
mineOffset += theirsCurrent.newLines - theirsCurrent.oldLines;
} else {
// Overlap, merge as best we can
let mergedHunk = {
oldStart: Math.min(mineCurrent.oldStart, theirsCurrent.oldStart),
oldLines: 0,
newStart: Math.min(mineCurrent.newStart + mineOffset, theirsCurrent.oldStart + theirsOffset),
newLines: 0,
lines: []
};
mergeLines(mergedHunk, mineCurrent.oldStart, mineCurrent.lines, theirsCurrent.oldStart, theirsCurrent.lines);
theirsIndex++;
mineIndex++;

ret.hunks.push(mergedHunk);
}
}

return ret;
}

function loadPatch(param, base) {
if (typeof param === 'string') {
if (/^@@/m.test(param) || (/^Index:/m.test(param))) {
return parsePatch(param)[0];
}

if (!base) {
throw new Error('Must provide a base reference or pass in a patch');
}
return structuredPatch(undefined, undefined, base, param);
}

return param;
}

function fileNameChanged(patch) {
return patch.newFileName && patch.newFileName !== patch.oldFileName;
}

function selectField(index, mine, theirs) {
if (mine === theirs) {
return mine;
} else {
index.conflict = true;
return {mine, theirs};
}
}

function hunkBefore(test, check) {
return test.oldStart < check.oldStart
&& (test.oldStart + test.oldLines) < check.oldStart;
}

function cloneHunk(hunk, offset) {
return {
oldStart: hunk.oldStart, oldLines: hunk.oldLines,
newStart: hunk.newStart + offset, newLines: hunk.newLines,
lines: hunk.lines
};
}

function mergeLines(hunk, mineOffset, mineLines, theirOffset, theirLines) {
// This will generally result in a conflicted hunk, but there are cases where the context
// is the only overlap where we can successfully merge the content here.
let mine = {offset: mineOffset, lines: mineLines, index: 0},
their = {offset: theirOffset, lines: theirLines, index: 0};

// Handle any leading content
insertLeading(hunk, mine, their);
insertLeading(hunk, their, mine);

// Now in the overlap content. Scan through and select the best changes from each.
while (mine.index < mine.lines.length && their.index < their.lines.length) {
let mineCurrent = mine.lines[mine.index],
theirCurrent = their.lines[their.index];

if ((mineCurrent[0] === '-' || mineCurrent[0] === '+')
&& (theirCurrent[0] === '-' || theirCurrent[0] === '+')) {
// Both modified ...
mutualChange(hunk, mine, their);
} else if (mineCurrent[0] === '+' && theirCurrent[0] === ' ') {
// Mine inserted
hunk.lines.push(... collectChange(mine));
} else if (theirCurrent[0] === '+' && mineCurrent[0] === ' ') {
// Theirs inserted
hunk.lines.push(... collectChange(their));
} else if (mineCurrent[0] === '-' && theirCurrent[0] === ' ') {
// Mine removed or edited
removal(hunk, mine, their);
} else if (theirCurrent[0] === '-' && mineCurrent[0] === ' ') {
// Their removed or edited
removal(hunk, their, mine, true);
} else if (mineCurrent === theirCurrent) {
// Context identity
hunk.lines.push(mineCurrent);
mine.index++;
their.index++;
} else {
// Context mismatch
conflict(hunk, collectChange(mine), collectChange(their));
}
}

// Now push anything that may be remaining
insertTrailing(hunk, mine);
insertTrailing(hunk, their);

calcLineCount(hunk);
}

function mutualChange(hunk, mine, their) {
let myChanges = collectChange(mine),
theirChanges = collectChange(their);

if (allRemoves(myChanges) && allRemoves(theirChanges)) {
// Special case for remove changes that are supersets of one another
if (arrayStartsWith(myChanges, theirChanges)
&& skipRemoveSuperset(their, myChanges, myChanges.length - theirChanges.length)) {
hunk.lines.push(... myChanges);
return;
} else if (arrayStartsWith(theirChanges, myChanges)
&& skipRemoveSuperset(mine, theirChanges, theirChanges.length - myChanges.length)) {
hunk.lines.push(... theirChanges);
return;
}
} else if (arrayEqual(myChanges, theirChanges)) {
hunk.lines.push(... myChanges);
return;
}

conflict(hunk, myChanges, theirChanges);
}

function removal(hunk, mine, their, swap) {
let myChanges = collectChange(mine),
theirChanges = collectContext(their, myChanges);
if (theirChanges.merged) {
hunk.lines.push(... theirChanges.merged);
} else {
conflict(hunk, swap ? theirChanges : myChanges, swap ? myChanges : theirChanges);
}
}

function conflict(hunk, mine, their) {
hunk.conflict = true;
hunk.lines.push({
conflict: true,
mine: mine,
theirs: their
});
}

function insertLeading(hunk, insert, their) {
while (insert.offset < their.offset && insert.index < insert.lines.length) {
let line = insert.lines[insert.index++];
hunk.lines.push(line);
insert.offset++;
}
}
function insertTrailing(hunk, insert) {
while (insert.index < insert.lines.length) {
let line = insert.lines[insert.index++];
hunk.lines.push(line);
}
}

function collectChange(state) {
let ret = [],
operation = state.lines[state.index][0];
while (state.index < state.lines.length) {
let line = state.lines[state.index];

// Group additions that are immediately after subtractions and treat them as one "atomic" modify change.
if (operation === '-' && line[0] === '+') {
operation = '+';
}

if (operation === line[0]) {
ret.push(line);
state.index++;
} else {
break;
}
}

return ret;
}
function collectContext(state, matchChanges) {
let changes = [],
merged = [],
matchIndex = 0,
contextChanges = false,
conflicted = false;
while (matchIndex < matchChanges.length
&& state.index < state.lines.length) {
let change = state.lines[state.index],
match = matchChanges[matchIndex];

// Once we've hit our add, then we are done
if (match[0] === '+') {
break;
}

contextChanges = contextChanges || change[0] !== ' ';

merged.push(match);
matchIndex++;

// Consume any additions in the other block as a conflict to attempt
// to pull in the remaining context after this
if (change[0] === '+') {
conflicted = true;

while (change[0] === '+') {
changes.push(change);
change = state.lines[++state.index];
}
}

if (match.substr(1) === change.substr(1)) {
changes.push(change);
state.index++;
} else {
conflicted = true;
}
}

if ((matchChanges[matchIndex] || '')[0] === '+'
&& contextChanges) {
conflicted = true;
}

if (conflicted) {
return changes;
}

while (matchIndex < matchChanges.length) {
merged.push(matchChanges[matchIndex++]);
}

return {
merged,
changes
};
}

function allRemoves(changes) {
return changes.reduce(function(prev, change) {
return prev && change[0] === '-';
}, true);
}
function skipRemoveSuperset(state, removeChanges, delta) {
for (let i = 0; i < delta; i++) {
let changeContent = removeChanges[removeChanges.length - delta + i].substr(1);
if (state.lines[state.index + i] !== ' ' + changeContent) {
return false;
}
}

state.index += delta;
return true;
}
21 changes: 21 additions & 0 deletions src/util/array.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
export function arrayEqual(a, b) {
if (a.length !== b.length) {
return false;
}

return arrayStartsWith(a, b);
}

export function arrayStartsWith(array, start) {
if (start.length > array.length) {
return false;
}

for (let i = 0; i < start.length; i++) {
if (start[i] !== array[i]) {
return false;
}
}

return true;
}
Loading

0 comments on commit f94da90

Please sign in to comment.