Commit ae1a90a8 authored by Phil Hughes's avatar Phil Hughes

Merge branch '10232-calculate-diff-patched-in-webide' into 'master'

Resolve "Calculate diff of the Web IDE file changes"

Closes #10232

See merge request gitlab-org/gitlab-ee!11478
parents b6d3820e f5afac84
import { commitActionForFile } from '~/ide/stores/utils';
import { commitActionTypes } from '~/ide/constants';
import createFileDiff from './create_file_diff';
const filesWithChanges = ({ stagedFiles = [], changedFiles = [] }) => {
// We need changed files to overwrite staged, so put them at the end.
const changes = stagedFiles.concat(changedFiles).reduce((acc, file) => {
const key = file.path;
const action = commitActionForFile(file);
const prev = acc[key];
// If a file was deleted, which was previously added, then we should do nothing.
if (action === commitActionTypes.delete && prev && prev.action === commitActionTypes.create) {
delete acc[key];
} else {
acc[key] = { action, file };
}
return acc;
}, {});
// We need to clean "move" actions, because we can only support 100% similarity moves at the moment.
// This is because the previous file's content might not be loaded.
Object.values(changes)
.filter(change => change.action === commitActionTypes.move)
.forEach(change => {
const prev = changes[change.file.prevPath];
if (!prev) {
return;
}
if (change.file.content === prev.file.content) {
// If content is the same, continue with the move but don't do the prevPath's delete.
delete changes[change.file.prevPath];
} else {
// Otherwise, treat the move as a delete / create.
Object.assign(change, { action: commitActionTypes.create });
}
});
return Object.values(changes);
};
const createDiff = state => {
const changes = filesWithChanges(state);
const toDelete = changes.filter(x => x.action === commitActionTypes.delete).map(x => x.file.path);
const patch = changes
.filter(x => x.action !== commitActionTypes.delete)
.map(({ file, action }) => createFileDiff(file, action))
.join('');
return {
patch,
toDelete,
};
};
export default createDiff;
import { createTwoFilesPatch } from 'diff';
import { commitActionTypes } from '~/ide/constants';
const DEV_NULL = '/dev/null';
const DEFAULT_MODE = '100644';
const NO_NEW_LINE = '\\ No newline at end of file';
const NEW_LINE = '\n';
/**
* Cleans patch generated by `diff` package.
*
* - Removes "=======" separator added at the beginning
*/
const cleanTwoFilesPatch = text => text.replace(/^(=+\s*)/, '');
const endsWithNewLine = val => !val || val[val.length - 1] === NEW_LINE;
const addEndingNewLine = val => (endsWithNewLine(val) ? val : val + NEW_LINE);
const removeEndingNewLine = val => (endsWithNewLine(val) ? val.substr(0, val.length - 1) : val);
const diffHead = (prevPath, newPath = '') =>
`diff --git "a/${prevPath}" "b/${newPath || prevPath}"`;
const createDiffBody = (path, content, isCreate) => {
if (!content) {
return '';
}
const prefix = isCreate ? '+' : '-';
const fromPath = isCreate ? DEV_NULL : `a/${path}`;
const toPath = isCreate ? `b/${path}` : DEV_NULL;
const hasNewLine = endsWithNewLine(content);
const lines = removeEndingNewLine(content).split(NEW_LINE);
const chunkHead = isCreate ? `@@ -0,0 +1,${lines.length} @@` : `@@ -1,${lines.length} +0,0 @@`;
const chunk = lines
.map(line => `${prefix}${line}`)
.concat(!hasNewLine ? [NO_NEW_LINE] : [])
.join(NEW_LINE);
return `--- ${fromPath}
+++ ${toPath}
${chunkHead}
${chunk}`;
};
const createMoveFileDiff = (prevPath, newPath) => `${diffHead(prevPath, newPath)}
rename from ${prevPath}
rename to ${newPath}`;
const createNewFileDiff = (path, content) => {
const diff = createDiffBody(path, content, true);
return `${diffHead(path)}
new file mode ${DEFAULT_MODE}
${diff}`;
};
const createDeleteFileDiff = (path, content) => {
const diff = createDiffBody(path, content, false);
return `${diffHead(path)}
deleted file mode ${DEFAULT_MODE}
${diff}`;
};
const createUpdateFileDiff = (path, oldContent, newContent) => {
const patch = createTwoFilesPatch(`a/${path}`, `b/${path}`, oldContent, newContent);
return `${diffHead(path)}
${cleanTwoFilesPatch(patch)}`;
};
const createFileDiffRaw = (file, action) => {
switch (action) {
case commitActionTypes.move:
return createMoveFileDiff(file.prevPath, file.path);
case commitActionTypes.create:
return createNewFileDiff(file.path, file.content);
case commitActionTypes.delete:
return createDeleteFileDiff(file.path, file.content);
case commitActionTypes.update:
return createUpdateFileDiff(file.path, file.raw || '', file.content);
default:
return '';
}
};
/**
* Create a git diff for a single IDE file.
*
* ## Notes:
* When called with `commitActionType.move`, it assumes that the move
* is a 100% similarity move. No diff will be generated. This is because
* generating a move with changes is not support by the current IDE, since
* the source file might not have it's content loaded yet.
*
* When called with `commitActionType.delete`, it does not support
* deleting files with a mode different than 100644. For the IDE mirror, this
* isn't needed because deleting is handled outside the unified patch.
*
* ## References:
* - https://git-scm.com/docs/git-diff#_generating_patches_with_p
*/
const createFileDiff = (file, action) =>
// It's important that the file diff ends in a new line - git expects this.
addEndingNewLine(createFileDiffRaw(file, action));
export default createFileDiff;
import createDiff from './create_diff';
export const createMirror = () => {
const uploadDiff = () => {
// For now, this is a placeholder.
// It will be implemented in https://gitlab.com/gitlab-org/gitlab-ee/issues/5276
};
return {
upload(state) {
uploadDiff(createDiff(state));
},
};
};
export default createMirror();
import eventHub from '~/ide/eventhub';
import mirror from '../../lib/mirror';
export default function createMirrorPlugin() {
return store => {
eventHub.$on('editor.save', () => mirror.upload(store.state));
};
}
export const createFile = (path, content = '') => ({
id: path,
path,
content,
raw: content,
});
export const createNewFile = (path, content) =>
Object.assign(createFile(path, content), {
tempFile: true,
raw: '',
});
export const createDeletedFile = (path, content) =>
Object.assign(createFile(path, content), {
deleted: true,
});
export const createUpdatedFile = (path, oldContent, content) =>
Object.assign(createFile(path, content), {
raw: oldContent,
});
export const createMovedFile = (path, prevPath, content) =>
Object.assign(createNewFile(path, content), {
prevPath,
});
import { commitActionTypes } from '~/ide/constants';
import createDiff from 'ee/ide/lib/create_diff';
import createFileDiff from 'ee/ide/lib/create_file_diff';
import {
createNewFile,
createUpdatedFile,
createDeletedFile,
createMovedFile,
} from '../file_helpers';
const PATH_FOO = 'test/foo.md';
const PATH_BAR = 'test/bar.md';
const PATH_ZED = 'test/zed.md';
const PATH_LOREM = 'test/lipsum/nested/lorem.md';
const PATH_IPSUM = 'test/lipsum/ipsum.md';
const TEXT = `Lorem ipsum dolor sit amet,
consectetur adipiscing elit.
Morbi ex dolor, euismod nec rutrum nec, egestas at ligula.
Praesent scelerisque ut nisi eu eleifend.
Suspendisse potenti.
`;
const LINES = TEXT.trim().split('\n');
const joinDiffs = (...patches) => patches.join('');
describe('EE IDE lib/create_diff', () => {
it('with created files, generates patch', () => {
const changedFiles = [createNewFile(PATH_FOO, TEXT), createNewFile(PATH_BAR, '')];
const result = createDiff({ changedFiles });
expect(result).toEqual({
patch: joinDiffs(
createFileDiff(changedFiles[0], commitActionTypes.create),
createFileDiff(changedFiles[1], commitActionTypes.create),
),
toDelete: [],
});
});
it('with deleted files, adds to delete', () => {
const changedFiles = [createDeletedFile(PATH_FOO, TEXT), createDeletedFile(PATH_BAR, '')];
const result = createDiff({ changedFiles });
expect(result).toEqual({
patch: '',
toDelete: [PATH_FOO, PATH_BAR],
});
});
it('with updated files, generates patch', () => {
const changedFiles = [createUpdatedFile(PATH_FOO, TEXT, 'A change approaches!')];
const result = createDiff({ changedFiles });
expect(result).toEqual({
patch: createFileDiff(changedFiles[0], commitActionTypes.update),
toDelete: [],
});
});
it('with files in both staged and changed, prefer changed', () => {
const changedFiles = [
createUpdatedFile(PATH_FOO, TEXT, 'Do a change!'),
createDeletedFile(PATH_LOREM),
];
const result = createDiff({
changedFiles,
stagedFiles: [createUpdatedFile(PATH_LOREM, TEXT, ''), createDeletedFile(PATH_FOO, TEXT)],
});
expect(result).toEqual({
patch: createFileDiff(changedFiles[0], commitActionTypes.update),
toDelete: [PATH_LOREM],
});
});
it('with file created in staging and deleted in changed, do nothing', () => {
const result = createDiff({
changedFiles: [createDeletedFile(PATH_FOO)],
stagedFiles: [createNewFile(PATH_FOO, TEXT)],
});
expect(result).toEqual({
patch: '',
toDelete: [],
});
});
it('with file deleted in both staged and changed, delete', () => {
const result = createDiff({
changedFiles: [createDeletedFile(PATH_LOREM)],
stagedFiles: [createDeletedFile(PATH_LOREM)],
});
expect(result).toEqual({
patch: '',
toDelete: [PATH_LOREM],
});
});
it('with file moved, create and delete', () => {
const changedFiles = [createMovedFile(PATH_BAR, PATH_FOO, TEXT)];
const result = createDiff({
changedFiles,
stagedFiles: [createDeletedFile(PATH_FOO)],
});
expect(result).toEqual({
patch: createFileDiff(changedFiles[0], commitActionTypes.create),
toDelete: [PATH_FOO],
});
});
it('with file moved and no content, move', () => {
const changedFiles = [createMovedFile(PATH_BAR, PATH_FOO)];
const result = createDiff({
changedFiles,
stagedFiles: [createDeletedFile(PATH_FOO)],
});
expect(result).toEqual({
patch: createFileDiff(changedFiles[0], commitActionTypes.move),
toDelete: [],
});
});
it('creates a well formatted patch', () => {
const changedFiles = [
createMovedFile(PATH_BAR, PATH_FOO),
createDeletedFile(PATH_ZED),
createNewFile(PATH_LOREM, TEXT),
createUpdatedFile(PATH_IPSUM, TEXT, "That's all folks!"),
];
const expectedPatch = `diff --git "a/${PATH_FOO}" "b/${PATH_BAR}"
rename from ${PATH_FOO}
rename to ${PATH_BAR}
diff --git "a/${PATH_LOREM}" "b/${PATH_LOREM}"
new file mode 100644
--- /dev/null
+++ b/${PATH_LOREM}
@@ -0,0 +1,${LINES.length} @@
${LINES.map(line => `+${line}`).join('\n')}
diff --git "a/${PATH_IPSUM}" "b/${PATH_IPSUM}"
--- a/${PATH_IPSUM}
+++ b/${PATH_IPSUM}
@@ -1,${LINES.length} +1,1 @@
${LINES.map(line => `-${line}`).join('\n')}
+That's all folks!
\\ No newline at end of file
`;
const result = createDiff({ changedFiles });
expect(result).toEqual({
patch: expectedPatch,
toDelete: [PATH_ZED],
});
});
});
import { commitActionTypes } from '~/ide/constants';
import createFileDiff from 'ee/ide/lib/create_file_diff';
import {
createUpdatedFile,
createNewFile,
createMovedFile,
createDeletedFile,
} from '../file_helpers';
const PATH = 'test/numbers.md';
const PATH_FOO = 'test/foo.md';
const TEXT_LINE_COUNT = 100;
const TEXT = Array(TEXT_LINE_COUNT)
.fill(0)
.map((_, idx) => `${idx + 1}`)
.join('\n');
const spliceLines = (content, lineNumber, deleteCount = 0, newLines = []) => {
const lines = content.split('\n');
lines.splice(lineNumber, deleteCount, ...newLines);
return lines.join('\n');
};
const mapLines = (content, mapFn) =>
content
.split('\n')
.map(mapFn)
.join('\n');
describe('EE IDE lib/create_file_diff', () => {
it('returns empty string with "garbage" action', () => {
const result = createFileDiff(createNewFile(PATH, ''), 'garbage');
expect(result).toBe('');
});
it('preserves ending whitespace in file', () => {
const oldContent = spliceLines(TEXT, 99, 1, ['100 ']);
const newContent = spliceLines(oldContent, 99, 0, ['Lorem', 'Ipsum']);
const expected = `
99
+Lorem
+Ipsum
100 `;
const result = createFileDiff(
createUpdatedFile(PATH, oldContent, newContent),
commitActionTypes.update,
);
expect(result).toContain(expected);
});
describe('with "create" action', () => {
const expectedHead = `diff --git "a/${PATH}" "b/${PATH}"
new file mode 100644`;
const expectedChunkHead = lineCount => `--- /dev/null
+++ b/${PATH}
@@ -0,0 +1,${lineCount} @@`;
it('with empty file, does not include diff body', () => {
const result = createFileDiff(createNewFile(PATH, ''), commitActionTypes.create);
expect(result).toBe(`${expectedHead}\n`);
});
it('with single line, includes diff body', () => {
const result = createFileDiff(createNewFile(PATH, '\n'), commitActionTypes.create);
expect(result).toBe(`${expectedHead}
${expectedChunkHead(1)}
+
`);
});
it('without newline, includes no newline comment', () => {
const result = createFileDiff(createNewFile(PATH, 'Lorem ipsum'), commitActionTypes.create);
expect(result).toBe(`${expectedHead}
${expectedChunkHead(1)}
+Lorem ipsum
\\ No newline at end of file
`);
});
it('with content, includes diff body', () => {
const content = `${TEXT}\n`;
const result = createFileDiff(createNewFile(PATH, content), commitActionTypes.create);
expect(result).toBe(`${expectedHead}
${expectedChunkHead(TEXT_LINE_COUNT)}
${mapLines(TEXT, line => `+${line}`)}
`);
});
});
describe('with "delete" action', () => {
const expectedHead = `diff --git "a/${PATH}" "b/${PATH}"
deleted file mode 100644`;
const expectedChunkHead = lineCount => `--- a/${PATH}
+++ /dev/null
@@ -1,${lineCount} +0,0 @@`;
it('with empty file, does not include diff body', () => {
const result = createFileDiff(createDeletedFile(PATH, ''), commitActionTypes.delete);
expect(result).toBe(`${expectedHead}\n`);
});
it('with content, includes diff body', () => {
const content = `${TEXT}\n`;
const result = createFileDiff(createDeletedFile(PATH, content), commitActionTypes.delete);
expect(result).toBe(`${expectedHead}
${expectedChunkHead(TEXT_LINE_COUNT)}
${mapLines(TEXT, line => `-${line}`)}
`);
});
});
describe('with "update" action', () => {
it('includes diff body', () => {
const oldContent = `${TEXT}\n`;
const newContent = `${spliceLines(TEXT, 50, 3, ['Lorem'])}\n`;
const result = createFileDiff(
createUpdatedFile(PATH, oldContent, newContent),
commitActionTypes.update,
);
expect(result).toBe(`diff --git "a/${PATH}" "b/${PATH}"
--- a/${PATH}
+++ b/${PATH}
@@ -47,11 +47,9 @@
47
48
49
50
-51
-52
-53
+Lorem
54
55
56
57
`);
});
});
describe('with "move" action', () => {
it('returns rename head', () => {
const result = createFileDiff(createMovedFile(PATH, PATH_FOO), commitActionTypes.move);
expect(result).toBe(`diff --git "a/${PATH_FOO}" "b/${PATH}"
rename from ${PATH_FOO}
rename to ${PATH}
`);
});
});
});
import eventHub from '~/ide/eventhub';
import { createStore } from '~/ide/stores';
import createMirrorPlugin from 'ee/ide/stores/plugins/mirror';
import mirror from 'ee/ide/lib/mirror';
jest.mock('ee/ide/lib/mirror');
describe('EE IDE stores/plugins/mirror', () => {
let store;
let plugin;
beforeEach(() => {
store = createStore();
plugin = createMirrorPlugin();
plugin(store);
});
it('does not initally call upload', () => {
expect(mirror.upload).not.toHaveBeenCalled();
});
it('uploads on editor.save event', () => {
eventHub.$emit('editor.save');
expect(mirror.upload).toHaveBeenCalledWith(store.state);
});
});
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment