insomnia/packages/insomnia-app/app/sync/vcs/util.js
Gregory Schier 0a616fba6b
Version Control (beta) (#1439)
* VCS proof of concept underway!

* Stuff

* Some things

* Replace deprecated Electron makeSingleInstance

* Rename `window` variables so not to be confused with window object

* Don't unnecessarily update request when URL does not change

* Regenerate package-lock

* Fix tests + ESLint

* Publish

 - insomnia-app@1.0.49
 - insomnia-cookies@0.0.12
 - insomnia-httpsnippet@1.16.18
 - insomnia-importers@2.0.13
 - insomnia-libcurl@0.0.23
 - insomnia-prettify@0.1.7
 - insomnia-url@0.1.6
 - insomnia-xpath@1.0.9
 - insomnia-plugin-base64@1.0.6
 - insomnia-plugin-cookie-jar@1.0.8
 - insomnia-plugin-core-themes@1.0.5
 - insomnia-plugin-default-headers@1.1.9
 - insomnia-plugin-file@1.0.7
 - insomnia-plugin-hash@1.0.7
 - insomnia-plugin-jsonpath@1.0.12
 - insomnia-plugin-now@1.0.11
 - insomnia-plugin-os@1.0.13
 - insomnia-plugin-prompt@1.1.9
 - insomnia-plugin-request@1.0.18
 - insomnia-plugin-response@1.0.16
 - insomnia-plugin-uuid@1.0.10

* Broken but w/e

* Some tweaks

* Big refactor. Create local snapshots and push done

* POC merging and a lot of improvements

* Lots of work done on initial UI/UX

* Fix old tests

* Atomic writes and size-based batches

* Update StageEntry definition once again to be better

* Factor out GraphQL query logic

* Merge algorithm, history modal, other minor things

* Fix test

* Merge, checkout, revert w/ user changes now work

* Force UI to refresh when switching branches changes active request

* Rough draft pull() and some cleanup

* E2EE stuff and some refactoring

* Add ability to share project with team and fixed tests

* VCS now created in root component and better remote project handling

* Remove unused definition

* Publish

 - insomnia-account@0.0.2
 - insomnia-app@1.1.1
 - insomnia-cookies@0.0.14
 - insomnia-httpsnippet@1.16.20
 - insomnia-importers@2.0.15
 - insomnia-libcurl@0.0.25
 - insomnia-prettify@0.1.9
 - insomnia-sync@0.0.2
 - insomnia-url@0.1.8
 - insomnia-xpath@1.0.11
 - insomnia-plugin-base64@1.0.8
 - insomnia-plugin-cookie-jar@1.0.10
 - insomnia-plugin-core-themes@1.0.7
 - insomnia-plugin-file@1.0.9
 - insomnia-plugin-hash@1.0.9
 - insomnia-plugin-jsonpath@1.0.14
 - insomnia-plugin-now@1.0.13
 - insomnia-plugin-os@1.0.15
 - insomnia-plugin-prompt@1.1.11
 - insomnia-plugin-request@1.0.20
 - insomnia-plugin-response@1.0.18
 - insomnia-plugin-uuid@1.0.12

* Move some deps around

* Fix Flow errors

* Update package.json

* Fix eslint errors

* Fix tests

* Update deps

* bootstrap insomnia-sync

* TRy fixing appveyor

* Try something else

* Bump lerna

* try powershell

*  Try again

* Fix imports

* Fixed errors

* sync types refactor

* Show remote projects in workspace dropdown

* Improved pulling of non-local workspaces

* Loading indicators and some tweaks

* Clean up sync staging modal

* Some sync improvements:

- No longer store stage
- Upgrade Electron
- Sync UI/UX improvements

* Fix snyc tests

* Upgraded deps and hot loader tweaks (it's broken for some reason)

* Fix tests

* Branches dialog, network refactoring, some tweaks

* Fixed merging when other branch is empty

* A bunch of small fixes from real testing

* Fixed pull merge logic

* Fix tests

* Some bug fixes

* A few small tweaks

* Conflict resolution and other improvements

* Fix tests

* Add revert changes

* Deal with duplicate projects per workspace

* Some tweaks and accessibility improvements

* Tooltip accessibility

* Fix API endpoint

* Fix tests

* Remove jest dep from insomnia-importers
2019-04-17 17:50:03 -07:00

479 lines
11 KiB
JavaScript

// @flow
import clone from 'clone';
import crypto from 'crypto';
import { deterministicStringify } from '../lib/deterministicStringify';
import type {
Branch,
DocumentKey,
MergeConflict,
Snapshot,
SnapshotState,
SnapshotStateEntry,
SnapshotStateMap,
StageEntry,
StatusCandidate,
StatusCandidateMap,
} from '../types';
export function generateSnapshotStateMap(snapshot: Snapshot | null): SnapshotStateMap {
if (!snapshot) {
return {};
}
return generateStateMap(snapshot.state);
}
export function generateStateMap(state: SnapshotState | null): SnapshotStateMap {
if (!state) {
return {};
}
const map = {};
for (const entry of state) {
map[entry.key] = entry;
}
return map;
}
export function generateCandidateMap(candidates: Array<StatusCandidate>): StatusCandidateMap {
const map = {};
for (const candidate of candidates) {
map[candidate.key] = candidate;
}
return map;
}
export function combinedMapKeys<T: SnapshotStateMap | StatusCandidateMap>(
...maps: Array<T>
): Array<DocumentKey> {
const keyMap = {};
for (const map of maps) {
for (const key of Object.keys(map)) {
keyMap[key] = true;
}
}
return Object.keys(keyMap);
}
export function threeWayMerge(
root: SnapshotState,
trunk: SnapshotState,
other: SnapshotState,
): { state: SnapshotState, conflicts: Array<MergeConflict> } {
const stateRoot = generateStateMap(root);
const stateTrunk = generateStateMap(trunk);
const stateOther = generateStateMap(other);
const allKeys = combinedMapKeys(stateRoot, stateTrunk, stateOther);
const newState: SnapshotState = [];
const conflicts: Array<MergeConflict> = [];
for (const key of allKeys) {
const root = stateRoot[key] || null;
const trunk = stateTrunk[key] || null;
const other = stateOther[key] || null;
// This condition tree checks every possible case that root, trunk, other can be in
// separately. YES, it could be simplified but we want to expand every case to make
// it as bulletproof and readable as possible.
//
// Here are the possible combinations:
// root => [ exists, missing ]
// trunk => [ exists, missing, modified ]
// other => [ exists, missing, modified ]
//
// Therefore, the total number of cases is equal to 2 * 3! = 12
// ~~~~~~~~~~ //
// Unmodified //
// ~~~~~~~~~~ //
// (1/12)
// Unmodified
if (root && trunk && other && root.blob === trunk.blob && root.blob === other.blob) {
newState.push(trunk);
continue;
}
// ~~~~~~~~~ //
// Deletions //
// ~~~~~~~~~ //
// (2/12)
// Deleted in both
if (root && trunk === null && other === null) {
continue;
}
// (3/12)
// Deleted in trunk
if (root && trunk === null && other && other.blob === root.blob) {
continue;
}
// (4/12)
// Deleted in other
if (root && trunk && other === null && trunk.blob === root.blob) {
continue;
}
// ~~~~~~~~~ //
// Additions //
// ~~~~~~~~~ //
// (5/12)
// Added in both
if (root === null && trunk && other) {
if (trunk.blob !== other.blob) {
conflicts.push({
key,
name: other.name,
message: 'both added',
mineBlob: trunk.blob,
theirsBlob: other.blob,
choose: other.blob,
});
}
newState.push(trunk || other);
continue;
}
// (6/12)
// Added in trunk
if (root === null && trunk && other === null) {
newState.push(trunk);
continue;
}
// (7/12)
// Added in other
if (root === null && trunk === null && other) {
newState.push(other);
continue;
}
// ~~~~~~~~~~~~~ //
// Modifications //
// ~~~~~~~~~~~~~ //
// (8/12)
// Modified in both
if (root && trunk && other && root.blob !== trunk.blob && root.blob !== other.blob) {
if (trunk.blob !== other.blob) {
conflicts.push({
key,
name: other.name,
message: 'both modified',
mineBlob: trunk.blob,
theirsBlob: other.blob,
choose: other.blob,
});
}
newState.push(trunk);
continue;
}
// (9/12)
// Modified in trunk
if (root && trunk && other && root.blob !== trunk.blob && root.blob === other.blob) {
newState.push(trunk);
continue;
}
// (10/12)
// Modified in other
if (root && trunk && other && root.blob === trunk.blob && root.blob !== other.blob) {
newState.push(other);
continue;
}
// ~~~~~~ //
// Combos //
// ~~~~~~ //
// (11/12)
// Deleted in trunk and modified in other
if (root && trunk === null && other && other.blob !== root.blob) {
conflicts.push({
key,
name: other.name,
message: 'you deleted and they modified',
mineBlob: null,
theirsBlob: other.blob,
choose: other.blob,
});
newState.push(other);
continue;
}
// (12/12)
// Deleted in other and modified in trunk
if (root && trunk && other === null && trunk.blob !== root.blob) {
conflicts.push({
key,
name: root.name,
message: 'they deleted and you modified',
mineBlob: trunk.blob,
theirsBlob: null,
choose: trunk.blob,
});
newState.push(trunk);
continue;
}
// This should never actually happen, but let's error just to be safe
throw new Error('3-way merge hit impossible state');
}
return {
state: newState,
conflicts: conflicts,
};
}
export function compareBranches(
a: Branch | null,
b: Branch | null,
): { ahead: number, behind: number } {
const snapshotsA = a ? a.snapshots : [];
const snapshotsB = b ? b.snapshots : [];
const latestA = snapshotsA[snapshotsA.length - 1] || null;
const latestB = snapshotsB[snapshotsB.length - 1] || null;
const result = {
ahead: 0,
behind: 0,
};
if (latestA === latestB) {
return result;
}
if (latestA === null) {
result.behind = snapshotsB.length;
return result;
}
if (latestB === null) {
result.ahead = snapshotsA.length;
return result;
}
const root = getRootSnapshot(a, b);
if (root === null) {
return result;
}
const indexOfRootInA = snapshotsA.indexOf(root);
const indexOfRootInB = snapshotsB.indexOf(root);
result.ahead = snapshotsA.length - indexOfRootInA - 1;
result.behind = snapshotsB.length - indexOfRootInB - 1;
return result;
}
export function stateDelta(
base: SnapshotState,
desired: SnapshotState,
): {
add: Array<SnapshotStateEntry>,
update: Array<SnapshotStateEntry>,
remove: Array<SnapshotStateEntry>,
} {
const result = {
add: [],
update: [],
remove: [],
};
const stateMapStart = generateStateMap(base);
const stateMapFinish = generateStateMap(desired);
for (const key of combinedMapKeys(stateMapStart, stateMapFinish)) {
const start = stateMapStart[key];
const finish = stateMapFinish[key];
if (!start && finish) {
result.add.push(finish);
continue;
}
if (start && !finish) {
result.remove.push(start);
continue;
}
if (start && finish && start.blob !== finish.blob) {
result.update.push(finish);
continue;
}
}
return result;
}
export function getStagable(
state: SnapshotState,
candidates: Array<StatusCandidate>,
): Array<StageEntry> {
const stagable: Array<StageEntry> = [];
const stateMap = generateStateMap(state);
const candidateMap = generateCandidateMap(candidates);
for (const key of combinedMapKeys(stateMap, candidateMap)) {
const entry = stateMap[key];
const candidate = candidateMap[key];
if (!entry && candidate) {
const { name, document } = candidate;
const { hash: blobId, content: blobContent } = hashDocument(document);
stagable.push({ key, name, blobId, blobContent, added: true });
continue;
}
if (entry && !candidate) {
const { name, blob: blobId } = entry;
stagable.push({ key, name, blobId, deleted: true });
continue;
}
if (entry && candidate) {
const { document, name } = candidate;
const { hash: blobId, content: blobContent } = hashDocument(document);
if (entry.blob !== blobId) {
stagable.push({ key, name, blobId, blobContent, modified: true });
}
continue;
}
}
return stagable;
}
export function getRootSnapshot(a: Branch | null, b: Branch | null): string | null {
const snapshotsA = a ? a.snapshots : [];
const snapshotsB = b ? b.snapshots : [];
let rootSnapshotId = '';
for (let ai = snapshotsA.length - 1; ai >= 0; ai--) {
for (let bi = snapshotsB.length - 1; bi >= 0; bi--) {
if (snapshotsA[ai] === snapshotsB[bi]) {
return snapshotsA[ai];
}
}
}
return rootSnapshotId || null;
}
export function preMergeCheck(
trunkState: SnapshotState,
otherState: SnapshotState,
candidates: Array<StatusCandidate>,
): {
conflicts: Array<StatusCandidate>,
dirty: Array<StatusCandidate>,
} {
const result = {
conflicts: [],
dirty: [],
};
const trunkMap = generateStateMap(trunkState);
const otherMap = generateStateMap(otherState);
for (const candidate of candidates) {
const { key } = candidate;
const trunk = trunkMap[key];
const other = otherMap[key];
// Candidate is not in trunk or other (not yet in version control)
if (!trunk && !other) {
result.dirty.push(candidate);
continue;
}
const { hash: blobId } = hashDocument(candidate.document);
// Candidate is same as trunk (unchanged) so anything goes
if (trunk && trunk.blob === blobId) {
continue;
}
// Candidate is the same as trunk (nothing to do)
if (trunk && blobId === trunk.blob) {
continue;
}
// Candidate is the same as other (would update to same value)
if (other && blobId === other.blob) {
continue;
}
// Candidate is different but trunk and other are the same (preserve safe change)
if (
other &&
trunk &&
other.blob === trunk.blob &&
blobId !== other.blob &&
blobId !== trunk.blob
) {
result.dirty.push(candidate);
continue;
}
// All other cases result in conflict
result.conflicts.push(candidate);
}
return result;
}
export function hashDocument(doc: Object): { content: string, hash: string } {
if (!doc) {
throw new Error('Cannot hash undefined value');
}
// Remove fields we don't care about for sync purposes
const newDoc = clone(doc);
delete newDoc.modified;
const content = deterministicStringify(newDoc);
const hash = crypto
.createHash('sha1')
.update(content)
.digest('hex');
return { hash, content };
}
export function updateStateWithConflictResolutions(
state: SnapshotState,
conflicts: Array<MergeConflict>,
): SnapshotState {
const newStateMap = generateStateMap(state);
for (const { choose, key, name } of conflicts) {
const stateEntry = state.find(e => e.key === key);
// Not in the state, but we choose the conflict
if (!stateEntry && choose !== null) {
newStateMap[key] = { key, name, blob: choose };
continue;
}
// Add the conflict
if (choose !== null) {
newStateMap[key] = { ...stateEntry, blob: choose };
continue;
}
// Chose to delete it, so don't add to state
delete newStateMap[key];
}
return Object.keys(newStateMap).map(k => newStateMap[k]);
}