From 776073fa441a6392c08a5e74955fb4fd66c8e54b Mon Sep 17 00:00:00 2001 From: iamhyc Date: Sun, 10 May 2026 15:40:09 +0800 Subject: [PATCH] fix(merge): replace 2-way DMP patch with proper 3-way merge (diff3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the flawed two-way diff-match-patch patch_apply logic with a proper three-way merge (like Git's diff3) across all merge code paths. Problem: The original code used dmp.patch_make(A,B) + dmp.patch_apply(patches, C) which silently corrupted or lost local edits when both local and remote changed the same region. No conflict detection existed (#353, #180). Changes: - src/utils/threeWayMerge.ts: line-level three-way merge engine that computes edit hunks from base->local and base->remote, detects overlapping hunks as conflicts, emits standard Git conflict markers (<<<<<<<, =======, >>>>>>>) that VS Code natively understands. - src/core/remoteFileSystemProvider.ts: writeFile() uses three-way merge. On conflict, writes markers + warns, preserves server state in _otBase field for correct post-resolution OT op computation. Zero duplication — existing OT update code reused via _otBase. - src/scm/localReplicaSCM.ts: overwrite() uses three-way merge. On conflict, writes markers locally only, blocks remote push. - src/api/socketioAlt.ts: edited-handler uses three-way merge. Removed unused DiffMatchPatch import. - test/threeWayMerge.test.ts: 29 unit tests covering trivial merges, one-side changes, non-overlapping auto-merge, 5 conflict scenarios, LaTeX content, and 3 post-conflict OT correctness invariants. Fixes #353, fixes #180 --- src/api/socketioAlt.ts | 19 +- src/core/remoteFileSystemProvider.ts | 58 +++- src/scm/localReplicaSCM.ts | 47 ++- src/utils/threeWayMerge.ts | 372 +++++++++++++++++++++++ test/threeWayMerge.test.ts | 439 +++++++++++++++++++++++++++ 5 files changed, 917 insertions(+), 18 deletions(-) create mode 100644 src/utils/threeWayMerge.ts create mode 100644 test/threeWayMerge.test.ts diff --git a/src/api/socketioAlt.ts b/src/api/socketioAlt.ts index b87fc7d4..f050dac5 100644 --- a/src/api/socketioAlt.ts +++ b/src/api/socketioAlt.ts @@ -1,11 +1,11 @@ /* eslint-disable @typescript-eslint/naming-convention */ import * as vscode from 'vscode'; -import * as DiffMatchPatch from 'diff-match-patch'; import { EventEmitter } from 'events'; import { BaseAPI, Identity, ProjectMessageResponseSchema, ProjectSettingsSchema } from './base'; import { DocumentEntity, FileEntity, ProjectEntity, VirtualFileSystem } from '../core/remoteFileSystemProvider'; import { UpdateSchema, UpdateUserSchema } from './socketio'; import { ROOT_NAME } from '../consts'; +import { threeWayMerge, tryTrivialMerge } from '../utils/threeWayMerge'; const keyHistoryRefreshInterval = `${ROOT_NAME}.invisibleMode.historyRefreshInterval`; const keyChatMessageRefreshInterval = `${ROOT_NAME}.invisibleMode.chatMessageRefreshInterval`; @@ -211,13 +211,24 @@ export class SocketIOAlt { if (_doc && _doc.isDirty) { await _doc.save(); } // generate patch and apply locally const vfsLocalVersion = this.vfsLocalVersion!; - const dmp = new DiffMatchPatch(); const localContent = new TextDecoder().decode( await vfs.openFile(_uri) ); const baseRemoteContent = (await vfs.getFileDiff(pathname, vfsLocalVersion, vfsLocalVersion))?.diff[0].u; const latestRemoteContent = (await vfs.getFileDiff(pathname, latestVersion, latestVersion))?.diff[0].u; if (baseRemoteContent!==undefined && latestRemoteContent!==undefined) { - const patch = dmp.patch_make(baseRemoteContent, latestRemoteContent); - const [localContentPatched, _] = dmp.patch_apply(patch, localContent); + // Perform a proper three-way merge: + // base = baseRemoteContent (common ancestor) + // local = localContent (current local file) + // remote = latestRemoteContent (latest server state) + let localContentPatched = tryTrivialMerge(baseRemoteContent, localContent, latestRemoteContent); + if (localContentPatched === undefined) { + const mergeResult = threeWayMerge(baseRemoteContent, localContent, latestRemoteContent); + localContentPatched = mergeResult.content; + if (mergeResult.hasConflict) { + vscode.window.showWarningMessage( + vscode.l10n.t('Merge conflict in "{0}". Remote changes overlap with your local edits. Conflict markers have been inserted — please review and resolve.', pathname) + ); + } + } this._eventEmitter.emit('otUpdateApplied', { doc: entityId, v: (entity as DocumentEntity).version, //bypass update check diff --git a/src/core/remoteFileSystemProvider.ts b/src/core/remoteFileSystemProvider.ts index 7c0a8eec..14438ef5 100644 --- a/src/core/remoteFileSystemProvider.ts +++ b/src/core/remoteFileSystemProvider.ts @@ -9,6 +9,7 @@ import { ClientManager } from '../collaboration/clientManager'; import { EventBus } from '../utils/eventBus'; import { SCMCollectionProvider } from '../scm/scmCollectionProvider'; import { ExtendedBaseAPI, ProjectLinkedFileProvider, UrlLinkedFileProvider } from '../api/extendedBase'; +import { threeWayMerge, tryTrivialMerge } from '../utils/threeWayMerge'; const __OUTPUTS_ID = `${ROOT_NAME}-outputs`; @@ -34,6 +35,8 @@ export interface DocumentEntity extends FileEntity { lastVersion?: number, localCache?: string, remoteCache?: string, + /** Server state at the moment a conflict was detected. When set, the next writeFile is a post-conflict resolution save; the OT op is computed from this base, then the field is cleared. */ + _otBase?: string, } export interface FileRefEntity extends FileEntity { @@ -745,11 +748,58 @@ export class VirtualFileSystem extends vscode.Disposable { if (doc.version===undefined || doc.localCache===undefined || doc.remoteCache===undefined) { return; } - const dmp = new DiffMatchPatch(); - const patches = dmp.patch_make(doc.localCache, doc.remoteCache); - const mergeResArray = dmp.patch_apply(patches, _content); - const mergeRes = mergeResArray[0] as string; + // If _otBase is set, a previous conflict was detected and the user has + // since resolved the markers. Skip merge — compute the OT op from the + // real server state stored in _otBase. + let mergeRes: string; + let hasConflict = false; + + if (doc._otBase !== undefined) { + doc.remoteCache = doc._otBase; + delete doc._otBase; + mergeRes = _content; + } else { + // Perform a proper three-way merge (like Git's diff3) using: + // base = doc.localCache (what we last agreed on locally) + // local = _content (user's current edits) + // remote = doc.remoteCache (latest known server state) + const trivial = tryTrivialMerge(doc.localCache, _content, doc.remoteCache); + if (trivial !== undefined) { + mergeRes = trivial; + } else { + const mergeResult = threeWayMerge(doc.localCache, _content, doc.remoteCache); + mergeRes = mergeResult.content; + hasConflict = mergeResult.hasConflict; + } + } + + if (hasConflict) { + // Conflicts detected: write conflict markers to disk so VS Code's + // built-in merge conflict UI can help the user resolve them. + // Do NOT send an OT update to the server while conflicts exist. + // Preserve the server state in _otBase so the post-resolution + // save can compute the OT op from the correct base. + const conflictContent = new TextEncoder().encode(mergeRes); + await vscode.workspace.fs.writeFile(uri, conflictContent); + doc._otBase = doc.remoteCache; + doc.localCache = mergeRes; + this.isDirty = true; + + vscode.window.showWarningMessage( + vscode.l10n.t('Merge conflict detected in "{0}". Your changes overlap with changes from the server. Please review the conflict markers in the editor and save after resolving.', doc.name) + ); + + setTimeout(() => { + this.notify([ + {type: vscode.FileChangeType.Changed, uri: uri} + ]); + }, 10); + return; + } + + // No conflict: proceed with the merged content + const dmp = new DiffMatchPatch(); const update = { doc: doc._id, lastV: doc.lastVersion, diff --git a/src/scm/localReplicaSCM.ts b/src/scm/localReplicaSCM.ts index efa885b9..22636cbf 100644 --- a/src/scm/localReplicaSCM.ts +++ b/src/scm/localReplicaSCM.ts @@ -3,6 +3,7 @@ import * as DiffMatchPatch from 'diff-match-patch'; import { minimatch } from 'minimatch'; import { BaseSCM, CommitItem, SettingItem } from "."; import { VirtualFileSystem, parseUri } from '../core/remoteFileSystemProvider'; +import { threeWayMerge, tryTrivialMerge } from '../utils/threeWayMerge'; const IGNORE_SETTING_KEY = 'ignore-patterns'; @@ -249,20 +250,46 @@ export class LocalReplicaSCMProvider extends BaseSCM { this.setBypassCache(relPath, remoteContent); await this.writeFile(relPath, remoteContent); } else { - const dmp = new DiffMatchPatch(); const baseContentStr = new TextDecoder().decode(baseContent); const localContentStr = new TextDecoder().decode(localContent); const remoteContentStr = new TextDecoder().decode(remoteContent); - // merge local and remote changes - const localPatches = dmp.patch_make( baseContentStr, localContentStr ); - const remotePatches = dmp.patch_make( baseContentStr, remoteContentStr ); - const [mergedContentStr, _results] = dmp.patch_apply( remotePatches, localContentStr ); - // write the merged content to local + + // Perform a proper three-way merge (like Git's diff3): + // base = last known common state + // local = current local file content + // remote = current remote (VFS) file content + // + // First try trivial merges: + let mergedContentStr = tryTrivialMerge(baseContentStr, localContentStr, remoteContentStr); + let hasConflict = false; + + if (mergedContentStr === undefined) { + // Need full three-way merge + const mergeResult = threeWayMerge(baseContentStr, localContentStr, remoteContentStr); + mergedContentStr = mergeResult.content; + hasConflict = mergeResult.hasConflict; + } + const mergedContent = new TextEncoder().encode(mergedContentStr); - await this.writeFile(relPath, mergedContent); - // write the merged content to remote - if (localPatches.length!==0) { - await vscode.workspace.fs.writeFile(vfsUri, mergedContent); + + if (hasConflict) { + // Write conflicted content with markers to local only. + // Do NOT push to remote — the user must resolve conflicts first. + await this.writeFile(relPath, mergedContent); + this.setBypassCache(relPath, mergedContent); + vscode.window.showWarningMessage( + vscode.l10n.t('Merge conflict in "{0}". Local and remote changes overlap. Conflict markers have been inserted — please review and resolve, then save.', relPath) + ); + } else { + // No conflict: write merged content to local + await this.writeFile(relPath, mergedContent); + // Push merged content to remote if local had changes + const dmp = new DiffMatchPatch(); + const localPatches = dmp.patch_make( baseContentStr, localContentStr ); + if (localPatches.length!==0) { + await vscode.workspace.fs.writeFile(vfsUri, mergedContent); + } + this.setBypassCache(relPath, mergedContent); } } } diff --git a/src/utils/threeWayMerge.ts b/src/utils/threeWayMerge.ts new file mode 100644 index 00000000..1256ff5a --- /dev/null +++ b/src/utils/threeWayMerge.ts @@ -0,0 +1,372 @@ +import * as DiffMatchPatch from 'diff-match-patch'; + +/** + * Result of a three-way merge operation. + */ +export interface ThreeWayMergeResult { + /** The merged content. If hasConflict is true, contains <<<<<<<, =======, >>>>>>> markers. */ + content: string; + /** Whether conflicts were detected during the merge. */ + hasConflict: boolean; +} + +/** + * Represents a contiguous changed region (hunk) in a diff. + * All indices are line numbers (0-based). + */ +interface DiffHunk { + /** Start line in base (0-based, inclusive). */ + baseStart: number; + /** End line in base (0-based, exclusive). */ + baseEnd: number; + /** Start line in the modified version (0-based, inclusive). */ + modifiedStart: number; + /** End line in the modified version (0-based, exclusive). */ + modifiedEnd: number; + /** The lines that replace base[baseStart..baseEnd] in the modified version. */ + modifiedLines: string[]; +} + +/** + * Compute line-level diff hunks between base and modified text. + * + * Uses diff-match-patch's line-mode diff internally for robust line-level comparison, + * then converts the raw diffs into structured hunks. + */ +function computeHunks(baseLines: string[], modifiedLines: string[]): DiffHunk[] { + const dmp = new DiffMatchPatch(); + const baseText = baseLines.join('\n'); + const modText = modifiedLines.join('\n'); + + // Use DMP's line-mode diff: each line is encoded as a single character, + // then diffed at character level, then decoded back to lines. + const lineData = (dmp as any).diff_linesToChars_(baseText, modText); + const rawDiffs: Array<[number, string]> = dmp.diff_main(lineData.chars1, lineData.chars2, false); + (dmp as any).diff_charsToLines_(rawDiffs, lineData.lineArray); + + const hunks: DiffHunk[] = []; + let basePos = 0; + let modPos = 0; + + for (const [op, text] of rawDiffs) { + // The text from diff_charsToLines_ is a concatenation of lines each ending with '\n'. + // Split and remove the trailing empty entry. + const lines = text.split('\n'); + if (lines.length > 0 && lines[lines.length - 1] === '') { + lines.pop(); + } + const lineCount = lines.length; + + if (op === 0) { + // Equal: advance both positions + basePos += lineCount; + modPos += lineCount; + } else if (op === -1) { + // Delete from base: base has these lines, modified does not + hunks.push({ + baseStart: basePos, + baseEnd: basePos + lineCount, + modifiedStart: modPos, + modifiedEnd: modPos, + modifiedLines: [], + }); + basePos += lineCount; + } else if (op === 1) { + // Insert into modified: base does not have these lines, modified does + hunks.push({ + baseStart: basePos, + baseEnd: basePos, + modifiedStart: modPos, + modifiedEnd: modPos + lineCount, + modifiedLines: lines, + }); + modPos += lineCount; + } + } + + return hunks; +} + +/** + * Check whether two hunks overlap in the base in a way that could cause conflict. + * + * Returns true if: + * 1. The hunk base ranges intersect (they touch the same lines), OR + * 2. One hunk is a pure insert (baseStart === baseEnd) at a position + * that the other hunk touches or deletes. + */ +function hunksOverlap(a: DiffHunk, b: DiffHunk): boolean { + // Standard range intersection: the hunks share at least one base line + if (a.baseStart < b.baseEnd && b.baseStart < a.baseEnd) { + return true; + } + // Pure insert at a position inside the other hunk's affected range. + // Example: local inserts at line 5 while remote deletes/modifies lines 4-6. + if (a.baseStart === a.baseEnd && a.baseStart >= b.baseStart && a.baseStart <= b.baseEnd) { + return true; + } + if (b.baseStart === b.baseEnd && b.baseStart >= a.baseStart && b.baseStart <= a.baseEnd) { + return true; + } + return false; +} + +/** + * Merge two adjacent or overlapping hunks from the same side into one. + */ +function mergeAdjacentHunks(hunks: DiffHunk[]): DiffHunk[] { + if (hunks.length <= 1) { return hunks; } + + const merged: DiffHunk[] = []; + let current = hunks[0]; + + for (let i = 1; i < hunks.length; i++) { + const next = hunks[i]; + // Adjacent or overlapping in base + if (current.baseEnd >= next.baseStart) { + // Merge: extend base range and append modified lines + current = { + baseStart: Math.min(current.baseStart, next.baseStart), + baseEnd: Math.max(current.baseEnd, next.baseEnd), + modifiedStart: Math.min(current.modifiedStart, next.modifiedStart), + modifiedEnd: Math.max(current.modifiedEnd, next.modifiedEnd), + modifiedLines: current.modifiedLines.concat(next.modifiedLines), + }; + } else { + merged.push(current); + current = next; + } + } + merged.push(current); + return merged; +} + +/** + * A resolved merge entry: either a clean hunk from one side, or a conflict group. + */ +type MergeEntry = + | { kind: 'equal'; baseStart: number; baseEnd: number } + | { kind: 'hunk'; baseStart: number; baseEnd: number; side: 'local' | 'remote'; modifiedLines: string[] } + | { kind: 'conflict'; baseStart: number; baseEnd: number; + localHunks: DiffHunk[]; remoteHunks: DiffHunk[] }; + +/** + * Split text into lines. Unlike `String.split('\n')`, an empty input string + * yields an empty array `[]` (not `['']`), preventing spurious empty-line + * artifacts during merge reconstruction. + */ +function splitLines(text: string): string[] { + if (text === '') { return []; } + return text.split('\n'); +} + +/** + * Perform a three-way merge (like Git's diff3) of `local` and `remote` changes + * against a common `base` ancestor. + * + * - Non-overlapping changes are merged automatically. + * - Overlapping changes (conflicts) are marked with standard Git conflict markers + * (`<<<<<<< Local`, `=======`, `>>>>>>> Remote`) that VS Code natively understands + * and provides a merge conflict resolution UI for. + * + * @param base The common ancestor text. + * @param local The local (current) version of the text. + * @param remote The remote (incoming) version of the text. + * @returns A {@link ThreeWayMergeResult} with the merged content and conflict flag. + */ +export function threeWayMerge(base: string, local: string, remote: string): ThreeWayMergeResult { + const baseLines = splitLines(base); + const localLines = splitLines(local); + const remoteLines = splitLines(remote); + + // Compute edit hunks from base to each side + const localHunks = mergeAdjacentHunks(computeHunks(baseLines, localLines)); + const remoteHunks = mergeAdjacentHunks(computeHunks(baseLines, remoteLines)); + + // Build a unified, sorted list of all hunks with side tags + type TaggedHunk = DiffHunk & { side: 'local' | 'remote' }; + const allTaggedHunks: TaggedHunk[] = [ + ...localHunks.map(h => ({ ...h, side: 'local' as const })), + ...remoteHunks.map(h => ({ ...h, side: 'remote' as const })), + ].sort((a, b) => a.baseStart - b.baseStart || a.baseEnd - b.baseEnd); + + // --- Phase 1: Group overlapping hunks into MergeEntries --- + const entries: MergeEntry[] = []; + const processed = new Set(); // indices into allTaggedHunks that have been consumed + + for (let i = 0; i < allTaggedHunks.length; i++) { + if (processed.has(i)) { continue; } + + // Find all hunks (including this one) that form a connected overlap chain + const group: TaggedHunk[] = [allTaggedHunks[i]]; + processed.add(i); + + // Expand the group transitively: any hunk that overlaps with any hunk already in the group + let expanded = true; + while (expanded) { + expanded = false; + for (let j = i + 1; j < allTaggedHunks.length; j++) { + if (processed.has(j)) { continue; } + for (const gh of group) { + if (hunksOverlap(gh, allTaggedHunks[j])) { + group.push(allTaggedHunks[j]); + processed.add(j); + expanded = true; + break; + } + } + } + } + + // Sort group by baseStart + group.sort((a, b) => a.baseStart - b.baseStart); + + const localInGroup = group.filter(h => h.side === 'local'); + const remoteInGroup = group.filter(h => h.side === 'remote'); + + if (localInGroup.length > 0 && remoteInGroup.length > 0) { + // Both sides touched this region → CONFLICT + const baseStart = Math.min(...group.map(h => h.baseStart)); + const baseEnd = Math.max(...group.map(h => h.baseEnd)); + entries.push({ + kind: 'conflict', + baseStart, + baseEnd, + localHunks: localInGroup, + remoteHunks: remoteInGroup, + }); + } else if (localInGroup.length > 0) { + // Only local changed → apply local hunks + for (const h of localInGroup) { + entries.push({ + kind: 'hunk', + baseStart: h.baseStart, + baseEnd: h.baseEnd, + side: 'local', + modifiedLines: h.modifiedLines, + }); + } + } else { + // Only remote changed → apply remote hunks + for (const h of remoteInGroup) { + entries.push({ + kind: 'hunk', + baseStart: h.baseStart, + baseEnd: h.baseEnd, + side: 'remote', + modifiedLines: h.modifiedLines, + }); + } + } + } + + // Sort entries by baseStart + entries.sort((a, b) => a.baseStart - b.baseStart); + + // --- Phase 2: Walk through base, applying entries --- + const result: string[] = []; + let hasConflict = false; + let baseIdx = 0; + + for (const entry of entries) { + // Copy unchanged base lines up to this entry + while (baseIdx < entry.baseStart && baseIdx < baseLines.length) { + result.push(baseLines[baseIdx]); + baseIdx++; + } + + if (entry.kind === 'conflict') { + // Build local version: base region with local hunks applied + const localVersion = applyHunksToBase( + baseLines, entry.baseStart, entry.baseEnd, entry.localHunks + ); + // Build remote version: base region with remote hunks applied + const remoteVersion = applyHunksToBase( + baseLines, entry.baseStart, entry.baseEnd, entry.remoteHunks + ); + + result.push('<<<<<<< Local'); + for (const line of localVersion) { result.push(line); } + result.push('======='); + for (const line of remoteVersion) { result.push(line); } + result.push('>>>>>>> Remote'); + + hasConflict = true; + baseIdx = entry.baseEnd; + } else if (entry.kind === 'hunk') { + // Apply the hunk: skip deleted base lines, add modified lines + baseIdx = entry.baseEnd; + for (const line of entry.modifiedLines) { + result.push(line); + } + } + } + + // Copy remaining base lines + while (baseIdx < baseLines.length) { + result.push(baseLines[baseIdx]); + baseIdx++; + } + + return { + content: result.join('\n'), + hasConflict, + }; +} + +/** + * Apply a set of hunks to a base region, returning the resulting lines. + * Used to reconstruct local/remote versions for conflict markers. + */ +function applyHunksToBase( + baseLines: string[], + regionStart: number, + regionEnd: number, + hunks: DiffHunk[] +): string[] { + // Sort hunks by baseStart + const sorted = [...hunks].sort((a, b) => a.baseStart - b.baseStart); + const result: string[] = []; + let pos = regionStart; + + for (const hunk of sorted) { + // Copy base lines before this hunk + while (pos < hunk.baseStart && pos < regionEnd) { + result.push(baseLines[pos]); + pos++; + } + // Apply hunk: skip deleted base lines, insert modified lines + for (const line of hunk.modifiedLines) { + result.push(line); + } + pos = hunk.baseEnd; + } + + // Copy remaining base lines + while (pos < regionEnd) { + result.push(baseLines[pos]); + pos++; + } + + return result; +} + +/** + * Quick check: can we do a fast trivial merge without conflict? + * Returns the merged content if trivial, or undefined if a full three-way merge is needed. + */ +export function tryTrivialMerge(base: string, local: string, remote: string): string | undefined { + if (base === local) { + // Local hasn't changed; use remote + return remote; + } + if (base === remote) { + // Remote hasn't changed; use local + return local; + } + if (local === remote) { + // Both made the same changes; use either + return local; + } + return undefined; // Need full three-way merge +} diff --git a/test/threeWayMerge.test.ts b/test/threeWayMerge.test.ts new file mode 100644 index 00000000..35b3b7b7 --- /dev/null +++ b/test/threeWayMerge.test.ts @@ -0,0 +1,439 @@ +/** + * Unit tests for threeWayMerge and tryTrivialMerge. + * + * Run via: npx tsc && node out/test/threeWayMerge.test.js + * + * Tests cover the exact scenarios from issues #353 and #180: + * local changes being silently overwritten by remote during sync. + */ + +import * as assert from 'assert'; +import * as DiffMatchPatch from 'diff-match-patch'; +import { threeWayMerge, tryTrivialMerge, ThreeWayMergeResult } from '../src/utils/threeWayMerge'; + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +function assertNoConflict(result: ThreeWayMergeResult, expected: string, label: string) { + assert.strictEqual(result.hasConflict, false, `${label}: expected no conflict`); + assert.strictEqual(result.content, expected, `${label}: content mismatch`); +} + +function assertConflict(result: ThreeWayMergeResult, label: string) { + assert.strictEqual(result.hasConflict, true, `${label}: expected conflict`); + // Verify conflict markers are present + assert.ok(result.content.includes('<<<<<<< Local'), `${label}: missing Local marker`); + assert.ok(result.content.includes('======='), `${label}: missing separator`); + assert.ok(result.content.includes('>>>>>>> Remote'), `${label}: missing Remote marker`); +} + +// ─── tryTrivialMerge tests ────────────────────────────────────────────────── + +console.log('\n─── tryTrivialMerge ───'); + +{ + // base == local → use remote + const r = tryTrivialMerge('same', 'same', 'remote'); + assert.strictEqual(r, 'remote', 'base==local should return remote'); + console.log(' ✓ base==local → remote'); +} +{ + // base == remote → use local + const r = tryTrivialMerge('same', 'local', 'same'); + assert.strictEqual(r, 'local', 'base==remote should return local'); + console.log(' ✓ base==remote → local'); +} +{ + // local == remote → use local + const r = tryTrivialMerge('base', 'same', 'same'); + assert.strictEqual(r, 'same', 'local==remote should return either'); + console.log(' ✓ local==remote → either'); +} +{ + // All different → undefined (need full merge) + const r = tryTrivialMerge('base', 'local', 'remote'); + assert.strictEqual(r, undefined, 'all different should return undefined'); + console.log(' ✓ all different → undefined'); +} +{ + // All empty + const r = tryTrivialMerge('', '', ''); + assert.strictEqual(r, '', 'all empty should return empty'); + console.log(' ✓ all empty → empty'); +} + +// ─── threeWayMerge: no-change cases ───────────────────────────────────────── + +console.log('\n─── threeWayMerge: no-change ───'); + +{ + // All identical + const r = threeWayMerge('hello\nworld', 'hello\nworld', 'hello\nworld'); + assertNoConflict(r, 'hello\nworld', 'identical content'); + console.log(' ✓ identical content'); +} +{ + // Empty all around + const r = threeWayMerge('', '', ''); + assertNoConflict(r, '', 'all empty'); + console.log(' ✓ all empty'); +} + +// ─── threeWayMerge: one-side changes ──────────────────────────────────────── + +console.log('\n─── threeWayMerge: one-side changes ───'); + +{ + // Only local changed + const base = 'line1\nline2\nline3'; + const local = 'line1\nline2_MODIFIED\nline3'; + const remote = 'line1\nline2\nline3'; + const r = threeWayMerge(base, local, remote); + assertNoConflict(r, local, 'only local changed'); + console.log(' ✓ only local changed'); +} +{ + // Only remote changed + const base = 'line1\nline2\nline3'; + const local = 'line1\nline2\nline3'; + const remote = 'line1\nline2_REMOTE\nline3'; + const r = threeWayMerge(base, local, remote); + assertNoConflict(r, remote, 'only remote changed'); + console.log(' ✓ only remote changed'); +} +{ + // Local deletes a line + const base = 'A\nB\nC'; + const local = 'A\nC'; + const remote = 'A\nB\nC'; + const r = threeWayMerge(base, local, remote); + assertNoConflict(r, local, 'local deletion'); + console.log(' ✓ local deletion'); +} +{ + // Remote inserts a line + const base = 'A\nB\nC'; + const local = 'A\nB\nC'; + const remote = 'A\nB\nINSERTED\nC'; + const r = threeWayMerge(base, local, remote); + assertNoConflict(r, remote, 'remote insertion'); + console.log(' ✓ remote insertion'); +} + +// ─── threeWayMerge: non-overlapping changes (auto-merge) ──────────────────── + +console.log('\n─── threeWayMerge: non-overlapping changes (auto-merge) ───'); + +{ + // Local changes line 2, remote changes line 4 — separate regions + const base = 'line1\nline2\nline3\nline4\nline5'; + const local = 'line1\nLOCAL\nline3\nline4\nline5'; + const remote = 'line1\nline2\nline3\nREMOTE\nline5'; + const r = threeWayMerge(base, local, remote); + assertNoConflict(r, 'line1\nLOCAL\nline3\nREMOTE\nline5', 'non-overlapping changes'); + console.log(' ✓ non-overlapping line modifications'); +} +{ + // Local adds at beginning, remote adds at end + const base = 'A\nB\nC'; + const local = 'LOCAL_START\nA\nB\nC'; + const remote = 'A\nB\nC\nREMOTE_END'; + const r = threeWayMerge(base, local, remote); + assertNoConflict(r, 'LOCAL_START\nA\nB\nC\nREMOTE_END', 'additions at both ends'); + console.log(' ✓ additions at opposite ends'); +} +{ + // Local deletes line 1, remote modifies line 3 + const base = 'A\nB\nC\nD\nE'; + const local = 'B\nC\nD\nE'; // delete A + const remote = 'A\nB\nC_MOD\nD\nE'; // modify C + const r = threeWayMerge(base, local, remote); + assertNoConflict(r, 'B\nC_MOD\nD\nE', 'local delete + remote modify (non-overlap)'); + console.log(' ✓ local delete + remote modify (separate regions)'); +} + +// ─── threeWayMerge: CONFLICT cases (issue #353, #180 scenarios) ───────────── + +console.log('\n─── threeWayMerge: conflicts ───'); + +{ + // Same line modified differently — THE CLASSIC CONFLICT + const base = 'line1\nline2\nline3'; + const local = 'line1\nLOCAL_VERSION\nline3'; + const remote = 'line1\nREMOTE_VERSION\nline3'; + const r = threeWayMerge(base, local, remote); + assertConflict(r, 'same line modified'); + assert.ok(r.content.includes('LOCAL_VERSION'), 'conflict should contain local version'); + assert.ok(r.content.includes('REMOTE_VERSION'), 'conflict should contain remote version'); + console.log(' ✓ same line modified differently → conflict markers'); +} +{ + // Local deletes, remote modifies same line — ISSUE #353/#180 + const base = 'A\nB\nC'; + const local = 'A\nC'; // deleted B + const remote = 'A\nB_MODIFIED\nC'; // modified B + const r = threeWayMerge(base, local, remote); + assertConflict(r, 'local delete vs remote modify'); + // Local side of conflict should be empty (deletion) + console.log(' ✓ local delete vs remote modify → conflict (not silent overwrite)'); +} +{ + // Both insert at same position + const base = 'A\nB\nC'; + const local = 'A\nLOCAL_INSERT\nB\nC'; + const remote = 'A\nREMOTE_INSERT\nB\nC'; + const r = threeWayMerge(base, local, remote); + assertConflict(r, 'both insert at same position'); + console.log(' ✓ both insert at same position → conflict'); +} +{ + // Both insert at beginning of file (empty base position) + const base = ''; + const local = 'LOCAL_CONTENT'; + const remote = 'REMOTE_CONTENT'; + const r = threeWayMerge(base, local, remote); + assertConflict(r, 'both insert at empty base'); + console.log(' ✓ both insert at empty base → conflict'); +} +{ + // Local inserts, remote deletes surrounding region + const base = 'A\nB\nC\nD\nE'; + const local = 'A\nB\nINSERTED\nC\nD\nE'; + const remote = 'A\nE'; // deleted B,C,D + const r = threeWayMerge(base, local, remote); + assertConflict(r, 'local insert inside remote delete region'); + console.log(' ✓ local insert inside remote deletion → conflict'); +} + +// ─── threeWayMerge: edge cases ────────────────────────────────────────────── + +console.log('\n─── threeWayMerge: edge cases ───'); + +{ + // Empty base, local adds content, remote unchanged + const r = threeWayMerge('', 'new content', ''); + assertNoConflict(r, 'new content', 'empty base, local adds'); + console.log(' ✓ empty base, only local adds'); +} +{ + // Empty base, remote adds content, local unchanged + const r = threeWayMerge('', '', 'new content'); + assertNoConflict(r, 'new content', 'empty base, remote adds'); + console.log(' ✓ empty base, only remote adds'); +} +{ + // single line, no newline + const r = threeWayMerge('hello', 'hello world', 'hello'); + assertNoConflict(r, 'hello world', 'single line modification'); + console.log(' ✓ single line modification'); +} +{ + // trailing newline preservation + const base = 'A\nB\n'; + const local = 'A\nB_MOD\n'; + const remote = 'A\nB\n'; + const r = threeWayMerge(base, local, remote); + assertNoConflict(r, 'A\nB_MOD\n', 'trailing newline preserved'); + console.log(' ✓ trailing newline preserved'); +} +{ + // multiple non-overlapping hunks + const base = '1\n2\n3\n4\n5\n6\n7\n8\n9\n10'; + const local = '1\n2_LOCAL\n3\n4\n5\n6\n7\n8_LOCAL\n9\n10'; + const remote = '1\n2\n3\n4_REMOTE\n5\n6_REMOTE\n7\n8\n9\n10'; + const r = threeWayMerge(base, local, remote); + assertNoConflict(r, '1\n2_LOCAL\n3\n4_REMOTE\n5\n6_REMOTE\n7\n8_LOCAL\n9\n10', 'multiple non-overlapping hunks'); + console.log(' ✓ multiple non-overlapping hunks'); +} +{ + // LaTeX-like content (realistic scenario from issues) + const base = [ + '\\section{Introduction}', + 'This is the introduction.', + '', + '\\section{Methods}', + 'We used method A.', + '', + '\\section{Results}', + 'The results are shown.', + '', + '\\end{document}', + ].join('\n'); + + const local = [ + '\\section{Introduction}', + 'This is the UPDATED introduction.', + '', + '\\section{Methods}', + 'We used method A.', + '', + '\\section{Results}', + 'The results are shown.', + '', + '\\end{document}', + ].join('\n'); + + const remote = [ + '\\section{Introduction}', + 'This is the introduction.', + '', + '\\section{Methods}', + 'We used method B (improved).', + '', + '\\section{Results}', + 'The results are shown.', + '', + '\\end{document}', + ].join('\n'); + + // Non-overlapping: local changes Introduction, remote changes Methods + const r = threeWayMerge(base, local, remote); + assertNoConflict(r, [ + '\\section{Introduction}', + 'This is the UPDATED introduction.', + '', + '\\section{Methods}', + 'We used method B (improved).', + '', + '\\section{Results}', + 'The results are shown.', + '', + '\\end{document}', + ].join('\n'), 'LaTeX non-overlapping sections'); + console.log(' ✓ LaTeX non-overlapping sections auto-merge'); +} +{ + // LaTeX-like conflict: both edit the same paragraph + const base = [ + '\\section{Abstract}', + 'This paper presents a novel approach.', + '', + '\\section{Introduction}', + 'The field has grown rapidly.', + ].join('\n'); + + const local = [ + '\\section{Abstract}', + 'We propose a revolutionary method for solving this problem.', + '', + '\\section{Introduction}', + 'The field has grown rapidly.', + ].join('\n'); + + const remote = [ + '\\section{Abstract}', + 'This work introduces an innovative framework.', + '', + '\\section{Introduction}', + 'The field has grown rapidly.', + ].join('\n'); + + const r = threeWayMerge(base, local, remote); + assertConflict(r, 'LaTeX same paragraph edited'); + assert.ok(r.content.includes('revolutionary method'), 'should contain local text'); + assert.ok(r.content.includes('innovative framework'), 'should contain remote text'); + console.log(' ✓ LaTeX same paragraph conflict → markers with both versions'); +} + +// ─── Post-conflict resolution (writeFile flow simulation) ─────────────────── + +console.log('\n─── Post-conflict resolution (OT correctness) ───'); + +{ + // SCENARIO: User resolves a conflict and saves. + // + // Initial state: + // base = original common ancestor + // local = user's edit (before seeing conflict) + // remote = server's version + // + // After conflict detection, writeFile() writes markers & sets: + // doc.localCache = conflictedState (with <<<<<<<, =======, >>>>>>>) + // doc._otBase = remote (PRESERVED — server's actual state) + // + // User resolves by editing the file to resolvedContent and saves. + // writeFile() sees _otBase !== undefined → skips threeWayMerge, + // restores doc.remoteCache = _otBase, computes OT op as + // diff(remoteCache → resolvedContent), then deletes _otBase. + // + // This test verifies that computing OT op from the PRESERVED server state + // is correct, whereas computing it from the conflicted state would be WRONG. + + const base = 'line1\nline2\nline3'; + const local = 'line1\nLOCAL_EDIT\nline3'; // what user wrote + const remote = 'line1\nREMOTE_EDIT\nline3'; // what server has + + // Simulate conflict detection + const conflictResult = threeWayMerge(base, local, remote); + assert.strictEqual(conflictResult.hasConflict, true); + const conflictedState = conflictResult.content; // contains <<<<<<<, =======, >>>>>>> + + // User resolves: picks local version + const resolvedContent = local; + + // --- CORRECT approach (what the _otBase path does) --- + // OT op = diff(remoteCache → resolvedContent), where remoteCache was restored + // from the preserved _otBase (server state at conflict time) + const dmp1 = new DiffMatchPatch(); + const correctOp = (dmp1 as any).patch_make(remote, resolvedContent); + const [correctApplyResult, correctApplyOk] = (dmp1 as any).patch_apply(correctOp, remote); + const allCorrectApplied = (correctApplyOk as boolean[]).every((ok: boolean) => ok); + assert.strictEqual(correctApplyResult, resolvedContent, + 'CORRECT: diff(serverState→resolved) applied to serverState yields resolved content'); + assert.ok(allCorrectApplied, 'CORRECT: all patches applied cleanly'); + + // --- WRONG approach (the OLD bug: overwriting remoteCache without _otBase) --- + // OT op = diff(conflictedState → resolvedContent), but server has remote, NOT conflicted + const dmp2 = new DiffMatchPatch(); + const wrongOp = (dmp2 as any).patch_make(conflictedState, resolvedContent); + const [wrongApplyResult, wrongApplyOk] = (dmp2 as any).patch_apply(wrongOp, remote); + // This will likely produce garbled output or fail to apply cleanly + const allWrongApplied = (wrongApplyOk as boolean[]).every((ok: boolean) => ok); + const wrongIsCorrect = wrongApplyResult === resolvedContent && allWrongApplied; + // In practice, applying diff(conflicted→resolved) to serverState gives garbage + assert.strictEqual(wrongIsCorrect, false, + 'WRONG (old bug): diff(conflicted→resolved) applied to serverState does NOT yield resolved'); + console.log(' ✓ post-conflict OT op: diff(serverState→resolved) is correct'); + console.log(' ✓ post-conflict OT op: diff(conflictedState→resolved) would corrupt'); +} + +{ + // SCENARIO: User resolves by picking the REMOTE side + const base = 'A\nB\nC\nD\nE'; + const local = 'A\nB_LOCAL\nC\nD\nE'; + const remote = 'A\nB_REMOTE\nC\nD\nE'; + + const conflictResult = threeWayMerge(base, local, remote); + assert.strictEqual(conflictResult.hasConflict, true); + + // User decides to keep remote version + const resolvedContent = remote; + const dmp = new DiffMatchPatch(); + const op = (dmp as any).patch_make(remote, resolvedContent); + const [applyResult, applyOk] = (dmp as any).patch_apply(op, remote); + assert.strictEqual(applyResult, resolvedContent, + 'choosing remote side: diff(remote→remote) = identity'); + console.log(' ✓ post-conflict: choosing remote side → identity OT op'); +} + +{ + // SCENARIO: User resolves by writing a completely new merged version + const base = 'line1\nline2\nline3'; + const local = 'line1\nLOCAL\nline3'; + const remote = 'line1\nREMOTE\nline3'; + + const conflictResult = threeWayMerge(base, local, remote); + assert.strictEqual(conflictResult.hasConflict, true); + + // User manually writes a merged version + const resolvedContent = 'line1\nMERGED_BY_USER\nline3'; + const dmp = new DiffMatchPatch(); + const op = (dmp as any).patch_make(remote, resolvedContent); + const [applyResult, applyOk] = (dmp as any).patch_apply(op, remote); + assert.strictEqual(applyResult, resolvedContent, + 'custom merge: diff(serverState→merged) applied correctly'); + console.log(' ✓ post-conflict: custom merge resolution → correct OT op'); +} + +// ─── Summary ──────────────────────────────────────────────────────────────── + +console.log('\n─── All tests passed ✓ ───\n');