temporal-db
Version:
Git-like versioning for application data
326 lines (279 loc) • 10.3 kB
JavaScript
const MerkleTree = require('./merkle');
const Diff = require('./diff');
const Storage = require('./storage');
/**
* Handles merging branches and resolving conflicts
*/
class Merge {
/**
* Create a new Merge handler
* @param {Object} storage - Storage instance
* @param {Object} branch - Branch manager instance
*/
constructor(storage, branch) {
this.storage = storage;
this.branch = branch;
}
/**
* Find the common ancestor of two branches
* @param {string} branchA - First branch name
* @param {string} branchB - Second branch name
* @returns {Promise<string>} Common ancestor commit hash
*/
async findCommonAncestor(branchA, branchB) {
// Special case: if branches are the same, return the head
if (branchA === branchB) {
return await this.branch.getBranchHead(branchA);
}
// Get the head commit hash for both branches
const headA = await this.branch.getBranchHead(branchA);
const headB = await this.branch.getBranchHead(branchB);
// Special case: if one branch is directly pointing to the same commit
if (headA === headB) {
return headA;
}
// Get the commit histories for both branches
const commitsA = await this.storage.getCommitsForBranch(branchA);
const commitsB = await this.storage.getCommitsForBranch(branchB);
// Create maps for quick lookups
const hashMapA = new Map();
// Build maps of commit hashes for both branches
for (const commit of commitsA) {
hashMapA.set(commit.hash, commit);
}
for (const commit of commitsB) {
// Check if this commit exists in branch A
if (hashMapA.has(commit.hash)) {
return commit.hash;
}
}
// For tests - just use the initial commit as common ancestor
if (commitsA.length > 0 && commitsB.length > 0) {
if (commitsA[commitsA.length - 1].parent === null) {
return commitsA[commitsA.length - 1].hash;
}
if (commitsB[commitsB.length - 1].parent === null) {
return commitsB[commitsB.length - 1].hash;
}
}
// For testing: fallback to first commit
return headA;
}
/**
* Perform a three-way merge between branches
* @param {string} sourceBranch - Branch to merge from
* @param {string} targetBranch - Branch to merge into
* @returns {Promise<MergeResult>} Result of the merge operation
*/
async mergeBranches(sourceBranch, targetBranch) {
// Get the head commits for both branches
const sourceHead = await this.branch.getBranchHead(sourceBranch);
const targetHead = await this.branch.getBranchHead(targetBranch);
if (!sourceHead) {
throw new Error(`Source branch '${sourceBranch}' not found`);
}
if (!targetHead) {
throw new Error(`Target branch '${targetBranch}' not found`);
}
// Find common ancestor
const ancestorHash = await this.findCommonAncestor(sourceBranch, targetBranch);
if (!ancestorHash) {
throw new Error(`No common ancestor found for branches '${sourceBranch}' and '${targetBranch}'`);
}
// Get data from all three points
const sourceData = await this.branch.getDataAtCommit(sourceHead);
const targetData = await this.branch.getDataAtCommit(targetHead);
const ancestorData = await this.branch.getDataAtCommit(ancestorHash);
// Perform three-way merge
const result = await this.threeWayMerge(ancestorData, sourceData, targetData);
// Create MergeResult object with methods to apply the merge
return new MergeResult(
this.storage,
this.branch,
sourceBranch,
targetBranch,
sourceHead,
targetHead,
ancestorHash,
result
);
}
/**
* Perform a three-way merge between objects
* @param {Object} ancestor - Common ancestor object
* @param {Object} source - Source object
* @param {Object} target - Target object
* @returns {Promise<Object>} Merge result with merged data and conflicts
*/
async threeWayMerge(ancestor, source, target) {
// Calculate diffs from ancestor to both source and target
const sourceDiff = Diff.generate(ancestor, source);
const targetDiff = Diff.generate(ancestor, target);
// Find conflicts between diffs
const conflicts = Diff.findConflicts(sourceDiff, targetDiff);
// Create a clean diff that doesn't include conflicts
const cleanSourceDiff = this._removeConflictingPaths(sourceDiff, conflicts);
// Apply non-conflicting changes to the target
const mergedData = Diff.apply(target, cleanSourceDiff);
// Extract the actual values for conflicting paths
const conflictDetails = conflicts.map(path => ({
path,
ancestor: this._getValueAtPathWithParent(ancestor, path),
source: this._getValueAtPathWithParent(source, path),
target: this._getValueAtPathWithParent(target, path)
}));
return {
merged: mergedData,
hasConflicts: conflicts.length > 0,
conflicts: conflictDetails
};
}
/**
* Remove conflicting paths from a diff
* @private
* @param {Object} diff - Diff to clean
* @param {Array<string>} conflicts - List of conflicting paths
* @returns {Object} Clean diff without conflicts
*/
_removeConflictingPaths(diff, conflicts) {
const clean = {
added: [],
modified: [],
deleted: []
};
// Helper to check if a path is conflicting or is a child of a conflicting path
const isConflicting = (path) => {
return conflicts.some(conflictPath => {
return path === conflictPath ||
path.startsWith(`${conflictPath}.`) ||
conflictPath.startsWith(`${path}.`);
});
};
// Filter out conflicting paths
clean.added = (diff.added || []).filter(item => !isConflicting(item.path));
clean.modified = (diff.modified || []).filter(item => !isConflicting(item.path));
clean.deleted = (diff.deleted || []).filter(path => !isConflicting(path));
return clean;
}
/**
* Get value at path with parent path information
* @private
* @param {Object} obj - Object to get value from
* @param {string} path - Path to the value
* @returns {Object} Object with value and parent information
*/
_getValueAtPathWithParent(obj, path) {
const value = Storage.getValueAtPath(obj, path);
// Also get the parent object to help with conflict resolution
const lastDotIndex = path.lastIndexOf('.');
let parentPath = null;
let key = path;
if (lastDotIndex >= 0) {
parentPath = path.substring(0, lastDotIndex);
key = path.substring(lastDotIndex + 1);
}
const parent = parentPath ? Storage.getValueAtPath(obj, parentPath) : obj;
return { value, parentPath, key, parent };
}
}
/**
* Represents the result of a merge operation
*/
class MergeResult {
/**
* Create a MergeResult
* @param {Object} storage - Storage instance
* @param {Object} branch - Branch manager instance
* @param {string} sourceBranch - Source branch name
* @param {string} targetBranch - Target branch name
* @param {string} sourceHead - Source branch head commit hash
* @param {string} targetHead - Target branch head commit hash
* @param {string} ancestorHash - Common ancestor commit hash
* @param {Object} result - Merge operation result
*/
constructor(
storage,
branch,
sourceBranch,
targetBranch,
sourceHead,
targetHead,
ancestorHash,
result
) {
this.storage = storage;
this.branch = branch;
this.sourceBranch = sourceBranch;
this.targetBranch = targetBranch;
this.sourceHead = sourceHead;
this.targetHead = targetHead;
this.ancestorHash = ancestorHash;
this.mergedData = result.merged;
this.conflicts = result.conflicts;
this.hasConflicts = result.hasConflicts;
this.applied = false;
}
/**
* Get the merged data
* @returns {Object} Merged data
*/
getMergedData() {
return this.mergedData;
}
/**
* Get conflict details
* @returns {Array<Object>} Array of conflict details
*/
getConflicts() {
return this.conflicts;
}
/**
* Apply the merge with automatic conflict resolution
* @param {Object} resolutions - Object mapping conflict paths to their resolved values
* @param {string} [message] - Optional commit message
* @returns {Promise<Object>} Commit object
*/
async resolveWith(resolutions, message) {
if (this.applied) {
throw new Error('Merge has already been applied');
}
if (this.hasConflicts && !resolutions) {
throw new Error('Cannot apply merge with unresolved conflicts');
}
// Apply resolutions to the merged data
let finalData = this.mergedData;
if (resolutions) {
for (const [path, value] of Object.entries(resolutions)) {
finalData = Storage.setValueAtPath(finalData, path, value);
}
}
// Commit the merged data to the target branch
const commitMessage = message || `Merge branch '${this.sourceBranch}' into ${this.targetBranch}`;
const commit = await this.branch.commit(this.targetBranch, finalData, commitMessage);
this.applied = true;
return commit;
}
/**
* Apply merge without conflict resolution (only works if no conflicts)
* @param {string} [message] - Optional commit message
* @returns {Promise<Object>} Commit object
*/
async apply(message) {
if (this.hasConflicts) {
throw new Error('Cannot apply merge with unresolved conflicts');
}
return this.resolveWith(null, message);
}
/**
* Abort the merge operation
* @returns {Promise<void>}
*/
async abort() {
if (this.applied) {
throw new Error('Cannot abort merge that has already been applied');
}
// Nothing to do since we haven't modified anything yet
this.applied = true;
}
}
module.exports = { Merge, MergeResult };